diff --git a/common/changes/@microsoft/tsdoc-config/synonyms_2019-12-19-22-47.json b/common/changes/@microsoft/tsdoc-config/synonyms_2019-12-19-22-47.json new file mode 100644 index 00000000..80673684 --- /dev/null +++ b/common/changes/@microsoft/tsdoc-config/synonyms_2019-12-19-22-47.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc-config", + "comment": "Add support for configuring synonyms.", + "type": "minor" + } + ], + "packageName": "@microsoft/tsdoc-config", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/@microsoft/tsdoc/synonyms_2019-12-19-22-47.json b/common/changes/@microsoft/tsdoc/synonyms_2019-12-19-22-47.json new file mode 100644 index 00000000..a057a4c4 --- /dev/null +++ b/common/changes/@microsoft/tsdoc/synonyms_2019-12-19-22-47.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc", + "comment": "Add support for synonyms and the 'see' tag.", + "type": "minor" + } + ], + "packageName": "@microsoft/tsdoc", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/eslint-plugin-tsdoc/synonyms_2019-12-19-22-47.json b/common/changes/eslint-plugin-tsdoc/synonyms_2019-12-19-22-47.json new file mode 100644 index 00000000..bfab3c9a --- /dev/null +++ b/common/changes/eslint-plugin-tsdoc/synonyms_2019-12-19-22-47.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "eslint-plugin-tsdoc", + "comment": "", + "type": "none" + } + ], + "packageName": "eslint-plugin-tsdoc", + "email": "ron.buckton@microsoft.com" +} \ No newline at end of file diff --git a/tsdoc-config/src/TSDocConfigFile.ts b/tsdoc-config/src/TSDocConfigFile.ts index 5c78b314..4f88376f 100644 --- a/tsdoc-config/src/TSDocConfigFile.ts +++ b/tsdoc-config/src/TSDocConfigFile.ts @@ -31,6 +31,16 @@ interface ITagConfigJson { tagName: string; syntaxKind: 'inline' | 'block' | 'modifier'; allowMultiple?: boolean; + synonyms?: string[]; +} + +interface ISynonymConfigJson { + add?: ISynonymSetJson; + remove?: ISynonymSetJson; +} + +interface ISynonymSetJson { + [tagName: string]: string[]; } interface IConfigJson { @@ -38,6 +48,7 @@ interface IConfigJson { tsdocVersion: string; extends?: string[]; tagDefinitions: ITagConfigJson[]; + synonyms?: ISynonymConfigJson; } /** @@ -62,6 +73,8 @@ export class TSDocConfigFile { private _tsdocSchema: string; private readonly _extendsPaths: string[]; private readonly _tagDefinitions: TSDocTagDefinition[]; + private readonly _synonymAdditions: Map; + private readonly _synonymDeletions: Map; private constructor() { this.log = new ParserMessageLog(); @@ -72,7 +85,9 @@ export class TSDocConfigFile { this._hasErrors = false; this._tsdocSchema = ''; this._extendsPaths = []; - this._tagDefinitions= []; + this._tagDefinitions = []; + this._synonymAdditions = new Map(); + this._synonymDeletions = new Map(); } /** @@ -129,6 +144,14 @@ export class TSDocConfigFile { return this._tagDefinitions; } + public get synonymAdditions(): ReadonlyMap> { + return this._synonymAdditions; + } + + public get synonymDeletions(): ReadonlyMap> { + return this._synonymDeletions; + } + private _reportError(parserMessageParameters: IParserMessageParameters): void { this.log.addMessage(new ParserMessage(parserMessageParameters)); this._hasErrors = true; @@ -181,9 +204,22 @@ export class TSDocConfigFile { this._tagDefinitions.push(new TSDocTagDefinition({ tagName: jsonTagDefinition.tagName, syntaxKind: syntaxKind, + synonyms: jsonTagDefinition.synonyms, allowMultiple: jsonTagDefinition.allowMultiple })); } + if (configJson.synonyms) { + if (configJson.synonyms.add) { + for (const tagName of Object.keys(configJson.synonyms.add)) { + this._synonymAdditions.set(tagName, configJson.synonyms.add[tagName]); + } + } + if (configJson.synonyms.remove) { + for (const tagName of Object.keys(configJson.synonyms.remove)) { + this._synonymDeletions.set(tagName, configJson.synonyms.remove[tagName]); + } + } + } } private _loadWithExtends(configFilePath: string, referencingConfigFile: TSDocConfigFile | undefined, @@ -329,5 +365,23 @@ export class TSDocConfigFile { for (const tagDefinition of this.tagDefinitions) { configuration.addTagDefinition(tagDefinition); } + + this.synonymDeletions.forEach((synonyms, tagName) => { + const tagDefinition: TSDocTagDefinition | undefined + = configuration.tryGetTagDefinition(tagName); + if (!tagDefinition) { + throw new Error(`A tag with the name ${tagName} could not be found.`); + } + configuration.removeSynonym(tagDefinition, ...synonyms); + }); + + this.synonymAdditions.forEach((synonyms, tagName) => { + const tagDefinition: TSDocTagDefinition | undefined + = configuration.tryGetTagDefinition(tagName); + if (!tagDefinition) { + throw new Error(`A tag with the name ${tagName} could not be found.`); + } + configuration.addSynonym(tagDefinition, ...synonyms); + }); } } diff --git a/tsdoc-config/src/__tests__/TSDocConfigFile.test.ts b/tsdoc-config/src/__tests__/TSDocConfigFile.test.ts index 8e0af966..408e24ac 100644 --- a/tsdoc-config/src/__tests__/TSDocConfigFile.test.ts +++ b/tsdoc-config/src/__tests__/TSDocConfigFile.test.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { TSDocConfigFile } from '../TSDocConfigFile'; +import { TSDocSynonymCollection } from '@microsoft/tsdoc/lib/configuration/TSDocSynonymCollection'; function getRelativePath(testPath: string): string { return path @@ -23,10 +24,32 @@ expect.addSnapshotSerializer({ extendsPaths: value.extendsPaths, extendsFiles: value.extendsFiles, tagDefinitions: value.tagDefinitions, + synonymAdditions: Array.from(value.synonymAdditions).reduce>>( + (obj, [key, value]) => { + obj[key] = value; + return obj; + }, + {} + ), + synonymDeletions: Array.from(value.synonymDeletions).reduce>>( + (obj, [key, value]) => { + obj[key] = value; + return obj; + }, + {} + ), messages: value.log.messages }); } }); +expect.addSnapshotSerializer({ + test(value: unknown) { + return value instanceof TSDocSynonymCollection; + }, + print(value: TSDocSynonymCollection, serialize: (value: unknown) => string, indent: (str: string) => string): string { + return serialize(value.synonyms); + } +}); function testLoadingFolder(assetPath: string): TSDocConfigFile { return TSDocConfigFile.loadForFolder(path.join(__dirname, assetPath)); @@ -40,6 +63,8 @@ test('Load p1', () => { "fileNotFound": false, "filePath": "assets/p1/tsdoc.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [], "tsdocSchema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", } @@ -66,6 +91,8 @@ test('Load p2', () => { "unformattedText": "File not found", }, ], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [], "tsdocSchema": "", } @@ -81,8 +108,11 @@ test('Load p3', () => { "fileNotFound": false, "filePath": "assets/p3/base1/tsdoc-base1.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [ TSDocTagDefinition { + "_synonymCollection": Array [], "allowMultiple": false, "standardization": "None", "syntaxKind": 2, @@ -98,8 +128,11 @@ test('Load p3', () => { "fileNotFound": false, "filePath": "assets/p3/base2/tsdoc-base2.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [ TSDocTagDefinition { + "_synonymCollection": Array [], "allowMultiple": false, "standardization": "None", "syntaxKind": 2, @@ -117,8 +150,11 @@ test('Load p3', () => { "fileNotFound": false, "filePath": "assets/p3/tsdoc.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [ TSDocTagDefinition { + "_synonymCollection": Array [], "allowMultiple": false, "standardization": "None", "syntaxKind": 2, @@ -140,8 +176,11 @@ test('Load p4', () => { "fileNotFound": false, "filePath": "assets/p4/node_modules/example-lib/dist/tsdoc-example.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [ TSDocTagDefinition { + "_synonymCollection": Array [], "allowMultiple": false, "standardization": "None", "syntaxKind": 2, @@ -158,8 +197,11 @@ test('Load p4', () => { "fileNotFound": false, "filePath": "assets/p4/tsdoc.json", "messages": Array [], + "synonymAdditions": Object {}, + "synonymDeletions": Object {}, "tagDefinitions": Array [ TSDocTagDefinition { + "_synonymCollection": Array [], "allowMultiple": false, "standardization": "None", "syntaxKind": 2, @@ -171,3 +213,33 @@ test('Load p4', () => { } `); }); +test('Load synonyms', () => { + expect(testLoadingFolder('assets/synonyms')).toMatchInlineSnapshot(` + Object { + "extendsFiles": Array [], + "extendsPaths": Array [], + "fileNotFound": false, + "filePath": "assets/synonyms/tsdoc.json", + "messages": Array [], + "synonymAdditions": Object { + "@readonly": Array [ + "@readonly2", + ], + }, + "synonymDeletions": Object {}, + "tagDefinitions": Array [ + TSDocTagDefinition { + "_synonymCollection": Array [ + "@bar", + ], + "allowMultiple": false, + "standardization": "None", + "syntaxKind": 1, + "tagName": "@foo", + "tagNameWithUpperCase": "@FOO", + }, + ], + "tsdocSchema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + } + `); +}); diff --git a/tsdoc-config/src/__tests__/assets/synonyms/tsconfig.json b/tsdoc-config/src/__tests__/assets/synonyms/tsconfig.json new file mode 100644 index 00000000..2c63c085 --- /dev/null +++ b/tsdoc-config/src/__tests__/assets/synonyms/tsconfig.json @@ -0,0 +1,2 @@ +{ +} diff --git a/tsdoc-config/src/__tests__/assets/synonyms/tsdoc.json b/tsdoc-config/src/__tests__/assets/synonyms/tsdoc.json new file mode 100644 index 00000000..0d48f74e --- /dev/null +++ b/tsdoc-config/src/__tests__/assets/synonyms/tsdoc.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "tagDefinitions": [ + { "tagName": "@foo", "syntaxKind": "block", "synonyms": ["@bar"] } + ], + "synonyms": { + "add": { + "@readonly": ["@readonly2"] + } + } +} \ No newline at end of file diff --git a/tsdoc/schemas/tsdoc.schema.json b/tsdoc/schemas/tsdoc.schema.json index bfb9b425..c9158034 100644 --- a/tsdoc/schemas/tsdoc.schema.json +++ b/tsdoc/schemas/tsdoc.schema.json @@ -22,6 +22,22 @@ "items": { "$ref": "#/definitions/tsdocTagDefinition" } + }, + + "synonyms": { + "description": "Additional synonyms to add or remove from built-in tag definitions.", + "type": "object", + "properties": { + "add": { + "description": "Synonyms to add.", + "$ref": "#/definitions/synonymSet" + }, + "remove": { + "description": "Synonyms to remove.", + "$ref": "#/definitions/synonymSet" + } + }, + "additionalProperties": false } }, "required": [ "$schema" ], @@ -44,10 +60,28 @@ "allowMultiple": { "description": "If true, then this tag may appear multiple times in a doc comment. By default, a tag may only appear once.", "type": "boolean" + }, + "synonyms": { + "description": "Synonyms of the custom tag. TSDoc tag names start with an at-sign (@) followed by ASCII letters using camelCase capitalization.", + "type": "array", + "items": { + "type": "string" + } } }, "required": ["tagName", "syntaxKind"], "additionalProperties": false + }, + "synonymSet": { + "description": "Provides the assocation between a tag and the synonyms to be added or removed.", + "type": "object", + "additionalProperties": { + "description": "Synonyms of the tag. TSDoc tag names start with an at-sign (@) followed by ASCII letters using camelCase capitalization.", + "type": "array", + "items": { + "type": "string" + } + } } } } diff --git a/tsdoc/src/__tests__/ParsingBasics.test.ts b/tsdoc/src/__tests__/ParsingBasics.test.ts index 7c277cac..e3c57427 100644 --- a/tsdoc/src/__tests__/ParsingBasics.test.ts +++ b/tsdoc/src/__tests__/ParsingBasics.test.ts @@ -7,6 +7,7 @@ import { TSDocTagSyntaxKind } from '../index'; import { TestHelpers } from '../parser/__tests__/TestHelpers'; +import { StandardTags } from '../details/StandardTags'; test('01 Simple @beta and @internal extraction', () => { const parserContext: ParserContext = TestHelpers.parseAndMatchDocCommentSnapshot([ @@ -111,3 +112,17 @@ test('04 typeParam blocks', () => { ' */' ].join('\n')); }); + +test('05 synonyms', () => { + const configuration: TSDocConfiguration = new TSDocConfiguration(); + configuration.addSynonym(StandardTags.readonly, "@readonly2"); + TestHelpers.parseAndMatchDocCommentSnapshot([ + '/**', + ' * @param a - description1', + ' * @arg b - description2', + ' * @argument c - description3', + ' * @return description4', + ' * @readonly2', + ' */' + ].join('\n'), configuration); +}); \ No newline at end of file diff --git a/tsdoc/src/__tests__/__snapshots__/ParsingBasics.test.ts.snap b/tsdoc/src/__tests__/__snapshots__/ParsingBasics.test.ts.snap index 686fd104..01958270 100644 --- a/tsdoc/src/__tests__/__snapshots__/ParsingBasics.test.ts.snap +++ b/tsdoc/src/__tests__/__snapshots__/ParsingBasics.test.ts.snap @@ -1596,3 +1596,276 @@ Object { "_12_logMessages": Array [], } `; + +exports[`05 synonyms 1`] = ` +Object { + "_00_lines": Array [ + "@param a - description1", + "@arg b - description2", + "@argument c - description3", + "@return description4", + "@readonly2", + ], + "_01_gaps": Array [], + "_02_summarySection": Object { + "kind": "Section", + }, + "_03_remarksBlock": undefined, + "_04_privateRemarksBlock": undefined, + "_05_deprecatedBlock": undefined, + "_06_paramBlocks": Array [ + Object { + "kind": "ParamBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@param", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_ParameterName", + "nodeExcerpt": "a", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_Hyphen", + "nodeExcerpt": "-", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "description1", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "kind": "ParamBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@arg", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_ParameterName", + "nodeExcerpt": "b", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_Hyphen", + "nodeExcerpt": "-", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "description2", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "kind": "ParamBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@argument", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_ParameterName", + "nodeExcerpt": "c", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ParamBlock_Hyphen", + "nodeExcerpt": "-", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "description3", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + "_07_typeParamBlocks": Array [], + "_08_returnsBlock": Object { + "kind": "Block", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@return", + }, + ], + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " description4", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + "_09_customBlocks": Array [], + "_10_inheritDocTag": undefined, + "_11_modifierTags": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@readonly2", + }, + ], + }, + ], + "_12_logMessages": Array [], +} +`; diff --git a/tsdoc/src/configuration/TSDocConfiguration.ts b/tsdoc/src/configuration/TSDocConfiguration.ts index abd08a57..290c246d 100644 --- a/tsdoc/src/configuration/TSDocConfiguration.ts +++ b/tsdoc/src/configuration/TSDocConfiguration.ts @@ -1,24 +1,35 @@ import { StandardTags } from '../details/StandardTags'; -import { TSDocTagDefinition } from './TSDocTagDefinition'; +import { TSDocTagDefinition, ITSDocTagDefinitionInternalParameters } from './TSDocTagDefinition'; import { TSDocValidationConfiguration } from './TSDocValidationConfiguration'; import { DocNodeManager } from './DocNodeManager'; import { BuiltInDocNodes } from '../nodes/BuiltInDocNodes'; import { TSDocMessageId, allTsdocMessageIds, allTsdocMessageIdsSet } from '../parser/TSDocMessageId'; +import { TSDocSynonymCollection } from './TSDocSynonymCollection'; +import { StringChecks } from '../parser/StringChecks'; + +interface ITSDocTagDefinitionOverride { + derivedTagDefinition: TSDocTagDefinition; + synonymCollection: TSDocSynonymCollection; +} /** * Configuration for the TSDocParser. */ export class TSDocConfiguration { - private readonly _tagDefinitions: TSDocTagDefinition[]; - private readonly _tagDefinitionsByName: Map; - private readonly _supportedTagDefinitions: Set; + private readonly _baseTagDefinitionsByName: Map; + private readonly _baseTagDefinitions: TSDocTagDefinition[]; + private readonly _supportedBaseTagDefinitions: Set; private readonly _validation: TSDocValidationConfiguration; private readonly _docNodeManager: DocNodeManager; + private _tagDefinitionOverrides: Map | undefined; + private _tagDefinitionOverridesReverseMap: Map | undefined; + private _derivedTagDefinitions: TSDocTagDefinition[] | undefined; + private _supportedDerivedTagDefinitions: TSDocTagDefinition[] | undefined; public constructor() { - this._tagDefinitions = []; - this._tagDefinitionsByName = new Map(); - this._supportedTagDefinitions = new Set(); + this._baseTagDefinitions = []; + this._baseTagDefinitionsByName = new Map(); + this._supportedBaseTagDefinitions = new Set(); this._validation = new TSDocValidationConfiguration(); this._docNodeManager = new DocNodeManager(); @@ -36,7 +47,10 @@ export class TSDocConfiguration { * The subset of "supported" tags is tracked by {@link TSDocConfiguration.supportedTagDefinitions}. */ public get tagDefinitions(): ReadonlyArray { - return this._tagDefinitions; + if (!this._derivedTagDefinitions) { + this._derivedTagDefinitions = this._baseTagDefinitions.map(tagDefinition => this.getConfiguredTagDefinition(tagDefinition)); + } + return this._derivedTagDefinitions; } /** @@ -48,7 +62,10 @@ export class TSDocConfiguration { * {@link TSDocValidationConfiguration.reportUnsupportedTags} is enabled. */ public get supportedTagDefinitions(): ReadonlyArray { - return this.tagDefinitions.filter(x => this.isTagSupported(x)); + if (!this._supportedDerivedTagDefinitions) { + this._supportedDerivedTagDefinitions = this.tagDefinitions.filter(x => this.isTagSupported(x)); + } + return this._supportedDerivedTagDefinitions; } /** @@ -70,7 +87,7 @@ export class TSDocConfiguration { * if not found. */ public tryGetTagDefinition(tagName: string): TSDocTagDefinition | undefined { - return this._tagDefinitionsByName.get(tagName.toUpperCase()); + return this.tryGetTagDefinitionWithUpperCase(tagName.toUpperCase()); } /** @@ -78,7 +95,19 @@ export class TSDocConfiguration { * if not found. */ public tryGetTagDefinitionWithUpperCase(alreadyUpperCaseTagName: string): TSDocTagDefinition | undefined { - return this._tagDefinitionsByName.get(alreadyUpperCaseTagName); + const tagDefinition: TSDocTagDefinition | undefined + = this._baseTagDefinitionsByName.get(alreadyUpperCaseTagName); + return tagDefinition && this.getConfiguredTagDefinition(tagDefinition); + } + + /** + * Return the configured version of a tag definition. If a tag definition has been configured + * with additional synonyms, the derived tag definition is returned. + */ + public getConfiguredTagDefinition(tagDefinition: TSDocTagDefinition): TSDocTagDefinition { + const override: ITSDocTagDefinitionOverride | undefined = + this._tagDefinitionOverrides && this._tagDefinitionOverrides.get(tagDefinition); + return override ? override.derivedTagDefinition : tagDefinition; } /** @@ -89,20 +118,11 @@ export class TSDocConfiguration { * If a tag is "defined" this means that the parser recognizes it and understands its syntax. * Whereas if a tag is "supported", this means it is defined AND the application implements the tag. */ - public addTagDefinition(tagDefinition: TSDocTagDefinition): void { - const existingDefinition: TSDocTagDefinition | undefined - = this._tagDefinitionsByName.get(tagDefinition.tagNameWithUpperCase); - - if (existingDefinition === tagDefinition) { - return; - } - - if (existingDefinition) { - throw new Error(`A tag is already defined using the name ${existingDefinition.tagName}`); + public addTagDefinition(tagDefinition: TSDocTagDefinition, supported?: boolean | undefined): void { + this._addTagDefinition(tagDefinition); + if (supported !== undefined) { + this.setSupportForTag(tagDefinition, supported); } - - this._tagDefinitions.push(tagDefinition); - this._tagDefinitionsByName.set(tagDefinition.tagNameWithUpperCase, tagDefinition); } /** @@ -116,11 +136,7 @@ export class TSDocConfiguration { supported?: boolean | undefined): void { for (const tagDefinition of tagDefinitions) { - this.addTagDefinition(tagDefinition); - - if (supported !== undefined) { - this.setSupportForTag(tagDefinition, supported); - } + this.addTagDefinition(tagDefinition, supported); } } @@ -128,8 +144,9 @@ export class TSDocConfiguration { * Returns true if the tag is supported in this configuration. */ public isTagSupported(tagDefinition: TSDocTagDefinition): boolean { - this._requireTagToBeDefined(tagDefinition); - return this._supportedTagDefinitions.has(tagDefinition); + const baseTagDefinition: TSDocTagDefinition = this._getBaseTagDefinition(tagDefinition); + this._requireTagToBeDefined(baseTagDefinition); + return this._supportedBaseTagDefinitions.has(baseTagDefinition); } /** @@ -144,14 +161,16 @@ export class TSDocConfiguration { * to true. */ public setSupportForTag(tagDefinition: TSDocTagDefinition, supported: boolean): void { - this._requireTagToBeDefined(tagDefinition); + const baseTagDefinition: TSDocTagDefinition = this._getBaseTagDefinition(tagDefinition); + this._requireTagToBeDefined(baseTagDefinition); if (supported) { - this._supportedTagDefinitions.add(tagDefinition); + this._supportedBaseTagDefinitions.add(baseTagDefinition); } else { - this._supportedTagDefinitions.delete(tagDefinition); + this._supportedBaseTagDefinitions.delete(baseTagDefinition); } this.validation.reportUnsupportedTags = true; + this._invalidateDerived(); } /** @@ -163,6 +182,101 @@ export class TSDocConfiguration { } } + /** + * Adds a synonym to a registered tag definition. + * @param tagDefinition - The tag definition to which to add a new synonym. + * @param synonyms - The synonyms to add. + * @returns The configured version of the provided tag definition. + */ + public addSynonym(tagDefinition: TSDocTagDefinition, ...synonyms: string[]): TSDocTagDefinition { + const baseTagDefinition: TSDocTagDefinition = this._getBaseTagDefinition(tagDefinition); + this._requireTagToBeDefined(baseTagDefinition); + + const synonymsWithUpperCase: string[] = synonyms.map(synonym => synonym.toUpperCase()); + const synonymsToAdd: string[] = []; + const synonymsWithUpperCaseToAdd: string[] = []; + for (let i: number = 0; i < synonyms.length; i++) { + const synonym: string = synonyms[i]; + const synonymWithUpperCase: string = synonymsWithUpperCase[i]; + StringChecks.validateTSDocTagName(synonym); + + const existingDefinition: TSDocTagDefinition | undefined + = this._baseTagDefinitionsByName.get(synonymWithUpperCase); + + if (existingDefinition) { + if (existingDefinition !== baseTagDefinition) { + throw new Error(`A tag is already defined using the name ${synonym}`); + } + continue; + } + + synonymsToAdd.push(synonym); + synonymsWithUpperCaseToAdd.push(synonymWithUpperCase); + } + + if (synonymsToAdd.length === 0) { + return this.getConfiguredTagDefinition(baseTagDefinition); + } + + const override: ITSDocTagDefinitionOverride = this._overrideTagDefinition(baseTagDefinition); + for (let i: number = 0; i < synonymsToAdd.length; i++) { + override.synonymCollection.add(synonymsToAdd[i]); + this._baseTagDefinitionsByName.set(synonymsWithUpperCaseToAdd[i], baseTagDefinition); + } + + return override.derivedTagDefinition; + } + + /** + * Removes a synonym from a registered tag definition. + * @param tagDefinition - The tag definition from which to remove a synonym. + * @param synonyms - The synonyms to remove. + * @returns The configured version of the provided tag definition. + */ + public removeSynonym(tagDefinition: TSDocTagDefinition, ...synonyms: string[]): TSDocTagDefinition { + const baseTagDefinition: TSDocTagDefinition = this._getBaseTagDefinition(tagDefinition); + this._requireTagToBeDefined(baseTagDefinition); + + const synonymsWithUpperCase: string[] = synonyms.map(synonym => synonym.toUpperCase()); + const synonymsToRemove: string[] = []; + const synonymsWithUpperCaseToRemove: string[] = []; + for (let i: number = 0; i < synonyms.length; i++) { + const synonym: string = synonyms[i]; + const synonymWithUpperCase: string = synonymsWithUpperCase[i]; + StringChecks.validateTSDocTagName(synonym); + + const existingDefinition: TSDocTagDefinition | undefined + = this._baseTagDefinitionsByName.get(synonymWithUpperCase); + + if (!existingDefinition) { + continue; + } + + if (existingDefinition !== baseTagDefinition) { + throw new Error(`The synonym ${synonym} is not provided by this tag.`); + } + + if (baseTagDefinition.tagNameWithUpperCase === synonymWithUpperCase) { + throw new Error(`${synonym} is the primary tag name for this definition and cannot be removed.`); + } + + synonymsToRemove.push(synonym); + synonymsWithUpperCaseToRemove.push(synonymWithUpperCase); + } + + if (synonymsToRemove.length === 0) { + return this.getConfiguredTagDefinition(baseTagDefinition); + } + + const override: ITSDocTagDefinitionOverride = this._overrideTagDefinition(baseTagDefinition); + for (let i: number = 0; i < synonymsToRemove.length; i++) { + override.synonymCollection.delete(synonymsToRemove[i]); + this._baseTagDefinitionsByName.delete(synonymsWithUpperCaseToRemove[i]); + } + + return override.derivedTagDefinition; + } + /** * Returns true if the specified {@link TSDocMessageId} string is implemented by this release of the TSDoc parser. * This can be used to detect misspelled identifiers. @@ -188,14 +302,99 @@ export class TSDocConfiguration { return allTsdocMessageIds as ReadonlyArray; } - private _requireTagToBeDefined(tagDefinition: TSDocTagDefinition): void { + private _requireTagToBeDefined(baseTagDefinition: TSDocTagDefinition): void { const matching: TSDocTagDefinition | undefined - = this._tagDefinitionsByName.get(tagDefinition.tagNameWithUpperCase); + = this._baseTagDefinitionsByName.get(baseTagDefinition.tagNameWithUpperCase); if (matching) { - if (matching === tagDefinition) { + if (matching === baseTagDefinition) { return; } } throw new Error('The specified TSDocTagDefinition is not defined for this TSDocConfiguration'); } + + private _getBaseTagDefinition(tagDefinition: TSDocTagDefinition): TSDocTagDefinition { + return this._tagDefinitionOverridesReverseMap && + this._tagDefinitionOverridesReverseMap.get(tagDefinition) || tagDefinition; + } + + private _addTagDefinition(tagDefinition: TSDocTagDefinition): void { + const baseTagDefinition: TSDocTagDefinition = this._getBaseTagDefinition(tagDefinition); + const existingDefinition: TSDocTagDefinition | undefined + = this._baseTagDefinitionsByName.get(baseTagDefinition.tagNameWithUpperCase); + + if (existingDefinition === baseTagDefinition) { + return; + } + + if (existingDefinition) { + throw new Error(`A tag is already defined using the name ${existingDefinition.tagName}`); + } + + const synonyms: ReadonlyArray = baseTagDefinition.synonyms; + const synonymsWithUpperCase: ReadonlyArray = baseTagDefinition.synonymsWithUpperCase; + const synonymsToAdd: string[] = []; + for (let i: number = 0; i < synonymsWithUpperCase.length; i++) { + const synonymWithUpperCase: string = synonymsWithUpperCase[i]; + const existingDefinition: TSDocTagDefinition | undefined + = this._baseTagDefinitionsByName.get(synonymWithUpperCase); + if (existingDefinition) { + if (existingDefinition !== baseTagDefinition) { + throw new Error(`A tag is already defined using the name ${synonyms[i]}`); + } + continue; + } + synonymsToAdd.push(synonymWithUpperCase); + } + + this._baseTagDefinitions.push(baseTagDefinition); + this._baseTagDefinitionsByName.set(baseTagDefinition.tagNameWithUpperCase, baseTagDefinition); + for (const synonym of synonymsToAdd) { + this._baseTagDefinitionsByName.set(synonym, baseTagDefinition); + } + + this._invalidateDerived(); + } + + private _overrideTagDefinition(baseTagDefinition: TSDocTagDefinition): ITSDocTagDefinitionOverride { + if (!this._tagDefinitionOverrides) { + this._tagDefinitionOverrides = new Map(); + } + if (!this._tagDefinitionOverridesReverseMap) { + this._tagDefinitionOverridesReverseMap = new Map(); + } + + let override: ITSDocTagDefinitionOverride | undefined = + this._tagDefinitionOverrides.get(baseTagDefinition); + + if (!override) { + const synonymCollection: TSDocSynonymCollection = new TSDocSynonymCollection(); + const derivedTagParameters: ITSDocTagDefinitionInternalParameters = { + tagName: baseTagDefinition.tagName, + syntaxKind: baseTagDefinition.syntaxKind, + allowMultiple: baseTagDefinition.allowMultiple, + standardization: baseTagDefinition.standardization, + synonyms: baseTagDefinition.synonyms.slice(), + synonymCollection, + }; + + const derivedTagDefinition: TSDocTagDefinition = new TSDocTagDefinition(derivedTagParameters); + override = { derivedTagDefinition, synonymCollection }; + + this._tagDefinitionOverrides.set(baseTagDefinition, override); + this._tagDefinitionOverridesReverseMap.set(derivedTagDefinition, baseTagDefinition); + this._invalidateDerived(); + } + + return override; + } + + private _invalidateDerived(): void { + if (this._derivedTagDefinitions) { + this._derivedTagDefinitions = undefined; + } + if (this._supportedDerivedTagDefinitions) { + this._supportedDerivedTagDefinitions = undefined; + } + } } diff --git a/tsdoc/src/configuration/TSDocSynonymCollection.ts b/tsdoc/src/configuration/TSDocSynonymCollection.ts new file mode 100644 index 00000000..3718fccc --- /dev/null +++ b/tsdoc/src/configuration/TSDocSynonymCollection.ts @@ -0,0 +1,67 @@ +import { StringChecks } from "../parser/StringChecks"; + +/** + * @internal + */ +export class TSDocSynonymCollection { + private _synonyms: string[]; + private _synonymsWithUpperCase: string[] | undefined; + + public constructor() { + this._synonyms = []; + this._synonymsWithUpperCase = undefined; + } + + public get count(): number { + return this._synonyms.length; + } + + public get synonyms(): ReadonlyArray { + return this._synonyms; + } + + public get synonymsWithUpperCase(): ReadonlyArray { + if (!this._synonymsWithUpperCase) { + this._synonymsWithUpperCase = this._synonyms.map(synonym => synonym.toUpperCase()); + } + return this._synonymsWithUpperCase; + } + + public add(synonym: string): void { + StringChecks.validateTSDocTagName(synonym); + if (this._synonyms.indexOf(synonym) >= 0) { + return; + } + this._synonyms.push(synonym); + this._invalidateSynonymsWithUpperCase(); + } + + public delete(synonym: string): boolean { + const index: number = this._synonyms.indexOf(synonym); + if (index >= 0) { + this._synonyms.splice(index, 1); + this._invalidateSynonymsWithUpperCase(); + return true; + } + return false; + } + + public clear(): void { + this._synonyms.length = 0; + this._invalidateSynonymsWithUpperCase(); + } + + public hasTagName(tagName: string): boolean { + return this.synonymsWithUpperCase.indexOf(tagName.toUpperCase()) >= 0; + } + + public [Symbol.iterator](): IterableIterator { + return this._synonyms[Symbol.iterator](); + } + + private _invalidateSynonymsWithUpperCase(): void { + if (this._synonymsWithUpperCase) { + this._synonymsWithUpperCase = undefined; + } + } +} \ No newline at end of file diff --git a/tsdoc/src/configuration/TSDocTagDefinition.ts b/tsdoc/src/configuration/TSDocTagDefinition.ts index 62af44d3..0ec7cae3 100644 --- a/tsdoc/src/configuration/TSDocTagDefinition.ts +++ b/tsdoc/src/configuration/TSDocTagDefinition.ts @@ -1,5 +1,7 @@ import { StringChecks } from '../parser/StringChecks'; import { Standardization } from '../details/Standardization'; +import { DocBlockTag, DocInlineTagBase } from '../nodes'; +import { TSDocSynonymCollection } from './TSDocSynonymCollection'; /** * Determines the type of syntax for a TSDocTagDefinition @@ -29,6 +31,7 @@ export enum TSDocTagSyntaxKind { export interface ITSDocTagDefinitionParameters { tagName: string; syntaxKind: TSDocTagSyntaxKind; + synonyms?: string[]; allowMultiple?: boolean; } @@ -37,6 +40,7 @@ export interface ITSDocTagDefinitionParameters { */ export interface ITSDocTagDefinitionInternalParameters extends ITSDocTagDefinitionParameters { standardization: Standardization; + synonymCollection?: TSDocSynonymCollection; } /** @@ -72,6 +76,8 @@ export class TSDocTagDefinition { */ public readonly allowMultiple: boolean; + private _synonymCollection: TSDocSynonymCollection; + public constructor(parameters: ITSDocTagDefinitionParameters) { StringChecks.validateTSDocTagName(parameters.tagName); this.tagName = parameters.tagName; @@ -80,5 +86,49 @@ export class TSDocTagDefinition { this.standardization = (parameters as ITSDocTagDefinitionInternalParameters).standardization || Standardization.None; this.allowMultiple = !!parameters.allowMultiple; + this._synonymCollection = (parameters as ITSDocTagDefinitionInternalParameters).synonymCollection || + new TSDocSynonymCollection(); + if (parameters.synonyms) { + for (const synonym of parameters.synonyms) { + if (synonym !== this.tagName) { + this._synonymCollection.add(synonym); + } + } + } + } + + /** + * Synonyms for the TSDoc tag. TSDoc tag names start with an at-sign ("@") followed + * by ASCII letters using "camelCase" capitalization. + */ + public get synonyms(): ReadonlyArray { + return this._synonymCollection.synonyms; + } + + /** + * Synonyms for the TSDoc tag in all capitals, which is used for performing + * case-insensitive comparisons or lookups. + */ + public get synonymsWithUpperCase(): ReadonlyArray { + return this._synonymCollection.synonymsWithUpperCase; + } + + /** + * Returns whether this tag definition is a definition of the provided tag. + */ + public isDefinitionOfTag(tag: DocBlockTag | DocInlineTagBase): boolean { + const hasCorrectKind: boolean = this.syntaxKind === TSDocTagSyntaxKind.InlineTag ? + tag instanceof DocInlineTagBase : + tag instanceof DocBlockTag; + return hasCorrectKind && this.hasTagName(tag.tagNameWithUpperCase); + } + + /** + * Returns whether the provided tag name is defined by this tag definition. + */ + public hasTagName(tagName: string): boolean { + const tagNameWithUpperCase: string = tagName.toUpperCase(); + return this.tagNameWithUpperCase === tagNameWithUpperCase || + this.synonymsWithUpperCase.indexOf(tagNameWithUpperCase) >= 0; } } diff --git a/tsdoc/src/configuration/__tests__/TSDocConfiguration.ts b/tsdoc/src/configuration/__tests__/TSDocConfiguration.ts new file mode 100644 index 00000000..bb2e8ad3 --- /dev/null +++ b/tsdoc/src/configuration/__tests__/TSDocConfiguration.ts @@ -0,0 +1,247 @@ +import { TSDocConfiguration } from '../TSDocConfiguration'; +import { TSDocTagDefinition, TSDocTagSyntaxKind } from '../TSDocTagDefinition'; + +describe('Synonym overrides', () => { + describe('addSynonym', () => { + describe('with no existing synonym in base', () => { + let configuration: TSDocConfiguration; + let baseTag: TSDocTagDefinition; + let derivedTag: TSDocTagDefinition; + beforeEach(() => { + configuration = new TSDocConfiguration(); + baseTag = new TSDocTagDefinition({ + syntaxKind: TSDocTagSyntaxKind.BlockTag, + tagName: '@foo', + }); + configuration.addTagDefinition(baseTag); + configuration.setSupportForTag(baseTag, /*supported*/ true); + derivedTag = configuration.addSynonym(baseTag, '@bar'); + afterEach(() => { + configuration = undefined!; + baseTag = undefined!; + derivedTag = undefined!; + }); + }); + test('does not mutate base tag', () => { + expect(baseTag.synonyms).toHaveLength(0); + }); + test('returns a derived tag', () => { + expect(derivedTag).not.toBe(baseTag); + }); + test('derived tag has expected synonyms', () => { + expect(derivedTag.synonyms).toEqual(['@bar']); + }); + test('derived tag differs from base only in synonyms', () => { + expect(derivedTag.tagName).toEqual(baseTag.tagName); + expect(derivedTag.tagNameWithUpperCase).toEqual(baseTag.tagNameWithUpperCase); + expect(derivedTag.syntaxKind).toEqual(baseTag.syntaxKind); + expect(derivedTag.standardization).toEqual(baseTag.standardization); + expect(derivedTag.allowMultiple).toEqual(baseTag.allowMultiple); + expect(derivedTag.synonyms).not.toEqual(baseTag.synonyms); + }); + test('additional synonym for base returns derived', () => { + expect(configuration.addSynonym(baseTag, '@baz')).toBe(derivedTag); + }); + test('additional synonym for derived returns derived', () => { + expect(configuration.addSynonym(derivedTag, '@baz')).toBe(derivedTag); + }); + test('additional synonym for derived mutates derived', () => { + configuration.addSynonym(derivedTag, '@baz') + expect(derivedTag.synonyms).toEqual(['@bar', '@baz']); + }); + test('derived replaces base in tagDefinitions', () => { + expect(configuration.tagDefinitions).toHaveLength(1); + expect(configuration.tagDefinitions[0]).toBe(derivedTag); + }); + test('derived replaces base in supportedTagDefinitions', () => { + expect(configuration.supportedTagDefinitions).toHaveLength(1); + expect(configuration.supportedTagDefinitions[0]).toBe(derivedTag); + }); + test('derived tag reachable by name', () => { + expect(configuration.tryGetTagDefinition('@foo')).toBe(derivedTag); + }); + test('derived tag reachable by synonym', () => { + expect(configuration.tryGetTagDefinition('@bar')).toBe(derivedTag); + }); + test('configured tag of base is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(baseTag)).toBe(derivedTag); + }); + test('configured tag of derived is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(derivedTag)).toBe(derivedTag); + }); + }); + describe('for existing synonym in base', () => { + let configuration: TSDocConfiguration; + let baseTag: TSDocTagDefinition; + let baseTagAfterAddExisting: TSDocTagDefinition; + beforeEach(() => { + configuration = new TSDocConfiguration(); + baseTag = new TSDocTagDefinition({ + syntaxKind: TSDocTagSyntaxKind.BlockTag, + tagName: '@foo', + synonyms: ['@bar'] + }); + configuration.addTagDefinition(baseTag); + configuration.setSupportForTag(baseTag, /*supported*/ true); + baseTagAfterAddExisting = configuration.addSynonym(baseTag, '@bar'); + afterEach(() => { + configuration = undefined!; + baseTag = undefined!; + baseTagAfterAddExisting = undefined!; + }); + }); + test('does not modify base tag', () => { + expect(baseTag.synonyms).toEqual(['@bar']); + }); + test('returns the base tag', () => { + expect(baseTagAfterAddExisting).toBe(baseTag); + }); + test('base tag reachable by name', () => { + expect(configuration.tryGetTagDefinition('@foo')).toBe(baseTag); + }); + test('base tag reachable by synonym', () => { + expect(configuration.tryGetTagDefinition('@bar')).toBe(baseTag); + }); + test('configured tag of base is base tag', () => { + expect(configuration.getConfiguredTagDefinition(baseTag)).toBe(baseTag); + }); + describe('additional synonym', () => { + let derivedTag: TSDocTagDefinition; + beforeEach(() => { + derivedTag = configuration.addSynonym(baseTag, '@baz'); + afterEach(() => { + derivedTag = undefined!; + }); + }); + test('does not modify base tag', () => { + expect(baseTag.synonyms).toEqual(['@bar']); + }); + test('returns a derived tag', () => { + expect(derivedTag).not.toBe(baseTag); + }); + test('does not modify base tag', () => { + expect(baseTag.synonyms).toHaveLength(0); + }); + test('derived replaces base in tagDefinitions', () => { + expect(configuration.tagDefinitions).toHaveLength(1); + expect(configuration.tagDefinitions[0]).toBe(derivedTag); + }); + test('derived replaces base in supportedTagDefinitions', () => { + expect(configuration.supportedTagDefinitions).toHaveLength(1); + expect(configuration.supportedTagDefinitions[0]).toBe(derivedTag); + }); + test('derived tag reachable by name', () => { + expect(configuration.tryGetTagDefinition('@foo')).toBe(derivedTag); + }); + test('derived tag reachable by synonym', () => { + expect(configuration.tryGetTagDefinition('@baz')).toBe(derivedTag); + }); + test('configured tag of base is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(baseTag)).toBe(derivedTag); + }); + test('configured tag of derived is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(derivedTag)).toBe(derivedTag); + }); + }); + }); + }); + describe('removeSynonym', () => { + describe('with no existing synonym in base', () => { + let configuration: TSDocConfiguration; + let baseTag: TSDocTagDefinition; + let derivedTag: TSDocTagDefinition; + let derivedTagAfterRemove: TSDocTagDefinition; + beforeEach(() => { + configuration = new TSDocConfiguration(); + baseTag = new TSDocTagDefinition({ + syntaxKind: TSDocTagSyntaxKind.BlockTag, + tagName: '@foo', + }); + configuration.addTagDefinition(baseTag); + derivedTag = configuration.addSynonym(baseTag, '@bar'); + derivedTagAfterRemove = configuration.removeSynonym(baseTag, '@bar'); + afterEach(() => { + configuration = undefined!; + baseTag = undefined!; + derivedTag = undefined!; + derivedTagAfterRemove = undefined!; + }); + }); + test('returned tag remains derived', () => { + expect(derivedTagAfterRemove).toBe(derivedTag); + }); + test('mutates synonyms on derived', () => { + expect(derivedTag.synonyms).toHaveLength(0); + }); + test('derived replaces base in tagDefinitions', () => { + expect(configuration.tagDefinitions).toHaveLength(1); + expect(configuration.tagDefinitions[0]).toBe(derivedTag); + }); + test('derived replaces base in supportedTagDefinitions', () => { + expect(configuration.supportedTagDefinitions).toHaveLength(1); + expect(configuration.supportedTagDefinitions[0]).toBe(derivedTag); + }); + test('derived tag reachable by name', () => { + expect(configuration.tryGetTagDefinition('@foo')).toBe(derivedTag); + }); + test('nothing reachable by synonym', () => { + expect(configuration.tryGetTagDefinition('@bar')).toBeUndefined(); + }); + test('configured tag of base is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(baseTag)).toBe(derivedTag); + }); + test('configured tag of derived is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(derivedTag)).toBe(derivedTag); + }); + }); + describe('with existing synonym in base', () => { + let configuration: TSDocConfiguration; + let baseTag: TSDocTagDefinition; + let derivedTag: TSDocTagDefinition; + beforeEach(() => { + configuration = new TSDocConfiguration(); + baseTag = new TSDocTagDefinition({ + syntaxKind: TSDocTagSyntaxKind.BlockTag, + tagName: '@foo', + synonyms: ['@bar'] + }); + configuration.addTagDefinition(baseTag); + derivedTag = configuration.removeSynonym(baseTag, '@bar'); + afterEach(() => { + configuration = undefined!; + baseTag = undefined!; + derivedTag = undefined!; + }); + }); + test('does not mutate base tag', () => { + expect(baseTag.synonyms).toEqual(['@bar']); + }); + test('returns a derived tag', () => { + expect(derivedTag).not.toBe(baseTag); + }); + test('derived tag has expected synonyms', () => { + expect(derivedTag.synonyms).toHaveLength(0); + }); + test('derived replaces base in tagDefinitions', () => { + expect(configuration.tagDefinitions).toHaveLength(1); + expect(configuration.tagDefinitions[0]).toBe(derivedTag); + }); + test('derived replaces base in supportedTagDefinitions', () => { + expect(configuration.supportedTagDefinitions).toHaveLength(1); + expect(configuration.supportedTagDefinitions[0]).toBe(derivedTag); + }); + test('derived tag reachable by name', () => { + expect(configuration.tryGetTagDefinition('@foo')).toBe(derivedTag); + }); + test('nothing reachable by synonym', () => { + expect(configuration.tryGetTagDefinition('@bar')).toBeUndefined(); + }); + test('configured tag of base is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(baseTag)).toBe(derivedTag); + }); + test('configured tag of derived is derived tag', () => { + expect(configuration.getConfiguredTagDefinition(derivedTag)).toBe(derivedTag); + }); + }); + }); +}); \ No newline at end of file diff --git a/tsdoc/src/details/ModifierTagSet.ts b/tsdoc/src/details/ModifierTagSet.ts index 2e976546..607b74a2 100644 --- a/tsdoc/src/details/ModifierTagSet.ts +++ b/tsdoc/src/details/ModifierTagSet.ts @@ -1,5 +1,13 @@ import { DocBlockTag } from '../nodes/DocBlockTag'; import { TSDocTagDefinition, TSDocTagSyntaxKind } from '../configuration/TSDocTagDefinition'; +import { TSDocConfiguration } from '../configuration/TSDocConfiguration'; + +/** + * Constructor parameters for {@link ModifierTagSet}. + */ +export interface IModifierTagSetParameters { + configuration: TSDocConfiguration; +} /** * Represents a set of modifier tags that were extracted from a doc comment. @@ -11,6 +19,8 @@ import { TSDocTagDefinition, TSDocTagSyntaxKind } from '../configuration/TSDocTa * signature is internal (i.e. not part of the public API contract). */ export class ModifierTagSet { + public readonly configuration: TSDocConfiguration; + private readonly _nodes: DocBlockTag[] = []; // NOTE: To implement case insensitivity, the keys in this set are always upper-case. @@ -18,6 +28,14 @@ export class ModifierTagSet { // the Turkish "i" character correctly). private readonly _nodesByName: Map = new Map(); + /** + * Don't call this directly. Instead use {@link TSDocParser} + * @internal + */ + public constructor(parameters: IModifierTagSetParameters) { + this.configuration = parameters.configuration; + } + /** * The original block tag nodes that defined the modifiers in this set, excluding duplicates. */ @@ -36,8 +54,7 @@ export class ModifierTagSet { /** * Returns true if the set contains a DocBlockTag matching the specified tag definition. - * Note that synonyms are not considered. The comparison is case-insensitive. - * The TSDocTagDefinition must be a modifier tag. + * The comparison is case-insensitive. The TSDocTagDefinition must be a modifier tag. * @param tagName - The name of the tag, including the `@` prefix For example, `@internal` */ public hasTag(modifierTagDefinition: TSDocTagDefinition): boolean { @@ -53,7 +70,19 @@ export class ModifierTagSet { if (modifierTagDefinition.syntaxKind !== TSDocTagSyntaxKind.ModifierTag) { throw new Error('The tag definition is not a modifier tag'); } - return this._nodesByName.get(modifierTagDefinition.tagNameWithUpperCase); + + const configuredTagDefinition: TSDocTagDefinition + = this.configuration.getConfiguredTagDefinition(modifierTagDefinition); + + let tag: DocBlockTag | undefined = + this._nodesByName.get(configuredTagDefinition.tagNameWithUpperCase); + if (!tag) { + for (const synonym of configuredTagDefinition.synonymsWithUpperCase) { + tag = this._nodesByName.get(synonym); + if (tag) break; + } + } + return tag; } /** diff --git a/tsdoc/src/details/StandardTags.ts b/tsdoc/src/details/StandardTags.ts index 126839c7..3ac281b4 100644 --- a/tsdoc/src/details/StandardTags.ts +++ b/tsdoc/src/details/StandardTags.ts @@ -68,6 +68,7 @@ export class StandardTags { */ public static readonly defaultValue: TSDocTagDefinition = StandardTags._defineTag({ tagName: '@defaultValue', + synonyms: ['@default'], syntaxKind: TSDocTagSyntaxKind.BlockTag, standardization: Standardization.Extended }); @@ -222,6 +223,7 @@ export class StandardTags { */ public static readonly param: TSDocTagDefinition = StandardTags._defineTag({ tagName: '@param', + synonyms: ['@arg', '@argument'], syntaxKind: TSDocTagSyntaxKind.BlockTag, allowMultiple: true, standardization: Standardization.Core @@ -295,6 +297,7 @@ export class StandardTags { */ public static readonly returns: TSDocTagDefinition = StandardTags._defineTag({ tagName: '@returns', + synonyms: ['@return'], syntaxKind: TSDocTagSyntaxKind.BlockTag, standardization: Standardization.Core }); @@ -316,6 +319,31 @@ export class StandardTags { standardization: Standardization.Extended }); + /** + * (Extended) + * + * Used to document another symbol or resource that may be related to the current item being documented. + * + * @remarks + * + * For example: + * + * ```ts + * /** + * * Make a rectangle from two points. + * * + * * @see {@link Point} + * */ + * function makeRect(a: Point, b: Point): Rect; + * ``` + */ + public static readonly see: TSDocTagDefinition = StandardTags._defineTag({ + tagName: '@see', + synonyms: ['@seeAlso'], + syntaxKind: TSDocTagSyntaxKind.BlockTag, + standardization: Standardization.Extended + }); + /** * (Extended) * @@ -349,6 +377,7 @@ export class StandardTags { */ public static readonly throws: TSDocTagDefinition = StandardTags._defineTag({ tagName: '@throws', + synonyms: ['@exception'], syntaxKind: TSDocTagSyntaxKind.BlockTag, allowMultiple: true, standardization: Standardization.Extended @@ -363,6 +392,7 @@ export class StandardTags { */ public static readonly typeParam: TSDocTagDefinition = StandardTags._defineTag({ tagName: '@typeParam', + synonyms: ['@template'], syntaxKind: TSDocTagSyntaxKind.BlockTag, allowMultiple: true, standardization: Standardization.Core @@ -408,6 +438,7 @@ export class StandardTags { StandardTags.remarks, StandardTags.returns, StandardTags.sealed, + StandardTags.see, StandardTags.throws, StandardTags.typeParam, StandardTags.virtual diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index 44d6d125..f11e2b9f 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -96,7 +96,7 @@ export class TSDocEmitter { this._ensureLineSkipped(); this._renderNode(docBlock.blockTag); - if (docBlock.blockTag.tagNameWithUpperCase === StandardTags.returns.tagNameWithUpperCase) { + if (StandardTags.returns.isDefinitionOfTag(docBlock.blockTag)) { this._writeContent(' '); this._hangingParagraph = true; } diff --git a/tsdoc/src/nodes/DocComment.ts b/tsdoc/src/nodes/DocComment.ts index 36428446..b6bb5f62 100644 --- a/tsdoc/src/nodes/DocComment.ts +++ b/tsdoc/src/nodes/DocComment.ts @@ -104,7 +104,10 @@ export class DocComment extends DocNode { this.typeParams = new DocParamCollection({ configuration: this.configuration }); this.returnsBlock = undefined; - this.modifierTagSet = new StandardModifierTagSet(); + const modifierTagSetParameters: IModifierTagSetParameters = { + configuration: parameters.configuration + }; + this.modifierTagSet = new StandardModifierTagSet(modifierTagSetParameters); this._customBlocks = []; } @@ -166,4 +169,5 @@ export class DocComment extends DocNode { } // Circular reference -import { TSDocEmitter } from '../emitters/TSDocEmitter'; +import { TSDocEmitter } from '../emitters/TSDocEmitter';import { IModifierTagSetParameters } from '../details/ModifierTagSet'; + diff --git a/tsdoc/src/nodes/DocLinkTag.ts b/tsdoc/src/nodes/DocLinkTag.ts index 134a0570..dbd8bc44 100644 --- a/tsdoc/src/nodes/DocLinkTag.ts +++ b/tsdoc/src/nodes/DocLinkTag.ts @@ -7,6 +7,7 @@ import { } from './DocInlineTagBase'; import { DocExcerpt, ExcerptKind } from './DocExcerpt'; import { TokenSequence } from '../parser/TokenSequence'; +import { StandardTags } from '../details/StandardTags'; /** * Constructor parameters for {@link DocLinkTag}. @@ -62,7 +63,7 @@ export class DocLinkTag extends DocInlineTagBase { public constructor(parameters: IDocLinkTagParameters | IDocLinkTagParsedParameters) { super(parameters); - if (this.tagNameWithUpperCase !== '@LINK') { + if (!StandardTags.link.hasTagName(this.tagNameWithUpperCase)) { throw new Error('DocLinkTag requires the tag name to be "{@link}"'); } diff --git a/tsdoc/src/parser/NodeParser.ts b/tsdoc/src/parser/NodeParser.ts index 9b60df83..7e5cbf82 100644 --- a/tsdoc/src/parser/NodeParser.ts +++ b/tsdoc/src/parser/NodeParser.ts @@ -276,14 +276,14 @@ export class NodeParser { if (tagDefinition) { switch (tagDefinition.syntaxKind) { case TSDocTagSyntaxKind.BlockTag: - if (docBlockTag.tagNameWithUpperCase === StandardTags.param.tagNameWithUpperCase) { + if (StandardTags.param.isDefinitionOfTag(docBlockTag)) { const docParamBlock: DocParamBlock = this._parseParamBlock(tokenReader, docBlockTag); this._parserContext.docComment.params.add(docParamBlock); this._currentSection = docParamBlock.content; return; - } else if (docBlockTag.tagNameWithUpperCase === StandardTags.typeParam.tagNameWithUpperCase) { + } else if (StandardTags.typeParam.isDefinitionOfTag(docBlockTag)) { const docParamBlock: DocParamBlock = this._parseParamBlock(tokenReader, docBlockTag); this._parserContext.docComment.typeParams.add(docParamBlock); @@ -315,22 +315,16 @@ export class NodeParser { private _addBlockToDocComment(block: DocBlock): void { const docComment: DocComment = this._parserContext.docComment; - - switch (block.blockTag.tagNameWithUpperCase) { - case StandardTags.remarks.tagNameWithUpperCase: - docComment.remarksBlock = block; - break; - case StandardTags.privateRemarks.tagNameWithUpperCase: - docComment.privateRemarks = block; - break; - case StandardTags.deprecated.tagNameWithUpperCase: - docComment.deprecatedBlock = block; - break; - case StandardTags.returns.tagNameWithUpperCase: - docComment.returnsBlock = block; - break; - default: - docComment.appendCustomBlock(block); + if (StandardTags.remarks.isDefinitionOfTag(block.blockTag)) { + docComment.remarksBlock = block; + } else if (StandardTags.privateRemarks.isDefinitionOfTag(block.blockTag)) { + docComment.privateRemarks = block; + } else if (StandardTags.deprecated.isDefinitionOfTag(block.blockTag)) { + docComment.deprecatedBlock = block; + } else if (StandardTags.returns.isDefinitionOfTag(block.blockTag)) { + docComment.returnsBlock = block; + } else { + docComment.appendCustomBlock(block); } } @@ -669,15 +663,12 @@ export class NodeParser { tagContentExcerpt ? tagContentExcerpt : TokenSequence.createEmpty(this._parserContext)); let docNode: DocNode; - switch (tagNameWithUpperCase) { - case StandardTags.inheritDoc.tagNameWithUpperCase: - docNode = this._parseInheritDocTag(docInlineTagParsedParameters, embeddedTokenReader); - break; - case StandardTags.link.tagNameWithUpperCase: - docNode = this._parseLinkTag(docInlineTagParsedParameters, embeddedTokenReader); - break; - default: - docNode = new DocInlineTag(docInlineTagParsedParameters); + if (StandardTags.inheritDoc.hasTagName(tagNameWithUpperCase)) { + docNode = this._parseInheritDocTag(docInlineTagParsedParameters, embeddedTokenReader); + } else if (StandardTags.link.hasTagName(tagNameWithUpperCase)) { + docNode = this._parseLinkTag(docInlineTagParsedParameters, embeddedTokenReader); + } else { + docNode = new DocInlineTag(docInlineTagParsedParameters); } // Validate the tag