2025-11-28 15:17:24 -08:00

630 lines
16 KiB
JavaScript

/*
Copyright 2019 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.
*/
/**
* Methods for working with namepaths in JSDoc.
*
* @module @jsdoc/name
*/
import escape from 'escape-string-regexp';
import _ from 'lodash';
/**
* Longnames that have a special meaning in JSDoc.
*
* @enum {string}
*/
export const LONGNAMES = {
/** Longname used for doclets that do not have a longname, such as anonymous functions. */
ANONYMOUS: '<anonymous>',
/** Longname that represents global scope. */
GLOBAL: '<global>',
/** Longname for the default export in an ES2015 module. */
MODULE_DEFAULT_EXPORT: '<moduleDefaultExport>',
/** Longname prefix for an export in an ES2015 module. */
MODULE_EXPORT: '<moduleExport>',
};
// Module namespace prefix.
export const MODULE_NAMESPACE = 'module:';
/**
* Names and punctuation marks that identify doclet scopes.
*
* @enum {string}
*/
export const SCOPE = {
/**
* Scope names.
*/
NAMES: {
/**
* Global scope. The symbol is available globally.
*/
GLOBAL: 'global',
/**
* Inner scope. The symbol is available only within the enclosing scope.
*/
INNER: 'inner',
/**
* Instance scope. The symbol is available on instances of its parent.
*/
INSTANCE: 'instance',
/**
* Static scope. The symbol is a static property of its parent.
*/
STATIC: 'static',
},
/**
* Punctuation used in JSDoc namepaths to identify a symbol's scope.
*
* Global scope does not have a punctuation equivalent.
*/
PUNC: {
/**
* The abbreviation for inner scope: `~`
*/
INNER: '~',
/**
* The abbreviation for instance scope: `#`
*/
INSTANCE: '#',
/**
* The abbreviation for static scope: `.`
*/
STATIC: '.',
},
};
/**
* Doclet scope identifiers mapped to the equivalent punctuation.
*
* @enum {string}
*/
export const SCOPE_TO_PUNC = {
/**
* The abbreviation for inner scope.
*/
inner: SCOPE.PUNC.INNER,
/**
* The abbreviation for instance scope.
*/
instance: SCOPE.PUNC.INSTANCE,
/**
* The abbreviation for static scope.
*/
static: SCOPE.PUNC.STATIC,
};
/**
* Doclet scope punctuation mapped to the equivalent identifiers.
*
* @enum {string}
*/
export const PUNC_TO_SCOPE = {
/**
* The identifier for inner scope.
*/
'~': 'inner',
/**
* The identifier for instance scope.
*/
'#': 'instance',
/**
* The identifier for static scope.
*/
'.': 'static',
};
const SCOPE_PUNC = _.values(SCOPE.PUNC);
const SCOPE_PUNC_STRING = `[${SCOPE_PUNC.join()}]`;
const REGEXP_LEADING_SCOPE = new RegExp(`^(${SCOPE_PUNC_STRING})`);
const REGEXP_TRAILING_SCOPE = new RegExp(`(${SCOPE_PUNC_STRING})$`);
const DESCRIPTION = '(?:(?:[ \\t]*\\-\\s*|\\s+)(\\S[\\s\\S]*))?$';
const REGEXP_DESCRIPTION = new RegExp(DESCRIPTION);
const REGEXP_NAME_DESCRIPTION = new RegExp(`^(\\[[^\\]]+\\]|\\S+)${DESCRIPTION}`);
/**
* Checks whether a name appears to represent a complete longname that is a member of the specified
* parent.
*
* @example
* nameIsLongname('foo.bar', 'foo'); // true
* nameIsLongname('foo.bar', 'baz'); // false
* nameIsLongname('bar', 'foo'); // false
* @param {string} name - The name to check.
* @param {string} memberof - The parent of the name.
* @returns {boolean} `true` if the name represents a complete longname that is a member of the
* parent; otherwise, `false`.
*/
export function nameIsLongname(name, memberof) {
const regexp = new RegExp(`^${escape(memberof)}${SCOPE_PUNC_STRING}`);
return regexp.test(name);
}
/**
* For names that identify a property of a prototype, replaces the `prototype` portion of the name
* with `#`, which indicates instance-level scope. For example, `Foo.prototype.bar` becomes
* `Foo#bar`.
*
* @param {string} name - The name in which to change `prototype` to `#`.
* @returns {string} The updated name.
*/
export function prototypeToPunc(name) {
// Don't mangle symbols named `prototype`.
if (name === 'prototype') {
return name;
}
// If there's a trailing open bracket ([), as in `Foo.prototype['bar']`, keep it.
return name.replace(/(?:^|\.)prototype(?:$|\.|(\[))/g, `${SCOPE.PUNC.INSTANCE}$1`);
}
/**
* Gets the leading scope character, if any, from a name.
*
* @param {string} name - The name to check.
* @returns {?string} The leading scope character, if one is present.
*/
export function getLeadingScope(name) {
const match = name.match(REGEXP_LEADING_SCOPE);
return match?.[1];
}
/**
* Gets the trailing scope character, if any, from a name.
*
* @param {string} name - The name to check.
* @returns {?string} The trailing scope character, if one is present.
*/
export function getTrailingScope(name) {
const match = name.match(REGEXP_TRAILING_SCOPE);
return match?.[1];
}
/**
* Gets a symbol's basename, which is the first part of its full name before any scope punctuation.
* For example, all of the following names have the basename `Foo`:
*
* + `Foo`
* + `Foo.bar`
* + `Foo.prototype.bar`
* + `Foo#bar`
*
* Similarly, the longname `module:@foo/bar.Baz` has the basename `module:@foo/bar`.
*
* @param {?string} [name] - The symbol's full name.
* @returns {?string} The symbol's basename.
*/
export function getBasename(name) {
if (!name) {
return null;
}
return name.replace(/^((?:[a-z]+:)?[$a-z@_][$a-z_/0-9]*).*?$/i, '$1');
}
// TODO: docs
export function stripNamespace(longname) {
return longname.replace(/^[a-zA-Z]+:/, '');
}
// TODO: docs
function slice(longname, sliceChars, forcedMemberof) {
let i;
let memberof = '';
let name = '';
let parts;
let partsRegExp;
let scopePunc = '';
let sliceCharsPrefix = '';
let token;
const tokens = [];
let variation;
sliceChars ??= SCOPE_PUNC;
// For longnames like `MyClass##myPrivateMethod`, use a negative lookbehind assertion to prevent
// the leading `#` in `#myPrivateMethod` from being treated as a scope character.
if (sliceChars.includes(SCOPE.PUNC.INSTANCE)) {
sliceCharsPrefix = `(?<!${SCOPE.PUNC.INSTANCE})`;
}
// Quoted strings in a longname are atomic, so we convert them to tokens:
// foo["bar"] => foo.@{1}@
// Foo.prototype["bar"] => Foo#@{1}
longname = longname.replace(/(prototype|#)?(\[?["'].+?["']\]?)/g, ($, p1, p2) => {
let punc = '';
// Is there a leading bracket?
if (/^\[/.test(p2)) {
// Is it a static or instance member?
punc = p1 ? SCOPE.PUNC.INSTANCE : SCOPE.PUNC.STATIC;
p2 = p2.replace(/^\[/g, '').replace(/\]$/g, '');
}
token = `@{${tokens.length}}@`;
tokens.push(p2);
return punc + token;
});
longname = prototypeToPunc(longname);
if (typeof forcedMemberof !== 'undefined') {
partsRegExp = new RegExp(`^(.*?)(${sliceCharsPrefix}[${sliceChars.join()}]?)$`);
name = longname.substr(forcedMemberof.length);
parts = forcedMemberof.match(partsRegExp);
if (parts[1]) {
memberof = parts[1] ?? forcedMemberof;
}
if (parts[2]) {
scopePunc = parts[2];
}
} else if (longname) {
parts =
longname.match(new RegExp(`^(:?(.+)(${sliceCharsPrefix}[${sliceChars.join()}]))?(.+?)$`)) ??
[];
name = parts.pop() ?? '';
scopePunc = parts.pop() ?? '';
memberof = parts.pop() ?? '';
}
// Like `@name foo.bar(2)`.
parts = name.match(/(.+)\(([^)]+)\)$/);
if (parts) {
name = parts[1];
variation = parts[2];
}
// Restore quoted strings.
i = tokens.length;
while (i--) {
longname = longname.replace(`@{${i}}@`, tokens[i]);
memberof = memberof.replace(`@{${i}}@`, tokens[i]);
scopePunc = scopePunc.replace(`@{${i}}@`, tokens[i]);
name = name.replace(`@{${i}}@`, tokens[i]);
}
return {
longname: longname,
memberof: memberof,
scope: scopePunc,
name: name,
variation: variation,
};
}
// TODO: Document this with a typedef.
/**
* Given a longname like `a.b#c(2)`, this method splits it into the following parts:
*
* + `longname`
* + `memberof`
* + `scope`
* + `name`
* + `variation`
*
* @param {string} longname - The longname to divide into parts.
* @param {string} forcedMemberof
* @returns {object} Representing the properties of the given name.
*/
export function toParts(longname, forcedMemberof) {
return slice(longname, null, forcedMemberof);
}
/**
* Applies a namespace to a longname.
*
* If the longname already has a namespace, then the namespace is not applied.
*
* @param {string} longname - The longname of the symbol.
* @param {string} ns - The namespace to be applied.
* @returns {string} The longname with the namespace applied.
*/
export function applyNamespace(longname, ns) {
const nameParts = slice(longname);
const name = nameParts.name;
longname = nameParts.longname;
if (!/^[a-zA-Z]+?:.+$/i.test(name)) {
longname = longname.replace(new RegExp(`${escape(name)}$`), `${ns}:${name}`);
}
return longname;
}
/**
* Checks whether a parent longname is an ancestor of a child longname.
*
* @param {string} parent - The parent longname.
* @param {string} child - The child longname.
* @returns {boolean} `true` if the parent is an ancestor of the child; otherwise, `false`.
*/
export function hasAncestor(parent, child) {
let parentIsAncestor = false;
let memberof = child;
if (!parent || !child) {
return parentIsAncestor;
}
// Fast path for obvious non-ancestors.
if (child.indexOf(parent) !== 0) {
return parentIsAncestor;
}
do {
memberof = slice(memberof).memberof;
if (memberof === parent) {
parentIsAncestor = true;
}
} while (!parentIsAncestor && memberof);
return parentIsAncestor;
}
// TODO: docs
export function fromParts({ memberof, scope, name, variation }) {
return [memberof ?? '', scope ?? '', name ?? '', variation ? `(${variation})` : ''].join('');
}
// TODO: docs
export function stripVariation(name) {
const parts = slice(name);
parts.variation = '';
return fromParts(parts);
}
function splitLongname(longname, options) {
const chunks = [];
let currentNameInfo;
const nameInfo = {};
let previousName = longname;
const splitters = SCOPE_PUNC.concat('/');
options = _.defaults(options ?? {}, {
includeVariation: true,
});
do {
if (!options.includeVariation) {
previousName = stripVariation(previousName);
}
currentNameInfo = nameInfo[previousName] = slice(previousName, splitters);
previousName = currentNameInfo.memberof;
chunks.push(currentNameInfo.scope + currentNameInfo.name);
} while (previousName);
return {
chunks: chunks.reverse(),
nameInfo: nameInfo,
};
}
// TODO: Document this with a typedef.
// TODO: Add at least one or two basic tests, so we know if this completely breaks.
/**
* Converts an array of doclet longnames into a tree structure, optionally attaching doclets to the
* tree.
*
* Each level of the tree is an object with the following properties:
*
* + `longname {string}`: The longname.
* + `memberof {?string}`: The memberof.
* + `scope {?string}`: The longname's scope, represented as a punctuation mark (for example, `#`
* for instance and `.` for static).
* + `name {string}`: The short name.
* + `doclet {?Object}`: The doclet associated with the longname, or `null` if the doclet was not
* provided.
* + `children {?Object}`: The children of the current longname. Not present if there are no
* children.
*
* For example, suppose you have the following array of doclet longnames:
*
* ```js
* [
* "module:a",
* "module:a/b",
* "myNamespace",
* "myNamespace.Foo",
* "myNamespace.Foo#bar"
* ]
* ```
*
* This method converts these longnames to the following tree:
*
* ```js
* {
* "module:a": {
* "longname": "module:a",
* "memberof": "",
* "scope": "",
* "name": "module:a",
* "doclet": null,
* "children": {
* "/b": {
* "longname": "module:a/b",
* "memberof": "module:a",
* "scope": "/",
* "name": "b",
* "doclet": null
* }
* }
* },
* "myNamespace": {
* "longname": "myNamespace",
* "memberof": "",
* "scope": "",
* "name": "myNamespace",
* "doclet": null,
* "children": {
* ".Foo": {
* "longname": "myNamespace.Foo",
* "memberof": "myNamespace",
* "scope": ".",
* "name": "Foo",
* "doclet": null,
* "children": {
* "#bar": {
* "longname": "myNamespace.Foo#bar",
* "memberof": "myNamespace.Foo",
* "scope": "#",
* "name": "bar",
* "doclet": null
* }
* }
* }
* }
* }
* }
* ```
*
* @param {Array<string>} longnames - The longnames to convert into a tree.
* @param {Object<string, module:@jsdoc/doclet.Doclet>} doclets - The doclets to attach to a tree.
* Each property should be the longname of a doclet, and each value should be the doclet for that
* longname.
* @returns {Object} A tree with information about each longname in the format shown above.
*/
export function longnamesToTree(longnames, doclets) {
const splitOptions = { includeVariation: false };
const tree = {};
longnames.forEach((longname) => {
let currentLongname = '';
let currentParent = tree;
let nameInfo;
let processed;
// Don't try to add empty longnames to the tree.
if (!longname) {
return;
}
processed = splitLongname(longname, splitOptions);
nameInfo = processed.nameInfo;
processed.chunks.forEach((chunk) => {
currentLongname += chunk;
if (currentParent !== tree) {
currentParent.children ??= {};
currentParent = currentParent.children;
}
if (!Object.hasOwn(currentParent, chunk)) {
currentParent[chunk] = nameInfo[currentLongname];
}
if (currentParent[chunk]) {
currentParent[chunk].doclet = doclets ? doclets[currentLongname] : null;
currentParent = currentParent[chunk];
}
});
});
return tree;
}
// TODO: Document this with a typedef.
/**
* Splits a string that starts with a name and ends with a description into its parts. Allows the
* default value (if present) to contain brackets. Returns `null` if the name contains mismatched
* brackets.
*
* @param {string} nameDesc - The combined name and description.
* @returns {?Object} An object with `name` and `description` properties.
*/
function splitNameMatchingBrackets(nameDesc) {
const buffer = [];
let c;
let match;
let stack = 0;
let stringEnd = null;
for (var i = 0; i < nameDesc.length; ++i) {
c = nameDesc[i];
buffer.push(c);
if (stringEnd) {
if (c === '\\' && i + 1 < nameDesc.length) {
buffer.push(nameDesc[++i]);
} else if (c === stringEnd) {
stringEnd = null;
}
} else if (c === '"' || c === "'") {
stringEnd = c;
} else if (c === '[') {
++stack;
} else if (c === ']') {
if (--stack === 0) {
break;
}
}
}
if (stack || stringEnd) {
return null;
}
match = nameDesc.substring(i).match(REGEXP_DESCRIPTION) ?? [];
return {
name: buffer.join(''),
description: match[1],
};
}
// TODO: Document this with a typedef.
/**
* Splits a string that starts with a name and ends with a description into separate parts.
*
* @param {string} str - The combined name and description.
* @returns {Object} An object with `name` and `description` properties.
*/
export function splitNameAndDescription(str) {
// Like: `name`, `[name]`, `name text`, `[name] text`, `name - text`, or `[name] - text`.
// To ensure that we don't get confused by leading dashes in Markdown list items, the hyphen
// must be on the same line as the name.
let match;
// Optional values get special treatment.
let result = null;
if (str[0] === '[') {
result = splitNameMatchingBrackets(str);
if (result !== null) {
return result;
}
}
match = str.match(REGEXP_NAME_DESCRIPTION);
return {
name: match?.[1] ?? '',
description: match?.[2] ?? '',
};
}