/* 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. * * The 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. * * 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. * * @alias @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 tracks it automatically. * * @param {@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(); // TODO: Add descriptions and types for public properties. /** @type Map> */ this.allDocletsByLongname = new Map(); /** Doclets that are used to generate output. */ this.doclets = new Set(); /** @type Map> */ this.docletsByKind = new Map(); /** @type Map> */ this.docletsByLongname = new Map(); /** @type Map> */ this.docletsByMemberof = new Map(); /** @type Map> */ this.docletsByNodeId = new Map(); this.docletsWithAugments = new Set(); this.docletsWithBorrowed = new Set(); this.docletsWithImplements = new Set(); this.docletsWithMixes = new Set(); this.globals = new Set(); /** @type Map> */ this.listenersByListensTo = new Map(); /** Doclets that aren't used to generate output. */ 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. add(doclet) { let doclets; let nodeId; // Doclets with the `` 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 }); } } get allDoclets() { return new Set([...this.doclets, ...this.unusedDoclets]); } 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 ?? ''; } get longnames() { return Array.from(this.docletsByLongname.keys()); } get sourcePaths() { return Array.from(this.#sourcePaths.values()); } startListening() { if (!this.#isListening) { this.#emitter.on('docletChanged', this.#docletChangedHandler); this.#emitter.on('newDoclet', this.#newDocletHandler); this.#isListening = true; } } stopListening() { if (this.#isListening) { this.#emitter.removeListener('docletChanged', this.#docletChangedHandler); this.#emitter.removeListener('newDoclet', this.#newDocletHandler); this.#isListening = false; } } }