diff --git a/tools/api-markdown-documenter/src/LintApiModel.ts b/tools/api-markdown-documenter/src/LintApiModel.ts index 45f01cd6d571..fe2ae0e6acfa 100644 --- a/tools/api-markdown-documenter/src/LintApiModel.ts +++ b/tools/api-markdown-documenter/src/LintApiModel.ts @@ -3,14 +3,23 @@ * Licensed under the MIT License. */ -import { fail } from "node:assert"; +import { fail, strict as assert } from "node:assert"; import { ApiDocumentedItem, type ApiItem, ApiItemContainerMixin, type ApiModel, } from "@microsoft/api-extractor-model"; -import type { DocInheritDocTag } from "@microsoft/tsdoc"; +import { + DocBlock, + type DocComment, + type DocInheritDocTag, + DocInlineTag, + type DocLinkTag, + type DocNode, + DocNodeContainer, + DocNodeKind, +} from "@microsoft/tsdoc"; import { defaultConsoleLogger } from "./Logging.js"; import { resolveSymbolicReference } from "./utilities/index.js"; import type { ConfigurationBase } from "./ConfigurationBase.js"; @@ -76,6 +85,8 @@ interface MutableLinterErrors { * Errors found during linting. */ export interface LinterErrors { + // TODO: malformed tag errors + /** * Errors related to reference tags (e.g., `link` or `inheritDoc` tags) with invalid targets. */ @@ -119,6 +130,8 @@ export async function lintApiModel( /** * Recursively validates the given API item and all its descendants within the API model. + * + * @remarks Populates `errors` with any errors encountered during validation. */ function lintApiItem( apiItem: ApiItem, @@ -145,7 +158,8 @@ function lintApiItem( } } - // TODO: Check other TSDoc contents + // Check TSDoc contents + lintComment(apiItem.tsdocComment, apiItem, apiModel, errors); } // If the item has children, recursively validate them. @@ -156,6 +170,158 @@ function lintApiItem( } } +/** + * Validates a TSDoc comment associated with an API item. + * + * @remarks Populates `errors` with any errors encountered during validation. + */ +function lintComment( + comment: DocComment, + associatedItem: ApiDocumentedItem, + apiModel: ApiModel, + errors: MutableLinterErrors, +): void { + checkTagsUnderTsdocNode(comment.summarySection, associatedItem, apiModel, errors); + + if (comment.deprecatedBlock !== undefined) { + checkTagsUnderTsdocNode(comment.deprecatedBlock, associatedItem, apiModel, errors); + } + + if (comment.remarksBlock !== undefined) { + checkTagsUnderTsdocNode(comment.remarksBlock, associatedItem, apiModel, errors); + } + + if (comment.privateRemarks !== undefined) { + checkTagsUnderTsdocNode(comment.privateRemarks, associatedItem, apiModel, errors); + } + + checkTagsUnderTsdocNodes(comment.params.blocks, associatedItem, apiModel, errors); + + checkTagsUnderTsdocNodes(comment.typeParams.blocks, associatedItem, apiModel, errors); + + checkTagsUnderTsdocNodes(comment.customBlocks, associatedItem, apiModel, errors); +} + +/** + * Validates the provided TSDoc node and its children. + * + * @remarks Populates `errors` with any errors encountered during validation. + * Co-recursive with {@link checkTagsUnderTsdocNodes}. + */ +function checkTagsUnderTsdocNode( + node: DocNode, + associatedItem: ApiDocumentedItem, + apiModel: ApiModel, + errors: MutableLinterErrors, +): void { + switch (node.kind) { + // Nodes under which links cannot occur + case DocNodeKind.CodeSpan: + case DocNodeKind.BlockTag: + case DocNodeKind.EscapedText: + case DocNodeKind.FencedCode: + case DocNodeKind.HtmlStartTag: + case DocNodeKind.HtmlEndTag: + case DocNodeKind.PlainText: + case DocNodeKind.SoftBreak: { + break; + } + // Nodes with children ("content") + case DocNodeKind.Block: + case DocNodeKind.ParamBlock: { + assert(node instanceof DocBlock, 'Expected a "DocBlock" node.'); + checkTagsUnderTsdocNode(node.content, associatedItem, apiModel, errors); + break; + } + // Nodes with children ("nodes") + case DocNodeKind.Paragraph: + case DocNodeKind.Section: { + assert(node instanceof DocNodeContainer, 'Expected a "DocNodeContainer" node.'); + checkTagsUnderTsdocNodes(node.nodes, associatedItem, apiModel, errors); + break; + } + case DocNodeKind.InlineTag: { + assert(node instanceof DocInlineTag, 'Expected a "DocInlineTag" node.'); + // TODO: malformed tag errors + break; + } + case DocNodeKind.LinkTag: { + const result = checkLinkTag(node as DocLinkTag, associatedItem, apiModel); + if (result !== undefined) { + errors.referenceErrors.add(result); + } + break; + } + case DocNodeKind.InheritDocTag: { + // See notes in `lintApiItem` for why we handle `@inheritDoc` tags are not expected or handled here. + fail( + "Encountered an @inheritDoc tag while walking a TSDoc tree. API-Extractor resolves such tags at a higher level, so this is unexpected.", + ); + } + default: { + throw new Error(`Unsupported DocNode kind: "${node.kind}".`); + } + } +} + +/** + * Validates the provided TSDoc nodes and their children. + * + * @remarks Populates `errors` with any errors encountered during validation. + * Co-recursive with {@link checkTagsUnderTsdocNode}. + */ +function checkTagsUnderTsdocNodes( + nodes: readonly DocNode[], + associatedItem: ApiDocumentedItem, + apiModel: ApiModel, + errors: MutableLinterErrors, +): void { + for (const node of nodes) { + checkTagsUnderTsdocNode(node, associatedItem, apiModel, errors); + } +} + +/** + * Validates the provided link tag, ensuring that the target reference is valid within the API model. + * + * @returns An error, if the link tag's target reference is invalid. + * Otherwise, `undefined`. + */ +function checkLinkTag( + linkTag: DocLinkTag, + apiItem: ApiItem, + apiModel: ApiModel, +): ReferenceError | undefined { + // If the link tag was parsed correctly (which we know it was in this case, because we have a `DocLinkTag`), then we don't have to worry about syntax validation. + + // If the link points to some external URL, no-op. + // In the future, we could potentially leverage some sort of URL validator here, + // but for now our primary concern is validating symbolic links. + if (linkTag.urlDestination !== undefined) { + return undefined; + } + + assert( + linkTag.codeDestination !== undefined, + "Expected a `codeDestination` or `urlDestination` to be defined, but neither was.", + ); + + // If the link is a symbolic reference, validate it. + try { + resolveSymbolicReference(apiItem, linkTag.codeDestination, apiModel); + } catch { + return { + tagName: "@link", + sourceItem: apiItem.getScopedNameWithinPackage(), + packageName: apiItem.getAssociatedPackage()?.name ?? fail("Package name not found"), + referenceTarget: linkTag.codeDestination.emitAsTsdoc(), + linkText: linkTag.linkText, + }; + } + + return undefined; +} + /** * Checks the provided API item's `{@inheritDoc}` tag, ensuring that the target reference is valid within the API model. */ diff --git a/tools/api-markdown-documenter/src/test/LintApiModel.test.ts b/tools/api-markdown-documenter/src/test/LintApiModel.test.ts index 340c4257d420..6a4706a96f64 100644 --- a/tools/api-markdown-documenter/src/test/LintApiModel.test.ts +++ b/tools/api-markdown-documenter/src/test/LintApiModel.test.ts @@ -6,24 +6,45 @@ import * as Path from "node:path"; import { fileURLToPath } from "node:url"; +import { ApiModel } from "@microsoft/api-extractor-model"; import { expect } from "chai"; -import { lintApiModel, type ReferenceError, type LinterErrors } from "../LintApiModel.js"; +import { lintApiModel, type LinterErrors } from "../LintApiModel.js"; import { loadModel } from "../LoadModel.js"; const dirname = Path.dirname(fileURLToPath(import.meta.url)); const testModelsDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "test-data"); describe("lintApiModel", () => { - // TODO: add case with no errors + it("Empty API Model yields no errors", async () => { + const apiModel = new ApiModel(); + const result = await lintApiModel({ apiModel }); + + expect(result).to.be.undefined; + }); it("API Model with invalid links yields the expected errors", async () => { const modelDirectoryPath = Path.resolve(testModelsDirectoryPath, "simple-suite-test"); + const apiModel = await loadModel({ modelDirectoryPath }); const expected: LinterErrors = { - referenceErrors: new Set([ - // TODO: add other expected errors once they are validated + referenceErrors: new Set([ + { + tagName: "@link", + sourceItem: "", // link appears in package documentation + packageName: "simple-suite-test", + referenceTarget: "InvalidItem", + linkText: undefined, + }, + { + tagName: "@link", + sourceItem: "", // link appears in package documentation + packageName: "simple-suite-test", + referenceTarget: "InvalidItem", + linkText: + "even though I link to an invalid item, I would still like this text to be rendered", + }, { tagName: "@inheritDoc", sourceItem: "TestInterface.propertyWithBadInheritDocTarget",