diff --git a/common/changes/@microsoft/tsdoc/beta-declref-utility_2022-05-16-07-02.json b/common/changes/@microsoft/tsdoc/beta-declref-utility_2022-05-16-07-02.json new file mode 100644 index 00000000..2f619762 --- /dev/null +++ b/common/changes/@microsoft/tsdoc/beta-declref-utility_2022-05-16-07-02.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc", + "comment": "add utility methods to DeclarationReference components", + "type": "minor" + } + ], + "packageName": "@microsoft/tsdoc", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/tsdoc/parse-beta-declref_2022-05-16-07-04.json b/common/changes/@microsoft/tsdoc/parse-beta-declref_2022-05-16-07-04.json new file mode 100644 index 00000000..adf1b168 --- /dev/null +++ b/common/changes/@microsoft/tsdoc/parse-beta-declref_2022-05-16-07-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc", + "comment": "parse beta DeclarationReference in TSDoc", + "type": "minor" + } + ], + "packageName": "@microsoft/tsdoc", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index d21fb58d..96e1815d 100644 --- a/tsdoc/etc/tsdoc.api.md +++ b/tsdoc/etc/tsdoc.api.md @@ -69,7 +69,11 @@ export class DocComment extends DocNode { // @public export class DocDeclarationReference extends DocNode { // @internal - constructor(parameters: IDocDeclarationReferenceParameters | IDocDeclarationReferenceParsedParameters); + constructor(parameters: IDocDeclarationReferenceParameters | IDocDeclarationReferenceParsedParameters | IBetaDocDeclarationReferenceParameters | IBetaDocDeclarationReferenceParsedParameters); + // Warning: (ae-forgotten-export) The symbol "DeclarationReference" needs to be exported by the entry point index.d.ts + // + // @beta + get declarationReference(): DeclarationReference | undefined; emitAsTsdoc(): string; get importPath(): string | undefined; // @override (undocumented) @@ -450,6 +454,8 @@ export enum ExcerptKind { // (undocumented) CodeSpan_OpeningDelimiter = "CodeSpan_OpeningDelimiter", // (undocumented) + DeclarationReference_DeclarationReference = "DeclarationReference_DeclarationReference", + // (undocumented) DeclarationReference_ImportHash = "DeclarationReference_ImportHash", // (undocumented) DeclarationReference_ImportPath = "DeclarationReference_ImportPath", @@ -531,6 +537,20 @@ export enum ExcerptKind { Spacing = "Spacing" } +// @beta +export interface IBetaDocDeclarationReferenceParameters extends IDocNodeParameters { + // (undocumented) + declarationReference: DeclarationReference; +} + +// @beta +export interface IBetaDocDeclarationReferenceParsedParameters extends IDocNodeParsedParameters { + // (undocumented) + declarationReference?: DeclarationReference; + // (undocumented) + declarationReferenceExcerpt: TokenSequence; +} + // @public export interface IDocBlockParameters extends IDocNodeParameters { // (undocumented) @@ -1085,8 +1105,10 @@ export class ParserMessageLog { addMessageForDocErrorText(docErrorText: DocErrorText): void; addMessageForTextRange(messageId: TSDocMessageId, messageText: string, textRange: TextRange): void; addMessageForTokenSequence(messageId: TSDocMessageId, messageText: string, tokenSequence: TokenSequence, docNode?: DocNode): void; + createMarker(): number; get messages(): ReadonlyArray; - } + rollbackToMarker(marker: number): void; +} // @public export class PlainTextEmitter { @@ -1248,6 +1270,9 @@ export class TSDocConfiguration { isHtmlElementSupported(htmlTag: string): boolean; isKnownMessageId(messageId: TSDocMessageId | string): boolean; isTagSupported(tagDefinition: TSDocTagDefinition): boolean; + // (undocumented) + get parseBetaDeclarationReferences(): boolean | 'prefer'; + set parseBetaDeclarationReferences(value: boolean | 'prefer'); setSupportedHtmlElements(htmlTags: string[]): void; setSupportForTag(tagDefinition: TSDocTagDefinition, supported: boolean): void; setSupportForTags(tagDefinitions: ReadonlyArray, supported: boolean): void; diff --git a/tsdoc/src/beta/DeclarationReference.ts b/tsdoc/src/beta/DeclarationReference.ts index 750a8b43..b7b706db 100644 --- a/tsdoc/src/beta/DeclarationReference.ts +++ b/tsdoc/src/beta/DeclarationReference.ts @@ -5,67 +5,110 @@ /* eslint-disable no-inner-declarations */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @rushstack/no-new-null */ // NOTE: See DeclarationReference.grammarkdown for information on the underlying grammar. - +// NOTE: @rushstack/no-new-null is disabled for places where `null` is used as a sentinel to +// indicate explicit non-presence of a value (such as when removing values using `.with()`). + +import { TSDocConfiguration } from '../configuration/TSDocConfiguration'; +import { + DocDeclarationReference, + DocMemberIdentifier, + DocMemberReference, + DocMemberSelector, + DocMemberSymbol +} from '../nodes'; import { StringChecks } from '../parser/StringChecks'; +import { TokenKind, Token as DocToken } from '../parser/Token'; +import { TokenReader } from '../parser/TokenReader'; + +// #region DeclarationReference /** * Represents a reference to a declaration. * @beta */ export class DeclarationReference { - private _source: ModuleSource | GlobalSource | undefined; - private _navigation: Navigation.Locals | Navigation.Exports | undefined; + private _source: Source | undefined; + private _navigation: SourceNavigation | undefined; private _symbol: SymbolReference | undefined; - public constructor( - source?: ModuleSource | GlobalSource, - navigation?: Navigation.Locals | Navigation.Exports, - symbol?: SymbolReference - ) { + public constructor(source?: Source, navigation?: SourceNavigation, symbol?: SymbolReference) { this._source = source; this._navigation = navigation; this._symbol = symbol; } - public get source(): ModuleSource | GlobalSource | undefined { + /** + * Gets the source for the declaration. + */ + public get source(): Source | undefined { return this._source; } - public get navigation(): Navigation.Locals | Navigation.Exports | undefined { - if (!this._source || !this._symbol) { - return undefined; - } - if (this._source === GlobalSource.instance) { - return Navigation.Locals; - } - if (this._navigation === undefined) { - return Navigation.Exports; - } - return this._navigation; + /** + * Gets whether the symbol for the declaration is a local or exported symbol of the source. + */ + public get navigation(): SourceNavigation | undefined { + return resolveNavigation(this._source, this._symbol, this._navigation); } + /** + * Gets the symbol reference for the declaration. + */ public get symbol(): SymbolReference | undefined { return this._symbol; } + /** + * Gets a value indicating whether this {@link DeclarationReference} is empty. + */ public get isEmpty(): boolean { return this.source === undefined && this.symbol === undefined; } - public static parse(text: string): DeclarationReference { - const parser: Parser = new Parser(text); + /** + * Parses a {@link DeclarationReference} from the provided text. + */ + public static parse(source: string): DeclarationReference { + const parser: Parser = new Parser(new TextReader(source)); const reference: DeclarationReference = parser.parseDeclarationReference(); if (parser.errors.length) { - throw new SyntaxError(`Invalid DeclarationReference '${text}':\n ${parser.errors.join('\n ')}`); + throw new SyntaxError(`Invalid DeclarationReference '${source}':\n ${parser.errors.join('\n ')}`); + } else if (!parser.eof) { + throw new SyntaxError(`Invalid DeclarationReference '${source}'`); } - if (!parser.eof) { - throw new SyntaxError(`Invalid DeclarationReference '${text}'`); + return reference; + } + + /** + * Parses a {@link DeclarationReference} from the provided text. + */ + public static tryParse(source: string): DeclarationReference | undefined; + /** + * Parses a {@link DeclarationReference} from the provided text. + * @internal + */ + public static tryParse(source: TokenReader, fallback?: boolean): DeclarationReference | undefined; + public static tryParse(source: string | TokenReader, fallback?: boolean): DeclarationReference | undefined { + const marker: number | undefined = typeof source === 'string' ? undefined : source.createMarker(); + const reader: ICharacterReader = + typeof source === 'string' ? new TextReader(source) : new TokenReaderNormalizer(source); + const parser: Parser = new Parser(reader, fallback); + const reference: DeclarationReference = parser.parseDeclarationReference(); + if (parser.errors.length || (!parser.eof && typeof source === 'string')) { + if (marker !== undefined && typeof source !== 'string') { + source.backtrackToMarker(marker); + } + return undefined; } return reference; } + /** + * Parses a {@link Component} from the provided text. + */ public static parseComponent(text: string): Component { if (text[0] === '[') { return ComponentReference.parse(text); @@ -78,7 +121,7 @@ export class DeclarationReference { * Determines whether the provided string is a well-formed symbol navigation component string. */ public static isWellFormedComponentString(text: string): boolean { - const scanner: Scanner = new Scanner(text); + const scanner: Scanner = new Scanner(new TextReader(text)); return scanner.scan() === Token.String ? scanner.scan() === Token.EofToken : scanner.token() === Token.Text @@ -93,12 +136,14 @@ export class DeclarationReference { public static escapeComponentString(text: string): string { if (text.length === 0) { return '""'; + } else { + const ch: string = text.charAt(0); + if (ch === '[' || ch === '"' || !this.isWellFormedComponentString(text)) { + return JSON.stringify(text); + } else { + return text; + } } - const ch: string = text.charAt(0); - if (ch === '[' || ch === '"' || !this.isWellFormedComponentString(text)) { - return JSON.stringify(text); - } - return text; } /** @@ -111,11 +156,11 @@ export class DeclarationReference { } catch { throw new SyntaxError(`Invalid Component '${text}'`); } - } - if (!this.isWellFormedComponentString(text)) { + } else if (!this.isWellFormedComponentString(text)) { throw new SyntaxError(`Invalid Component '${text}'`); + } else { + return text; } - return text; } /** @@ -123,7 +168,7 @@ export class DeclarationReference { * have a trailing `!` character. */ public static isWellFormedModuleSourceString(text: string): boolean { - const scanner: Scanner = new Scanner(text + '!'); + const scanner: Scanner = new Scanner(new TextReader(text + '!')); return ( scanner.rescanModuleSource() === Token.ModuleSource && !scanner.stringIsUnterminated && @@ -138,12 +183,14 @@ export class DeclarationReference { public static escapeModuleSourceString(text: string): string { if (text.length === 0) { return '""'; + } else { + const ch: string = text.charAt(0); + if (ch === '"' || !this.isWellFormedModuleSourceString(text)) { + return JSON.stringify(text); + } else { + return text; + } } - const ch: string = text.charAt(0); - if (ch === '"' || !this.isWellFormedModuleSourceString(text)) { - return JSON.stringify(text); - } - return text; } /** @@ -156,84 +203,209 @@ export class DeclarationReference { } catch { throw new SyntaxError(`Invalid Module source '${text}'`); } - } - if (!this.isWellFormedModuleSourceString(text)) { + } else if (!this.isWellFormedModuleSourceString(text)) { throw new SyntaxError(`Invalid Module source '${text}'`); + } else { + return text; } - return text; } + /** + * Returns an empty {@link DeclarationReference}. + * + * An alias for `DeclarationReference.from({ })`. + */ public static empty(): DeclarationReference { - return new DeclarationReference(); + return DeclarationReference.from({}); } + /** + * Creates a new {@link DeclarationReference} for the provided package. + * + * An alias for `Declaration.from({ packageName, importPath })`. + */ public static package(packageName: string, importPath?: string): DeclarationReference { - return new DeclarationReference(ModuleSource.fromPackage(packageName, importPath)); + return DeclarationReference.from({ packageName, importPath }); } + /** + * Creates a new {@link DeclarationReference} for the provided module path. + */ public static module(path: string, userEscaped?: boolean): DeclarationReference { return new DeclarationReference(new ModuleSource(path, userEscaped)); } + /** + * Creates a new {@link DeclarationReference} for the global scope. + */ public static global(): DeclarationReference { return new DeclarationReference(GlobalSource.instance); } - public static from(base: DeclarationReference | undefined): DeclarationReference { - return base || this.empty(); + /** + * Creates a new {@link DeclarationReference} from the provided parts. + */ + public static from(parts: DeclarationReferenceLike | undefined): DeclarationReference { + const resolved: ResolvedDeclarationReferenceLike | undefined = resolveDeclarationReferenceLike( + parts, + /*fallbackReference*/ undefined + ); + if (resolved === undefined) { + return new DeclarationReference(); + } else if (resolved instanceof DeclarationReference) { + return resolved; + } else { + const { source, navigation, symbol } = resolved; + return new DeclarationReference( + source === undefined ? undefined : Source.from(source), + navigation, + symbol === undefined ? undefined : SymbolReference.from(symbol) + ); + } + } + + /** + * Returns a {@link DeclarationReference} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * If a part is set to `null`, the part will be removed in the result. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: DeclarationReferenceParts): DeclarationReference { + const { source, navigation, symbol } = resolveDeclarationReferenceParts( + parts, + this.source, + this.navigation, + this.symbol + ); + + const resolvedSource: Source | undefined = source === undefined ? undefined : Source.from(source); + + const resolvedSymbol: SymbolReference | undefined = + symbol === undefined ? undefined : SymbolReference.from(symbol); + + const resolvedNavigation: SourceNavigation | undefined = resolveNavigation( + resolvedSource, + resolvedSymbol, + navigation + ); + + if ( + Source.equals(this.source, resolvedSource) && + SymbolReference.equals(this.symbol, resolvedSymbol) && + this.navigation === resolvedNavigation + ) { + return this; + } else { + return new DeclarationReference(resolvedSource, navigation, resolvedSymbol); + } } - public withSource(source: ModuleSource | GlobalSource | undefined): DeclarationReference { - return this._source === source ? this : new DeclarationReference(source, this._navigation, this._symbol); + /** + * Returns an {@link DeclarationReference} updated with the provided source. + * + * An alias for `declref.with({ source: source ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided source. + */ + public withSource(source: Source | undefined): DeclarationReference { + return this.with({ source: source ?? null }); } - public withNavigation( - navigation: Navigation.Locals | Navigation.Exports | undefined - ): DeclarationReference { - return this._navigation === navigation - ? this - : new DeclarationReference(this._source, navigation, this._symbol); + /** + * Returns an {@link DeclarationReference} updated with the provided navigation. + * + * An alias for `declref.with({ navigation: navigation ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided navigation. + */ + public withNavigation(navigation: SourceNavigation | undefined): DeclarationReference { + return this.with({ navigation: navigation ?? null }); } + /** + * Returns an {@link DeclarationReference} updated with the provided symbol. + * + * An alias for `declref.with({ symbol: symbol ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided symbol. + */ public withSymbol(symbol: SymbolReference | undefined): DeclarationReference { - return this._symbol === symbol ? this : new DeclarationReference(this._source, this._navigation, symbol); + return this.with({ symbol: symbol ?? null }); } - public withComponentPath(componentPath: ComponentPath): DeclarationReference { - return this.withSymbol( - this.symbol ? this.symbol.withComponentPath(componentPath) : new SymbolReference(componentPath) - ); + /** + * Returns an {@link DeclarationReference} whose symbol has been updated with the provided component path. + * + * An alias for `declref.with({ componentPath: componentPath ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided component path. + */ + public withComponentPath(componentPath: ComponentPath | undefined): DeclarationReference { + return this.with({ componentPath: componentPath ?? null }); } + /** + * Returns an {@link DeclarationReference} whose symbol has been updated with the provided meaning. + * + * An alias for `declref.with({ meaning: meaning ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided meaning. + */ public withMeaning(meaning: Meaning | undefined): DeclarationReference { - if (!this.symbol) { - if (meaning === undefined) { - return this; - } - return this.withSymbol(SymbolReference.empty().withMeaning(meaning)); - } - return this.withSymbol(this.symbol.withMeaning(meaning)); + return this.with({ meaning: meaning ?? null }); } + /** + * Returns an {@link DeclarationReference} whose symbol has been updated with the provided overload index. + * + * An alias for `declref.with({ overloadIndex: overloadIndex ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided overload index. + */ public withOverloadIndex(overloadIndex: number | undefined): DeclarationReference { - if (!this.symbol) { - if (overloadIndex === undefined) { - return this; - } - return this.withSymbol(SymbolReference.empty().withOverloadIndex(overloadIndex)); - } - return this.withSymbol(this.symbol.withOverloadIndex(overloadIndex)); + return this.with({ overloadIndex: overloadIndex ?? null }); } - public addNavigationStep(navigation: Navigation, component: ComponentLike): DeclarationReference { + /** + * Returns a new {@link DeclarationReference} whose symbol has been updated to include the provided navigation step in its component path. + * @returns This object if there were no changes; otherwise, a new object updated with the provided navigation step. + */ + public addNavigationStep( + navigation: Navigation, + component: ComponentLike + ): DeclarationReference { if (this.symbol) { return this.withSymbol(this.symbol.addNavigationStep(navigation, component)); + } else { + if (navigation === Navigation.Members) { + navigation = Navigation.Exports; + } + return this.with({ + navigation, + symbol: SymbolReference.from({ componentPath: ComponentRoot.from({ component }) }) + }); } - if (navigation === Navigation.Members) { - navigation = Navigation.Exports; + } + + /** + * Tests whether two {@link DeclarationReference} objects are equivalent. + */ + public static equals( + left: DeclarationReference | undefined, + right: DeclarationReference | undefined + ): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return left.toString() === right.toString(); } - const symbol: SymbolReference = new SymbolReference(new ComponentRoot(Component.from(component))); - return new DeclarationReference(this.source, navigation, symbol); + } + + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: DeclarationReference): boolean { + return DeclarationReference.equals(this, other); } public toString(): string { @@ -245,241 +417,1989 @@ export class DeclarationReference { } } +// #region DeclarationReferenceParts + /** - * Indicates the symbol table from which to resolve the next symbol component. + * A part that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if this part is used by `with()` (which allows `null` for some parts), `false` if this part is used + * by `from()` (which does not allow `null`). + * * @beta */ -export const enum Navigation { - Exports = '.', - Members = '#', - Locals = '~' -} +export type DeclarationReferenceSourcePart = Parts< + With, + { + /** The module or global source for a symbol. */ + source?: Part>; + } +>; /** - * Represents a module. + * Parts that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * * @beta */ -export class ModuleSource { - public readonly escapedPath: string; - private _path: string | undefined; - - private _pathComponents: IParsedPackage | undefined; - - public constructor(path: string, userEscaped: boolean = true) { - this.escapedPath = - this instanceof ParsedModuleSource ? path : escapeModuleSourceIfNeeded(path, userEscaped); - } - - public get path(): string { - return this._path || (this._path = DeclarationReference.unescapeModuleSourceString(this.escapedPath)); - } - - public get packageName(): string { - return this._getOrParsePathComponents().packageName; - } +export type DeclarationReferenceSourceParts = + | DeclarationReferenceSourcePart + | SourceParts; - public get scopeName(): string { - const scopeName: string = this._getOrParsePathComponents().scopeName; - return scopeName ? '@' + scopeName : ''; +/** + * Parts that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type DeclarationReferenceNavigationParts = Parts< + With, + { + /** Indicates whether the symbol is exported or local to the source. */ + navigation?: Part; } +>; - public get unscopedPackageName(): string { - return this._getOrParsePathComponents().unscopedPackageName; +/** + * A part that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if this part is used by `with()` (which allows `null` for some parts), `false` if this part is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type DeclarationReferenceSymbolPart = Parts< + With, + { + /** The referenced symbol. */ + symbol?: Part>; } +>; - public get importPath(): string { - return this._getOrParsePathComponents().importPath || ''; - } +/** + * Parts that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type DeclarationReferenceSymbolParts = + | DeclarationReferenceSymbolPart + | SymbolReferenceParts; - public static fromScopedPackage( - scopeName: string | undefined, - unscopedPackageName: string, - importPath?: string - ): ModuleSource { - let packageName: string = unscopedPackageName; - if (scopeName) { - if (scopeName.charAt(0) === '@') { - scopeName = scopeName.slice(1); - } - packageName = `@${scopeName}/${unscopedPackageName}`; +/** + * Parts that can be used to compose or update a {@link DeclarationReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type DeclarationReferenceParts = DeclarationReferenceSourceParts & + DeclarationReferenceNavigationParts & + DeclarationReferenceSymbolParts; + +function resolveDeclarationReferenceSource( + parts: DeclarationReferenceSourceParts, + fallbackSource: Source | undefined +): SourceLike | undefined { + // If `source` is neither `null` or `undefined`, returns the resolved source-like. + // If `source` is `null`, returns `undefined` (which removes `source` from the updated DeclarationReference). + // If `packageName`, `scopeName`, `unscopedPackageName`, or `importPath` are present, returns the resolved `ModuleSourceParts` for those properties. + // If `source` is `undefined`, assumes no change and returns `fallbackSource`. + const { source, packageName, scopeName, unscopedPackageName, importPath } = parts as AllParts; + if (source !== undefined) { + if (packageName !== undefined) { + throw new TypeError(`Cannot specify both 'source' and 'packageName'`); + } else if (scopeName !== undefined) { + throw new TypeError(`Cannot specify both 'source' and 'scopeName'`); + } else if (unscopedPackageName !== undefined) { + throw new TypeError(`Cannot specify both 'source' and 'unscopedPackageName'`); + } else if (importPath !== undefined) { + throw new TypeError(`Cannot specify both 'source' and 'importPath'`); } - - const parsed: IParsedPackage = { packageName, scopeName: scopeName || '', unscopedPackageName }; - return this._fromPackageName(parsed, packageName, importPath); + if (source === null) { + return undefined; + } else { + return resolveSourceLike(source, fallbackSource); + } + } else if ( + packageName !== undefined || + scopeName !== undefined || + unscopedPackageName !== undefined || + importPath !== undefined + ) { + return resolveModuleSourceParts( + parts as ModuleSourceParts, + tryCast(fallbackSource, ModuleSource)?.['_getOrParsePathComponents']() + ); + } else { + return fallbackSource; } +} - public static fromPackage(packageName: string, importPath?: string): ModuleSource { - return this._fromPackageName(parsePackageName(packageName), packageName, importPath); +function resolveDeclarationReferenceNavigation( + parts: DeclarationReferenceNavigationParts, + fallbackNavigation: SourceNavigation | undefined +): SourceNavigation | undefined { + // If `navigation` is neither `null` nor `undefined`, returns `navigation`. + // If `navigation` is `null`, returns `undefined` (which removes `navigation` from the updated DeclarationReference). + // If `navigation` is `undeifned`, returns `fallbackNavigation`. + const { navigation } = parts; + if (navigation !== undefined) { + if (navigation === null) { + return undefined; + } else { + return navigation; + } + } else { + return fallbackNavigation; } +} - private static _fromPackageName( - parsed: IParsedPackage | null, - packageName: string, - importPath?: string - ): ModuleSource { - if (!parsed) { - throw new Error('Parsed package must be provided.'); +function resolveDeclarationReferenceSymbol( + parts: DeclarationReferenceSymbolParts, + fallbackSymbol: SymbolReference | undefined +): SymbolReferenceLike | undefined { + // If `symbol` is neither `null` or `undefined`, returns the resolved symbol-reference-like. + // If `symbol` is `null`, returns `undefined` (which removes `symbol` from the updated DeclarationReference). + // If `componentPath`, `meaning`, or `overloadIndex` are present, returns the resolved `SymbolReferenceParts` for those properties. + // If `symbol` is `undefined`, assumes no change and returns `fallbackSymbol`. + const { symbol, componentPath, meaning, overloadIndex } = parts as AllParts; + if (symbol !== undefined) { + if (componentPath !== undefined) { + throw new TypeError(`Cannot specify both 'symbol' and 'componentPath'`); + } else if (meaning !== undefined) { + throw new TypeError(`Cannot specify both 'symbol' and 'meaning'`); + } else if (overloadIndex !== undefined) { + throw new TypeError(`Cannot specify both 'symbol' and 'overloadIndex'`); } - - const packageNameError: string | undefined = StringChecks.explainIfInvalidPackageName(packageName); - if (packageNameError) { - throw new SyntaxError(`Invalid NPM package name: ${packageNameError}`); + if (symbol === null) { + return undefined; + } else { + return resolveSymbolReferenceLike(symbol, fallbackSymbol); } + } else if (componentPath !== undefined || meaning !== undefined || overloadIndex !== undefined) { + return resolveSymbolReferenceLike(parts as SymbolReferenceParts, fallbackSymbol); + } else { + return fallbackSymbol; + } +} - let path: string = packageName; - if (importPath) { - if (invalidImportPathRegExp.test(importPath)) { - throw new SyntaxError(`Invalid import path '${importPath}`); - } - path += '/' + importPath; - parsed.importPath = importPath; - } +type ResolvedDeclarationReferenceParts = DeclarationReferenceSourcePart & + DeclarationReferenceNavigationParts & + DeclarationReferenceSymbolPart; + +function resolveDeclarationReferenceParts( + parts: DeclarationReferenceParts, + fallbackSource: Source | undefined, + fallbackNavigation: Navigation.Exports | Navigation.Locals | undefined, + fallbackSymbol: SymbolReference | undefined +): ResolvedDeclarationReferenceParts { + return { + source: resolveDeclarationReferenceSource(parts, fallbackSource), + navigation: resolveDeclarationReferenceNavigation(parts, fallbackNavigation), + symbol: resolveDeclarationReferenceSymbol(parts, fallbackSymbol) + }; +} - const source: ModuleSource = new ModuleSource(path); - source._pathComponents = parsed; - return source; - } +// #endregion DeclarationReferenceParts - public toString(): string { - return `${this.escapedPath}!`; +/** + * A value that can be resolved to a {@link DeclarationReference}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type DeclarationReferenceLike = + | DeclarationReference + | DeclarationReferenceParts + | string; + +type ResolvedDeclarationReferenceLike = DeclarationReference | ResolvedDeclarationReferenceParts; + +function resolveDeclarationReferenceLike( + reference: DeclarationReferenceLike | undefined, + fallbackReference: DeclarationReference | undefined +): ResolvedDeclarationReferenceLike | undefined { + if (reference === undefined) { + return undefined; + } else if (reference instanceof DeclarationReference) { + return reference; + } else if (typeof reference === 'string') { + return DeclarationReference.parse(reference); + } else { + return resolveDeclarationReferenceParts( + reference, + fallbackReference?.source, + fallbackReference?.navigation, + fallbackReference?.symbol + ); } +} - private _getOrParsePathComponents(): IParsedPackage { - if (!this._pathComponents) { - const path: string = this.path; - const parsed: IParsedPackage | null = parsePackageName(path); - if (parsed && !StringChecks.explainIfInvalidPackageName(parsed.packageName)) { - this._pathComponents = parsed; - } else { - this._pathComponents = { - packageName: '', - scopeName: '', - unscopedPackageName: '', - importPath: path - }; - } - } - return this._pathComponents; +function resolveNavigation( + source: Source | undefined, + symbol: SymbolReference | undefined, + navigation: SourceNavigation | undefined +): SourceNavigation | undefined { + if (!source || !symbol) { + return undefined; + } else if (source === GlobalSource.instance) { + return Navigation.Locals; + } else if (navigation === undefined) { + return Navigation.Exports; + } else { + return navigation; } } -class ParsedModuleSource extends ModuleSource {} +// #endregion DeclarationReference -// matches the following: -// 'foo' -> ["foo", "foo", undefined, "foo", undefined] -// 'foo/bar' -> ["foo/bar", "foo", undefined, "foo", "bar"] -// '@scope/foo' -> ["@scope/foo", "@scope/foo", "scope", "foo", undefined] -// '@scope/foo/bar' -> ["@scope/foo/bar", "@scope/foo", "scope", "foo", "bar"] -// does not match: -// '/' -// '@/' -// '@scope/' -// capture groups: -// 1. The package name (including scope) -// 2. The scope name (excluding the leading '@') -// 3. The unscoped package name -// 4. The package-relative import path -const packageNameRegExp: RegExp = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.+))?$/; - -// no leading './' or '.\' -// no leading '../' or '..\' -// no leading '/' or '\' -// not '.' or '..' -const invalidImportPathRegExp: RegExp = /^(\.\.?([\\/]|$)|[\\/])/; +// #region SourceBase -interface IParsedPackage { - packageName: string; - scopeName: string; - unscopedPackageName: string; - importPath?: string; -} +/** + * Abstract base class for the source of a {@link DeclarationReference}. + * @beta + */ +export abstract class SourceBase { + public abstract readonly kind: string; -// eslint-disable-next-line @rushstack/no-new-null -function parsePackageName(text: string): IParsedPackage | null { - const match: RegExpExecArray | null = packageNameRegExp.exec(text); - if (!match) { - return match; + /** + * Combines this source with the provided parts to create a new {@link DeclarationReference}. + */ + public toDeclarationReference( + this: Source, + parts?: DeclarationReferenceNavigationParts & + DeclarationReferenceSymbolParts + ): DeclarationReference { + return DeclarationReference.from({ ...parts, source: this }); } - const [, packageName = '', scopeName = '', unscopedPackageName = '', importPath]: RegExpExecArray = match; - return { packageName, scopeName, unscopedPackageName, importPath }; + + public abstract toString(): string; } +// #endregion SourceBase + +// #region GlobalSource + /** * Represents the global scope. * @beta */ -export class GlobalSource { +export class GlobalSource extends SourceBase { + /** + * A singleton instance of {@link GlobalSource}. + */ public static readonly instance: GlobalSource = new GlobalSource(); - private constructor() {} + public readonly kind: 'global-source' = 'global-source'; + + private constructor() { + super(); + } public toString(): string { return '!'; } } -/** - * @beta - */ -export type Component = ComponentString | ComponentReference; - -/** - * @beta - */ -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace Component { - export function from(value: ComponentLike): Component { - if (typeof value === 'string') { - return new ComponentString(value); - } - if (value instanceof DeclarationReference) { - return new ComponentReference(value); - } - return value; - } -} +// #endregion GlobalSource -/** - * @beta - */ -export type ComponentLike = Component | DeclarationReference | string; +// #region ModuleSource /** + * Represents a module source. * @beta */ -export class ComponentString { - public readonly text: string; +export class ModuleSource extends SourceBase { + public readonly kind: 'module-source' = 'module-source'; - public constructor(text: string, userEscaped?: boolean) { - this.text = this instanceof ParsedComponentString ? text : escapeComponentIfNeeded(text, userEscaped); - } + private _escapedPath: string; + private _path: string | undefined; + private _pathComponents: IParsedPackage | undefined; + private _packageName: string | undefined; + + /** + * @param path The module source path, including the package name. + * @param userEscaped If `false`, escapes `path` if needed. If `true` (default), validates `path` is already escaped. + */ + public constructor(path: string, userEscaped: boolean = true) { + super(); + this._escapedPath = escapeModuleSourceIfNeeded(path, this instanceof ParsedModuleSource, userEscaped); + } + + /** + * A canonically escaped module source string. + */ + public get escapedPath(): string { + return this._escapedPath; + } + + /** + * An unescaped module source string. + */ + public get path(): string { + return this._path !== undefined + ? this._path + : (this._path = DeclarationReference.unescapeModuleSourceString(this.escapedPath)); + } + + /** + * The full name of the module's package, such as `typescript` or `@microsoft/api-extractor`. + */ + public get packageName(): string { + if (this._packageName === undefined) { + const parsed: IParsedPackage = this._getOrParsePathComponents(); + this._packageName = formatPackageName(parsed.scopeName, parsed.unscopedPackageName); + } + return this._packageName; + } + + /** + * Returns the scope portion of a scoped package name (i.e., `@scope` in `@scope/package`). + */ + public get scopeName(): string { + return this._getOrParsePathComponents().scopeName ?? ''; + } + + /** + * Returns the non-scope portion of a scoped package name (i.e., `package` in `@scope/package`, or `typescript` in `typescript`). + */ + public get unscopedPackageName(): string { + return this._getOrParsePathComponents().unscopedPackageName; + } + + /** + * Returns the package-relative import path of a module source (i.e., `path/to/file` in `packageName/path/to/file`). + */ + public get importPath(): string { + return this._getOrParsePathComponents().importPath ?? ''; + } + + /** + * Creates a new {@link ModuleSource} from the supplied parts. + */ + public static from(parts: ModuleSourceLike): ModuleSource { + const resolved: ResolvedModuleSourceLike = resolveModuleSourceLike(parts, /*fallbackSource*/ undefined); + if (resolved instanceof ModuleSource) { + return resolved; + } else { + const source: ModuleSource = new ModuleSource( + formatModuleSource(resolved.scopeName, resolved.unscopedPackageName, resolved.importPath) + ); + source._pathComponents = resolved; + return source; + } + } + + /** + * Creates a new {@link ModuleSource} for a scoped package. + * + * An alias for `ModuleSource.from({ scopeName, unscopedPackageName, importPath })`. + */ + public static fromScopedPackage( + scopeName: string | undefined, + unscopedPackageName: string, + importPath?: string + ): ModuleSource { + return ModuleSource.from({ scopeName, unscopedPackageName, importPath }); + } + + /** + * Creates a new {@link ModuleSource} for package. + */ + public static fromPackage(packageName: string, importPath?: string): ModuleSource { + return ModuleSource.from({ packageName, importPath }); + } + + /** + * Gets a {@link ModuleSource} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * If a part is set to `null`, the part will be removed in the result. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: ModuleSourceParts): ModuleSource { + const current: IParsedPackage = this._getOrParsePathComponents(); + const parsed: IParsedPackage = resolveModuleSourceParts(parts, current); + if ( + parsed.scopeName === current.scopeName && + parsed.unscopedPackageName === current.unscopedPackageName && + parsed.importPath === current.importPath + ) { + return this; + } else { + const source: ModuleSource = new ModuleSource( + formatModuleSource(parsed.scopeName, parsed.unscopedPackageName, parsed.importPath) + ); + source._pathComponents = parsed; + return source; + } + } + + /** + * Tests whether two {@link ModuleSource} values are equivalent. + */ + public static equals(left: ModuleSource | undefined, right: ModuleSource | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return left.packageName === right.packageName && left.importPath === right.importPath; + } + } + + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: ModuleSource): boolean { + return ModuleSource.equals(this, other); + } + + public toString(): string { + return `${this.escapedPath}!`; + } + + private _getOrParsePathComponents(): IParsedPackage { + if (!this._pathComponents) { + const path: string = this.path; + const parsed: IParsedPackage | null = tryParsePackageName(path); + if ( + parsed && + !StringChecks.explainIfInvalidPackageName( + formatPackageName(parsed.scopeName, parsed.unscopedPackageName) + ) + ) { + this._pathComponents = parsed; + } else { + this._pathComponents = { + scopeName: undefined, + unscopedPackageName: '', + importPath: path + }; + } + } + return this._pathComponents; + } +} + +class ParsedModuleSource extends ModuleSource { + public constructor(text: string, userEscaped?: boolean) { + super(text, userEscaped); + try { + setPrototypeOf?.(this, ModuleSource.prototype); + } catch { + // ignored + } + } +} + +// matches the following: +// 'foo' -> ["foo", undefined, "foo", undefined] +// 'foo/bar' -> ["foo/bar", undefined, "foo", "bar"] +// '@scope/foo' -> ["@scope/foo", "scope", "foo", undefined] +// '@scope/foo/bar' -> ["@scope/foo/bar", "scope", "foo", "bar"] +// does not match: +// '/' +// '@/' +// '@scope/' +// capture groups: +// 1. The scope name (including the leading '@') +// 2. The unscoped package name +// 3. The package-relative import path +const packageNameRegExp: RegExp = /^(?:(@[^/]+?)\/)?([^/]+?)(?:\/(.+))?$/; + +// no leading './' or '.\' +// no leading '../' or '..\' +// no leading '/' or '\' +// not '.' or '..' +const invalidImportPathRegExp: RegExp = /^(\.\.?([\\/]|$)|[\\/])/; + +interface IParsedPackage { + scopeName: string | undefined; + unscopedPackageName: string; + importPath: string | undefined; +} + +function parsePackageName(text: string): IParsedPackage { + const parsed: IParsedPackage | null = tryParsePackageName(text); + if (!parsed) { + throw new SyntaxError(`Invalid NPM package name: The package name ${JSON.stringify(text)} was invalid`); + } + + const packageNameError: string | undefined = StringChecks.explainIfInvalidPackageName( + formatPackageName(parsed.scopeName, parsed.unscopedPackageName) + ); + if (packageNameError !== undefined) { + throw new SyntaxError(packageNameError); + } + + if (parsed.importPath && invalidImportPathRegExp.test(parsed.importPath)) { + throw new SyntaxError(`Invalid import path ${JSON.stringify(parsed.importPath)}`); + } + + return parsed; +} + +function tryParsePackageName(text: string): IParsedPackage | null { + const match: RegExpExecArray | null = packageNameRegExp.exec(text); + if (!match) { + return match; + } + const [, scopeName, unscopedPackageName = '', importPath]: RegExpExecArray = match; + return { scopeName, unscopedPackageName, importPath }; +} + +function formatPackageName(scopeName: string | undefined, unscopedPackageName: string | undefined): string { + let packageName: string = ''; + if (unscopedPackageName) { + packageName = unscopedPackageName; + if (scopeName) { + packageName = `${scopeName}/${packageName}`; + } + } + return packageName; +} + +function parseModuleSource(text: string): IParsedPackage { + if (text.slice(-1) === '!') { + text = text.slice(0, -1); + } + return parsePackageName(text); +} + +function formatModuleSource( + scopeName: string | undefined, + unscopedPackageName: string | undefined, + importPath: string | undefined +): string { + let path: string = formatPackageName(scopeName, unscopedPackageName); + if (importPath) { + path += '/' + importPath; + } + return path; +} + +/** + * Parts that can be used to compose or update a {@link ModuleSource}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ModuleSourceParts = Parts< + With, + | { + /** The full name of the package. */ + packageName: string; + + /** A package relative import path. */ + importPath?: Part; + } + | { + /** The scope name for a scoped package. */ + scopeName?: Part; + + /** The unscoped package name for a scoped package, or a package name that must not contain a scope. */ + unscopedPackageName: string; + + /** A package relative import path. */ + importPath?: Part; + } + | { + /** A package relative import path. */ + importPath: string; + } +>; + +function resolveModuleSourceParts( + parts: ModuleSourceParts, + fallback: IParsedPackage | undefined +): IParsedPackage { + const { scopeName, unscopedPackageName, packageName, importPath } = parts as AllParts; + if (scopeName !== undefined) { + // If we reach this branch, we're defining a scoped package + + // verify parts aren't incompatible + if (packageName !== undefined) { + throw new TypeError("Cannot specify 'packageName' with 'scopeName', use 'unscopedPackageName' instead"); + } + + // validate `scopeName` + const newScopeName: string | undefined = scopeName ? ensureScopeName(scopeName) : undefined; + if (newScopeName !== undefined) { + const scopeNameError: string | undefined = StringChecks.explainIfInvalidPackageScope(newScopeName); + if (scopeNameError !== undefined) { + throw new SyntaxError(`Invalid NPM package name: ${scopeNameError}`); + } + } + + const newUnscopedPackageName: string | undefined = unscopedPackageName ?? fallback?.unscopedPackageName; + if (newUnscopedPackageName === undefined) { + throw new TypeError( + "If either 'scopeName' or 'unscopedPackageName' are specified, both must be present" + ); + } + + const unscopedPackageNameError: string | undefined = StringChecks.explainIfInvalidUnscopedPackageName( + newUnscopedPackageName + ); + if (unscopedPackageNameError !== undefined) { + throw new SyntaxError(`Invalid NPM package name: ${unscopedPackageNameError}`); + } + + if (typeof importPath === 'string' && invalidImportPathRegExp.test(importPath)) { + throw new SyntaxError(`Invalid import path ${JSON.stringify(importPath)}`); + } + + const newImportPath: string | undefined = + typeof importPath === 'string' + ? importPath + : importPath === undefined + ? fallback?.importPath + : undefined; + + return { + scopeName: newScopeName, + unscopedPackageName: newUnscopedPackageName, + importPath: newImportPath + }; + } else if (unscopedPackageName !== undefined) { + // If we reach this branch, we're either: + // - creating an unscoped package + // - updating the non-scoped part of a scoped package + // - updating the package name of a non-scoped package + + // verify parts aren't incompatible + if (packageName !== undefined) { + throw new TypeError("Cannot specify both 'packageName' and 'unscopedPackageName'"); + } + + const unscopedPackageNameError: string | undefined = StringChecks.explainIfInvalidUnscopedPackageName( + unscopedPackageName + ); + if (unscopedPackageNameError !== undefined) { + throw new SyntaxError(`Invalid NPM package name: ${unscopedPackageNameError}`); + } + + if (typeof importPath === 'string' && invalidImportPathRegExp.test(importPath)) { + throw new SyntaxError(`Invalid import path ${JSON.stringify(importPath)}`); + } + + const newScopeName: string | undefined = fallback?.scopeName; + + const newImportPath: string | undefined = + typeof importPath === 'string' + ? importPath + : importPath === undefined + ? fallback?.importPath + : undefined; + + return { + scopeName: newScopeName, + unscopedPackageName, + importPath: newImportPath + }; + } else if (packageName !== undefined) { + // If we reach this branch, we're creating a possibly scoped or unscoped package + + // parse and verify package + const parsed: IParsedPackage = parsePackageName(packageName); + if (importPath !== undefined) { + // verify parts aren't incompatible. + if (parsed.importPath !== undefined) { + throw new TypeError("Cannot specify 'importPath' if 'packageName' contains a path"); + } + // validate `importPath` + if (typeof importPath === 'string' && invalidImportPathRegExp.test(importPath)) { + throw new SyntaxError(`Invalid import path ${JSON.stringify(importPath)}`); + } + parsed.importPath = importPath ?? undefined; + } else if (parsed.importPath === undefined) { + parsed.importPath = fallback?.importPath; + } + return parsed; + } else if (importPath !== undefined) { + // If we reach this branch, we're creating a path without a package scope + + if (fallback?.unscopedPackageName) { + if (typeof importPath === 'string' && invalidImportPathRegExp.test(importPath)) { + throw new SyntaxError(`Invalid import path ${JSON.stringify(importPath)}`); + } + } + + return { + scopeName: fallback?.scopeName, + unscopedPackageName: fallback?.unscopedPackageName ?? '', + importPath: importPath ?? undefined + }; + } else if (fallback !== undefined) { + return fallback; + } else { + throw new TypeError( + "You must specify either 'packageName', 'importPath', or both 'scopeName' and 'unscopedPackageName'" + ); + } +} + +/** + * A value that can be resolved to a {@link ModuleSource}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type ModuleSourceLike = ModuleSourceParts | ModuleSource | string; + +type ResolvedModuleSourceLike = ModuleSource | IParsedPackage; + +function resolveModuleSourceLike( + source: ModuleSourceLike, + fallbackSource: ModuleSource | undefined +): ResolvedModuleSourceLike { + if (source instanceof ModuleSource) { + return source; + } else if (typeof source === 'string') { + return parseModuleSource(source); + } else { + return resolveModuleSourceParts(source, fallbackSource); + } +} + +// #endregion ModuleSource + +// #region Source + +/** + * A valid source in a {@link DeclarationReference}. + * @beta + */ +export type Source = GlobalSource | ModuleSource; + +/** + * @beta + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Source { + /** + * Creates a {@link Source} from the provided parts. + */ + export function from(parts: SourceLike): Source { + const resolved: ResolvedSourceLike = resolveSourceLike(parts, /*fallbackSource*/ undefined); + if (resolved instanceof GlobalSource || resolved instanceof ModuleSource) { + return resolved; + } else { + const source: ModuleSource = new ModuleSource( + formatModuleSource(resolved.scopeName, resolved.unscopedPackageName, resolved.importPath) + ); + source['_pathComponents'] = resolved; + return source; + } + } + + /** + * Tests whether two {@link Source} objects are equivalent. + */ + export function equals(left: Source | undefined, right: Source | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else if (left instanceof GlobalSource) { + return right instanceof GlobalSource; + } else if (right instanceof GlobalSource) { + return left instanceof GlobalSource; + } else { + return ModuleSource.equals(left, right); + } + } +} + +/** + * Parts that can be used to compose or update a {@link Source}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type SourceParts = ModuleSourceParts; + +type ResolvedSourceParts = IParsedPackage; + +function resolveSourceParts( + parts: SourceParts, + fallbackSource: Source | undefined +): ResolvedSourceParts { + return resolveModuleSourceParts(parts, tryCast(fallbackSource, ModuleSource)); +} + +/** + * A value that can be resolved to a {@link Source}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type SourceLike = GlobalSource | ModuleSourceLike; + +type ResolvedSourceLike = Source | ResolvedSourceParts; + +function resolveSourceLike( + source: SourceLike, + fallbackSource: Source | undefined +): ResolvedSourceLike { + if (source instanceof ModuleSource || source instanceof GlobalSource) { + return source; + } else if (source === '!') { + return GlobalSource.instance; + } else if (typeof source === 'string') { + return parseModuleSource(source); + } else { + return resolveSourceParts(source, fallbackSource); + } +} + +// #endregion Source + +// #region SymbolReference + +/** + * Represents a reference to a TypeScript symbol. + * @beta + */ +export class SymbolReference { + public readonly componentPath: ComponentPath | undefined; + public readonly meaning: Meaning | undefined; + public readonly overloadIndex: number | undefined; + + public constructor( + component: ComponentPath | undefined, + { meaning, overloadIndex }: Pick, 'meaning' | 'overloadIndex'> = {} + ) { + this.componentPath = component; + this.meaning = meaning; + this.overloadIndex = overloadIndex; + } + + /** + * Gets whether this reference does not contain a `componentPath`, `meaning`, or `overloadIndex`. + */ + public get isEmpty(): boolean { + return this.componentPath === undefined && this.overloadIndex === undefined && this.meaning === undefined; + } + + /** + * Creates an empty {@link SymbolReference}. + */ + public static empty(): SymbolReference { + return new SymbolReference(/*component*/ undefined); + } + + /** + * Parses a {@link SymbolReference} from the supplied text. + */ + public static parse(text: string): SymbolReference { + const parser: Parser = new Parser(new TextReader(text)); + const symbol: SymbolReference | undefined = parser.tryParseSymbolReference(); + if (parser.errors.length) { + throw new SyntaxError(`Invalid SymbolReference '${text}':\n ${parser.errors.join('\n ')}`); + } else if (!parser.eof || symbol === undefined) { + throw new SyntaxError(`Invalid SymbolReference '${text}'`); + } else { + return symbol; + } + } + + /** + * Attempts to parse a {@link SymbolReference} from the supplied text. Returns `undefined` if parsing + * fails rather than throwing an error. + */ + public static tryParse(text: string): SymbolReference | undefined { + const parser: Parser = new Parser(new TextReader(text)); + const symbol: SymbolReference | undefined = parser.tryParseSymbolReference(); + if (!parser.errors.length && parser.eof) { + return symbol; + } + } + + /** + * Creates a new {@link SymbolReference} from the provided parts. + */ + public static from(parts: SymbolReferenceLike | undefined): SymbolReference { + const resolved: ResolvedSymbolReferenceLike | undefined = resolveSymbolReferenceLike( + parts, + /*fallbackSymbol*/ undefined + ); + if (resolved === undefined) { + return new SymbolReference(/*component*/ undefined); + } else if (typeof resolved === 'string') { + return SymbolReference.parse(resolved); + } else if (resolved instanceof SymbolReference) { + return resolved; + } else { + const { componentPath, meaning, overloadIndex } = resolved; + return new SymbolReference( + componentPath === undefined ? undefined : ComponentPath.from(componentPath), + { meaning, overloadIndex } + ); + } + } + + /** + * Returns a {@link SymbolReference} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * If a part is set to `null`, the part will be removed in the result. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: SymbolReferenceParts): SymbolReference { + const { componentPath, meaning, overloadIndex } = resolveSymbolReferenceParts( + parts, + this.componentPath, + this.meaning, + this.overloadIndex + ); + const resolvedComponentPath: ComponentPath | undefined = + componentPath === undefined ? undefined : ComponentPath.from(componentPath); + if ( + ComponentPath.equals(this.componentPath, resolvedComponentPath) && + this.meaning === meaning && + this.overloadIndex === overloadIndex + ) { + return this; + } else { + return new SymbolReference(resolvedComponentPath, { meaning, overloadIndex }); + } + } + + /** + * Gets a {@link SymbolReference} updated with the provided component path. + * + * An alias for `symbol.with({ componentPath: componentPath ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided component path. + */ + public withComponentPath(componentPath: ComponentPath | undefined): SymbolReference { + return this.with({ componentPath: componentPath ?? null }); + } + + /** + * Gets a {@link SymbolReference} updated with the provided meaning. + * + * An alias for `symbol.with({ meaning: meaning ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided meaning. + */ + public withMeaning(meaning: Meaning | undefined): SymbolReference { + return this.with({ meaning: meaning ?? null }); + } + + /** + * Gets a {@link SymbolReference} updated with the provided overload index. + * + * An alias for `symbol.with({ overloadIndex: overloadIndex ?? null })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided overload index. + */ + public withOverloadIndex(overloadIndex: number | undefined): SymbolReference { + return this.with({ overloadIndex: overloadIndex ?? null }); + } + + /** + * Combines this {@link SymbolReference} with the provided {@link Source} to create a {@link DeclarationReference}. + * + * An alias for `symbol.toDeclarationReference({ source })`. + */ + public withSource(source: Source | undefined): DeclarationReference { + return this.toDeclarationReference({ source }); + } + + /** + * Creates a new {@link SymbolReference} that navigates from this {@link SymbolReference} to the provided {@link Component}. + */ + public addNavigationStep( + navigation: Navigation, + component: ComponentLike + ): SymbolReference { + if (!this.componentPath) { + throw new Error('Cannot add a navigation step to an empty symbol reference.'); + } + return new SymbolReference(this.componentPath.addNavigationStep(navigation, component)); + } + + /** + * Tests whether two {@link SymbolReference} values are equivalent. + */ + public static equals(left: SymbolReference | undefined, right: SymbolReference | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return ( + ComponentPath.equals(left.componentPath, right.componentPath) && + left.meaning === right.meaning && + left.overloadIndex === right.overloadIndex + ); + } + } + + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: SymbolReference): boolean { + return SymbolReference.equals(this, other); + } + + /** + * Combines this {@link SymbolReference} with the provided parts to create a {@link DeclarationReference}. + */ + public toDeclarationReference( + parts?: DeclarationReferenceSourceParts & + DeclarationReferenceNavigationParts + ): DeclarationReference { + return DeclarationReference.from({ ...parts, symbol: this }); + } + + public toString(): string { + let result: string = `${this.componentPath || ''}`; + if (this.meaning && this.overloadIndex !== undefined) { + result += `:${this.meaning}(${this.overloadIndex})`; + } else if (this.meaning) { + result += `:${this.meaning}`; + } else if (this.overloadIndex !== undefined) { + result += `:${this.overloadIndex}`; + } + return result; + } + + /** + * Creates an array of {@link DocMemberReference} objects from this symbol. + * @internal + */ + public toDocMemberReferences(configuration: TSDocConfiguration): DocMemberReference[] { + const memberReferences: DocMemberReference[] = []; + if (this.componentPath) { + let componentRoot: ComponentPath = this.componentPath; + const componentPathRev: ComponentNavigation[] = []; + while (componentRoot instanceof ComponentNavigation) { + componentPathRev.push(componentRoot); + componentRoot = componentRoot.parent; + } + + const selector: DocMemberSelector | undefined = + componentPathRev.length === 0 + ? meaningToSelector(configuration, this.meaning, undefined, this.overloadIndex) + : undefined; + + memberReferences.push( + componentToDocMemberReference(configuration, /*hasDot*/ false, componentRoot.component, selector) + ); + + for (let i: number = componentPathRev.length - 1; i >= 0; i--) { + const segment: ComponentNavigation = componentPathRev[i]; + + const selector: DocMemberSelector | undefined = + i === 0 + ? meaningToSelector(configuration, this.meaning, segment.navigation, this.overloadIndex) + : undefined; + + memberReferences.push( + componentToDocMemberReference(configuration, /*hasDot*/ true, segment.component, selector) + ); + } + } + + return memberReferences; + } +} + +/** + * Parts used to compose or update a {@link SymbolReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type SymbolReferenceParts = Parts< + With, + { + /** The component path for the symbol */ + componentPath?: Part>; + + /** The meaning of the symbol */ + meaning?: Part; + + /** The overload index of the symbol */ + overloadIndex?: Part; + } +>; + +function resolveSymbolReferenceParts( + parts: SymbolReferenceParts, + fallbackComponentPath: ComponentPath | undefined, + fallbackMeaning: Meaning | undefined, + fallbackOverloadIndex: number | undefined +): SymbolReferenceParts { + const { componentPath, meaning = fallbackMeaning, overloadIndex = fallbackOverloadIndex } = parts; + return { + componentPath: + componentPath === null + ? undefined + : componentPath === undefined + ? fallbackComponentPath + : resolveComponentPathLike(componentPath, fallbackComponentPath), + meaning: meaning ?? undefined, + overloadIndex: overloadIndex ?? undefined + }; +} + +/** + * A value that can be resolved to a {@link SymbolReference}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type SymbolReferenceLike = string | SymbolReference | SymbolReferenceParts; + +type ResolvedSymbolReferenceLike = string | SymbolReference | SymbolReferenceParts; + +function resolveSymbolReferenceLike( + symbol: SymbolReferenceLike | undefined, + fallbackSymbol: SymbolReference | undefined +): ResolvedSymbolReferenceLike | undefined { + if (symbol === undefined || symbol instanceof SymbolReference || typeof symbol === 'string') { + return symbol; + } else { + const resolved: SymbolReferenceParts = resolveSymbolReferenceParts( + symbol, + fallbackSymbol?.componentPath, + fallbackSymbol?.meaning, + fallbackSymbol?.overloadIndex + ); + if ( + resolved.componentPath !== undefined || + resolved.meaning !== undefined || + resolved.overloadIndex !== undefined || + fallbackSymbol !== undefined + ) { + return resolved; + } + } +} + +// #endregion SymbolReference + +// #region ComponentPathBase + +/** + * Abstract base class for a part of {@link ComponentPath}. + * @beta + */ +export abstract class ComponentPathBase { + public abstract readonly kind: string; + public readonly component: Component; + + private declare _: never; // NOTE: This makes a ComponentPath compare nominally rather than structurally which removes its properties from completions in `ComponentPath.from({ ... })` + + public constructor(component: Component) { + this.component = component; + } + + /** + * Gets the {@link ComponentRoot} at the root of the component path. + */ + public abstract get root(): ComponentRoot; + + /** + * Creates a new {@link ComponentNavigation} step that navigates from this {@link ComponentPath} to the provided component. + */ + public addNavigationStep( + this: ComponentPath, + navigation: Navigation, + component: ComponentLike + ): ComponentNavigation { + // tslint:disable-next-line:no-use-before-declare + return new ComponentNavigation(this, navigation, Component.from(component)); + } + + /** + * Combines this {@link ComponentPath} with a {@link Meaning} to create a new {@link SymbolReference}. + * + * An alias for `componentPath.toSymbolReference({ meaning })`. + */ + public withMeaning(this: ComponentPath, meaning: Meaning | undefined): SymbolReference { + return this.toSymbolReference({ meaning }); + } + + /** + * Combines this {@link ComponentPath} with an overload index to create a new {@link SymbolReference}. + * + * An alias for `componentPath.toSymbolReference({ overloadIndex })`. + */ + public withOverloadIndex(this: ComponentPath, overloadIndex: number | undefined): SymbolReference { + return this.toSymbolReference({ overloadIndex }); + } + + /** + * Combines this {@link ComponentPath} with a {@link Source} to create a new {@link DeclarationReference}. + * + * An alias for `componentPath.toDeclarationReference({ source })`. + */ + public withSource(this: ComponentPath, source: Source | undefined): DeclarationReference { + return this.toDeclarationReference({ source }); + } + + /** + * Combines this {@link ComponentPath} with the provided parts to create a new {@link SymbolReference}. + */ + public toSymbolReference( + this: ComponentPath, + parts?: Omit, 'componentPath' | 'component'> + ): SymbolReference { + return SymbolReference.from({ ...parts, componentPath: this }); + } + + /** + * Combines this {@link ComponentPath} with the provided parts to create a new {@link DeclarationReference}. + */ + public toDeclarationReference( + this: ComponentPath, + parts?: DeclarationReferenceSourceParts & + DeclarationReferenceNavigationParts & + Omit, 'componentPath' | 'component'> + ): DeclarationReference { + return DeclarationReference.from({ ...parts, componentPath: this }); + } + + /** + * Starting with this path segment, yields each parent path segment. + */ + public *ancestors(this: ComponentPath, includeSelf?: boolean): IterableIterator { + let ancestor: ComponentPath | undefined = this; + while (ancestor) { + if (!includeSelf) { + includeSelf = true; + } else { + yield ancestor; + } + ancestor = ancestor instanceof ComponentNavigation ? ancestor.parent : undefined; + } + } + + public abstract toString(): string; +} + +// #endregion ComponentPathBase + +// #region ComponentRoot + +/** + * Represents the root of a {@link ComponentPath}. + * @beta + */ +export class ComponentRoot extends ComponentPathBase { + public readonly kind: 'component-root' = 'component-root'; + + /** + * Gets the {@link ComponentRoot} at the root of the component path. + */ + public get root(): ComponentRoot { + return this; + } + + /** + * Creates a new {@link ComponentRoot} from the provided parts. + */ + public static from(parts: ComponentRootLike): ComponentRoot { + const resolved: ResolvedComponentRootLike = resolveComponentRootLike( + parts, + /*fallbackComponent*/ undefined + ); + if (resolved instanceof ComponentRoot) { + return resolved; + } else { + const { component } = resolved; + return new ComponentRoot(Component.from(component)); + } + } + + /** + * Returns a {@link ComponentRoot} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: ComponentRootParts): ComponentRoot { + const { component } = resolveComponentRootParts(parts, this.component); + const resolvedComponent: Component = Component.from(component); + if (Component.equals(this.component, resolvedComponent)) { + return this; + } else { + return new ComponentRoot(resolvedComponent); + } + } + + /** + * Tests whether two {@link ComponentRoot} values are equivalent. + */ + public static equals(left: ComponentRoot | undefined, right: ComponentRoot | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return Component.equals(left.component, right.component); + } + } + + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: ComponentRoot): boolean { + return ComponentRoot.equals(this, other); + } + + /** + * Returns a {@link ComponentRoot} updated with the provided component. + * If a part is set to `undefined`, the current value is used. + * + * An alias for `componentRoot.with({ component })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided component. + */ + public withComponent(component: ComponentLike): ComponentRoot { + return this.with({ component }); + } + + public toString(): string { + return this.component.toString(); + } +} + +/** + * Parts used to compose or update a {@link ComponentRoot}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentRootParts = Parts< + With, + { + /** The component for the {@link ComponentRoot} */ + component: ComponentLike; + } +>; + +function resolveComponentRootParts( + parts: ComponentRootParts, + fallbackComponent: Component | undefined +): ComponentRootParts { + const { component = fallbackComponent } = parts; + if (component === undefined) { + throw new TypeError("The property 'component' is required."); + } + return { + component: resolveComponentLike(component, fallbackComponent) + }; +} + +/** + * A value that can be resolved to a {@link ComponentRoot}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentRootLike = + | ComponentRoot + | ComponentRootParts + | ComponentLike; + +type ResolvedComponentRootLike = ComponentRoot | ComponentRootParts; + +function resolveComponentRootLike( + componentRoot: ComponentRootLike, + fallbackComponent: Component | undefined +): ResolvedComponentRootLike { + if (componentRoot instanceof ComponentRoot) { + return componentRoot; + } else if ( + componentRoot instanceof ComponentString || + componentRoot instanceof ComponentReference || + componentRoot instanceof DeclarationReference || + typeof componentRoot === 'string' + ) { + return resolveComponentRootParts({ component: componentRoot }, fallbackComponent); + } + const { component, text, reference } = componentRoot as AllParts; + if (component !== undefined) { + if (text !== undefined) { + throw new TypeError(`Cannot specify both 'component' and 'text'`); + } else if (reference !== undefined) { + throw new TypeError(`Cannot specify both 'component' and 'reference'`); + } + return resolveComponentRootParts({ component }, fallbackComponent); + } else if (text !== undefined || reference !== undefined) { + return resolveComponentRootParts({ component: { text, reference } }, fallbackComponent); + } else { + return resolveComponentRootParts({}, fallbackComponent); + } +} + +// #endregion ComponentRoot + +// #region ComponentNavigation + +/** + * Represents a navigation step in a {@link ComponentPath}. + * @beta + */ +export class ComponentNavigation extends ComponentPathBase { + public readonly kind: 'component-navigation' = 'component-navigation'; + public readonly parent: ComponentPath; + public readonly navigation: Navigation; + + public constructor(parent: ComponentPath, navigation: Navigation, component: Component) { + super(component); + this.parent = parent; + this.navigation = navigation; + } + + /** + * Gets the {@link ComponentRoot} at the root of the component path. + */ + public get root(): ComponentRoot { + let parent: ComponentPath = this.parent; + while (!(parent instanceof ComponentRoot)) { + parent = parent.parent; + } + return parent; + } + + /** + * Creates a new {@link ComponentNavigation} from the provided parts. + */ + public static from(parts: ComponentNavigationLike): ComponentNavigation { + const resolved: ResolvedComponentNavigationLike = resolveComponentNavigationLike( + parts, + /*fallbackParent*/ undefined, + /*fallbackNavigation*/ undefined, + /*fallbackComponent*/ undefined + ); + if (resolved instanceof ComponentNavigation) { + return resolved; + } else { + const { parent, navigation, component } = resolved; + return new ComponentNavigation(ComponentPath.from(parent), navigation, Component.from(component)); + } + } + + /** + * Returns a {@link ComponentNavigation} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: ComponentNavigationParts): ComponentNavigation { + const { parent, navigation, component } = resolveComponentNavigationParts( + parts, + this.parent, + this.navigation, + this.component + ); + const resolvedParent: ComponentPath = ComponentPath.from(parent); + const resolvedComponent: Component = Component.from(component); + if ( + ComponentPath.equals(this.parent, resolvedParent) && + this.navigation === navigation && + Component.equals(this.component, resolvedComponent) + ) { + return this; + } else { + return new ComponentNavigation(resolvedParent, navigation, resolvedComponent); + } + } + + /** + * Returns a {@link ComponentNavigation} updated with the provided parent. + * + * An alias for `componentNav.with({ parent })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided parent. + */ + public withParent(parent: ComponentPath): ComponentNavigation { + return this.with({ parent }); + } + + /** + * Returns a {@link ComponentNavigation} updated with the provided navigation. + * + * An alias for `componentNav.with({ navigation })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided navigation. + */ + public withNavigation(navigation: Navigation): ComponentNavigation { + return this.with({ navigation }); + } + + /** + * Returns a {@link ComponentNavigation} updated with the provided component. + * + * An alias for `componentNav.with({ component })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided component. + */ + public withComponent(component: ComponentLike): ComponentNavigation { + return this.with({ component }); + } + + /** + * Tests whether two {@link ComponentNavigation} values are equivalent. + */ + public static equals( + left: ComponentNavigation | undefined, + right: ComponentNavigation | undefined + ): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return ( + ComponentPath.equals(left.parent, right.parent) && + left.navigation === right.navigation && + Component.equals(left.component, right.component) + ); + } + } + + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: ComponentNavigation): boolean { + return ComponentNavigation.equals(this, other); + } + + public toString(): string { + return `${this.parent}${formatNavigation(this.navigation)}${this.component}`; + } +} + +/** + * Parts used to compose or update a {@link ComponentNavigation}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentNavigationParts = Parts< + With, + { + /** The parent {@link ComponentPath} segment for this navigation step. */ + parent: ComponentPathLike; + + /** The kind of navigation for this navigation step. */ + navigation: Navigation; + + /** The component for this navigation step. */ + component: ComponentLike; + } +>; + +function resolveComponentNavigationParts( + parts: ComponentNavigationParts, + fallbackParent: ComponentPath | undefined, + fallbackNavigation: Navigation | undefined, + fallbackComponent: Component | undefined +): ComponentNavigationParts { + const { + parent = fallbackParent, + navigation = fallbackNavigation, + component = fallbackComponent + } = parts as AllParts; + if (parent === undefined) { + throw new TypeError("The 'parent' property is required"); + } + if (navigation === undefined) { + throw new TypeError("The 'navigation' property is required"); + } + if (component === undefined) { + throw new TypeError("The 'component' property is required"); + } + return { + parent: resolveComponentPathLike(parent, fallbackParent), + navigation, + component: resolveComponentLike(component, fallbackComponent) + }; +} + +/** + * A value that can be resolved to a {@link ComponentNavigation}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentNavigationLike = + | ComponentNavigation + | ComponentNavigationParts; + +type ResolvedComponentNavigationLike = ComponentNavigation | ComponentNavigationParts; + +function resolveComponentNavigationLike( + value: ComponentNavigationLike, + fallbackParent: ComponentPath | undefined, + fallbackNavigation: Navigation | undefined, + fallbackComponent: Component | undefined +): ResolvedComponentNavigationLike { + if (value instanceof ComponentNavigation) { + return value; + } else { + return resolveComponentNavigationParts(value, fallbackParent, fallbackNavigation, fallbackComponent); + } +} + +// #endregion ComponentNavigation + +// #region ComponentPath + +/** + * The path used to traverse a root symbol to a specific declaration. + * @beta + */ +export type ComponentPath = ComponentRoot | ComponentNavigation; + +/** + * @beta + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ComponentPath { + /** + * Parses a {@link SymbolReference} from the supplied text. + */ + export function parse(text: string): ComponentPath { + const parser: Parser = new Parser(new TextReader(text)); + const componentPath: ComponentPath = parser.parseComponentPath(); + if (parser.errors.length) { + throw new SyntaxError(`Invalid ComponentPath '${text}':\n ${parser.errors.join('\n ')}`); + } else if (!parser.eof || componentPath === undefined) { + throw new SyntaxError(`Invalid ComponentPath '${text}'`); + } else { + return componentPath; + } + } + + /** + * Creates a new {@link ComponentPath} from the provided parts. + */ + export function from(parts: ComponentPathLike): ComponentPath { + const resolved: ResolvedComponentPathLike = resolveComponentPathLike( + parts, + /*fallbackComponentPath*/ undefined + ); + if (resolved instanceof ComponentRoot || resolved instanceof ComponentNavigation) { + return resolved; + } else if (typeof resolved === 'string') { + return parse(resolved); + } else if ('navigation' in resolved) { + return ComponentNavigation.from(resolved); + } else { + return ComponentRoot.from(resolved); + } + } + + /** + * Tests whether two {@link ComponentPath} values are equivalent. + */ + export function equals(left: ComponentPath | undefined, right: ComponentPath | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else if (left instanceof ComponentRoot) { + return right instanceof ComponentRoot && ComponentRoot.equals(left, right); + } else { + return right instanceof ComponentNavigation && ComponentNavigation.equals(left, right); + } + } +} + +/** + * Parts that can be used to compose or update a {@link ComponentPath}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentPathParts = + | ComponentRootParts + | ComponentNavigationParts; + +function resolveComponentPathParts( + parts: ComponentPathParts, + fallbackComponentPath: ComponentPath | undefined +): ComponentPathParts { + const { component, navigation, parent } = parts as AllParts; + if (navigation !== undefined || parent !== undefined) { + const fallbackComponent: ComponentNavigation | undefined = tryCast( + fallbackComponentPath, + ComponentNavigation + ); + return resolveComponentNavigationParts( + { component, navigation, parent }, + fallbackComponent?.parent, + fallbackComponent?.navigation, + fallbackComponent?.component + ); + } else { + const fallbackComponent: ComponentRoot | undefined = tryCast(fallbackComponentPath, ComponentRoot); + return resolveComponentRootParts({ component }, fallbackComponent?.component); + } +} + +/** + * A value that can be resolved to a {@link ComponentPath}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentPathLike = + | Exclude, string> + | ComponentNavigationLike + | string; + +type ResolvedComponentPathLike = + | ComponentPath + | ComponentRootParts + | ComponentNavigationParts + | string; + +function resolveComponentPathLike( + value: ComponentPathLike, + fallbackComponentPath: ComponentPath | undefined +): ResolvedComponentPathLike { + if (value instanceof ComponentRoot || value instanceof ComponentNavigation) { + return value; + } else if (value instanceof ComponentString || value instanceof ComponentReference) { + return resolveComponentPathParts({ component: value }, fallbackComponentPath); + } else if (value instanceof DeclarationReference) { + return resolveComponentPathParts({ component: { reference: value } }, fallbackComponentPath); + } else if (typeof value === 'string') { + return value; + } + const { component, navigation, parent, text, reference } = value as AllParts; + if (component !== undefined || navigation !== undefined || parent !== undefined) { + if (text !== undefined || reference !== undefined) { + const first: string = + component !== undefined ? 'component' : navigation !== undefined ? 'navigation' : 'parent'; + if (text !== undefined) { + throw new TypeError(`Cannot specify both '${first}' and 'text'`); + } else { + throw new TypeError(`Cannot specify both '${first}' and 'reference'`); + } + } + return resolveComponentPathParts({ component, navigation, parent }, fallbackComponentPath); + } else if (text !== undefined || reference !== undefined) { + return resolveComponentPathParts({ component: { text, reference } }, fallbackComponentPath); + } else { + return resolveComponentPathParts({}, fallbackComponentPath); + } +} + +// #endregion ComponentPath + +// #region ComponentBase + +/** + * Abstract base class for a {@link Component}. + * @beta + */ +export abstract class ComponentBase { + public abstract readonly kind: string; + + private declare _: never; // NOTE: This makes a Component compare nominally rather than structurally which removes its properties from completions in `Component.from({ ... })` + + /** + * Combines this component with the provided parts to create a new {@link Component}. + * @param parts - The parts for the component path segment. If `undefined` or an empty object, then the + * result is a {@link ComponentRoot}. Otherwise, the result is a {@link ComponentNavigation}. + */ + public toComponentPath( + this: Component, + parts?: Omit, 'component'> + ): ComponentPath { + return ComponentPath.from({ ...parts, component: this }); + } + + public abstract toString(): string; +} + +// #endregion ComponentBase + +// #region ComponentString + +/** + * A {@link Component} in a component path that refers to a property name. + * @beta + */ +export class ComponentString extends ComponentBase { + public readonly kind: 'component-string' = 'component-string'; + public readonly text: string; + + public constructor(text: string, userEscaped?: boolean) { + super(); + this.text = this instanceof ParsedComponentString ? text : escapeComponentIfNeeded(text, userEscaped); + } + + /** + * Creates a new {@link ComponentString} from the provided parts. + */ + public static from(parts: ComponentStringLike): ComponentString { + if (parts instanceof ComponentString) { + return parts; + } else if (typeof parts === 'string') { + return new ComponentString(parts); + } else { + return new ComponentString(parts.text); + } + } + + /** + * Tests whether two {@link ComponentString} values are equivalent. + */ + public static equals(left: ComponentString | undefined, right: ComponentString | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return left.text === right.text; + } + } + + /** + * Tests whether this component is equivalent to `other`. + */ + public equals(other: ComponentString): boolean { + return ComponentString.equals(this, other); + } public toString(): string { return this.text; } } -class ParsedComponentString extends ComponentString {} +class ParsedComponentString extends ComponentString { + public constructor(text: string, userEscaped?: boolean) { + super(text, userEscaped); + try { + setPrototypeOf?.(this, ComponentString.prototype); + } catch { + // ignored + } + } +} + +/** + * Parts that can be used to compose or update a {@link ComponentString}. + * + * @beta + */ +export type ComponentStringParts = Parts< + /*With*/ false, + { + /** The text for a {@link ComponentString}. */ + text: string; + } +>; + +/** + * A value that can be resolved to a {@link ComponentString}. + * @beta + */ +export type ComponentStringLike = ComponentStringParts | ComponentString | string; + +// #endregion ComponentString + +// #region ComponentReference /** + * A {@link Component} in a component path that refers to a unique symbol declared on another declaration, such as `Symbol.iterator`. * @beta */ -export class ComponentReference { +export class ComponentReference extends ComponentBase { + public readonly kind: 'component-reference' = 'component-reference'; public readonly reference: DeclarationReference; public constructor(reference: DeclarationReference) { + super(); this.reference = reference; } + /** + * Parses a string into a standalone {@link ComponentReference}. + */ public static parse(text: string): ComponentReference { - if (text.length > 2 && text.charAt(0) === '[' && text.charAt(text.length - 1) === ']') { + if (isBracketed(text)) { return new ComponentReference(DeclarationReference.parse(text.slice(1, -1))); } throw new SyntaxError(`Invalid component reference: '${text}'`); } + /** + * Creates a new {@link ComponentReference} from the provided parts. + */ + public static from(parts: ComponentReferenceLike): ComponentReference { + if (parts instanceof ComponentReference) { + return parts; + } else if (typeof parts === 'string') { + return ComponentReference.parse(parts); + } else if (parts instanceof DeclarationReference) { + return new ComponentReference(parts); + } else { + const { reference } = resolveComponentReferenceParts(parts, /*fallbackReference*/ undefined); + return new ComponentReference(DeclarationReference.from(reference)); + } + } + + /** + * Returns a {@link ComponentReference} updated with the provided parts. + * If a part is set to `undefined`, the current value is used. + * @returns This object if there were no changes; otherwise, a new object updated with the provided parts. + */ + public with(parts: ComponentReferenceParts): ComponentReference { + const { reference } = resolveComponentReferenceParts(parts, this.reference); + const resolvedReference: DeclarationReference = DeclarationReference.from(reference); + if (DeclarationReference.equals(this.reference, resolvedReference)) { + return this; + } else { + return new ComponentReference(resolvedReference); + } + } + + /** + * Returns a {@link ComponentReference} updated with the provided reference. + * + * An alias for `componentRef.with({ reference })`. + * + * @returns This object if there were no changes; otherwise, a new object updated with the provided reference. + */ public withReference(reference: DeclarationReference): ComponentReference { - return this.reference === reference ? this : new ComponentReference(reference); + return this.with({ reference }); + } + + /** + * Tests whether two {@link ComponentReference} values are equivalent. + */ + public static equals(left: ComponentReference | undefined, right: ComponentReference | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else { + return DeclarationReference.equals(left.reference, right.reference); + } + } + + /** + * Tests whether this component is equivalent to `other`. + */ + public equals(other: ComponentReference): boolean { + return ComponentReference.equals(this, other); } public toString(): string { @@ -488,78 +2408,221 @@ export class ComponentReference { } /** + * Parts that can be used to compose or update a {@link ComponentReference}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentReferenceParts = Parts< + With, + { + /** The reference for a {@link ComponentReference}. */ + reference: DeclarationReferenceLike; + } +>; + +function resolveComponentReferenceParts( + parts: ComponentReferenceParts, + fallbackReference: DeclarationReference | undefined +): ComponentReferenceParts { + const { reference = fallbackReference } = parts; + const resolvedReference: ResolvedDeclarationReferenceLike | undefined = resolveDeclarationReferenceLike( + reference, + fallbackReference + ); + if (resolvedReference === undefined) { + throw new TypeError("The property 'reference' is required"); + } + return { + reference: resolvedReference + }; +} + +/** + * A value that can be resolved to a {@link ComponentReference}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentReferenceLike = + | ComponentReference + | ComponentReferenceParts + | DeclarationReference + | string; + +// #endregion ComponentReference + +// #region Component + +/** + * A component in a {@link ComponentPath}. + * @beta + */ +export type Component = ComponentString | ComponentReference; + +/** + * @beta + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Component { + /** + * Creates a new {@link Component} from the provided parts. + */ + export function from(parts: ComponentLike): Component { + const resolved: ResolvedComponentLike = resolveComponentLike(parts, /*fallbackComponent*/ undefined); + if (resolved instanceof ComponentString || resolved instanceof ComponentReference) { + return resolved; + } else if ('text' in resolved) { + return ComponentString.from(resolved); + } else { + return ComponentReference.from(resolved); + } + } + + /** + * Tests whether two {@link Component} values are equivalent. + */ + export function equals(left: Component | undefined, right: Component | undefined): boolean { + if (left === undefined || right === undefined) { + return left === right; + } else if (left instanceof ComponentString) { + return right instanceof ComponentString && ComponentString.equals(left, right); + } else { + return right instanceof ComponentReference && ComponentReference.equals(left, right); + } + } +} + +function componentToDocMemberReference( + configuration: TSDocConfiguration, + hasDot: boolean, + component: Component, + selector: DocMemberSelector | undefined +): DocMemberReference { + const memberIdentifier: DocMemberIdentifier | undefined = + component instanceof ComponentString + ? new DocMemberIdentifier({ configuration, identifier: component.text }) + : undefined; + + const memberSymbol: DocMemberSymbol | undefined = + component instanceof ComponentReference + ? new DocMemberSymbol({ + configuration, + symbolReference: new DocDeclarationReference({ + configuration, + declarationReference: component.reference + }) + }) + : undefined; + + return new DocMemberReference({ + configuration, + hasDot, + memberIdentifier, + memberSymbol, + selector + }); +} + +/** + * Parts that can be used to compose a {@link Component}. + * + * @typeParam With - `true` if these parts are used by `with()` (which allows `null` for some parts), `false` if these parts are used by `from()` (which does not allow `null`). + * + * @beta + */ +export type ComponentParts = ComponentStringParts | ComponentReferenceParts; + +function resolveComponentParts( + parts: ComponentParts, + fallbackComponent: Component | undefined +): ComponentParts { + const { text, reference } = parts as AllParts; + if (text !== undefined) { + if (reference !== undefined) { + throw new TypeError("Cannot specify both 'text' and 'reference'"); + } + return { text }; + } else if (reference !== undefined) { + return resolveComponentReferenceParts( + { reference }, + tryCast(fallbackComponent, ComponentReference)?.reference + ); + } else { + if (fallbackComponent === undefined) { + throw new TypeError("One of properties 'text' or 'reference' is required"); + } + return fallbackComponent; + } +} + +/** + * A value that can be resolved to a {@link Component}. + * + * @typeParam With - `true` if this type is used by `with()` (which allows `null` for some parts), `false` if this value is used + * by `from()` (which does not allow `null`). + * * @beta */ -export type ComponentPath = ComponentRoot | ComponentNavigation; +export type ComponentLike = + | ComponentStringLike + | Exclude, string>; -/** - * @beta - */ -export abstract class ComponentPathBase { - public readonly component: Component; +type ResolvedComponentLike = Component | ComponentStringParts | ComponentReferenceParts; - public constructor(component: Component) { - this.component = component; +function resolveComponentLike( + value: ComponentLike, + fallbackComponent: Component | undefined +): ResolvedComponentLike { + if (value instanceof ComponentString || value instanceof ComponentReference) { + return value; + } else if (value instanceof DeclarationReference) { + return resolveComponentParts({ reference: value }, fallbackComponent); + } else if (typeof value === 'string') { + return resolveComponentParts({ text: value }, fallbackComponent); + } else { + return resolveComponentParts(value, fallbackComponent); } +} - public addNavigationStep( - this: ComponentPath, - navigation: Navigation, - component: ComponentLike - ): ComponentPath { - // tslint:disable-next-line:no-use-before-declare - return new ComponentNavigation(this, navigation, Component.from(component)); - } +// #endregion Component - public abstract toString(): string; -} +// #region Navigation /** + * Indicates the symbol table from which to resolve the next symbol component. * @beta */ -export class ComponentRoot extends ComponentPathBase { - public withComponent(component: ComponentLike): ComponentRoot { - return this.component === component ? this : new ComponentRoot(Component.from(component)); - } - - public toString(): string { - return this.component.toString(); - } +export const enum Navigation { + Exports = '.', + Members = '#', + Locals = '~' } /** * @beta */ -export class ComponentNavigation extends ComponentPathBase { - public readonly parent: ComponentPath; - public readonly navigation: Navigation; - - public constructor(parent: ComponentPath, navigation: Navigation, component: Component) { - super(component); - this.parent = parent; - this.navigation = navigation; - } - - public withParent(parent: ComponentPath): ComponentNavigation { - return this.parent === parent ? this : new ComponentNavigation(parent, this.navigation, this.component); - } +export type SourceNavigation = Navigation.Exports | Navigation.Locals; - public withNavigation(navigation: Navigation): ComponentNavigation { - return this.navigation === navigation - ? this - : new ComponentNavigation(this.parent, navigation, this.component); +function formatNavigation(navigation: Navigation | undefined): string { + switch (navigation) { + case Navigation.Exports: + return '.'; + case Navigation.Members: + return '#'; + case Navigation.Locals: + return '~'; + default: + return ''; } +} - public withComponent(component: ComponentLike): ComponentNavigation { - return this.component === component - ? this - : new ComponentNavigation(this.parent, this.navigation, Component.from(component)); - } +// #endregion Navigation - public toString(): string { - return `${this.parent}${formatNavigation(this.navigation)}${this.component}`; - } -} +// #region Meaning /** * @beta @@ -581,82 +2644,56 @@ export const enum Meaning { ComplexType = 'complex' // Any complex type } -/** - * @beta - */ -export interface ISymbolReferenceOptions { - meaning?: Meaning; - overloadIndex?: number; -} - -/** - * Represents a reference to a TypeScript symbol. - * @beta - */ -export class SymbolReference { - public readonly componentPath: ComponentPath | undefined; - public readonly meaning: Meaning | undefined; - public readonly overloadIndex: number | undefined; - - public constructor( - component: ComponentPath | undefined, - { meaning, overloadIndex }: ISymbolReferenceOptions = {} - ) { - this.componentPath = component; - this.overloadIndex = overloadIndex; - this.meaning = meaning; - } - - public static empty(): SymbolReference { - return new SymbolReference(/*component*/ undefined); - } - - public withComponentPath(componentPath: ComponentPath | undefined): SymbolReference { - return this.componentPath === componentPath - ? this - : new SymbolReference(componentPath, { - meaning: this.meaning, - overloadIndex: this.overloadIndex - }); - } - - public withMeaning(meaning: Meaning | undefined): SymbolReference { - return this.meaning === meaning - ? this - : new SymbolReference(this.componentPath, { - meaning, - overloadIndex: this.overloadIndex - }); +function meaningToSelector( + configuration: TSDocConfiguration, + meaning: Meaning | undefined, + navigation: Navigation | undefined, + overloadIndex: number | undefined +): DocMemberSelector | undefined { + if (overloadIndex !== undefined) { + return new DocMemberSelector({ + configuration, + selector: overloadIndex.toString() + }); } - - public withOverloadIndex(overloadIndex: number | undefined): SymbolReference { - return this.overloadIndex === overloadIndex - ? this - : new SymbolReference(this.componentPath, { - meaning: this.meaning, - overloadIndex - }); + switch (meaning) { + case Meaning.Class: + case Meaning.Interface: + case Meaning.Namespace: + case Meaning.TypeAlias: + case Meaning.Function: + case Meaning.Enum: + case Meaning.Constructor: + return new DocMemberSelector({ + configuration, + selector: meaning + }); + case Meaning.Variable: + return new DocMemberSelector({ + configuration, + selector: 'variable' + }); + case Meaning.Member: + case Meaning.Event: + switch (navigation) { + case Navigation.Exports: + return new DocMemberSelector({ + configuration, + selector: 'static' + }); + case Navigation.Members: + return new DocMemberSelector({ + configuration, + selector: 'instance' + }); + } + break; } +} - public addNavigationStep(navigation: Navigation, component: ComponentLike): SymbolReference { - if (!this.componentPath) { - throw new Error('Cannot add a navigation step to an empty symbol reference.'); - } - return new SymbolReference(this.componentPath.addNavigationStep(navigation, component)); - } +// #endregion Meaning - public toString(): string { - let result: string = `${this.componentPath || ''}`; - if (this.meaning && this.overloadIndex !== undefined) { - result += `:${this.meaning}(${this.overloadIndex})`; - } else if (this.meaning) { - result += `:${this.meaning}`; - } else if (this.overloadIndex !== undefined) { - result += `:${this.overloadIndex}`; - } - return result; - } -} +// #region Token const enum Token { None, @@ -767,35 +2804,198 @@ function tokenToString(token: Token): string { } } -class Scanner { - private _tokenPos: number; - private _pos: number; +// #endregion Token + +// #region Scanner + +interface ICharacterReader { + readonly eof: boolean; + mark(): number; + rewind(marker: number): void; + readChar(count?: number): string; + peekChar(lookahead?: number): string; + readFrom(marker: number): string; +} + +class TextReader implements ICharacterReader { private _text: string; - private _token: Token; - private _stringIsUnterminated: boolean; + private _pos: number; public constructor(text: string) { + this._text = text; this._pos = 0; - this._tokenPos = 0; - this._stringIsUnterminated = false; + } + + public get eof(): boolean { + return this._pos >= this._text.length; + } + + public mark(): number { + return this._pos; + } + + public rewind(marker: number): void { + this._pos = marker; + } + + public readChar(count: number = 1): string { + if (count < 1) throw new RangeError('Argument out of range: count'); + let ch: string = ''; + while (count > 0) { + if (this.eof) return ''; + ch = this._text.charAt(this._pos++); + count--; + } + return ch; + } + + public peekChar(lookahead: number = 1): string { + if (lookahead < 1) throw new RangeError('Argument out of range: lookahead'); + const marker: number = this.mark(); + const ch: string = this.readChar(lookahead); + this.rewind(marker); + return ch; + } + + public readFrom(marker: number): string { + return this._text.substring(marker, this._pos); + } +} + +class TokenReaderNormalizer implements ICharacterReader { + private _tokenReader: TokenReader; + private _token: DocToken | undefined; + private _partialTokenPos: number = 0; + private _startMarker: number; + private _markerSizes: { [marker: number]: number | undefined } = {}; + + public constructor(tokenReader: TokenReader) { + this._tokenReader = tokenReader; + this._startMarker = tokenReader.createMarker(); + this._token = tokenReader.peekTokenKind() === TokenKind.EndOfInput ? undefined : tokenReader.peekToken(); + if (this._token) { + this._markerSizes[this._startMarker] = this._token.range.length; + } + } + + public get eof(): boolean { + return this._token === undefined; + } + + public mark(): number { + const tokenMarker: number = this._tokenReader.createMarker(); + + let offset: number = 0; + for (let i: number = this._startMarker; i < tokenMarker; i++) { + const markerSize: number = this._markerSizes[i] ?? 1; + offset += markerSize; + } + + offset += this._partialTokenPos; + return offset; + } + + public rewind(marker: number): void { + let tokenMarker: number = this._startMarker; + let partialTokenPos: number = 0; + + let offset: number = 0; + while (offset < marker) { + const markerSize: number = this._markerSizes[tokenMarker] ?? 1; + if (offset + markerSize < marker) { + offset += markerSize; + tokenMarker++; + } else { + partialTokenPos = marker - offset; + break; + } + } + + this._tokenReader.backtrackToMarker(tokenMarker); + this._token = this._tokenReader.peekToken(); + this._partialTokenPos = partialTokenPos; + } + + public readChar(count: number = 1): string { + if (count < 1) throw new RangeError('Argument out of range: count'); + let ch: string = ''; + while (count > 0) { + if (!this._token) return ''; + if (this._partialTokenPos === this._token.range.length) { + if (this._tokenReader.peekTokenKind() === TokenKind.EndOfInput) { + this._token = undefined; + } else { + this._tokenReader.readToken(); + this._token = this._tokenReader.peekToken(); + } + + this._partialTokenPos = 0; + if (!this._token) { + return ''; + } else { + const length: number = this._token.range.length; + if (length > 1) { + this._markerSizes[this._tokenReader.createMarker()] = length; + } + } + } + ch = this._token.toString().charAt(this._partialTokenPos++); + count--; + } + return ch; + } + + public peekChar(lookahead: number = 1): string { + if (lookahead < 1) throw new RangeError('Argument out of range: lookahead'); + const tokenMarker: number = this._tokenReader.createMarker(); + const savedTokenReader: TokenReader = this._tokenReader; + const savedToken: DocToken | undefined = this._token; + const savedPartialTokenPos: number = this._partialTokenPos; + const ch: string = this.readChar(lookahead); + this._partialTokenPos = savedPartialTokenPos; + this._token = savedToken; + this._tokenReader = savedTokenReader; + this._tokenReader.backtrackToMarker(tokenMarker); + return ch; + } + + public readFrom(marker: number): string { + const currentMarker: number = this.mark(); + const savedTokenReader: TokenReader = this._tokenReader; + this._tokenReader = savedTokenReader.clone(); + this.rewind(marker); + let text: string = ''; + while (this.mark() < currentMarker) { + text += this.readChar(1); + } + this._tokenReader = savedTokenReader; + return text; + } +} + +class Scanner { + private _reader: ICharacterReader; + private _token: Token; + private _tokenMarker: number; + private _stringIsUnterminated: boolean; + + public constructor(reader: ICharacterReader) { + this._reader = reader; + this._tokenMarker = reader.mark(); this._token = Token.None; - this._text = text; + this._stringIsUnterminated = false; } public get stringIsUnterminated(): boolean { return this._stringIsUnterminated; } - public get text(): string { - return this._text; - } - public get tokenText(): string { - return this._text.slice(this._tokenPos, this._pos); + return this._reader.readFrom(this._tokenMarker); } public get eof(): boolean { - return this._pos >= this._text.length; + return this._reader.eof; } public token(): Token { @@ -803,9 +3003,9 @@ class Scanner { } public speculate(cb: (accept: () => void) => T): T { - const tokenPos: number = this._tokenPos; - const pos: number = this._pos; - const text: string = this._text; + const tokenMarker: number = this._tokenMarker; + const marker: number = this._reader.mark(); + const reader: ICharacterReader = this._reader; const token: Token = this._token; const stringIsUnterminated: boolean = this._stringIsUnterminated; let accepted: boolean = false; @@ -816,21 +3016,21 @@ class Scanner { return cb(accept); } finally { if (!accepted) { - this._tokenPos = tokenPos; - this._pos = pos; - this._text = text; - this._token = token; this._stringIsUnterminated = stringIsUnterminated; + this._token = token; + this._reader = reader; + this._reader.rewind(marker); + this._tokenMarker = tokenMarker; } } } public scan(): Token { if (!this.eof) { - this._tokenPos = this._pos; + this._tokenMarker = this._reader.mark(); this._stringIsUnterminated = false; while (!this.eof) { - const ch: string = this._text.charAt(this._pos++); + const ch: string = this._reader.readChar(); switch (ch) { case '{': return (this._token = Token.OpenBraceToken); @@ -879,11 +3079,11 @@ class Scanner { } return this.speculate((accept) => { if (!this.eof) { - this._pos = this._tokenPos; + this._reader.rewind(this._tokenMarker); this._stringIsUnterminated = false; let scanned: 'string' | 'other' | 'none' = 'none'; while (!this.eof) { - const ch: string = this._text[this._pos]; + const ch: string = this._reader.peekChar(1); if (ch === '!') { if (scanned === 'none') { return this._token; @@ -891,7 +3091,7 @@ class Scanner { accept(); return (this._token = Token.ModuleSource); } - this._pos++; + this._reader.readChar(); if (ch === '"') { if (scanned === 'other') { // strings not allowed after scanning any other characters @@ -964,7 +3164,7 @@ class Scanner { private scanString(): void { while (!this.eof) { - const ch: string = this._text.charAt(this._pos++); + const ch: string = this._reader.readChar(); switch (ch) { case '"': return; @@ -987,91 +3187,196 @@ class Scanner { return; } - const ch: string = this._text.charAt(this._pos); + const ch: string = this._reader.peekChar(1); // EscapeSequence:: CharacterEscapeSequence if (isCharacterEscapeSequence(ch)) { - this._pos++; + this._reader.readChar(1); return; } // EscapeSequence:: `0` [lookahead != DecimalDigit] + if (ch === '0' && !isDecimalDigit(this._reader.peekChar(2))) { + this._reader.readChar(1); + return; + } + + // EscapeSequence:: HexEscapeSequence + if (ch === 'x' && isHexDigit(this._reader.peekChar(2)) && isHexDigit(this._reader.peekChar(3))) { + this._reader.readChar(3); + return; + } + + // EscapeSequence:: UnicodeEscapeSequence + // UnicodeEscapeSequence:: `u` Hex4Digits if ( - ch === '0' && - (this._pos + 1 === this._text.length || !isDecimalDigit(this._text.charAt(this._pos + 1))) + ch === 'u' && + isHexDigit(this._reader.peekChar(2)) && + isHexDigit(this._reader.peekChar(3)) && + isHexDigit(this._reader.peekChar(4)) && + isHexDigit(this._reader.peekChar(5)) ) { - this._pos++; + this._reader.readChar(5); return; } - // EscapeSequence:: HexEscapeSequence - if ( - ch === 'x' && - this._pos + 3 <= this._text.length && - isHexDigit(this._text.charAt(this._pos + 1)) && - isHexDigit(this._text.charAt(this._pos + 2)) - ) { - this._pos += 3; - return; - } + // EscapeSequence:: UnicodeEscapeSequence + // UnicodeEscapeSequence:: `u` `{` CodePoint `}` + if (ch === 'u' && this._reader.peekChar(2) === '{') { + let hexDigits: string = this._reader.peekChar(3); + if (isHexDigit(hexDigits)) { + for ( + let i: number = 4, ch2: string = this._reader.peekChar(i); + ch2 !== ''; + i++, ch2 = this._reader.peekChar(i) + ) { + if (ch2 === '}') { + const mv: number = parseInt(hexDigits, 16); + if (mv <= 0x10ffff) { + this._reader.readChar(i + 1); + return; + } + break; + } + if (!isHexDigit(ch2)) { + hexDigits += ch2; + break; + } + } + } + } + this._stringIsUnterminated = true; + } + + private scanText(): void { + while (!this._reader.eof) { + const ch: string = this._reader.peekChar(); + if (isPunctuator(ch) || ch === '"') { + return; + } + this._reader.readChar(); + } + } +} + +function isHexDigit(ch: string): boolean { + switch (ch) { + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + return true; + default: + return isDecimalDigit(ch); + } +} + +function isDecimalDigit(ch: string): boolean { + switch (ch) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + return true; + default: + return false; + } +} + +function isCharacterEscapeSequence(ch: string): boolean { + return isSingleEscapeCharacter(ch) || isNonEscapeCharacter(ch); +} + +function isNonEscapeCharacter(ch: string): boolean { + return !isEscapeCharacter(ch) && !isLineTerminator(ch); +} - // EscapeSequence:: UnicodeEscapeSequence - // UnicodeEscapeSequence:: `u` Hex4Digits - if ( - ch === 'u' && - this._pos + 5 <= this._text.length && - isHexDigit(this._text.charAt(this._pos + 1)) && - isHexDigit(this._text.charAt(this._pos + 2)) && - isHexDigit(this._text.charAt(this._pos + 3)) && - isHexDigit(this._text.charAt(this._pos + 4)) - ) { - this._pos += 5; - return; - } +function isEscapeCharacter(ch: string): boolean { + switch (ch) { + case 'x': + case 'u': + return true; + default: + return isSingleEscapeCharacter(ch) || isDecimalDigit(ch); + } +} - // EscapeSequence:: UnicodeEscapeSequence - // UnicodeEscapeSequence:: `u` `{` CodePoint `}` - if (ch === 'u' && this._pos + 4 <= this._text.length && this._text.charAt(this._pos + 1) === '{') { - let hexDigits: string = this._text.charAt(this._pos + 2); - if (isHexDigit(hexDigits)) { - for (let i: number = this._pos + 3; i < this._text.length; i++) { - const ch2: string = this._text.charAt(i); - if (ch2 === '}') { - const mv: number = parseInt(hexDigits, 16); - if (mv <= 0x10ffff) { - this._pos = i + 1; - return; - } - break; - } - if (!isHexDigit(ch2)) { - hexDigits += ch2; - break; - } - } - } - } - this._stringIsUnterminated = true; +function isSingleEscapeCharacter(ch: string): boolean { + switch (ch) { + case "'": + case '"': + case '\\': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + case 'v': + return true; + default: + return false; } +} - private scanText(): void { - while (this._pos < this._text.length) { - const ch: string = this._text.charAt(this._pos); - if (isPunctuator(ch) || ch === '"') { - return; - } - this._pos++; - } +function isLineTerminator(ch: string): boolean { + switch (ch) { + case '\r': + case '\n': + // TODO: , + return true; + default: + return false; + } +} + +function isPunctuator(ch: string): boolean { + switch (ch) { + case '{': + case '}': + case '(': + case ')': + case '[': + case ']': + case '!': + case '.': + case '#': + case '~': + case ':': + case ',': + case '@': + return true; + default: + return false; } } +// #endregion Scanner + +// #region Parser + class Parser { private _errors: string[]; private _scanner: Scanner; + private _fallback: boolean; - public constructor(text: string) { + public constructor(reader: ICharacterReader, fallback: boolean = false) { this._errors = []; - this._scanner = new Scanner(text); + this._fallback = fallback; + this._scanner = new Scanner(reader); this._scanner.scan(); } @@ -1084,9 +3389,8 @@ class Parser { } public parseDeclarationReference(): DeclarationReference { - let source: ModuleSource | GlobalSource | undefined; + let source: Source | undefined; let navigation: Navigation.Locals | undefined; - let symbol: SymbolReference | undefined; if (this.optionalToken(Token.ExclamationToken)) { // Reference to global symbol source = GlobalSource.instance; @@ -1097,12 +3401,7 @@ class Parser { navigation = Navigation.Locals; } } - if (this.isStartOfComponent()) { - symbol = this.parseSymbol(); - } else if (this.token() === Token.ColonToken) { - symbol = this.parseSymbolRest(new ComponentRoot(new ComponentString('', /*userEscaped*/ true))); - } - return new DeclarationReference(source, navigation, symbol); + return new DeclarationReference(source, navigation, this.tryParseSymbolReference()); } public parseModuleSourceString(): string { @@ -1110,12 +3409,28 @@ class Parser { return this.parseTokenString(Token.ModuleSource, 'Module source'); } + public parseComponentPath(): ComponentPath { + return this.parseComponentRest(this.parseRootComponent()); + } + + public tryParseSymbolReference(): SymbolReference | undefined { + if (this.isStartOfComponent()) { + return this.parseSymbol(); + } else if (this.token() === Token.ColonToken) { + return this.parseSymbolRest(new ComponentRoot(new ComponentString('', /*userEscaped*/ true))); + } + } + public parseComponentString(): string { switch (this._scanner.token()) { case Token.String: return this.parseString(); default: - return this.parseComponentCharacters(); + const text: string | undefined = this.parseComponentCharacters(); + if (text === undefined) { + return this.fail('One or more characters expected', ''); + } + return text; } } @@ -1130,7 +3445,7 @@ class Parser { } private parseSymbol(): SymbolReference { - const component: ComponentPath = this.parseComponentRest(this.parseRootComponent()); + const component: ComponentPath = this.parseComponentPath(); return this.parseSymbolRest(component); } @@ -1254,11 +3569,14 @@ class Parser { } } - private parseComponentCharacters(): string { - let text: string = ''; + private parseComponentCharacters(): string | undefined { + let text: string | undefined; for (;;) { switch (this._scanner.token()) { case Token.Text: + if (text === undefined) { + text = ''; + } text += this.parseText(); break; default: @@ -1281,7 +3599,11 @@ class Parser { } private parseText(): string { - return this.parseTokenString(Token.Text, 'Text'); + const text: string = this.parseTokenString(Token.Text, 'Text'); + if (this._fallback && StringChecks.isSystemSelector(text)) { + return this.fail('No system selectors in fallback parsing', text); + } + return text; } private parseString(): string { @@ -1301,7 +3623,11 @@ class Parser { this.expectToken(Token.OpenBracketToken); const reference: DeclarationReference = this.parseDeclarationReference(); this.expectToken(Token.CloseBracketToken); - return new ComponentReference(reference); + const component: ComponentReference = new ComponentReference(reference); + if (this._fallback && reference.isEmpty) { + return this.fail('No empty brackets in fallback parsing', component); + } + return component; } private optionalToken(token: Token): boolean { @@ -1327,123 +3653,7 @@ class Parser { } } -function formatNavigation(navigation: Navigation | undefined): string { - switch (navigation) { - case Navigation.Exports: - return '.'; - case Navigation.Members: - return '#'; - case Navigation.Locals: - return '~'; - default: - return ''; - } -} - -function isCharacterEscapeSequence(ch: string): boolean { - return isSingleEscapeCharacter(ch) || isNonEscapeCharacter(ch); -} - -function isSingleEscapeCharacter(ch: string): boolean { - switch (ch) { - case "'": - case '"': - case '\\': - case 'b': - case 'f': - case 'n': - case 'r': - case 't': - case 'v': - return true; - default: - return false; - } -} - -function isNonEscapeCharacter(ch: string): boolean { - return !isEscapeCharacter(ch) && !isLineTerminator(ch); -} - -function isEscapeCharacter(ch: string): boolean { - switch (ch) { - case 'x': - case 'u': - return true; - default: - return isSingleEscapeCharacter(ch) || isDecimalDigit(ch); - } -} - -function isLineTerminator(ch: string): boolean { - switch (ch) { - case '\r': - case '\n': - // TODO: , - return true; - default: - return false; - } -} - -function isDecimalDigit(ch: string): boolean { - switch (ch) { - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - return true; - default: - return false; - } -} - -function isHexDigit(ch: string): boolean { - switch (ch) { - case 'a': - case 'b': - case 'c': - case 'd': - case 'e': - case 'f': - case 'A': - case 'B': - case 'C': - case 'D': - case 'E': - case 'F': - return true; - default: - return isDecimalDigit(ch); - } -} - -function isPunctuator(ch: string): boolean { - switch (ch) { - case '{': - case '}': - case '(': - case ')': - case '[': - case ']': - case '!': - case '.': - case '#': - case '~': - case ':': - case ',': - case '@': - return true; - default: - return false; - } -} +// #endregion Parser function escapeComponentIfNeeded(text: string, userEscaped?: boolean): string { if (userEscaped) { @@ -1455,12 +3665,74 @@ function escapeComponentIfNeeded(text: string, userEscaped?: boolean): string { return DeclarationReference.escapeComponentString(text); } -function escapeModuleSourceIfNeeded(text: string, userEscaped?: boolean): string { +function escapeModuleSourceIfNeeded(text: string, parsed: boolean, userEscaped: boolean): string { if (userEscaped) { - if (!DeclarationReference.isWellFormedModuleSourceString(text)) { + if (!parsed && !DeclarationReference.isWellFormedModuleSourceString(text)) { throw new SyntaxError(`Invalid Module source '${text}'`); } return text; } return DeclarationReference.escapeModuleSourceString(text); } + +function isBracketed(value: string): boolean { + return value.length > 2 && value.charAt(0) === '[' && value.charAt(value.length - 1) === ']'; +} + +function ensureScopeName(scopeName: string): string { + return scopeName.length && scopeName.charAt(0) !== '@' ? `@${scopeName}` : scopeName; +} + +interface ObjectConstructorWithSetPrototypeOf extends ObjectConstructor { + setPrototypeOf?(obj: object, proto: object | null): object; +} + +const setPrototypeOf: + | ((obj: object, proto: object | null) => object) + | undefined = (Object as ObjectConstructorWithSetPrototypeOf).setPrototypeOf; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function tryCast(value: unknown, type: new (...args: any[]) => T): T | undefined { + return value instanceof type ? value : undefined; +} + +/** + * Describes the parts that can be used in a `from()` or `with()` call. + * + * In a `with()` call, all optional parts can also be `null`, and all non-optional parts become optional. + * + * @typeParam With - If `true, indicates these parts are used in a `with()` call. + * + * @beta + */ +export type Parts = [With] extends [false] + ? T + : T extends unknown // NOTE: Distributes over `T` + ? Partial + : never; + +/** + * If a part can be removed via a `with()` call, marks that part with `| null` + * + * @typeParam With - If `true, indicates this part is used in a `with()` call. + * + * @beta + */ +export type Part = [With] extends [false] ? T : T | null; + +/** + * Distributes over `T` to get all possible keys of `T`. + */ +type AllKeysOf = T extends unknown ? keyof T : never; + +/** + * Distributes over `T` to get all possible values of `T` with the key `P`. + */ +type AllValuesOf = T extends unknown ? (P extends keyof T ? T[P] : undefined) : never; + +/** + * Distributes over `T` to get all possible properties of every `T`. + */ +type AllParts = { + [P in AllKeysOf]: AllValuesOf; +}; diff --git a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts index dfbc57f4..d82ab54d 100644 --- a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts +++ b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + import { ModuleSource, GlobalSource, @@ -7,9 +9,43 @@ import { ComponentNavigation, DeclarationReference, SymbolReference, - ComponentReference + ComponentReference, + ComponentString, + Component, + ComponentLike, + ComponentPath, + ComponentNavigationParts, + Source } from '../DeclarationReference'; +// aliases to make some of the 'each' tests easier to read +const { from: MOD } = ModuleSource; +const { from: DREF } = DeclarationReference; +const { from: SYM } = SymbolReference; +const { from: CROOT } = ComponentRoot; +const { from: CSTR } = ComponentString; +const { from: CREF } = ComponentReference; + +function CNAV(parts: ComponentNavigationParts): ComponentNavigation; +function CNAV( + parent: ComponentPath, + navigation: '.' | '#' | '~', + component: ComponentLike +): ComponentNavigation; +function CNAV( + ...args: + | [ComponentNavigationParts] + | [ComponentPath, '.' | '#' | '~', ComponentLike] +): ComponentNavigation { + switch (args.length) { + case 3: + const [parent, navigation, component] = args; + return ComponentNavigation.from({ parent, navigation: navigation as Navigation, component }); + case 1: + return ComponentNavigation.from(args[0]); + } +} + describe('parser', () => { it('parse component text', () => { const ref: DeclarationReference = DeclarationReference.parse('abc'); @@ -117,17 +153,24 @@ describe('parser', () => { DeclarationReference.parse('@scope/foo'); }).toThrow(); }); + it.each` + text + ${'!bar..baz'} + ${'!bar.#baz'} + ${'!bar.~baz'} + ${'!bar#.baz'} + ${'!bar##baz'} + ${'!bar#~baz'} + ${'!bar~.baz'} + ${'!bar~#baz'} + ${'!bar~~baz'} + `('parse invalid symbol $text', ({ text }) => { + expect(() => { + DeclarationReference.parse(text); + }).toThrow(); + }); }); -it('add navigation step', () => { - const ref: DeclarationReference = DeclarationReference.empty().addNavigationStep( - Navigation.Members, - ComponentReference.parse('[Symbol.iterator]') - ); - const symbol: SymbolReference = ref.symbol!; - expect(symbol).toBeInstanceOf(SymbolReference); - expect(symbol.componentPath).toBeDefined(); - expect(symbol.componentPath!.component.toString()).toBe('[Symbol.iterator]'); -}); + describe('DeclarationReference', () => { it.each` text | expected @@ -164,7 +207,7 @@ describe('DeclarationReference', () => { ${'"}"'} | ${true} ${'"("'} | ${true} ${'")"'} | ${true} - `('isWellFormedComponentString($text)', ({ text, expected }) => { + `('static isWellFormedComponentString($text)', ({ text, expected }) => { expect(DeclarationReference.isWellFormedComponentString(text)).toBe(expected); }); it.each` @@ -189,7 +232,7 @@ describe('DeclarationReference', () => { ${'[a!b]'} | ${'"[a!b]"'} ${'""'} | ${'"\\"\\""'} ${'"a"'} | ${'"\\"a\\""'} - `('escapeComponentString($text)', ({ text, expected }) => { + `('static escapeComponentString($text)', ({ text, expected }) => { expect(DeclarationReference.escapeComponentString(text)).toBe(expected); }); it.each` @@ -201,7 +244,7 @@ describe('DeclarationReference', () => { ${'"a.b"'} | ${'a.b'} ${'"\\"\\""'} | ${'""'} ${'"\\"a\\""'} | ${'"a"'} - `('unescapeComponentString($text)', ({ text, expected }) => { + `('static unescapeComponentString($text)', ({ text, expected }) => { if (expected === undefined) { expect(() => DeclarationReference.unescapeComponentString(text)).toThrow(); } else { @@ -244,7 +287,7 @@ describe('DeclarationReference', () => { ${'"("'} | ${true} ${'")"'} | ${true} ${'"[a!b]"'} | ${true} - `('isWellFormedModuleSourceString($text)', ({ text, expected }) => { + `('static isWellFormedModuleSourceString($text)', ({ text, expected }) => { expect(DeclarationReference.isWellFormedModuleSourceString(text)).toBe(expected); }); it.each` @@ -269,7 +312,7 @@ describe('DeclarationReference', () => { ${'[a!b]'} | ${'"[a!b]"'} ${'""'} | ${'"\\"\\""'} ${'"a"'} | ${'"\\"a\\""'} - `('escapeModuleSourceString($text)', ({ text, expected }) => { + `('static escapeModuleSourceString($text)', ({ text, expected }) => { expect(DeclarationReference.escapeModuleSourceString(text)).toBe(expected); }); it.each` @@ -282,14 +325,801 @@ describe('DeclarationReference', () => { ${'"a.b"'} | ${'a.b'} ${'"\\"\\""'} | ${'""'} ${'"\\"a\\""'} | ${'"a"'} - `('unescapeModuleSourceString($text)', ({ text, expected }) => { + `('static unescapeModuleSourceString($text)', ({ text, expected }) => { if (expected === undefined) { expect(() => DeclarationReference.unescapeModuleSourceString(text)).toThrow(); } else { expect(DeclarationReference.unescapeModuleSourceString(text)).toBe(expected); } }); + describe('static from()', () => { + it('static from(undefined)', () => { + const declref = DeclarationReference.from(undefined); + expect(declref.isEmpty).toBe(true); + }); + it('static from(string)', () => { + const declref = DeclarationReference.from('a!b'); + expect(declref.source?.toString()).toBe('a!'); + expect(declref.symbol?.toString()).toBe('b'); + }); + it('static from(DeclarationReference)', () => { + const declref1 = DeclarationReference.from('a!b'); + const declref2 = DeclarationReference.from(declref1); + expect(declref2).toBe(declref1); + }); + it('static from({ })', () => { + const declref = DeclarationReference.from({}); + expect(declref.isEmpty).toBe(true); + }); + it('static from({ source })', () => { + const source = MOD('a'); + const declref = DeclarationReference.from({ source }); + expect(declref.source).toBe(source); + }); + it('static from({ packageName })', () => { + const declref = DeclarationReference.from({ packageName: 'a' }); + expect(declref.source).toBeInstanceOf(ModuleSource); + expect(declref.source?.toString()).toBe('a!'); + }); + it('static from({ symbol })', () => { + const symbol = SYM('a'); + const declref = DeclarationReference.from({ symbol }); + expect(declref.symbol).toBe(symbol); + }); + it('static from({ componentPath })', () => { + const declref = DeclarationReference.from({ componentPath: 'a' }); + expect(declref.symbol).toBeDefined(); + expect(declref.symbol?.toString()).toBe('a'); + }); + }); + describe('with()', () => { + describe('with({ })', () => { + it('produces same reference', () => { + const declref = DeclarationReference.from({}); + expect(declref.with({})).toBe(declref); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref.with({}); + expect(declref.source).toBe(source); + expect(declref.navigation).toBe(Navigation.Exports); + expect(declref.symbol).toBe(symbol); + }); + }); + describe('with({ source: })', () => { + it('produces same reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + const declref2 = declref1.with({ source }); + expect(declref2).toBe(declref1); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref1.with({ source }); + expect(declref1.source).toBe(source); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ source: })', () => { + it('produces same reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + const declref2 = declref1.with({ source: MOD('a') }); + expect(declref2).toBe(declref1); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref1.with({ source: MOD('a') }); + expect(declref1.source).toBe(source); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ source: })', () => { + it('produces same reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + const declref2 = declref1.with({ source: 'a' }); + expect(declref2).toBe(declref1); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref1.with({ source: 'a' }); + expect(declref1.source).toBe(source); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ source: })', () => { + it('produces new reference', () => { + const source1 = MOD('a'); + const symbol = SYM('a'); + const source2 = MOD('b'); + const declref1 = DeclarationReference.from({ + source: source1, + navigation: Navigation.Exports, + symbol + }); + const declref2 = declref1.with({ source: source2 }); + expect(declref2).not.toBe(declref1); + expect(declref2.source).toBe(source2); + expect(declref2.navigation).toBe(Navigation.Exports); + expect(declref2.symbol).toBe(symbol); + }); + it('does not change existing reference', () => { + const source1 = MOD('a'); + const source2 = MOD('b'); + const symbol = SYM('a'); + const declref1 = DeclarationReference.from({ + source: source1, + navigation: Navigation.Exports, + symbol + }); + declref1.with({ source: source2 }); + expect(declref1.source).toBe(source1); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ source: })', () => { + it('produces new reference', () => { + const source = MOD('b'); + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ source }); + expect(declref2).not.toBe(declref1); + expect(declref2.source).toBe(source); + }); + }); + describe('with({ source: null })', () => { + it('w/existing source: produces new reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.source).toBeUndefined(); + }); + it('w/existing source: does not change existing reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + declref1.with({ source: null }); + expect(declref1.source).toBe(source); + }); + it('w/o existing source: produces same reference', () => { + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ navigation: Navigation.Exports, symbol }); + const declref2 = declref1.with({ source: null }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: undefined })', () => { + it('w/existing source: produces same reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: undefined }); + expect(declref2).toBe(declref1); + }); + it('w/o existing source: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ source: undefined }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: { packageName: } })', () => { + it('produces same reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { packageName: 'a' } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: { packageName: } })', () => { + it('produces new reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { packageName: 'b' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/o new importPath in package name: does not change importPath', () => { + const source = MOD('a/b'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { packageName: 'c' } }); + expect(declref2.source?.toString()).toBe('c/b!'); + }); + it('w/new importPath in package name: changes importPath', () => { + const source = MOD('a/b'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { packageName: 'c/d' } }); + expect(declref2.source?.toString()).toBe('c/d!'); + }); + }); + describe('with({ source: { packageName: } })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ source: { packageName: 'b' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + }); + describe('with({ source: { unscopedPackageName: } })', () => { + it('w/o scope: produces same reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { unscopedPackageName: 'a' } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: { unscopedPackageName: } })', () => { + it('w/existing source w/o scope: produces new reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ source: { unscopedPackageName: 'b' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/o existing source: produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ source: { unscopedPackageName: 'b' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/existing source w/scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ source: { unscopedPackageName: 'c' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@a/c!'); + }); + it('does not change importPath', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b/c') }); + const declref2 = declref1.with({ source: { unscopedPackageName: 'd' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@a/d/c!'); + }); + }); + describe('with({ source: { scopeName: } })', () => { + it('produces same reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ source: { scopeName: '@a' } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: { scopeName: } })', () => { + it('w/source w/scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ source: { scopeName: '@c' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@c/b!'); + }); + it('w/source w/o scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('a') }); + const declref2 = declref1.with({ source: { scopeName: '@b' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@b/a!'); + }); + it('does not change importPath', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b/c') }); + const declref2 = declref1.with({ source: { scopeName: '@d' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@d/b/c!'); + }); + }); + describe('with({ source: { scopeName: } })', () => { + it('w/existing scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ source: { scopeName: null } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/o existing scope: produces same reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('a') }); + const declref2 = declref1.with({ source: { scopeName: null } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ source: { scopeName: null } })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('a') }); + const declref2 = declref1.with({ source: { scopeName: '@c' } }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@c/a!'); + }); + }); + describe('with({ packageName: })', () => { + it('produces same reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ packageName: 'a' }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ packageName: })', () => { + it('produces new reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ packageName: 'b' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/o new importPath in package name: does not change importPath', () => { + const source = MOD('a/b'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ packageName: 'c' }); + expect(declref2.source?.toString()).toBe('c/b!'); + }); + it('w/new importPath in package name: changes importPath', () => { + const source = MOD('a/b'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ packageName: 'c/d' }); + expect(declref2.source?.toString()).toBe('c/d!'); + }); + }); + describe('with({ packageName: })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ packageName: 'b' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + }); + describe('with({ unscopedPackageName: })', () => { + it('w/o scope: produces same reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ unscopedPackageName: 'a' }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ unscopedPackageName: })', () => { + it('w/o scope: produces new reference', () => { + const source = MOD('a'); + const declref1 = DeclarationReference.from({ source }); + const declref2 = declref1.with({ unscopedPackageName: 'b' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ unscopedPackageName: 'c' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@a/c!'); + }); + it('does not change importPath', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b/c') }); + const declref2 = declref1.with({ unscopedPackageName: 'd' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@a/d/c!'); + }); + }); + describe('with({ unscopedPackageName: })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ unscopedPackageName: 'b' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + }); + describe('with({ scopeName: })', () => { + it('produces same reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ scopeName: '@a' }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ scopeName: })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ scopeName: '@c' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@c/b!'); + }); + it('does not change importPath', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b/c') }); + const declref2 = declref1.with({ scopeName: '@d' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@d/b/c!'); + }); + }); + describe('with({ scopeName: })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('a') }); + const declref2 = declref1.with({ scopeName: '@b' }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('@b/a!'); + }); + }); + describe('with({ scopeName: null })', () => { + it('w/existing scope: produces new reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('@a/b') }); + const declref2 = declref1.with({ scopeName: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.source?.toString()).toBe('b!'); + }); + it('w/o existing scope: produces same reference', () => { + const declref1 = DeclarationReference.from({ source: MOD('a') }); + const declref2 = declref1.with({ scopeName: null }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: })', () => { + it('produces same reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol }); + expect(declref2).toBe(declref1); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref1.with({ symbol }); + expect(declref1.source).toBe(source); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ symbol: })', () => { + it('produces same reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: SYM('a:var') }); + expect(declref2).toBe(declref1); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ source, navigation: Navigation.Exports, symbol }); + declref1.with({ symbol: SYM('b') }); + expect(declref1.source).toBe(source); + expect(declref1.navigation).toBe(Navigation.Exports); + expect(declref1.symbol).toBe(symbol); + }); + }); + describe('with({ symbol: })', () => { + it('produces new reference', () => { + const symbol1 = SYM('a:var'); + const symbol2 = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol: symbol1 }); + const declref2 = declref1.with({ symbol: symbol2 }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol).toBe(symbol2); + }); + it('does not change existing reference', () => { + const source = MOD('a'); + const symbol = SYM('a:var'); + const declref = DeclarationReference.from({ source, symbol }); + declref.with({ symbol: SYM('b:var') }); + expect(declref.source).toBe(source); + expect(declref.symbol).toBe(symbol); + }); + }); + describe('with({ symbol: })', () => { + it('produces new reference', () => { + const symbol2 = SYM('b:var'); + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: symbol2 }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol).toBe(symbol2); + }); + }); + describe('with({ symbol: null })', () => { + it('w/existing symbol: produces new reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol).toBeUndefined(); + }); + it('w/existing symbol: does not change existing reference', () => { + const symbol = SYM('a:var'); + const declref = DeclarationReference.from({ symbol }); + declref.with({ symbol: null }); + expect(declref.symbol).toBe(symbol); + }); + it('w/o existing symbol: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: null }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: { componentPath: } })', () => { + it('produces same reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { componentPath: CROOT(CSTR('a')) } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: { componentPath: } })', () => { + it('produces new reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { componentPath: CROOT(CSTR('b')) } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath?.toString()).toBe('b'); + }); + }); + describe('with({ symbol: { componentPath: } })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: { componentPath: CROOT(CSTR('a')) } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath?.toString()).toBe('a'); + }); + }); + describe('with({ symbol: { componentPath: null } })', () => { + it('produces new reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { componentPath: null } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath).toBeUndefined(); + }); + }); + describe('with({ symbol: { meaning: } })', () => { + it('produces same reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { meaning: Meaning.Variable } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: { meaning: } })', () => { + it('produces new reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { meaning: Meaning.Interface } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Interface); + }); + }); + describe('with({ symbol: { meaning: } })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { meaning: Meaning.Variable } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Variable); + }); + it('w/o symbol: produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: { meaning: Meaning.Variable } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Variable); + }); + }); + describe('with({ symbol: { meaning: null } })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { meaning: null } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBeUndefined(); + }); + it('w/o symbol: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: { meaning: null } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: { overloadIndex: } })', () => { + it('produces same reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { overloadIndex: 0 } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ symbol: { overloadIndex: } })', () => { + it('produces new reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { overloadIndex: 1 } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(1); + }); + }); + describe('with({ symbol: { overloadIndex: } })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { overloadIndex: 0 } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(0); + }); + it('w/o symbol: produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: { overloadIndex: 0 } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(0); + }); + }); + describe('with({ symbol: { overloadIndex: null } })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ symbol: { overloadIndex: null } }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBeUndefined(); + }); + it('w/o symbol: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ symbol: { overloadIndex: null } }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ componentPath: })', () => { + it('produces same reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ componentPath: CROOT(CSTR('a')) }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ componentPath: })', () => { + it('produces new reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ componentPath: CROOT(CSTR('b')) }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath?.toString()).toBe('b'); + }); + }); + describe('with({ componentPath: })', () => { + it('produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ componentPath: CROOT(CSTR('a')) }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath?.toString()).toBe('a'); + }); + }); + describe('with({ componentPath: null })', () => { + it('produces new reference', () => { + const symbol = SYM('a:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ componentPath: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.componentPath).toBeUndefined(); + }); + }); + describe('with({ meaning: })', () => { + it('produces same reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ meaning: Meaning.Variable }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ meaning: })', () => { + it('produces new reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ meaning: Meaning.Interface }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Interface); + }); + }); + describe('with({ meaning: })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ meaning: Meaning.Variable }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Variable); + }); + it('w/o symbol: produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ meaning: Meaning.Variable }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBe(Meaning.Variable); + }); + }); + describe('with({ meaning: null })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b:var'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ meaning: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.meaning).toBeUndefined(); + }); + it('w/o symbol: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ meaning: null }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ overloadIndex: })', () => { + it('produces same reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ overloadIndex: 0 }); + expect(declref2).toBe(declref1); + }); + }); + describe('with({ overloadIndex: })', () => { + it('produces new reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ overloadIndex: 1 }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(1); + }); + }); + describe('with({ overloadIndex: })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ overloadIndex: 0 }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(0); + }); + it('w/o symbol: produces new reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ overloadIndex: 0 }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBe(0); + }); + }); + describe('with({ overloadIndex: null })', () => { + it('w/symbol: produces new reference', () => { + const symbol = SYM('b:0'); + const declref1 = DeclarationReference.from({ symbol }); + const declref2 = declref1.with({ overloadIndex: null }); + expect(declref2).not.toBe(declref1); + expect(declref2.symbol?.overloadIndex).toBeUndefined(); + }); + it('w/o symbol: produces same reference', () => { + const declref1 = DeclarationReference.from({}); + const declref2 = declref1.with({ overloadIndex: null }); + expect(declref2).toBe(declref1); + }); + }); + }); + it('addNavigationStep()', () => { + const symbol1 = SYM('a'); + const symbol2 = symbol1.addNavigationStep(Navigation.Exports, 'b'); + expect(symbol2.componentPath).toBeInstanceOf(ComponentNavigation); + expect((symbol2.componentPath as ComponentNavigation).parent).toBe(symbol1.componentPath); + expect((symbol2.componentPath as ComponentNavigation).navigation).toBe(Navigation.Exports); + expect((symbol2.componentPath as ComponentNavigation).component.toString()).toBe('b'); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${DREF('a!b:var')} | ${undefined} | ${false} + ${DREF('a!b:var')} | ${DREF('a!b:var')} | ${true} + ${DREF('a!b:var')} | ${DREF('a!b')} | ${false} + `('static equals($left, $right)', ({ left, right, expected }) => { + expect(DeclarationReference.equals(left, right)).toBe(expected); + expect(DeclarationReference.equals(right, left)).toBe(expected); + }); }); + +describe('SourceBase', () => { + it('toDeclarationReference()', () => { + const source = MOD('a'); + const symbol = SYM({}); + const declref = source.toDeclarationReference({ + navigation: Navigation.Exports, + symbol + }); + expect(declref.source).toBe(source); + expect(declref.navigation).toBe(Navigation.Exports); + expect(declref.symbol).toBe(symbol); + }); +}); + describe('ModuleSource', () => { it.each` text | packageName | scopeName | unscopedPackageName | importPath @@ -329,4 +1159,924 @@ describe('ModuleSource', () => { expect(source.path).toBe(text); } ); + describe('static from()', () => { + it('static from(string) w/o scope', () => { + const source = ModuleSource.from('a/b'); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('static from(string) w/trailing "!"', () => { + const source = ModuleSource.from('a/b!'); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('static from(string) w/ scope', () => { + const source = ModuleSource.from('@a/b/c'); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('static from(ModuleSource)', () => { + const source1 = ModuleSource.from('a'); + const source2 = ModuleSource.from(source1); + expect(source2).toBe(source1); + }); + it('static from({ packageName: "a/b" })', () => { + const source = ModuleSource.from({ packageName: 'a/b' }); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('static from({ packageName: "@a/b/c" })', () => { + const source = ModuleSource.from({ packageName: '@a/b/c' }); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('static from({ packageName, importPath })', () => { + const source = ModuleSource.from({ packageName: 'a', importPath: 'b' }); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('static from({ unscopedPackageName })', () => { + const source = ModuleSource.from({ unscopedPackageName: 'a', importPath: 'b' }); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('static from({ scopeName, unscopedPackageName, importPath })', () => { + const source = ModuleSource.from({ scopeName: 'a', unscopedPackageName: 'b', importPath: 'c' }); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('static from({ importPath })', () => { + const source = ModuleSource.from({ importPath: '/c' }); + expect(source.packageName).toBe(''); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe(''); + expect(source.importPath).toBe('/c'); + }); + }); + describe('with()', () => { + it('with({ })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({}); + expect(source2).toBe(source1); + }); + it('with({ packageName: })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ packageName: 'a' }); + expect(source2).toBe(source1); + }); + it('with({ packageName: }) w/ existing scopeName', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ packageName: 'd' }); + expect(source2.packageName).toBe('d'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('d'); + expect(source2.importPath).toBe('c'); + }); + it('with({ packageName: }) w/o existing scopeName', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ packageName: 'c' }); + expect(source2.packageName).toBe('c'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('c'); + expect(source2.importPath).toBe('b'); + }); + it('with({ packageName: })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ packageName: 'c/d' }); + expect(source2.packageName).toBe('c'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('c'); + expect(source2.importPath).toBe('d'); + }); + it('with({ scopeName: })', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ scopeName: '@a' }); + expect(source2).toBe(source1); + }); + it('with({ scopeName: }) w/ existing scopeName', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ scopeName: 'd' }); + expect(source2.packageName).toBe('@d/b'); + expect(source2.scopeName).toBe('@d'); + expect(source2.unscopedPackageName).toBe('b'); + expect(source2.importPath).toBe('c'); + }); + it('with({ scopeName: }) w/o existing scopeName', () => { + const source1 = ModuleSource.from('b/c'); + const source2 = source1.with({ scopeName: 'd' }); + expect(source2.packageName).toBe('@d/b'); + expect(source2.scopeName).toBe('@d'); + expect(source2.unscopedPackageName).toBe('b'); + expect(source2.importPath).toBe('c'); + }); + it('with({ scopeName: null }) w/ existing scopeName', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ scopeName: null }); + expect(source2.packageName).toBe('b'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('b'); + expect(source2.importPath).toBe('c'); + }); + it('with({ unscopedPackageName: })', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ unscopedPackageName: 'b' }); + expect(source2).toBe(source1); + }); + it('with({ unscopedPackageName: }) w/ scopeName', () => { + const source1 = ModuleSource.from('@a/b/c'); + const source2 = source1.with({ unscopedPackageName: 'd' }); + expect(source2.packageName).toBe('@a/d'); + expect(source2.scopeName).toBe('@a'); + expect(source2.unscopedPackageName).toBe('d'); + expect(source2.importPath).toBe('c'); + }); + it('with({ unscopedPackageName: }) w/o scopeName', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ unscopedPackageName: 'c' }); + expect(source2.packageName).toBe('c'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('c'); + expect(source2.importPath).toBe('b'); + }); + it('with({ importPath: })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ importPath: 'b' }); + expect(source2).toBe(source1); + }); + it('with({ importPath: })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ importPath: 'c' }); + expect(source2.packageName).toBe('a'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('a'); + expect(source2.importPath).toBe('c'); + }); + it('with({ importPath: null })', () => { + const source1 = ModuleSource.from('a/b'); + const source2 = source1.with({ importPath: null }); + expect(source2.packageName).toBe('a'); + expect(source2.scopeName).toBe(''); + expect(source2.unscopedPackageName).toBe('a'); + expect(source2.importPath).toBe(''); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${MOD('a')} | ${undefined} | ${false} + ${MOD('a')} | ${MOD('a')} | ${true} + ${MOD('a')} | ${MOD('a/b')} | ${false} + `('static equals($left, $right)', ({ left, right, expected }) => { + expect(ModuleSource.equals(left, right)).toBe(expected); + expect(ModuleSource.equals(right, left)).toBe(expected); + }); +}); + +describe('Source', () => { + describe('from()', () => { + it('from("!")', () => { + const source = Source.from('!'); + expect(source).toBe(GlobalSource.instance); + }); + it('from(string) w/o scope', () => { + const source = Source.from('a/b') as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('from(string) w/trailing "!"', () => { + const source = Source.from('a/b!') as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('from(string) w/ scope', () => { + const source = Source.from('@a/b/c') as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('from(ModuleSource)', () => { + const source1 = ModuleSource.from('a'); + const source2 = Source.from(source1); + expect(source2).toBe(source1); + }); + it('from(GlobalSource)', () => { + const source1 = GlobalSource.instance; + const source2 = Source.from(source1); + expect(source2).toBe(source1); + }); + it('from({ packageName: "a/b" })', () => { + const source = Source.from({ packageName: 'a/b' }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('from({ packageName: "@a/b/c" })', () => { + const source = Source.from({ packageName: '@a/b/c' }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('from({ packageName, importPath })', () => { + const source = Source.from({ packageName: 'a', importPath: 'b' }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('from({ unscopedPackageName })', () => { + const source = Source.from({ unscopedPackageName: 'a', importPath: 'b' }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('a'); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe('a'); + expect(source.importPath).toBe('b'); + }); + it('from({ scopeName, unscopedPackageName, importPath })', () => { + const source = Source.from({ + scopeName: 'a', + unscopedPackageName: 'b', + importPath: 'c' + }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe('@a/b'); + expect(source.scopeName).toBe('@a'); + expect(source.unscopedPackageName).toBe('b'); + expect(source.importPath).toBe('c'); + }); + it('from({ importPath })', () => { + const source = Source.from({ importPath: '/c' }) as ModuleSource; + expect(source).toBeInstanceOf(ModuleSource); + expect(source.packageName).toBe(''); + expect(source.scopeName).toBe(''); + expect(source.unscopedPackageName).toBe(''); + expect(source.importPath).toBe('/c'); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${GlobalSource.instance} | ${undefined} | ${false} + ${GlobalSource.instance} | ${GlobalSource.instance} | ${true} + ${MOD('a')} | ${undefined} | ${false} + ${MOD('a')} | ${MOD('a')} | ${true} + ${MOD('a')} | ${GlobalSource.instance} | ${false} + ${MOD('a')} | ${MOD('a/b')} | ${false} + `('equals($left, $right)', ({ left, right, expected }) => { + expect(Source.equals(left, right)).toBe(expected); + expect(Source.equals(right, left)).toBe(expected); + }); +}); + +describe('SymbolReference', () => { + it('static empty()', () => { + const symbol = SymbolReference.empty(); + expect(symbol.componentPath).toBeUndefined(); + expect(symbol.meaning).toBeUndefined(); + expect(symbol.overloadIndex).toBeUndefined(); + }); + describe('static from()', () => { + it('static from({ })', () => { + const symbol = SymbolReference.from({}); + expect(symbol.componentPath).toBeUndefined(); + expect(symbol.meaning).toBeUndefined(); + expect(symbol.overloadIndex).toBeUndefined(); + }); + it('static from({ componentPath })', () => { + const componentPath = CROOT(CSTR('a')); + const symbol = SymbolReference.from({ componentPath }); + expect(symbol.componentPath).toBe(componentPath); + expect(symbol.meaning).toBeUndefined(); + expect(symbol.overloadIndex).toBeUndefined(); + }); + it('static from({ meaning })', () => { + const symbol = SymbolReference.from({ meaning: Meaning.Variable }); + expect(symbol.componentPath).toBeUndefined(); + expect(symbol.meaning).toBe(Meaning.Variable); + expect(symbol.overloadIndex).toBeUndefined(); + }); + it('static from(SymbolReference)', () => { + const symbol1 = SYM({}); + const symbol2 = SymbolReference.from(symbol1); + expect(symbol2).toBe(symbol1); + }); + }); + describe('with()', () => { + it('with({ })', () => { + const symbol = SYM({}); + const updated = symbol.with({}); + expect(updated).toBe(symbol); + }); + it('with({ componentPath: })', () => { + const componentPath = CROOT(CSTR('a')); + const symbol = SYM({ componentPath }); + const updated = symbol.with({ componentPath }); + expect(updated).toBe(symbol); + }); + it('with({ componentPath: })', () => { + const componentPath = CROOT(CSTR('a')); + const symbol = SYM({ componentPath }); + const updated = symbol.with({ componentPath: CROOT(CSTR('a')) }); + expect(updated).toBe(symbol); + }); + it('with({ componentPath: null })', () => { + const componentPath = CROOT(CSTR('a')); + const symbol = SYM({ componentPath }); + const updated = symbol.with({ componentPath: null }); + expect(updated).not.toBe(symbol); + expect(updated.componentPath).toBeUndefined(); + }); + it('with({ meaning: })', () => { + const symbol = SYM({ meaning: Meaning.Variable }); + const updated = symbol.with({ meaning: Meaning.Variable }); + expect(updated).toBe(symbol); + }); + it('with({ overloadIndex: })', () => { + const symbol = SYM({ overloadIndex: 0 }); + const updated = symbol.with({ overloadIndex: 0 }); + expect(updated).toBe(symbol); + }); + }); + it('withComponentPath()', () => { + const root = CROOT(CSTR('a')); + const symbol = SYM({}); + const updated = symbol.withComponentPath(root); + expect(updated).not.toBe(symbol); + expect(updated.componentPath).toBe(root); + }); + it('withMeaning()', () => { + const symbol = SYM({}); + const updated = symbol.withMeaning(Meaning.Variable); + expect(updated).not.toBe(symbol); + expect(updated.meaning).toBe(Meaning.Variable); + }); + it('withOverloadIndex()', () => { + const symbol = SYM({}); + const updated = symbol.withOverloadIndex(0); + expect(updated).not.toBe(symbol); + expect(updated.overloadIndex).toBe(0); + }); + it('withSource()', () => { + const symbol = SYM({}); + const source = ModuleSource.fromPackage('a'); + const declref = symbol.withSource(source); + expect(declref.source).toBe(source); + expect(declref.symbol).toBe(symbol); + }); + it('addNavigationStep()', () => { + const root = CROOT(CSTR('a')); + const component = CSTR('b'); + const symbol = SYM({ componentPath: root }); + const step = symbol.addNavigationStep(Navigation.Exports, component); + expect(step.componentPath).toBeInstanceOf(ComponentNavigation); + expect((step.componentPath as ComponentNavigation).parent).toBe(root); + expect((step.componentPath as ComponentNavigation).navigation).toBe(Navigation.Exports); + expect((step.componentPath as ComponentNavigation).component).toBe(component); + }); + it('toDeclarationReference()', () => { + const root = CROOT(CSTR('a')); + const symbol = SYM({ componentPath: root }); + const source = ModuleSource.fromPackage('b'); + const declref = symbol.toDeclarationReference({ + source, + navigation: Navigation.Exports + }); + expect(declref.source).toBe(source); + expect(declref.navigation).toBe(Navigation.Exports); + expect(declref.symbol).toBe(symbol); + }); +}); + +describe('ComponentPathBase', () => { + it('addNavigationStep()', () => { + const root = CROOT(CSTR('a')); + const component = CSTR('b'); + const step = root.addNavigationStep(Navigation.Exports, component); + expect(step.parent).toBe(root); + expect(step.navigation).toBe(Navigation.Exports); + expect(step.component).toBe(component); + }); + it('withMeaning()', () => { + const component = CROOT(CSTR('a')); + const symbol = component.withMeaning(Meaning.Variable); + expect(symbol.componentPath).toBe(component); + expect(symbol.meaning).toBe(Meaning.Variable); + }); + it('withOverloadIndex()', () => { + const component = CROOT(CSTR('a')); + const symbol = component.withOverloadIndex(0); + expect(symbol.componentPath).toBe(component); + expect(symbol.overloadIndex).toBe(0); + }); + it('withSource()', () => { + const component = CROOT(CSTR('a')); + const source = ModuleSource.fromPackage('b'); + const declref = component.withSource(source); + expect(declref.source).toBe(source); + expect(declref.navigation).toBe(Navigation.Exports); + expect(declref.symbol?.componentPath).toBe(component); + }); + it('toSymbolReference()', () => { + const component = CROOT(CSTR('a')); + const symbol = component.toSymbolReference({ + meaning: Meaning.Variable, + overloadIndex: 0 + }); + expect(symbol.componentPath).toBe(component); + expect(symbol.meaning).toBe(Meaning.Variable); + expect(symbol.overloadIndex).toBe(0); + }); + it('toDeclarationReference()', () => { + const component = CROOT(CSTR('a')); + const source = ModuleSource.fromPackage('b'); + const declref = component.toDeclarationReference({ + source, + navigation: Navigation.Exports, + meaning: Meaning.Variable, + overloadIndex: 0 + }); + expect(declref.source).toBe(source); + expect(declref.navigation).toBe(Navigation.Exports); + expect(declref.symbol).toBeDefined(); + expect(declref.symbol?.componentPath).toBe(component); + expect(declref.symbol?.meaning).toBe(Meaning.Variable); + expect(declref.symbol?.overloadIndex).toBe(0); + }); +}); + +describe('ComponentRoot', () => { + it('root', () => { + const component = new ComponentString('a'); + const root = new ComponentRoot(component); + expect(root.root).toBe(root); + }); + describe('static from()', () => { + it('static from({ component })', () => { + const component = Component.from('a'); + const componentPath = ComponentRoot.from({ component }); + expect(componentPath).toBeInstanceOf(ComponentRoot); + expect(componentPath.component).toBe(component); + }); + it('static from(ComponentRoot)', () => { + const component = Component.from('a'); + const root = new ComponentRoot(component); + const componentPath = ComponentRoot.from(root); + expect(componentPath).toBeInstanceOf(ComponentRoot); + expect(componentPath).toBe(root); + }); + }); + describe('with()', () => { + it('with({ })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const updated = root.with({}); + expect(updated).toBe(root); + }); + it('with({ component: })', () => { + const component = Component.from('a'); + const root = ComponentRoot.from({ component }); + const updated = root.with({ component }); + expect(updated).toBe(root); + }); + it('with({ component: Component })', () => { + const component = Component.from('a'); + const root = ComponentRoot.from({ component }); + const newComponent = Component.from('b'); + const updated = root.with({ component: newComponent }); + expect(updated).not.toBe(root); + expect(updated.component).toBe(newComponent); + }); + it('with({ component: DeclarationReference })', () => { + const component = Component.from('a'); + const root = ComponentRoot.from({ component }); + const reference = DeclarationReference.parse('b'); + const updated = root.with({ component: reference }); + expect(updated).not.toBe(root); + expect(updated.component).toBeInstanceOf(ComponentReference); + expect((updated.component as ComponentReference).reference).toBe(reference); + }); + it('with({ component: string })', () => { + const component = Component.from('a'); + const root = ComponentRoot.from({ component }); + const updated = root.with({ component: 'b' }); + expect(updated).not.toBe(root); + expect(updated.component).toBeInstanceOf(ComponentString); + expect((updated.component as ComponentString).text).toBe('b'); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CROOT(CSTR('a'))} | ${undefined} | ${false} + ${CROOT(CSTR('a'))} | ${CROOT(CSTR('a'))} | ${true} + ${CROOT(CSTR('a'))} | ${CROOT(CSTR('b'))} | ${false} + ${CROOT(CSTR('a'))} | ${CROOT(CREF('[a]'))} | ${false} + `('static equals(left, right) $#', ({ left, right, expected }) => { + expect(ComponentRoot.equals(left, right)).toBe(expected); + expect(ComponentRoot.equals(right, left)).toBe(expected); + }); +}); + +describe('ComponentNavigation', () => { + it('root', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + expect(step.root).toBe(root); + }); + describe('static from()', () => { + it('static from(parts)', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + expect(step.parent).toBe(root); + expect(step.navigation).toBe(Navigation.Exports); + expect(step.component).toBeInstanceOf(ComponentString); + expect((step.component as ComponentString).text).toBe('b'); + }); + it('static from(ComponentNavigation)', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const result = ComponentNavigation.from(step); + expect(result).toBe(step); + }); + }); + describe('with()', () => { + it('with({ })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const updated = step.with({}); + expect(updated).toBe(step); + }); + it('with({ parent: })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const updated = step.with({ parent: root }); + expect(updated).toBe(step); + }); + it('with({ parent: })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const newRoot = ComponentRoot.from({ component: 'c' }); + const updated = step.with({ parent: newRoot }); + expect(updated).not.toBe(step); + expect(updated.parent).toBe(newRoot); + }); + it('with({ navigation: })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const updated = step.with({ navigation: Navigation.Exports }); + expect(updated).toBe(step); + }); + it('with({ navigation: })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const updated = step.with({ navigation: Navigation.Members }); + expect(updated).not.toBe(step); + expect(updated.navigation).toBe(Navigation.Members); + }); + it('with({ component: })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const component = Component.from('b'); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component }); + const updated = step.with({ component }); + expect(updated).toBe(step); + }); + it('with({ component: new Component })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const component = Component.from('b'); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component }); + const newComponent = Component.from('c'); + const updated = step.with({ component: newComponent }); + expect(updated).not.toBe(step); + expect(updated.component).toBe(newComponent); + }); + it('with({ component: string })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const updated = step.with({ component: 'c' }); + expect(updated).not.toBe(step); + expect(updated.component).toBeInstanceOf(ComponentString); + expect((updated.component as ComponentString).text).toBe('c'); + }); + it('with({ component: DeclarationReference })', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentNavigation.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const reference = DeclarationReference.parse('c'); + const updated = step.with({ component: reference }); + expect(updated).not.toBe(step); + expect(updated.component).toBeInstanceOf(ComponentReference); + expect((updated.component as ComponentReference).reference).toBe(reference); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${undefined} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('b'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'a' })} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'b' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'a' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', 'a')} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', 'b')} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '#', CSTR('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[b]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'a' })} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'b' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'a' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('a'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('b'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', 'a')} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '#', CREF('[a]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CREF('[a]')), '.', CSTR('a'))} | ${false} + `('static equals(left, right) $#', ({ left, right, expected }) => { + expect(ComponentNavigation.equals(left, right)).toBe(expected); + expect(ComponentNavigation.equals(right, left)).toBe(expected); + }); +}); + +describe('ComponentPath', () => { + describe('static from()', () => { + it('from({ component })', () => { + const component = Component.from('a'); + const componentPath = ComponentPath.from({ component }); + expect(componentPath).toBeInstanceOf(ComponentRoot); + expect(componentPath.component).toBe(component); + }); + it('from(ComponentRoot)', () => { + const component = Component.from('a'); + const root = new ComponentRoot(component); + const componentPath = ComponentPath.from(root); + expect(componentPath).toBe(root); + }); + it('from(string)', () => { + const componentPath = ComponentPath.from('a.b.[c]'); + const pathABC = componentPath as ComponentNavigation; + const pathAB = pathABC?.parent as ComponentNavigation; + const pathA = pathAB?.parent as ComponentRoot; + expect(pathABC).toBeInstanceOf(ComponentNavigation); + expect(pathABC.component).toBeInstanceOf(ComponentReference); + expect(pathABC.component.toString()).toBe('[c]'); + expect(pathAB).toBeInstanceOf(ComponentNavigation); + expect(pathAB.component).toBeInstanceOf(ComponentString); + expect(pathAB.component.toString()).toBe('b'); + expect(pathA).toBeInstanceOf(ComponentRoot); + expect(pathA.component).toBeInstanceOf(ComponentString); + expect(pathA.component.toString()).toBe('a'); + }); + it('from(parts)', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentPath.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + expect(step).toBeInstanceOf(ComponentNavigation); + expect((step as ComponentNavigation).parent).toBe(root); + expect((step as ComponentNavigation).navigation).toBe(Navigation.Exports); + expect(step.component).toBeInstanceOf(ComponentString); + expect((step.component as ComponentString).text).toBe('b'); + }); + it('from(ComponentNavigation)', () => { + const root = ComponentRoot.from({ component: 'a' }); + const step = ComponentPath.from({ parent: root, navigation: Navigation.Exports, component: 'b' }); + const result = ComponentPath.from(step); + expect(result).toBe(step); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CROOT(CSTR('a'))} | ${undefined} | ${false} + ${CROOT(CSTR('a'))} | ${CROOT(CSTR('a'))} | ${true} + ${CROOT(CSTR('a'))} | ${CROOT(CSTR('b'))} | ${false} + ${CROOT(CSTR('a'))} | ${CROOT(CREF('[a]'))} | ${false} + ${CROOT(CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${false} + ${undefined} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${undefined} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('b'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'a' })} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'b' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'a' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', 'a')} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '.', 'b')} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CSTR('a')), '#', CSTR('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CREF('[b]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'a' })} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { reference: 'b' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', { text: 'a' })} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('a'))} | ${true} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', DREF('b'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '.', 'a')} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CREF('[a]'))} | ${CNAV(CROOT(CSTR('a')), '#', CREF('[a]'))} | ${false} + ${CNAV(CROOT(CSTR('a')), '.', CSTR('a'))} | ${CNAV(CROOT(CREF('[a]')), '.', CSTR('a'))} | ${false} + `('equals(left, right) $#', ({ left, right, expected }) => { + expect(ComponentPath.equals(left, right)).toBe(expected); + expect(ComponentPath.equals(right, left)).toBe(expected); + }); +}); + +describe('ComponentBase', () => { + it('toComponentPath()', () => { + const component = new ComponentString('a'); + const componentPath = component.toComponentPath(); + expect(componentPath).toBeInstanceOf(ComponentRoot); + expect(componentPath.component).toBe(component); + }); + it('toComponentPath(parts)', () => { + const parent = new ComponentRoot(new ComponentString('a')); + const component = new ComponentString('b'); + const componentPath = component.toComponentPath({ parent, navigation: Navigation.Exports }); + expect(componentPath).toBeInstanceOf(ComponentNavigation); + expect(componentPath.component).toBe(component); + expect((componentPath as ComponentNavigation).parent).toBe(parent); + expect((componentPath as ComponentNavigation).navigation).toBe(Navigation.Exports); + }); +}); + +describe('ComponentString', () => { + describe('static from()', () => { + it.each` + parts | expected + ${''} | ${'""'} + ${{ text: '' }} | ${'""'} + ${'a'} | ${'a'} + ${{ text: 'a' }} | ${'a'} + ${'['} | ${'"["'} + ${{ text: '[' }} | ${'"["'} + `('static from($parts)', ({ parts, expected }) => { + const component = ComponentString.from(parts); + const actual = component.toString(); + expect(actual).toBe(expected); + }); + it('static from(ComponentString)', () => { + const component = new ComponentString('a'); + const actual = ComponentString.from(component); + expect(actual).toBe(component); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CSTR('a')} | ${undefined} | ${false} + ${CSTR('a')} | ${CSTR('a')} | ${true} + ${CSTR('a')} | ${CSTR('b')} | ${false} + `('static equals(left, right) $#', ({ left, right, expected }) => { + expect(ComponentString.equals(left, right)).toBe(expected); + expect(ComponentString.equals(right, left)).toBe(expected); + }); +}); + +describe('ComponentReference', () => { + describe('static from()', () => { + it.each` + parts | expected + ${'[a]'} | ${'[a]'} + ${DeclarationReference.parse('a')} | ${'[a]'} + ${{ reference: 'a' }} | ${'[a]'} + ${{ reference: DeclarationReference.parse('a') }} | ${'[a]'} + `('static from($parts)', ({ parts, expected }) => { + const component = ComponentReference.from(parts); + const actual = component.toString(); + expect(actual).toBe(expected); + }); + }); + describe('with()', () => { + it('with({ })', () => { + const component = ComponentReference.parse('[a]'); + const updated = component.with({}); + expect(updated).toBe(component); + }); + it('with({ reference: same DeclarationReference })', () => { + const component = ComponentReference.parse('[a]'); + const updated = component.with({ reference: component.reference }); + expect(updated).toBe(component); + }); + it('with({ reference: equivalent DeclarationReference })', () => { + const component = ComponentReference.parse('[a]'); + const updated = component.with({ reference: DeclarationReference.parse('a') }); + expect(updated).toBe(component); + }); + it('with({ reference: equivalent string })', () => { + const component = ComponentReference.parse('[a]'); + const updated = component.with({ reference: 'a' }); + expect(updated).toBe(component); + }); + it('with({ reference: different DeclarationReference })', () => { + const reference = DeclarationReference.parse('a'); + const component = new ComponentReference(reference); + const newReference = DeclarationReference.parse('b'); + const updated = component.with({ reference: newReference }); + expect(updated).not.toBe(component); + expect(updated.reference).toBe(newReference); + }); + it('with({ reference: different string })', () => { + const reference = DeclarationReference.parse('a'); + const component = new ComponentReference(reference); + const updated = component.with({ reference: 'b' }); + expect(updated).not.toBe(component); + expect(updated.reference).not.toBe(reference); + expect(updated.reference.toString()).toBe('b'); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CREF('[a]')} | ${undefined} | ${false} + ${CREF('[a]')} | ${CREF('[a]')} | ${true} + ${CREF('[a]')} | ${CREF('[b]')} | ${false} + ${CREF('[a]')} | ${CREF({ reference: 'a' })} | ${true} + ${CREF('[a]')} | ${CREF({ reference: 'b' })} | ${false} + ${CREF('[a]')} | ${CREF({ reference: DREF('a') })} | ${true} + ${CREF('[a]')} | ${CREF({ reference: DREF('b') })} | ${false} + `('static equals(left, right) $#', ({ left, right, expected }) => { + expect(ComponentReference.equals(left, right)).toBe(expected); + expect(ComponentReference.equals(right, left)).toBe(expected); + }); +}); + +describe('Component', () => { + describe('static from()', () => { + it('from({ text: string })', () => { + const component = Component.from({ text: 'a' }); + expect(component).toBeInstanceOf(ComponentString); + expect(component.toString()).toBe('a'); + }); + it('from({ reference: string })', () => { + const component = Component.from({ reference: 'a' }); + expect(component).toBeInstanceOf(ComponentReference); + expect(component.toString()).toBe('[a]'); + }); + it('from({ reference: DeclarationReference })', () => { + const reference = DeclarationReference.parse('a'); + const component = Component.from({ reference }); + expect(component).toBeInstanceOf(ComponentReference); + expect((component as ComponentReference).reference).toBe(reference); + }); + it('from(string)', () => { + const component = Component.from('a'); + expect(component).toBeInstanceOf(ComponentString); + expect(component.toString()).toBe('a'); + }); + it('from(DeclarationReference)', () => { + const reference = DeclarationReference.parse('a'); + const component = Component.from(reference); + expect(component).toBeInstanceOf(ComponentReference); + expect((component as ComponentReference).reference).toBe(reference); + }); + it('from(Component)', () => { + const component = new ComponentString('a'); + const result = Component.from(component); + expect(result).toBe(component); + }); + }); + it.each` + left | right | expected + ${undefined} | ${undefined} | ${true} + ${CSTR('a')} | ${undefined} | ${false} + ${CSTR('a')} | ${CREF('[a]')} | ${false} + ${CSTR('a')} | ${CSTR('a')} | ${true} + ${CSTR('a')} | ${CSTR('b')} | ${false} + ${CREF('[a]')} | ${undefined} | ${false} + ${CREF('[a]')} | ${CREF('[a]')} | ${true} + ${CREF('[a]')} | ${CREF('[b]')} | ${false} + ${CREF('[a]')} | ${CREF({ reference: 'a' })} | ${true} + ${CREF('[a]')} | ${CREF({ reference: 'b' })} | ${false} + ${CREF('[a]')} | ${CREF({ reference: DREF('a') })} | ${true} + ${CREF('[a]')} | ${CREF({ reference: DREF('b') })} | ${false} + `('equals(left, right) $#', ({ left, right, expected }) => { + expect(Component.equals(left, right)).toBe(expected); + expect(Component.equals(right, left)).toBe(expected); + }); }); diff --git a/tsdoc/src/configuration/TSDocConfiguration.ts b/tsdoc/src/configuration/TSDocConfiguration.ts index 93dc7b7a..21b44e8b 100644 --- a/tsdoc/src/configuration/TSDocConfiguration.ts +++ b/tsdoc/src/configuration/TSDocConfiguration.ts @@ -15,6 +15,7 @@ export class TSDocConfiguration { private readonly _validation: TSDocValidationConfiguration; private readonly _docNodeManager: DocNodeManager; private readonly _supportedHtmlElements: Set; + private _parseBetaDeclarationReferences: boolean | 'prefer' = false; public constructor() { this._tagDefinitions = []; @@ -254,4 +255,12 @@ export class TSDocConfiguration { } throw new Error('The specified TSDocTagDefinition is not defined for this TSDocConfiguration'); } + + public get parseBetaDeclarationReferences(): boolean | 'prefer' { + return this._parseBetaDeclarationReferences; + } + + public set parseBetaDeclarationReferences(value: boolean | 'prefer') { + this._parseBetaDeclarationReferences = value; + } } diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index bec376d2..5d8cacf1 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -144,15 +144,19 @@ export class TSDocEmitter { case DocNodeKind.DeclarationReference: const docDeclarationReference: DocDeclarationReference = docNode as DocDeclarationReference; - this._writeContent(docDeclarationReference.packageName); - this._writeContent(docDeclarationReference.importPath); - if ( - docDeclarationReference.packageName !== undefined || - docDeclarationReference.importPath !== undefined - ) { - this._writeContent('#'); + if (docDeclarationReference.declarationReference) { + this._writeContent(docDeclarationReference.declarationReference.toString()); + } else { + this._writeContent(docDeclarationReference.packageName); + this._writeContent(docDeclarationReference.importPath); + if ( + docDeclarationReference.packageName !== undefined || + docDeclarationReference.importPath !== undefined + ) { + this._writeContent('#'); + } + this._renderNodes(docDeclarationReference.memberReferences); } - this._renderNodes(docDeclarationReference.memberReferences); break; case DocNodeKind.ErrorText: diff --git a/tsdoc/src/nodes/DocDeclarationReference.ts b/tsdoc/src/nodes/DocDeclarationReference.ts index 4c213163..734c2343 100644 --- a/tsdoc/src/nodes/DocDeclarationReference.ts +++ b/tsdoc/src/nodes/DocDeclarationReference.ts @@ -3,6 +3,7 @@ import { DocMemberReference } from './DocMemberReference'; import { TokenSequence } from '../parser/TokenSequence'; import { DocExcerpt, ExcerptKind } from './DocExcerpt'; import { StringBuilder } from '../emitters/StringBuilder'; +import { DeclarationReference, ModuleSource } from '../beta/DeclarationReference'; /** * Constructor parameters for {@link DocDeclarationReference}. @@ -13,6 +14,14 @@ export interface IDocDeclarationReferenceParameters extends IDocNodeParameters { memberReferences?: DocMemberReference[]; } +/** + * Constructor parameters for {@link DocDeclarationReference}. + * @beta + */ +export interface IBetaDocDeclarationReferenceParameters extends IDocNodeParameters { + declarationReference: DeclarationReference; +} + /** * Constructor parameters for {@link DocDeclarationReference}. */ @@ -24,6 +33,15 @@ export interface IDocDeclarationReferenceParsedParameters extends IDocNodeParsed memberReferences?: DocMemberReference[]; } +/** + * Constructor parameters for {@link DocDeclarationReference}. + * @beta + */ +export interface IBetaDocDeclarationReferenceParsedParameters extends IDocNodeParsedParameters { + declarationReferenceExcerpt: TokenSequence; + declarationReference?: DeclarationReference; +} + /** * Represents a declaration reference. * @@ -41,54 +59,75 @@ export class DocDeclarationReference extends DocNode { private readonly _importHashExcerpt: DocExcerpt | undefined; private readonly _spacingAfterImportHashExcerpt: DocExcerpt | undefined; - private readonly _memberReferences: DocMemberReference[]; + private _memberReferences: DocMemberReference[] | undefined; + + private readonly _declarationReference: DeclarationReference | undefined; + private readonly _declarationReferenceExcerpt: DocExcerpt | undefined; /** * Don't call this directly. Instead use {@link TSDocParser} * @internal */ public constructor( - parameters: IDocDeclarationReferenceParameters | IDocDeclarationReferenceParsedParameters + parameters: + | IDocDeclarationReferenceParameters + | IDocDeclarationReferenceParsedParameters + | IBetaDocDeclarationReferenceParameters + | IBetaDocDeclarationReferenceParsedParameters ) { super(parameters); if (DocNode.isParsedParameters(parameters)) { - if (parameters.packageNameExcerpt) { - this._packageNameExcerpt = new DocExcerpt({ - configuration: this.configuration, - excerptKind: ExcerptKind.DeclarationReference_PackageName, - content: parameters.packageNameExcerpt - }); - } - if (parameters.importPathExcerpt) { - this._importPathExcerpt = new DocExcerpt({ - configuration: this.configuration, - excerptKind: ExcerptKind.DeclarationReference_ImportPath, - content: parameters.importPathExcerpt - }); - } - if (parameters.importHashExcerpt) { - this._importHashExcerpt = new DocExcerpt({ - configuration: this.configuration, - excerptKind: ExcerptKind.DeclarationReference_ImportHash, - content: parameters.importHashExcerpt - }); - } - if (parameters.spacingAfterImportHashExcerpt) { - this._spacingAfterImportHashExcerpt = new DocExcerpt({ + if ('declarationReferenceExcerpt' in parameters) { + this._declarationReferenceExcerpt = new DocExcerpt({ configuration: this.configuration, - excerptKind: ExcerptKind.Spacing, - content: parameters.spacingAfterImportHashExcerpt + excerptKind: ExcerptKind.DeclarationReference_DeclarationReference, + content: parameters.declarationReferenceExcerpt }); + this._declarationReference = + parameters.declarationReference ?? + DeclarationReference.parse(this._declarationReferenceExcerpt.content.toString()); + } else { + if (parameters.packageNameExcerpt) { + this._packageNameExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.DeclarationReference_PackageName, + content: parameters.packageNameExcerpt + }); + } + if (parameters.importPathExcerpt) { + this._importPathExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.DeclarationReference_ImportPath, + content: parameters.importPathExcerpt + }); + } + if (parameters.importHashExcerpt) { + this._importHashExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.DeclarationReference_ImportHash, + content: parameters.importHashExcerpt + }); + } + if (parameters.spacingAfterImportHashExcerpt) { + this._spacingAfterImportHashExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.Spacing, + content: parameters.spacingAfterImportHashExcerpt + }); + } + if (parameters.memberReferences) { + this._memberReferences = parameters.memberReferences.slice(); + } } + } else if ('declarationReference' in parameters) { + this._declarationReference = parameters.declarationReference; } else { this._packageName = parameters.packageName; this._importPath = parameters.importPath; - } - - this._memberReferences = []; - if (parameters.memberReferences) { - this._memberReferences.push(...parameters.memberReferences); + if (parameters.memberReferences) { + this._memberReferences = parameters.memberReferences.slice(); + } } } @@ -103,6 +142,12 @@ export class DocDeclarationReference extends DocNode { * Example: `"@scope/my-package"` */ public get packageName(): string | undefined { + if (this.declarationReference) { + if (this.declarationReference.source instanceof ModuleSource) { + return this.declarationReference.source.packageName; + } + return undefined; + } if (this._packageName === undefined) { if (this._packageNameExcerpt !== undefined) { this._packageName = this._packageNameExcerpt.content.toString(); @@ -121,6 +166,11 @@ export class DocDeclarationReference extends DocNode { * Example: `"../path2/path2"` */ public get importPath(): string | undefined { + if (this.declarationReference) { + if (this.declarationReference.source instanceof ModuleSource) { + return this.declarationReference.source.importPath; + } + } if (this._importPath === undefined) { if (this._importPathExcerpt !== undefined) { this._importPath = this._importPathExcerpt.content.toString(); @@ -135,9 +185,21 @@ export class DocDeclarationReference extends DocNode { * because the reference refers to a module. */ public get memberReferences(): ReadonlyArray { + if (!this._memberReferences) { + this._memberReferences = + this._declarationReference?.symbol?.toDocMemberReferences(this.configuration) ?? []; + } return this._memberReferences; } + /** + * Gets the beta DeclarationReference for this reference. + * @beta + */ + public get declarationReference(): DeclarationReference | undefined { + return this._declarationReference; + } + /** * Generates the TSDoc representation of this declaration reference. */ @@ -151,13 +213,15 @@ export class DocDeclarationReference extends DocNode { /** @override */ protected onGetChildNodes(): ReadonlyArray { - return [ - this._packageNameExcerpt, - this._importPathExcerpt, - this._importHashExcerpt, - this._spacingAfterImportHashExcerpt, - ...this._memberReferences - ]; + return this._declarationReferenceExcerpt + ? [this._declarationReferenceExcerpt] + : [ + this._packageNameExcerpt, + this._importPathExcerpt, + this._importHashExcerpt, + this._spacingAfterImportHashExcerpt, + ...(this._memberReferences ?? []) + ]; } } diff --git a/tsdoc/src/nodes/DocExcerpt.ts b/tsdoc/src/nodes/DocExcerpt.ts index df21c29b..06ed0abe 100644 --- a/tsdoc/src/nodes/DocExcerpt.ts +++ b/tsdoc/src/nodes/DocExcerpt.ts @@ -19,6 +19,7 @@ export enum ExcerptKind { DeclarationReference_PackageName = 'DeclarationReference_PackageName', DeclarationReference_ImportPath = 'DeclarationReference_ImportPath', DeclarationReference_ImportHash = 'DeclarationReference_ImportHash', + DeclarationReference_DeclarationReference = 'DeclarationReference_DeclarationReference', /** * Input characters that were reported as an error and do not appear to be part of a valid expression. diff --git a/tsdoc/src/parser/NodeParser.ts b/tsdoc/src/parser/NodeParser.ts index 6777fffc..a046da38 100644 --- a/tsdoc/src/parser/NodeParser.ts +++ b/tsdoc/src/parser/NodeParser.ts @@ -45,6 +45,7 @@ import { TSDocTagDefinition, TSDocTagSyntaxKind } from '../configuration/TSDocTa import { StandardTags } from '../details/StandardTags'; import { PlainTextEmitter } from '../emitters/PlainTextEmitter'; import { TSDocMessageId } from './TSDocMessageId'; +import { DeclarationReference } from '../beta/DeclarationReference'; interface IFailure { // (We use "failureMessage" instead of "errorMessage" here so that DocErrorText doesn't @@ -1251,6 +1252,53 @@ export class NodeParser { return !!parameters.codeDestination; } + private _parseBetaDeclarationReference( + tokenReader: TokenReader, + fallback: boolean + ): DocDeclarationReference | undefined { + tokenReader.assertAccumulatedSequenceIsEmpty(); + const marker: number = tokenReader.createMarker(); + try { + const declarationReference: DeclarationReference | undefined = DeclarationReference.tryParse( + tokenReader, + fallback + ); + if (!tokenReader.isAccumulatedSequenceEmpty()) { + const declarationReferenceExcerpt: TokenSequence = tokenReader.extractAccumulatedSequence(); + return new DocDeclarationReference({ + parsed: true, + configuration: this._configuration, + declarationReferenceExcerpt, + declarationReference + }); + } + } catch { + // do nothing + } + + tokenReader.backtrackToMarker(marker); + return undefined; + } + + private _tryParseBetaDeclarationReferenceAtMarker( + tokenReader: TokenReader, + marker: number + ): DocDeclarationReference | undefined { + if (this._parserContext.configuration.parseBetaDeclarationReferences === true) { + const betaReader: TokenReader = tokenReader.clone(); + betaReader.backtrackToMarker(marker); + const node: DocDeclarationReference | undefined = this._parseBetaDeclarationReference( + betaReader, + /*fallback*/ true + ); + if (node) { + tokenReader.updateFrom(betaReader); + return node; + } + } + return undefined; + } + private _parseDeclarationReference( tokenReader: TokenReader, tokenSequenceForErrorContext: TokenSequence, @@ -1258,10 +1306,22 @@ export class NodeParser { ): DocDeclarationReference | undefined { tokenReader.assertAccumulatedSequenceIsEmpty(); + if (this._parserContext.configuration.parseBetaDeclarationReferences === 'prefer') { + const node: DocDeclarationReference | undefined = this._parseBetaDeclarationReference( + tokenReader, + /*fallback*/ false + ); + if (node) { + return node; + } + } + + const marker: number = tokenReader.createMarker(); + const logMarker: number = this._parserContext.log.createMarker(); + // The package name can contain characters that look like a member reference. This means we need to scan forwards // to see if there is a "#". However, we need to be careful not to match a "#" that is part of a quoted expression. - const marker: number = tokenReader.createMarker(); let hasHash: boolean = false; // A common mistake is to forget the "#" for package name or import path. The telltale sign @@ -1305,6 +1365,21 @@ export class NodeParser { // so don't set lookingForImportCharacters = false tokenReader.readToken(); break; + case TokenKind.OtherPunctuation: + if ( + this._parserContext.configuration.parseBetaDeclarationReferences === true && + tokenReader.peekToken().toString() === '!' + ) { + // '!' has no meaning in a TSDoc declaration reference. This could be a beta DeclarationReference, so try to parse one + const node: DocDeclarationReference | undefined = this._tryParseBetaDeclarationReferenceAtMarker( + tokenReader, + marker + ); + if (node) { + return node; + } + } + // falls through default: // Once we reach something other than AsciiWord and Period, then the meaning of // slashes and at-signs is no longer obvious. @@ -1317,6 +1392,16 @@ export class NodeParser { if (!hasHash && sawImportCharacters) { // We saw characters that will be a syntax error if interpreted as a member reference, // but would make sense as a package name or import path, but we did not find a "#" + + // This could be a beta DeclarationReference, so try to parse one + const node: DocDeclarationReference | undefined = this._tryParseBetaDeclarationReferenceAtMarker( + tokenReader, + marker + ); + if (node) { + return node; + } + this._parserContext.log.addMessageForTokenSequence( TSDocMessageId.ReferenceMissingHash, 'The declaration reference appears to contain a package name or import path,' + @@ -1428,6 +1513,15 @@ export class NodeParser { spacingAfterImportHashExcerpt = this._tryReadSpacingAndNewlines(tokenReader); if (packageNameExcerpt === undefined && importPathExcerpt === undefined) { + // This could be a beta DeclarationReference, so try to parse one + const node: DocDeclarationReference | undefined = this._tryParseBetaDeclarationReferenceAtMarker( + tokenReader, + marker + ); + if (node) { + return node; + } + this._parserContext.log.addMessageForTokenSequence( TSDocMessageId.ReferenceHashSyntax, 'The hash character must be preceded by a package name or import path', @@ -1459,6 +1553,16 @@ export class NodeParser { ); if (!memberReference) { + // This could be a beta DeclarationReference, so try to parse one + const node: DocDeclarationReference | undefined = this._tryParseBetaDeclarationReferenceAtMarker( + tokenReader, + marker + ); + if (node) { + this._parserContext.log.rollbackToMarker(logMarker); + return node; + } + return undefined; } @@ -1474,6 +1578,15 @@ export class NodeParser { importPathExcerpt === undefined && memberReferences.length === 0 ) { + // This could be a beta DeclarationReference, so try to parse one + const node: DocDeclarationReference | undefined = this._tryParseBetaDeclarationReferenceAtMarker( + tokenReader, + marker + ); + if (node) { + return node; + } + // We didn't find any parts of a declaration reference this._parserContext.log.addMessageForTokenSequence( TSDocMessageId.MissingReference, diff --git a/tsdoc/src/parser/ParserMessageLog.ts b/tsdoc/src/parser/ParserMessageLog.ts index a7b7a864..c50b7f0a 100644 --- a/tsdoc/src/parser/ParserMessageLog.ts +++ b/tsdoc/src/parser/ParserMessageLog.ts @@ -83,4 +83,20 @@ export class ParserMessageLog { }) ); } + + /** + * Returns a value that can be used to rollback the message log to a specific point in time. + */ + public createMarker(): number { + return this._messages.length; + } + + /** + * Rolls back the message log to a specific point in time. + */ + public rollbackToMarker(marker: number): void { + if (marker >= 0 && marker < this._messages.length) { + this._messages.length = marker; + } + } } diff --git a/tsdoc/src/parser/StringChecks.ts b/tsdoc/src/parser/StringChecks.ts index 22e95603..3bbe73bc 100644 --- a/tsdoc/src/parser/StringChecks.ts +++ b/tsdoc/src/parser/StringChecks.ts @@ -132,6 +132,44 @@ export class StringChecks { return undefined; } + /** + * Tests whether the input string is a valid scope portion of a scoped NPM package name. + */ + public static explainIfInvalidPackageScope(scopeName: string): string | undefined { + if (scopeName.length === 0) { + return 'An package scope cannot be an empty string'; + } + + if (scopeName.charAt(0) !== '@') { + return `An package scope must start with '@'`; + } + + if (!StringChecks._validPackageNameRegExp.test(`${scopeName}/package`)) { + return `The name ${JSON.stringify(scopeName)} is not a valid package scope`; + } + + return undefined; + } + + /** + * Tests whether the input string is a valid non-scope portion of a scoped NPM package name. + */ + public static explainIfInvalidUnscopedPackageName(unscopedPackageName: string): string | undefined { + if (unscopedPackageName.length === 0) { + return 'An unscoped package name cannot be an empty string'; + } + + if (unscopedPackageName.charAt(0) === '@') { + return `An unscoped package name cannot start with '@'`; + } + + if (!StringChecks._validPackageNameRegExp.test(`@scope/${unscopedPackageName}`)) { + return `The name ${JSON.stringify(unscopedPackageName)} is not a valid unscoped package name`; + } + + return undefined; + } + /** * Tests whether the input string is a valid declaration reference import path. */ diff --git a/tsdoc/src/parser/TokenReader.ts b/tsdoc/src/parser/TokenReader.ts index b7ad1a98..2d9aea21 100644 --- a/tsdoc/src/parser/TokenReader.ts +++ b/tsdoc/src/parser/TokenReader.ts @@ -204,4 +204,29 @@ export class TokenReader { this._accumulatedStartIndex = marker; } } + + /** + * Create a copy of the token reader at the same position. + */ + public clone(): TokenReader { + const clone: TokenReader = new TokenReader(this._parserContext); + clone._readerStartIndex = this._readerStartIndex; + clone._readerEndIndex = this._readerEndIndex; + clone._currentIndex = this._currentIndex; + clone._accumulatedStartIndex = this._accumulatedStartIndex; + return clone; + } + + /** + * Update this reader to match the same position as another reader with the same context. + */ + public updateFrom(other: TokenReader): void { + if (other._parserContext !== this._parserContext) { + throw new Error('The other token reader must use the same parser context'); + } + this._readerStartIndex = other._readerStartIndex; + this._readerEndIndex = other._readerEndIndex; + this._currentIndex = other._currentIndex; + this._accumulatedStartIndex = other._accumulatedStartIndex; + } } diff --git a/tsdoc/src/parser/__tests__/NodeParserLinkTag.test.ts b/tsdoc/src/parser/__tests__/NodeParserLinkTag.test.ts index 7067b9d8..2f02eec0 100644 --- a/tsdoc/src/parser/__tests__/NodeParserLinkTag.test.ts +++ b/tsdoc/src/parser/__tests__/NodeParserLinkTag.test.ts @@ -1,3 +1,4 @@ +import { TSDocConfiguration } from '../../configuration/TSDocConfiguration'; import { TestHelpers } from './TestHelpers'; test('00 Link text: positive examples', () => { @@ -94,3 +95,18 @@ test('07 Declaration reference with import path only: negative examples', () => ['/**', ' * {@link /path1#}', ' * {@link /path1 path2#}', ' */'].join('\n') ); }); + +test('08 Parse beta declaration references (fallback)', () => { + const config: TSDocConfiguration = new TSDocConfiguration(); + config.parseBetaDeclarationReferences = true; + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * {@link foo!Bar:class}', ' * {@link Bar.foo}', ' */'].join('\n'), + config + ); +}); + +test('09 Parse beta declaration references (preferred)', () => { + const config: TSDocConfiguration = new TSDocConfiguration(); + config.parseBetaDeclarationReferences = 'prefer'; + TestHelpers.parseAndMatchNodeParserSnapshot(['/**', ' * {@link Bar.foo}', ' */'].join('\n'), config); +}); diff --git a/tsdoc/src/parser/__tests__/__snapshots__/NodeParserLinkTag.test.ts.snap b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserLinkTag.test.ts.snap index 6eafc8a1..86a5874d 100644 --- a/tsdoc/src/parser/__tests__/__snapshots__/NodeParserLinkTag.test.ts.snap +++ b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserLinkTag.test.ts.snap @@ -1683,3 +1683,201 @@ Object { }, } `; + +exports[`08 Parse beta declaration references (fallback) 1`] = ` +Object { + "buffer": "/**[n] * {@link foo!Bar:class}[n] * {@link Bar.foo}[n] */", + "gaps": Array [], + "lines": Array [ + "{@link foo!Bar:class}", + "{@link Bar.foo}", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "LinkTag", + "nodes": Array [ + Object { + "kind": "Excerpt: InlineTag_OpeningDelimiter", + "nodeExcerpt": "{", + }, + Object { + "kind": "Excerpt: InlineTag_TagName", + "nodeExcerpt": "@link", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "DeclarationReference", + "nodes": Array [ + Object { + "kind": "Excerpt: DeclarationReference_DeclarationReference", + "nodeExcerpt": "foo!Bar:class", + }, + ], + }, + Object { + "kind": "Excerpt: InlineTag_ClosingDelimiter", + "nodeExcerpt": "}", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "LinkTag", + "nodes": Array [ + Object { + "kind": "Excerpt: InlineTag_OpeningDelimiter", + "nodeExcerpt": "{", + }, + Object { + "kind": "Excerpt: InlineTag_TagName", + "nodeExcerpt": "@link", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "DeclarationReference", + "nodes": Array [ + Object { + "kind": "MemberReference", + "nodes": Array [ + Object { + "kind": "MemberIdentifier", + "nodes": Array [ + Object { + "kind": "Excerpt: MemberIdentifier_Identifier", + "nodeExcerpt": "Bar", + }, + ], + }, + ], + }, + Object { + "kind": "MemberReference", + "nodes": Array [ + Object { + "kind": "Excerpt: MemberReference_Dot", + "nodeExcerpt": ".", + }, + Object { + "kind": "MemberIdentifier", + "nodes": Array [ + Object { + "kind": "Excerpt: MemberIdentifier_Identifier", + "nodeExcerpt": "foo", + }, + ], + }, + ], + }, + ], + }, + Object { + "kind": "Excerpt: InlineTag_ClosingDelimiter", + "nodeExcerpt": "}", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; + +exports[`09 Parse beta declaration references (preferred) 1`] = ` +Object { + "buffer": "/**[n] * {@link Bar.foo}[n] */", + "gaps": Array [], + "lines": Array [ + "{@link Bar.foo}", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "LinkTag", + "nodes": Array [ + Object { + "kind": "Excerpt: InlineTag_OpeningDelimiter", + "nodeExcerpt": "{", + }, + Object { + "kind": "Excerpt: InlineTag_TagName", + "nodeExcerpt": "@link", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "DeclarationReference", + "nodes": Array [ + Object { + "kind": "Excerpt: DeclarationReference_DeclarationReference", + "nodeExcerpt": "Bar.foo", + }, + ], + }, + Object { + "kind": "Excerpt: InlineTag_ClosingDelimiter", + "nodeExcerpt": "}", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`;