Skip to content

Commit ae327a5

Browse files
authored
Merge pull request microsoft#1410 from microsoft/octogonz/ae-name-escaping
[api-extractor] ApiItem.name is now quoted when it contains invalid identifier characters
2 parents c81518d + 793f139 commit ae327a5

24 files changed

Lines changed: 491 additions & 45 deletions

apps/api-documenter/src/documenters/MarkdownDocumenter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ export class MarkdownDocumenter {
791791
let baseName: string = '';
792792
for (const hierarchyItem of apiItem.getHierarchy()) {
793793
// For overloaded methods, add a suffix such as "MyClass.myMethod_2".
794-
let qualifiedName: string = hierarchyItem.displayName;
794+
let qualifiedName: string = Utilities.getSafeFilenameForName(hierarchyItem.displayName);
795795
if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) {
796796
if (hierarchyItem.overloadIndex > 1) {
797797
// Subtract one for compatibility with earlier releases of API Documenter.
@@ -805,13 +805,13 @@ export class MarkdownDocumenter {
805805
case ApiItemKind.EntryPoint:
806806
break;
807807
case ApiItemKind.Package:
808-
baseName = PackageName.getUnscopedName(hierarchyItem.displayName);
808+
baseName = Utilities.getSafeFilenameForName(PackageName.getUnscopedName(hierarchyItem.displayName));
809809
break;
810810
default:
811811
baseName += '.' + qualifiedName;
812812
}
813813
}
814-
return baseName.toLowerCase() + '.md';
814+
return baseName + '.md';
815815
}
816816

817817
private _getLinkFilenameForApiItem(apiItem: ApiItem): string {

apps/api-documenter/src/documenters/YamlDocumenter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -693,19 +693,19 @@ export class YamlDocumenter {
693693
case ApiItemKind.EntryPoint:
694694
break;
695695
case ApiItemKind.Package:
696-
result += PackageName.getUnscopedName(current.displayName);
696+
result += Utilities.getSafeFilenameForName(PackageName.getUnscopedName(current.displayName));
697697
break;
698698
default:
699699
if (current.parent && current.parent.kind === ApiItemKind.EntryPoint) {
700700
result += '/';
701701
} else {
702702
result += '.';
703703
}
704-
result += current.displayName;
704+
result += Utilities.getSafeFilenameForName(current.displayName);
705705
break;
706706
}
707707
}
708-
return path.join(this._outputFolder, result.toLowerCase() + '.yml');
708+
return path.join(this._outputFolder, result + '.yml');
709709
}
710710

711711
private _deleteOldOutputFiles(): void {

apps/api-documenter/src/utils/Utilities.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@microsoft/api-extractor-model';
88

99
export class Utilities {
10+
private static readonly _badFilenameCharsRegExp: RegExp = /[^a-z0-9_\-\.]/ig;
1011
/**
1112
* Generates a concise signature for a function. Example: "getArea(width, height)"
1213
*/
@@ -16,4 +17,13 @@ export class Utilities {
1617
}
1718
return apiItem.displayName;
1819
}
20+
21+
/**
22+
* Converts bad filename characters to underscores.
23+
*/
24+
public static getSafeFilenameForName(name: string): string {
25+
// TODO: This can introduce naming collisions.
26+
// We will fix that as part of https://github.com/microsoft/web-build-tools/issues/1308
27+
return name.replace(Utilities._badFilenameCharsRegExp, '_').toLowerCase();
28+
}
1929
}

apps/api-extractor/src/analyzer/AstSymbolTable.ts

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ExportAnalyzer } from './ExportAnalyzer';
1313
import { AstImport } from './AstImport';
1414
import { MessageRouter } from '../collector/MessageRouter';
1515
import { TypeScriptInternals } from './TypeScriptInternals';
16+
import { StringChecks } from './StringChecks';
1617

1718
export type AstEntity = AstSymbol | AstImport;
1819

@@ -210,6 +211,88 @@ export class AstSymbolTable {
210211
return this._entitiesByIdentifierNode.get(identifier);
211212
}
212213

214+
/**
215+
* Builds an AstSymbol.localName for a given ts.Symbol. In the current implementation, the localName is
216+
* a TypeScript-like expression that may be a string literal or ECMAScript symbol expression.
217+
*
218+
* ```ts
219+
* class X {
220+
* // localName="identifier"
221+
* public identifier: number = 1;
222+
* // localName="\"identifier\""
223+
* public "quoted string!": number = 2;
224+
* // localName="[MyNamespace.MySymbol]"
225+
* public [MyNamespace.MySymbol]: number = 3;
226+
* }
227+
* ```
228+
*/
229+
public static getLocalNameForSymbol(symbol: ts.Symbol): string {
230+
const symbolName: string = symbol.name;
231+
232+
// TypeScript binds well-known ECMAScript symbols like "[Symbol.iterator]" as "__@iterator".
233+
// Decode it back into "[Symbol.iterator]".
234+
const wellKnownSymbolName: string | undefined = TypeScriptHelpers.tryDecodeWellKnownSymbolName(symbolName);
235+
if (wellKnownSymbolName) {
236+
return wellKnownSymbolName;
237+
}
238+
239+
const isUniqueSymbol: boolean = TypeScriptHelpers.isUniqueSymbolName(symbolName);
240+
241+
// We will try to obtain the name from a declaration; otherwise we'll fall back to the symbol name.
242+
let unquotedName: string = symbolName;
243+
244+
for (const declaration of symbol.declarations || []) {
245+
// Handle cases such as "export default class X { }" where the symbol name is "default"
246+
// but the local name is "X".
247+
const localSymbol: ts.Symbol | undefined = TypeScriptInternals.tryGetLocalSymbol(declaration);
248+
if (localSymbol) {
249+
unquotedName = localSymbol.name;
250+
}
251+
252+
// If it is a non-well-known symbol, then return the late-bound name. For example, "X.Y.z" in this example:
253+
//
254+
// namespace X {
255+
// export namespace Y {
256+
// export const z: unique symbol = Symbol("z");
257+
// }
258+
// }
259+
//
260+
// class C {
261+
// public [X.Y.z](): void { }
262+
// }
263+
//
264+
if (isUniqueSymbol) {
265+
const declarationName: ts.DeclarationName | undefined = ts.getNameOfDeclaration(declaration);
266+
if (declarationName && ts.isComputedPropertyName(declarationName)) {
267+
const lateBoundName: string | undefined = TypeScriptHelpers.tryGetLateBoundName(declarationName);
268+
if (lateBoundName) {
269+
// Here the string may contain an expression such as "[X.Y.z]". Names starting with "[" are always
270+
// expressions. If a string literal contains those characters, the code below will JSON.stringify() it
271+
// to avoid a collision.
272+
return lateBoundName;
273+
}
274+
}
275+
}
276+
}
277+
278+
// Otherwise that name may come from a quoted string or pseudonym like `__constructor`.
279+
// If the string is not a safe identifier, then we must add quotes.
280+
// Note that if it was quoted but did not need to be quoted, here we will remove the quotes.
281+
if (!StringChecks.isSafeUnquotedMemberIdentifier(unquotedName)) {
282+
// For API Extractor's purposes, a canonical form is more appropriate than trying to reflect whatever
283+
// appeared in the source code. The code is not even guaranteed to be consistent, for example:
284+
//
285+
// class X {
286+
// public "f1"(x: string): void;
287+
// public f1(x: boolean): void;
288+
// public 'f1'(x: string | boolean): void { }
289+
// }
290+
return JSON.stringify(unquotedName);
291+
}
292+
293+
return unquotedName;
294+
}
295+
213296
/**
214297
* Used by analyze to recursively analyze the entire child tree.
215298
*/
@@ -425,35 +508,7 @@ export class AstSymbolTable {
425508
}
426509
}
427510

428-
let localName: string | undefined = options.localName;
429-
430-
if (localName === undefined) {
431-
// We will try to obtain the name from a declaration; otherwise we'll fall back to the symbol name
432-
// This handles cases such as "export default class X { }" where the symbol name is "default"
433-
// but the declaration name is "X".
434-
localName = followedSymbol.name;
435-
if (TypeScriptHelpers.isWellKnownSymbolName(localName)) {
436-
// TypeScript binds well-known ECMAScript symbols like "Symbol.iterator" as "__@iterator".
437-
// This converts a string like "__@iterator" into the property name "[Symbol.iterator]".
438-
localName = `[Symbol.${localName.slice(3)}]`;
439-
} else {
440-
const isUniqueSymbol: boolean = TypeScriptHelpers.isUniqueSymbolName(localName);
441-
for (const declaration of followedSymbol.declarations || []) {
442-
const declarationName: ts.DeclarationName | undefined = ts.getNameOfDeclaration(declaration);
443-
if (declarationName && ts.isIdentifier(declarationName)) {
444-
localName = declarationName.getText().trim();
445-
break;
446-
}
447-
if (isUniqueSymbol && declarationName && ts.isComputedPropertyName(declarationName)) {
448-
const lateBoundName: string | undefined = TypeScriptHelpers.tryGetLateBoundName(declarationName);
449-
if (lateBoundName) {
450-
localName = lateBoundName;
451-
break;
452-
}
453-
}
454-
}
455-
}
456-
}
511+
const localName: string | undefined = options.localName || AstSymbolTable.getLocalNameForSymbol(followedSymbol);
457512

458513
astSymbol = new AstSymbol({
459514
followedSymbol: followedSymbol,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
/**
5+
* Helpers for validating various text string formats.
6+
*/
7+
export class StringChecks {
8+
// Note: In addition to letters, numbers, underscores, and dollar signs, modern ECMAScript
9+
// also allows Unicode categories such as letters, combining marks, digits, and connector punctuation.
10+
// These are mostly supported in all environments except IE11, so if someone wants it, we would accept
11+
// a PR to allow them (although the test surface might be somewhat large).
12+
private static readonly _identifierBadCharRegExp: RegExp = /[^a-z0-9_$]/i;
13+
14+
// Identifiers most not start with a number.
15+
private static readonly _identifierNumberStartRegExp: RegExp = /^[0-9]/;
16+
17+
/**
18+
* Tests whether the input string is safe to use as an ECMAScript identifier without quotes.
19+
*
20+
* @remarks
21+
* For example:
22+
*
23+
* ```ts
24+
* class X {
25+
* public okay: number = 1;
26+
* public "not okay!": number = 2;
27+
* }
28+
* ```
29+
*
30+
* A precise check is extremely complicated and highly dependent on the ECMAScript standard version
31+
* and how faithfully the interpreter implements it. To keep things simple, `isValidUnquotedIdentifier()`
32+
* conservatively checks for basic alphanumeric identifiers and returns false otherwise.
33+
*
34+
* Based on `StringChecks.explainIfInvalidUnquotedIdentifier()` from TSDoc.
35+
*/
36+
public static isSafeUnquotedMemberIdentifier(identifier: string): boolean {
37+
if (identifier.length === 0) {
38+
return false; // cannot be empty
39+
}
40+
41+
if (StringChecks._identifierBadCharRegExp.test(identifier)) {
42+
return false; // cannot contain bad characters
43+
}
44+
45+
if (StringChecks._identifierNumberStartRegExp.test(identifier)) {
46+
return false; // cannot start with a number
47+
}
48+
49+
return true;
50+
}
51+
}

apps/api-extractor/src/analyzer/TypeScriptHelpers.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,23 @@ export class TypeScriptHelpers {
212212

213213
// Matches TypeScript's encoded names for well-known ECMAScript symbols like
214214
// "__@iterator" or "__@toStringTag".
215-
private static readonly _wellKnownSymbolNameRegExp: RegExp = /^__@\w+$/;
215+
private static readonly _wellKnownSymbolNameRegExp: RegExp = /^__@(\w+)$/;
216216

217217
/**
218-
* Returns whether the provided name was generated for a built-in ECMAScript symbol.
218+
* Decodes the names that the compiler generates for a built-in ECMAScript symbol.
219+
*
220+
* @remarks
221+
* TypeScript binds well-known ECMAScript symbols like `[Symbol.iterator]` as `__@iterator`.
222+
* If `name` is of this form, then `tryGetWellKnownSymbolName()` converts it back into e.g. `[Symbol.iterator]`.
223+
* If the string does not start with `__@` then `undefined` is returned.
219224
*/
220-
public static isWellKnownSymbolName(name: string): boolean {
221-
return TypeScriptHelpers._wellKnownSymbolNameRegExp.test(name);
225+
public static tryDecodeWellKnownSymbolName(name: string): string | undefined {
226+
const match: RegExpExecArray | null = TypeScriptHelpers._wellKnownSymbolNameRegExp.exec(name);
227+
if (match) {
228+
const identifier: string = match[1];
229+
return `[Symbol.${identifier}]`;
230+
}
231+
return undefined;
222232
}
223233

224234
// Matches TypeScript's encoded names for late-bound symbols derived from `unique symbol` declarations

apps/api-extractor/src/analyzer/TypeScriptInternals.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,13 @@ export class TypeScriptInternals {
8383
public static getSymbolParent(symbol: ts.Symbol): ts.Symbol | undefined {
8484
return (symbol as any).parent;
8585
}
86+
87+
/**
88+
* In an statement like `export default class X { }`, the `Symbol.name` will be `default`
89+
* whereas the `localSymbol` is `X`.
90+
*/
91+
public static tryGetLocalSymbol(declaration: ts.Declaration): ts.Symbol | undefined {
92+
return (declaration as any).localSymbol;
93+
}
94+
8695
}

0 commit comments

Comments
 (0)