jsdoc/packages/jsdoc-doclet/lib/doclet-store.js
2025-07-05 09:37:16 -07:00

504 lines
15 KiB
JavaScript

/*
Copyright 2023 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 { dirname, join } from 'node:path';
import { LONGNAMES } from '@jsdoc/name';
import commonPathPrefix from 'common-path-prefix';
import _ from 'lodash';
const ANONYMOUS_LONGNAME = LONGNAMES.ANONYMOUS;
function addToSet(targetMap, key, value) {
if (!targetMap.has(key)) {
targetMap.set(key, new Set());
}
targetMap.get(key).add(value);
}
function diffArrays(value, previousValue = []) {
return {
added: _.difference(value, previousValue),
removed: _.difference(previousValue, value),
};
}
function getSourcePath({ meta }) {
return meta?.path ? join(meta.path, meta.filename) : meta?.filename;
}
function removeFromSet(targetMap, key, value) {
const set = targetMap.get(key);
if (set) {
set.delete(value);
// If the set is now empty, delete it from the map.
if (set.size === 0) {
targetMap.delete(key);
}
}
}
/**
* Stores and classifies the doclets that JSDoc creates as it parses your source files.
*
* A doclet store categorizes doclets based on their properties, so that the JSDoc template can
* efficiently retrieve the doclets that it needs. For example, when the template generates
* documentation for a class, it can retrieve all of the doclets that represent members of that
* class.
*
* To retrieve the doclets that you need, use the doclet store's instance properties. For example,
* {@link module:@jsdoc/doclet.DocletStore#docletsByLongname} maps longnames to the doclets with
* that longname.
*
* After you add a doclet to the store, the store automatically tracks changes to a doclet's
* properties and recategorizes the doclet as needed. For example, if a doclet's `kind` property
* changes from `class` to `interface`, then the doclet store automatically recategorizes the doclet
* as an interface.
*
* Doclets can be _visible_, meaning that they should be used to generate output, or _hidden_,
* meaning that they're ignored when generating output. Except as noted, the doclet store exposes
* only visible doclets.
*
* @alias module:@jsdoc/doclet.DocletStore
*/
export class DocletStore {
#commonPathPrefix;
#docletChangedHandler;
#emitter;
#isListening;
#newDocletHandler;
#sourcePaths;
static #propertiesWithMaps = ['kind', 'longname', 'memberof'];
static #propertyToMapName = new Map(
DocletStore.#propertiesWithMaps.map((prop) => [prop, 'docletsBy' + _.capitalize(prop)])
);
static #propertiesWithSets = ['augments', 'borrowed', 'implements', 'mixes'];
static #propertyToSetName = new Map(
DocletStore.#propertiesWithSets.map((prop) => [prop, 'docletsWith' + _.capitalize(prop)])
);
/**
* Creates a doclet store.
*
* When you create a doclet store, you provide a JSDoc environment object. The doclet store
* listens for new doclets that are created in that environment. When a new doclet is created, the
* doclet store adds it automatically and tracks updates to the doclet.
*
* @param {module:@jsdoc/core.Env} env - The JSDoc environment to use.
*/
constructor(env) {
this.#commonPathPrefix = null;
this.#emitter = env.emitter;
this.#isListening = false;
this.#sourcePaths = new Map();
/**
* Map of all doclet longnames to a `Set` of all doclets with that longname. Includes both
* visible and hidden doclets.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.allDocletsByLongname = new Map();
/**
* All visible doclets.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.doclets = new Set();
/**
* Map from a doclet kind to a `Set` of doclets with that kind.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.docletsByKind = new Map();
/**
* Map from a doclet longname to a `Set` of doclets with that longname.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.docletsByLongname = new Map();
/**
* Map from a doclet `memberof` value to a `Set` of doclets with that `memberof`.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.docletsByMemberof = new Map();
/**
* Map from an AST node ID, generated during parsing, to a `Set` of doclets for that node ID.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.docletsByNodeId = new Map();
/**
* Doclets that have an `augments` property.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.docletsWithAugments = new Set();
/**
* Doclets that have a `borrowed` property.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.docletsWithBorrowed = new Set();
/**
* Doclets that have an `implements` property.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.docletsWithImplements = new Set();
/**
* Doclets that have a `mixes` property.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.docletsWithMixes = new Set();
/**
* Doclets that belong to the global scope.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.globals = new Set();
/**
* Map from an event's longname to a `Set` of doclets that listen to that event.
*
* @type Map<string, Set<module:@jsdoc/doclet.Doclet>>
*/
this.listenersByListensTo = new Map();
/**
* Doclets that are hidden and shouldn't be used to generate output.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
this.unusedDoclets = new Set();
this.#docletChangedHandler = (e) => this.#handleDocletChanged(e, {});
this.#newDocletHandler = (e) => this.#handleDocletChanged(e, { newDoclet: true });
this.startListening();
}
#handleDocletChanged({ doclet, property, oldValue, newValue }, opts) {
const isVisible = doclet.isVisible();
const newDoclet = opts.newDoclet ?? false;
const wasVisible = newDoclet ? false : this.doclets.has(doclet);
const visibilityChanged = newDoclet || wasVisible !== isVisible;
const docletInfo = {
isGlobal: doclet.isGlobal(),
isVisible,
newDoclet,
newValue,
oldValue,
property,
setFnName: isVisible ? 'add' : 'delete',
visibilityChanged,
wasVisible,
};
if (newDoclet) {
this.#trackAllDocletsByLongname(doclet, {});
this.#trackDocletByNodeId(doclet);
}
if (visibilityChanged) {
this.#toggleVisibility(doclet, docletInfo);
}
// In the following cases, there's nothing more to do:
//
// + The doclet isn't visible, and we're seeing it for the first time.
// + The doclet isn't visible, and its visibility didn't change.
if (!isVisible && (newDoclet || !visibilityChanged)) {
return;
}
// Update all watchable properties.
this.#updateWatchableProperties(doclet, docletInfo);
// Update list of source paths for visible doclets.
if (visibilityChanged) {
this.#updateSourcePaths(doclet, docletInfo);
}
}
#toggleGlobal(doclet, { isGlobal, isVisible }) {
if (isGlobal && isVisible) {
this.globals.add(doclet);
} else {
this.globals.delete(doclet);
}
}
#toggleVisibility(doclet, { isVisible, setFnName }) {
const action = isVisible ? 'delete' : 'add';
this.doclets[setFnName](doclet);
this.unusedDoclets[action](doclet);
}
// Updates `this.allDocletsByLongname` _only_.
#trackAllDocletsByLongname(doclet, { property, oldValue, newValue }) {
newValue ??= doclet.longname;
if (property && property !== 'longname') {
return;
}
if (oldValue) {
removeFromSet(this.allDocletsByLongname, oldValue, doclet);
}
if (newValue) {
addToSet(this.allDocletsByLongname, newValue, doclet);
}
}
#trackDocletByNodeId(doclet) {
const nodeId = doclet.meta?.code?.node?.nodeId;
if (nodeId) {
addToSet(this.docletsByNodeId, nodeId, doclet);
}
}
#updateMapProperty(
requestedProp,
doclet,
{ property: eventProp, oldValue: oldKey, newValue: newKey, isVisible, wasVisible }
) {
const mapName = DocletStore.#propertyToMapName.get(requestedProp);
const map = this[mapName];
// If the event didn't specify the property name that we're interested in, then ignore the new
// key; it doesn't apply to this property. Instead, get the key from the doclet.
if (requestedProp !== eventProp) {
newKey = doclet[requestedProp];
}
// If the doclet is no longer visible, we must always remove it from the set, using whatever key
// we have.
if (wasVisible && !isVisible) {
removeFromSet(map, oldKey ?? newKey, doclet);
} else if (wasVisible && oldKey) {
removeFromSet(map, oldKey, doclet);
}
if (isVisible && newKey) {
addToSet(map, newKey, doclet);
}
}
#updateSetProperty(prop, value, { setFnName }) {
const set = this[DocletStore.#propertyToSetName.get(prop)];
if (Object.hasOwn(value, prop) && value[prop]?.length) {
set[setFnName](value);
} else {
set.delete(value);
}
}
#updateSourcePaths(doclet, { isVisible }) {
const sourcePath = getSourcePath(doclet);
if (!sourcePath || !isVisible) {
this.#sourcePaths.delete(doclet);
} else if (sourcePath) {
this.#sourcePaths.set(doclet, sourcePath);
}
// Invalidate the cached common prefix for source paths.
this.#commonPathPrefix = null;
}
#updateWatchableProperties(doclet, docletInfo) {
const { isVisible, newDoclet, newValue, oldValue, property, visibilityChanged, wasVisible } =
docletInfo;
// `access` only affects visibility, which is handled above, so we ignore it here.
if (visibilityChanged || property === 'augments') {
this.#updateSetProperty('augments', doclet, docletInfo);
}
if (visibilityChanged || property === 'borrowed') {
this.#updateSetProperty('borrowed', doclet, docletInfo);
}
// `ignore` only affects visibility, which is handled above, so we ignore it here.
if (visibilityChanged || property === 'implements') {
this.#updateSetProperty('implements', doclet, docletInfo);
}
if (visibilityChanged || property === 'kind') {
this.#toggleGlobal(doclet, docletInfo);
this.#updateMapProperty('kind', doclet, docletInfo);
}
if (visibilityChanged || property === 'listens') {
let added;
let diff;
let removed;
if (newDoclet) {
added = doclet.listens;
removed = [];
} else {
diff = diffArrays(newValue, oldValue);
added = diff.added;
removed = diff.removed;
}
if (added && isVisible) {
added.forEach((listensTo) => addToSet(this.listenersByListensTo, listensTo, doclet));
}
if (removed && wasVisible) {
removed.forEach((listensTo) => removeFromSet(this.listenersByListensTo, listensTo, doclet));
}
}
if (visibilityChanged || property === 'longname') {
this.#updateMapProperty('longname', doclet, docletInfo);
this.#trackAllDocletsByLongname(doclet, docletInfo);
}
if (visibilityChanged || property === 'memberof') {
this.#updateMapProperty('memberof', doclet, docletInfo);
}
if (visibilityChanged || property === 'mixes') {
this.#updateSetProperty('mixes', doclet, docletInfo);
}
if (visibilityChanged || property === 'scope') {
this.#toggleGlobal(doclet, docletInfo);
}
// `undocumented` only affects visibility, which is handled above, so we ignore it here.
}
/**
* Adds a doclet to the store directly, rather than by listening to events from the JSDoc
* environment.
*
* Use this method if you need to track a doclet that's generated outside of JSDoc's parsing
* process.
*
* @param {module:@jsdoc/doclet.Doclet} doclet - The doclet to add.
*/
add(doclet) {
let doclets;
let nodeId;
// Doclets with the `<anonymous>` longname are used only to track variables in the AST node's
// scope. Just track the doclet by node ID so the parser can look it up by node ID.
if (doclet.longname === ANONYMOUS_LONGNAME) {
nodeId = doclet.meta?.code?.node?.nodeId;
if (nodeId) {
doclets = this.docletsByNodeId.get(nodeId) ?? new Set();
doclets.add(doclet);
this.docletsByNodeId.set(nodeId, doclets);
}
} else {
this.#newDocletHandler({ doclet });
}
}
/**
* All known doclets, including both visible and hidden doclets.
*
* @type Set<module:@jsdoc/doclet.Doclet>
*/
get allDoclets() {
return new Set([...this.doclets, ...this.unusedDoclets]);
}
/**
* The longest filepath prefix that's shared by the source files that were parsed.
*
* + If there's only one source file, then the prefix is the source file's directory name.
* + If the source files don't have a common prefix, then the prefix is an empty string.
*
* If a doclet is hidden, then its source filepath is ignored when determining the prefix.
*
* @type {string}
*/
get commonPathPrefix() {
let commonPrefix;
let sourcePaths;
if (this.#commonPathPrefix !== null) {
return this.#commonPathPrefix;
}
sourcePaths = this.sourcePaths;
if (sourcePaths.length === 1) {
// If there's only one filepath, then the common prefix is just its dirname.
commonPrefix = dirname(sourcePaths[0]);
} else if (sourcePaths.length > 1) {
// Remove the trailing slash if present.
commonPrefix = commonPathPrefix(sourcePaths).replace(/[\\/]$/, '');
} else {
commonPrefix = null;
}
this.#commonPathPrefix = commonPrefix;
return commonPrefix ?? '';
}
/**
* The longnames of all visible doclets.
*
* @type Array<string>
*/
get longnames() {
return Array.from(this.docletsByLongname.keys());
}
/**
* The source paths associated with all visible doclets.
*
* @type Array<string>
*/
get sourcePaths() {
return Array.from(this.#sourcePaths.values());
}
/**
* Start listening to events from the JSDoc environment.
*
* In general, you don't need to call this method. A `DocletStore` always listens for events by
* default.
*/
startListening() {
if (!this.#isListening) {
this.#emitter.on('docletChanged', this.#docletChangedHandler);
this.#emitter.on('newDoclet', this.#newDocletHandler);
this.#isListening = true;
}
}
/**
* Stop listening to events from the JSDoc environment.
*
* Call this method if you're done using a `DocletStore`, and you don't want it to listen to
* future events.
*/
stopListening() {
if (this.#isListening) {
this.#emitter.removeListener('docletChanged', this.#docletChangedHandler);
this.#emitter.removeListener('newDoclet', this.#newDocletHandler);
this.#isListening = false;
}
}
}