diff --git a/src/lib/converter/factories/comment.ts b/src/lib/converter/factories/comment.ts index c36baeaad..9751d6966 100644 --- a/src/lib/converter/factories/comment.ts +++ b/src/lib/converter/factories/comment.ts @@ -209,7 +209,8 @@ export function parseComment( if ( tagName === "param" || tagName === "typeparam" || - tagName === "template" + tagName === "template" || + tagName === "inheritdoc" ) { line = consumeTypeData(line); const param = /[^\s]+/.exec(line); diff --git a/src/lib/converter/plugins/ImplementsPlugin.ts b/src/lib/converter/plugins/ImplementsPlugin.ts index ebded471f..5b7c6019c 100644 --- a/src/lib/converter/plugins/ImplementsPlugin.ts +++ b/src/lib/converter/plugins/ImplementsPlugin.ts @@ -1,5 +1,4 @@ import { - Reflection, ReflectionKind, DeclarationReflection, SignatureReflection, @@ -8,8 +7,8 @@ import { Type, ReferenceType } from "../../models/types/index"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import { Context } from "../context"; -import { Comment } from "../../models/comments/comment"; import { zip } from "../../utils/array"; +import { copyComment } from "../utils/reflections"; /** * A plugin that detects interface implementations of functions and @@ -82,7 +81,7 @@ export class ImplementsPlugin extends ConverterComponent { interfaceMember, context.project ); - this.copyComment(classMember, interfaceMember); + copyComment(classMember, interfaceMember); if ( interfaceMember.kindOf(ReflectionKind.FunctionOrMethod) && @@ -105,7 +104,7 @@ export class ImplementsPlugin extends ConverterComponent { interfaceSignature, context.project ); - this.copyComment( + copyComment( classSignature, interfaceSignature ); @@ -119,46 +118,6 @@ export class ImplementsPlugin extends ConverterComponent { ); } - /** - * Copy the comment of the source reflection to the target reflection. - * - * @param target - * @param source - */ - private copyComment(target: Reflection, source: Reflection) { - if ( - target.comment && - source.comment && - target.comment.hasTag("inheritdoc") - ) { - target.comment.copyFrom(source.comment); - - if ( - target instanceof SignatureReflection && - target.parameters && - source instanceof SignatureReflection && - source.parameters - ) { - for ( - let index = 0, count = target.parameters.length; - index < count; - index++ - ) { - const sourceParameter = source.parameters[index]; - if (sourceParameter && sourceParameter.comment) { - const targetParameter = target.parameters[index]; - if (!targetParameter.comment) { - targetParameter.comment = new Comment(); - targetParameter.comment.copyFrom( - sourceParameter.comment - ); - } - } - } - } - } - } - private analyzeInheritance( context: Context, reflection: DeclarationReflection @@ -201,7 +160,7 @@ export class ImplementsPlugin extends ConverterComponent { parentMember, context.project ); - this.copyComment(child, parentMember); + copyComment(child, parentMember); } } } diff --git a/src/lib/converter/plugins/InheritDocPlugin.ts b/src/lib/converter/plugins/InheritDocPlugin.ts new file mode 100644 index 000000000..a6c718865 --- /dev/null +++ b/src/lib/converter/plugins/InheritDocPlugin.ts @@ -0,0 +1,88 @@ +import { + ContainerReflection, + DeclarationReflection, + ReflectionKind, + SignatureReflection, + Type, +} from "../../models"; +import { Component, ConverterComponent } from "../components"; +import { Converter } from "../converter"; +import { Context } from "../context"; +import { copyComment } from "../utils/reflections"; +import { + Reflection, + TraverseCallback, +} from "../../models/reflections/abstract"; + +/** + * A plugin that handles `inheritDoc` by copying documentation from another API item. + * + * What gets copied: + * - short text + * - text + * - `@remarks` block + * - `@params` block + * - `@typeParam` block + * - `@return` block + */ +@Component({ name: "inheritDoc" }) +export class InheritDocPlugin extends ConverterComponent { + /** + * Create a new InheritDocPlugin instance. + */ + initialize() { + this.listenTo( + this.owner, + { + [Converter.EVENT_RESOLVE]: this.onResolve, + }, + undefined, + -200 + ); + } + + /** + * Triggered when the converter resolves a reflection. + * + * Traverse through reflection descendant to check for `inheritDoc` tag. + * If encountered, the parameter of the tag iss used to determine a source reflection + * that will provide actual comment. + * + * @param context The context object describing the current state the converter is in. + * @param reflection The reflection that is currently resolved. + */ + private onResolve(_context: Context, reflection: DeclarationReflection) { + if (reflection instanceof ContainerReflection) { + const descendantsCallback: TraverseCallback = (item) => { + item.traverse(descendantsCallback); + const inheritDoc = item.comment?.getTag("inheritdoc") + ?.paramName; + const source = + inheritDoc && reflection.findReflectionByName(inheritDoc); + let referencedReflection = source; + if ( + source instanceof DeclarationReflection && + item instanceof SignatureReflection + ) { + const isFunction = source?.kindOf( + ReflectionKind.FunctionOrMethod + ); + if (isFunction) { + referencedReflection = + source.signatures?.find((signature) => { + return Type.isTypeListEqual( + signature.getParameterTypes(), + item.getParameterTypes() + ); + }) ?? source.signatures?.[0]; + } + } + + if (referencedReflection instanceof Reflection) { + copyComment(item, referencedReflection); + } + }; + reflection.traverse(descendantsCallback); + } + } +} diff --git a/src/lib/converter/plugins/index.ts b/src/lib/converter/plugins/index.ts index 8c88a6702..213d6ec9f 100644 --- a/src/lib/converter/plugins/index.ts +++ b/src/lib/converter/plugins/index.ts @@ -8,3 +8,4 @@ export { ImplementsPlugin } from "./ImplementsPlugin"; export { PackagePlugin } from "./PackagePlugin"; export { SourcePlugin } from "./SourcePlugin"; export { TypePlugin } from "./TypePlugin"; +export { InheritDocPlugin } from "./InheritDocPlugin"; diff --git a/src/lib/converter/utils/reflections.ts b/src/lib/converter/utils/reflections.ts index bf2fb24b6..a127ccf0a 100644 --- a/src/lib/converter/utils/reflections.ts +++ b/src/lib/converter/utils/reflections.ts @@ -1,4 +1,12 @@ -import { IntrinsicType, Type, UnionType } from "../../models"; +import { + Comment, + DeclarationReflection, + IntrinsicType, + Reflection, + SignatureReflection, + Type, + UnionType, +} from "../../models"; export function removeUndefined(type: Type) { if (type instanceof UnionType) { @@ -13,3 +21,73 @@ export function removeUndefined(type: Type) { } return type; } + +/** + * Copy the comment of the source reflection to the target reflection. + * + * @param target - Reflection with comment containing `inheritdoc` tag + * @param source - Referenced reflection + */ +export function copyComment(target: Reflection, source: Reflection) { + if ( + target.comment && + source.comment && + target.comment.hasTag("inheritdoc") + ) { + if ( + target instanceof DeclarationReflection && + source instanceof DeclarationReflection + ) { + target.typeParameters = source.typeParameters; + } + if ( + target instanceof SignatureReflection && + source instanceof SignatureReflection + ) { + target.typeParameters = source.typeParameters; + /** + * TSDoc overrides existing parameters entirely with inherited ones, while + * existing implementation merges them. + * To avoid breaking things, `inheritDoc` tag is additionally checked for the parameter, + * so the previous behaviour will continue to work. + * + * TODO: When breaking change becomes acceptable remove legacy implementation + */ + if (target.comment.getTag("inheritdoc")?.paramName) { + target.parameters = source.parameters; + } else { + legacyCopyImplementation(target, source); + } + } + target.comment.removeTags("inheritdoc"); + target.comment.copyFrom(source.comment); + } +} + +/** + * Copy comments from source reflection to target reflection, parameters are merged. + * + * @param target - Reflection with comment containing `inheritdoc` tag + * @param source - Parent reflection + */ +function legacyCopyImplementation( + target: SignatureReflection, + source: SignatureReflection +) { + if (target.parameters && source.parameters) { + for ( + let index = 0, count = target.parameters.length; + index < count; + index++ + ) { + const sourceParameter = source.parameters[index]; + if (sourceParameter && sourceParameter.comment) { + const targetParameter = target.parameters[index]; + if (!targetParameter.comment) { + targetParameter.comment = new Comment(); + targetParameter.comment.copyFrom(sourceParameter.comment); + } + } + } + } +} diff --git a/src/lib/models/comments/comment.ts b/src/lib/models/comments/comment.ts index bddd3ca34..aa921a2c2 100644 --- a/src/lib/models/comments/comment.ts +++ b/src/lib/models/comments/comment.ts @@ -1,6 +1,8 @@ import { removeIf } from "../../utils"; import { CommentTag } from "./tag"; +const COPIED_TAGS = ["remarks"]; + /** * A model that represents a comment. * @@ -85,14 +87,27 @@ export class Comment { /** * Copy the data of the given comment into this comment. * - * @param comment + * `shortText`, `text`, `returns` and tags from `COPIED_TAGS` are copied; + * other instance tags left unchanged. + * + * @param comment - Source comment to copy from */ copyFrom(comment: Comment) { this.shortText = comment.shortText; this.text = comment.text; this.returns = comment.returns; - this.tags = comment.tags.map( - (tag) => new CommentTag(tag.tagName, tag.paramName, tag.text) - ); + const overrideTags: CommentTag[] = comment.tags + .filter((tag) => COPIED_TAGS.includes(tag.tagName)) + .map((tag) => new CommentTag(tag.tagName, tag.paramName, tag.text)); + this.tags.forEach((tag, index) => { + const matchingTag = overrideTags.find( + (matchingOverride) => matchingOverride?.tagName === tag.tagName + ); + if (matchingTag) { + this.tags[index] = matchingTag; + overrideTags.splice(overrideTags.indexOf(matchingTag), 1); + } + }); + this.tags = [...this.tags, ...overrideTags]; } } diff --git a/src/test/converter/inheritance/inherit-doc.ts b/src/test/converter/inheritance/inherit-doc.ts new file mode 100644 index 000000000..9b6a34e90 --- /dev/null +++ b/src/test/converter/inheritance/inherit-doc.ts @@ -0,0 +1,87 @@ +/** + * Source interface summary + * + * @typeParam T - Source interface type parameter + */ +export interface InterfaceSource { + /** + * Source interface property description + * + * @typeParam T - Source interface type parameter + */ + property: T; + + /** + * Source interface method description + * + * @param arg + */ + someMethod(arg: number): T; +} + +/** + * @inheritDoc InterfaceSource + * + */ +export interface InterfaceTarget { + /** + * @inheritDoc InterfaceSource.property + */ + property: T; + + /** + * @inheritDoc InterfaceSource.someMethod + * + * @param arg + */ + someMethod(arg: number): T; +} + +/** + * Function summary + * + * This part of the commentary will be inherited by other entities + * + * @remarks + * + * Remarks will be inherited + * + * @example + * + * This part of the commentary will not be inherited + * + * @typeParam T - Type of arguments + * @param arg1 - First argument + * @param arg2 - Second argument + * @returns Stringified sum or concatenation of numeric arguments + */ +export function functionSource(arg1: T, arg2: T): string { + if (typeof arg1 === "number" && typeof arg2 === "number") { + return `${arg1 + arg2}`; + } + return `${arg1}${arg2}`; +} + +/** + * @inheritDoc SubClassA.printName + */ +export function functionTargetGlobal() { + return ""; +} + +/** + * @inheritDoc functionSource + * + * @example + * + * This function inherited commentary from the `functionSource` function + * + * @typeParam T - This will be inherited + * @param arg1 - This will be inherited + * @param arg2 - This will be inherited + * @returns This will be inherited + * + */ +export function functionTargetLocal(arg1: T, arg2: T) { + return ""; +} diff --git a/src/test/converter/inheritance/specs.json b/src/test/converter/inheritance/specs.json new file mode 100644 index 000000000..f2755ef85 --- /dev/null +++ b/src/test/converter/inheritance/specs.json @@ -0,0 +1,404 @@ +{ + "id": 0, + "name": "typedoc", + "kind": 0, + "kindString": "Project", + "flags": {}, + "children": [ + { + "id": 13, + "name": "InterfaceSource", + "kind": 256, + "kindString": "Interface", + "flags": {}, + "comment": { + "shortText": "Source interface summary" + }, + "children": [ + { + "id": 14, + "name": "property", + "kind": 1024, + "kindString": "Property", + "flags": {}, + "comment": { + "shortText": "Source interface property description", + "tags": [ + { + "tag": "typeparam", + "text": "Source interface type parameter\n", + "param": "T" + } + ] + }, + "type": { + "type": "reference", + "name": "T" + } + }, + { + "id": 15, + "name": "someMethod", + "kind": 2048, + "kindString": "Method", + "flags": {}, + "signatures": [ + { + "id": 16, + "name": "someMethod", + "kind": 4096, + "kindString": "Call signature", + "flags": {}, + "comment": { + "shortText": "Source interface method description" + }, + "parameters": [ + { + "id": 17, + "name": "arg", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "\n" + }, + "type": { + "type": "intrinsic", + "name": "number" + } + } + ], + "type": { + "type": "reference", + "name": "T" + } + } + ] + } + ], + "groups": [ + { + "title": "Properties", + "kind": 1024, + "children": [ + 14 + ] + }, + { + "title": "Methods", + "kind": 2048, + "children": [ + 15 + ] + } + ], + "typeParameter": [ + { + "id": 18, + "name": "T", + "kind": 131072, + "kindString": "Type parameter", + "flags": {}, + "comment": { + "shortText": "Source interface type parameter\n" + } + } + ] + }, + { + "id": 19, + "name": "InterfaceTarget", + "kind": 256, + "kindString": "Interface", + "flags": {}, + "comment": { + "shortText": "Source interface summary" + }, + "children": [ + { + "id": 20, + "name": "property", + "kind": 1024, + "kindString": "Property", + "flags": {}, + "comment": { + "shortText": "Source interface property description" + }, + "type": { + "type": "reference", + "name": "T" + } + }, + { + "id": 21, + "name": "someMethod", + "kind": 2048, + "kindString": "Method", + "flags": {}, + "signatures": [ + { + "id": 22, + "name": "someMethod", + "kind": 4096, + "kindString": "Call signature", + "flags": {}, + "comment": { + "shortText": "Source interface method description" + }, + "parameters": [ + { + "id": 17, + "name": "arg", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "\n" + }, + "type": { + "type": "intrinsic", + "name": "number" + } + } + ], + "type": { + "type": "reference", + "name": "T" + } + } + ] + } + ], + "groups": [ + { + "title": "Properties", + "kind": 1024, + "children": [ + 20 + ] + }, + { + "title": "Methods", + "kind": 2048, + "children": [ + 21 + ] + } + ], + "typeParameter": [ + { + "id": 18, + "name": "T", + "kind": 131072, + "kindString": "Type parameter", + "flags": {}, + "comment": { + "shortText": "Source interface type parameter\n" + } + } + ] + }, + { + "id": 1, + "name": "functionSource", + "kind": 64, + "kindString": "Function", + "flags": {}, + "signatures": [ + { + "id": 2, + "name": "functionSource", + "kind": 4096, + "kindString": "Call signature", + "flags": {}, + "comment": { + "shortText": "Function summary", + "text": "This part of the commentary will be inherited by other entities\n", + "returns": "Stringified sum or concatenation of numeric arguments\n", + "tags": [ + { + "tag": "remarks", + "text": "\n\nRemarks will be inherited\n" + }, + { + "tag": "example", + "text": "\n\nThis part of the commentary will not be inherited\n" + } + ] + }, + "typeParameter": [ + { + "id": 3, + "name": "T", + "kind": 131072, + "kindString": "Type parameter", + "flags": {}, + "comment": { + "text": "Type of arguments" + } + } + ], + "parameters": [ + { + "id": 4, + "name": "arg1", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "First argument" + }, + "type": { + "type": "reference", + "name": "T" + } + }, + { + "id": 5, + "name": "arg2", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "Second argument" + }, + "type": { + "type": "reference", + "name": "T" + } + } + ], + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + }, + { + "id": 6, + "name": "functionTargetGlobal", + "kind": 64, + "kindString": "Function", + "flags": {}, + "signatures": [ + { + "id": 7, + "name": "functionTargetGlobal", + "kind": 4096, + "kindString": "Call signature", + "flags": {}, + "comment": { + "tags": [ + { + "tag": "inheritdoc", + "text": "\n", + "param": "SubClassA.printName" + } + ] + }, + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + }, + { + "id": 8, + "name": "functionTargetLocal", + "kind": 64, + "kindString": "Function", + "flags": {}, + "signatures": [ + { + "id": 9, + "name": "functionTargetLocal", + "kind": 4096, + "kindString": "Call signature", + "flags": {}, + "comment": { + "shortText": "Function summary", + "text": "This part of the commentary will be inherited by other entities\n", + "returns": "This will be inherited\n\n", + "tags": [ + { + "tag": "example", + "text": "\n\nThis function inherited commentary from the `functionSource` function\n" + }, + { + "tag": "remarks", + "text": "\n\nRemarks will be inherited\n" + } + ] + }, + "typeParameter": [ + { + "id": 3, + "name": "T", + "kind": 131072, + "kindString": "Type parameter", + "flags": {}, + "comment": { + "text": "Type of arguments" + } + } + ], + "parameters": [ + { + "id": 4, + "name": "arg1", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "First argument" + }, + "type": { + "type": "reference", + "name": "T" + } + }, + { + "id": 5, + "name": "arg2", + "kind": 32768, + "kindString": "Parameter", + "flags": {}, + "comment": { + "text": "Second argument" + }, + "type": { + "type": "reference", + "name": "T" + } + } + ], + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + } + ], + "groups": [ + { + "title": "Interfaces", + "kind": 256, + "children": [ + 13, + 19 + ] + }, + { + "title": "Functions", + "kind": 64, + "children": [ + 1, + 6, + 8 + ] + } + ] +} \ No newline at end of file