From ce0b34efe788d9c31bc2d914f958842e7ba1464b Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Sun, 8 Nov 2026 22:56:21 -0500
Subject: [PATCH 1/5] test: add a TSDoc oracle and its corpus
---
tests/fixtures/tsdoc/corpus.json | 2952 ++++++++++++++++++++++++++++++
tools/tsdoc-oracle.sh | 42 +
2 files changed, 2994 insertions(+)
create mode 100644 tests/fixtures/tsdoc/corpus.json
create mode 100755 tools/tsdoc-oracle.sh
diff --git a/tests/fixtures/tsdoc/corpus.json b/tests/fixtures/tsdoc/corpus.json
new file mode 100644
index 0000000..2925017
--- /dev/null
+++ b/tests/fixtures/tsdoc/corpus.json
@@ -0,0 +1,2952 @@
+[
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/**\n * line 1 \n * line 2\n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/***/",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/**\n *\n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/**\n \n \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/**\n * \\$\\@param\n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserBasics",
+ "comment": "/**\n * letter: \\A space: \\ end of line: \\\n */",
+ "messages": [
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 15
+ },
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 25
+ },
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 41
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * line `1`\n * line ` 2` sdf\n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * M`&`M\n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * `multi\n * line`\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 21
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * ``\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-span-empty",
+ "text": "A code span must contain at least one character between the backticks",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * This is a code fence with all parts:\n * ```a language! \n * some `code` here\n * ``` \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * This is a code fence with no language or trailing whitespace:\n * ```\n * some `code` here\n * ```*/",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Code fence incorrectly indented:\n * ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-opening-indent",
+ "text": "The opening backtick for a code fence must appear at the start of the line",
+ "at": 46
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Code fence not starting the line:\n *a```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-opening-indent",
+ "text": "The opening backtick for a code fence must appear at the start of the line",
+ "at": 44
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Code fence not being terminated 1:\n * ```*/",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-missing-delimiter",
+ "text": "Error parsing code fence: Missing closing delimiter",
+ "at": 45
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Code fence not being terminated 2:\n * ``` some stuff\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-missing-delimiter",
+ "text": "Error parsing code fence: Missing closing delimiter",
+ "at": 45
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Language having backticks:\n * ``` some stuff ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-specifier-syntax",
+ "text": "Error parsing code fence: The language specifier cannot contain backtick characters",
+ "at": 37
+ },
+ {
+ "id": "tsdoc-code-fence-opening-indent",
+ "text": "The opening backtick for a code fence must appear at the start of the line",
+ "at": 52
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Closing delimiter being indented:\n * ```\n * code\n * ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-closing-indent",
+ "text": "The closing delimiter for a code fence must not be indented",
+ "at": 59
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserCode",
+ "comment": "/**\n * Closing delimiter not being on a line by itself:\n * ```\n * code\n * ``` a\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-closing-syntax",
+ "text": "Unexpected characters after closing delimiter for code fence",
+ "at": 79
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * \n * \n * \n * \n * \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * < tag/>\n * \n * \n * \" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 13
+ },
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "The HTML tag has invalid syntax: Expecting an attribute or \">\" or \"/>\"",
+ "at": 18
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 27
+ },
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "The HTML tag has invalid syntax: Expecting an attribute or \">\" or \"/>\"",
+ "at": 32
+ },
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "The HTML tag has invalid syntax: Expecting an attribute or \">\" or \"/>\"",
+ "at": 40
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 49
+ },
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "The HTML tag has invalid syntax: Expecting an attribute or \">\" or \"/>\"",
+ "at": 54
+ }
+ ]
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * \n * \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * \n * \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * \n * \n */",
+ "messages": []
+ },
+ {
+ "source": "microsoft/tsdoc NodeParserHtml",
+ "comment": "/**\n * \n * \n * b\n */",
+ "messages": [
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \\a and \\{ and \\\\\n */",
+ "messages": [
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ends with \\\n */",
+ "messages": [
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * a@b\n */",
+ "messages": [
+ {
+ "id": "tsdoc-at-sign-in-word",
+ "text": "The \"@\" character looks like part of a TSDoc tag; use a backslash to escape it",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * @ x\n */",
+ "messages": [
+ {
+ "id": "tsdoc-at-sign-without-tag-name",
+ "text": "Expecting a TSDoc tag name after \"@\"; if it is not a tag, use a backslash to escape this character",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * x @\n */",
+ "messages": [
+ {
+ "id": "tsdoc-at-sign-without-tag-name",
+ "text": "Expecting a TSDoc tag name after \"@\"; if it is not a tag, use a backslash to escape this character",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc tag starting with \"{@\"",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc inline tag name after the \"{@\" characters",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@ x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc inline tag name after the \"{@\" characters",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 11
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * a {\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc tag starting with \"{@\"",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * @foo-bar\n */",
+ "messages": [
+ {
+ "id": "tsdoc-characters-after-block-tag",
+ "text": "The token \"@foo\" looks like a TSDoc tag but contains an invalid character \"-\"; if it is not a tag, use a backslash to escape the \"@\"",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * @foo.\n */",
+ "messages": [
+ {
+ "id": "tsdoc-characters-after-block-tag",
+ "text": "The token \"@foo\" looks like a TSDoc tag but contains an invalid character \".\"; if it is not a tag, use a backslash to escape the \"@\"",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * @1abc\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-tag-name",
+ "text": "A TSDoc tag name must start with a letter and contain only letters and numbers",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * @_x\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-tag-name",
+ "text": "A TSDoc tag name must start with a letter and contain only letters and numbers",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@1x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-tag-name",
+ "text": "A TSDoc tag name must start with a letter and contain only letters and numbers",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 11
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link:x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-characters-after-inline-tag",
+ "text": "The character \":\" cannot appear after the TSDoc tag name; expecting a space",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 15
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@label}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link x\n */",
+ "messages": [
+ {
+ "id": "tsdoc-inline-tag-missing-right-brace",
+ "text": "The TSDoc inline tag name is missing its closing \"}\"",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link x\n * y}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-dot",
+ "text": "Expecting a period before the next component of a declaration reference",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link {x}}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-inline-tag-unescaped-brace",
+ "text": "The \"{\" character must be escaped with a backslash when used inside a TSDoc inline tag",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc tag starting with \"{@\"",
+ "at": 14
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 16
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link x\\a}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "Error reading inline TSDoc tag: A backslash can only be used to escape a punctuation character",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-unnecessary-backslash",
+ "text": "A backslash can only be used to escape a punctuation character",
+ "at": 15
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link x\\}}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-destination-syntax",
+ "text": "Unexpected character after link destination",
+ "at": 15
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@label A} {@label B}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@foo x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-undefined-tag",
+ "text": "The TSDoc tag \"@foo\" is not defined in this configuration",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * Text.\n * @see Foo\n * @see Bar\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * Text.\n * @returns A.\n * @returns B.\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * Text.\n * @example A.\n * @example B.\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * `code`\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ``\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-span-empty",
+ "text": "A code span must contain at least one character between the backticks",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * `x\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ` `\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n * ```\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * text ```ts\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-opening-indent",
+ "text": "The opening backtick for a code fence must appear at the start of the line",
+ "at": 12
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts`x\n * code\n * ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-specifier-syntax",
+ "text": "Error parsing code fence: The language specifier cannot contain backtick characters",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 12
+ },
+ {
+ "id": "tsdoc-code-fence-missing-delimiter",
+ "text": "Error parsing code fence: Missing closing delimiter",
+ "at": 26
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-missing-delimiter",
+ "text": "Error parsing code fence: Missing closing delimiter",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-missing-delimiter",
+ "text": "Error parsing code fence: Missing closing delimiter",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n * ```\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-closing-indent",
+ "text": "The closing delimiter for a code fence must not be indented",
+ "at": 24
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n * ``` trailing\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-fence-closing-syntax",
+ "text": "Unexpected characters after closing delimiter for code fence",
+ "at": 28
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n * ``` \n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n * code\n * `` not yet\n * ```\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * ```ts\n *\n * ```\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * bold\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * bold no\n */",
+ "config": {
+ "supportedHtmlElements": [
+ "b"
+ ]
+ },
+ "messages": [
+ {
+ "id": "tsdoc-unsupported-html-name",
+ "text": "Invalid HTML element: The HTML element name \"i\" is not defined by your TSDoc configuration",
+ "at": 19
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 21
+ },
+ {
+ "id": "tsdoc-unsupported-html-name",
+ "text": "Expecting an HTML element name: The HTML element name \"i\" is not defined by your TSDoc configuration",
+ "at": 24
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 27
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * bold no\n */",
+ "config": {
+ "supportedHtmlElements": [
+ "b"
+ ],
+ "reportUnsupportedHtmlElements": false
+ },
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * link\n */",
+ "config": {
+ "supportedHtmlElements": [
+ "a"
+ ]
+ },
+ "messages": [
+ {
+ "id": "tsdoc-unsupported-html-name",
+ "text": "The HTML element has an invalid attribute: The HTML element name \"href\" is not defined by your TSDoc configuration",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 18
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * link\n */",
+ "config": {
+ "reportUnsupportedHtmlElements": true
+ },
+ "messages": [
+ {
+ "id": "tsdoc-unsupported-html-name",
+ "text": "Invalid HTML element: The HTML element name \"a\" is not defined by your TSDoc configuration",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 18
+ },
+ {
+ "id": "tsdoc-unsupported-html-name",
+ "text": "Expecting an HTML element name: The HTML element name \"a\" is not defined by your TSDoc configuration",
+ "at": 23
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 26
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * < b>\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Invalid HTML element: A space is not allowed here",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 10
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * <>\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Invalid HTML element: Expecting an HTML name",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * <1b>\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Invalid HTML element: An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 10
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Invalid HTML element: An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 11
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-html-tag-missing-equals",
+ "text": "The HTML element has an invalid attribute: Expecting \"=\" after HTML attribute name",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-html-tag-missing-string",
+ "text": "The HTML element has an invalid attribute: Expecting an HTML string starting with a single-quote or double-quote character",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 16
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-html-string-missing-quote",
+ "text": "The HTML element has an invalid attribute: The HTML string is missing its closing quote",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-text-after-html-string",
+ "text": "The HTML element has an invalid attribute: The next character after a closing quote must be spacing or punctuation",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n *
\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n *
\n */",
+ "messages": [
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "The HTML tag has invalid syntax: Expecting an attribute or \">\" or \"/>\"",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 12
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \" or \"/>\"",
+ "at": 7
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * a>\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Expecting an HTML element name: A space is not allowed here",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 11
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * \n */",
+ "messages": [
+ {
+ "id": "tsdoc-html-tag-missing-greater-than",
+ "text": "Expecting a closing \">\" for the HTML tag",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 12
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * >\n */",
+ "messages": [
+ {
+ "id": "tsdoc-malformed-html-name",
+ "text": "Expecting an HTML element name: Expecting an HTML name",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-escape-greater-than",
+ "text": "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag",
+ "at": 9
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-empty",
+ "text": "The @link tag content is missing",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link }\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-empty",
+ "text": "The @link tag content is missing",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link http://}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-invalid-url",
+ "text": "An @link URL must have at least one character after \"://\"",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link http://x.dev}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link https://x.dev | text}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link https://x.dev y}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-destination-syntax",
+ "text": "Unexpected character after link destination",
+ "at": 28
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link //x.dev}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-invalid-url",
+ "text": "An @link URL must begin with a scheme comprised only of letters and numbers followed by \"://\". (For general URLs, use an HTML \"\" tag instead.)",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link 1http://x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-invalid-url",
+ "text": "An @link URL must begin with a scheme comprised only of letters and numbers followed by \"://\". (For general URLs, use an HTML \"\" tag instead.)",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link ftp:x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-missing-parens",
+ "text": "Syntax error in declaration reference: the member selector must be enclosed in parentheses",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a.b-c+d:x}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-destination-syntax",
+ "text": "Unexpected character after link destination",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a|b|c}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-unescaped-text",
+ "text": "The \"|\" character may not be used in the link text without escaping it",
+ "at": 16
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a | b {c}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-inline-tag-unescaped-brace",
+ "text": "The \"{\" character must be escaped with a backslash when used inside a TSDoc inline tag",
+ "at": 7
+ },
+ {
+ "id": "tsdoc-malformed-inline-tag",
+ "text": "Expecting a TSDoc tag starting with \"{@\"",
+ "at": 20
+ },
+ {
+ "id": "tsdoc-escape-right-brace",
+ "text": "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag",
+ "at": 22
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a |}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a | }\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link a |\n * b}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo bar}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-dot",
+ "text": "Expecting a period before the next component of a declaration reference",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.bar}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-identifier",
+ "text": "Syntax error in declaration reference: expecting a member identifier",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link .foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-identifier",
+ "text": "Syntax error in declaration reference: expecting a member identifier",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link :foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-identifier",
+ "text": "Syntax error in declaration reference: expecting a member identifier",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link @scope/pkg}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-hash",
+ "text": "The declaration reference appears to contain a package name or import path, but it is missing the \"#\" delimiter",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link ./path}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-hash",
+ "text": "The declaration reference appears to contain a package name or import path, but it is missing the \"#\" delimiter",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg#Foo}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link @scope/pkg#Foo}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link @scope/pkg/path/to#Foo}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg/path#Foo}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link #Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-hash-syntax",
+ "text": "The hash character must be preceded by a package name or import path",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link ./path#Foo}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link /path#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-import-path",
+ "text": "An import path must not start with \"/\" unless prefixed by a package name",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg//path#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-import-path",
+ "text": "An import path must not contain \"//\"",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg/path/#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-import-path",
+ "text": "An import path must not end with \"/\"",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link @scope#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-package-name",
+ "text": "The package name \"@scope\" is not a valid package name",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link @#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-package-name",
+ "text": "The package name \"@\" is not a valid package name",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pk!g#Foo}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-malformed-package-name",
+ "text": "The package name \"pk!g\" is not a valid package name",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg# }\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link pkg#}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:constructor)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:static)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.(bar:instance)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-colon",
+ "text": "Expecting a colon after the identifier because the expression is in parentheses",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-label",
+ "text": "Expecting a selector label after the colon",
+ "at": 18
+ },
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "Invalid syntax for selector",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:}\n */",
+ "throws": "Cannot read past end of stream"
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo:static}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-missing-parens",
+ "text": "Syntax error in declaration reference: the member selector must be enclosed in parentheses",
+ "at": 17
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:static}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-right-paren",
+ "text": "Expecting a matching right parenthesis",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:LABEL)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:L)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "A label selector must be comprised of upper case letters, numbers, and underscores and must not start with a number",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:Label)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "A label selector must be comprised of upper case letters, numbers, and underscores and must not start with a number",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:_A1)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:2)}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:02)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "If the selector begins with a number, it must be a positive integer value",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:2a)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "If the selector begins with a number, it must be a positive integer value",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:staticx)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "The selector \"staticx\" is not a recognized TSDoc system selector name",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link (Foo:x1)}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-selector-syntax",
+ "text": "Invalid syntax for selector",
+ "at": 19
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link [Symbol.iterator]}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.[Symbol.iterator]}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link [Symbol.iterator}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-right-bracket",
+ "text": "Missing closing square bracket for symbol reference",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link [ ]}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-missing-reference",
+ "text": "Expecting a declaration reference",
+ "at": 14
+ },
+ {
+ "id": "tsdoc-reference-symbol-syntax",
+ "text": "Missing declaration reference in symbol reference",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.\"bar baz\"}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link \"\"}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-empty-identifier",
+ "text": "The quoted identifier cannot be empty",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link \"bar}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-missing-quote",
+ "text": "Unexpected end of input inside quoted member identifier",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link static}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-unquoted-identifier",
+ "text": "The identifier \"static\" must be quoted because it is a TSDoc system selector name",
+ "at": 14
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.class}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-unquoted-identifier",
+ "text": "The identifier \"class\" must be quoted because it is a TSDoc system selector name",
+ "at": 18
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link $a.b$}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-missing-reference",
+ "text": "Expecting a declaration reference",
+ "at": 8
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.1a}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-reference-unquoted-identifier",
+ "text": "The identifier must not start with a number",
+ "at": 18
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo . bar}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link ( Foo : static )}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * {@link Foo.(bar:1)|text}\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/** One line. */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/** One line with a tag @beta */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n no star\n @beta\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\r\n * CRLF {@link Foo}.\r\n * @beta\r\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n *\n *\n * after blanks\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * a\n\n * b `x\n\n * y`\n */",
+ "messages": [
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 15
+ },
+ {
+ "id": "tsdoc-code-span-missing-delimiter",
+ "text": "The code span is missing its closing backtick",
+ "at": 23
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n *x\n * y\n */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/** first\n * second */",
+ "messages": []
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * café été {@link Café}\n */",
+ "messages": [
+ {
+ "id": "tsdoc-link-tag-destination-syntax",
+ "text": "Unexpected character after link destination",
+ "at": 29
+ }
+ ]
+ },
+ {
+ "source": "docov",
+ "comment": "/**\n * tab\tseparated\t@beta\n */",
+ "messages": []
+ }
+]
diff --git a/tools/tsdoc-oracle.sh b/tools/tsdoc-oracle.sh
new file mode 100755
index 0000000..0f1f464
--- /dev/null
+++ b/tools/tsdoc-oracle.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Records what @microsoft/tsdoc, the parser eslint-plugin-tsdoc runs, finds in each comment of
+# tests/fixtures/tsdoc/corpus.json, as a test oracle: each entry's `messages` becomes its message
+# ids, texts and byte offsets into the comment, with the entry's `config` (a tsdoc.json's keys) set
+# up the way @microsoft/tsdoc-config does, and `throws` says when the parser throws instead. The
+# docov tests check docov's TSDoc parser against the file. Not part of the checks or CI.
+# Usage: tools/tsdoc-oracle.sh [CORPUS]
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+corpus=${1:-tests/fixtures/tsdoc/corpus.json}
+deps=$(mktemp -d)
+trap 'rm -rf "$deps"' EXIT
+
+npm install --silent --no-save --prefix "$deps" @microsoft/tsdoc@0.17.1 @microsoft/tsdoc-config@0.18.3 >/dev/null
+
+NODE_PATH="$deps/node_modules" node - "$corpus" <<'JS'
+const fs = require("node:fs");
+const { TSDocConfiguration, TSDocParser } = require("@microsoft/tsdoc");
+const { TSDocConfigFile } = require("@microsoft/tsdoc-config");
+const [file] = process.argv.slice(2);
+
+const entries = JSON.parse(fs.readFileSync(file, "utf8"));
+for (const entry of entries) {
+ delete entry.messages;
+ delete entry.throws;
+ const configuration = new TSDocConfiguration();
+ if (entry.config) {
+ const config = TSDocConfigFile.loadFromObject({ $schema: TSDocConfigFile.CURRENT_SCHEMA_URL, ...entry.config });
+ config.configureParser(configuration);
+ }
+ try {
+ const { log } = new TSDocParser(configuration).parseString(entry.comment);
+ // Offsets in UTF-8 bytes, as Rust's strings index.
+ const at = (pos) => Buffer.byteLength(entry.comment.slice(0, pos));
+ entry.messages = log.messages.map((m) => ({ id: m.messageId, text: m.unformattedText, at: at(m.textRange.pos) }));
+ } catch (error) {
+ entry.throws = error.message;
+ }
+}
+fs.writeFileSync(file, JSON.stringify(entries, null, 2) + "\n");
+JS
From 345adbafce84af3a9a8c65d6c32af2e280faabf6 Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Sun, 8 Nov 2026 22:56:22 -0500
Subject: [PATCH 2/5] feat: read tsdoc.json and check TSDoc's grammar
---
docov.schema.json | 155 ++++
src/cache.rs | 2 +
src/check.rs | 34 +-
src/check/tsdoc_code.rs | 26 +
src/check/tsdoc_escapes.rs | 26 +
src/check/tsdoc_html.rs | 26 +
src/check/tsdoc_inline_tags.rs | 27 +
src/check/tsdoc_links.rs | 27 +
src/check/tsdoc_params.rs | 26 +
src/check/tsdoc_tags.rs | 31 +
src/collect.rs | 4 +-
src/collect/diagnose.rs | 8 +-
src/collect/rules.rs | 13 +-
src/collect/tests.rs | 4 +-
src/feature/tsdoc_config.rs | 60 ++
src/lib.rs | 1 +
src/lsp/project.rs | 2 +
src/main.rs | 1 +
src/nested.rs | 44 +-
src/tsdoc.rs | 180 +++++
src/tsdoc/config.rs | 240 +++++++
src/tsdoc/parse.rs | 671 ++++++++++++++++++
src/tsdoc/parse/code.rs | 116 +++
src/tsdoc/parse/html.rs | 119 ++++
src/tsdoc/parse/link.rs | 413 +++++++++++
src/tsdoc/tests.rs | 57 ++
src/tsdoc/tokens.rs | 177 +++++
tests/fixtures/checks/tsdoc-code/fail.ts | 9 +
tests/fixtures/checks/tsdoc-code/pass.ts | 9 +
tests/fixtures/checks/tsdoc-escapes/fail.ts | 6 +
tests/fixtures/checks/tsdoc-escapes/pass.ts | 6 +
tests/fixtures/checks/tsdoc-html/fail.ts | 6 +
tests/fixtures/checks/tsdoc-html/pass.ts | 6 +
.../fixtures/checks/tsdoc-inline-tags/fail.ts | 8 +
.../fixtures/checks/tsdoc-inline-tags/pass.ts | 8 +
tests/fixtures/checks/tsdoc-links/fail.ts | 6 +
tests/fixtures/checks/tsdoc-links/pass.ts | 6 +
tests/fixtures/checks/tsdoc-params/fail.ts | 9 +
tests/fixtures/checks/tsdoc-params/pass.ts | 9 +
tests/fixtures/checks/tsdoc-tags/fail.ts | 11 +
tests/fixtures/checks/tsdoc-tags/pass.ts | 9 +
tests/fixtures/tsdoc/parser.ts | 2 +-
tests/props.rs | 2 +
tests/props/lsp.rs | 4 +-
tests/props/tsdoc.rs | 479 +++++++++++++
45 files changed, 3074 insertions(+), 11 deletions(-)
create mode 100644 src/check/tsdoc_code.rs
create mode 100644 src/check/tsdoc_escapes.rs
create mode 100644 src/check/tsdoc_html.rs
create mode 100644 src/check/tsdoc_inline_tags.rs
create mode 100644 src/check/tsdoc_links.rs
create mode 100644 src/check/tsdoc_params.rs
create mode 100644 src/check/tsdoc_tags.rs
create mode 100644 src/feature/tsdoc_config.rs
create mode 100644 src/tsdoc.rs
create mode 100644 src/tsdoc/config.rs
create mode 100644 src/tsdoc/parse.rs
create mode 100644 src/tsdoc/parse/code.rs
create mode 100644 src/tsdoc/parse/html.rs
create mode 100644 src/tsdoc/parse/link.rs
create mode 100644 src/tsdoc/tests.rs
create mode 100644 src/tsdoc/tokens.rs
create mode 100644 tests/fixtures/checks/tsdoc-code/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-code/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-escapes/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-escapes/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-html/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-html/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-inline-tags/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-inline-tags/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-links/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-links/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-params/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-params/pass.ts
create mode 100644 tests/fixtures/checks/tsdoc-tags/fail.ts
create mode 100644 tests/fixtures/checks/tsdoc-tags/pass.ts
create mode 100644 tests/props/tsdoc.rs
diff --git a/docov.schema.json b/docov.schema.json
index 5ad35d2..7880e77 100644
--- a/docov.schema.json
+++ b/docov.schema.json
@@ -551,6 +551,153 @@
],
"description": "A description's first sentence is short and ends with a period."
},
+ "tsdoc-code": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each TSDoc code span and code fence is closed and well formed."
+ },
+ "tsdoc-escapes": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "TSDoc text escapes a {, } or > that isn't markup and an @ that isn't a tag, and a backslash escapes only punctuation."
+ },
+ "tsdoc-html": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each HTML tag in a TSDoc block is well formed, and an element the file's tsdoc.json supports."
+ },
+ "tsdoc-inline-tags": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each TSDoc tag has a valid name with a space after it, and each inline tag is closed with no unescaped { inside."
+ },
+ "tsdoc-links": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each TSDoc {@link} has a URL or a declaration reference in TSDoc's grammar, and text only after a |."
+ },
+ "tsdoc-params": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each TSDoc @param and @typeParam has a valid name and a hyphen before its description, without a {type}."
+ },
+ "tsdoc-tags": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "items": false,
+ "minItems": 1,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/Level"
+ },
+ {
+ "$ref": "#/$defs/NoOptions"
+ }
+ ],
+ "type": "array"
+ }
+ ],
+ "description": "Each TSDoc tag is defined and supported by the file's tsdoc.json, used as its kind, and repeated only with allowMultiple."
+ },
"valid-link": {
"anyOf": [
{
@@ -907,6 +1054,14 @@
"default": null,
"description": "The lowest documented percentage that passes, or thresholds by path: `global` for every file together, `perFile` for each file, and gitignore-style globs for the files each matches together."
},
+ "tsdocConfig": {
+ "default": null,
+ "description": "A tsdoc.json, relative to this file's folder, that the tsdoc-* checks read every file's blocks with: its tagDefinitions, noStandardTags, supportForTags, supportedHtmlElements, reportUnsupportedHtmlElements and the files it extends (paths or package names, resolved the way Node resolves them). Without it, and with \"syntax\": \"tsdoc\", each file gets the tsdoc.json in the nearest folder above it with a tsconfig.json or a package.json, as @microsoft/tsdoc-config finds one; otherwise TSDoc's standard tags. Unknown keys in a tsdoc.json are an error, as in tsdoc-config.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"typesThreshold": {
"default": null,
"description": "The lowest percentage of JavaScript functions and methods with a {type} on each @param and @returns that passes.",
diff --git a/src/cache.rs b/src/cache.rs
index 2c3d86b..369b7f9 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -41,6 +41,8 @@ impl Stamp {
rules.public.as_ref().map(|api| api.reach(path).into_iter().flatten().collect());
reached.iter_mut().for_each(|names| names.sort_unstable());
reached.hash(&mut hasher);
+ // So does the file's tsdoc.json, to the checks that read it.
+ rules.tsdoc(path).hash(&mut hasher);
Some(Self { size: metadata.len(), modified, rules: hasher.finish() })
}
}
diff --git a/src/check.rs b/src/check.rs
index 6fc0f7d..5984c69 100644
--- a/src/check.rs
+++ b/src/check.rs
@@ -5,9 +5,10 @@
//!
//! oxc_linter has eslint-plugin-jsdoc's rules, but it isn't published (its Cargo.toml says
//! `publish = false`, and crates.io has no `oxc_linter`), and its rules run on oxc_semantic inside
-//! the whole linter. These are written on oxc_jsdoc, the parser oxc_linter's rules use too.
+//! the whole linter. These are written on oxc_jsdoc, the parser oxc_linter's rules use too, and the
+//! `tsdoc-*` checks on docov's port of TSDoc's own parser.
-use std::{any::Any, fmt, sync::LazyLock};
+use std::{any::Any, cell::OnceCell, fmt, sync::LazyLock};
use oxc_jsdoc::JSDoc;
use rustc_hash::FxHashSet;
@@ -18,6 +19,7 @@ use serde_json::Value;
use crate::{
collect::{Shape, doc},
feature::syntax::Syntax,
+ tsdoc::{self, Configuration, Group, Parsed},
};
/// A check's rule id, and what makes its entry.
@@ -97,6 +99,10 @@ pub struct Subject<'a, 's> {
/// The doc comment syntax the config writes, which says how a check reads what the two write
/// differently.
pub syntax: Syntax,
+ /// The TSDoc configuration its file is read with.
+ pub tsdoc: &'a Configuration,
+ /// Its block, parsed as TSDoc, once a check asks.
+ pub(crate) parsed: OnceCell,
}
impl Subject<'_, '_> {
@@ -122,6 +128,17 @@ impl Subject<'_, '_> {
doc::inherits(self.doc)
}
+ /// Adds a finding for each message TSDoc's parser has about `group`'s part of its block's
+ /// grammar, as `id: text`.
+ pub fn tsdoc(&self, group: Group, out: &mut Vec) {
+ let block = self.block();
+ // The block's span ends before its `*/`.
+ let comment = &self.source[block as usize..self.doc.span.end as usize + 2];
+ let parsed = self.parsed.get_or_init(|| tsdoc::parse(comment, self.tsdoc));
+ let messages = parsed.messages.iter().filter(|message| message.group() == group);
+ out.extend(messages.map(|m| Finding { at: block + m.at, message: format!("{}: {}", m.id, m.text) }));
+ }
+
/// Adds a finding at its block that it's missing `what`, unless it inherits its docs.
pub fn missing(&self, what: String, out: &mut Vec) {
if !self.inherits() {
@@ -148,6 +165,9 @@ pub trait Check: 'static {
/// What it wants, in a line, for the schema.
const ABOUT: &'static str;
+ /// Whether it reads its block as TSDoc, by the tsdoc.json its file has.
+ const TSDOC: bool = false;
+
/// Adds what it finds wrong with `subject`'s block to `out`.
fn check(options: &Self::Options, subject: &Subject, out: &mut Vec);
}
@@ -163,6 +183,7 @@ type AnyOptions = Box;
/// A check, registered.
pub struct Entry {
about: &'static str,
+ tsdoc: bool,
schema: fn(&mut SchemaGenerator) -> Schema,
options: fn(Option<&Value>) -> serde_json::Result,
run: fn(&AnyOptions, &Subject, &mut Vec),
@@ -173,6 +194,7 @@ impl Entry {
pub fn of() -> Self {
Self {
about: C::ABOUT,
+ tsdoc: C::TSDOC,
schema: |generator| generator.subschema_for::(),
options: options::,
run: run::,
@@ -280,6 +302,7 @@ struct On {
setting: Setting,
options: AnyOptions,
run: fn(&AnyOptions, &Subject, &mut Vec),
+ tsdoc: bool,
}
/// The checks that are on, which scans run on each counted item with a block.
@@ -311,12 +334,17 @@ impl Checks {
.map(|(id, setting)| {
let (rule, entry) = entry(id).expect("a parsed rule id");
let options = (entry.options)(setting.options.as_ref()).expect("parsed options");
- On { rule, setting: setting.clone(), options, run: entry.run }
+ On { rule, setting: setting.clone(), options, run: entry.run, tsdoc: entry.tsdoc }
})
.collect();
(!on.is_empty()).then_some(Self { on })
}
+ /// Whether one that's on reads blocks as TSDoc, by the tsdoc.json each file has.
+ pub fn tsdoc(&self) -> bool {
+ self.on.iter().any(|on| on.tsdoc)
+ }
+
/// Each check that's on, by rule id, and its level.
pub fn levels(&self) -> impl Iterator- {
self.on.iter().map(|on| (on.rule, on.setting.level))
diff --git a/src/check/tsdoc_code.rs b/src/check/tsdoc_code.rs
new file mode 100644
index 0000000..eac3453
--- /dev/null
+++ b/src/check/tsdoc_code.rs
@@ -0,0 +1,26 @@
+//! `tsdoc-code`: code spans and code fences are closed and well formed, as `@microsoft/tsdoc`
+//! reads them: a code span has something between its backticks and ends on its line, and a code
+//! fence's backticks start their lines, unindented, with no backtick in its language and nothing
+//! after the closing ones.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocCode;
+
+impl Check for TsdocCode {
+ type Options = NoOptions;
+ const ABOUT: &'static str = "Each TSDoc code span and code fence is closed and well formed.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Code, out);
+ }
+}
diff --git a/src/check/tsdoc_escapes.rs b/src/check/tsdoc_escapes.rs
new file mode 100644
index 0000000..e155b9a
--- /dev/null
+++ b/src/check/tsdoc_escapes.rs
@@ -0,0 +1,26 @@
+//! `tsdoc-escapes`: text escapes what TSDoc would read as markup, as `@microsoft/tsdoc` reads it:
+//! a `{` that doesn't start an inline tag, a `}` and a `>` outside one, and an `@` in a word or
+//! without a tag name. A backslash escapes only punctuation.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocEscapes;
+
+impl Check for TsdocEscapes {
+ type Options = NoOptions;
+ const ABOUT: &'static str =
+ "TSDoc text escapes a {, } or > that isn't markup and an @ that isn't a tag, and a backslash escapes only punctuation.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Escapes, out);
+ }
+}
diff --git a/src/check/tsdoc_html.rs b/src/check/tsdoc_html.rs
new file mode 100644
index 0000000..b7638e7
--- /dev/null
+++ b/src/check/tsdoc_html.rs
@@ -0,0 +1,26 @@
+//! `tsdoc-html`: HTML tags are well formed, as `@microsoft/tsdoc` reads them, and when the file's
+//! tsdoc.json has `supportedHtmlElements` (or `reportUnsupportedHtmlElements`), name only the
+//! elements it lists. Like TSDoc, it holds attribute names to that list too.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocHtml;
+
+impl Check for TsdocHtml {
+ type Options = NoOptions;
+ const ABOUT: &'static str =
+ "Each HTML tag in a TSDoc block is well formed, and an element the file's tsdoc.json supports.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Html, out);
+ }
+}
diff --git a/src/check/tsdoc_inline_tags.rs b/src/check/tsdoc_inline_tags.rs
new file mode 100644
index 0000000..6b580ec
--- /dev/null
+++ b/src/check/tsdoc_inline_tags.rs
@@ -0,0 +1,27 @@
+//! `tsdoc-inline-tags`: tags are well formed, as `@microsoft/tsdoc` reads them. A tag's name is a
+//! letter then letters and digits, with a space after a block tag's and an inline tag's, and an
+//! inline tag `{@name …}` is closed, with any `{` inside escaped. `{@inheritDoc}` has nothing past
+//! its declaration reference.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocInlineTags;
+
+impl Check for TsdocInlineTags {
+ type Options = NoOptions;
+ const ABOUT: &'static str =
+ "Each TSDoc tag has a valid name with a space after it, and each inline tag is closed with no unescaped { inside.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::InlineTags, out);
+ }
+}
diff --git a/src/check/tsdoc_links.rs b/src/check/tsdoc_links.rs
new file mode 100644
index 0000000..6748027
--- /dev/null
+++ b/src/check/tsdoc_links.rs
@@ -0,0 +1,27 @@
+//! `tsdoc-links`: each `{@link}` has a URL with a scheme (`https://…`) or a declaration reference
+//! in TSDoc's grammar (`pkg#Class.(member:static)`), then nothing but its text after a `|`, and
+//! each `{@inheritDoc}` reference follows that grammar too, as `@microsoft/tsdoc` reads them.
+//! `valid-link` checks JSDoc's links, and looks up their names.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocLinks;
+
+impl Check for TsdocLinks {
+ type Options = NoOptions;
+ const ABOUT: &'static str =
+ "Each TSDoc {@link} has a URL or a declaration reference in TSDoc's grammar, and text only after a |.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Links, out);
+ }
+}
diff --git a/src/check/tsdoc_params.rs b/src/check/tsdoc_params.rs
new file mode 100644
index 0000000..b692de8
--- /dev/null
+++ b/src/check/tsdoc_params.rs
@@ -0,0 +1,26 @@
+//! `tsdoc-params`: each `@param` and `@typeParam` names a parameter with a valid identifier and
+//! puts ` - ` before its description (`tsdoc-param-tag-missing-hyphen`), with no JSDoc-style
+//! `{type}` or `[optional]` name, as `@microsoft/tsdoc` reads them.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocParams;
+
+impl Check for TsdocParams {
+ type Options = NoOptions;
+ const ABOUT: &'static str =
+ "Each TSDoc @param and @typeParam has a valid name and a hyphen before its description, without a {type}.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Params, out);
+ }
+}
diff --git a/src/check/tsdoc_tags.rs b/src/check/tsdoc_tags.rs
new file mode 100644
index 0000000..78da9bf
--- /dev/null
+++ b/src/check/tsdoc_tags.rs
@@ -0,0 +1,31 @@
+//! `tsdoc-tags`: each tag is one the file's tsdoc.json defines (`tsdoc-undefined-tag`) and
+//! supports, when its `supportForTags` says which it does (`tsdoc-unsupported-tag`), and is used as
+//! the kind it's defined as: an inline tag in braces, a block or modifier tag not
+//! (`tsdoc-tag-should-not-have-braces`, `tsdoc-inline-tag-missing-braces`). A tag without
+//! `allowMultiple` is used once (`docov-repeated-tag`), and a modifier tag has nothing after it on
+//! its line (`docov-modifier-tag-text`), which TSDoc would put in the section before it; TSDoc
+//! itself checks neither, so their ids are docov's. It also has TSDoc's checks of particular
+//! tags: `@deprecated` needs a message, and `{@inheritDoc}` comes once, with no summary or
+//! `@remarks`.
+
+use crate::{
+ check::{Check, Entry, Finding, NoOptions, Subject},
+ tsdoc::Group,
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The check.
+pub struct TsdocTags;
+
+impl Check for TsdocTags {
+ type Options = NoOptions;
+ const ABOUT: &'static str = "Each TSDoc tag is defined and supported by the file's tsdoc.json, used as its kind, and repeated only with allowMultiple.";
+ const TSDOC: bool = true;
+
+ fn check(_: &NoOptions, subject: &Subject, out: &mut Vec) {
+ subject.tsdoc(Group::Tags, out);
+ }
+}
diff --git a/src/collect.rs b/src/collect.rs
index 3393583..07fa9c2 100644
--- a/src/collect.rs
+++ b/src/collect.rs
@@ -253,6 +253,7 @@ impl Collected<'_> {
) -> FileReport {
let unused_ignores = if rules.unused_ignores { self.unused_ignores(rules, reached) } else { Vec::new() };
let Self { found, exported, ignores, lines, names, code, .. } = self;
+ let tsdoc = rules.tsdoc(path);
let items = found
.iter()
.filter(|f| rules.counts(f, &exported, reached))
@@ -263,7 +264,8 @@ impl Collected<'_> {
let (documented, ignore) = (rules.documents(f), ignores.covering(f.kind, line));
let mut item = Item { name: f.name.clone(), kind: f.kind, line, column, documented, facts, ignore };
if let Some(checks) = rules.checks.as_ref().filter(|_| !item.ignored()) {
- item.facts.diagnostics = diagnose::diagnose(checks, f, is_exported, &lines, &names, rules.syntax);
+ item.facts.diagnostics =
+ diagnose::diagnose(checks, f, is_exported, &lines, &names, rules.syntax, tsdoc);
}
// `--fix` leaves a block that's there already alone, a stub included.
if !item.documented && !item.ignored() && !f.block {
diff --git a/src/collect/diagnose.rs b/src/collect/diagnose.rs
index 2b1d8a0..695b34c 100644
--- a/src/collect/diagnose.rs
+++ b/src/collect/diagnose.rs
@@ -1,5 +1,7 @@
//! Runs the doc checks that are on over a file's counted items with a block.
+use std::cell::OnceCell;
+
use oxc_ast::{
CommentContent,
ast::{BindingIdentifier, Declaration, ExportDefaultDeclarationKind, Program, Statement},
@@ -11,11 +13,12 @@ use super::{Found, Lines, Rules, doc, jsdoc};
use crate::{
check::{Checks, Diagnostic, Subject},
feature::syntax::Syntax,
+ tsdoc::Configuration,
};
/// What `checks` find in `found`'s block, if it has one: an item that's `exported` or not, in the
/// file whose text `lines` has, which declares or imports `names` at its top level, read in
-/// `syntax`.
+/// `syntax`, and as TSDoc by `tsdoc`.
pub(super) fn diagnose<'s>(
checks: &Checks,
found: &Found<'s>,
@@ -23,6 +26,7 @@ pub(super) fn diagnose<'s>(
lines: &Lines<'s>,
names: &FxHashSet<&'s str>,
syntax: Syntax,
+ tsdoc: &Configuration,
) -> Vec {
let Some(doc) = &found.doc else { return Vec::new() };
let subject = Subject {
@@ -34,6 +38,8 @@ pub(super) fn diagnose<'s>(
source: lines.source,
names,
syntax,
+ tsdoc,
+ parsed: OnceCell::new(),
};
checks.run(&subject, |at| lines.position(at as usize))
}
diff --git a/src/collect/rules.rs b/src/collect/rules.rs
index d735b9a..b015f18 100644
--- a/src/collect/rules.rs
+++ b/src/collect/rules.rs
@@ -1,6 +1,6 @@
//! Which items count, and when one is documented: the scan's settings, which features fill in.
-use std::{fmt, sync::Arc};
+use std::{fmt, path::Path, sync::Arc};
use regex::Regex;
use rustc_hash::FxHashSet;
@@ -15,6 +15,7 @@ use crate::{
},
glob::Globs,
public::Api,
+ tsdoc::{self, Configuration, config::Configs},
};
/// Which items count, and when one is documented. The default is docov's out of the box.
@@ -68,6 +69,10 @@ pub struct Rules {
/// cache stamps a file by their levels and options.
#[serde(serialize_with = "checks")]
pub checks: Option>,
+ /// The TSDoc configuration each file's blocks are read with, when a check reads them as TSDoc
+ /// and the config names a tsdoc.json or has TSDoc's syntax. The cache stamps a file by its own.
+ #[serde(skip)]
+ pub tsdoc: Option>,
}
/// Regexes, by their sources.
@@ -87,6 +92,12 @@ impl From for Rules {
}
impl Rules {
+ /// The TSDoc configuration the file at `path` is read with: TSDoc's default when none is
+ /// loaded.
+ pub fn tsdoc(&self, path: &Path) -> &Configuration {
+ self.tsdoc.as_ref().map_or(&tsdoc::DEFAULT, |configs| configs.of(path))
+ }
+
/// Whether `found` counts: it's in scope (`exported` holds the names exported apart from their
/// declarations, and how, and `reached` the local names the package's entry points reach in
/// its file, if they reach it), and no filter leaves it out.
diff --git a/src/collect/tests.rs b/src/collect/tests.rs
index 45d51f0..bd5317a 100644
--- a/src/collect/tests.rs
+++ b/src/collect/tests.rs
@@ -486,7 +486,9 @@ fn deprecated_items_and_generated_files_can_be_left_out() {
/// in `syntax`: each finding's line, rule and message.
fn findings(source: &str, syntax: Syntax) -> Vec<(usize, &'static str, String)> {
let error = check::Setting { level: check::Level::Error, options: None };
- let checks = check::Checks::new(check::ids().map(|id| (id, &error))).unwrap();
+ // The tsdoc-* checks have their own tests, against @microsoft/tsdoc's findings.
+ let ids = check::ids().filter(|id| !id.starts_with("tsdoc-"));
+ let checks = check::Checks::new(ids.map(|id| (id, &error))).unwrap();
let rules = Rules { checks: Some(std::sync::Arc::new(checks)), syntax, ..Scope::All.into() };
let items = scan(source, SourceType::ts(), &rules).unwrap().into_iter();
items.flat_map(|i| i.facts.diagnostics).map(|d| (d.line, d.rule, d.message)).collect()
diff --git a/src/feature/tsdoc_config.rs b/src/feature/tsdoc_config.rs
new file mode 100644
index 0000000..089bd1d
--- /dev/null
+++ b/src/feature/tsdoc_config.rs
@@ -0,0 +1,60 @@
+//! `--tsdoc-config` and `tsdocConfig`: the tsdoc.json the `tsdoc-*` checks read every file's
+//! blocks with. Without one, and with `"syntax": "tsdoc"`, each file is read with the tsdoc.json
+//! `@microsoft/tsdoc-config` finds for it, in the nearest folder with a tsconfig.json or a
+//! package.json. Otherwise they're read with TSDoc's standard tags.
+
+use std::path::PathBuf;
+
+use schemars::JsonSchema;
+use serde::Deserialize;
+
+use crate::{
+ Config,
+ feature::{Entry, Feature},
+};
+
+pub(crate) fn entry() -> Entry {
+ Entry::of::()
+}
+
+/// The tsdoc.json the flag or the key names, if one does.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TsdocConfig {
+ flag: Option,
+ key: Option,
+}
+
+/// The flag.
+#[derive(clap::Args)]
+#[group(skip)]
+pub struct Flags {
+ /// Read TSDoc blocks with this tsdoc.json [default: the config's `tsdocConfig`, or with "syntax": "tsdoc", each file's nearest]
+ #[arg(long, value_name = "FILE")]
+ tsdoc_config: Option,
+}
+
+/// The key.
+#[derive(Debug, Default, PartialEq, Deserialize, JsonSchema)]
+#[serde(default, rename_all = "camelCase")]
+pub struct Keys {
+ /// A tsdoc.json, relative to this file's folder, that the tsdoc-* checks read every file's blocks with: its tagDefinitions, noStandardTags, supportForTags, supportedHtmlElements, reportUnsupportedHtmlElements and the files it extends (paths or package names, resolved the way Node resolves them). Without it, and with "syntax": "tsdoc", each file gets the tsdoc.json in the nearest folder above it with a tsconfig.json or a package.json, as @microsoft/tsdoc-config finds one; otherwise TSDoc's standard tags. Unknown keys in a tsdoc.json are an error, as in tsdoc-config.
+ tsdoc_config: Option,
+}
+
+impl Feature for TsdocConfig {
+ type Flags = Flags;
+ type Keys = Keys;
+
+ fn new(flags: Flags, keys: Keys) -> Self {
+ Self { flag: flags.tsdoc_config, key: keys.tsdoc_config }
+ }
+}
+
+impl TsdocConfig {
+ /// The tsdoc.json it names, as a path from the current folder: the flag's, or else the key's,
+ /// which is relative to the folder of the file in `config` that wrote it.
+ pub fn path(&self, config: &Config) -> Option {
+ let dir = || config.dirs.get("tsdocConfig").unwrap_or(&config.dir);
+ self.flag.clone().or_else(|| self.key.as_ref().map(|key| dir().components().chain(key.components()).collect()))
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 088c973..8f0baa9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -18,6 +18,7 @@ pub mod public;
pub mod report;
pub mod rewrite;
pub mod root;
+pub mod tsdoc;
pub mod walk;
pub use collect::{FileReport, Item, Kind, Rules, scan, scan_file};
diff --git a/src/lsp/project.rs b/src/lsp/project.rs
index 47d6441..1c6a3ee 100644
--- a/src/lsp/project.rs
+++ b/src/lsp/project.rs
@@ -157,6 +157,8 @@ impl Projects {
let paths = if config.paths.is_empty() { vec![here] } else { config.paths.clone() };
plan.load_public_with(&paths, |dir, paths| self.api(dir, paths, reading))
.map_err(|errors| errors.join("\n"))?;
+ let tsdoc = plan.load_tsdoc().map_err(|errors| errors.join("\n"))?;
+ self.see(reading, tsdoc);
Ok(plan.files().first().map(|(_, rules)| (*rules).clone()))
}
diff --git a/src/main.rs b/src/main.rs
index 70dcf94..62c69c6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -146,6 +146,7 @@ fn run(cli: &Cli, matches: &clap::ArgMatches, watched: &mut Vec) -> Res
};
let mut plan = nested::Plan::new(&config, settings, matches, files).map_err(|e| vec![e])?;
plan.load_public(&paths)?;
+ plan.load_tsdoc()?;
let (settings, files) = (plan.settings(), plan.files());
if let Some(explained) = settings.get::().write(&files, &mut io::stdout().lock()) {
return explained.map(|()| true).map_err(|e| vec![e]);
diff --git a/src/nested.rs b/src/nested.rs
index 86cd119..60feb68 100644
--- a/src/nested.rs
+++ b/src/nested.rs
@@ -18,9 +18,12 @@ use crate::{
baseline::{Baseline, Excused},
nested::Nested,
overrides::Overrides,
+ syntax::Syntax,
+ tsdoc_config::TsdocConfig,
},
public::Api,
root::Root,
+ tsdoc::config::Configs,
walk,
};
@@ -48,6 +51,8 @@ pub struct Group {
baseline_dir: PathBuf,
/// The items its baseline lets through, if it reads one.
excused: Option,
+ /// The tsdoc.json it names, as a path from the current folder, if it names one.
+ tsdoc_config: Option,
/// Its files, sorted.
pub files: Vec,
}
@@ -79,8 +84,21 @@ impl Group {
Some(place)
};
let scoped_by = files.iter().map(scope_of).collect();
- let configs = config.files.clone();
- Ok(Self { config: path, configs, settings, rules, scoped, scoped_by, root, dir, baseline_dir, excused, files })
+ let (configs, tsdoc_config) = (config.files.clone(), settings.get::().path(config));
+ Ok(Self {
+ config: path,
+ configs,
+ settings,
+ rules,
+ scoped,
+ scoped_by,
+ root,
+ dir,
+ baseline_dir,
+ excused,
+ tsdoc_config,
+ files,
+ })
}
/// Each of its files, with the rules it's scanned with.
@@ -181,6 +199,28 @@ impl Plan {
Ok(())
}
+ /// Reads the tsdoc.json each group's files are read with, for the groups with a check that
+ /// reads blocks as TSDoc: the one the group's config names, or with TSDoc's syntax, each
+ /// file's nearest. It says which files it read and looked for, or why one can't be used.
+ pub fn load_tsdoc(&mut self) -> Result, Vec> {
+ let mut sources = Vec::new();
+ for group in &mut self.groups {
+ if !group.rules.checks.as_ref().is_some_and(|checks| checks.tsdoc()) {
+ continue;
+ }
+ let configs = match &group.tsdoc_config {
+ Some(path) => Configs::file(path),
+ None if group.rules.syntax == Syntax::Tsdoc => Configs::find(group.files.iter().map(PathBuf::as_path)),
+ None => continue,
+ };
+ let configs = Arc::new(configs.map_err(|e| vec![e])?);
+ sources.extend(configs.sources.iter().cloned());
+ let all = std::iter::once(&mut group.rules).chain(group.scoped.values_mut());
+ all.for_each(|rules| rules.tsdoc = Some(Arc::clone(&configs)));
+ }
+ Ok(sources)
+ }
+
/// The root config's settings.
pub fn settings(&self) -> &Settings {
&self.groups[0].settings
diff --git a/src/tsdoc.rs b/src/tsdoc.rs
new file mode 100644
index 0000000..124f7ca
--- /dev/null
+++ b/src/tsdoc.rs
@@ -0,0 +1,180 @@
+//! TSDoc: the tags a tsdoc.json defines, and a parser of TSDoc's grammar that finds what
+//! `@microsoft/tsdoc` does, by its message ids, so docov's findings line up with
+//! `eslint-plugin-tsdoc`'s. It's a port of that package's parser, which would otherwise take Node
+//! to run; nothing on crates.io parses TSDoc's grammar or reads tsdoc.json.
+
+pub mod config;
+mod parse;
+#[cfg(test)]
+mod tests;
+mod tokens;
+
+use std::sync::LazyLock;
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+pub use self::parse::{Message, Parsed, parse};
+
+/// The parts of TSDoc's grammar, each of which a doc check reports the messages of.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Group {
+ /// Tags: undefined, unsupported, of the wrong kind or repeated, and what a tag needs.
+ Tags,
+ /// `@param` and `@typeParam` blocks.
+ Params,
+ /// How a tag is written.
+ InlineTags,
+ /// `{`, `}`, `>`, `@` and backslashes in text.
+ Escapes,
+ /// Code spans and fences.
+ Code,
+ /// HTML elements.
+ Html,
+ /// `{@link}` and the declaration references in it.
+ Links,
+}
+
+impl Message {
+ /// The part of the grammar it's about.
+ pub fn group(&self) -> Group {
+ let id = self.id;
+ match id {
+ "tsdoc-undefined-tag"
+ | "tsdoc-unsupported-tag"
+ | "tsdoc-tag-should-not-have-braces"
+ | "tsdoc-inline-tag-missing-braces"
+ | "tsdoc-missing-deprecation-message"
+ | "tsdoc-extra-inheritdoc-tag"
+ | "tsdoc-inheritdoc-incompatible-tag"
+ | "tsdoc-inheritdoc-incompatible-summary"
+ | "docov-repeated-tag"
+ | "docov-modifier-tag-text" => Group::Tags,
+ "tsdoc-escape-right-brace"
+ | "tsdoc-escape-greater-than"
+ | "tsdoc-unnecessary-backslash"
+ | "tsdoc-at-sign-in-word"
+ | "tsdoc-at-sign-without-tag-name"
+ | "tsdoc-malformed-inline-tag" => Group::Escapes,
+ "tsdoc-malformed-tag-name"
+ | "tsdoc-characters-after-block-tag"
+ | "tsdoc-characters-after-inline-tag"
+ | "tsdoc-inline-tag-missing-right-brace"
+ | "tsdoc-inline-tag-unescaped-brace"
+ | "tsdoc-inheritdoc-tag-syntax" => Group::InlineTags,
+ "tsdoc-text-after-html-string" | "tsdoc-malformed-html-name" | "tsdoc-unsupported-html-name" => Group::Html,
+ _ if id.starts_with("tsdoc-param-") => Group::Params,
+ _ if id.starts_with("tsdoc-code-") => Group::Code,
+ _ if id.starts_with("tsdoc-html-") => Group::Html,
+ _ => Group::Links,
+ }
+ }
+}
+
+/// How a tag is written.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum SyntaxKind {
+ /// In braces, inside other text: `{@link}`.
+ Inline,
+ /// Starting a section of its own: `@remarks`.
+ Block,
+ /// By itself, marking the item: `@internal`.
+ Modifier,
+}
+
+/// A tag TSDoc knows.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub struct TagDefinition {
+ /// Its name, with its `@`.
+ pub name: String,
+ /// How it's written.
+ pub kind: SyntaxKind,
+ /// Whether a comment may have it more than once.
+ pub allow_multiple: bool,
+}
+
+/// TSDoc's standard tags, by name, kind and whether a comment may have more than one.
+const STANDARD: [(&str, SyntaxKind, bool); 29] = {
+ use SyntaxKind::{Block, Inline, Modifier};
+ [
+ ("@alpha", Modifier, false),
+ ("@beta", Modifier, false),
+ ("@decorator", Block, true),
+ ("@defaultValue", Block, false),
+ ("@deprecated", Block, false),
+ ("@eventProperty", Modifier, false),
+ ("@example", Block, true),
+ ("@experimental", Modifier, false),
+ ("@inheritDoc", Inline, false),
+ ("@internal", Modifier, false),
+ ("@label", Inline, false),
+ ("@link", Inline, true),
+ ("@override", Modifier, false),
+ ("@packageDocumentation", Modifier, false),
+ ("@param", Block, true),
+ ("@privateRemarks", Block, false),
+ ("@public", Modifier, false),
+ ("@readonly", Modifier, false),
+ ("@remarks", Block, false),
+ ("@returns", Block, false),
+ ("@sealed", Modifier, false),
+ ("@see", Block, false),
+ ("@throws", Block, true),
+ ("@typeParam", Block, true),
+ ("@virtual", Modifier, false),
+ ("@jsx", Block, false),
+ ("@jsxRuntime", Block, false),
+ ("@jsxFrag", Block, false),
+ ("@jsxImportSource", Block, false),
+ ]
+};
+
+/// What TSDoc's parser is set up with: the tags it knows, which of them the tool supports, and
+/// which HTML elements it allows. The default is `@microsoft/tsdoc`'s: every standard tag, and no
+/// check of support or HTML elements.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub struct Configuration {
+ /// The tags it knows.
+ pub tags: Vec,
+ /// The names of the tags it supports, upper-cased, when it reports the others.
+ pub supported: Option>,
+ /// The HTML element names it allows.
+ pub html: Vec,
+ /// Whether it reports the HTML elements `html` doesn't name.
+ pub report_unsupported_html: bool,
+}
+
+impl Default for Configuration {
+ fn default() -> Self {
+ Self::standard(true)
+ }
+}
+
+/// The default configuration, shared.
+pub static DEFAULT: LazyLock = LazyLock::new(Configuration::default);
+
+impl Configuration {
+ /// A configuration with TSDoc's standard tags if `standard`, or none.
+ pub fn standard(standard: bool) -> Self {
+ let tags = STANDARD.iter().filter(|_| standard);
+ let tags = tags.map(|&(name, kind, allow_multiple)| TagDefinition { name: name.into(), kind, allow_multiple });
+ Self { tags: tags.collect(), supported: None, html: Vec::new(), report_unsupported_html: false }
+ }
+
+ /// The tag named `name` (with its `@`), in any case.
+ pub fn tag(&self, name: &str) -> Option<&TagDefinition> {
+ self.tags.iter().find(|tag| tag.name.eq_ignore_ascii_case(name))
+ }
+
+ /// Whether a tag it knows is unsupported, when it says which are.
+ fn unsupported(&self, tag: &TagDefinition) -> bool {
+ let upper = tag.name.to_ascii_uppercase();
+ self.supported.as_ref().is_some_and(|supported| !supported.contains(&upper))
+ }
+
+ /// Whether it reports the HTML element `name`.
+ fn reports_html(&self, name: &str) -> bool {
+ self.report_unsupported_html && !self.html.iter().any(|allowed| allowed == name)
+ }
+}
diff --git a/src/tsdoc/config.rs b/src/tsdoc/config.rs
new file mode 100644
index 0000000..5aa70af
--- /dev/null
+++ b/src/tsdoc/config.rs
@@ -0,0 +1,240 @@
+//! tsdoc.json: the tags and HTML elements a project's TSDoc defines, read the way
+//! `@microsoft/tsdoc-config` reads them, with the files it `extends`, and found the way it finds
+//! one for a source file.
+
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use indexmap::IndexMap;
+use oxc_resolver::{ResolveOptions, Resolver};
+use rustc_hash::{FxHashMap, FxHashSet};
+use serde::Deserialize;
+use serde_json::Value;
+
+use super::{Configuration, SyntaxKind, TagDefinition, parse::valid_tag_name};
+use crate::root::Root;
+
+/// The file's name.
+pub const FILE_NAME: &str = "tsdoc.json";
+
+/// The `$schema` a tsdoc.json has to have.
+pub const SCHEMA: &str = "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json";
+
+/// A tsdoc.json as written.
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct File {
+ #[serde(rename = "$schema")]
+ _schema: String,
+ #[serde(default)]
+ extends: Vec,
+ no_standard_tags: Option,
+ #[serde(default)]
+ tag_definitions: Vec,
+ #[serde(default)]
+ support_for_tags: IndexMap,
+ supported_html_elements: Option>,
+ report_unsupported_html_elements: Option,
+}
+
+/// A tag a tsdoc.json defines.
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct Tag {
+ tag_name: String,
+ syntax_kind: SyntaxKind,
+ #[serde(default)]
+ allow_multiple: bool,
+}
+
+/// A tsdoc.json and the files it extends, read.
+struct Loaded {
+ path: PathBuf,
+ file: File,
+ extends: Vec,
+}
+
+/// Why a tsdoc.json can't be used: its path, TSDoc's message id and what's wrong.
+fn error(path: &Path, id: &str, text: impl std::fmt::Display) -> String {
+ format!("{}: {id}: {text}", path.display())
+}
+
+/// The configuration the tsdoc.json at `path` sets up, and the files it read, `path` first.
+pub fn load(path: &Path) -> Result<(Configuration, Vec), String> {
+ let resolver = Resolver::new(ResolveOptions::default());
+ let loaded = read(path, &resolver, &mut Vec::new())?;
+ let standard = !no_standard_tags(&loaded).unwrap_or(false);
+ let mut configuration = Configuration::standard(standard);
+ configure(&loaded, &mut configuration)?;
+ let mut sources = Vec::new();
+ paths(&loaded, &mut sources);
+ Ok((configuration, sources))
+}
+
+/// Reads the file at `path`, and the files it extends. `chain` holds the real paths of the files
+/// that extend it, to stop at a cycle.
+fn read(path: &Path, resolver: &Resolver, chain: &mut Vec) -> Result {
+ let not_found = |e: std::io::Error| error(path, "tsdoc-config-file-not-found", e);
+ let real = dunce::canonicalize(path).map_err(not_found)?;
+ if chain.contains(&real) {
+ return Err(error(path, "tsdoc-config-cyclic-extends", "it extends itself through the files it extends"));
+ }
+ let mut text = fs::read_to_string(path).map_err(not_found)?;
+ let invalid = |e: &dyn std::fmt::Display| error(path, "tsdoc-config-invalid-json", e);
+ json_strip_comments::strip(&mut text).map_err(|e| invalid(&e))?;
+ let value: Value = serde_json::from_str(&text).map_err(|e| invalid(&e))?;
+ if value.get("$schema").and_then(Value::as_str) != Some(SCHEMA) {
+ return Err(error(path, "tsdoc-config-unsupported-schema", format!("expected a \"$schema\" of \"{SCHEMA}\"")));
+ }
+ let file = File::deserialize(value).map_err(|e| error(path, "tsdoc-config-schema-error", e))?;
+ let html_name = |name: &String| name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
+ if let Some(name) = file.supported_html_elements.iter().flatten().find(|name| html_name(name)) {
+ return Err(error(path, "tsdoc-config-schema-error", format!("\"{name}\" isn't an HTML element name")));
+ }
+ let mut names: Vec = Vec::new();
+ for tag in &file.tag_definitions {
+ if !valid_tag_name(&tag.tag_name) {
+ let text = format!("\"{}\" isn't a tag name: an @, a letter, then letters and digits", tag.tag_name);
+ return Err(error(path, "tsdoc-config-invalid-tag-name", text));
+ }
+ let upper = tag.tag_name.to_ascii_uppercase();
+ if names.contains(&upper) {
+ let text = format!("\"tagDefinitions\" defines \"{}\" more than once", tag.tag_name);
+ return Err(error(path, "tsdoc-config-duplicate-tag-name", text));
+ }
+ names.push(upper);
+ }
+ let dir = real.parent().expect("a file is in a folder").to_owned();
+ chain.push(real);
+ let mut extends = Vec::new();
+ for specifier in &file.extends {
+ let resolved = resolver.resolve(&dir, specifier).map_err(|e| {
+ error(path, "tsdoc-config-unresolved-extends", format!("can't resolve \"{specifier}\": {e}"))
+ })?;
+ extends.push(read(resolved.path(), resolver, chain)?);
+ }
+ chain.pop();
+ Ok(Loaded { path: path.to_owned(), file, extends })
+}
+
+/// `noStandardTags`, from the file or else the last of the files it extends that sets it.
+fn no_standard_tags(loaded: &Loaded) -> Option {
+ loaded.file.no_standard_tags.or_else(|| loaded.extends.iter().rev().find_map(no_standard_tags))
+}
+
+/// Adds what `loaded` sets to `configuration`, after what the files it extends set.
+fn configure(loaded: &Loaded, configuration: &mut Configuration) -> Result<(), String> {
+ for base in &loaded.extends {
+ configure(base, configuration)?;
+ }
+ let File { tag_definitions, support_for_tags, supported_html_elements, report_unsupported_html_elements, .. } =
+ &loaded.file;
+ for tag in tag_definitions {
+ if let Some(defined) = configuration.tag(&tag.tag_name) {
+ let text = format!("\"{}\" is defined already, as \"{}\"", tag.tag_name, defined.name);
+ return Err(error(&loaded.path, "tsdoc-config-duplicate-tag-name", text));
+ }
+ let (name, kind, allow_multiple) = (tag.tag_name.clone(), tag.syntax_kind, tag.allow_multiple);
+ configuration.tags.push(TagDefinition { name, kind, allow_multiple });
+ }
+ for (name, &supported) in support_for_tags {
+ let Some(defined) = configuration.tag(name) else {
+ let text = format!("\"supportForTags\" names \"{name}\", which isn't defined");
+ return Err(error(&loaded.path, "tsdoc-config-undefined-tag", text));
+ };
+ let upper = defined.name.to_ascii_uppercase();
+ let set = configuration.supported.get_or_insert_default();
+ set.retain(|tag| *tag != upper);
+ if supported {
+ set.push(upper);
+ }
+ }
+ if let Some(html) = supported_html_elements {
+ configuration.html.clone_from(html);
+ configuration.report_unsupported_html = true;
+ }
+ if let Some(report) = report_unsupported_html_elements {
+ configuration.report_unsupported_html = *report;
+ }
+ Ok(())
+}
+
+/// The paths of `loaded` and the files it extends.
+fn paths(loaded: &Loaded, out: &mut Vec) {
+ out.push(loaded.path.clone());
+ loaded.extends.iter().for_each(|base| paths(base, out));
+}
+
+/// The tsdoc.json `@microsoft/tsdoc-config` reads for a file in `folder`: the one in the nearest
+/// folder from there up that has a tsconfig.json or a package.json, if it's there, and the files
+/// it looked for to find it. It looks above the current folder too, for a scan run inside a
+/// package.
+pub fn find(folder: &Path) -> (Option, Vec) {
+ let folder = Root::new(Path::new("")).absolute(folder);
+ let mut looked = Vec::new();
+ for dir in folder.ancestors() {
+ for marker in ["tsconfig.json", "package.json"] {
+ looked.push(dir.join(marker));
+ if dir.join(marker).is_file() {
+ let config = dir.join(FILE_NAME);
+ looked.push(config.clone());
+ return (config.is_file().then_some(config), looked);
+ }
+ }
+ }
+ (None, looked)
+}
+
+/// The configuration each scanned file's comments are read with.
+#[derive(Debug, Default)]
+pub struct Configs {
+ /// The one every file is read with, when the config names one.
+ every: Option>,
+ /// The one each folder's files are read with, by the folder, when it isn't the default.
+ folders: FxHashMap>,
+ /// The files read, and looked for, to find them.
+ pub sources: Vec,
+}
+
+impl Configs {
+ /// The tsdoc.json at `path`, for every file.
+ pub fn file(path: &Path) -> Result {
+ let (configuration, sources) = load(path)?;
+ Ok(Self { every: Some(Arc::new(configuration)), folders: FxHashMap::default(), sources })
+ }
+
+ /// The tsdoc.json [found](find) for each of `files`, each one read once.
+ pub fn find<'p>(files: impl IntoIterator
- ) -> Result {
+ let mut configs = Self::default();
+ let (mut seen, mut read) = (FxHashSet::default(), FxHashMap::default());
+ for file in files {
+ let folder = file.parent().unwrap_or(file);
+ if !seen.insert(folder.to_owned()) {
+ continue;
+ }
+ let (found, looked) = find(folder);
+ configs.sources.extend(looked);
+ let Some(found) = found else { continue };
+ let configuration: Arc = match read.get(&found) {
+ Some(configuration) => Arc::clone(configuration),
+ None => {
+ let (configuration, sources) = load(&found)?;
+ configs.sources.extend(sources);
+ Arc::clone(read.entry(found).or_insert(Arc::new(configuration)))
+ }
+ };
+ configs.folders.insert(folder.to_owned(), configuration);
+ }
+ Ok(configs)
+ }
+
+ /// The configuration the file at `path` is read with: TSDoc's default when nothing sets
+ /// another.
+ pub fn of(&self, path: &Path) -> &Configuration {
+ let folder = path.parent().and_then(|folder| self.folders.get(folder));
+ self.every.as_deref().or(folder.map(Arc::as_ref)).unwrap_or(&super::DEFAULT)
+ }
+}
diff --git a/src/tsdoc/parse.rs b/src/tsdoc/parse.rs
new file mode 100644
index 0000000..0af1fa6
--- /dev/null
+++ b/src/tsdoc/parse.rs
@@ -0,0 +1,671 @@
+//! TSDoc's grammar: a port of `@microsoft/tsdoc`'s NodeParser that keeps what it reports, by
+//! message id and where, and leaves out the tree it builds. Each construct is read the way that
+//! parser reads it, so a mistake is reported once, where `@microsoft/tsdoc` reports it, and the
+//! text after it reads the same.
+
+mod code;
+mod html;
+mod link;
+
+use std::ops::Range;
+
+use super::{
+ Configuration, SyntaxKind, TagDefinition,
+ tokens::{Kind, Token, tokens},
+};
+
+/// Something wrong in a comment.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Message {
+ /// TSDoc's message id, as `eslint-plugin-tsdoc` reports it, or docov's for what TSDoc doesn't
+ /// check (`docov-repeated-tag`, `docov-modifier-tag-text`).
+ pub id: &'static str,
+ /// What's wrong, in TSDoc's words.
+ pub text: String,
+ /// Where, as a byte offset into the comment.
+ pub at: u32,
+}
+
+/// What a comment's parse found.
+#[derive(Debug, Default)]
+pub struct Parsed {
+ /// What's wrong, in the order the parser found it.
+ pub messages: Vec,
+}
+
+/// Parses `comment`, a whole `/** */` comment, with the tags and HTML elements `config` defines.
+pub fn parse(comment: &str, config: &Configuration) -> Parsed {
+ let tokens = tokens(comment);
+ let mut parser = Parser {
+ comment,
+ tokens: &tokens,
+ config,
+ messages: Vec::new(),
+ sections: vec![false],
+ section: 0,
+ deprecated: None,
+ remarks: None,
+ inherit_doc: false,
+ modifier: None,
+ used: Vec::new(),
+ };
+ parser.run();
+ Parsed { messages: parser.messages }
+}
+
+/// A token sequence, by token indices.
+type Sequence = Range;
+
+/// Why a construct couldn't be read, before it's reported: TSDoc's message id and text.
+struct Failure {
+ id: &'static str,
+ text: String,
+}
+
+impl Failure {
+ fn new(id: &'static str, text: String) -> Self {
+ Self { id, text }
+ }
+}
+
+/// A reader over the tokens, or the tokens of an inline tag's content: the token it's at, and the
+/// first of those it read since it last took them as a sequence.
+struct Reader<'t> {
+ tokens: &'t [Token],
+ end: usize,
+ current: usize,
+ accumulated: usize,
+}
+
+impl<'t> Reader<'t> {
+ fn new(tokens: &'t [Token], range: Sequence) -> Self {
+ Self { tokens, end: range.end, current: range.start, accumulated: range.start }
+ }
+
+ fn kind(&self, at: usize) -> Kind {
+ if at < self.end { self.tokens[at].kind } else { Kind::EndOfInput }
+ }
+
+ fn peek(&self) -> Kind {
+ self.kind(self.current)
+ }
+
+ fn peek_after(&self) -> Kind {
+ self.kind(self.current + 1)
+ }
+
+ fn peek_after_after(&self) -> Kind {
+ self.kind(self.current + 2)
+ }
+
+ /// The kind of the token before this one, in the whole comment.
+ fn previous(&self) -> Kind {
+ self.current.checked_sub(1).map_or(Kind::EndOfInput, |at| self.tokens[at].kind)
+ }
+
+ fn read(&mut self) -> Token {
+ self.current += 1;
+ self.tokens[self.current - 1]
+ }
+
+ fn marker(&self) -> usize {
+ self.current
+ }
+
+ fn backtrack(&mut self, marker: usize) {
+ self.current = marker;
+ self.accumulated = self.accumulated.min(marker);
+ }
+
+ fn is_empty(&self) -> bool {
+ self.accumulated == self.current
+ }
+
+ /// The tokens read since the last sequence taken.
+ fn extract(&mut self) -> Sequence {
+ let sequence = self.accumulated..self.current;
+ self.accumulated = self.current;
+ sequence
+ }
+
+ fn try_extract(&mut self) -> Option {
+ (!self.is_empty()).then(|| self.extract())
+ }
+}
+
+/// The parse of one comment.
+struct Parser<'c, 't> {
+ comment: &'c str,
+ tokens: &'t [Token],
+ config: &'c Configuration,
+ messages: Vec,
+ /// Whether each section has text, the summary first.
+ sections: Vec,
+ /// The section text goes into now.
+ section: usize,
+ /// The last `@deprecated` block's section, its tag's name as written, and where it is.
+ deprecated: Option<(usize, String, u32)>,
+ /// The last `@remarks` block's tag, as written, and where it is.
+ remarks: Option<(String, u32)>,
+ /// Whether the comment has an `{@inheritDoc}`.
+ inherit_doc: bool,
+ /// The modifier tag on this line, before any text after it.
+ modifier: Option<(String, u32)>,
+ /// The tags used so far that a comment may have only one of, upper-cased.
+ used: Vec,
+}
+
+/// What an inline tag turned out to be.
+enum Inline {
+ /// A mistake.
+ Error,
+ /// `{@inheritDoc}`, read without a mistake.
+ InheritDoc,
+ /// `{@link}`, with whether its text has more than spaces.
+ Link(bool),
+ /// Another tag.
+ Tag,
+}
+
+impl<'c> Parser<'c, '_> {
+ fn run(&mut self) {
+ let mut reader = Reader::new(self.tokens, 0..self.tokens.len());
+ loop {
+ match reader.peek() {
+ Kind::EndOfInput => break,
+ Kind::Newline => {
+ self.flush(&mut reader);
+ reader.read();
+ reader.extract();
+ self.modifier = None;
+ }
+ Kind::Backslash => {
+ self.before_markup(&mut reader);
+ self.backslash(&mut reader);
+ }
+ Kind::AtSign => {
+ self.flush(&mut reader);
+ self.block(&mut reader);
+ }
+ Kind::LeftCurly => {
+ self.before_markup(&mut reader);
+ let marker = reader.marker();
+ match self.inline_tag(&mut reader) {
+ Inline::InheritDoc if self.inherit_doc => {
+ let end = reader.marker() - 1;
+ let text = "A doc comment cannot have more than one @inheritDoc tag";
+ self.error_range(&mut reader, marker, end, "tsdoc-extra-inheritdoc-tag", text.into());
+ }
+ Inline::InheritDoc => self.inherit_doc = true,
+ Inline::Link(text) => self.sections[self.section] |= text,
+ Inline::Error | Inline::Tag => {}
+ }
+ }
+ Kind::RightCurly => {
+ self.before_markup(&mut reader);
+ let text = "The \"}\" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag";
+ self.error(&mut reader, "tsdoc-escape-right-brace", text.into());
+ }
+ Kind::LessThan => {
+ self.before_markup(&mut reader);
+ if reader.peek_after() == Kind::Slash {
+ self.html_end_tag(&mut reader);
+ } else {
+ self.html_start_tag(&mut reader);
+ }
+ }
+ Kind::GreaterThan => {
+ self.before_markup(&mut reader);
+ let text =
+ "The \">\" character should be escaped using a backslash to avoid confusion with an HTML tag";
+ self.error(&mut reader, "tsdoc-escape-greater-than", text.into());
+ }
+ Kind::Backtick => {
+ self.before_markup(&mut reader);
+ if reader.peek_after() == Kind::Backtick && reader.peek_after_after() == Kind::Backtick {
+ self.fenced_code(&mut reader);
+ } else {
+ self.code_span(&mut reader);
+ }
+ }
+ _ => {
+ reader.read();
+ }
+ }
+ }
+ self.flush(&mut reader);
+ self.validate();
+ }
+
+ /// The checks of the whole comment, once it's read.
+ fn validate(&mut self) {
+ if let Some((section, name, at)) = self.deprecated.take()
+ && !self.sections[section]
+ {
+ let text = format!(
+ "The {name} block must include a deprecation message, e.g. describing the recommended alternative"
+ );
+ self.log("tsdoc-missing-deprecation-message", text, at);
+ }
+ if !self.inherit_doc {
+ return;
+ }
+ if let Some((name, at)) = self.remarks.take() {
+ let text =
+ format!("A \"{name}\" block must not be used, because that content is provided by the @inheritDoc tag");
+ self.log("tsdoc-inheritdoc-incompatible-tag", text, at);
+ }
+ if self.sections[0] {
+ let text = "The summary section must not have any content, because that content is provided by the @inheritDoc tag";
+ self.log("tsdoc-inheritdoc-incompatible-summary", text.into(), 0);
+ }
+ }
+
+ /// The text of the tokens in `sequence`.
+ fn text(&self, sequence: Sequence) -> &'c str {
+ match sequence.len() {
+ 0 => "",
+ _ => &self.comment[self.tokens[sequence.start].start as usize..self.tokens[sequence.end - 1].end as usize],
+ }
+ }
+
+ /// Where the token at `index` starts.
+ fn at(&self, index: usize) -> u32 {
+ self.tokens[index].start
+ }
+
+ fn log(&mut self, id: &'static str, text: String, at: u32) {
+ self.messages.push(Message { id, text, at });
+ }
+
+ /// Takes the plain text read so far, into the current section.
+ fn flush(&mut self, reader: &mut Reader) {
+ let Some(text) = reader.try_extract() else { return };
+ if self.tokens[text].iter().any(|token| token.kind != Kind::Spacing) {
+ self.sections[self.section] = true;
+ self.after_modifier();
+ }
+ }
+
+ /// Takes the plain text read so far, before markup, which is text after a modifier tag too.
+ fn before_markup(&mut self, reader: &mut Reader) {
+ self.flush(reader);
+ self.after_modifier();
+ }
+
+ /// Reports text after a modifier tag on its line, which TSDoc puts in the section before it.
+ fn after_modifier(&mut self) {
+ if let Some((name, at)) = self.modifier.take() {
+ let text = format!(
+ "The TSDoc tag \"{name}\" is a modifier tag; text after it on its line belongs to the section before it"
+ );
+ self.log("docov-modifier-tag-text", text, at);
+ }
+ }
+
+ /// Reports `definition`'s second use in the comment, if it may have only one.
+ fn used(&mut self, definition: &TagDefinition, name: &str, at: u32) {
+ if definition.allow_multiple {
+ return;
+ }
+ let upper = definition.name.to_ascii_uppercase();
+ if self.used.contains(&upper) {
+ self.log(
+ "docov-repeated-tag",
+ format!("The TSDoc tag \"{name}\" may only appear once in a doc comment"),
+ at,
+ );
+ } else {
+ self.used.push(upper);
+ }
+ }
+
+ /// Reports a tag that isn't defined, is used as the wrong kind, or isn't supported.
+ fn validate_tag(&mut self, definition: Option<&TagDefinition>, name: &str, inline: bool, at: u32) {
+ let Some(definition) = definition else {
+ let text = format!("The TSDoc tag \"{name}\" is not defined in this configuration");
+ return self.log("tsdoc-undefined-tag", text, at);
+ };
+ match (definition.kind == SyntaxKind::Inline, inline) {
+ (false, true) => {
+ let text = format!(
+ "The TSDoc tag \"{name}\" is not an inline tag; it must not be enclosed in \"{{ }}\" braces"
+ );
+ self.log("tsdoc-tag-should-not-have-braces", text, at);
+ }
+ (true, false) => {
+ let text =
+ format!("The TSDoc tag \"{name}\" is an inline tag; it must be enclosed in \"{{ }}\" braces");
+ self.log("tsdoc-inline-tag-missing-braces", text, at);
+ }
+ _ if self.config.unsupported(definition) => {
+ let text = format!("The TSDoc tag \"{name}\" is not supported by this tool");
+ self.log("tsdoc-unsupported-tag", text, at);
+ }
+ _ => {}
+ }
+ }
+
+ /// A block or modifier tag, `@name`.
+ fn block(&mut self, reader: &mut Reader) {
+ let Some((name, sequence)) = self.block_tag(reader) else {
+ return self.after_modifier();
+ };
+ self.modifier = None;
+ let at = self.at(sequence.start);
+ let definition = self.config.tag(name);
+ self.validate_tag(definition, name, false, at);
+ let Some(definition) = definition.filter(|definition| definition.kind != SyntaxKind::Inline) else { return };
+ self.used(definition, name, at);
+ if definition.kind == SyntaxKind::Modifier {
+ self.modifier = Some((name.to_owned(), at));
+ return;
+ }
+ let param = ["@param", "@typeParam"].into_iter().find(|param| param.eq_ignore_ascii_case(name));
+ if let Some(param) = param {
+ self.param_block(reader, param, at);
+ }
+ self.sections.push(false);
+ self.section = self.sections.len() - 1;
+ if name.eq_ignore_ascii_case("@deprecated") {
+ self.deprecated = Some((self.section, name.to_owned(), at));
+ } else if name.eq_ignore_ascii_case("@remarks") {
+ self.remarks = Some((name.to_owned(), at));
+ }
+ }
+
+ /// A block tag's `@name`, or none when it's a mistake, which it reports.
+ fn block_tag(&mut self, reader: &mut Reader) -> Option<(&'c str, Sequence)> {
+ let marker = reader.marker();
+ if !matches!(reader.previous(), Kind::EndOfInput | Kind::Spacing | Kind::Newline) {
+ let text = "The \"@\" character looks like part of a TSDoc tag; use a backslash to escape it";
+ self.backtrack_error(reader, marker, "tsdoc-at-sign-in-word", text.into());
+ return None;
+ }
+ reader.read();
+ if reader.peek() != Kind::AsciiWord {
+ let text =
+ "Expecting a TSDoc tag name after \"@\"; if it is not a tag, use a backslash to escape this character";
+ self.backtrack_error(reader, marker, "tsdoc-at-sign-without-tag-name", text.into());
+ return None;
+ }
+ reader.read();
+ let name = self.text(marker..reader.marker());
+ if !matches!(reader.peek(), Kind::Spacing | Kind::Newline | Kind::EndOfInput) {
+ let bad = self.first_char(reader.marker());
+ let text = format!(
+ "The token \"{name}\" looks like a TSDoc tag but contains an invalid character {bad}; if it is not a tag, use a backslash to escape the \"@\""
+ );
+ self.backtrack_error(reader, marker, "tsdoc-characters-after-block-tag", text);
+ return None;
+ }
+ if !valid_tag_name(name) {
+ let failure = Failure::new("tsdoc-malformed-tag-name", TAG_NAME.into());
+ self.error_for_failure(reader, marker, "", failure);
+ return None;
+ }
+ Some((name, reader.extract()))
+ }
+
+ /// The first character of the token at `index`, quoted as JSON.
+ fn first_char(&self, index: usize) -> String {
+ let c = self.comment[self.at(index) as usize..].chars().next().unwrap_or_default();
+ serde_json::to_string(&c).expect("a char serializes")
+ }
+
+ /// A `@param` or `@typeParam` block's name and hyphen.
+ fn param_block(&mut self, reader: &mut Reader, tag: &str, at: u32) {
+ let start = reader.marker();
+ self.spacing(reader);
+ self.jsdoc_type(reader, tag);
+ let bracket = (reader.peek() == Kind::LeftSquare).then(|| {
+ reader.read();
+ reader.extract().start
+ });
+ let name_start = reader.marker();
+ while matches!(reader.peek(), Kind::AsciiWord | Kind::Period | Kind::Dollar) {
+ reader.read();
+ }
+ let name = self.text(name_start..reader.marker());
+ if let Some(why) = invalid_identifier(name) {
+ reader.backtrack(start);
+ let text = match name {
+ "" => format!("The {tag} block should be followed by a parameter name"),
+ _ => format!("The {tag} block should be followed by a valid parameter name: {why}"),
+ };
+ return self.log("tsdoc-param-tag-with-invalid-name", text, at);
+ }
+ reader.extract();
+ if let Some(bracket) = bracket {
+ // A newline follows the name at least, so this reads to the `]` or goes back.
+ let marker = reader.marker();
+ self.value_rest(reader, Kind::LeftSquare, Kind::RightSquare, marker);
+ let text = format!(
+ "The {tag} should not include a JSDoc-style optional name; it must not be enclosed in '[ ]' brackets."
+ );
+ self.log("tsdoc-param-tag-with-invalid-optional-name", text, self.at(bracket));
+ }
+ self.spacing(reader);
+ self.jsdoc_type(reader, tag);
+ if reader.peek() != Kind::Hyphen {
+ let text = format!("The {tag} block should be followed by a parameter name and then a hyphen");
+ return self.log("tsdoc-param-tag-missing-hyphen", text, at);
+ }
+ reader.read();
+ reader.extract();
+ self.spacing(reader);
+ self.jsdoc_type(reader, tag);
+ }
+
+ /// Reports a JSDoc-style `{type}` in a `@param` block, which TSDoc leaves to TypeScript.
+ fn jsdoc_type(&mut self, reader: &mut Reader, tag: &str) {
+ if reader.peek() != Kind::LeftCurly || reader.peek_after() == Kind::AtSign {
+ return;
+ }
+ let start = reader.marker();
+ reader.read();
+ if let Some(sequence) = self.value_rest(reader, Kind::LeftCurly, Kind::RightCurly, start) {
+ let text = format!("The {tag} block should not include a JSDoc-style '{{type}}'");
+ self.log("tsdoc-param-tag-with-invalid-type", text, self.at(sequence.start));
+ self.spacing(reader);
+ }
+ }
+
+ /// The rest of a JSDoc type or default value after its opening `open`, up to the `close` that
+ /// matches it, past nested pairs and quoted strings, or none when it isn't closed.
+ fn value_rest(&mut self, reader: &mut Reader, open: Kind, close: Kind, start: usize) -> Option {
+ let (mut quote, mut depth) = (None, 1);
+ while depth > 0 {
+ let mut kind = reader.peek();
+ match kind {
+ _ if kind == open => depth += usize::from(quote.is_none()),
+ _ if kind == close => depth -= usize::from(quote.is_none()),
+ Kind::Backslash if quote.is_some() => {
+ reader.read();
+ kind = reader.peek();
+ }
+ Kind::DoubleQuote | Kind::SingleQuote | Kind::Backtick => {
+ quote = match quote {
+ None => Some(kind),
+ Some(quoted) if quoted == kind => None,
+ quoted => quoted,
+ };
+ }
+ _ => {}
+ }
+ if kind == Kind::EndOfInput {
+ reader.backtrack(start);
+ return None;
+ }
+ reader.read();
+ }
+ reader.try_extract()
+ }
+
+ /// A backslash escape.
+ fn backslash(&mut self, reader: &mut Reader) {
+ let marker = reader.marker();
+ reader.read();
+ // Every line ends with a newline, so something follows a backslash.
+ if !reader.read().kind.punctuation() {
+ let text = "A backslash can only be used to escape a punctuation character";
+ return self.backtrack_error(reader, marker, "tsdoc-unnecessary-backslash", text.into());
+ }
+ reader.extract();
+ self.sections[self.section] = true;
+ }
+
+ /// An inline tag, `{@name ...}`.
+ fn inline_tag(&mut self, reader: &mut Reader) -> Inline {
+ let marker = reader.marker();
+ reader.read();
+ reader.extract();
+ let at_sign = reader.marker();
+ if reader.peek() != Kind::AtSign {
+ let text = "Expecting a TSDoc tag starting with \"{@\"";
+ self.backtrack_error(reader, marker, "tsdoc-malformed-inline-tag", text.into());
+ return Inline::Error;
+ }
+ reader.read();
+ while reader.peek() == Kind::AsciiWord {
+ reader.read();
+ }
+ let name = self.text(at_sign..reader.marker());
+ if name == "@" {
+ let text = "Expecting a TSDoc inline tag name after the \"{@\" characters";
+ let failure = Failure::new("tsdoc-malformed-inline-tag", text.into());
+ return self.error_range_for_failure(reader, marker, at_sign, "", failure);
+ }
+ if !valid_tag_name(name) {
+ let failure = Failure::new("tsdoc-malformed-tag-name", TAG_NAME.into());
+ return self.error_range_for_failure(reader, marker, at_sign, "", failure);
+ }
+ let name_sequence = reader.extract();
+ if self.spacing(reader).is_none() && reader.peek() != Kind::RightCurly {
+ let bad = self.first_char(reader.marker());
+ let text = format!("The character {bad} cannot appear after the TSDoc tag name; expecting a space");
+ let failure = Failure::new("tsdoc-characters-after-inline-tag", text);
+ return self.error_range_for_failure(reader, marker, at_sign, "", failure);
+ }
+ loop {
+ match reader.peek() {
+ Kind::EndOfInput => {
+ let text = "The TSDoc inline tag name is missing its closing \"}\"";
+ self.error_range(reader, marker, at_sign, "tsdoc-inline-tag-missing-right-brace", text.into());
+ return Inline::Error;
+ }
+ Kind::Backslash => {
+ reader.read();
+ if !reader.peek().punctuation() {
+ let text = "A backslash can only be used to escape a punctuation character";
+ let failure = Failure::new("tsdoc-unnecessary-backslash", text.into());
+ let prefix = "Error reading inline TSDoc tag: ";
+ return self.error_range_for_failure(reader, marker, at_sign, prefix, failure);
+ }
+ reader.read();
+ }
+ Kind::LeftCurly => {
+ let text =
+ "The \"{\" character must be escaped with a backslash when used inside a TSDoc inline tag";
+ let failure = Failure::new("tsdoc-inline-tag-unescaped-brace", text.into());
+ return self.error_range_for_failure(reader, marker, at_sign, "", failure);
+ }
+ Kind::RightCurly => break,
+ _ => {
+ reader.read();
+ }
+ }
+ }
+ let content = reader.try_extract();
+ reader.read();
+ reader.extract();
+ let tag = if name.eq_ignore_ascii_case("@inheritDoc") {
+ self.inherit_doc_tag(content, name_sequence.start)
+ } else if name.eq_ignore_ascii_case("@link") {
+ self.link_tag(content, name_sequence.start)
+ } else {
+ Inline::Tag
+ };
+ let at = self.at(name_sequence.start);
+ let definition = self.config.tag(name);
+ self.validate_tag(definition, name, true, at);
+ // A second `{@inheritDoc}` has a message of its own.
+ let inline = definition.filter(|d| d.kind == SyntaxKind::Inline && !name.eq_ignore_ascii_case("@inheritDoc"));
+ if let Some(definition) = inline {
+ self.used(definition, name, at);
+ }
+ tag
+ }
+
+ /// Reads spaces and line breaks, if there are any.
+ fn spacing(&mut self, reader: &mut Reader) -> Option {
+ while matches!(reader.peek(), Kind::Spacing | Kind::Newline) {
+ reader.read();
+ }
+ reader.try_extract()
+ }
+
+ /// Reports the token it's at as a mistake, and reads past it.
+ fn error(&mut self, reader: &mut Reader, id: &'static str, text: String) {
+ self.backtrack_error(reader, reader.marker(), id, text);
+ }
+
+ /// Goes back to `marker`, and reports the token there as a mistake.
+ fn backtrack_error(&mut self, reader: &mut Reader, marker: usize, id: &'static str, text: String) {
+ self.error_range(reader, marker, marker, id, text);
+ }
+
+ /// Goes back to `start` and reads past `end`, reporting a mistake.
+ fn error_range(&mut self, reader: &mut Reader, start: usize, end: usize, id: &'static str, text: String) {
+ self.error_range_for_failure(reader, start, end, "", Failure::new(id, text));
+ }
+
+ /// Goes back to `start` and reads past `end`, reporting `failure` there, where
+ /// `@microsoft/tsdoc` reports it: at the start of the text it skips.
+ fn error_range_for_failure(
+ &mut self,
+ reader: &mut Reader,
+ start: usize,
+ end: usize,
+ prefix: &str,
+ failure: Failure,
+ ) -> Inline {
+ // Every caller's `end` is a token it has seen, so never the end of the input.
+ reader.backtrack(start);
+ while reader.marker() <= end {
+ reader.read();
+ }
+ reader.extract();
+ self.log(failure.id, format!("{prefix}{}", failure.text), self.at(start));
+ Inline::Error
+ }
+
+ /// Goes back to `marker` and reads one token, reporting `failure`.
+ fn error_for_failure(&mut self, reader: &mut Reader, marker: usize, prefix: &str, failure: Failure) {
+ self.error_range_for_failure(reader, marker, marker, prefix, failure);
+ }
+}
+
+/// What a tag name that isn't valid is told.
+const TAG_NAME: &str = "A TSDoc tag name must start with a letter and contain only letters and numbers";
+
+/// Whether `name` is a valid tag name: `@`, a letter, then letters and digits.
+pub(super) fn valid_tag_name(name: &str) -> bool {
+ let mut chars = name.chars();
+ chars.next() == Some('@')
+ && chars.next().is_some_and(|c| c.is_ascii_alphabetic())
+ && chars.all(|c| c.is_ascii_alphanumeric())
+}
+
+/// Why `identifier` isn't a valid unquoted identifier, if it isn't.
+fn invalid_identifier(identifier: &str) -> Option<&'static str> {
+ if identifier.is_empty() {
+ Some("The identifier cannot be an empty string")
+ } else if identifier.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) {
+ Some("The identifier cannot non-word characters")
+ } else if identifier.starts_with(|c: char| c.is_ascii_digit()) {
+ Some("The identifier must not start with a number")
+ } else {
+ None
+ }
+}
diff --git a/src/tsdoc/parse/code.rs b/src/tsdoc/parse/code.rs
new file mode 100644
index 0000000..32088c6
--- /dev/null
+++ b/src/tsdoc/parse/code.rs
@@ -0,0 +1,116 @@
+//! Code spans (`` `code` ``) and code fences.
+
+use super::{Failure, Parser, Reader};
+use crate::tsdoc::tokens::Kind;
+
+impl Parser<'_, '_> {
+ /// A code fence: a line of three backticks and a language, code, and a line of three
+ /// backticks.
+ pub(super) fn fenced_code(&mut self, reader: &mut Reader) {
+ let start = reader.marker();
+ let opening = start + 2;
+ if !matches!(reader.previous(), Kind::Newline | Kind::EndOfInput) {
+ let text = "The opening backtick for a code fence must appear at the start of the line";
+ return self.error_range(reader, start, opening, "tsdoc-code-fence-opening-indent", text.into());
+ }
+ for _ in 0..3 {
+ reader.read();
+ }
+ reader.extract();
+ // The language, to the end of the line. Every line ends with a newline before the end of
+ // the input, here and after the closing backticks.
+ loop {
+ match reader.peek() {
+ Kind::Newline => {
+ reader.read();
+ break;
+ }
+ Kind::Backtick => {
+ let text = "The language specifier cannot contain backtick characters";
+ let failure = Failure::new("tsdoc-code-fence-specifier-syntax", text.into());
+ self.error_range_for_failure(reader, start, opening, "Error parsing code fence: ", failure);
+ return;
+ }
+ _ => _ = reader.read(),
+ }
+ }
+ reader.extract();
+ let code = reader.marker();
+ let (before, code_end) = loop {
+ match reader.peek() {
+ Kind::EndOfInput => {
+ let failure =
+ Failure::new("tsdoc-code-fence-missing-delimiter", "Missing closing delimiter".into());
+ self.error_range_for_failure(reader, start, opening, "Error parsing code fence: ", failure);
+ return;
+ }
+ Kind::Newline => {
+ let mut before = reader.read();
+ let code_end = reader.marker();
+ while reader.peek() == Kind::Spacing {
+ before = reader.read();
+ }
+ let mut backticks = 0;
+ while backticks < 3 && reader.peek() == Kind::Backtick {
+ reader.read();
+ backticks += 1;
+ }
+ if backticks == 3 {
+ break (before, code_end);
+ }
+ }
+ _ => _ = reader.read(),
+ }
+ };
+ if before.kind != Kind::Newline {
+ let text = "The closing delimiter for a code fence must not be indented";
+ self.log("tsdoc-code-fence-closing-indent", text.into(), before.start);
+ }
+ reader.extract();
+ self.sections[self.section] |=
+ self.tokens[code..code_end].iter().any(|token| !matches!(token.kind, Kind::Spacing | Kind::Newline));
+ // The rest of the closing line.
+ loop {
+ match reader.peek() {
+ Kind::Spacing => _ = reader.read(),
+ Kind::Newline => {
+ reader.read();
+ break;
+ }
+ _ => {
+ let text = "Unexpected characters after closing delimiter for code fence";
+ self.log("tsdoc-code-fence-closing-syntax", text.into(), self.at(reader.marker()));
+ break;
+ }
+ }
+ }
+ reader.try_extract();
+ }
+
+ /// A code span.
+ pub(super) fn code_span(&mut self, reader: &mut Reader) {
+ let marker = reader.marker();
+ reader.read();
+ reader.extract();
+ loop {
+ match reader.peek() {
+ Kind::Backtick if reader.is_empty() => {
+ let text = "A code span must contain at least one character between the backticks";
+ return self.error_range(reader, marker, marker + 1, "tsdoc-code-span-empty", text.into());
+ }
+ Kind::Backtick => {
+ let code = reader.extract();
+ self.sections[self.section] |= self.tokens[code].iter().any(|token| token.kind != Kind::Spacing);
+ reader.read();
+ reader.extract();
+ return;
+ }
+ Kind::EndOfInput | Kind::Newline => {
+ let text = "The code span is missing its closing backtick";
+ return self.backtrack_error(reader, marker, "tsdoc-code-span-missing-delimiter", text.into());
+ }
+ _ => _ = reader.read(),
+ }
+ }
+ }
+}
diff --git a/src/tsdoc/parse/html.rs b/src/tsdoc/parse/html.rs
new file mode 100644
index 0000000..69069cd
--- /dev/null
+++ b/src/tsdoc/parse/html.rs
@@ -0,0 +1,119 @@
+//! HTML tags: ``, `
` and ``.
+
+use super::{Failure, Parser, Reader};
+use crate::tsdoc::tokens::Kind;
+
+impl Parser<'_, '_> {
+ /// An HTML start tag, or a self-closing one.
+ pub(super) fn html_start_tag(&mut self, reader: &mut Reader) {
+ let marker = reader.marker();
+ reader.read();
+ reader.extract();
+ if let Err(failure) = self.html_name(reader) {
+ return self.error_for_failure(reader, marker, "Invalid HTML element: ", failure);
+ }
+ self.spacing(reader);
+ while reader.peek() == Kind::AsciiWord {
+ if let Err(failure) = self.html_attribute(reader) {
+ return self.error_for_failure(reader, marker, "The HTML element has an invalid attribute: ", failure);
+ }
+ }
+ if reader.peek() == Kind::Slash {
+ reader.read();
+ }
+ if reader.peek() != Kind::GreaterThan {
+ let text = "Expecting an attribute or \">\" or \"/>\"";
+ let failure = Failure::new("tsdoc-html-tag-missing-greater-than", text.into());
+ return self.error_for_failure(reader, marker, "The HTML tag has invalid syntax: ", failure);
+ }
+ reader.read();
+ reader.extract();
+ }
+
+ /// An attribute, `name="value"`.
+ fn html_attribute(&mut self, reader: &mut Reader) -> Result<(), Failure> {
+ self.html_name(reader)?;
+ self.spacing(reader);
+ if reader.peek() != Kind::Equals {
+ let text = "Expecting \"=\" after HTML attribute name";
+ return Err(Failure::new("tsdoc-html-tag-missing-equals", text.into()));
+ }
+ reader.read();
+ reader.extract();
+ self.spacing(reader);
+ self.html_string(reader)?;
+ reader.extract();
+ self.spacing(reader);
+ Ok(())
+ }
+
+ /// An attribute's value, in single or double quotes.
+ fn html_string(&mut self, reader: &mut Reader) -> Result<(), Failure> {
+ let quote = reader.peek();
+ if !matches!(quote, Kind::DoubleQuote | Kind::SingleQuote) {
+ let text = "Expecting an HTML string starting with a single-quote or double-quote character";
+ return Err(Failure::new("tsdoc-html-tag-missing-string", text.into()));
+ }
+ reader.read();
+ while reader.peek() != quote {
+ if matches!(reader.peek(), Kind::EndOfInput | Kind::Newline) {
+ let text = "The HTML string is missing its closing quote";
+ return Err(Failure::new("tsdoc-html-string-missing-quote", text.into()));
+ }
+ reader.read();
+ }
+ reader.read();
+ if reader.peek() == Kind::AsciiWord {
+ let text = "The next character after a closing quote must be spacing or punctuation";
+ return Err(Failure::new("tsdoc-text-after-html-string", text.into()));
+ }
+ Ok(())
+ }
+
+ /// An HTML end tag, ``.
+ pub(super) fn html_end_tag(&mut self, reader: &mut Reader) {
+ let marker = reader.marker();
+ reader.read();
+ reader.read();
+ reader.extract();
+ if let Err(failure) = self.html_name(reader) {
+ return self.error_for_failure(reader, marker, "Expecting an HTML element name: ", failure);
+ }
+ self.spacing(reader);
+ if reader.peek() != Kind::GreaterThan {
+ let text = "Expecting a closing \">\" for the HTML tag";
+ let failure = Failure::new("tsdoc-html-tag-missing-greater-than", text.into());
+ return self.error_for_failure(reader, marker, "", failure);
+ }
+ reader.read();
+ reader.extract();
+ }
+
+ /// An element's or attribute's name, which has to be one the configuration allows when it
+ /// says which it does.
+ fn html_name(&mut self, reader: &mut Reader) -> Result<(), Failure> {
+ if reader.peek() == Kind::Spacing {
+ return Err(Failure::new("tsdoc-malformed-html-name", "A space is not allowed here".into()));
+ }
+ while matches!(reader.peek(), Kind::Hyphen | Kind::Period | Kind::AsciiWord) {
+ reader.read();
+ }
+ let Some(name) = reader.try_extract() else {
+ return Err(Failure::new("tsdoc-malformed-html-name", "Expecting an HTML name".into()));
+ };
+ let name = self.text(name);
+ let mut chars = name.chars();
+ let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
+ && chars.all(|c| c.is_ascii_alphanumeric() || c == '-');
+ if !valid {
+ let text = "An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens";
+ return Err(Failure::new("tsdoc-malformed-html-name", text.into()));
+ }
+ if self.config.reports_html(name) {
+ let quoted = serde_json::to_string(name).expect("a string serializes");
+ let text = format!("The HTML element name {quoted} is not defined by your TSDoc configuration");
+ return Err(Failure::new("tsdoc-unsupported-html-name", text));
+ }
+ Ok(())
+ }
+}
diff --git a/src/tsdoc/parse/link.rs b/src/tsdoc/parse/link.rs
new file mode 100644
index 0000000..764b2d2
--- /dev/null
+++ b/src/tsdoc/parse/link.rs
@@ -0,0 +1,413 @@
+//! `{@link}` and `{@inheritDoc}`, and the declaration references they name:
+//! `@scope/pkg/path#Class.(member:static)`.
+
+use super::{Inline, Parser, Reader, Sequence, invalid_identifier};
+use crate::tsdoc::tokens::Kind;
+
+/// TSDoc's system selectors, which an unquoted identifier can't be.
+const SYSTEM_SELECTORS: [&str; 10] =
+ ["instance", "static", "constructor", "class", "enum", "function", "interface", "namespace", "type", "variable"];
+
+impl Parser<'_, '_> {
+ /// The content of `{@inheritDoc}`, whose name starts at token `name`: nothing, or a
+ /// declaration reference.
+ pub(super) fn inherit_doc_tag(&mut self, content: Option, name: usize) -> Inline {
+ let Some(content) = content else { return Inline::InheritDoc };
+ let mut reader = Reader::new(self.tokens, content);
+ if self.declaration_reference(&mut reader, name).is_none() {
+ return Inline::Error;
+ }
+ if reader.peek() != Kind::EndOfInput {
+ let token = reader.read();
+ let text = "Unexpected character after declaration reference";
+ self.log("tsdoc-inheritdoc-tag-syntax", text.into(), token.start);
+ return Inline::Error;
+ }
+ Inline::InheritDoc
+ }
+
+ /// The content of `{@link}`, whose name starts at token `name`: a URL or a declaration
+ /// reference, and text after a `|`.
+ pub(super) fn link_tag(&mut self, content: Option, name: usize) -> Inline {
+ let Some(content) = content else {
+ self.log("tsdoc-link-tag-empty", "The @link tag content is missing".into(), self.at(name));
+ return Inline::Error;
+ };
+ let mut reader = Reader::new(self.tokens, content);
+ let slashes = |reader: &Reader| reader.peek() == Kind::Slash && reader.peek_after() == Kind::Slash;
+ let mut url = slashes(&reader);
+ let marker = reader.marker();
+ while !url {
+ match reader.peek() {
+ Kind::AsciiWord | Kind::Period | Kind::Hyphen | Kind::Plus => _ = reader.read(),
+ Kind::Colon => {
+ reader.read();
+ url = slashes(&reader);
+ break;
+ }
+ _ => break,
+ }
+ }
+ reader.backtrack(marker);
+ let destination = if url { self.url(&mut reader) } else { self.declaration_reference(&mut reader, name) };
+ if destination.is_none() {
+ return Inline::Error;
+ }
+ match reader.peek() {
+ Kind::Pipe => {
+ reader.read();
+ reader.extract();
+ self.spacing(&mut reader);
+ let mut text = false;
+ loop {
+ match reader.peek() {
+ Kind::EndOfInput => return Inline::Link(text),
+ Kind::Pipe | Kind::LeftCurly => {
+ let token = reader.read();
+ let c = &self.comment[token.start as usize..token.end as usize];
+ let text =
+ format!("The \"{c}\" character may not be used in the link text without escaping it");
+ // At the link's text, as far as it goes.
+ let at = self.at(reader.extract().start);
+ self.log("tsdoc-link-tag-unescaped-text", text, at);
+ return Inline::Error;
+ }
+ Kind::Spacing | Kind::Newline => _ = reader.read(),
+ _ => {
+ text = true;
+ reader.read();
+ }
+ }
+ }
+ }
+ Kind::EndOfInput => Inline::Link(false),
+ _ => {
+ let token = reader.read();
+ let text = "Unexpected character after link destination";
+ self.log("tsdoc-link-tag-destination-syntax", text.into(), token.start);
+ Inline::Error
+ }
+ }
+ }
+
+ /// A URL, which has to start with a scheme and `://`, and the spacing after it.
+ fn url(&mut self, reader: &mut Reader) -> Option<()> {
+ while !matches!(reader.peek(), Kind::Spacing | Kind::Newline | Kind::EndOfInput | Kind::Pipe | Kind::RightCurly)
+ {
+ reader.read();
+ }
+ let sequence = reader.extract();
+ let at = self.at(sequence.start);
+ let url = self.text(sequence);
+ let scheme = url.split_once("://").map(|(scheme, _)| scheme).filter(|scheme| {
+ let mut chars = scheme.chars();
+ chars.next().is_some_and(|c| c.is_ascii_alphabetic()) && chars.all(|c| c.is_ascii_alphanumeric())
+ });
+ let why = match scheme {
+ None => {
+ "An @link URL must begin with a scheme comprised only of letters and numbers followed by \"://\". (For general URLs, use an HTML \"\" tag instead.)"
+ }
+ Some(scheme) if url.len() == scheme.len() + 3 => {
+ "An @link URL must have at least one character after \"://\""
+ }
+ Some(_) => {
+ self.spacing(reader);
+ return Some(());
+ }
+ };
+ self.log("tsdoc-link-tag-invalid-url", why.into(), at);
+ None
+ }
+
+ /// A declaration reference: a package name and import path, then `#`, then member references,
+ /// either part optional. A mistake in it is reported at the token `context` when it has no
+ /// place of its own.
+ fn declaration_reference(&mut self, reader: &mut Reader, context: usize) -> Option<()> {
+ let marker = reader.marker();
+ let (mut hash, mut import_characters, mut looking) = (false, false, true);
+ loop {
+ match reader.peek() {
+ Kind::Pound => {
+ hash = true;
+ break;
+ }
+ Kind::Slash | Kind::AtSign => {
+ import_characters |= looking;
+ reader.read();
+ }
+ Kind::AsciiWord | Kind::Period | Kind::Hyphen => _ = reader.read(),
+ Kind::DoubleQuote
+ | Kind::EndOfInput
+ | Kind::LeftCurly
+ | Kind::LeftParen
+ | Kind::LeftSquare
+ | Kind::Newline
+ | Kind::Pipe
+ | Kind::RightCurly
+ | Kind::RightParen
+ | Kind::RightSquare
+ | Kind::SingleQuote
+ | Kind::Spacing => break,
+ _ => {
+ looking = false;
+ reader.read();
+ }
+ }
+ }
+ if !hash && import_characters {
+ let start = reader.extract().start;
+ let text = "The declaration reference appears to contain a package name or import path, but it is missing the \"#\" delimiter";
+ self.log("tsdoc-reference-missing-hash", text.into(), self.at(start));
+ return None;
+ }
+ reader.backtrack(marker);
+ let (mut package, mut import) = (false, false);
+ if hash {
+ if reader.peek() != Kind::Period {
+ let scoped = reader.peek() == Kind::AtSign;
+ let mut finished_scope = false;
+ loop {
+ match reader.peek() {
+ Kind::Slash if scoped && !finished_scope => {
+ reader.read();
+ finished_scope = true;
+ }
+ Kind::Slash | Kind::Pound => break,
+ _ => _ = reader.read(),
+ }
+ }
+ if let Some(sequence) = reader.try_extract() {
+ package = true;
+ let name = self.text(sequence.clone());
+ if !valid_package_name(name) {
+ let quoted = serde_json::to_string(name).expect("a string serializes");
+ let text = format!("The package name {quoted} is not a valid package name");
+ self.log("tsdoc-reference-malformed-package-name", text, self.at(sequence.start));
+ return None;
+ }
+ }
+ }
+ while reader.peek() != Kind::Pound {
+ reader.read();
+ }
+ if let Some(sequence) = reader.try_extract() {
+ import = true;
+ let path = self.text(sequence.clone());
+ let why = if path.contains("//") {
+ Some("An import path must not contain \"//\"")
+ } else if path.ends_with('/') {
+ Some("An import path must not end with \"/\"")
+ } else if !package && path.starts_with('/') {
+ Some("An import path must not start with \"/\" unless prefixed by a package name")
+ } else {
+ None
+ };
+ if let Some(why) = why {
+ self.log("tsdoc-reference-malformed-import-path", why.into(), self.at(sequence.start));
+ return None;
+ }
+ }
+ reader.read();
+ let hash = reader.extract().start;
+ self.spacing(reader);
+ if !package && !import {
+ let text = "The hash character must be preceded by a package name or import path";
+ self.log("tsdoc-reference-hash-syntax", text.into(), self.at(hash));
+ return None;
+ }
+ }
+ let mut members = 0;
+ while matches!(
+ reader.peek(),
+ Kind::Period | Kind::LeftParen | Kind::AsciiWord | Kind::Colon | Kind::LeftSquare | Kind::DoubleQuote
+ ) {
+ self.member_reference(reader, members > 0, context)?;
+ members += 1;
+ }
+ if !package && !import && members == 0 {
+ self.log("tsdoc-missing-reference", "Expecting a declaration reference".into(), self.at(context));
+ return None;
+ }
+ Some(())
+ }
+
+ /// One member reference: `.name`, `.(name:selector)` or `[symbol]`.
+ fn member_reference(&mut self, reader: &mut Reader, dot: bool, context: usize) -> Option<()> {
+ if dot {
+ if reader.peek() != Kind::Period {
+ let text = "Expecting a period before the next component of a declaration reference";
+ self.log("tsdoc-reference-missing-dot", text.into(), self.at(context));
+ return None;
+ }
+ reader.read();
+ reader.extract();
+ self.spacing(reader);
+ }
+ let paren = (reader.peek() == Kind::LeftParen).then(|| {
+ reader.read();
+ reader.extract().start
+ });
+ if paren.is_some() {
+ self.spacing(reader);
+ }
+ if reader.peek() == Kind::LeftSquare {
+ self.member_symbol(reader)?;
+ } else {
+ self.member_identifier(reader, context)?;
+ }
+ self.spacing(reader);
+ if reader.peek() == Kind::Colon {
+ reader.read();
+ let colon = reader.extract().start;
+ self.spacing(reader);
+ if paren.is_none() {
+ let text = "Syntax error in declaration reference: the member selector must be enclosed in parentheses";
+ self.log("tsdoc-reference-selector-missing-parens", text.into(), self.at(colon));
+ return None;
+ }
+ self.member_selector(reader, colon)?;
+ self.spacing(reader);
+ } else if let Some(paren) = paren {
+ let text = "Expecting a colon after the identifier because the expression is in parentheses";
+ self.log("tsdoc-reference-missing-colon", text.into(), self.at(paren));
+ return None;
+ }
+ if let Some(paren) = paren {
+ if reader.peek() != Kind::RightParen {
+ self.log(
+ "tsdoc-reference-missing-right-paren",
+ "Expecting a matching right parenthesis".into(),
+ self.at(paren),
+ );
+ return None;
+ }
+ reader.read();
+ reader.extract();
+ self.spacing(reader);
+ }
+ Some(())
+ }
+
+ /// A symbol, `[declaration reference]`.
+ fn member_symbol(&mut self, reader: &mut Reader) -> Option<()> {
+ reader.read();
+ let bracket = reader.extract().start;
+ self.spacing(reader);
+ if self.declaration_reference(reader, bracket).is_none() {
+ let text = "Missing declaration reference in symbol reference";
+ self.log("tsdoc-reference-symbol-syntax", text.into(), self.at(bracket));
+ return None;
+ }
+ if reader.peek() != Kind::RightSquare {
+ let text = "Missing closing square bracket for symbol reference";
+ self.log("tsdoc-reference-missing-right-bracket", text.into(), self.at(bracket));
+ return None;
+ }
+ reader.read();
+ reader.extract();
+ Some(())
+ }
+
+ /// A member's name, plain or in double quotes.
+ fn member_identifier(&mut self, reader: &mut Reader, context: usize) -> Option<()> {
+ if reader.peek() == Kind::DoubleQuote {
+ reader.read();
+ let quote = reader.extract().start;
+ while reader.peek() != Kind::DoubleQuote {
+ if reader.peek() == Kind::EndOfInput {
+ let text = "Unexpected end of input inside quoted member identifier";
+ self.log("tsdoc-reference-missing-quote", text.into(), self.at(quote));
+ return None;
+ }
+ reader.read();
+ }
+ if reader.is_empty() {
+ self.log(
+ "tsdoc-reference-empty-identifier",
+ "The quoted identifier cannot be empty".into(),
+ self.at(quote),
+ );
+ return None;
+ }
+ reader.extract();
+ reader.read();
+ reader.extract();
+ return Some(());
+ }
+ while matches!(reader.peek(), Kind::AsciiWord | Kind::Dollar) {
+ reader.read();
+ }
+ let Some(sequence) = reader.try_extract() else {
+ let text = "Syntax error in declaration reference: expecting a member identifier";
+ self.log("tsdoc-reference-missing-identifier", text.into(), self.at(context));
+ return None;
+ };
+ let identifier = self.text(sequence.clone());
+ let why = match invalid_identifier(identifier) {
+ Some(why) => why.to_owned(),
+ None if SYSTEM_SELECTORS.contains(&identifier) => {
+ format!("The identifier \"{identifier}\" must be quoted because it is a TSDoc system selector name")
+ }
+ None => return Some(()),
+ };
+ self.log("tsdoc-reference-unquoted-identifier", why, self.at(sequence.start));
+ None
+ }
+
+ /// A selector after a member's `:`, at token `colon`: a system selector, a label or an index.
+ fn member_selector(&mut self, reader: &mut Reader, colon: usize) -> Option<()> {
+ if reader.peek() != Kind::AsciiWord {
+ self.log(
+ "tsdoc-reference-missing-label",
+ "Expecting a selector label after the colon".into(),
+ self.at(colon),
+ );
+ // `@microsoft/tsdoc` reads the next token as the selector whatever it is, and throws at
+ // the end of the tag's content; this stops there.
+ if reader.peek() == Kind::EndOfInput {
+ return None;
+ }
+ }
+ reader.read();
+ let sequence = reader.extract();
+ let selector = self.text(sequence.clone());
+ let Some(why) = selector_error(selector) else { return Some(()) };
+ self.log("tsdoc-reference-selector-syntax", why, self.at(sequence.start));
+ None
+ }
+}
+
+/// Whether `name` is a valid npm package name, scoped or not.
+fn valid_package_name(name: &str) -> bool {
+ let part = |part: &str| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c));
+ match name.strip_prefix('@').map(|scoped| scoped.split_once('/')) {
+ Some(Some((scope, name))) => part(scope) && part(name),
+ Some(None) => false,
+ None => part(name),
+ }
+}
+
+/// What's wrong with `selector`, if something is: a selector is a system selector (`static`), a
+/// label in upper case (`LABEL`), or an index (`2`).
+fn selector_error(selector: &str) -> Option {
+ // A selector is a token, so it has a character.
+ let first = selector.chars().next().unwrap_or_default();
+ let rest = &selector[first.len_utf8()..];
+ if first.is_ascii_digit() {
+ let index = first != '0' && rest.chars().all(|c| c.is_ascii_digit());
+ return (!index).then(|| "If the selector begins with a number, it must be a positive integer value".into());
+ }
+ if first.is_ascii_uppercase() || first == '_' {
+ let label = !rest.is_empty() && rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
+ let text = "A label selector must be comprised of upper case letters, numbers, and underscores and must not start with a number";
+ return (!label).then(|| text.into());
+ }
+ if SYSTEM_SELECTORS.contains(&selector) {
+ return None;
+ }
+ if selector.chars().all(|c| c.is_ascii_lowercase()) {
+ let quoted = serde_json::to_string(selector).expect("a string serializes");
+ return Some(format!("The selector {quoted} is not a recognized TSDoc system selector name"));
+ }
+ Some("Invalid syntax for selector".into())
+}
diff --git a/src/tsdoc/tests.rs b/src/tsdoc/tests.rs
new file mode 100644
index 0000000..56f3f9c
--- /dev/null
+++ b/src/tsdoc/tests.rs
@@ -0,0 +1,57 @@
+use std::fs;
+
+use serde::Deserialize;
+use serde_json::{Map, Value, json};
+
+use super::*;
+
+/// An entry of tests/fixtures/tsdoc/corpus.json, which tools/tsdoc-oracle.sh fills in with what
+/// `@microsoft/tsdoc` finds.
+#[derive(Deserialize)]
+struct Entry {
+ comment: String,
+ #[serde(default)]
+ config: Option