documentation
Version:
a documentation generator
419 lines (383 loc) • 13.1 kB
JavaScript
import t from '@babel/types';
import babelGenerate from '@babel/generator';
import _ from 'lodash';
import findTarget from './finders.js';
import typeAnnotation from '../type_annotation.js';
const generate = babelGenerate.default || babelGenerate;
/**
* Infers param tags by reading function parameter names
*
* @param {Object} comment parsed comment
* @returns {Object} comment with parameters
*/
export default function inferParams(comment) {
let path = findTarget(comment.context.ast);
if (!path) {
return comment;
}
// In case of `/** */ var x = function () {}` findTarget returns
// the declarator.
if (t.isVariableDeclarator(path)) {
path = path.get('init');
}
// If this is an ES6 class with a constructor method, infer
// parameters from that constructor method.
if (
t.isClassDeclaration(path) &&
!(comment.constructorComment && comment.constructorComment.hideconstructor)
) {
const constructor = path.node.body.body.find(item => {
// https://github.com/babel/babylon/blob/master/ast/spec.md#classbody
return t.isClassMethod(item) && item.kind === 'constructor';
});
if (constructor) {
return inferAndCombineParams(constructor.params, comment);
}
}
if (
!t.isFunction(path) &&
!t.isTSDeclareFunction(path) &&
!t.isTSDeclareMethod(path) &&
!t.isFunctionTypeAnnotation(path) &&
!t.isTSMethodSignature(path)
) {
return comment;
}
if (comment.kind === 'class' && comment.hideconstructor) {
return comment;
}
let params = t.isTSMethodSignature(path)
? path.node.parameters
: path.node.params;
// Flow function annotations separate rest parameters into a different list
if (t.isFunctionTypeAnnotation(path) && path.node.rest) {
params = params.concat(path.node.rest);
}
const result = inferAndCombineParams(params, comment);
// Wrap flow rest parameter with a RestType
if (t.isFunctionTypeAnnotation(path) && path.node.rest) {
const rest = result.params[result.params.length - 1];
rest.type = {
type: 'RestType',
expression: rest.type
};
}
return result;
}
function inferAndCombineParams(params, comment) {
const inferredParams = params.map((param, i) => {
const doc = paramToDoc(param, '', i);
if (param.optional) {
doc.type = {
type: 'OptionalType',
expression: doc.type
};
}
return doc;
});
const paramsToMerge = comment.params;
if (comment.constructorComment) {
paramsToMerge.push.apply(paramsToMerge, comment.constructorComment.params);
}
const mergedParamsAndErrors = mergeTrees(inferredParams, paramsToMerge);
// Then merge the trees. This is the hard part.
return Object.assign(comment, {
params: mergedParamsAndErrors.mergedParams,
errors: comment.errors.concat(mergedParamsAndErrors.errors)
});
}
// Utility methods ============================================================
//
const PATH_SPLIT_CAPTURING = /(\[])?(\.)/g;
const PATH_SPLIT = /(?:\[])?\./g;
function tagDepth(tag) {
return (tag.name || '').split(PATH_SPLIT).length;
}
/**
* Index tags by their `name` property into an ES6 map.
*/
function mapTags(tags) {
return new Map(
tags.map(tag => {
return [tag.name, tag];
})
);
}
/**
* Babel parses JavaScript source code and produces an abstract syntax
* tree that includes methods and their arguments. This function takes
* that AST and uses it to infer details that would otherwise need
* explicit documentation, like the names of comments and their
* default values.
*
* It is especially careful to allow the user and the machine to collaborate:
* documentation.js should not overwrite any details that the user
* explicitly sets.
*
* @private
* @param {Object} param the abstract syntax tree of the parameter in JavaScript
* @param {number} i the number of this parameter, in argument order
* @param {string} prefix of the comment, if it is nested, like in the case of destructuring
* @returns {Object} parameter with inference.
*/
function paramToDoc(param, prefix, i) {
const autoName = '$' + String(i);
const prefixedName = prefix + '.' + param.name;
switch (param.type) {
case 'AssignmentPattern': {
// (a = b)
const newAssignmentParam = paramToDoc(param.left, '', i);
if (Array.isArray(newAssignmentParam)) {
throw new Error('Encountered an unexpected parameter type');
}
return Object.assign(newAssignmentParam, {
default: generate(param.right, {
compact: true
}).code,
type: newAssignmentParam.type
});
}
// ObjectPattern <AssignmentProperty | RestElement>
case 'ObjectPattern': {
// { a }
if (prefix === '') {
// If this is a root-level param, like f({ x }), then we need to name
// it, like $0 or $1, depending on its position.
return {
title: 'param',
name: autoName,
anonymous: true,
type: (param.typeAnnotation &&
typeAnnotation(param.typeAnnotation)) || {
type: 'NameExpression',
name: 'Object'
},
properties: _.flatMap(param.properties, prop => {
return paramToDoc(prop, prefix + autoName);
})
};
} else if (param.indexed) {
// Likewise, if this object pattern sits inside of an ArrayPattern,
// like [{ foo }], it shouldn't just look like $0.foo, but like $0.0.foo,
// so make sure it isn't indexed first.
return {
title: 'param',
name: prefixedName,
anonymous: true,
type: (param.typeAnnotation &&
typeAnnotation(param.typeAnnotation)) || {
type: 'NameExpression',
name: 'Object'
},
properties: _.flatMap(param.properties, prop => {
return paramToDoc(prop, prefixedName);
})
};
}
// If, otherwise, this is nested, we don't really represent it as
// a parameter in and of itself - we just want its children, and
// it will be the . in obj.prop
return _.flatMap(param.properties, prop => {
return paramToDoc(prop, prefix);
});
}
// ArrayPattern<Pattern | null>
case 'ArrayPattern': {
// ([a, b, { c }])
if (prefix === '') {
return {
title: 'param',
name: autoName,
anonymous: true,
type: (param.typeAnnotation &&
typeAnnotation(param.typeAnnotation)) || {
type: 'NameExpression',
name: 'Array'
},
// Array destructuring lets you name the elements in the array,
// but those names don't really make sense within the JSDoc
// indexing tradition, or have any external meaning. So
// instead we're going to (immutably) rename the parameters to their
// indices
properties: _.flatMap(param.elements, (element, idx) => {
const indexedElement = Object.assign({}, element, {
name: String(idx),
indexed: true
});
return paramToDoc(indexedElement, autoName);
})
};
}
return _.flatMap(param.elements, (element, idx) => {
const indexedElement = Object.assign({}, element, {
name: String(idx)
});
return paramToDoc(indexedElement, prefix);
});
}
case 'ObjectProperty': {
return Object.assign(
paramToDoc(
param.value,
prefix + '.' + param.key.name || param.key.value
),
{
name: prefix + '.' + param.key.name || param.key.value
}
);
}
case 'RestProperty': // (a, ...b)
case 'RestElement': {
const type = {
type: 'RestType'
};
if (param.typeAnnotation) {
type.expression = typeAnnotation(param.typeAnnotation);
}
return {
title: 'param',
name: prefix ? `${prefix}.${param.argument.name}` : param.argument.name,
lineNumber: param.loc.start.line,
type
};
}
case 'FunctionTypeParam': // flow interface method signature
return {
title: 'param',
name: prefix ? prefix + '.' + param.name.name : param.name.name,
lineNumber: param.loc.start.line,
type: param.typeAnnotation
? typeAnnotation(param.typeAnnotation)
: undefined
};
default: {
// (a)
const newParam = {
title: 'param',
name: prefix ? prefixedName : param.name,
// A skipped array argument like ([, a]);
// looks like { name: '0', indexed: true }, and thus has no location,
// so we allow location to be undefined here.
lineNumber: param.loc ? param.loc.start.line : undefined
};
// Flow/TS annotations
if (param.typeAnnotation && param.typeAnnotation.typeAnnotation) {
newParam.type = typeAnnotation(param.typeAnnotation.typeAnnotation);
}
return newParam;
}
}
}
/**
* Recurse through a potentially nested parameter tag,
* replacing the auto-generated name, like $0, with an explicit
* name provided from a JSDoc comment. For instance, if you have a code
* block like
*
* function f({ x });
*
* It would by default be documented with a first param $0, with a member $0.x
*
* If you specify the name of the param, then it could be documented with, say,
* options and options.x. So we need to recursively rename not just $0 but
* also $0.x and maybe $0.x.y.z all to options.x and options.x.y.z
*/
function renameTree(node, explicitName) {
const parts = node.name.split(PATH_SPLIT_CAPTURING);
parts[0] = explicitName;
node.name = parts.join('');
if (node.properties) {
node.properties.forEach(property => renameTree(property, explicitName));
}
}
export function mergeTrees(inferred, explicit) {
// The first order of business is ensuring that the root types are specified
// in the right order. For the order of arguments, the inferred reality
// is the ground-truth: a function like
// function addThem(a, b, c) {}
// Should always see (a, b, c) in that order
// First, if all parameters are specified, allow explicit names to apply
// to destructuring parameters, which do not have inferred names. This is
// _only_ enabled in the case in which all parameters are specified explicitly
if (inferred.length === explicit.length) {
for (let i = 0; i < inferred.length; i++) {
if (inferred[i].anonymous === true) {
renameTree(inferred[i], explicit[i].name);
}
}
}
return mergeTopNodes(inferred, explicit);
}
function mergeTopNodes(inferred, explicit) {
const mapExplicit = mapTags(explicit);
const inferredNames = new Set(inferred.map(tag => tag.name));
const explicitTagsWithoutInference = explicit.filter(tag => {
return tagDepth(tag) === 1 && !inferredNames.has(tag.name);
});
const errors = explicitTagsWithoutInference.map(tag => {
return {
message:
`An explicit parameter named ${
tag.name || ''
} was specified but didn't match ` +
`inferred information ${Array.from(inferredNames).join(', ')}`,
commentLineNumber: tag.lineNumber
};
});
return {
errors,
mergedParams: inferred
.map(inferredTag => {
const explicitTag = mapExplicit.get(inferredTag.name);
return explicitTag
? combineTags(inferredTag, explicitTag)
: inferredTag;
})
.concat(explicitTagsWithoutInference)
};
}
// This method is used for _non-root_ properties only - we use mergeTopNodes
// for root properties, which strictly requires inferred only. In this case,
// we combine all tags:
// - inferred & explicit
// - explicit only
// - inferred only
function mergeNodes(inferred, explicit) {
const intersection = _.intersectionBy(inferred, explicit, tag => tag.name);
const explicitOnly = _.differenceBy(explicit, inferred, tag => tag.name);
const inferredOnly = _.differenceBy(inferred, explicit, tag => tag.name);
const mapExplicit = mapTags(explicit);
return intersection
.map(inferredTag => {
const explicitTag = mapExplicit.get(inferredTag.name);
return explicitTag ? combineTags(inferredTag, explicitTag) : inferredTag;
})
.concat(explicitOnly)
.concat(inferredOnly);
}
function combineTags(inferredTag, explicitTag) {
let type = explicitTag.type;
let defaultValue;
if (!explicitTag.type) {
type = inferredTag.type;
}
if (!explicitTag.default && inferredTag.default) {
defaultValue = inferredTag.default;
}
const hasProperties =
(inferredTag.properties && inferredTag.properties.length) ||
(explicitTag.properties && explicitTag.properties.length);
return Object.assign(
explicitTag,
hasProperties
? {
properties: mergeNodes(
inferredTag.properties || [],
explicitTag.properties || []
)
}
: {},
{ type },
defaultValue ? { default: defaultValue } : {}
);
}