diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 7ff33e69ef562..b35fa69c8fc90 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -351,6 +351,11 @@ mod nextjs { pub mod no_unwanted_polyfillio; } +/// +mod jsdoc { + pub mod empty_tags; +} + mod tree_shaking { pub mod no_side_effects_in_initialization; } @@ -668,5 +673,6 @@ oxc_macros::declare_all_lint_rules! { nextjs::no_document_import_in_page, nextjs::no_unwanted_polyfillio, nextjs::no_before_interactive_script_outside_document, + jsdoc::empty_tags, tree_shaking::no_side_effects_in_initialization, } diff --git a/crates/oxc_linter/src/rules/jsdoc/empty_tags.rs b/crates/oxc_linter/src/rules/jsdoc/empty_tags.rs new file mode 100644 index 0000000000000..31d3488cf558a --- /dev/null +++ b/crates/oxc_linter/src/rules/jsdoc/empty_tags.rs @@ -0,0 +1,351 @@ +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; +use phf::phf_set; +use serde::Deserialize; + +use crate::{context::LintContext, rule::Rule}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content.")] +#[diagnostic(severity(warning), help("`@{1}` tag should be empty."))] +struct EmptyTagsDiagnostic(#[label] Span, String); + +#[derive(Debug, Default, Clone)] +pub struct EmptyTags(Box); + +declare_oxc_lint!( + /// ### What it does + /// Expects the following tags to be empty of any content: + /// - `@abstract` + /// - `@async` + /// - `@generator` + /// - `@global` + /// - `@hideconstructor` + /// - `@ignore` + /// - `@inner` + /// - `@instance` + /// - `@override` + /// - `@readonly` + /// - `@inheritDoc` + /// - `@internal` + /// - `@overload` + /// - `@package` + /// - `@private` + /// - `@protected` + /// - `@public` + /// - `@static` + /// + /// ### Why is this bad? + /// The void tags should be empty. + /// + /// ### Example + /// ```javascript + /// // Passing + /// /** @async */ + /// + /// /** @private */ + /// + /// // Failing + /// /** @async foo */ + /// + /// /** @private bar */ + /// ``` + EmptyTags, + restriction +); + +const EMPTY_TAGS: phf::Set<&'static str> = phf_set! { + "abstract", + "async", + "generator", + "global", + "hideconstructor", + "ignore", + "inner", + "instance", + "override", + "readonly", + "inheritDoc", + "internal", + "overload", + "package", + "private", + "protected", + "public", + "static", +}; + +#[derive(Debug, Default, Clone, Deserialize)] +struct EmptyTagsConfig { + #[serde(default)] + tags: Vec, +} + +impl Rule for EmptyTags { + fn from_configuration(value: serde_json::Value) -> Self { + value + .as_array() + .and_then(|arr| arr.first()) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .map_or_else(Self::default, |value| Self(Box::new(value))) + } + + fn run_once(&self, ctx: &LintContext) { + let is_empty_tag_kind = |kind: &str| { + if EMPTY_TAGS.contains(kind) { + return true; + } + if !self.0.tags.is_empty() && self.0.tags.contains(&kind.to_string()) { + return true; + } + false + }; + + for jsdoc in ctx.semantic().jsdoc().iter_all() { + for (span, tag) in jsdoc.tags() { + if !is_empty_tag_kind(tag.kind) { + continue; + } + if tag.comment().is_empty() { + continue; + } + + ctx.diagnostic(EmptyTagsDiagnostic(*span, tag.kind.to_string())); + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ( + " + /** + * @abstract + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @param aName + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @abstract + * @inheritdoc + * @async + */ + function quux () { + + } + ", + None, + None, + ), + // ( + // " + // /** + // * @private {someType} + // */ + // function quux () { + + // } + // ", + // None, + // Some(serde_json::json!({ + // "jsdoc": { + // "mode": "closure", + // }, + // })), + // ), + ( + " + /** + * @private + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @internal + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * Create an array. + * + * @private + * + * @param {string[]} [elem] - Elements to make an array of. + * @param {boolean} [clone] - Optionally clone nodes. + * @returns {string[]} The array of nodes. + */ + function quux () {} + ", + None, + None, + ), + ]; + + let fail = vec![ + ( + " + /** + * @abstract extra text + */ + function quux () { + + } + ", + None, + None, + ), + // ( + // " + // /** + // * @interface extra text + // */ + // ", + // None, + // Some(serde_json::json!({ + // "jsdoc": { + // "mode": "closure", + // }, + // })), + // ), + ( + " + class Test { + /** + * @abstract extra text + */ + quux () { + + } + } + ", + None, + None, + ), + ( + " + /** + * @abstract extra text + * @inheritdoc + * @async out of place + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @event anEvent + */ + function quux () { + + } + ", + Some(serde_json::json!([ + { + "tags": [ + "event", + ], + }, + ])), + None, + ), + ( + " + /** + * @private {someType} + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @internal {someType} + */ + function quux () { + + } + ", + None, + None, + ), + ( + " + /** + * @private {someType} + */ + function quux () { + + } + ", + None, + Some(serde_json::json!({ + "jsdoc": { + "ignorePrivate": true, + }, + })), + ), + ]; + + Tester::new(EmptyTags::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/empty_tags.snap b/crates/oxc_linter/src/snapshots/empty_tags.snap new file mode 100644 index 0000000000000..56eac04075132 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/empty_tags.snap @@ -0,0 +1,75 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: empty_tags +--- + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:17] + 2 │ /** + 3 │ * @abstract extra text + · ───────── + 4 │ */ + ╰──── + help: `@abstract` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:4:17] + 3 │ /** + 4 │ * @abstract extra text + · ───────── + 5 │ */ + ╰──── + help: `@abstract` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:17] + 2 │ /** + 3 │ * @abstract extra text + · ───────── + 4 │ * @inheritdoc + ╰──── + help: `@abstract` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:5:17] + 4 │ * @inheritdoc + 5 │ * @async out of place + · ────── + 6 │ */ + ╰──── + help: `@async` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:17] + 2 │ /** + 3 │ * @event anEvent + · ────── + 4 │ */ + ╰──── + help: `@event` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:13] + 2 │ /** + 3 │ * @private {someType} + · ──────── + 4 │ */ + ╰──── + help: `@private` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:13] + 2 │ /** + 3 │ * @internal {someType} + · ───────── + 4 │ */ + ╰──── + help: `@internal` tag should be empty. + + ⚠ eslint-plugin-jsdoc(empty-tags): Expects the void tags to be empty of any content. + ╭─[empty_tags.tsx:3:13] + 2 │ /** + 3 │ * @private {someType} + · ──────── + 4 │ */ + ╰──── + help: `@private` tag should be empty.