feat(linter/jsdoc): Implement no-defaults rule (#3098)

Part of #1170

>
https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-defaults.md#repos-sticky-header
This commit is contained in:
Yuji Sugiura 2024-04-26 01:03:21 +09:00 committed by GitHub
parent 0185eb2edc
commit 5866086d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 506 additions and 2 deletions

View file

@ -364,6 +364,7 @@ mod jsdoc {
pub mod check_tag_names;
pub mod empty_tags;
pub mod implements_on_classes;
pub mod no_defaults;
pub mod require_property;
pub mod require_property_description;
pub mod require_property_name;
@ -698,6 +699,7 @@ oxc_macros::declare_all_lint_rules! {
jsdoc::check_tag_names,
jsdoc::empty_tags,
jsdoc::implements_on_classes,
jsdoc::no_defaults,
jsdoc::require_property,
jsdoc::require_property_type,
jsdoc::require_property_name,

View file

@ -0,0 +1,440 @@
use oxc_ast::AstKind;
use oxc_diagnostics::{
miette::{self, Diagnostic},
thiserror::Error,
};
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use serde::Deserialize;
use crate::{context::LintContext, rule::Rule, AstNode};
#[derive(Debug, Error, Diagnostic)]
#[error("eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.")]
#[diagnostic(severity(warning), help("{1}"))]
struct NoDefaultsDiagnostic(#[label] pub Span, String);
#[derive(Debug, Default, Clone)]
pub struct NoDefaults(Box<NoDefaultsConfig>);
declare_oxc_lint!(
/// ### What it does
/// This rule reports defaults being used on the relevant portion of `@param` or `@default`.
/// It also optionally reports the presence of the square-bracketed optional arguments at all.
///
/// ### Why is this bad?
/// The rule is intended to prevent the indication of defaults on tags
/// where this would be redundant with ES6 default parameters.
///
/// ### Example
/// ```javascript
/// // Passing
/// /** @param {number} foo */
/// function quux (foo) {}
/// /** @param foo */
/// function quux (foo) {}
///
/// // Failing
/// /** @param {number} [foo="7"] */
/// function quux (foo) {}
/// ```
NoDefaults,
correctness
);
#[derive(Debug, Default, Clone, Deserialize)]
struct NoDefaultsConfig {
#[serde(default, rename = "noOptionalParamNames")]
no_optional_param_names: bool,
}
/// Get the definition root node of a function.
/// JSDoc often appears on the parent node of a function.
///
/// ```js
/// /** FunctionDeclaration */
/// function foo() {}
///
/// /** VariableDeclaration > VariableDeclarator > FunctionExpression */
/// const bar = function() {}
///
/// /** VariableDeclaration > VariableDeclarator > ArrowFunctionExpression */
/// const baz = () => {}
///
/// /** MethodDefinition > FunctionExpression */
/// class X { quux() {} }
///
/// /** PropertyDefinition > ArrowFunctionExpression */
/// class X { quux = () => {} }
/// ```
fn get_function_definition_node<'a, 'b>(
node: &'b AstNode<'a>,
ctx: &'b LintContext<'a>,
) -> Option<&'b AstNode<'a>> {
match node.kind() {
AstKind::Function(f) if f.is_function_declaration() => return Some(node),
AstKind::Function(f) if f.is_expression() => {}
AstKind::ArrowFunctionExpression(_) => {}
_ => return None,
};
let mut current_node = node;
while let Some(parent_node) = ctx.nodes().parent_node(current_node.id()) {
match parent_node.kind() {
AstKind::VariableDeclarator(_) | AstKind::ParenthesizedExpression(_) => {
current_node = parent_node;
}
AstKind::MethodDefinition(_)
| AstKind::PropertyDefinition(_)
| AstKind::VariableDeclaration(_) => return Some(parent_node),
_ => return None,
}
}
None
}
impl Rule for NoDefaults {
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<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
let Some(jsdocs) = get_function_definition_node(node, ctx)
.and_then(|node| ctx.jsdoc().get_all_by_node(node))
else {
return;
};
let settings = &ctx.settings().jsdoc;
let resolved_param_tag_name = settings.resolve_tag_name("param");
let config = &self.0;
for jsdoc in jsdocs {
for tag in jsdoc.tags() {
let tag_name = tag.kind.parsed();
if tag_name != resolved_param_tag_name {
continue;
}
let (_, Some(name_part), _) = tag.type_name_comment() else {
continue;
};
if !name_part.optional {
continue;
}
match (config.no_optional_param_names, name_part.default) {
(true, _) => ctx.diagnostic(NoDefaultsDiagnostic(
name_part.span,
format!("Optional param names are not permitted on `@{tag_name}` tag."),
)),
(false, true) => ctx.diagnostic(NoDefaultsDiagnostic(
name_part.span,
format!("Defaults are not permitted on `@{tag_name}` tag."),
)),
(false, false) => {}
}
}
}
}
}
#[test]
fn test() {
use crate::tester::Tester;
let pass = vec![
(
"
/**
* @param foo
*/
function quux (foo) {
}
",
None,
None,
),
(
"
/**
* @param {number} foo
*/
function quux (foo) {
}
",
None,
None,
),
(
"
/**
* @param {number} foo
* @param {number} [bar]
*/
function quux (foo) {
}
",
None,
None,
),
// (
// "
// /**
// * @param foo
// */
// ",
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
(
"
/**
* @function
* @param {number} foo
*/
",
None,
None,
),
(
"
/**
* @callback
* @param {number} foo
*/
",
None,
None,
),
(
"
/**
* @param {number} foo
*/
function quux (foo) {
}
",
Some(serde_json::json!([
{
"noOptionalParamNames": true,
},
])),
None,
),
// (
// "
// /**
// * @default
// */
// const a = {};
// ",
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
];
let fail = vec![
(
r#"
/**
* @param {number} [foo="7"]
*/
function quux (foo) {
}
"#,
None,
None,
),
(
"
/**
* @param {number} foo
* @param {number} [bar = 1]
*/
const quux = (foo, bar = 1) => {
}
",
None,
None,
),
(
r#"
class Test {
/**
* @param {number} [foo="7"]
*/
quux (foo) {
}
}
"#,
None,
None,
),
(
r#"
/**
* @param {number} [foo="7"]
*/
function quux (foo) {
}
"#,
Some(serde_json::json!([
{
"noOptionalParamNames": true,
},
])),
None,
),
(
"
/**
* @param {number} [foo]
*/
function quux (foo) {
}
",
Some(serde_json::json!([
{
"noOptionalParamNames": true,
},
])),
None,
),
(
r#"
/**
* @arg {number} [foo="7"]
*/
function quux (foo) {
}
"#,
None,
Some(serde_json::json!({ "settings": {
"jsdoc": {
"tagNamePreference": {
"param": "arg",
},
},
} })),
),
// (
// r#"
// /**
// * @param {number} [foo="7"]
// */
// function quux (foo) {
// }
// "#,
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
// (
// r#"
// /**
// * @function
// * @param {number} [foo="7"]
// */
// "#,
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
// (
// r#"
// /**
// * @callback
// * @param {number} [foo="7"]
// */
// "#,
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
// (
// "
// /**
// * @default {}
// */
// const a = {};
// ",
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// None,
// ),
// (
// "
// /**
// * @defaultvalue {}
// */
// const a = {};
// ",
// Some(serde_json::json!([
// {
// "contexts": [
// "any",
// ],
// },
// ])),
// Some(serde_json::json!({ "settings": {
// "jsdoc": {
// "tagNamePreference": {
// "default": "defaultvalue",
// },
// },
// } })),
// ),
];
Tester::new(NoDefaults::NAME, pass, fail).test_and_snapshot();
}

View file

@ -0,0 +1,57 @@
---
source: crates/oxc_linter/src/tester.rs
expression: no_defaults
---
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:3:33]
2 │ /**
3 │ * @param {number} [foo="7"]
· ─────────
4 │ */
╰────
help: Defaults are not permitted on `@param` tag.
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:4:33]
3 │ * @param {number} foo
4 │ * @param {number} [bar = 1]
· ─────────
5 │ */
╰────
help: Defaults are not permitted on `@param` tag.
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:4:33]
3 │ /**
4 │ * @param {number} [foo="7"]
· ─────────
5 │ */
╰────
help: Defaults are not permitted on `@param` tag.
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:3:33]
2 │ /**
3 │ * @param {number} [foo="7"]
· ─────────
4 │ */
╰────
help: Optional param names are not permitted on `@param` tag.
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:3:33]
2 │ /**
3 │ * @param {number} [foo]
· ─────
4 │ */
╰────
help: Optional param names are not permitted on `@param` tag.
⚠ eslint-plugin-jsdoc(no-defaults): Defaults are not permitted.
╭─[no_defaults.tsx:3:31]
2 │ /**
3 │ * @arg {number} [foo="7"]
· ─────────
4 │ */
╰────
help: Defaults are not permitted on `@arg` tag.

View file

@ -132,18 +132,23 @@ impl<'a> JSDocTagTypePart<'a> {
pub struct JSDocTagTypeNamePart<'a> {
raw: &'a str,
pub span: Span,
pub optional: bool,
pub default: bool,
}
impl<'a> JSDocTagTypeNamePart<'a> {
pub fn new(part_content: &'a str, span: Span) -> Self {
debug_assert!(part_content.trim() == part_content);
Self { raw: part_content, span }
let optional = part_content.starts_with('[') && part_content.ends_with(']');
let default = optional && part_content.contains('=');
Self { raw: part_content, span, optional, default }
}
/// Returns the type name itself.
/// `.raw` may be like `[foo = var]`, so extract the name
pub fn parsed(&self) -> &'a str {
if self.raw.starts_with('[') {
if self.optional {
let inner = self.raw.trim_start_matches('[').trim_end_matches(']').trim();
return inner.split_once('=').map_or(inner, |(v, _)| v.trim());
}