2024-06-07 20:13:15 -07:00

633 lines
18 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 EventEmitter from 'node:events';
import fs from 'node:fs';
import { AstBuilder, astNode, Syntax, Walker } from '@jsdoc/ast';
import { name } from '@jsdoc/core';
import { Doclet, DocletStore } from '@jsdoc/doclet';
import _ from 'lodash';
import { Visitor } from './visitor.js';
const { getBasename, LONGNAMES, SCOPE, toParts } = name;
// Prefix for JavaScript strings that were provided in lieu of a filename.
const SCHEMA = 'javascript:'; // eslint-disable-line no-script-url
// TODO: docs
function pretreat(code) {
return (
code
// comment out hashbang at the top of the file, like: #!/usr/bin/env node
.replace(/^(#![\S \t]+\r?\n)/, '// $1')
// to support code minifiers that preserve /*! comments, treat /*!* as equivalent to /**
.replace(/\/\*!\*/g, '/**')
// merge adjacent doclets
.replace(/\*\/\/\*\*+/g, '@also')
);
}
// TODO: docs
function definedInScope(doclet, basename) {
return (
Boolean(doclet?.meta?.vars) && Boolean(basename) && Object.hasOwn(doclet.meta.vars, basename)
);
}
function getLastValue(set) {
let value;
if (set) {
for (value of set);
}
return value;
}
// TODO: docs
/**
* @alias module:jsdoc/src/parser.Parser
* @extends module:events.EventEmitter
*/
export class Parser extends EventEmitter {
// TODO: docs
constructor(dependencies) {
super();
this._conf = dependencies.get('config');
this._dependencies = dependencies;
this._docletStore = new DocletStore(dependencies);
this._emitter = dependencies.get('emitter');
this._log = dependencies.get('log');
this.moduleTypes = new Map();
this._visitor = new Visitor();
this._walker = new Walker(dependencies, this);
this._visitor.setParser(this);
// Create a special doclet for the global namespace. Prevent it from emitting events when its
// watchable properties change.
this._globalDoclet = new Doclet(`@name ${LONGNAMES.GLOBAL}`, { _watch: false }, dependencies);
this._globalDoclet.longname = LONGNAMES.GLOBAL;
Object.defineProperties(this, {
dependencies: {
get() {
return this._dependencies;
},
},
visitor: {
get() {
return this._visitor;
},
},
walker: {
get() {
return this._walker;
},
},
});
}
_stopListening() {
this._docletStore.stopListening();
}
// TODO: Always emit events from the dependencies' emitter, never from the parser.
emit(eventName, event, ...args) {
super.emit(eventName, event, ...args);
this._emitter.emit(eventName, event, ...args);
}
// TODO: update docs
/**
* Parse the given source files for JSDoc comments.
* @param {Array.<string>} sourceFiles An array of filepaths to the JavaScript sources.
* @param {string} [encoding]
*
* @fires module:jsdoc/src/parser.Parser.parseBegin
* @fires module:jsdoc/src/parser.Parser.fileBegin
* @fires module:jsdoc/src/parser.Parser.jsdocCommentFound
* @fires module:jsdoc/src/parser.Parser.symbolFound
* @fires module:jsdoc/src/parser.Parser.newDoclet
* @fires module:jsdoc/src/parser.Parser.fileComplete
* @fires module:jsdoc/src/parser.Parser.parseComplete
*
* @example <caption>Parse two source files.</caption>
* var myFiles = ['file1.js', 'file2.js'];
* var docs = jsdocParser.parse(myFiles);
*/
parse(sourceFiles, encoding) {
let filename = '';
let sourceCode = '';
let sourceFile;
const parsedFiles = [];
const e = {};
encoding ??= this._conf.encoding ?? 'utf8';
if (typeof sourceFiles === 'string') {
sourceFiles = [sourceFiles];
}
e.sourcefiles = sourceFiles;
this._log.debug('Parsing source files: %j', sourceFiles);
this.emit('parseBegin', e);
for (let i = 0, l = sourceFiles.length; i < l; i++) {
sourceCode = '';
sourceFile = sourceFiles[i];
if (sourceFile.indexOf(SCHEMA) === 0) {
sourceCode = sourceFile.substring(SCHEMA.length);
filename = `[[string${i}]]`;
} else {
filename = sourceFile;
try {
sourceCode = fs.readFileSync(filename, encoding);
} catch (err) {
this._log.error(`Unable to read and parse the source file ${filename}: ${err}`);
}
}
if (sourceCode.length) {
this._parseSourceCode(sourceCode, filename);
parsedFiles.push(filename);
}
}
if (this.listenerCount('parseComplete')) {
this.emit('parseComplete', {
sourcefiles: parsedFiles,
doclets: this.results(),
});
}
this._log.debug('Finished parsing source files.');
return this._docletStore;
}
// TODO: docs
fireProcessingComplete(doclets) {
this.emit('processingComplete', { doclets: doclets });
}
// TODO: docs
results() {
return Array.from(this._docletStore.allDoclets);
}
addResult(doclet) {
this._docletStore.add(doclet);
}
// TODO: docs
addAstNodeVisitor(visitor) {
this._visitor.addAstNodeVisitor(visitor);
}
// TODO: docs
getAstNodeVisitors() {
return this._visitor.getAstNodeVisitors();
}
/** @private */
_parseSourceCode(sourceCode, sourceName) {
let ast;
const builder = new AstBuilder(this._dependencies);
let e = {
filename: sourceName,
};
let sourceType;
this.emit('fileBegin', e);
this._log.info(`Parsing ${sourceName} ...`);
if (!e.defaultPrevented) {
e = {
filename: sourceName,
source: sourceCode,
};
this.emit('beforeParse', e);
sourceCode = e.source;
sourceName = e.filename;
sourceCode = pretreat(e.source);
sourceType = this._conf.source ? this._conf.source.type : undefined;
ast = builder.build(sourceCode, sourceName, sourceType);
if (ast) {
this._walkAst(ast, this._visitor, sourceName);
}
}
this.emit('fileComplete', e);
}
/** @private */
_walkAst(ast, visitor, sourceName) {
const { filename, moduleType } = this._walker.recurse(ast, visitor, sourceName);
this.moduleTypes.set(filename, moduleType);
}
// TODO: docs
addDocletRef(e) {
let anonymousDoclet;
const node = e?.code?.node;
if (!node) {
return;
}
// Create a placeholder if the node is an undocumented anonymous function; there might be
// variables in its scope.
if (
(node.type === Syntax.FunctionDeclaration ||
node.type === Syntax.FunctionExpression ||
node.type === Syntax.ArrowFunctionExpression) &&
!this._getDocletById(node.nodeId)
) {
anonymousDoclet = new Doclet(
`@name ${LONGNAMES.ANONYMOUS}`,
{
_watch: false,
code: e.code,
},
this._dependencies
);
anonymousDoclet.longname = LONGNAMES.ANONYMOUS;
anonymousDoclet.undocumented = true;
this._docletStore.add(anonymousDoclet);
}
}
// TODO: docs
_getDocletById(id) {
return getLastValue(this._docletStore.docletsByNodeId.get(id));
}
/**
* Retrieve the most recently seen doclet that has the given longname.
*
* @param {string} longname - The longname to search for.
* @return {module:@jsdoc/doclet.Doclet?} The most recent doclet for the longname.
*/
_getDocletByLongname(longname) {
let doclets = this._getDocletsByLongname(longname);
return doclets[doclets.length - 1];
}
/**
* Retrieves all doclets with the given longname.
*
* @param {string} longname - The longname to search for.
* @return {Array<module:@jsdoc/doclet.Doclet>} The doclets for the longname.
*/
_getDocletsByLongname(longname) {
let doclets;
if (longname === LONGNAMES.GLOBAL) {
return [this._globalDoclet];
}
doclets = this._docletStore.docletsByLongname.get(longname);
return doclets ? Array.from(doclets) : [];
}
// TODO: docs
/**
* Given a node, determine what the node is a member of.
* @param {node} node
* @returns {string} The long name of the node that this is a member of.
*/
astnodeToMemberof(node) {
let basename;
let doclet;
let scope;
const result = {};
const type = node.type;
if (
(type === Syntax.FunctionDeclaration ||
type === Syntax.FunctionExpression ||
type === Syntax.ArrowFunctionExpression ||
type === Syntax.VariableDeclarator) &&
node.enclosingScope
) {
doclet = this._getDocletById(node.enclosingScope.nodeId);
if (!doclet) {
result.memberof = LONGNAMES.ANONYMOUS + SCOPE.PUNC.INNER;
} else {
result.memberof = doclet.longname + SCOPE.PUNC.INNER;
}
} else if (type === Syntax.ClassPrivateProperty || type === Syntax.ClassProperty) {
doclet = this._getDocletById(node.enclosingScope.nodeId);
if (!doclet) {
result.memberof = LONGNAMES.ANONYMOUS + SCOPE.PUNC.INSTANCE;
} else {
result.memberof = doclet.longname + SCOPE.PUNC.INSTANCE;
}
} else if (type === Syntax.MethodDefinition && node.kind === 'constructor') {
doclet = this._getDocletById(node.enclosingScope.nodeId);
// global classes aren't a member of anything
if (doclet.memberof) {
result.memberof = doclet.memberof + SCOPE.PUNC.INNER;
}
}
// special case for methods in classes that are returned by arrow function expressions; for
// other method definitions, we get the memberof from the node name elsewhere. yes, this is
// confusing...
else if (
type === Syntax.MethodDefinition &&
node.parent.parent.parent?.type === Syntax.ArrowFunctionExpression
) {
doclet = this._getDocletById(node.enclosingScope.nodeId);
if (doclet) {
result.memberof =
doclet.longname + (node.static === true ? SCOPE.PUNC.STATIC : SCOPE.PUNC.INSTANCE);
}
} else {
// check local references for aliases
scope = node;
basename = getBasename(astNode.nodeToValue(node));
// walk up the scope chain until we find the scope in which the node is defined
while (scope.enclosingScope) {
doclet = this._getDocletById(scope.enclosingScope.nodeId);
if (doclet && definedInScope(doclet, basename)) {
result.memberof = doclet.meta.vars[basename];
result.basename = basename;
break;
} else {
// move up
scope = scope.enclosingScope;
}
}
// do we know that it's a global?
doclet = this._getDocletByLongname(LONGNAMES.GLOBAL);
if (doclet && definedInScope(doclet, basename)) {
result.memberof = doclet.meta.vars[basename];
result.basename = basename;
} else {
doclet = this._getDocletById(node.parent.nodeId);
// set the result if we found a doclet. (if we didn't, the AST node may describe a
// global symbol.)
if (doclet) {
result.memberof = doclet.longname || doclet.name;
}
}
}
return result;
}
/**
* Get the doclet for the lowest-level class, if any, that is in the scope chain for a given node.
*
* @param {Object} node - The node whose scope chain will be searched.
* @return {module:@jsdoc/doclet.Doclet?} The doclet for the lowest-level class in the node's scope
* chain.
*/
_getParentClass({ enclosingScope }) {
let doclet;
let parts;
let scope = enclosingScope;
function isClass(d) {
return d?.kind === 'class';
}
while (scope) {
// get the doclet, if any, for the parent scope
doclet = this._getDocletById(scope.nodeId);
if (doclet) {
// is the doclet for a class? if so, we're done
if (isClass(doclet)) {
break;
}
// is the doclet for an instance member of a class? if so, try to get the doclet for the
// owning class
parts = toParts(doclet.longname);
if (parts.scope === SCOPE.PUNC.INSTANCE) {
doclet = this._getDocletsByLongname(parts.memberof).filter((d) => isClass(d))[0];
if (doclet) {
break;
}
}
}
// move up to the next parent scope
scope = scope.enclosingScope;
}
return isClass(doclet) ? doclet : null;
}
// TODO: docs
/**
* Resolve what "this" refers to relative to a node.
* @param {node} node - The "this" node
* @returns {string} The longname of the enclosing node.
*/
resolveThis(node) {
let doclet;
let parentClass;
let result;
// Properties are handled below.
if (node.type !== Syntax.Property && node.enclosingScope) {
// For ES2015 constructor functions, we use the class declaration to resolve `this`.
if (node.parent?.type === Syntax.MethodDefinition && node.parent?.kind === 'constructor') {
doclet = this._getDocletById(node.parent.parent.parent.nodeId);
}
// Otherwise, if there's an enclosing scope, we use the enclosing scope to resolve `this`.
else {
doclet = this._getDocletById(node.enclosingScope.nodeId);
}
if (!doclet) {
result = LONGNAMES.ANONYMOUS; // TODO handle global this?
} else if (doclet.this) {
result = doclet.this;
} else if (doclet.kind === 'function' && doclet.memberof) {
parentClass = this._getParentClass(node);
// like: function Foo() { this.bar = function(n) { /** blah */ this.name = n; };
// or: Foo.prototype.bar = function(n) { /** blah */ this.name = n; };
// or: var Foo = exports.Foo = function(n) { /** blah */ this.name = n; };
// or: Foo.constructor = function(n) { /** blah */ this.name = n; }
if (parentClass || /\.constructor$/.test(doclet.longname)) {
result = doclet.memberof;
}
// like: function notAClass(n) { /** global this */ this.name = n; }
else {
result = doclet.longname;
}
}
// like: var foo = function(n) { /** blah */ this.bar = n; }
else if (doclet.kind === 'member' && astNode.isAssignment(node)) {
result = doclet.longname;
}
// walk up to the closest class we can find
else if (doclet.kind === 'class' || doclet.kind === 'interface' || doclet.kind === 'module') {
result = doclet.longname;
} else if (node.enclosingScope) {
result = this.resolveThis(node.enclosingScope);
}
}
// For object properties, we use the node's parent (the object) instead.
else {
doclet = this._getDocletById(node.parent.nodeId);
if (!doclet) {
// The object wasn't documented, so we don't know what name to use.
result = '';
} else {
result = doclet.longname;
}
}
return result;
}
/**
* Given an AST node representing an object property, find the doclets for the parent object or
* objects.
*
* If the object is part of a simple assignment (for example, `var foo = { x: 1 }`), this method
* returns a single doclet (in this case, the doclet for `foo`).
*
* If the object is part of a chained assignment (for example, `var foo = exports.FOO = { x: 1 }`,
* this method returns multiple doclets (in this case, the doclets for `foo` and `exports.FOO`).
*
* @param {Object} node - An AST node representing an object property.
* @return {Array.<module:@jsdoc/doclet.Doclet>} An array of doclets for the parent object or objects, or
* an empty array if no doclets are found.
*/
resolvePropertyParents({ parent }) {
let currentAncestor = parent;
let nextAncestor = currentAncestor.parent;
let doclet;
const doclets = [];
while (currentAncestor) {
doclet = this._getDocletById(currentAncestor.nodeId);
if (doclet) {
doclets.push(doclet);
}
// if the next ancestor is an assignment expression (for example, `exports.FOO` in
// `var foo = exports.FOO = { x: 1 }`, keep walking upwards
if (nextAncestor?.type === Syntax.AssignmentExpression) {
nextAncestor = nextAncestor.parent;
currentAncestor = currentAncestor.parent;
}
// otherwise, we're done
else {
currentAncestor = null;
}
}
return doclets;
}
// TODO: docs
/**
* Resolve what function a var is limited to.
* @param {astnode} node
* @param {string} basename The leftmost name in the long name: in foo.bar.zip the basename is foo.
*/
resolveVar({ enclosingScope, type }, basename) {
let doclet;
let result;
const scope = enclosingScope;
// HACK: return an empty string for function declarations so they don't end up in anonymous
// scope (see #685 and #693)
if (type === Syntax.FunctionDeclaration) {
result = '';
} else if (!scope) {
result = ''; // global
} else {
doclet = this._getDocletById(scope.nodeId);
if (definedInScope(doclet, basename)) {
result = doclet.longname;
} else {
result = this.resolveVar(scope, basename);
}
}
return result;
}
// TODO: docs
resolveEnum(e) {
const doclets = this.resolvePropertyParents(e.code.node.parent);
doclets.forEach((doclet) => {
if (doclet?.isEnum) {
doclet.properties = doclet.properties || [];
// members of an enum inherit the enum's type
if (doclet.type && !e.doclet.type) {
// clone the type to prevent circular refs
e.doclet.type = _.cloneDeep(doclet.type);
}
e.doclet.undocumented = undefined;
e.doclet.defaultvalue = e.doclet.meta.code.value;
// add the doclet to the parent's properties
doclet.properties.push(e.doclet);
}
});
}
}
// TODO: docs
export function createParser(deps) {
return new Parser(deps);
}
// TODO: document other events
/**
* Fired once for each JSDoc comment in the current source code.
* @event jsdocCommentFound
* @memberof module:@jsdoc/parse.Parser
* @type {Object}
* @property {string} comment The text content of the JSDoc comment
* @property {number} lineno The line number associated with the found comment.
* @property {number} columnno The column number associated with the found comment.
* @property {string} filename The file name associated with the found comment.
*/