diff --git a/README.md b/README.md index 11ad819..9a4232b 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ Each finding prints to stderr as `docov: path:line:column: rule-id: message`, an - `valid-link`: each `{@link}`, `{@linkcode}` and `{@linkplain}` is closed, has a target and no unescaped `{`, and each `@see` has text. A target that's a name, or a namepath like `A#b` by its first part, is declared or imported in the same file or is a JavaScript, browser or Node.js global. Names in other files aren't looked up, nor are TSDoc's declaration references (`(A:constructor)`, `A.(b:static)`, `@scope/pkg#A`). With `"syntax": "tsdoc"`, the `A` in `A#b` is a package's name, which isn't looked up either. - `summary-sentence`: a description's first sentence is at most `maxLength` characters (120) and, with `period` (on by default), ends with `.`, `!` or `?`. - `doc-markdown`: a description's words that look like code (`snake_case`, `a::b`, `camelCase`) are in backticks, and its lists have no lazy continuation lines. `allowlist` adds words to the default list (`JavaScript`, `GitHub` and others). +- `tsdoc-tags`, `tsdoc-params`, `tsdoc-inline-tags`, `tsdoc-escapes`, `tsdoc-code`, `tsdoc-html` and `tsdoc-links`: TSDoc's grammar, as `@microsoft/tsdoc` reads it, with its message ids (`tsdoc-param-tag-missing-hyphen: …`) as `eslint-plugin-tsdoc` reports them. Tags are checked against a tsdoc.json: the one `--tsdoc-config` or `tsdocConfig` names, or with `"syntax": "tsdoc"` each file's nearest, found the way `@microsoft/tsdoc-config` finds it. `docov --print-schema` describes each. `const f = () => …` is a variable to docov, so the checks on parameters, returns and throws don't look at it. 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>, + #[serde(default)] + messages: Vec, + #[serde(default)] + throws: Option, +} + +#[derive(Debug, PartialEq, Deserialize)] +struct Expected { + id: String, + text: String, + at: u32, +} + +/// The configuration a tsdoc.json with `keys` sets up. +fn configuration(keys: &Map) -> Configuration { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(config::FILE_NAME); + let mut file = keys.clone(); + file.insert("$schema".into(), json!(config::SCHEMA)); + fs::write(&path, Value::Object(file).to_string()).unwrap(); + config::load(&path).unwrap().0 +} + +#[test] +fn messages_match_microsoft_tsdoc() { + let corpus = include_str!("../../tests/fixtures/tsdoc/corpus.json"); + let entries: Vec = serde_json::from_str(corpus).unwrap(); + let mut wrong = Vec::new(); + for entry in entries.iter().filter(|entry| entry.throws.is_none()) { + let configuration = entry.config.as_ref().map(configuration).unwrap_or_default(); + let parsed = parse(&entry.comment, &configuration); + let found: Vec = parsed + .messages + .into_iter() + .filter(|m| m.id.starts_with("tsdoc-")) + .map(|m| Expected { id: m.id.into(), text: m.text, at: m.at }) + .collect(); + if found != entry.messages { + wrong.push(format!("{:?}\n tsdoc: {:?}\n docov: {found:?}", entry.comment, entry.messages)); + } + } + assert!(wrong.is_empty(), "{} of {} differ:\n{}", wrong.len(), entries.len(), wrong.join("\n")); +} diff --git a/src/tsdoc/tokens.rs b/src/tsdoc/tokens.rs new file mode 100644 index 0000000..d0df96c --- /dev/null +++ b/src/tsdoc/tokens.rs @@ -0,0 +1,177 @@ +//! A `/** */` comment's tokens: its lines past each one's `*`, as `@microsoft/tsdoc`'s +//! LineExtractor finds them, split the way its Tokenizer splits them. + +/// What a token is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + /// Past the last line. Only the last token is one. + EndOfInput, + /// The end of a line. + Newline, + /// Spaces and tabs. + Spacing, + /// ASCII letters, digits and `_`. + AsciiWord, + /// A punctuation character no other kind names. + OtherPunctuation, + /// Anything else, such as other letters. + Other, + Backslash, + LessThan, + GreaterThan, + Equals, + SingleQuote, + DoubleQuote, + Slash, + Hyphen, + AtSign, + LeftCurly, + RightCurly, + Backtick, + Period, + Colon, + Comma, + LeftSquare, + RightSquare, + Pipe, + LeftParen, + RightParen, + Pound, + Plus, + Dollar, +} + +impl Kind { + /// The kind of `c`'s token. + fn of(c: char) -> Self { + match c { + ' ' | '\t' => Self::Spacing, + 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' => Self::AsciiWord, + '\\' => Self::Backslash, + '<' => Self::LessThan, + '>' => Self::GreaterThan, + '=' => Self::Equals, + '\'' => Self::SingleQuote, + '"' => Self::DoubleQuote, + '/' => Self::Slash, + '-' => Self::Hyphen, + '@' => Self::AtSign, + '{' => Self::LeftCurly, + '}' => Self::RightCurly, + '`' => Self::Backtick, + '.' => Self::Period, + ':' => Self::Colon, + ',' => Self::Comma, + '[' => Self::LeftSquare, + ']' => Self::RightSquare, + '|' => Self::Pipe, + '(' => Self::LeftParen, + ')' => Self::RightParen, + '#' => Self::Pound, + '+' => Self::Plus, + '$' => Self::Dollar, + c if c.is_ascii_punctuation() => Self::OtherPunctuation, + _ => Self::Other, + } + } + + /// Whether a run of these characters is one token. + fn runs(self) -> bool { + matches!(self, Self::Spacing | Self::AsciiWord | Self::Other) + } + + /// Whether it's CommonMark punctuation, which a backslash escapes. + pub(super) fn punctuation(self) -> bool { + !matches!(self, Self::EndOfInput | Self::Newline | Self::Spacing | Self::AsciiWord | Self::Other) + } +} + +/// A token, at byte offsets into the comment. +#[derive(Clone, Copy, Debug)] +pub(super) struct Token { + pub kind: Kind, + pub start: u32, + pub end: u32, +} + +/// The tokens of `comment`, a whole `/** */` comment: each line's, then a newline, and an +/// end-of-input token last. +pub(super) fn tokens(comment: &str) -> Vec { + let mut tokens = Vec::new(); + let mut last = 0; + for (start, end) in lines(comment) { + let mut run: Option<(Kind, usize)> = None; + for (at, c) in comment[start..end].char_indices() { + let (kind, at) = (Kind::of(c), start + at); + match run { + Some((same, _)) if same == kind && kind.runs() => {} + _ => { + if let Some((kind, from)) = run { + tokens.push(Token { kind, start: from as u32, end: at as u32 }); + } + run = Some((kind, at)); + } + } + } + if let Some((kind, from)) = run { + tokens.push(Token { kind, start: from as u32, end: end as u32 }); + } + tokens.push(Token { kind: Kind::Newline, start: end as u32, end: end as u32 }); + last = end; + } + tokens.push(Token { kind: Kind::EndOfInput, start: last as u32, end: last as u32 }); + tokens +} + +/// Where each line of `comment`'s text starts and ends: past the `/**` or a line's leading `*` +/// and the one space after it, up to the line's last character that isn't a space. A first line +/// with nothing on it isn't one, and a line without a `*` keeps its leading spaces. +fn lines(comment: &str) -> Vec<(usize, usize)> { + let bytes = comment.as_bytes(); + let space_at = |at: usize| usize::from(bytes.get(at) == Some(&b' ')); + let mut lines = Vec::new(); + // Past `/**` and a space after it. + let mut start = 3 + space_at(3); + let (mut end, mut first, mut advancing) = (start, true, false); + let skip = start; + let mut chars = comment.char_indices().skip_while(|&(at, _)| at < skip).peekable(); + while let Some((at, c)) = chars.next() { + let closes = c == '*' && chars.peek().is_some_and(|&(_, next)| next == '/'); + if advancing { + if c == '*' && !closes { + // A line's leading `*`, and a space after it. + start = at + 1 + space_at(at + 1); + end = start; + advancing = false; + if start > at + 1 { + chars.next(); + } + } else if c == '\n' { + lines.push((at, at)); + start = at + 1; + } else if !closes && !whitespace(c) { + end = at + c.len_utf8(); + advancing = false; + } + } else if c == '\n' { + if !first || end > start { + lines.push((start, end)); + } + (start, end, advancing, first) = (at + 1, at + 1, true, false); + } else if !closes && !whitespace(c) { + end = at + c.len_utf8(); + } + if closes { + if !advancing && end > start { + lines.push((start, end)); + } + break; + } + } + lines +} + +/// Whether `c` is whitespace to JavaScript's `\s`. +fn whitespace(c: char) -> bool { + (c.is_whitespace() && c != '\u{85}') || c == '\u{feff}' +} diff --git a/tests/fixtures/checks/tsdoc-code/fail.ts b/tests/fixtures/checks/tsdoc-code/fail.ts new file mode 100644 index 0000000..d6631c4 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-code/fail.ts @@ -0,0 +1,9 @@ +/** + * Runs `npm test. + * + * @example + * ```sh + * npm test + * ``` + */ +export function runTests(): void {} diff --git a/tests/fixtures/checks/tsdoc-code/pass.ts b/tests/fixtures/checks/tsdoc-code/pass.ts new file mode 100644 index 0000000..63f5c32 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-code/pass.ts @@ -0,0 +1,9 @@ +/** + * Runs `npm test`. + * + * @example + * ```sh + * npm test + * ``` + */ +export function runTests(): void {} diff --git a/tests/fixtures/checks/tsdoc-escapes/fail.ts b/tests/fixtures/checks/tsdoc-escapes/fail.ts new file mode 100644 index 0000000..ffbcd49 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-escapes/fail.ts @@ -0,0 +1,6 @@ +/** + * Formats a user as {name} , or name@host. + */ +export function formatUser(name: string, email: string): string { + return `${name} <${email}>`; +} diff --git a/tests/fixtures/checks/tsdoc-escapes/pass.ts b/tests/fixtures/checks/tsdoc-escapes/pass.ts new file mode 100644 index 0000000..cbf757c --- /dev/null +++ b/tests/fixtures/checks/tsdoc-escapes/pass.ts @@ -0,0 +1,6 @@ +/** + * Formats a user as \{name\} \, or name\@host. + */ +export function formatUser(name: string, email: string): string { + return `${name} <${email}>`; +} diff --git a/tests/fixtures/checks/tsdoc-html/fail.ts b/tests/fixtures/checks/tsdoc-html/fail.ts new file mode 100644 index 0000000..e789406 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-html/fail.ts @@ -0,0 +1,6 @@ +/** + * Renders the banner, in bold. + */ +export function renderBanner(): string { + return "banner"; +} diff --git a/tests/fixtures/checks/tsdoc-html/pass.ts b/tests/fixtures/checks/tsdoc-html/pass.ts new file mode 100644 index 0000000..c74a9ed --- /dev/null +++ b/tests/fixtures/checks/tsdoc-html/pass.ts @@ -0,0 +1,6 @@ +/** + * Renders the banner, in bold. + */ +export function renderBanner(): string { + return "banner"; +} diff --git a/tests/fixtures/checks/tsdoc-inline-tags/fail.ts b/tests/fixtures/checks/tsdoc-inline-tags/fail.ts new file mode 100644 index 0000000..9f68da8 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-inline-tags/fail.ts @@ -0,0 +1,8 @@ +/** + * Parses a date, as {@link Date.parse does. + * + * @returns The time. + */ +export function parseDate(text: string): number { + return Date.parse(text); +} diff --git a/tests/fixtures/checks/tsdoc-inline-tags/pass.ts b/tests/fixtures/checks/tsdoc-inline-tags/pass.ts new file mode 100644 index 0000000..435b1ac --- /dev/null +++ b/tests/fixtures/checks/tsdoc-inline-tags/pass.ts @@ -0,0 +1,8 @@ +/** + * Parses a date, as {@link Date.parse} does. + * + * @returns The time. + */ +export function parseDate(text: string): number { + return Date.parse(text); +} diff --git a/tests/fixtures/checks/tsdoc-links/fail.ts b/tests/fixtures/checks/tsdoc-links/fail.ts new file mode 100644 index 0000000..6160a5c --- /dev/null +++ b/tests/fixtures/checks/tsdoc-links/fail.ts @@ -0,0 +1,6 @@ +/** + * Retries a request, as {@link Client.send the client} does. See {@link www.example.com}. + */ +export function retry(attempts: number): number { + return attempts; +} diff --git a/tests/fixtures/checks/tsdoc-links/pass.ts b/tests/fixtures/checks/tsdoc-links/pass.ts new file mode 100644 index 0000000..bb88f70 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-links/pass.ts @@ -0,0 +1,6 @@ +/** + * Retries a request, as {@link Client.send | the client} does. See {@link https://example.com}. + */ +export function retry(attempts: number): number { + return attempts; +} diff --git a/tests/fixtures/checks/tsdoc-params/fail.ts b/tests/fixtures/checks/tsdoc-params/fail.ts new file mode 100644 index 0000000..b284a97 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-params/fail.ts @@ -0,0 +1,9 @@ +/** + * Joins two paths. + * + * @param {string} base - The folder. + * @param rest The path inside it. + */ +export function join(base: string, rest: string): string { + return `${base}/${rest}`; +} diff --git a/tests/fixtures/checks/tsdoc-params/pass.ts b/tests/fixtures/checks/tsdoc-params/pass.ts new file mode 100644 index 0000000..ed5cb91 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-params/pass.ts @@ -0,0 +1,9 @@ +/** + * Joins two paths. + * + * @param base - The folder. + * @param rest - The path inside it. + */ +export function join(base: string, rest: string): string { + return `${base}/${rest}`; +} diff --git a/tests/fixtures/checks/tsdoc-tags/fail.ts b/tests/fixtures/checks/tsdoc-tags/fail.ts new file mode 100644 index 0000000..bf14126 --- /dev/null +++ b/tests/fixtures/checks/tsdoc-tags/fail.ts @@ -0,0 +1,11 @@ +/** + * Reads the config file. + * + * @remarks Reads it once. + * @remarks And caches it. + * @internal Only the CLI calls it. + * @since 2.0 + */ +export function readConfig(path: string): string { + return path; +} diff --git a/tests/fixtures/checks/tsdoc-tags/pass.ts b/tests/fixtures/checks/tsdoc-tags/pass.ts new file mode 100644 index 0000000..2cd6d8f --- /dev/null +++ b/tests/fixtures/checks/tsdoc-tags/pass.ts @@ -0,0 +1,9 @@ +/** + * Reads the config file. + * + * @remarks Reads it once, and caches it. + * @internal + */ +export function readConfig(path: string): string { + return path; +} 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 * \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/tests/fixtures/tsdoc/parser.ts b/tests/fixtures/tsdoc/parser.ts index 61528f3..15c6360 100644 --- a/tests/fixtures/tsdoc/parser.ts +++ b/tests/fixtures/tsdoc/parser.ts @@ -65,7 +65,7 @@ export class TextParser extends Parser { } /** - * @remarks {@inheritDoc} + * {@inheritDoc} */ override emit(event: string): boolean { return super.emit(event); diff --git a/tests/props.rs b/tests/props.rs index 2981fd5..f90a102 100644 --- a/tests/props.rs +++ b/tests/props.rs @@ -29,6 +29,8 @@ mod fix; mod lsp; #[path = "props/scope_overrides.rs"] mod scope_overrides; +#[path = "props/tsdoc.rs"] +mod tsdoc; #[path = "props/unused.rs"] mod unused; diff --git a/tests/props/lsp.rs b/tests/props/lsp.rs index 7309215..9b0a929 100644 --- a/tests/props/lsp.rs +++ b/tests/props/lsp.rs @@ -292,10 +292,12 @@ fn the_server_says_what_it_can_not_do(tc: TestCase) { client.notify("textDocument/didOpen", json!({ "textDocument": 1 })); assert!(client.logged().starts_with("docov: invalid type")); - // A config that doesn't load, a jsdoc config, baseline or package.json that isn't there. + // A config that doesn't load, a jsdoc config, baseline, tsdoc config or package.json that isn't + // there. let configs = [ (r#"{ "threshold": "x" }"#, "docov.json"), (r#"{ "jsdocConfig": "jsdoc.json" }"#, "jsdoc.json"), + (r#"{ "tsdocConfig": "tsdoc.json", "rules": { "tsdoc-tags": "warn" } }"#, "tsdoc.json"), (r#"{ "baseline": "base.json" }"#, "base.json"), (r#"{ "scope": "public" }"#, "package.json"), (r#"{ "scope": "public", "paths": ["."] }"#, "package.json"), diff --git a/tests/props/tsdoc.rs b/tests/props/tsdoc.rs new file mode 100644 index 0000000..cdb4c90 --- /dev/null +++ b/tests/props/tsdoc.rs @@ -0,0 +1,485 @@ +//! Properties of the `tsdoc-*` checks: TSDoc's grammar as `@microsoft/tsdoc` reads it, the tags a +//! tsdoc.json defines, and which tsdoc.json each file is read with. + +use std::{fs, path::Path, sync::Arc}; + +use docov::{ + Rules, + tsdoc::{ + Configuration, SyntaxKind, TagDefinition, + config::{self, Configs}, + }, +}; +use hegel::{TestCase, generators as gs}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use super::{at_level, config_cli::docov, diagnose, level, write}; + +/// The `tsdoc-*` checks. +const RULES: [&str; 7] = + ["tsdoc-tags", "tsdoc-params", "tsdoc-inline-tags", "tsdoc-escapes", "tsdoc-code", "tsdoc-html", "tsdoc-links"]; + +/// The check that reports TSDoc's message `id`. +fn rule_of(id: &str) -> &'static str { + let tags = [ + "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", + ]; + let escapes = [ + "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", + ]; + let inline = [ + "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", + ]; + let html = [ + "tsdoc-html-tag-missing-greater-than", + "tsdoc-html-tag-missing-equals", + "tsdoc-html-tag-missing-string", + "tsdoc-html-string-missing-quote", + "tsdoc-text-after-html-string", + "tsdoc-malformed-html-name", + "tsdoc-unsupported-html-name", + ]; + match id { + _ if tags.contains(&id) => "tsdoc-tags", + _ if escapes.contains(&id) => "tsdoc-escapes", + _ if inline.contains(&id) => "tsdoc-inline-tags", + _ if html.contains(&id) => "tsdoc-html", + _ if id.starts_with("tsdoc-param-") => "tsdoc-params", + _ if id.starts_with("tsdoc-code-") => "tsdoc-code", + _ => "tsdoc-links", + } +} + +/// An entry of tests/fixtures/tsdoc/corpus.json: a comment, the tsdoc.json keys it's read with, +/// and what tools/tsdoc-oracle.sh recorded `@microsoft/tsdoc` finding in it. +#[derive(Deserialize)] +struct Entry { + comment: String, + #[serde(default)] + config: Option>, + #[serde(default)] + messages: Vec, + #[serde(default)] + throws: Option, +} + +#[derive(Deserialize)] +struct Expected { + id: String, + text: String, + at: usize, +} + +/// Writes a tsdoc.json with `keys` and its `$schema` in `dir`, and gives its path. +fn tsdoc_json(dir: &Path, keys: &Map) -> std::path::PathBuf { + let mut file = keys.clone(); + file.insert("$schema".into(), json!(config::SCHEMA)); + let path = dir.join(config::FILE_NAME); + fs::write(&path, Value::Object(file).to_string()).unwrap(); + path +} + +/// The 1-based line and column, in characters, of byte `at` in `source`. +fn position(source: &str, at: usize) -> (usize, usize) { + let before = &source[..at]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + (before.matches('\n').count() + 1, before[line_start..].chars().count() + 1) +} + +/// The `tsdoc-*` checks report, for each comment `@microsoft/tsdoc`'s own tests and docov's +/// corpus have, what that parser reports, by its message ids and texts and where, each under the +/// check for its part of the grammar, wherever the comment is in the file, and with the tsdoc.json +/// the entry has. A comment with nothing in it documents nothing, so it isn't checked. Where +/// `@microsoft/tsdoc` throws (a selector cut off by the end of a tag, `{@link (Foo:}`), docov stops +/// at the message it reports before that. +#[hegel::test(test_cases = 5)] +fn tsdoc_checks_report_what_microsoft_tsdoc_does(tc: TestCase) { + let levels: Vec<(&str, (Value, Option))> = RULES.iter().map(|rule| (*rule, level(&tc))).collect(); + let on: Vec<(&str, Value)> = levels.iter().map(|(rule, (value, _))| (*rule, value.clone())).collect(); + let before = "export const before = 1;\n".repeat(tc.draw(gs::integers::().max_value(2))); + let entries: Vec = serde_json::from_str(include_str!("../fixtures/tsdoc/corpus.json")).unwrap(); + let dir = tempfile::tempdir().unwrap(); + for entry in &entries { + let source = format!("{before}{}\nexport const v = 1;\n", entry.comment); + let tsdoc = entry.config.as_ref().map(|keys| Arc::new(Configs::file(&tsdoc_json(dir.path(), keys)).unwrap())); + let rules = Rules { tsdoc, ..Rules::default() }; + let empty = entry.comment[3..entry.comment.len() - 2].chars().all(|c| c.is_whitespace() || c == '*'); + let label = || Expected { + id: "tsdoc-reference-missing-label".into(), + text: "Expecting a selector label after the colon".into(), + at: entry.comment.find(':').unwrap(), + }; + let messages = if entry.throws.is_some() { &vec![label()] } else { &entry.messages }; + let mut wanted = Vec::new(); + for message in messages.iter().filter(|_| !empty) { + let rule = rule_of(&message.id); + let error = levels.iter().find(|(id, _)| *id == rule).unwrap().1.1; + let (line, column) = position(&source, before.len() + message.at); + let finding = (line, column, rule, format!("{}: {}", message.id, message.text)); + wanted.extend(at_level(vec![finding], error)); + } + wanted.sort(); + let mut found = diagnose(&source, &on, rules); + found.retain(|(.., message, _)| !message.starts_with("docov-")); + assert_eq!(found, wanted, "{}", entry.comment); + } +} + +/// A tag for [`tags_are_used_once_unless_they_allow_more`]: how it's written, whether a comment +/// may have more than one, and whether it's a modifier tag. +const TAGS: [(&str, bool, bool); 8] = [ + ("@remarks text", false, false), + ("@example text", true, false), + ("@see text", false, false), + ("@beta", false, true), + ("@public", false, true), + ("@virtual", false, true), + ("see {@label L}", false, false), + ("see {@link Foo}", true, false), +]; + +/// `tsdoc-tags` reports each use of a tag after its first, in any case, unless its definition +/// has `allowMultiple` (`docov-repeated-tag`), and a modifier tag with text after it on its line +/// (`docov-modifier-tag-text`), but not one followed by another tag. +#[hegel::test(test_cases = 50)] +fn tags_are_used_once_unless_they_allow_more(tc: TestCase) { + let (value, error) = level(&tc); + let mut source = "/**\n * Summary.\n".to_owned(); + let (mut seen, mut wanted) = (Vec::new(), Vec::new()); + for _ in 0..tc.draw(gs::integers::().max_value(6)) { + let mut line = " * ".to_owned(); + let pair = tc.draw(gs::booleans()); + let picked: Vec = + if pair { vec![4, 3] } else { vec![tc.draw(gs::integers::().max_value(TAGS.len() - 1))] }; + let trailing = tc.draw(gs::booleans()); + let upper = tc.draw(gs::booleans()); + let line_number = source.matches('\n').count() + 1; + for (i, &tag) in picked.iter().enumerate() { + let (written, multiple, modifier) = TAGS[tag]; + let at = written.find('@').unwrap(); + let name_end = written[at..].find([' ', '}']).map_or(written.len(), |end| at + end); + let name = &written[at..name_end]; + let name = if upper { name.to_uppercase() } else { name.to_owned() }; + if i > 0 { + line.push(' '); + } + let column = line.chars().count() + at + 1; + line += &written.replacen(&written[at..name_end], &name, 1); + let upper_name = name.to_uppercase(); + if !multiple && seen.contains(&upper_name) { + let text = + format!("docov-repeated-tag: The TSDoc tag \"{name}\" may only appear once in a doc comment"); + wanted.push((line_number, column, "tsdoc-tags", text)); + } + seen.push(upper_name); + if modifier && trailing && i == picked.len() - 1 { + let text = format!( + "docov-modifier-tag-text: The TSDoc tag \"{name}\" is a modifier tag; text after it on its line belongs to the section before it" + ); + wanted.push((line_number, column, "tsdoc-tags", text)); + } + } + if trailing { + line += " more"; + } + source += &line; + source.push('\n'); + } + source += " */\nexport const v = 1;\n"; + assert_eq!(diagnose(&source, &[("tsdoc-tags", value)], Rules::default()), at_level(wanted, error), "{source}"); +} + +/// A tag definition as a tsdoc.json writes it, and as docov reads it. +fn definition(tc: &TestCase, name: &str) -> (Value, TagDefinition) { + let (kind, word) = tc.draw(gs::sampled_from(vec![ + (SyntaxKind::Block, "block"), + (SyntaxKind::Inline, "inline"), + (SyntaxKind::Modifier, "modifier"), + ])); + let allow_multiple = tc.draw(gs::booleans()); + let json = json!({ "tagName": name, "syntaxKind": word, "allowMultiple": allow_multiple }); + (json, TagDefinition { name: name.into(), kind, allow_multiple }) +} + +/// A tsdoc.json sets up TSDoc's parser the way `@microsoft/tsdoc-config` does: the files it +/// extends (a path, or a file in a package) first, then its own tag definitions, +/// `supportForTags` and HTML elements over theirs, and its `noStandardTags` or else theirs. Comments +/// are fine in it. The files it read come back too. +#[hegel::test(test_cases = 50)] +fn tsdoc_json_sets_up_the_parser(tc: TestCase) { + let dir = tempfile::tempdir().unwrap(); + let mut base = Map::new(); + let mut top = Map::new(); + let base_standard = tc.draw(gs::optional(gs::booleans())); + let top_standard = tc.draw(gs::optional(gs::booleans())); + let (base_file, specifier) = if tc.draw(gs::booleans()) { + ("base/tsdoc.json", "./base/tsdoc.json") + } else { + ("node_modules/shared/tsdoc.json", "shared/tsdoc.json") + }; + let extends = tc.draw(gs::booleans()); + // The base file is there either way, and counts only when the top one extends it. + if let Some(no) = base_standard { + base.insert("noStandardTags".into(), json!(no)); + } + let standard = !top_standard.or(base_standard.filter(|_| extends)).unwrap_or(false); + let mut wanted = Configuration::standard(standard); + if extends { + let (json, custom) = definition(&tc, "@custom"); + base.insert("tagDefinitions".into(), json!([json])); + wanted.tags.push(custom); + if tc.draw(gs::booleans()) { + base.insert("supportedHtmlElements".into(), json!(["b", "my-el"])); + wanted.html = vec!["b".into(), "my-el".into()]; + wanted.report_unsupported_html = true; + } + top.insert("extends".into(), json!([specifier])); + } + if let Some(no) = top_standard { + top.insert("noStandardTags".into(), json!(no)); + } + let (json, mine) = definition(&tc, "@mine"); + top.insert("tagDefinitions".into(), json!([json])); + wanted.tags.push(mine); + let mut support = Map::new(); + let mut candidates = vec!["@MINE"]; + if extends { + candidates.push("@custom"); + } + if standard { + candidates.push("@remarks"); + } + for name in tc.draw(gs::vecs(gs::sampled_from(candidates)).max_size(4)) { + let supported = tc.draw(gs::booleans()); + support.insert(name.into(), json!(supported)); + } + if !support.is_empty() { + let mut supported = Vec::new(); + for (name, value) in &support { + let upper = name.to_uppercase(); + supported.retain(|tag| *tag != upper); + if value == &json!(true) { + supported.push(upper); + } + } + wanted.supported = Some(supported); + top.insert("supportForTags".into(), Value::Object(support)); + } + if tc.draw(gs::booleans()) { + top.insert("supportedHtmlElements".into(), json!(["i"])); + wanted.html = vec!["i".into()]; + wanted.report_unsupported_html = true; + } + if let Some(report) = tc.draw(gs::optional(gs::booleans())) { + top.insert("reportUnsupportedHtmlElements".into(), json!(report)); + wanted.report_unsupported_html = report; + } + base.insert("$schema".into(), json!(config::SCHEMA)); + write(dir.path(), base_file, &format!("// shared\n{}", Value::Object(base))); + let path = tsdoc_json(dir.path(), &top); + let (configuration, sources) = config::load(&path).unwrap(); + assert_eq!(configuration, wanted); + let mut read = vec![path]; + if extends { + read.push(dir.path().join(base_file)); + } + // The file it extends comes back as the resolver names it: by its real path, which on + // Windows can differ from the temporary folder's in form (a short name, no `\\?\`). + let real = |paths: &[std::path::PathBuf]| paths.iter().map(|p| fs::canonicalize(p).unwrap()).collect::>(); + assert_eq!(real(&sources), real(&read)); +} + +/// A tsdoc.json that can't be used fails with TSDoc's message id for why, naming the file: it +/// isn't there or isn't a file, isn't JSON (a stray `/` included), has another `$schema`, a key +/// tsdoc-config doesn't know, an HTML element name that isn't one, a tag name that isn't one, a +/// tag defined twice (in the file, over a file it extends, or over a standard tag), a supported tag +/// nobody defines, or extends a file that isn't there, one that can't be used, or itself. +#[hegel::test(test_cases = 3)] +fn tsdoc_json_errors_say_why(tc: TestCase) { + let schema = json!(config::SCHEMA); + let tag = |name: &str| json!({ "tagName": name, "syntaxKind": "block" }); + let cases: Vec<(Vec<(&str, String)>, &str)> = vec![ + (vec![], "tsdoc-config-file-not-found"), + (vec![("tsdoc.json/x", String::new())], "tsdoc-config-file-not-found"), + (vec![("tsdoc.json", "{ / }".into())], "tsdoc-config-invalid-json"), + (vec![("tsdoc.json", "{".into())], "tsdoc-config-invalid-json"), + (vec![("tsdoc.json", "{}".into())], "tsdoc-config-unsupported-schema"), + (vec![("tsdoc.json", json!({ "$schema": schema, "x": 1 }).to_string())], "tsdoc-config-schema-error"), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "supportedHtmlElements": ["b c"] }).to_string())], + "tsdoc-config-schema-error", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "supportedHtmlElements": [""] }).to_string())], + "tsdoc-config-schema-error", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "tagDefinitions": [tag("custom")] }).to_string())], + "tsdoc-config-invalid-tag-name", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "tagDefinitions": [tag("@a"), tag("@A")] }).to_string())], + "tsdoc-config-duplicate-tag-name", + ), + ( + vec![ + ("base.json", json!({ "$schema": schema, "tagDefinitions": [tag("@a")] }).to_string()), + ( + "tsdoc.json", + json!({ "$schema": schema, "extends": ["./base.json"], "tagDefinitions": [tag("@a")] }).to_string(), + ), + ], + "tsdoc-config-duplicate-tag-name", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "tagDefinitions": [tag("@remarks")] }).to_string())], + "tsdoc-config-duplicate-tag-name", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "supportForTags": { "@nope": true } }).to_string())], + "tsdoc-config-undefined-tag", + ), + ( + vec![("tsdoc.json", json!({ "$schema": schema, "extends": ["./missing.json"] }).to_string())], + "tsdoc-config-unresolved-extends", + ), + ( + vec![ + ("base.json", "{}".into()), + ("tsdoc.json", json!({ "$schema": schema, "extends": ["./base.json"] }).to_string()), + ], + "tsdoc-config-unsupported-schema", + ), + ( + vec![ + ("base.json", json!({ "$schema": schema, "supportForTags": { "@nope": true } }).to_string()), + ("tsdoc.json", json!({ "$schema": schema, "extends": ["./base.json"] }).to_string()), + ], + "tsdoc-config-undefined-tag", + ), + ( + vec![ + ("base.json", json!({ "$schema": schema, "extends": ["./tsdoc.json"] }).to_string()), + ("tsdoc.json", json!({ "$schema": schema, "extends": ["./base.json"] }).to_string()), + ], + "tsdoc-config-cyclic-extends", + ), + ]; + let folder = tc.draw(gs::sampled_from(vec!["", "a/", "a/b/"])); + for (files, id) in cases { + let dir = tempfile::tempdir().unwrap(); + for (name, text) in &files { + write(dir.path(), &format!("{folder}{name}"), text); + } + let path = dir.path().join(format!("{folder}tsdoc.json")); + let error = config::load(&path).unwrap_err(); + assert!(error.contains(id) && error.contains("json: "), "{error}"); + assert_eq!(Configs::file(&path).unwrap_err(), error); + // Found for a file beside a package.json, it fails the same, once it's there to find. + write(dir.path(), &format!("{folder}package.json"), "{}"); + let file = dir.path().join(format!("{folder}a.ts")); + if path.is_file() { + assert!(Configs::find([file.as_path()]).unwrap_err().contains(id)); + } + } +} + +/// With `tsdoc-tags` on, each file's blocks are read with the tsdoc.json `--tsdoc-config` names, +/// else the one `tsdocConfig` names (relative to the config file that has the key, an extended one +/// included), else with `"syntax": "tsdoc"` the one in the nearest folder with a tsconfig.json or +/// a package.json, if that folder has one (so a package with none, or a tsconfig.json below the +/// root's, gets TSDoc's standard tags), and otherwise with TSDoc's standard tags. One that can't be +/// used fails the scan (exit 2), but only when it's read. +#[hegel::test(test_cases = 30)] +fn the_cli_reads_each_files_tsdoc_json(tc: TestCase) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let custom = + json!({ "$schema": config::SCHEMA, "tagDefinitions": [{ "tagName": "@custom", "syntaxKind": "modifier" }] }); + write(root, "package.json", "{}"); + write(root, "a/package.json", "{}"); + write(root, "a/tsdoc.json", &custom.to_string()); + write(root, "b/tsconfig.json", "{}"); + write(root, "cfg/tsdoc.json", &custom.to_string()); + write(root, "tsdoc.json", &custom.to_string()); + write(root, "d/package.json", "{}"); + let block = "/**\n * Summary.\n * @custom\n */\nexport const v = 1;\n"; + let files = ["a/x.ts", "a/x2.ts", "a/sub/w.ts", "b/inner/y.ts", "c/z.ts", "d/v.ts"]; + for file in files { + write(root, file, block); + } + let on = tc.draw(gs::weighted_booleans(0.8)); + // Where the tsdoc.json comes from. + let from = tc.draw(gs::sampled_from(vec!["flag", "key", "nearest", "standard"])); + let syntax = match from { + "nearest" => "tsdoc", + "standard" => "jsdoc", + _ => tc.draw(gs::sampled_from(vec!["jsdoc", "tsdoc"])), + }; + let (key, flag, lookup) = (from == "key", from == "flag", from == "nearest"); + let mut config = json!({ "rules": { "tsdoc-tags": if on { "error" } else { "off" } }, "syntax": syntax }); + if key { + write(root, "cfg/shared.json", r#"{ "tsdocConfig": "tsdoc.json" }"#); + config["extends"] = json!("cfg/shared.json"); + } + write(root, "docov.json", &config.to_string()); + let mut args = vec!["--threshold", "0"]; + if flag { + args.extend(["--tsdoc-config", "cfg/tsdoc.json"]); + } + let (code, _, stderr) = docov(root, &args, false); + let defined = |file: &str| match () { + () if flag || key => true, + () if lookup => file.starts_with("a/") || file.starts_with("c/"), + () => false, + }; + let undefined: Vec<&str> = files.iter().copied().filter(|file| on && !defined(file)).collect(); + for file in files { + // A path as docov prints it, with the OS's separator. + let path = file.replace('/', std::path::MAIN_SEPARATOR_STR); + let finding = format!("{path}:3:4: tsdoc-tags: tsdoc-undefined-tag"); + assert_eq!(stderr.contains(&finding), undefined.contains(&file), "{file}\n{stderr}"); + } + assert_eq!(code, i32::from(!undefined.is_empty()), "{stderr}"); + // Run inside a folder with neither file, it looks above where it runs. + if lookup { + let (code, _, stderr) = docov(&root.join("c"), &[], false); + assert_eq!((code, stderr.as_str()), (0, "")); + } + // One that can't be used, when it's read, fails the scan. + write(root, "a/tsdoc.json", "{"); + write(root, "docov.json", r#"{ "rules": { "tsdoc-tags": "warn" }, "syntax": "tsdoc" }"#); + let (code, _, stderr) = docov(root, &[], false); + assert_eq!(code, 2, "{stderr}"); + assert!(stderr.contains("tsdoc.json: tsdoc-config-invalid-json"), "{stderr}"); +} + +/// With no tsconfig.json or package.json in a folder or above it, there's no tsdoc.json to read, +/// and docov says it looked for both in each folder. +#[hegel::test(test_cases = 3)] +fn no_package_no_tsdoc_json(tc: TestCase) { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join(tc.draw(gs::sampled_from(vec!["", "a", "a/b"]))); + fs::create_dir_all(&folder).unwrap(); + let (found, looked) = config::find(&folder); + assert_eq!(found, None); + assert_eq!(looked.len(), 2 * folder.ancestors().count()); +} 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