Jeff Williams 7a2e561e11
fix(jsdoc-parse): use the correct scopes when exported objects have properties
Previously, JSDoc parsed the test code and found the namepath `module:icecream.FLAVORS` (correct), but also `module:icecream~FLAVORS.VANILLA` (wrong, because `FLAVORS` is a static member of the module, not an inner member).

With this change, JSDoc should consistently identify `FLAVORS` as a static member of `module:icecream`.
2025-05-15 20:40:45 -07:00

420 lines
12 KiB
JavaScript

/*
Copyright 2010 the JSDoc Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Syntax } from '@jsdoc/ast';
import { Doclet } from '@jsdoc/doclet';
import * as name from '@jsdoc/name';
import escape from 'escape-string-regexp';
const PROTOTYPE_OWNER_REGEXP = /^(.+?)(\.prototype|#)$/;
const { LONGNAMES, SCOPE } = name;
const ESCAPED_MODULE_LONGNAMES = [
escape(LONGNAMES.MODULE_DEFAULT_EXPORT),
escape(LONGNAMES.MODULE_EXPORT),
escape('module.exports'),
].join('|');
let currentModule = null;
// Modules inferred from the value of an `@alias` tag, like `@alias module:foo.bar`.
let inferredModules = [];
class ModuleInfo {
constructor(doclet) {
this.longname = doclet.longname;
this.originalName = doclet.meta?.code?.name ?? '';
}
}
function filterByLongname({ longname }) {
// you can't document prototypes
if (/#$/.test(longname)) {
return true;
}
return false;
}
function createDoclet(comment, e, env) {
let doclet;
let flatComment;
let msg;
try {
doclet = new Doclet(comment, e, env);
} catch (error) {
flatComment = comment.replace(/[\r\n]/g, '');
msg = `cannot create a doclet for the comment "${flatComment}": ${error.message}`;
env.log.error(msg);
doclet = new Doclet('', e, env);
}
return doclet;
}
/**
* Create a doclet for a `symbolFound` event. The doclet represents an actual symbol that is defined
* in the code.
*
* Here's why this function is useful. A JSDoc comment can define a symbol name by including:
*
* + A `@name` tag
* + Another tag that accepts a name, such as `@function`
*
* When the JSDoc comment defines a symbol name, we treat it as a "virtual comment" for a symbol
* that isn't actually present in the code. And if a virtual comment is attached to a symbol, it's
* possible that the comment and symbol have nothing to do with one another.
*
* To handle this case, this function checks the new doclet to see if we've already added a name
* property by parsing the JSDoc comment. If so, this method creates a replacement doclet that
* ignores the attached JSDoc comment and only looks at the code.
*
* @private
*/
function createSymbolDoclet(comment, e, env) {
let doclet = createDoclet(comment, e, env);
if (doclet.name) {
// try again, without the comment
e.comment = '@undocumented';
doclet = createDoclet(e.comment, e, env);
}
return doclet;
}
function getModule() {
return inferredModules.length ? inferredModules[inferredModules.length - 1] : currentModule;
}
function setModule(doclet) {
if (doclet.kind === 'module') {
currentModule = new ModuleInfo(doclet);
} else if (doclet.longname.startsWith('module:')) {
inferredModules.push(
new ModuleInfo({
longname: name.getBasename(doclet.longname),
})
);
}
}
function isModuleExports(module, doclet) {
return module.longname === doclet.name;
}
/**
* Finds an AST node's closest ancestor with the specified type.
*
* @private
* @param {Object} node - The AST node.
* @param {(module:@jsdoc/ast.Syntax|string)} ancestorType - The type of ancestor node to find.
* @return {?Object} The closest ancestor with the specified type.
*/
function findAncestorWithType(node, ancestorType) {
let parent = node?.parent;
while (parent) {
if (parent.type === ancestorType) {
return parent;
}
parent = parent.parent;
}
return null;
}
function setModuleScopeMemberOf(parser, doclet) {
const moduleInfo = getModule();
let parentDoclet;
let skipMemberof;
// Handle module symbols, excluding CommonJS `module.exports`.
if (moduleInfo && !isModuleExports(moduleInfo, doclet)) {
if (!doclet.scope) {
// is this a method definition? if so, we usually get the scope from the node directly
if (doclet.meta?.code?.node?.type === Syntax.MethodDefinition) {
parentDoclet = parser._getDocletById(doclet.meta.code.node.parent.parent.nodeId);
// special case for constructors of classes that have @alias tags
if (doclet.meta.code.node.kind === 'constructor' && parentDoclet?.alias) {
// the constructor should use the same name as the class
doclet.addTag('alias', parentDoclet.alias);
doclet.addTag('name', parentDoclet.alias);
// and we shouldn't try to set a memberof value
skipMemberof = true;
} else {
doclet.addTag(doclet.meta.code.node.static ? 'static' : 'instance');
// The doclet should be a member of the parent doclet's alias.
if (parentDoclet?.alias) {
doclet.memberof = parentDoclet.alias;
}
}
}
// Is this something that the module exports? if so, it's a static member.
else if (findAncestorWithType(doclet.meta?.code?.node, Syntax.ExportNamedDeclaration)) {
doclet.addTag('static');
}
// Otherwise, it must be an inner member.
else {
doclet.addTag('inner');
}
}
// if the doclet isn't a memberof anything yet, and it's not a global, it must be a memberof
// the current module (unless we were told to skip adding memberof)
if (!skipMemberof && !doclet.memberof && doclet.scope !== SCOPE.NAMES.GLOBAL) {
doclet.addTag('memberof', moduleInfo.longname);
}
}
}
function setDefaultScope(doclet) {
// module doclets don't get a default scope
if (!doclet.scope && doclet.kind !== 'module') {
doclet.setScope(SCOPE.NAMES.GLOBAL);
}
}
function addDoclet(parser, newDoclet) {
let e;
if (newDoclet) {
setModule(newDoclet);
e = { doclet: newDoclet };
parser.emit('newDoclet', e);
if (!e.defaultPrevented && !filterByLongname(e.doclet)) {
parser.addResult(e.doclet);
}
}
}
function processAlias(parser, doclet, node) {
let match;
let memberofName;
if (doclet.alias === '{@thisClass}') {
memberofName = parser.resolveThis(node);
// "class" refers to the owner of the prototype, not the prototype itself
match = memberofName.match(PROTOTYPE_OWNER_REGEXP);
if (match) {
memberofName = match[1];
}
doclet.alias = memberofName;
}
doclet.addTag('name', doclet.alias);
doclet.postProcess();
}
function isModuleObject(doclet) {
return doclet.name === LONGNAMES.MODULE_DEFAULT_EXPORT || doclet.name === 'module.exports';
}
// TODO: separate code that resolves `this` from code that resolves the module object
function findSymbolMemberof(parser, doclet, node, nameStartsWith, trailingPunc) {
const docletIsModuleObject = isModuleObject(doclet);
let memberof = '';
let nameAndPunc;
let scopePunc = '';
// handle computed properties like foo['bar']
if (trailingPunc === '[') {
// we don't know yet whether the symbol is a static or instance member
trailingPunc = null;
}
nameAndPunc = nameStartsWith + (trailingPunc || '');
// Remove parts of the name that indicate module membership. Don't touch the name if it identifies
// the module object itself.
if (!docletIsModuleObject) {
doclet.name = doclet.name.replace(nameAndPunc, '');
}
// like `bar` in:
// exports.bar = 1;
// module.exports.bar = 1;
// module.exports = MyModuleObject; MyModuleObject.bar = 1;
if (nameStartsWith !== 'this' && currentModule && !docletIsModuleObject) {
memberof = currentModule.longname;
scopePunc = SCOPE.PUNC.STATIC;
}
// like: module.exports = 1;
else if (docletIsModuleObject && currentModule) {
doclet.addTag('name', currentModule.longname);
doclet.postProcess();
} else {
memberof = parser.resolveThis(node);
// like the following at the top level of a module:
// this.foo = 1;
if (nameStartsWith === 'this' && currentModule && !memberof) {
memberof = currentModule.longname;
scopePunc = SCOPE.PUNC.STATIC;
} else {
scopePunc = SCOPE.PUNC.INSTANCE;
}
}
return {
memberof: memberof,
scopePunc: scopePunc,
};
}
function addSymbolMemberof(parser, doclet, node) {
let basename;
let memberof;
let memberofInfo;
let moduleOriginalName = '';
let resolveTargetRegExp;
let scopePunc;
let unresolved;
if (!node) {
return;
}
// check to see if the doclet name is an unresolved reference to the module object, or to `this`
// TODO: handle cases where the module object is shadowed in the current scope
if (currentModule) {
moduleOriginalName = `|${currentModule.originalName}`;
}
resolveTargetRegExp = new RegExp(
`^((?:module\\.)?exports|${ESCAPED_MODULE_LONGNAMES}|this${moduleOriginalName})(\\.|\\[|$)`
);
unresolved = resolveTargetRegExp.exec(doclet.name);
if (unresolved) {
memberofInfo = findSymbolMemberof(parser, doclet, node, unresolved[1], unresolved[2]);
memberof = memberofInfo.memberof;
scopePunc = memberofInfo.scopePunc;
if (memberof) {
doclet.name = doclet.name ? memberof + scopePunc + doclet.name : memberof;
}
} else {
memberofInfo = parser.astnodeToMemberof(node);
basename = memberofInfo.basename;
memberof = memberofInfo.memberof;
}
// if we found a memberof name, apply it to the doclet
if (memberof) {
doclet.addTag('memberof', memberof);
if (basename) {
doclet.name = (doclet.name || '').replace(new RegExp(`^${escape(basename)}.`), '');
}
}
// otherwise, add the defaults for a module (if we're currently in a module)
else {
setModuleScopeMemberOf(parser, doclet);
}
}
function newSymbolDoclet(parser, docletSrc, e) {
const newDoclet = createSymbolDoclet(docletSrc, e, parser.env);
// if there's an alias, use that as the symbol name
if (newDoclet.alias) {
processAlias(parser, newDoclet, e.astnode);
}
// otherwise, get the symbol name from the code
else if (typeof e.code?.name !== 'undefined' && e.code?.name !== '') {
newDoclet.addTag('name', e.code.name);
if (!newDoclet.memberof) {
addSymbolMemberof(parser, newDoclet, e.astnode);
}
newDoclet.postProcess();
} else {
return false;
}
// Set the scope to `global` unless any of the following are true:
//
// + The doclet is a `memberof` something.
// + The doclet represents a module.
// + We're in a CommonJS module that exports only this symbol.
if (
!newDoclet.memberof &&
newDoclet.kind !== 'module' &&
(!currentModule || !isModuleExports(currentModule, newDoclet))
) {
newDoclet.scope = SCOPE.NAMES.GLOBAL;
}
// handle cases where the doclet kind is auto-detected from the node type
if (e.code.kind && newDoclet.kind === 'member') {
newDoclet.kind = e.code.kind;
}
addDoclet(parser, newDoclet);
e.doclet = newDoclet;
return true;
}
/**
* Attach these event handlers to a particular instance of a parser.
* @param parser
*/
export function attachTo(parser) {
// Handle JSDoc "virtual comments" that include one of the following:
// + A `@name` tag
// + Another tag that accepts a name, such as `@function`
parser.on('jsdocCommentFound', (e) => {
const comments = e.comment.split(/@also\b/g);
let newDoclet;
for (let i = 0, l = comments.length; i < l; i++) {
newDoclet = createDoclet(comments[i], e, parser.env);
// we're only interested in virtual comments here
if (!newDoclet.name) {
continue;
}
// add the default scope/memberof for a module (if we're in a module)
setModuleScopeMemberOf(parser, newDoclet);
newDoclet.postProcess();
// if we _still_ don't have a scope, use the default
setDefaultScope(newDoclet);
addDoclet(parser, newDoclet);
e.doclet = newDoclet;
}
});
// Handle named symbols in the code. May or may not have a JSDoc comment attached.
parser.on('symbolFound', (e) => {
const comments = e.comment.split(/@also\b/g);
for (let i = 0, l = comments.length; i < l; i++) {
newSymbolDoclet(parser, comments[i], e);
}
});
parser.on('fileComplete', () => {
currentModule = null;
inferredModules = [];
});
}