Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
155 changes: 155 additions & 0 deletions docov.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
}
}
Expand Down
34 changes: 31 additions & 3 deletions src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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<Parsed>,
}

impl Subject<'_, '_> {
Expand All @@ -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<Finding>) {
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<Finding>) {
if !self.inherits() {
Expand All @@ -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<Finding>);
}
Expand All @@ -163,6 +183,7 @@ type AnyOptions = Box<dyn Any + Send + Sync>;
/// A check, registered.
pub struct Entry {
about: &'static str,
tsdoc: bool,
schema: fn(&mut SchemaGenerator) -> Schema,
options: fn(Option<&Value>) -> serde_json::Result<AnyOptions>,
run: fn(&AnyOptions, &Subject, &mut Vec<Finding>),
Expand All @@ -173,6 +194,7 @@ impl Entry {
pub fn of<C: Check>() -> Self {
Self {
about: C::ABOUT,
tsdoc: C::TSDOC,
schema: |generator| generator.subschema_for::<C::Options>(),
options: options::<C>,
run: run::<C>,
Expand Down Expand Up @@ -280,6 +302,7 @@ struct On {
setting: Setting,
options: AnyOptions,
run: fn(&AnyOptions, &Subject, &mut Vec<Finding>),
tsdoc: bool,
}

/// The checks that are on, which scans run on each counted item with a block.
Expand Down Expand Up @@ -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<Item = (&'static str, Level)> {
self.on.iter().map(|on| (on.rule, on.setting.level))
Expand Down
26 changes: 26 additions & 0 deletions src/check/tsdoc_code.rs
Original file line number Diff line number Diff line change
@@ -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::<TsdocCode>()
}

/// 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<Finding>) {
subject.tsdoc(Group::Code, out);
}
}
26 changes: 26 additions & 0 deletions src/check/tsdoc_escapes.rs
Original file line number Diff line number Diff line change
@@ -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::<TsdocEscapes>()
}

/// 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<Finding>) {
subject.tsdoc(Group::Escapes, out);
}
}
26 changes: 26 additions & 0 deletions src/check/tsdoc_html.rs
Original file line number Diff line number Diff line change
@@ -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::<TsdocHtml>()
}

/// 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<Finding>) {
subject.tsdoc(Group::Html, out);
}
}
27 changes: 27 additions & 0 deletions src/check/tsdoc_inline_tags.rs
Original file line number Diff line number Diff line change
@@ -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::<TsdocInlineTags>()
}

/// 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<Finding>) {
subject.tsdoc(Group::InlineTags, out);
}
}
Loading
Loading