From c4bedf9c36cc2b3ec803612185ed107f0c3ce461 Mon Sep 17 00:00:00 2001 From: Ron Buckton Date: Fri, 2 Aug 2019 15:38:47 -0700 Subject: [PATCH] Improve parse for module sources --- ...tionReferenceUpdate3_2019-08-02-22-40.json | 11 + .../beta/DeclarationReference.grammarkdown | 19 +- tsdoc/src/beta/DeclarationReference.ts | 309 +++++++++++++----- .../__tests__/DeclarationReference.test.ts | 280 ++++++++++++++-- 4 files changed, 507 insertions(+), 112 deletions(-) create mode 100644 common/changes/@microsoft/tsdoc/declarationReferenceUpdate3_2019-08-02-22-40.json diff --git a/common/changes/@microsoft/tsdoc/declarationReferenceUpdate3_2019-08-02-22-40.json b/common/changes/@microsoft/tsdoc/declarationReferenceUpdate3_2019-08-02-22-40.json new file mode 100644 index 00000000..18c4f97e --- /dev/null +++ b/common/changes/@microsoft/tsdoc/declarationReferenceUpdate3_2019-08-02-22-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc", + "comment": "Improve DeclarationReference.parse for module sources", + "type": "patch" + } + ], + "packageName": "@microsoft/tsdoc", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/tsdoc/src/beta/DeclarationReference.grammarkdown b/tsdoc/src/beta/DeclarationReference.grammarkdown index 9f37cd51..cb272192 100644 --- a/tsdoc/src/beta/DeclarationReference.grammarkdown +++ b/tsdoc/src/beta/DeclarationReference.grammarkdown @@ -42,7 +42,7 @@ Punctuator:: one of `{` `}` `(` `)` `[` `]` `!` `.` `#` `~` `:` `,` FutureReservedPunctuator:: one of - `{` `}` + `{` `}` `@` NavigationPunctuator: one of `.` // Navigate via 'exports' of symbol @@ -107,6 +107,17 @@ Hex4Digits:: CodePoint:: > |HexDigits| but only if MV of |HexDigits| ≤ 0x10FFFF +// Represents the path for a module +ModuleSource:: + String + ModuleSourceCharacters + +ModuleSourceCharacters:: + ModuleSourceCharacter ModuleSourceCharacters? + +ModuleSourceCharacter:: + SourceCharacter but not one of `"` or `!` or LineTerminator + Component:: String ComponentCharacters @@ -130,13 +141,9 @@ DeclarationReference: SymbolReference // Shorthand reference to symbol ModuleSource `!` // Reference to a module ModuleSource `!` SymbolReference // Reference to an export of a module - ModuleSource `|` `~` SymbolReference // Reference to a local of a module + ModuleSource `!` `~` SymbolReference // Reference to a local of a module `!` SymbolReference // Reference to global symbol -// Represents the path for a module -ModuleSource: - Component - SymbolReference: ComponentPath Meaning? Meaning diff --git a/tsdoc/src/beta/DeclarationReference.ts b/tsdoc/src/beta/DeclarationReference.ts index 761bc73f..db3a7430 100644 --- a/tsdoc/src/beta/DeclarationReference.ts +++ b/tsdoc/src/beta/DeclarationReference.ts @@ -65,15 +65,25 @@ export class DeclarationReference { } /** - * Escapes a string for use as a symbol navigation component. If the string contains `!.#~:,"{}()` or starts with - * `[`, it is enclosed in quotes. + * Determines whether the provided string is a well-formed symbol navigation component string. + */ + public static isWellFormedComponentString(text: string): boolean { + const scanner: Scanner = new Scanner(text); + return scanner.scan() === Token.String ? scanner.scan() === Token.EofToken : + scanner.token() === Token.Text ? scanner.scan() === Token.EofToken : + scanner.token() === Token.EofToken; + } + + /** + * Escapes a string for use as a symbol navigation component. If the string contains any of `!.#~:,"{}()@` or starts + * with `[`, it is enclosed in quotes. */ public static escapeComponentString(text: string): string { if (text.length === 0) { return '""'; } const ch: string = text.charAt(0); - if (ch === '"' || ch === '[' || !this.isWellFormedComponentString(text)) { + if (ch === '[' || ch === '"' || !this.isWellFormedComponentString(text)) { return JSON.stringify(text); } return text; @@ -83,7 +93,7 @@ export class DeclarationReference { * Unescapes a string used as a symbol navigation component. */ public static unescapeComponentString(text: string): string { - if (text.length > 2 && text.charAt(0) === '"' && text.charAt(text.length - 1) === '"') { + if (text.length >= 2 && text.charAt(0) === '"' && text.charAt(text.length - 1) === '"') { try { return JSON.parse(text); } catch { @@ -97,12 +107,46 @@ export class DeclarationReference { } /** - * Determines whether the provided string is a well-formed symbol navigation component string. + * Determines whether the provided string is a well-formed module source string. The string may not + * have a trailing `!` character. */ - public static isWellFormedComponentString(text: string): boolean { - const parser: Parser = new Parser(text); - parser.parseComponentString(); - return parser.errors.length === 0 && parser.eof; + public static isWellFormedModuleSourceString(text: string): boolean { + const scanner: Scanner = new Scanner(text + '!'); + return scanner.rescanModuleSource() === Token.ModuleSource + && !scanner.stringIsUnterminated + && scanner.scan() === Token.ExclamationToken + && scanner.scan() === Token.EofToken; + } + + /** + * Escapes a string for use as a module source. If the string contains any of `!"` it is enclosed in quotes. + */ + public static escapeModuleSourceString(text: string): string { + if (text.length === 0) { + return '""'; + } + const ch: string = text.charAt(0); + if (ch === '"' || !this.isWellFormedModuleSourceString(text)) { + return JSON.stringify(text); + } + return text; + } + + /** + * Unescapes a string used as a module source. The string may not have a trailing `!` character. + */ + public static unescapeModuleSourceString(text: string): string { + if (text.length >= 2 && text.charAt(0) === '"' && text.charAt(text.length - 1) === '"') { + try { + return JSON.parse(text); + } catch { + throw new SyntaxError(`Invalid Module source '${text}'`); + } + } + if (!this.isWellFormedModuleSourceString(text)) { + throw new SyntaxError(`Invalid Module source '${text}'`); + } + return text; } public static empty(): DeclarationReference { @@ -196,24 +240,59 @@ export const enum Navigation { * @beta */ export class ModuleSource { - public readonly path: string; + public readonly escapedPath: string; + private _path: string | undefined; - private _pathComponents: { packageName: string, importPath: string } | undefined; + private _pathComponents: IParsedPackage | undefined; constructor(path: string, userEscaped: boolean = true) { - this.path = escapeIfNeeded(path, userEscaped); + this.escapedPath = this instanceof ParsedModuleSource ? path : escapeModuleSourceIfNeeded(path, userEscaped); + } + + public get path(): string { + return this._path || (this._path = DeclarationReference.unescapeModuleSourceString(this.escapedPath)); } public get packageName(): string { - return this._parsePathComponents().packageName; + return this._getOrParsePathComponents().packageName; + } + + public get scopeName(): string { + const scopeName: string = this._getOrParsePathComponents().scopeName; + return scopeName ? '@' + scopeName : ''; + } + + public get unscopedPackageName(): string { + return this._getOrParsePathComponents().unscopedPackageName; } public get importPath(): string { - return this._parsePathComponents().importPath; + return this._getOrParsePathComponents().importPath || ''; + } + + 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); } public static fromPackage(packageName: string, importPath?: string): ModuleSource { - if (!isValidPackageName(packageName)) { + return this._fromPackageName(parsePackageName(packageName), packageName, importPath); + } + + private static _fromPackageName(parsed: IParsedPackage | null, packageName: string, importPath?: string): + ModuleSource { + + if (!parsed || !isValidPackageName(packageName, parsed, /*allowImportPath*/ false)) { throw new SyntaxError(`Invalid package name '${packageName}'`); } @@ -223,29 +302,29 @@ export class ModuleSource { throw new SyntaxError(`Invalid import path '${importPath}`); } path += '/' + importPath; + parsed.importPath = importPath; } const source: ModuleSource = new ModuleSource(path); - source._pathComponents = { packageName, importPath: importPath || '' }; + source._pathComponents = parsed; return source; } public toString(): string { - return `${this.path}!`; + return `${this.escapedPath}!`; } - private _parsePathComponents(): { packageName: string, importPath: string } { + private _getOrParsePathComponents(): IParsedPackage { if (!this._pathComponents) { - const path: string = DeclarationReference.unescapeComponentString(this.path); - const match: RegExpExecArray | null = packageNameRegExp.exec(path); - if (match && isValidPackageName(match[1], match)) { - this._pathComponents = { - packageName: match[1], - importPath: match[2] || '' - }; + const path: string = this.path; + const parsed: IParsedPackage | null = parsePackageName(path); + if (parsed && isValidPackageName(parsed.packageName, parsed, /*allowImportPath*/ true)) { + this._pathComponents = parsed; } else { this._pathComponents = { packageName: '', + scopeName: '', + unscopedPackageName: '', importPath: path }; } @@ -254,6 +333,9 @@ export class ModuleSource { } } +class ParsedModuleSource extends ModuleSource { +} + // matches the following: // 'foo' -> ["foo", "foo", undefined, "foo", undefined] // 'foo/bar' -> ["foo/bar", "foo", undefined, "foo", "bar"] @@ -268,7 +350,7 @@ export class ModuleSource { // 2. The scope name (excluding the leading '@') // 3. The unscoped package name // 4. The package-relative import path -const packageNameRegExp: RegExp = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.*))?$/; +const packageNameRegExp: RegExp = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.+))?$/; // according to validate-npm-package-name: // no leading '.' @@ -278,20 +360,35 @@ const packageNameRegExp: RegExp = /^((?:@([^/]+?)\/)?([^/]+?))(?:\/(.*))?$/; // not 'node_modules' or 'favicon.ico' (blacklisted) const invalidPackageNameRegExp: RegExp = /^[._\s]|\s$|[A-Z~'!()*]|^(node_modules|favicon.ico)$/s; -// no leading './' -// no leading '../' -// no leading '/' +// no leading './' or '.\' +// no leading '../' or '..\' +// no leading '/' or '\' // not '.' or '..' -const invalidImportPathRegExp: RegExp = /^(\.\.?([\\/]|$)|\/)/; +const invalidImportPathRegExp: RegExp = /^(\.\.?([\\/]|$)|[\\/])/; + +interface IParsedPackage { + packageName: string; + scopeName: string; + unscopedPackageName: string; + importPath?: string; +} + +function parsePackageName(text: string): IParsedPackage | null { + const match: RegExpExecArray | null = packageNameRegExp.exec(text); + if (match === null) { + return match; + } + const [, packageName = '', scopeName = '', unscopedPackageName = '', importPath] = match; + return { packageName, scopeName, unscopedPackageName, importPath }; +} -function isValidPackageName(packageName: string, - match: RegExpExecArray | null = packageNameRegExp.exec(packageName)): boolean { - return !!match // must match the minimal pattern - && match[1] === packageName // must not contain excess characters - && packageName.length <= 214 // maximum length, per validate-npm-package-name +function isValidPackageName(packageName: string, name: IParsedPackage, allowImportPath: boolean): boolean { + return name.packageName.length <= 214 // maximum length, per validate-npm-package-name && !invalidPackageNameRegExp.test(packageName) // must not contain invalid characters - && (!match[2] || encodeURIComponent(match[2]) === match[2]) // scope must be URL-friendly - && encodeURIComponent(match[3]) === match[3]; // package must be URL-friendly + && (!name.scopeName || encodeURIComponent(name.scopeName) === name.scopeName) // scope must be URL-friendly + && encodeURIComponent(name.unscopedPackageName) === name.unscopedPackageName // package must be URL-friendly + && (name.importPath === undefined + || allowImportPath && !invalidImportPathRegExp.test(name.importPath)); // must not contain excess characters } /** @@ -348,7 +445,7 @@ export class ComponentString { public readonly text: string; constructor(text: string, userEscaped?: boolean) { - this.text = this instanceof ParsedComponentString ? text : escapeIfNeeded(text, userEscaped); + this.text = this instanceof ParsedComponentString ? text : escapeComponentIfNeeded(text, userEscaped); } public toString(): string { @@ -559,9 +656,11 @@ const enum Token { TildeToken, // '~' ColonToken, // ':' CommaToken, // ',' + AtToken, // '@' DecimalDigits, // '12345' String, // '"abc"' Text, // 'abc' + ModuleSource, // 'abc/def!' (excludes '!') // Keywords ClassKeyword, // 'class' InterfaceKeyword, // 'interface' @@ -593,6 +692,7 @@ function tokenToString(token: Token): string { case Token.TildeToken: return '~'; case Token.ColonToken: return ':'; case Token.CommaToken: return ','; + case Token.AtToken: return '@'; case Token.ClassKeyword: return 'class'; case Token.InterfaceKeyword: return 'interface'; case Token.TypeKeyword: return 'type'; @@ -612,6 +712,7 @@ function tokenToString(token: Token): string { case Token.DecimalDigits: return ''; case Token.String: return ''; case Token.Text: return ''; + case Token.ModuleSource: return ''; } } @@ -676,7 +777,7 @@ class Scanner { this._tokenPos = this._pos; this._stringIsUnterminated = false; while (!this.eof) { - const ch: string = this._text[this._pos++]; + const ch: string = this._text.charAt(this._pos++); switch (ch) { case '{': return this._token = Token.OpenBraceToken; case '}': return this._token = Token.CloseBraceToken; @@ -690,6 +791,7 @@ class Scanner { case '~': return this._token = Token.TildeToken; case ':': return this._token = Token.ColonToken; case ',': return this._token = Token.CommaToken; + case '@': return this._token = Token.AtToken; case '"': this.scanString(); return this._token = Token.String; @@ -702,6 +804,51 @@ class Scanner { return this._token = Token.EofToken; } + public rescanModuleSource(): Token { + switch (this._token) { + case Token.ModuleSource: + case Token.ExclamationToken: + case Token.EofToken: + return this._token; + } + return this.speculate(accept => { + if (!this.eof) { + this._pos = this._tokenPos; + this._stringIsUnterminated = false; + let scanned: 'string' | 'other' | 'none' = 'none'; + while (!this.eof) { + const ch: string = this._text[this._pos]; + if (ch === '!') { + if (scanned === 'none') { + return this._token; + } + accept(); + return this._token = Token.ModuleSource; + } + this._pos++; + if (ch === '"') { + if (scanned === 'other') { + // strings not allowed after scanning any other characters + return this._token; + } + scanned = 'string'; + this.scanString(); + } else { + if (scanned === 'string') { + // no other tokens allowed after string + return this._token; + } + scanned = 'other'; + if (!isPunctuator(ch)) { + this.scanText(); + } + } + } + } + return this._token; + }); + } + public rescanMeaning(): Token { if (this._token === Token.Text) { const tokenText: string = this.tokenText; @@ -737,7 +884,7 @@ class Scanner { private scanString(): void { while (!this.eof) { - const ch: string = this._text[this._pos++]; + const ch: string = this._text.charAt(this._pos++); switch (ch) { case '"': return; case '\\': @@ -845,7 +992,7 @@ class Parser { } public get eof(): boolean { - return this._scanner.eof; + return this.token() === Token.EofToken; } public get errors(): ReadonlyArray { @@ -859,32 +1006,26 @@ class Parser { if (this.optionalToken(Token.ExclamationToken)) { // Reference to global symbol source = GlobalSource.instance; - symbol = this.parseSymbol(); - } else if (this.isStartOfComponent()) { - // Either path for module source or first component of symbol - const root: Component = this.parseComponent(); - if (root instanceof ComponentString && this.optionalToken(Token.ExclamationToken)) { - // Definitely path for module source - source = new ModuleSource(root.text, /*userEscaped*/ true); - - // Check for optional `~` navigation token. - if (this.optionalToken(Token.TildeToken)) { - navigation = Navigation.Locals; - } - - if (this.isStartOfComponent()) { - symbol = this.parseSymbol(); - } - } else { - // Definitely a symbol - symbol = this.parseSymbolRest(this.parseComponentRest(new ComponentRoot(root))); + } else if (this._scanner.rescanModuleSource() === Token.ModuleSource) { + source = this.parseModuleSource(); + // Check for optional `~` navigation token. + if (this.optionalToken(Token.TildeToken)) { + 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.parseSymbolRest(new ComponentRoot(new ComponentString('', /*userEscaped*/ true))); } return new DeclarationReference(source, navigation, symbol); } + public parseModuleSourceString(): string { + this._scanner.rescanModuleSource(); + return this.parseTokenString(Token.ModuleSource, 'Module source'); + } + public parseComponentString(): string { switch (this._scanner.token()) { case Token.String: @@ -898,6 +1039,12 @@ class Parser { return this._scanner.token(); } + private parseModuleSource(): ModuleSource | undefined { + const source: string = this.parseModuleSourceString(); + this.expectToken(Token.ExclamationToken); + return new ParsedModuleSource(source, /*userEscaped*/ true); + } + private parseSymbol(): SymbolReference { const component: ComponentPath = this.parseComponentRest(this.parseRootComponent()); return this.parseSymbolRest(component); @@ -992,8 +1139,8 @@ class Parser { private isStartOfComponent(): boolean { switch (this.token()) { - case Token.String: case Token.Text: + case Token.String: case Token.OpenBracketToken: return true; default: @@ -1014,26 +1161,25 @@ class Parser { } } - private parseText(): string { - if (this._scanner.token() === Token.Text) { - const text: string = this._scanner.tokenText; - this._scanner.scan(); - return text; - } - return this.fail('Text expected', ''); - } - - private parseString(): string { - if (this._scanner.token() === Token.String) { + private parseTokenString(token: Token, tokenString?: string): string { + if (this._scanner.token() === token) { const text: string = this._scanner.tokenText; const stringIsUnterminated: boolean = this._scanner.stringIsUnterminated; this._scanner.scan(); if (stringIsUnterminated) { - return this.fail('Unterminated string literal', text); + return this.fail(`${tokenString || tokenToString(token)} is unterminated`, text); } return text; } - return this.fail('String expected', ''); + return this.fail(`${tokenString || tokenToString(token)} expected`, ''); + } + + private parseText(): string { + return this.parseTokenString(Token.Text, 'Text'); + } + + private parseString(): string { + return this.parseTokenString(Token.String, 'String'); } private parseComponent(): Component { @@ -1185,13 +1331,14 @@ function isPunctuator(ch: string): boolean { case '~': case ':': case ',': + case '@': return true; default: return false; } } -function escapeIfNeeded(text: string, userEscaped?: boolean): string { +function escapeComponentIfNeeded(text: string, userEscaped?: boolean): string { if (userEscaped) { if (!DeclarationReference.isWellFormedComponentString(text)) { throw new SyntaxError(`Invalid Component '${text}'`); @@ -1199,4 +1346,14 @@ function escapeIfNeeded(text: string, userEscaped?: boolean): string { return text; } return DeclarationReference.escapeComponentString(text); +} + +function escapeModuleSourceIfNeeded(text: string, userEscaped?: boolean): string { + if (userEscaped) { + if (!DeclarationReference.isWellFormedModuleSourceString(text)) { + throw new SyntaxError(`Invalid Module source '${text}'`); + } + return text; + } + return DeclarationReference.escapeModuleSourceString(text); } \ No newline at end of file diff --git a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts index b6ed11ed..f763ca44 100644 --- a/tsdoc/src/beta/__tests__/DeclarationReference.test.ts +++ b/tsdoc/src/beta/__tests__/DeclarationReference.test.ts @@ -32,47 +32,62 @@ describe('parser', () => { expect(ref.symbol!.componentPath).toBeDefined(); expect(ref.symbol!.componentPath!.component.toString()).toBe('[abc.[def]]'); }); - it('parse module source', () => { - const ref: DeclarationReference = DeclarationReference.parse('abc!'); + it.each` + text | path | navigation | symbol + ${'abc!'} | ${'abc'} | ${undefined} | ${undefined} + ${'"abc"!'} | ${'"abc"'} | ${undefined} | ${undefined} + ${'@microsoft/rush-stack-compiler-3.5!'} | ${'@microsoft/rush-stack-compiler-3.5'} | ${undefined} | ${undefined} + ${'abc!def'} | ${'abc'} | ${'.'} | ${'def'} + ${'abc!~def'} | ${'abc'} | ${'~'} | ${'def'} + `('parse module source $text', ({ text, path, navigation, symbol }) => { + const ref: DeclarationReference = DeclarationReference.parse(text); expect(ref.source).toBeInstanceOf(ModuleSource); - expect(ref.symbol).toBeUndefined(); - expect((ref.source as ModuleSource).path).toBe('abc'); + expect((ref.source as ModuleSource).escapedPath).toBe(path); + expect(ref.navigation).toBe(navigation); + if (symbol) { + expect(ref.symbol).toBeInstanceOf(SymbolReference); + expect(ref.symbol!.componentPath).toBeDefined(); + expect(ref.symbol!.componentPath!.component.toString()).toBe(symbol); + } else { + expect(ref.symbol).toBeUndefined(); + } }); - it('parse global source', () => { - const ref: DeclarationReference = DeclarationReference.parse('!abc'); + it.each` + text | symbol + ${'!abc'} | ${'abc'} + `('parse global source $text', ({ text, symbol }) => { + const ref: DeclarationReference = DeclarationReference.parse(text); expect(ref.source).toBe(GlobalSource.instance); expect(ref.symbol).toBeInstanceOf(SymbolReference); expect(ref.symbol!.componentPath).toBeDefined(); - expect(ref.symbol!.componentPath!.component.toString()).toBe('abc'); + expect(ref.symbol!.componentPath!.component.toString()).toBe(symbol); + }); + it.each` + text | meaning + ${'a:class'} | ${Meaning.Class} + ${'a:interface'} | ${Meaning.Interface} + ${'a:type'} | ${Meaning.TypeAlias} + ${'a:enum'} | ${Meaning.Enum} + ${'a:namespace'} | ${Meaning.Namespace} + ${'a:function'} | ${Meaning.Function} + ${'a:var'} | ${Meaning.Variable} + ${'a:constructor'} | ${Meaning.Constructor} + ${'a:member'} | ${Meaning.Member} + ${'a:event'} | ${Meaning.Event} + ${'a:call'} | ${Meaning.CallSignature} + ${'a:new'} | ${Meaning.ConstructSignature} + ${'a:index'} | ${Meaning.IndexSignature} + ${'a:complex'} | ${Meaning.ComplexType} + `('parse meaning $meaning', ({ text, meaning }) => { + const ref: DeclarationReference = DeclarationReference.parse(text); + expect(ref.symbol!.meaning).toBe(meaning); }); - const meanings: Meaning[] = [ - Meaning.Class, - Meaning.Interface, - Meaning.TypeAlias, - Meaning.Enum, - Meaning.Namespace, - Meaning.Function, - Meaning.Variable, - Meaning.Constructor, - Meaning.Member, - Meaning.Event, - Meaning.CallSignature, - Meaning.ConstructSignature, - Meaning.IndexSignature, - Meaning.ComplexType - ]; - for (const s of meanings) { - it(`parse meaning ':${s}'`, () => { - const ref: DeclarationReference = DeclarationReference.parse(`a:${s}`); - expect(ref.symbol!.meaning).toBe(s); - }); - } it('parse complex', () => { const ref: DeclarationReference = DeclarationReference.parse('foo/bar!N.C#z:member(1)'); const source: ModuleSource = ref.source as ModuleSource; expect(source).toBeInstanceOf(ModuleSource); - expect(source.path).toBe('foo/bar'); + expect(source.escapedPath).toBe('foo/bar'); expect(ref.navigation).toBe(Navigation.Exports); @@ -97,6 +112,11 @@ describe('parser', () => { expect(ref.toString()).toBe('foo/bar!N.C#z:member(1)'); }); + it('parse invalid module reference', () => { + expect(() => { + DeclarationReference.parse('@scope/foo'); + }).toThrow(); + }); }); it('add navigation step', () => { const ref: DeclarationReference = DeclarationReference.empty() @@ -105,4 +125,204 @@ it('add navigation step', () => { expect(symbol).toBeInstanceOf(SymbolReference); expect(symbol.componentPath).toBeDefined(); expect(symbol.componentPath!.component.toString()).toBe('[Symbol.iterator]'); +}); +describe('DeclarationReference', () => { + it.each` + text | expected + ${''} | ${true} + ${'a'} | ${true} + ${'a.b'} | ${false} + ${'a~b'} | ${false} + ${'a#b'} | ${false} + ${'a:class'} | ${false} + ${'a!'} | ${false} + ${'@a'} | ${false} + ${'a@'} | ${false} + ${'['} | ${false} + ${']'} | ${false} + ${'{'} | ${false} + ${'}'} | ${false} + ${'('} | ${false} + ${')'} | ${false} + ${'[a]'} | ${false} + ${'[a.b]'} | ${false} + ${'[a!b]'} | ${false} + ${'""'} | ${true} + ${'"a"'} | ${true} + ${'"a.b"'} | ${true} + ${'"a~b"'} | ${true} + ${'"a#b"'} | ${true} + ${'"a:class"'} | ${true} + ${'"a!"'} | ${true} + ${'"@a"'} | ${true} + ${'"a@"'} | ${true} + ${'"["'} | ${true} + ${'"]"'} | ${true} + ${'"{"'} | ${true} + ${'"}"'} | ${true} + ${'"("'} | ${true} + ${'")"'} | ${true} + `('isWellFormedComponentString($text)', ({ text, expected }) => { + expect(DeclarationReference.isWellFormedComponentString(text)).toBe(expected); + }); + it.each` + text | expected + ${''} | ${'""'} + ${'a'} | ${'a'} + ${'a.b'} | ${'"a.b"'} + ${'a~b'} | ${'"a~b"'} + ${'a#b'} | ${'"a#b"'} + ${'a:class'} | ${'"a:class"'} + ${'a!'} | ${'"a!"'} + ${'@a'} | ${'"@a"'} + ${'a@'} | ${'"a@"'} + ${'['} | ${'"["'} + ${']'} | ${'"]"'} + ${'{'} | ${'"{"'} + ${'}'} | ${'"}"'} + ${'('} | ${'"("'} + ${')'} | ${'")"'} + ${'[a]'} | ${'"[a]"'} + ${'[a.b]'} | ${'"[a.b]"'} + ${'[a!b]'} | ${'"[a!b]"'} + ${'""'} | ${'"\\\"\\\""'} + ${'"a"'} | ${'"\\\"a\\\""'} + `('escapeComponentString($text)', ({ text, expected }) => { + expect(DeclarationReference.escapeComponentString(text)).toBe(expected); + }); + it.each` + text | expected + ${''} | ${''} + ${'""'} | ${''} + ${'a'} | ${'a'} + ${'"a"'} | ${'a'} + ${'"a.b"'} | ${'a.b'} + ${'"\\"\\""'} | ${'""'} + ${'"\\"a\\""'} | ${'"a"'} + `('unescapeComponentString($text)', ({ text, expected }) => { + if (expected === undefined) { + expect(() => DeclarationReference.unescapeComponentString(text)).toThrow(); + } else { + expect(DeclarationReference.unescapeComponentString(text)).toBe(expected); + } + }); + it.each` + text | expected + ${''} | ${false} + ${'a'} | ${true} + ${'a.b'} | ${true} + ${'a~b'} | ${true} + ${'a#b'} | ${true} + ${'a:class'} | ${true} + ${'a!'} | ${false} + ${'@a'} | ${true} + ${'a@'} | ${true} + ${'['} | ${true} + ${']'} | ${true} + ${'{'} | ${true} + ${'}'} | ${true} + ${'('} | ${true} + ${')'} | ${true} + ${'[a]'} | ${true} + ${'[a.b]'} | ${true} + ${'[a!b]'} | ${false} + ${'""'} | ${true} + ${'"a"'} | ${true} + ${'"a.b"'} | ${true} + ${'"a~b"'} | ${true} + ${'"a#b"'} | ${true} + ${'"a:class"'} | ${true} + ${'"a!"'} | ${true} + ${'"@a"'} | ${true} + ${'"a@"'} | ${true} + ${'"["'} | ${true} + ${'"]"'} | ${true} + ${'"{"'} | ${true} + ${'"}"'} | ${true} + ${'"("'} | ${true} + ${'")"'} | ${true} + ${'"[a!b]"'} | ${true} + `('isWellFormedModuleSourceString($text)', ({ text, expected }) => { + expect(DeclarationReference.isWellFormedModuleSourceString(text)).toBe(expected); + }); + it.each` + text | expected + ${''} | ${'""'} + ${'a'} | ${'a'} + ${'a.b'} | ${'a.b'} + ${'a~b'} | ${'a~b'} + ${'a#b'} | ${'a#b'} + ${'a:class'} | ${'a:class'} + ${'a!'} | ${'"a!"'} + ${'@a'} | ${'@a'} + ${'a@'} | ${'a@'} + ${'['} | ${'['} + ${']'} | ${']'} + ${'{'} | ${'{'} + ${'}'} | ${'}'} + ${'('} | ${'('} + ${')'} | ${')'} + ${'[a]'} | ${'[a]'} + ${'[a.b]'} | ${'[a.b]'} + ${'[a!b]'} | ${'"[a!b]"'} + ${'""'} | ${'"\\\"\\\""'} + ${'"a"'} | ${'"\\\"a\\\""'} + `('escapeModuleSourceString($text)', ({ text, expected }) => { + expect(DeclarationReference.escapeModuleSourceString(text)).toBe(expected); + }); + it.each` + text | expected + ${''} | ${undefined} + ${'""'} | ${''} + ${'a'} | ${'a'} + ${'"a"'} | ${'a'} + ${'"a!"'} | ${'a!'} + ${'"a.b"'} | ${'a.b'} + ${'"\\"\\""'} | ${'""'} + ${'"\\"a\\""'} | ${'"a"'} + `('unescapeModuleSourceString($text)', ({ text, expected }) => { + if (expected === undefined) { + expect(() => DeclarationReference.unescapeModuleSourceString(text)).toThrow(); + } else { + expect(DeclarationReference.unescapeModuleSourceString(text)).toBe(expected); + } + }); +}); +describe('ModuleSource', () => { + it.each` + text | packageName | scopeName | unscopedPackageName | importPath + ${'a'} | ${'a'} | ${''} | ${'a'} | ${''} + ${'a/b'} | ${'a'} | ${''} | ${'a'} | ${'b'} + ${'@a/b'} | ${'@a/b'} | ${'@a'} | ${'b'} | ${''} + ${'@a/b/c'} | ${'@a/b'} | ${'@a'} | ${'b'} | ${'c'} + `('package parts of $text', ({ text, packageName, scopeName, unscopedPackageName, importPath }) => { + const source: ModuleSource = new ModuleSource(text); + expect(source.packageName).toBe(packageName); + expect(source.scopeName).toBe(scopeName); + expect(source.unscopedPackageName).toBe(unscopedPackageName); + expect(source.importPath).toBe(importPath); + }); + it.each` + packageName | importPath | text + ${'a'} | ${undefined} | ${'a'} + ${'a'} | ${'b'} | ${'a/b'} + ${'@a/b'} | ${undefined} | ${'@a/b'} + ${'@a/b'} | ${'c'} | ${'@a/b/c'} + `('fromPackage($packageName, $importPath)', ({ packageName, importPath, text }) => { + const source: ModuleSource = ModuleSource.fromPackage(packageName, importPath); + expect(source.path).toBe(text); + }); + it.each` + scopeName | unscopedPackageName | importPath | text + ${''} | ${'a'} | ${undefined} | ${'a'} + ${''} | ${'a'} | ${'b'} | ${'a/b'} + ${'a'} | ${'b'} | ${undefined} | ${'@a/b'} + ${'@a'} | ${'b'} | ${undefined} | ${'@a/b'} + ${'a'} | ${'b'} | ${'c'} | ${'@a/b/c'} + ${'@a'} | ${'b'} | ${'c'} | ${'@a/b/c'} + `('fromScopedPackage($scopeName, $unscopedPackageName, $importPath)', + ({ scopeName, unscopedPackageName, importPath, text }) => { + const source: ModuleSource = ModuleSource.fromScopedPackage(scopeName, unscopedPackageName, importPath); + expect(source.path).toBe(text); + }); }); \ No newline at end of file