From 3114f462692a657075ac9394f9d21a0f91819f30 Mon Sep 17 00:00:00 2001 From: Ron Buckton Date: Sun, 1 May 2022 00:44:20 +0000 Subject: [PATCH] Add additional utility methods to DeclarationReference --- tsdoc/src/beta/DeclarationReference.ts | 2554 ++++++++++++++--- .../__tests__/DeclarationReference.test.ts | 1765 +++++++++++- tsdoc/src/parser/StringChecks.ts | 38 + 3 files changed, 3928 insertions(+), 429 deletions(-) diff --git a/tsdoc/src/beta/DeclarationReference.ts b/tsdoc/src/beta/DeclarationReference.ts index 750a8b43..293ffb68 100644 --- a/tsdoc/src/beta/DeclarationReference.ts +++ b/tsdoc/src/beta/DeclarationReference.ts @@ -5,67 +5,77 @@ /* 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 { StringChecks } from '../parser/StringChecks'; +// #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; } + /** + * Parses a {@link DeclarationReference} from the provided text. + */ public static parse(text: string): DeclarationReference { const parser: Parser = new Parser(text); const reference: DeclarationReference = parser.parseDeclarationReference(); if (parser.errors.length) { throw new SyntaxError(`Invalid DeclarationReference '${text}':\n ${parser.errors.join('\n ')}`); - } - if (!parser.eof) { + } else if (!parser.eof) { throw new SyntaxError(`Invalid DeclarationReference '${text}'`); + } else { + return reference; } - return reference; } + /** + * Parses a {@link Component} from the provided text. + */ public static parseComponent(text: string): Component { if (text[0] === '[') { return ComponentReference.parse(text); @@ -93,12 +103,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 +123,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; } /** @@ -138,12 +150,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 +170,183 @@ 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}. + */ public static empty(): DeclarationReference { return new DeclarationReference(); } + /** + * Creates a new {@link DeclarationReference} for the provided package. + */ public static package(packageName: string, importPath?: string): DeclarationReference { return new DeclarationReference(ModuleSource.fromPackage(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 = resolveDeclarationReferenceLike( + parts, + /*fallbackReference*/ undefined + ); + 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) + ); + } } - public withSource(source: ModuleSource | GlobalSource | undefined): DeclarationReference { - return this._source === source ? this : new DeclarationReference(source, this._navigation, this._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) && + this.navigation === resolvedNavigation && + SymbolReference.equals(this.symbol, resolvedSymbol) + ) { + return this; + } else { + return new DeclarationReference(resolvedSource, navigation, resolvedSymbol); + } } - 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 source. + * @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 }); + } + + /** + * Returns an {@link DeclarationReference} updated with the provided navigation. + * @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. + * @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. + * @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. + * @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. + * @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) { + return right === undefined || right.isEmpty; + } else if (right === undefined) { + return left === undefined || left.isEmpty; + } 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,99 +358,399 @@ export class DeclarationReference { } } +// #region DeclarationReferenceParts + /** - * Indicates the symbol table from which to resolve the next symbol component. * @beta */ -export const enum Navigation { - Exports = '.', - Members = '#', - Locals = '~' +export type DeclarationReferenceSourcePart = Parts< + With, + { + /** The module or global source for a symbol. */ + source?: Part>; + } +>; + +/** + * @beta + */ +export type DeclarationReferenceSourceParts = + | DeclarationReferenceSourcePart + | SourceParts; + +/** + * @beta + */ +export type DeclarationReferenceNavigationParts = Parts< + With, + { + /** Indicates whether the symbol is exported or local to the source. */ + navigation?: Part; + } +>; + +/** + * @beta + */ +export type DeclarationReferenceSymbolPart = Parts< + With, + { + /** The referenced symbol. */ + symbol?: Part>; + } +>; + +/** + * @beta + */ +export type DeclarationReferenceSymbolParts = + | DeclarationReferenceSymbolPart + | SymbolReferenceParts; + +/** + * @beta + */ +export type DeclarationReferenceParts = DeclarationReferenceSourceParts & + DeclarationReferenceNavigationParts & + DeclarationReferenceSymbolParts; + +function resolveDeclarationReferenceSourcePart( + parts: DeclarationReferenceSourceParts, + fallbackSource: Source | undefined +): SourceLike | undefined { + 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'`); + } + 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; + } +} + +function resolveDeclarationReferenceNavigationPart( + parts: DeclarationReferenceNavigationParts, + fallbackNavigation: SourceNavigation | undefined +): SourceNavigation | undefined { + const { navigation } = parts; + if (navigation !== undefined) { + if (navigation === null) { + return undefined; + } else { + return navigation; + } + } else { + return fallbackNavigation; + } +} + +function resolveDeclarationReferenceSymbolPart( + parts: DeclarationReferenceSymbolParts, + fallbackSymbol: SymbolReference | undefined +): SymbolReferenceLike | undefined { + 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'`); + } + if (symbol === null) { + return undefined; + } else { + return resolveSymbolReferenceLike(symbol, fallbackSymbol); + } + } else if (componentPath !== undefined || meaning !== undefined || overloadIndex !== undefined) { + return resolveSymbolReferenceParts( + parts as SymbolReferenceParts, + fallbackSymbol?.componentPath, + fallbackSymbol?.meaning, + fallbackSymbol?.overloadIndex + ); + } else { + return fallbackSymbol; + } } +type ResolvedDeclarationReferenceParts = DeclarationReferenceSourcePart & + DeclarationReferenceNavigationParts & + DeclarationReferenceSymbolPart; + +function resolveDeclarationReferenceParts( + parts: DeclarationReferenceParts, + fallbackSource: Source | undefined, + fallbackNavigation: Navigation.Exports | Navigation.Locals | undefined, + fallbackSymbol: SymbolReference | undefined +): ResolvedDeclarationReferenceParts { + return { + source: resolveDeclarationReferenceSourcePart(parts, fallbackSource), + navigation: resolveDeclarationReferenceNavigationPart(parts, fallbackNavigation), + symbol: resolveDeclarationReferenceSymbolPart(parts, fallbackSymbol) + }; +} + +// #endregion DeclarationReferenceParts + /** - * Represents a module. * @beta */ -export class ModuleSource { - public readonly escapedPath: string; - private _path: string | undefined; +export type DeclarationReferenceLike = + | DeclarationReference + | DeclarationReferenceParts + | string; + +type ResolvedDeclarationReferenceLike = DeclarationReference | ResolvedDeclarationReferenceParts; + +function resolveDeclarationReferenceLike( + reference: DeclarationReferenceLike | undefined, + fallbackReference: DeclarationReference | undefined +): ResolvedDeclarationReferenceLike { + if (reference instanceof DeclarationReference) { + return reference; + } else if (reference === undefined) { + return DeclarationReference.empty(); + } else if (typeof reference === 'string') { + return DeclarationReference.parse(reference); + } else { + return resolveDeclarationReferenceParts( + reference, + fallbackReference?.source, + fallbackReference?.navigation, + fallbackReference?.symbol + ); + } +} + +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; + } +} + +// #endregion DeclarationReference + +// #region SourceBase + +/** + * Abstract base class for the source of a {@link DeclarationReference}. + * @beta + */ +export abstract class SourceBase { + public abstract readonly kind: string; + + /** + * 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 }); + } + + public abstract toString(): string; +} + +// #endregion SourceBase + +// #region GlobalSource + +/** + * Represents the global scope. + * @beta + */ +export class GlobalSource extends SourceBase { + /** + * A singleton instance of {@link GlobalSource}. + */ + public static readonly instance: GlobalSource = new GlobalSource(); + + public readonly kind: 'global-source' = 'global-source'; + + private constructor() { + super(); + } + + public toString(): string { + return '!'; + } +} +// #endregion GlobalSource + +// #region ModuleSource + +/** + * Represents a module source. + * @beta + */ +export class ModuleSource extends SourceBase { + public readonly kind: 'module-source' = 'module-source'; + + private _escapedPath: string; + private _path: string | undefined; private _pathComponents: IParsedPackage | undefined; + private _packageName: string | undefined; public constructor(path: string, userEscaped: boolean = true) { - this.escapedPath = - this instanceof ParsedModuleSource ? path : escapeModuleSourceIfNeeded(path, userEscaped); + 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 || (this._path = DeclarationReference.unescapeModuleSourceString(this.escapedPath)); + 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 { - return this._getOrParsePathComponents().packageName; + 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 { - const scopeName: string = this._getOrParsePathComponents().scopeName; - return scopeName ? '@' + scopeName : ''; + return this._getOrParsePathComponents().scopeName ?? ''; } + /** + * Returns the non-scope portion of a scoped package name (i.e., `package` in `@scope/package`) + */ 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 || ''; + return this._getOrParsePathComponents().importPath ?? ''; + } + + /** + * Creates a new {@link ModuleSource} from the supplied parts. + */ + public static from(parts: ModuleSourceLike | string): 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. + */ 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}`; - } - - const parsed: IParsedPackage = { packageName, scopeName: scopeName || '', unscopedPackageName }; - return this._fromPackageName(parsed, packageName, importPath); + return ModuleSource.from({ scopeName, unscopedPackageName, importPath }); } + /** + * Creates a new {@link ModuleSource} for package. + */ public static fromPackage(packageName: string, importPath?: string): ModuleSource { - return this._fromPackageName(parsePackageName(packageName), packageName, importPath); + return ModuleSource.from({ packageName, importPath }); } - private static _fromPackageName( - parsed: IParsedPackage | null, - packageName: string, - importPath?: string - ): ModuleSource { - if (!parsed) { - throw new Error('Parsed package must be provided.'); - } - - const packageNameError: string | undefined = StringChecks.explainIfInvalidPackageName(packageName); - if (packageNameError) { - throw new SyntaxError(`Invalid NPM package name: ${packageNameError}`); + /** + * 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; } + } - let path: string = packageName; - if (importPath) { - if (invalidImportPathRegExp.test(importPath)) { - throw new SyntaxError(`Invalid import path '${importPath}`); - } - path += '/' + importPath; - parsed.importPath = importPath; + /** + * 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; } + } - const source: ModuleSource = new ModuleSource(path); - source._pathComponents = parsed; - return source; + /** + * Tests whether this object is equivalent to `other`. + */ + public equals(other: ModuleSource): boolean { + return ModuleSource.equals(this, other); } public toString(): string { @@ -347,13 +760,17 @@ export class ModuleSource { private _getOrParsePathComponents(): IParsedPackage { if (!this._pathComponents) { const path: string = this.path; - const parsed: IParsedPackage | null = parsePackageName(path); - if (parsed && !StringChecks.explainIfInvalidPackageName(parsed.packageName)) { + const parsed: IParsedPackage | null = tryParsePackageName(path); + if ( + parsed && + !StringChecks.explainIfInvalidPackageName( + formatPackageName(parsed.scopeName, parsed.unscopedPackageName) + ) + ) { this._pathComponents = parsed; } else { this._pathComponents = { - packageName: '', - scopeName: '', + scopeName: undefined, unscopedPackageName: '', importPath: path }; @@ -363,23 +780,31 @@ export class ModuleSource { } } -class ParsedModuleSource extends ModuleSource {} +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", "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"] +// '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 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 = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.+))?$/; +// 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 '..\' @@ -388,98 +813,1338 @@ const packageNameRegExp: RegExp = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.+))?$/; const invalidImportPathRegExp: RegExp = /^(\.\.?([\\/]|$)|[\\/])/; interface IParsedPackage { - packageName: string; - scopeName: string; + scopeName: string | undefined; unscopedPackageName: string; - importPath?: string; + importPath: string | undefined; } -// 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; +function parsePackageName(text: string): IParsedPackage { + // eslint-disable-next-line @rushstack/no-new-null + const parsed: IParsedPackage | null = tryParsePackageName(text); + if (!parsed) { + throw new SyntaxError(`Invalid NPM package name: The package name ${JSON.stringify(text)} was invalid`); } - const [, packageName = '', scopeName = '', unscopedPackageName = '', importPath]: RegExpExecArray = match; - return { packageName, scopeName, unscopedPackageName, importPath }; -} + + 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; +} + +// eslint-disable-next-line @rushstack/no-new-null +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; +} + +/** + * Specifies the parts that can be used to construct or update a {@link ModuleSource}. + * @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'" + ); + } +} + +/** + * @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); + } + } +} + +/** + * @beta + */ +export type SourceParts = ModuleSourceParts; + +type ResolvedSourceParts = IParsedPackage; + +function resolveSourceParts( + parts: SourceParts, + fallbackSource: Source | undefined +): ResolvedSourceParts { + return resolveModuleSourceParts(parts, tryCast(fallbackSource, ModuleSource)); +} + +/** + * @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.overloadIndex = overloadIndex; + this.meaning = meaning; + } + + 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(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; + } + } + + /** + * Creates a new {@link SymbolReference} from the provided parts. + */ + public static from(parts: SymbolReferenceLike | undefined): SymbolReference { + const resolved: ResolvedSymbolReferenceLike = resolveSymbolReferenceLike( + parts, + /*fallbackSymbol*/ undefined + ); + 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. + * @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. + * @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. + * @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 }); + } + + public withSource(source: Source | undefined): DeclarationReference { + return this.toDeclarationReference({ source }); + } + + /** + * Creates a new {@link SymbolReference} that navigates from this SymbolReference to the provided 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) { + return right === undefined || right.isEmpty; + } else if (right === undefined) { + return left === undefined || left.isEmpty; + } 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); + } + + 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; + } +} + +/** + * @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 + }; +} + +/** + * @beta + */ +export type SymbolReferenceLike = string | SymbolReference | SymbolReferenceParts; + +type ResolvedSymbolReferenceLike = string | SymbolReference | SymbolReferenceParts; + +function resolveSymbolReferenceLike( + symbol: SymbolReferenceLike | undefined, + fallbackSymbol: SymbolReference | undefined +): ResolvedSymbolReferenceLike { + if (symbol instanceof SymbolReference || typeof symbol === 'string') { + return symbol; + } else if (symbol === undefined) { + return SymbolReference.empty(); + } else { + return resolveSymbolReferenceParts( + symbol, + fallbackSymbol?.componentPath, + fallbackSymbol?.meaning, + fallbackSymbol?.overloadIndex + ); + } +} + +// #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}. + */ + 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}. + */ + 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}. + */ + 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. + * @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(); + } +} + +/** + * @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) + }; +} + +/** + * @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. + * @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. + * @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. + * @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}`; + } +} + +/** + * @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) + }; +} /** - * Represents the global scope. * @beta */ -export class GlobalSource { - public static readonly instance: GlobalSource = new GlobalSource(); - - private constructor() {} - - public toString(): string { - return '!'; +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 Component = ComponentString | ComponentReference; +export type ComponentPath = ComponentRoot | ComponentNavigation; /** * @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); +export namespace ComponentPath { + /** + * Parses a {@link SymbolReference} from the supplied text. + */ + export function parse(text: string): ComponentPath { + const parser: Parser = new Parser(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); } - if (value instanceof DeclarationReference) { - return new ComponentReference(value); + } + + /** + * 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); } + } +} + +/** + * @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); + } +} + +/** + * @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 type ComponentLike = Component | DeclarationReference | string; +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 { +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 + } + } +} + +/** + * @beta + */ +export type ComponentStringParts = Parts< + /*With*/ false, + { + /** The text for a {@link ComponentString}. */ + text: string; + } +>; + +/** + * @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. + * @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 { @@ -490,77 +2155,167 @@ export class ComponentReference { /** * @beta */ -export type ComponentPath = ComponentRoot | ComponentNavigation; +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; + if (reference === undefined) { + throw new TypeError("The property 'reference' is required"); + } + return { + reference: resolveDeclarationReferenceLike(reference, fallbackReference) + }; +} /** * @beta */ -export abstract class ComponentPathBase { - public readonly component: Component; +export type ComponentReferenceLike = + | ComponentReference + | ComponentReferenceParts + | DeclarationReference + | string; - public constructor(component: Component) { - this.component = component; - } +// #endregion ComponentReference - 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)); - } +// #region Component - public abstract toString(): string; -} +/** + * A component in a {@link ComponentPath}. + * @beta + */ +export type Component = ComponentString | ComponentReference; /** * @beta */ -export class ComponentRoot extends ComponentPathBase { - public withComponent(component: ComponentLike): ComponentRoot { - return this.component === component ? this : new ComponentRoot(Component.from(component)); +// 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); + } } - public toString(): string { - return this.component.toString(); + /** + * 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); + } } } /** * @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; +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; } +} - public withParent(parent: ComponentPath): ComponentNavigation { - return this.parent === parent ? this : new ComponentNavigation(parent, this.navigation, this.component); - } +/** + * @beta + */ +export type ComponentLike = + | ComponentStringLike + | Exclude, string>; - public withNavigation(navigation: Navigation): ComponentNavigation { - return this.navigation === navigation - ? this - : new ComponentNavigation(this.parent, navigation, this.component); - } +type ResolvedComponentLike = Component | ComponentStringParts | ComponentReferenceParts; - public withComponent(component: ComponentLike): ComponentNavigation { - return this.component === component - ? this - : new ComponentNavigation(this.parent, this.navigation, Component.from(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 toString(): string { - return `${this.parent}${formatNavigation(this.navigation)}${this.component}`; +// #endregion Component + +// #region Navigation + +/** + * Indicates the symbol table from which to resolve the next symbol component. + * @beta + */ +export const enum Navigation { + Exports = '.', + Members = '#', + Locals = '~' +} + +/** + * @beta + */ +export type SourceNavigation = Navigation.Exports | Navigation.Locals; + +function formatNavigation(navigation: Navigation | undefined): string { + switch (navigation) { + case Navigation.Exports: + return '.'; + case Navigation.Members: + return '#'; + case Navigation.Locals: + return '~'; + default: + return ''; } } +// #endregion Navigation + +// #region Meaning + /** * @beta */ @@ -581,82 +2336,9 @@ 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 - }); - } - - public withOverloadIndex(overloadIndex: number | undefined): SymbolReference { - return this.overloadIndex === overloadIndex - ? this - : new SymbolReference(this.componentPath, { - meaning: this.meaning, - overloadIndex - }); - } - - 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)); - } - - 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; - } -} +// #endregion Meaning + +// #region Token const enum Token { None, @@ -767,6 +2449,10 @@ function tokenToString(token: Token): string { } } +// #endregion Token + +// #region Scanner + class Scanner { private _tokenPos: number; private _pos: number; @@ -1065,6 +2751,115 @@ class Scanner { } } +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); +} + +function isEscapeCharacter(ch: string): boolean { + switch (ch) { + case 'x': + case 'u': + return true; + default: + return isSingleEscapeCharacter(ch) || isDecimalDigit(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 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; @@ -1084,7 +2879,7 @@ 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)) { @@ -1097,11 +2892,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))); - } + symbol = this.tryParseSymbolReference(); return new DeclarationReference(source, navigation, symbol); } @@ -1110,6 +2901,18 @@ 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: @@ -1130,7 +2933,7 @@ class Parser { } private parseSymbol(): SymbolReference { - const component: ComponentPath = this.parseComponentRest(this.parseRootComponent()); + const component: ComponentPath = this.parseComponentPath(); return this.parseSymbolRest(component); } @@ -1327,123 +3130,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 +3142,55 @@ 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 { + // eslint-disable-next-line @rushstack/no-new-null + setPrototypeOf?(obj: object, proto: object | null): object; +} + +// eslint-disable-next-line @rushstack/no-new-null +const setPrototypeOf: + | ((obj: object, proto: object | null) => object) + | undefined = (Object as ObjectConstructorWithSetPrototypeOf).setPrototypeOf; + +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. + * @beta + */ +export type Parts = [With] extends [false] + ? T + : T extends unknown + ? Partial + : never; + +/** + * If a part can be removed via a `with()` call, marks that part with `| null` + * @beta + */ +export type Part = [With] extends [false] ? T : T | null; + +type AllKeysOf = T extends unknown ? keyof T : never; +type AllParts = { + [P in AllKeysOf]: T extends unknown ? (P extends keyof T ? T[P] : undefined) : never; +}; diff --git a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts index dfbc57f4..0b4a3642 100644 --- a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts +++ b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts @@ -7,9 +7,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] +) { + 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'); @@ -118,16 +152,7 @@ describe('parser', () => { }).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 +189,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 +214,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 +226,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 +269,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 +294,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 +307,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 +1141,923 @@ 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?.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/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. */