diff --git a/.changeset/functional-state-rust.md b/.changeset/functional-state-rust.md new file mode 100644 index 0000000000..79af590ef6 --- /dev/null +++ b/.changeset/functional-state-rust.md @@ -0,0 +1,7 @@ +--- +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Run `rerender-functional-setstate` natively in the patched Oxlint binding and avoid reporting a synchronous state update when the only other call to the same setter does not read that state. diff --git a/.changeset/tidy-oxen-compile.md b/.changeset/tidy-oxen-compile.md new file mode 100644 index 0000000000..81551d7735 --- /dev/null +++ b/.changeset/tidy-oxen-compile.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add an opt-in, source-patched Oxlint binding path with native rule parity, cache isolation, provenance metadata, and reproducible cross-platform build tooling. diff --git a/.gitattributes b/.gitattributes index 1455cff2cd..7274c38197 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ packages/fuzz/corpus/react-bench-0.9.7-audit/** -text whitespace=-trailing-space packages/fuzz/corpus/dummy-threejs-v14-audit/** -text whitespace=-trailing-space +native/oxlint/*.patch -text whitespace=-trailing-space diff --git a/.github/workflows/native-oxlint.yml b/.github/workflows/native-oxlint.yml new file mode 100644 index 0000000000..64120ac258 --- /dev/null +++ b/.github/workflows/native-oxlint.yml @@ -0,0 +1,87 @@ +name: Native Oxlint + +on: + push: + branches: [main] + paths: + - "native/oxlint/**" + - "scripts/native/**" + - "package.json" + - "packages/core/src/constants.ts" + - "packages/core/src/run-oxlint.ts" + - "packages/core/src/runners/oxlint/**" + - ".github/workflows/native-oxlint.yml" + pull_request: + branches: [main] + paths: + - "native/oxlint/**" + - "scripts/native/**" + - "package.json" + - "packages/core/src/constants.ts" + - "packages/core/src/run-oxlint.ts" + - "packages/core/src/runners/oxlint/**" + - ".github/workflows/native-oxlint.yml" + +permissions: + contents: read + +jobs: + verify-patch: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - run: node scripts/native/build-oxlint-binding.mjs --check-only + + build: + needs: verify-patch + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-24.04 + - ubuntu-24.04-arm + - macos-15-intel + - macos-15 + - windows-2025 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + with: + shared-key: native-oxlint-${{ matrix.runner }} + - run: node scripts/native/build-oxlint-binding.mjs --output dist/native-oxlint + - uses: actions/upload-artifact@v7 + with: + name: native-oxlint-${{ matrix.runner }} + path: dist/native-oxlint/* + if-no-files-found: error + + parity: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22.18.0" + cache: pnpm + - run: pnpm install --frozen-lockfile --prefer-offline + - run: pnpm --filter oxlint-plugin-react-doctor build + - uses: actions/download-artifact@v8 + with: + name: native-oxlint-ubuntu-24.04 + path: dist/native-oxlint + - run: node scripts/native/verify-oxlint-parity.mjs --directory dist/native-oxlint diff --git a/native/oxlint/README.md b/native/oxlint/README.md new file mode 100644 index 0000000000..2ebd6dd811 --- /dev/null +++ b/native/oxlint/README.md @@ -0,0 +1,24 @@ +# Source-patched Oxlint + +This directory contains a patch against one exact Oxc commit. It adds a `react-doctor-native` plugin while leaving the stock Oxlint JavaScript launcher and all unported React Doctor rules unchanged. + +The native rule cohort is listed in `upstream.json`, with one Rust source file per rule in `rules/`. React Doctor enables those rules only when `REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH` points to a built `.node` binding. Without that environment variable, scans use the published Oxlint binding and JavaScript rules exactly as before. + +## Build and verify + +```sh +nr native:oxlint:verify +nr native:oxlint:check +nr native:oxlint:build +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --benchmark +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --corpus packages/fuzz/tmp/corpus-repos +``` + +Set `CARGO_BUILD_JOBS=2` for the compile-check and release build on memory-constrained builders. + +`native:oxlint:verify` clones the pinned tag, checks its commit, and proves the patch still applies. `native:oxlint:check` overlays every native rule, regenerates Oxc's rule registry, and compile-checks the linter. `native:oxlint:build` performs the same source assembly, compiles and loads the N-API binding, and writes the binding plus provenance and SHA-256 hashes to `dist/native-oxlint`. + +The parity check runs the JavaScript and native implementations over the same adversarial TypeScript fixture and compares normalized diagnostics. Pass `--corpus` with a directory of repositories to compare every repository independently. A native rule should not be added to `nativeRules` or `NATIVE_REACT_DOCTOR_RULE_IDS` until both checks pass. + +The workflow builds artifacts for Linux x64/arm64, macOS x64/arm64, and Windows x64. It does not publish them. Shipping or making the native patch the default should happen only after corpus parity shows no diagnostic drift and benchmarks show at least a 15% p50 lint improvement. diff --git a/native/oxlint/react-doctor.patch b/native/oxlint/react-doctor.patch new file mode 100644 index 0000000000..9879c80c1f --- /dev/null +++ b/native/oxlint/react-doctor.patch @@ -0,0 +1,221 @@ +diff --git a/crates/oxc_linter/src/config/plugins.rs b/crates/oxc_linter/src/config/plugins.rs +index e4e346c..6ecbfc1 100644 +--- a/crates/oxc_linter/src/config/plugins.rs ++++ b/crates/oxc_linter/src/config/plugins.rs +@@ -68,6 +68,7 @@ pub fn plugin_display_name(plugin_name: &str) -> &str { + match plugin_name { + "jsx_a11y" => "jsx-a11y", + "react_perf" => "react-perf", ++ "react_doctor_native" => "react-doctor-native", + "nextjs" => "next", + _ => plugin_name, + } +@@ -121,6 +122,8 @@ bitflags! { + const NODE = 1 << 12; + /// `eslint-plugin-vue` + const VUE = 1 << 13; ++ /// Native React Doctor rules ++ const REACT_DOCTOR_NATIVE = 1 << 14; + } + } + +@@ -186,6 +189,7 @@ impl TryFrom<&str> for LintPlugins { + "promise" => Ok(LintPlugins::PROMISE), + "node" => Ok(LintPlugins::NODE), + "vue" => Ok(LintPlugins::VUE), ++ "react-doctor-native" | "react_doctor_native" => Ok(LintPlugins::REACT_DOCTOR_NATIVE), + // "eslint" is not really a plugin, so it's 'empty'. This has the added benefit of + // making it the default value. + "eslint" => Ok(LintPlugins::ESLINT), +@@ -211,6 +215,7 @@ impl From for &'static str { + LintPlugins::PROMISE => "promise", + LintPlugins::NODE => "node", + LintPlugins::VUE => "vue", ++ LintPlugins::REACT_DOCTOR_NATIVE => "react-doctor-native", + _ => "", + } + } +@@ -282,6 +287,7 @@ impl JsonSchema for LintPlugins { + Promise, + Node, + Vue, ++ ReactDoctorNative, + } + + let enum_schema = r#gen.subschema_for::(); +diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs +index 152f919..7e451af 100644 +--- a/crates/oxc_linter/src/context/mod.rs ++++ b/crates/oxc_linter/src/context/mod.rs +@@ -1,15 +1,23 @@ + #![expect(rustdoc::private_intra_doc_links)] // useful for intellisense + +-use std::{ffi::OsStr, ops::Deref, path::Path, rc::Rc}; ++use std::{ ++ borrow::Cow, ++ cell::OnceCell, ++ collections::HashMap, ++ ffi::OsStr, ++ ops::Deref, ++ path::Path, ++ rc::Rc, ++}; + + use javascript_globals::{GLOBALS, GLOBALS_BUILTIN, GLOBALS_ES2026}; + + use oxc_allocator::Allocator; +-use oxc_ast::ast::IdentifierReference; ++use oxc_ast::{AstKind, ast::IdentifierReference}; + use oxc_cfg::ControlFlowGraph; + use oxc_diagnostics::{OxcDiagnostic, Severity}; + use oxc_semantic::{IsGlobalReference, Semantic}; +-use oxc_span::Span; ++use oxc_span::{GetSpan, LabeledSpan, Span}; + + #[cfg(debug_assertions)] + use crate::rule::RuleFixMeta; +@@ -25,6 +33,74 @@ use crate::{ + mod host; + pub use host::{ContextHost, ContextSubHost, ContextSubHostOptions}; + ++fn apply_react_doctor_diagnostic_override( ++ context: &LintContext, ++ rule_name: &str, ++ message: &mut Message, ++) { ++ let upstream_message = message.error.message.as_ref(); ++ let diagnostic_message: Cow<'static, str> = match rule_name { ++ "html-has-lang" => { ++ Cow::Borrowed("Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.") ++ } ++ "no-access-key" => { ++ Cow::Borrowed("Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.") ++ } ++ "no-clone-element" => { ++ Cow::Borrowed("`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.") ++ } ++ "no-is-mounted" => { ++ Cow::Borrowed("`isMounted` is unreliable in modern React, so async callbacks can update state after unmount.") ++ } ++ "no-render-return-value" => { ++ Cow::Borrowed("Your app breaks in React 19 because `ReactDOM.render` returns nothing there.") ++ } ++ "no-will-update-set-state" => { ++ Cow::Borrowed("Calling setState in componentWillUpdate can trigger another update immediately, loop forever, and freeze the component.") ++ } ++ "self-closing-comp" => { ++ Cow::Borrowed("This tag has no children, so the closing tag adds noise without changing output.") ++ } ++ "no-distracting-elements" => { ++ let element = upstream_message ++ .split("`<") ++ .nth(1) ++ .and_then(|message| message.split_once('>')) ++ .map_or("element", |(element, _)| element); ++ Cow::Owned(format!("Users with attention or motion sensitivity struggle because `<{element}>` animates on its own, so use normal, accessible markup instead.")) ++ } ++ "require-render-return" => Cow::Borrowed( ++ "Your users see nothing because this `render` method returns nothing.", ++ ), ++ _ => return, ++ }; ++ if rule_name == "self-closing-comp" { ++ let diagnostic_spans = context.react_doctor_diagnostic_spans.get_or_init(|| { ++ context ++ .nodes() ++ .iter() ++ .filter_map(|node| match (rule_name, node.kind()) { ++ ("self-closing-comp", AstKind::JSXElement(element)) => element ++ .closing_element ++ .as_ref() ++ .map(|closing_element| { ++ (closing_element.span().start, element.opening_element.span()) ++ }), ++ _ => None, ++ }) ++ .collect() ++ }); ++ if let Some(diagnostic_span) = diagnostic_spans.get(&message.span.start) { ++ message.span = *diagnostic_span; ++ } ++ } ++ message.error.message = diagnostic_message; ++ message.error.help = None; ++ message.error.note = None; ++ message.error.labels.clear(); ++ message.error.labels.push(LabeledSpan::underline(message.span)); ++} ++ + /// Contains all of the state and context specific to this lint rule. + /// + /// Includes information like the rule name, plugin name, and severity of the rule. +@@ -59,5 +127,6 @@ pub struct LintContext<'a> { + /// } + /// ``` + severity: Severity, ++ react_doctor_diagnostic_spans: OnceCell>, + } + +@@ -264,7 +332,18 @@ impl<'a> LintContext<'a> { + /// Add a diagnostic message to the list of diagnostics. Outputs a diagnostic with the current rule + /// name, severity, and a link to the rule's documentation URL. + fn add_diagnostic(&self, mut message: Message) { +- if self.parent.disable_directives().contains(self.current_rule_name, message.span) { ++ if self.current_plugin_name == "react_doctor_native" { ++ apply_react_doctor_diagnostic_override(self, self.current_rule_name, &mut message); ++ } ++ let is_disabled = if self.current_plugin_name == "react_doctor_native" { ++ self.parent.disable_directives().contains( ++ &format!("react-doctor/{}", self.current_rule_name), ++ message.span, ++ ) ++ } else { ++ self.parent.disable_directives().contains(self.current_rule_name, message.span) ++ }; ++ if is_disabled { + return; + } + message.error = message +diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs +--- a/crates/oxc_linter/src/context/host.rs ++++ b/crates/oxc_linter/src/context/host.rs +@@ -493,24 +493,26 @@ impl<'a> ContextHost<'a> { + LintContext { + parent: self, ++ react_doctor_diagnostic_spans: OnceCell::new(), + current_rule_name: rule_name, + current_plugin_name: plugin_name, + current_plugin_display_name: plugin_display_name(plugin_name), + #[cfg(debug_assertions)] + current_rule_fix_capabilities: rule.fix(), + severity: severity.into(), + } + } + + /// Creates a new [`LintContext`] for testing purposes only. + #[cfg(test)] + pub(crate) fn spawn_for_test(self: Rc) -> LintContext<'a> { + LintContext { + parent: Rc::clone(&self), ++ react_doctor_diagnostic_spans: OnceCell::new(), + current_rule_name: "", + current_plugin_name: "eslint", + current_plugin_display_name: "eslint", + #[cfg(debug_assertions)] + current_rule_fix_capabilities: crate::rule::RuleFixMeta::None, + severity: oxc_diagnostics::Severity::Warning, + } + } +diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs +index baa13c5..7c21b9d 100644 +--- a/crates/oxc_linter/src/rules.rs ++++ b/crates/oxc_linter/src/rules.rs +@@ -3,6 +3,8 @@ + //! New rules need to be added to these `mod` statements. + //! Then run `cargo lintgen` to regenerate the RuleEnum and RuleRunnerImpls. + ++pub(crate) mod react_doctor_native; ++ + /// + pub(crate) mod import { + pub mod consistent_type_specifier_style; diff --git a/native/oxlint/rules/anchor-ambiguous-text.rs b/native/oxlint/rules/anchor-ambiguous-text.rs new file mode 100644 index 0000000000..b9da244d9b --- /dev/null +++ b/native/oxlint/rules/anchor-ambiguous-text.rs @@ -0,0 +1,148 @@ +use oxc_ast::{ + AstKind, + ast::{Expression, JSXChild, JSXElement, JSXExpression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, + utils::{ + get_element_type, get_string_literal_prop_value, has_jsx_prop_ignore_case, + is_hidden_from_screen_reader, + }, +}; + +const DEFAULT_AMBIGUOUS_WORDS: [&str; 5] = ["click here", "here", "link", "a link", "learn more"]; + +#[derive(Debug, Default, Clone)] +pub struct AnchorAmbiguousText; + +declare_oxc_lint!( + /// Require descriptive anchor text. + AnchorAmbiguousText, + react_doctor_native, + restriction, + version = "0.1.0", + short_description = "Require descriptive anchor text.", +); + +impl Rule for AnchorAmbiguousText { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + if get_element_type(ctx, &element.opening_element) != "a" { + return; + } + let Some(accessible_text) = get_accessible_text(element, ctx) else { + return; + }; + if accessible_text.trim().is_empty() { + return; + } + let normalized_text = normalize_anchor_text(&accessible_text); + if ambiguous_words(ctx) + .iter() + .any(|word| word.to_lowercase() == normalized_text) + { + let message = format!( + "Screen reader users can't tell where `{normalized_text}` goes, so name the destination, like \"View pricing details\"." + ); + ctx.diagnostic( + OxcDiagnostic::warn(message).with_label(element.opening_element.name.span()), + ); + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() && !is_non_production_file(ctx) + } +} + +fn get_accessible_text<'a>(element: &JSXElement<'a>, ctx: &LintContext<'a>) -> Option { + if let Some(aria_label) = has_jsx_prop_ignore_case(&element.opening_element, "aria-label") + && let Some(label) = get_string_literal_prop_value(aria_label) + { + return Some(label.to_string()); + } + if get_element_type(ctx, &element.opening_element) == "img" + && let Some(alt_attribute) = has_jsx_prop_ignore_case(&element.opening_element, "alt") + && let Some(alt_text) = get_string_literal_prop_value(alt_attribute) + { + return Some(alt_text.to_string()); + } + if is_hidden_from_screen_reader(ctx, &element.opening_element) { + return None; + } + let mut text = String::new(); + for child in &element.children { + match child { + JSXChild::Text(text_child) => text.push_str(text_child.value.as_str()), + JSXChild::Element(child_element) => { + if let Some(child_text) = get_accessible_text(child_element, ctx) { + text.push_str(&child_text); + } + } + JSXChild::ExpressionContainer(container) => { + if let Some(expression_text) = static_expression_text(&container.expression) { + text.push_str(expression_text); + } + } + _ => {} + } + } + Some(text) +} + +fn static_expression_text<'a>(expression: &'a JSXExpression<'_>) -> Option<&'a str> { + let expression = expression.as_expression()?.get_inner_expression(); + match expression { + Expression::StringLiteral(string_literal) => Some(string_literal.value.as_str()), + Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + Some( + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()), + ) + } + _ => None, + } +} + +fn normalize_anchor_text(text: &str) -> String { + text.to_lowercase() + .chars() + .filter(|character| { + !matches!( + character, + ',' | '.' | '?' | '¿' | '!' | '‽' | '¡' | ';' | ':' + ) + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn ambiguous_words<'a>(ctx: &'a LintContext<'_>) -> Vec<&'a str> { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("anchorAmbiguousText")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("words")) + .and_then(serde_json::Value::as_array) + .map(|words| words.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_else(|| DEFAULT_AMBIGUOUS_WORDS.to_vec()) +} diff --git a/native/oxlint/rules/anchor-has-content.rs b/native/oxlint/rules/anchor-has-content.rs new file mode 100644 index 0000000000..9f0deecf13 --- /dev/null +++ b/native/oxlint/rules/anchor-has-content.rs @@ -0,0 +1,126 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, globals::VALID_ARIA_ROLES, rule::Rule, utils::has_jsx_prop_ignore_case}; + +const MESSAGE: &str = "Blind users can't follow this link because screen readers announce nothing, so add visible text, `aria-label`, or `aria-labelledby`."; + +#[derive(Debug, Default, Clone)] +pub struct AnchorHasContent; + +declare_oxc_lint!( + /// Require accessible anchor content. + AnchorHasContent, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require accessible anchor content.", +); + +impl Rule for AnchorHasContent { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let opening_element = &element.opening_element; + if get_element_type(ctx, opening_element) != "a" { + return; + } + let curated_behavior = should_use_curated_behavior(ctx); + if curated_behavior + && has_jsx_prop_ignore_case(opening_element, "href").is_none() + && !can_have_link_role(opening_element) + { + return; + } + if is_hidden_from_screen_reader(opening_element, ctx) + || object_has_accessible_child(element, ctx) + || ["title", "aria-label", "aria-labelledby"] + .iter() + .any(|attribute_name| { + has_jsx_prop_ignore_case(opening_element, attribute_name).is_some() + }) + || (curated_behavior && is_trans_components_template(node, ctx)) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.name.span())); + } + + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + ctx.source_type().is_jsx() + && (!should_use_curated_behavior_host(ctx) || !is_non_production_file(ctx)) + } +} + +fn should_use_curated_behavior(ctx: &LintContext<'_>) -> bool { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("portedRuleMode")) + .and_then(serde_json::Value::as_str) + == Some("curated") +} + +fn should_use_curated_behavior_host(ctx: &crate::context::ContextHost) -> bool { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("portedRuleMode")) + .and_then(serde_json::Value::as_str) + == Some("curated") +} + +fn can_have_link_role(opening_element: &oxc_ast::ast::JSXOpeningElement<'_>) -> bool { + let Some(JSXAttributeItem::Attribute(role_attribute)) = + has_jsx_prop_ignore_case(opening_element, "role") + else { + return false; + }; + let Some(value) = &role_attribute.value else { + return true; + }; + let static_role = match value { + JSXAttributeValue::StringLiteral(string_literal) => Some(string_literal.value.as_str()), + JSXAttributeValue::ExpressionContainer(container) => match &container.expression { + JSXExpression::StringLiteral(string_literal) => Some(string_literal.value.as_str()), + JSXExpression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() + && template_literal.quasis.len() == 1 => + { + Some(template_literal.quasis[0].value.raw.as_str()) + } + _ => None, + }, + _ => None, + }; + static_role.is_none_or(|role| { + role.split_whitespace() + .find(|role_name| VALID_ARIA_ROLES.contains(*role_name)) + == Some("link") + }) +} + +fn is_trans_components_template<'a>(node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool { + for ancestor in ctx.nodes().ancestors(node.id()) { + let AstKind::JSXAttribute(_) = ancestor.kind() else { + continue; + }; + let parent = ctx.nodes().parent_node(ancestor.id()); + let AstKind::JSXOpeningElement(opening_element) = parent.kind() else { + return false; + }; + return matches!( + &opening_element.name, + JSXElementName::Identifier(identifier) if identifier.name == "Trans" + ) || matches!( + &opening_element.name, + JSXElementName::IdentifierReference(identifier) if identifier.name == "Trans" + ); + } + false +} diff --git a/native/oxlint/rules/aria-activedescendant-has-tabindex.rs b/native/oxlint/rules/aria-activedescendant-has-tabindex.rs new file mode 100644 index 0000000000..284d4cc3cf --- /dev/null +++ b/native/oxlint/rules/aria-activedescendant-has-tabindex.rs @@ -0,0 +1,253 @@ +use oxc_ast::{ + AstKind, + ast::{Expression, JSXAttributeItem, JSXAttributeValue, JSXOpeningElement}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{ + AstNode, + context::LintContext, + globals::HTML_TAG, + rule::Rule, + utils::{get_element_type, has_jsx_prop_ignore_case, is_interactive_element}, +}; + +const MESSAGE: &str = "Keyboard users can't focus this element with `aria-activedescendant` because it isn't tabbable, so add `tabIndex={0}`."; + +#[derive(Debug, Default, Clone)] +pub struct AriaActivedescendantHasTabindex; + +declare_oxc_lint!( + /// Require tabbable aria-activedescendant owners. + AriaActivedescendantHasTabindex, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require tabbable aria-activedescendant owners.", +); + +impl Rule for AriaActivedescendantHasTabindex { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if has_jsx_prop_ignore_case(opening_element, "aria-activedescendant").is_none() { + return; + } + let element_type = get_element_type(ctx, opening_element); + if !HTML_TAG.contains(element_type.as_ref()) { + return; + } + if let Some(JSXAttributeItem::Attribute(tab_index_attribute)) = + has_jsx_prop_ignore_case(opening_element, "tabIndex") + { + let should_report = tab_index_attribute + .value + .as_ref() + .and_then(|value| parse_static_jsx_number(value)) + .is_some_and(|tab_index| tab_index < -1.0); + if !should_report { + return; + } + } else if is_interactive_element(&element_type, opening_element) + || can_content_editable_be_tabbable(node, opening_element, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.name.span())); + } +} + +#[derive(Clone, Copy)] +struct ContentEditablePossibilities { + can_be_disabled: bool, + can_be_enabled: bool, + can_be_inherited: bool, +} + +const ENABLED_CONTENT_EDITABLE: ContentEditablePossibilities = ContentEditablePossibilities { + can_be_disabled: false, + can_be_enabled: true, + can_be_inherited: false, +}; +const DISABLED_CONTENT_EDITABLE: ContentEditablePossibilities = ContentEditablePossibilities { + can_be_disabled: true, + can_be_enabled: false, + can_be_inherited: false, +}; +const INHERITED_CONTENT_EDITABLE: ContentEditablePossibilities = ContentEditablePossibilities { + can_be_disabled: false, + can_be_enabled: false, + can_be_inherited: true, +}; +const UNKNOWN_CONTENT_EDITABLE: ContentEditablePossibilities = ContentEditablePossibilities { + can_be_disabled: true, + can_be_enabled: true, + can_be_inherited: true, +}; + +fn can_content_editable_be_tabbable<'a>( + node: &AstNode<'a>, + opening_element: &'a JSXOpeningElement<'a>, + ctx: &LintContext<'a>, +) -> bool { + let Some(possibilities) = get_content_editable_possibilities(opening_element, ctx) else { + return false; + }; + possibilities.can_be_enabled && !has_definitely_enabled_content_editable_ancestor(node, ctx) +} + +fn has_definitely_enabled_content_editable_ancestor<'a>( + node: &AstNode<'a>, + ctx: &LintContext<'a>, +) -> bool { + let mut did_skip_current_element = false; + for ancestor in ctx.nodes().ancestors(node.id()) { + let AstKind::JSXElement(element) = ancestor.kind() else { + continue; + }; + if !did_skip_current_element { + did_skip_current_element = true; + continue; + } + if !HTML_TAG.contains(get_element_type(ctx, &element.opening_element).as_ref()) { + return false; + } + let Some(possibilities) = get_content_editable_possibilities(&element.opening_element, ctx) + else { + continue; + }; + if possibilities.can_be_enabled + && !possibilities.can_be_disabled + && !possibilities.can_be_inherited + { + return true; + } + if !(possibilities.can_be_inherited + && !possibilities.can_be_disabled + && !possibilities.can_be_enabled) + { + return false; + } + } + false +} + +fn get_content_editable_possibilities<'a>( + opening_element: &'a JSXOpeningElement<'a>, + ctx: &LintContext<'a>, +) -> Option { + let JSXAttributeItem::Attribute(attribute) = + has_jsx_prop_ignore_case(opening_element, "contenteditable")? + else { + return Some(UNKNOWN_CONTENT_EDITABLE); + }; + let Some(value) = attribute.value.as_ref() else { + return Some(ENABLED_CONTENT_EDITABLE); + }; + Some(match value { + JSXAttributeValue::StringLiteral(string_literal) => { + content_editable_string_value(string_literal.value.as_str()) + } + JSXAttributeValue::ExpressionContainer(container) => container + .expression + .as_expression() + .map_or(UNKNOWN_CONTENT_EDITABLE, |expression| { + resolve_content_editable_expression(expression, ctx, &mut Vec::new()) + }), + _ => UNKNOWN_CONTENT_EDITABLE, + }) +} + +fn resolve_content_editable_expression<'a>( + expression: &'a Expression<'a>, + ctx: &LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> ContentEditablePossibilities { + match expression.get_inner_expression() { + Expression::BooleanLiteral(boolean_literal) => { + if boolean_literal.value { + ENABLED_CONTENT_EDITABLE + } else { + DISABLED_CONTENT_EDITABLE + } + } + Expression::StringLiteral(string_literal) => { + content_editable_string_value(string_literal.value.as_str()) + } + Expression::ConditionalExpression(conditional_expression) => merge_content_editable( + resolve_content_editable_expression( + &conditional_expression.consequent, + ctx, + &mut visited_symbol_ids.clone(), + ), + resolve_content_editable_expression( + &conditional_expression.alternate, + ctx, + &mut visited_symbol_ids.clone(), + ), + ), + Expression::Identifier(identifier) => { + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return UNKNOWN_CONTENT_EDITABLE; + }; + if visited_symbol_ids.contains(&symbol_id) { + return UNKNOWN_CONTENT_EDITABLE; + } + let declaration = ctx.symbol_declaration(symbol_id); + let AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return UNKNOWN_CONTENT_EDITABLE; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return UNKNOWN_CONTENT_EDITABLE; + }; + if !variable_declaration.kind.is_const() + || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return UNKNOWN_CONTENT_EDITABLE; + } + let Some(initializer) = declarator.init.as_ref() else { + return UNKNOWN_CONTENT_EDITABLE; + }; + visited_symbol_ids.push(symbol_id); + let possibilities = + resolve_content_editable_expression(initializer, ctx, visited_symbol_ids); + visited_symbol_ids.pop(); + possibilities + } + Expression::NullLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) => INHERITED_CONTENT_EDITABLE, + _ => UNKNOWN_CONTENT_EDITABLE, + } +} + +fn content_editable_string_value(value: &str) -> ContentEditablePossibilities { + match value.to_ascii_lowercase().as_str() { + "" | "plaintext-only" | "true" => ENABLED_CONTENT_EDITABLE, + "false" => DISABLED_CONTENT_EDITABLE, + _ => INHERITED_CONTENT_EDITABLE, + } +} + +fn merge_content_editable( + left: ContentEditablePossibilities, + right: ContentEditablePossibilities, +) -> ContentEditablePossibilities { + ContentEditablePossibilities { + can_be_disabled: left.can_be_disabled || right.can_be_disabled, + can_be_enabled: left.can_be_enabled || right.can_be_enabled, + can_be_inherited: left.can_be_inherited || right.can_be_inherited, + } +} diff --git a/native/oxlint/rules/aria-braille-equivalent.rs b/native/oxlint/rules/aria-braille-equivalent.rs new file mode 100644 index 0000000000..acb2f5bcd7 --- /dev/null +++ b/native/oxlint/rules/aria-braille-equivalent.rs @@ -0,0 +1,98 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, rule::Rule}; + +const ROLE_DESCRIPTION_MESSAGE: &str = "This braille role description has no non-braille equivalent. Add a nonempty aria-roledescription for other assistive technologies."; +const LABEL_MESSAGE: &str = "This braille label is the element's only accessible name. Add visible text, aria-label, aria-labelledby, alt, or title for non-braille assistive technology."; +const ACCESSIBLE_NAME_ATTRIBUTES: [&str; 4] = ["aria-label", "aria-labelledby", "alt", "title"]; + +#[derive(Debug, Default, Clone)] +pub struct AriaBrailleEquivalent; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LiteralAttributeState { + Missing, + Dynamic, + Empty, + Nonempty, +} + +declare_oxc_lint!( + /// Require non-braille equivalents for braille labels. + AriaBrailleEquivalent, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require non-braille equivalents for braille labels.", +); + +impl Rule for AriaBrailleEquivalent { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &crate::context::LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let opening_element = &element.opening_element; + if opening_element.attributes.iter().any(|attribute| { + matches!( + attribute, + oxc_ast::ast::JSXAttributeItem::SpreadAttribute(_) + ) + }) { + return; + } + if literal_attribute_state(opening_element, "aria-brailleroledescription") + == LiteralAttributeState::Nonempty + && matches!( + literal_attribute_state(opening_element, "aria-roledescription"), + LiteralAttributeState::Missing | LiteralAttributeState::Empty + ) + && let Some(attribute) = get_authoritative_jsx_attribute( + opening_element, + "aria-brailleroledescription", + false, + ) + { + ctx.diagnostic( + OxcDiagnostic::warn(ROLE_DESCRIPTION_MESSAGE).with_label(attribute.span), + ); + } + if literal_attribute_state(opening_element, "aria-braillelabel") + != LiteralAttributeState::Nonempty + || object_has_accessible_child(element, ctx) + || ACCESSIBLE_NAME_ATTRIBUTES.iter().any(|attribute_name| { + matches!( + literal_attribute_state(opening_element, attribute_name), + LiteralAttributeState::Dynamic | LiteralAttributeState::Nonempty + ) + }) + { + return; + } + let Some(attribute) = + get_authoritative_jsx_attribute(opening_element, "aria-braillelabel", false) + else { + return; + }; + ctx.diagnostic(OxcDiagnostic::warn(LABEL_MESSAGE).with_label(attribute.span)); + } +} + +fn literal_attribute_state( + opening_element: &oxc_ast::ast::JSXOpeningElement, + attribute_name: &str, +) -> LiteralAttributeState { + let Some(attribute) = get_authoritative_jsx_attribute(opening_element, attribute_name, false) + else { + return LiteralAttributeState::Missing; + }; + let Some(value) = get_string_literal_attribute_value(attribute) else { + return LiteralAttributeState::Dynamic; + }; + if value.trim().is_empty() { + LiteralAttributeState::Empty + } else { + LiteralAttributeState::Nonempty + } +} diff --git a/native/oxlint/rules/aria-props.rs b/native/oxlint/rules/aria-props.rs new file mode 100644 index 0000000000..70d71da8b8 --- /dev/null +++ b/native/oxlint/rules/aria-props.rs @@ -0,0 +1,37 @@ +use oxc_ast::{AstKind, ast::JSXAttributeName}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, globals::is_valid_aria_property, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct AriaProps; + +declare_oxc_lint!( + /// Disallow invalid ARIA attributes. + AriaProps, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow invalid ARIA attributes.", +); + +impl Rule for AriaProps { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXAttribute(attribute) = node.kind() else { + return; + }; + let JSXAttributeName::Identifier(identifier) = &attribute.name else { + return; + }; + let name = identifier.name.as_str(); + if name.starts_with("aria-") && !is_valid_aria_property(name) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Screen reader users get no help from `{name}` because it isn't a real ARIA attribute, so fix the spelling against the WAI-ARIA list." + )) + .with_label(identifier.span), + ); + } + } +} diff --git a/native/oxlint/rules/aria-role.rs b/native/oxlint/rules/aria-role.rs new file mode 100644 index 0000000000..abdaac3e55 --- /dev/null +++ b/native/oxlint/rules/aria-role.rs @@ -0,0 +1,129 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeValue, JSXExpression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::LintContext, + globals::{HTML_TAG, VALID_ARIA_ROLES}, + rule::Rule, + utils::{get_element_type, has_jsx_prop_ignore_case}, +}; + +const BASE_MESSAGE: &str = "This `role` is not a valid ARIA role, so assistive tech cannot expose it correctly. Use a real, non-abstract role."; + +#[derive(Debug, Default, Clone)] +pub struct AriaRole; + +declare_oxc_lint!( + /// Require valid, non-abstract ARIA roles. + AriaRole, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require valid, non-abstract ARIA roles.", +); + +impl Rule for AriaRole { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let Some(JSXAttributeItem::Attribute(role_attribute)) = + has_jsx_prop_ignore_case(opening_element, "role") + else { + return; + }; + if ignore_non_dom(ctx) + && !HTML_TAG.contains(get_element_type(ctx, opening_element).as_ref()) + { + return; + } + match role_attribute.value.as_ref() { + None => report_invalid_role(role_attribute.span, None, ctx), + Some(JSXAttributeValue::StringLiteral(string_literal)) => { + report_first_invalid_candidate( + &[string_literal.value.to_string()], + role_attribute.span, + ctx, + ); + } + Some(JSXAttributeValue::ExpressionContainer(container)) => { + if matches!(container.expression, JSXExpression::NullLiteral(_)) + || container.expression.is_undefined() + { + report_invalid_role(role_attribute.span, None, ctx); + return; + } + if let Some(candidates) = + get_static_jsx_attribute_string_values(role_attribute, ctx) + { + report_first_invalid_candidate(&candidates, role_attribute.span, ctx); + } + } + Some(_) => report_invalid_role(role_attribute.span, None, ctx), + } + } +} + +fn report_first_invalid_candidate(candidates: &[String], span: oxc_span::Span, ctx: &LintContext) { + let allowed_invalid_roles = allowed_invalid_roles(ctx); + for candidate in candidates { + if candidate.trim().is_empty() { + report_invalid_role(span, None, ctx); + return; + } + if let Some(invalid_role) = candidate.split_whitespace().find(|role| { + !VALID_ARIA_ROLES.contains(*role) + && !allowed_invalid_roles.iter().any(|allowed| allowed == role) + }) { + report_invalid_role(span, Some(invalid_role), ctx); + return; + } + } +} + +fn report_invalid_role(span: oxc_span::Span, invalid_role: Option<&str>, ctx: &LintContext) { + let message = invalid_role.map_or_else( + || BASE_MESSAGE.to_string(), + |role| format!("{BASE_MESSAGE} `{role}` is not one."), + ); + ctx.diagnostic(OxcDiagnostic::warn(message).with_label(span)); +} + +fn react_doctor_settings<'a>( + ctx: &'a LintContext<'_>, +) -> Option<&'a serde_json::Map> { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) +} + +fn ignore_non_dom(ctx: &LintContext) -> bool { + react_doctor_settings(ctx) + .and_then(|settings| settings.get("ariaRole")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("ignoreNonDOM")) + .and_then(serde_json::Value::as_bool) + .unwrap_or_else(|| { + react_doctor_settings(ctx) + .and_then(|settings| settings.get("portedRuleMode")) + .and_then(serde_json::Value::as_str) + == Some("curated") + }) +} + +fn allowed_invalid_roles<'a>(ctx: &'a LintContext<'_>) -> Vec<&'a str> { + react_doctor_settings(ctx) + .and_then(|settings| settings.get("ariaRole")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("allowedInvalidRoles")) + .and_then(serde_json::Value::as_array) + .map(|roles| roles.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default() +} diff --git a/native/oxlint/rules/aria-unsupported-elements.rs b/native/oxlint/rules/aria-unsupported-elements.rs new file mode 100644 index 0000000000..6c5df1e4db --- /dev/null +++ b/native/oxlint/rules/aria-unsupported-elements.rs @@ -0,0 +1,52 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, context::LintContext, globals::RESERVED_HTML_TAG, rule::Rule, utils::get_element_type, +}; + +#[derive(Debug, Default, Clone)] +pub struct AriaUnsupportedElements; + +declare_oxc_lint!( + /// Disallow ARIA attributes on unsupported elements. + AriaUnsupportedElements, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow ARIA attributes on unsupported elements.", +); + +impl Rule for AriaUnsupportedElements { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let tag_name = get_element_type(ctx, opening_element); + if !RESERVED_HTML_TAG.contains(&tag_name.as_ref()) { + return; + } + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(identifier) = &attribute.name else { + continue; + }; + let attribute_name = identifier.name.as_str(); + if attribute_name != "role" && !attribute_name.starts_with("aria-") { + continue; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Screen reader users get no help from `{attribute_name}` because `<{tag_name}>` doesn't accept it, so remove it from this element." + )) + .with_label(attribute.span), + ); + } + } +} diff --git a/native/oxlint/rules/circular-dependency.rs b/native/oxlint/rules/circular-dependency.rs new file mode 100644 index 0000000000..60a0e92e37 --- /dev/null +++ b/native/oxlint/rules/circular-dependency.rs @@ -0,0 +1,17 @@ +use oxc_macros::declare_oxc_lint; + +use crate::rule::Rule; + +#[derive(Debug, Default, Clone)] +pub struct CircularDependency; + +declare_oxc_lint!( + /// Project-analysis-owned circular dependency rule. + CircularDependency, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Project-analysis-owned circular dependency rule.", +); + +impl Rule for CircularDependency {} diff --git a/native/oxlint/rules/collect-axis-shorthand-values.rs b/native/oxlint/rules/collect-axis-shorthand-values.rs new file mode 100644 index 0000000000..ca8472a898 --- /dev/null +++ b/native/oxlint/rules/collect-axis-shorthand-values.rs @@ -0,0 +1,68 @@ +fn collect_axis_shorthand_values(class_name_value: &str, axis_prefix: &str) -> Vec { + let mut values = Vec::new(); + for (start_index, character) in class_name_value.char_indices() { + if start_index > 0 { + let previous_character = class_name_value[..start_index].chars().next_back(); + if !previous_character.is_some_and(char::is_whitespace) { + continue; + } + } + if character.is_whitespace() { + continue; + } + if let Some(value) = get_axis_shorthand_value(&class_name_value[start_index..], axis_prefix) + { + values.push(value); + } + } + values +} + +fn get_axis_shorthand_value(class_token: &str, axis_prefix: &str) -> Option { + let (sign, unsigned_token) = class_token + .strip_prefix('-') + .map_or(("", class_token), |value| ("-", value)); + let value_with_suffix = unsigned_token.strip_prefix(axis_prefix)?.strip_prefix('-')?; + let value_end_index = if value_with_suffix.starts_with('[') { + value_with_suffix.find(']')? + 1 + } else { + get_numeric_axis_value_end_index(value_with_suffix)? + }; + let suffix = &value_with_suffix[value_end_index..]; + if !suffix.is_empty() + && !suffix.starts_with(':') + && !suffix.chars().next().is_some_and(char::is_whitespace) + { + return None; + } + Some(format!("{sign}{}", &value_with_suffix[..value_end_index])) +} + +fn get_numeric_axis_value_end_index(value: &str) -> Option { + let mut byte_index = 0; + while value + .as_bytes() + .get(byte_index) + .is_some_and(u8::is_ascii_digit) + { + byte_index += 1; + } + if byte_index == 0 { + return None; + } + if value.as_bytes().get(byte_index) == Some(&b'.') { + let decimal_start_index = byte_index + 1; + byte_index = decimal_start_index; + while value + .as_bytes() + .get(byte_index) + .is_some_and(u8::is_ascii_digit) + { + byte_index += 1; + } + if byte_index == decimal_start_index { + return None; + } + } + Some(byte_index) +} diff --git a/native/oxlint/rules/collect-static-jsx-opening-elements.rs b/native/oxlint/rules/collect-static-jsx-opening-elements.rs new file mode 100644 index 0000000000..b7eec1cdb6 --- /dev/null +++ b/native/oxlint/rules/collect-static-jsx-opening-elements.rs @@ -0,0 +1,17 @@ +fn collect_static_jsx_opening_elements<'a>( + children: &'a [oxc_ast::ast::JSXChild<'a>], + opening_elements: &mut Vec<&'a oxc_ast::ast::JSXOpeningElement<'a>>, +) { + for child in children { + match child { + oxc_ast::ast::JSXChild::Element(element) => { + opening_elements.push(&element.opening_element); + collect_static_jsx_opening_elements(&element.children, opening_elements); + } + oxc_ast::ast::JSXChild::Fragment(fragment) => { + collect_static_jsx_opening_elements(&fragment.children, opening_elements); + } + _ => {} + } + } +} diff --git a/native/oxlint/rules/component-or-hook-function-name.rs b/native/oxlint/rules/component-or-hook-function-name.rs new file mode 100644 index 0000000000..de6ad4bc36 --- /dev/null +++ b/native/oxlint/rules/component-or-hook-function-name.rs @@ -0,0 +1,43 @@ +fn component_or_hook_function_name<'a, 'b>( + function_node: &'b crate::AstNode<'a>, + ctx: &'b crate::context::LintContext<'a>, +) -> Option<&'b str> { + if let oxc_ast::AstKind::Function(function) = function_node.kind() + && let Some(identifier) = &function.id + && crate::utils::is_react_component_or_hook_name(identifier.name.as_str()) + { + return Some(identifier.name.as_str()); + } + let mut expression_root = transparent_expression_root(function_node, ctx); + loop { + let parent = ctx.nodes().parent_node(expression_root.id()); + let oxc_ast::AstKind::CallExpression(call_expression) = parent.kind() else { + break; + }; + let is_first_argument = call_expression.arguments.first().is_some_and(|argument| { + argument.as_expression().is_some_and(|expression| { + oxc_span::GetSpan::span(expression) == oxc_span::GetSpan::span(expression_root) + }) + }); + if !is_first_argument + || !matches!( + call_expression.callee_name(), + Some("memo" | "forwardRef" | "observer" | "lazy") + ) + { + break; + } + expression_root = transparent_expression_root(parent, ctx); + } + let parent = ctx.nodes().parent_node(expression_root.id()); + let oxc_ast::AstKind::VariableDeclarator(declarator) = parent.kind() else { + return None; + }; + declarator + .id + .get_binding_identifier() + .filter(|identifier| { + crate::utils::is_react_component_or_hook_name(identifier.name.as_str()) + }) + .map(|identifier| identifier.name.as_str()) +} diff --git a/native/oxlint/rules/design-no-em-dash-in-jsx-text.rs b/native/oxlint/rules/design-no-em-dash-in-jsx-text.rs new file mode 100644 index 0000000000..b5f83fdb5d --- /dev/null +++ b/native/oxlint/rules/design-no-em-dash-in-jsx-text.rs @@ -0,0 +1,122 @@ +use lazy_regex::{Lazy, Regex, lazy_regex}; +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const EM_DASH_PROSE_MIN_WORD_COUNT: usize = 5; +const MESSAGE: &str = "Em dash (—) in UI text reads like AI output to your users."; +const LONG_FORM_CONTENT_PATH_SEGMENTS: [&str; 9] = [ + "article", + "articles", + "blog", + "changelog", + "content", + "doc", + "docs", + "post", + "posts", +]; +static EM_DASH_ENTITY_PATTERN: Lazy = lazy_regex!(r"(?i)&(?:mdash|#0*8212|#x0*2014);"); +static PROSE_EM_DASH_PATTERN: Lazy = lazy_regex!(r"\p{L}[^—\n]*—[^—\n]*\p{L}"); +static LETTER_WORD_PATTERN: Lazy = lazy_regex!(r"\p{L}+"); + +#[derive(Debug, Default, Clone)] +pub struct DesignNoEmDashInJsxText; + +declare_oxc_lint!( + /// Disallow em dashes embedded in UI prose. + DesignNoEmDashInJsxText, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow em dashes embedded in UI prose.", +); + +impl Rule for DesignNoEmDashInJsxText { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) && !is_long_form_content_path(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXExpressionContainer(container) => { + if matches!( + ctx.nodes().parent_node(node.id()).kind(), + AstKind::JSXAttribute(_) + ) || is_inside_excluded_typography_ancestor(node, ctx) + || is_inside_statically_hidden_jsx_subtree(node, ctx) + { + return; + } + let Some(expression) = container.expression.as_expression() else { + return; + }; + if has_prose_em_dash_in_static_expression(expression) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(container.span)); + } + } + AstKind::JSXText(text) => { + let rendered_text = EM_DASH_ENTITY_PATTERN.replace_all(text.value.as_str(), "—"); + if has_prose_em_dash(&rendered_text) + && !is_inside_excluded_typography_ancestor(node, ctx) + && !is_inside_statically_hidden_jsx_subtree(node, ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(text.span)); + } + } + _ => {} + } + } +} + +fn is_long_form_content_path(ctx: &ContextHost) -> bool { + ctx.file_path() + .to_string_lossy() + .split(['/', '\\']) + .any(|segment| { + LONG_FORM_CONTENT_PATH_SEGMENTS + .iter() + .any(|candidate| segment.eq_ignore_ascii_case(candidate)) + }) +} + +fn has_prose_em_dash(text: &str) -> bool { + text.contains('—') + && text.split(['\r', '\n']).any(|line| { + PROSE_EM_DASH_PATTERN.is_match(line) + && LETTER_WORD_PATTERN.find_iter(line).count() >= EM_DASH_PROSE_MIN_WORD_COUNT + }) +} + +fn has_prose_em_dash_in_static_expression(expression: &oxc_ast::ast::Expression) -> bool { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + has_prose_em_dash(string_literal.value.as_str()) + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) => { + template_literal.quasis.iter().any(|quasi| { + has_prose_em_dash( + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()), + ) + }) + } + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => { + has_prose_em_dash_in_static_expression(&conditional_expression.consequent) + || has_prose_em_dash_in_static_expression(&conditional_expression.alternate) + } + oxc_ast::ast::Expression::LogicalExpression(logical_expression) => { + has_prose_em_dash_in_static_expression(&logical_expression.right) + } + _ => false, + } +} diff --git a/native/oxlint/rules/design-no-redundant-padding-axes.rs b/native/oxlint/rules/design-no-redundant-padding-axes.rs new file mode 100644 index 0000000000..90c439f88f --- /dev/null +++ b/native/oxlint/rules/design-no-redundant-padding-axes.rs @@ -0,0 +1,67 @@ +use std::collections::HashSet; + +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +#[derive(Debug, Default, Clone)] +pub struct DesignNoRedundantPaddingAxes; + +declare_oxc_lint!( + /// Disallow matching horizontal and vertical padding utilities. + DesignNoRedundantPaddingAxes, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow matching horizontal and vertical padding utilities.", +); + +impl Rule for DesignNoRedundantPaddingAxes { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXAttribute(attribute) = node.kind() else { + return; + }; + let oxc_ast::ast::JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + return; + }; + if attribute_name.name != "className" { + return; + } + let Some(class_name_value) = get_string_literal_attribute_value(attribute) else { + return; + }; + if !class_name_value.contains("px-") || !class_name_value.contains("py-") { + return; + } + if has_responsive_axis_prefix(class_name_value, "px") + || has_responsive_axis_prefix(class_name_value, "py") + { + return; + } + let horizontal_values: HashSet = + collect_axis_shorthand_values(class_name_value, "px") + .into_iter() + .collect(); + for vertical_value in collect_axis_shorthand_values(class_name_value, "py") { + if !horizontal_values.contains(&vertical_value) { + continue; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "px-{vertical_value} and py-{vertical_value} duplicate p-{vertical_value}, so the class list is noisier without changing spacing." + )) + .with_label(attribute.span), + ); + } + } +} diff --git a/native/oxlint/rules/design-no-redundant-size-axes.rs b/native/oxlint/rules/design-no-redundant-size-axes.rs new file mode 100644 index 0000000000..e02cafe68a --- /dev/null +++ b/native/oxlint/rules/design-no-redundant-size-axes.rs @@ -0,0 +1,70 @@ +use std::collections::HashSet; + +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, +}; + +#[derive(Debug, Default, Clone)] +pub struct DesignNoRedundantSizeAxes; + +declare_oxc_lint!( + /// Disallow matching width and height utilities. + DesignNoRedundantSizeAxes, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow matching width and height utilities.", +); + +impl Rule for DesignNoRedundantSizeAxes { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run_once(&self, ctx: &LintContext<'_>) { + for node in ctx.nodes().iter() { + let AstKind::JSXAttribute(attribute) = node.kind() else { + continue; + }; + let oxc_ast::ast::JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + continue; + }; + if attribute_name.name != "className" { + continue; + } + let Some(class_name_value) = get_string_literal_attribute_value(attribute) else { + continue; + }; + if !class_name_value.contains("w-") || !class_name_value.contains("h-") { + continue; + } + if has_responsive_axis_prefix(class_name_value, "w") + || has_responsive_axis_prefix(class_name_value, "h") + { + continue; + } + let width_values: HashSet = + collect_axis_shorthand_values(class_name_value, "w") + .into_iter() + .collect(); + let Some(matched_value) = collect_axis_shorthand_values(class_name_value, "h") + .into_iter() + .find(|height_value| width_values.contains(height_value)) + else { + continue; + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "w-{matched_value} and h-{matched_value} duplicate size-{matched_value}, so the class list is noisier without changing layout." + )) + .with_label(attribute.span), + ); + return; + } + } +} diff --git a/native/oxlint/rules/design-no-space-on-flex-children.rs b/native/oxlint/rules/design-no-space-on-flex-children.rs new file mode 100644 index 0000000000..96cb5db9c9 --- /dev/null +++ b/native/oxlint/rules/design-no-space-on-flex-children.rs @@ -0,0 +1,73 @@ +use lazy_regex::{Lazy, Regex, lazy_regex}; +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +static SPACE_AXIS_PATTERN: Lazy = lazy_regex!( + r"(?:^|\s)(?:-)?space-(x|y)-(\d+(?:\.\d+)?|\[[^\]]+\])(?:$|[\s:])" +); + +#[derive(Debug, Default, Clone)] +pub struct DesignNoSpaceOnFlexChildren; + +declare_oxc_lint!( + /// Disallow space utilities on flex and grid parents. + DesignNoSpaceOnFlexChildren, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow space utilities on flex and grid parents.", +); + +impl Rule for DesignNoSpaceOnFlexChildren { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXAttribute(attribute) = node.kind() else { + return; + }; + let oxc_ast::ast::JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + return; + }; + if attribute_name.name != "className" { + return; + } + let Some(class_name_value) = get_string_literal_attribute_value(attribute) else { + return; + }; + if !class_name_value.contains("space-") || !has_flex_or_grid_class(class_name_value) { + return; + } + let Some(space_match) = SPACE_AXIS_PATTERN.captures(class_name_value) else { + return; + }; + let (Some(space_axis), Some(space_value)) = (space_match.get(1), space_match.get(2)) else { + return; + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "space-{}-{} on a flex or grid parent can leave uneven gaps when children hide, wrap, or render in RTL layouts.", + space_axis.as_str(), + space_value.as_str() + )) + .with_label(attribute.span), + ); + } +} + +fn has_flex_or_grid_class(class_name_value: &str) -> bool { + class_name_value.split_whitespace().any(|class_token| { + matches!( + class_token.rsplit(':').next(), + Some("flex" | "inline-flex" | "grid" | "inline-grid") + ) + }) +} diff --git a/native/oxlint/rules/design-no-three-period-ellipsis.rs b/native/oxlint/rules/design-no-three-period-ellipsis.rs new file mode 100644 index 0000000000..809d62944e --- /dev/null +++ b/native/oxlint/rules/design-no-three-period-ellipsis.rs @@ -0,0 +1,120 @@ +use lazy_regex::{Lazy, Regex, lazy_regex}; +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const MESSAGE: &str = "Use the real ellipsis character (\"…\") instead of three period characters."; +static TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN: Lazy = + lazy_regex!(r"[\p{L}\p{N}]\.\.\."); + +#[derive(Debug, Default, Clone)] +pub struct DesignNoThreePeriodEllipsis; + +declare_oxc_lint!( + /// Disallow three periods in user-facing UI text. + DesignNoThreePeriodEllipsis, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow three periods in user-facing UI text.", +); + +impl Rule for DesignNoThreePeriodEllipsis { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXAttribute(attribute) => { + let oxc_ast::ast::JSXAttributeName::Identifier(attribute_name) = &attribute.name + else { + return; + }; + if !is_user_facing_text_attribute(attribute_name.name.as_str()) { + return; + } + let Some(text_value) = get_string_literal_attribute_value(attribute) else { + return; + }; + if !TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN.is_match(text_value) { + return; + } + let span = attribute + .value + .as_ref() + .map_or(attribute.span, GetSpan::span); + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(span)); + } + AstKind::JSXExpressionContainer(container) => { + if matches!( + ctx.nodes().parent_node(node.id()).kind(), + AstKind::JSXAttribute(_) + ) || is_inside_excluded_typography_ancestor(node, ctx) + { + return; + } + let Some(expression) = container.expression.as_expression() else { + return; + }; + report_static_expression_ellipses(expression, ctx); + } + AstKind::JSXText(text) => { + if TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN.is_match(text.value.as_str()) + && !is_inside_excluded_typography_ancestor(node, ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(text.span)); + } + } + _ => {} + } + } +} + +fn is_user_facing_text_attribute(attribute_name: &str) -> bool { + matches!( + attribute_name.to_ascii_lowercase().as_str(), + "alt" | "aria-label" | "placeholder" | "title" + ) +} + +fn report_static_expression_ellipses( + expression: &oxc_ast::ast::Expression, + ctx: &LintContext<'_>, +) { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + if TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN.is_match(string_literal.value.as_str()) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(string_literal.span)); + } + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + let text_value = quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()); + if TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN.is_match(text_value) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(template_literal.span)); + } + } + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => { + report_static_expression_ellipses(&conditional_expression.consequent, ctx); + report_static_expression_ellipses(&conditional_expression.alternate, ctx); + } + oxc_ast::ast::Expression::LogicalExpression(logical_expression) => { + report_static_expression_ellipses(&logical_expression.right, ctx); + } + _ => {} + } +} diff --git a/native/oxlint/rules/design-no-vague-button-label.rs b/native/oxlint/rules/design-no-vague-button-label.rs new file mode 100644 index 0000000000..ab6dd0a38f --- /dev/null +++ b/native/oxlint/rules/design-no-vague-button-label.rs @@ -0,0 +1,173 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const VAGUE_BUTTON_LABELS: [&str; 10] = [ + "continue", + "submit", + "ok", + "okay", + "click here", + "here", + "yes", + "no", + "go", + "done", +]; + +#[derive(Debug, Default, Clone)] +pub struct DesignNoVagueButtonLabel; + +declare_oxc_lint!( + /// Disallow vague button labels. + DesignNoVagueButtonLabel, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Disallow vague button labels.", +); + +impl Rule for DesignNoVagueButtonLabel { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let Some(tag_name) = get_opening_element_tag_name(&element.opening_element, ctx) else { + return; + }; + if !is_button_like_tag_name(tag_name) { + return; + } + let Some(label_text) = collect_jsx_label_text(&element.children) else { + return; + }; + if label_text.is_empty() { + return; + } + let normalized_label = normalize_button_label(&label_text); + if !VAGUE_BUTTON_LABELS.contains(&normalized_label.as_str()) { + return; + } + if normalized_label == "continue" + && has_enclosing_form_with_previous_step_control(node, ctx) + { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Screen reader users may not know what \"{label_text}\" does. Use a specific action label." + )) + .with_label(element.opening_element.span), + ); + } +} + +fn is_button_like_tag_name(tag_name: &str) -> bool { + matches!(tag_name, "button" | "Button") +} + +fn is_previous_step_control_tag_name(tag_name: &str) -> bool { + is_button_like_tag_name(tag_name) || matches!(tag_name, "a" | "Link") +} + +fn normalize_button_label(label_text: &str) -> String { + label_text + .to_lowercase() + .trim_end_matches(['.', '!', '?', '…']) + .trim() + .to_string() +} + +fn collect_jsx_label_text(children: &[oxc_ast::ast::JSXChild]) -> Option { + if children.is_empty() { + return None; + } + let mut label_text = String::new(); + for child in children { + match child { + oxc_ast::ast::JSXChild::Text(text) => label_text.push_str(text.value.as_str()), + oxc_ast::ast::JSXChild::ExpressionContainer(container) => { + let expression = container.expression.as_expression()?; + match expression { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + label_text.push_str(string_literal.value.as_str()); + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() + && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + label_text.push_str( + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()), + ); + } + _ => return None, + } + } + oxc_ast::ast::JSXChild::Fragment(fragment) => { + label_text.push_str(&collect_jsx_label_text(&fragment.children)?); + } + oxc_ast::ast::JSXChild::Element(_) => return None, + _ => {} + } + } + Some(label_text.trim().to_string()) +} + +fn has_enclosing_form_with_previous_step_control<'a>( + node: &AstNode<'a>, + ctx: &LintContext<'a>, +) -> bool { + for ancestor in ctx.nodes().ancestors(node.id()) { + let AstKind::JSXElement(element) = ancestor.kind() else { + continue; + }; + if get_opening_element_tag_name(&element.opening_element, ctx) == Some("form") { + return has_previous_step_control(&element.children, ctx); + } + } + false +} + +fn has_previous_step_control<'a>( + children: &'a [oxc_ast::ast::JSXChild<'a>], + ctx: &LintContext<'a>, +) -> bool { + for child in children { + match child { + oxc_ast::ast::JSXChild::Fragment(fragment) => { + if has_previous_step_control(&fragment.children, ctx) { + return true; + } + } + oxc_ast::ast::JSXChild::Element(element) => { + let tag_name = get_opening_element_tag_name(&element.opening_element, ctx); + if tag_name.is_some_and(is_previous_step_control_tag_name) + && collect_jsx_label_text(&element.children) + .is_some_and(|label_text| matches!(normalize_button_label(&label_text).as_str(), "back" | "previous")) + { + return true; + } + if has_previous_step_control(&element.children, ctx) { + return true; + } + } + _ => {} + } + } + false +} diff --git a/native/oxlint/rules/details-requires-summary.rs b/native/oxlint/rules/details-requires-summary.rs new file mode 100644 index 0000000000..ef22f0a8ac --- /dev/null +++ b/native/oxlint/rules/details-requires-summary.rs @@ -0,0 +1,74 @@ +use oxc_ast::{ + AstKind, + ast::{JSXChild, JSXElement, JSXExpression, JSXOpeningElement}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "This details disclosure has no explicit summary as its first content child, so the browser falls back to an implementation-defined label. Add a meaningful summary first."; + +enum FirstStaticContent<'a> { + Missing, + Element(&'a JSXOpeningElement<'a>), + Dynamic, +} + +#[derive(Debug, Default, Clone)] +pub struct DetailsRequiresSummary; + +declare_oxc_lint!( + /// Require a summary as the first content child of details. + DetailsRequiresSummary, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require a summary as the first content child of details.", +); + +impl Rule for DetailsRequiresSummary { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let Some((element_type, _)) = resolve_jsx_element_type(&element.opening_element, ctx) + else { + return; + }; + if element_type != "details" { + return; + } + match get_first_static_content(element) { + FirstStaticContent::Dynamic => {} + FirstStaticContent::Element(opening_element) => { + if resolve_jsx_element_type(opening_element, ctx) + .is_some_and(|(element_type, _)| element_type == "summary") + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } + FirstStaticContent::Missing => { + ctx.diagnostic( + OxcDiagnostic::warn(MESSAGE).with_label(element.opening_element.span), + ); + } + } + } +} + +fn get_first_static_content<'a>(element: &'a JSXElement<'a>) -> FirstStaticContent<'a> { + for child in &element.children { + match child { + JSXChild::Text(text) if text.value.trim().is_empty() => {} + JSXChild::Element(element) => { + return FirstStaticContent::Element(&element.opening_element); + } + JSXChild::ExpressionContainer(container) + if matches!(container.expression, JSXExpression::EmptyExpression(_)) => {} + _ => return FirstStaticContent::Dynamic, + } + } + FirstStaticContent::Missing +} diff --git a/native/oxlint/rules/dialog-has-accessible-name.rs b/native/oxlint/rules/dialog-has-accessible-name.rs new file mode 100644 index 0000000000..4aa84fb4f2 --- /dev/null +++ b/native/oxlint/rules/dialog-has-accessible-name.rs @@ -0,0 +1,71 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXElementName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, + utils::{get_string_literal_prop_value, has_jsx_prop_ignore_case}, +}; + +const MESSAGE: &str = "This dialog has no accessible name, so screen readers announce it as just “dialog.” Add `aria-label` or point `aria-labelledby` at its heading."; + +#[derive(Debug, Default, Clone)] +pub struct DialogHasAccessibleName; + +declare_oxc_lint!( + /// Require accessible names on dialog elements. + DialogHasAccessibleName, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require accessible names on dialog elements.", +); + +impl Rule for DialogHasAccessibleName { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if !matches!( + &opening_element.name, + JSXElementName::Identifier(_) | JSXElementName::IdentifierReference(_) + ) { + return; + } + let Some((element_name, element_name_span)) = + resolve_jsx_element_type(opening_element, ctx) + else { + return; + }; + if element_name.chars().next().is_some_and(char::is_uppercase) + || (!element_name.eq("dialog") && !has_dialog_role(opening_element)) + || opening_element + .attributes + .iter() + .any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_))) + || ["aria-label", "aria-labelledby", "title"] + .iter() + .any(|attribute_name| { + has_jsx_prop_ignore_case(opening_element, attribute_name).is_some() + }) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(element_name_span)); + } +} + +fn has_dialog_role(opening_element: &oxc_ast::ast::JSXOpeningElement) -> bool { + has_jsx_prop_ignore_case(opening_element, "role") + .and_then(get_string_literal_prop_value) + .is_some_and(|role| matches!(role, "dialog" | "alertdialog")) +} diff --git a/native/oxlint/rules/direct-named-import-matches.rs b/native/oxlint/rules/direct-named-import-matches.rs new file mode 100644 index 0000000000..6cd7960579 --- /dev/null +++ b/native/oxlint/rules/direct-named-import-matches.rs @@ -0,0 +1,27 @@ +fn direct_named_import_matches<'a>( + identifier: &oxc_ast::ast::IdentifierReference<'a>, + expected_export_names: &[&str], + module_sources: &[&str], + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && module_sources.contains(&entry.module_request.name()) + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && matches!( + &entry.import_name, + crate::module_record::ImportImportName::Name(imported_name) + if expected_export_names.contains(&imported_name.name()) + ) + }) +} diff --git a/native/oxlint/rules/duplicate-jsx-subtree.rs b/native/oxlint/rules/duplicate-jsx-subtree.rs new file mode 100644 index 0000000000..96ff78e72b --- /dev/null +++ b/native/oxlint/rules/duplicate-jsx-subtree.rs @@ -0,0 +1,17 @@ +use oxc_macros::declare_oxc_lint; + +use crate::rule::Rule; + +#[derive(Debug, Default, Clone)] +pub struct DuplicateJsxSubtree; + +declare_oxc_lint!( + /// Project-analysis-owned duplicate JSX rule. + DuplicateJsxSubtree, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Project-analysis-owned duplicate JSX rule.", +); + +impl Rule for DuplicateJsxSubtree {} diff --git a/native/oxlint/rules/empty-table-header.rs b/native/oxlint/rules/empty-table-header.rs new file mode 100644 index 0000000000..e8dc4a61c2 --- /dev/null +++ b/native/oxlint/rules/empty-table-header.rs @@ -0,0 +1,65 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, rule::Rule}; + +const MESSAGE: &str = "This table header has no accessible text, so users cannot tell what its cells represent. Add header text or an accessible name."; + +#[derive(Debug, Default, Clone)] +pub struct EmptyTableHeader; + +declare_oxc_lint!( + /// Require accessible text on table headers. + EmptyTableHeader, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require accessible text on table headers.", +); + +impl Rule for EmptyTableHeader { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &crate::context::LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let opening_element = &element.opening_element; + let element_type = resolve_jsx_element_type_name(opening_element, ctx); + if element_type + .as_bytes() + .first() + .is_some_and(u8::is_ascii_uppercase) + || (element_type != "th" && !has_table_header_role(opening_element)) + || object_has_accessible_child(element, ctx) + || has_potential_accessible_name(opening_element) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } +} + +fn has_table_header_role(opening_element: &oxc_ast::ast::JSXOpeningElement) -> bool { + get_authoritative_jsx_attribute(opening_element, "role", false) + .and_then(get_string_literal_attribute_value) + .is_some_and(|role| { + matches!( + role.to_ascii_lowercase().as_str(), + "columnheader" | "rowheader" + ) + }) +} + +fn has_potential_accessible_name(opening_element: &oxc_ast::ast::JSXOpeningElement) -> bool { + ["aria-label", "aria-labelledby"] + .iter() + .any(|attribute_name| { + let Some(attribute) = + get_authoritative_jsx_attribute(opening_element, attribute_name, false) + else { + return false; + }; + get_string_literal_attribute_value(attribute) + .is_none_or(|value| !value.trim().is_empty()) + }) +} diff --git a/native/oxlint/rules/fieldset-requires-legend.rs b/native/oxlint/rules/fieldset-requires-legend.rs new file mode 100644 index 0000000000..db691713ad --- /dev/null +++ b/native/oxlint/rules/fieldset-requires-legend.rs @@ -0,0 +1,69 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXChild}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const FIELD_TAG_NAMES: [&str; 3] = ["input", "select", "textarea"]; +const MESSAGE: &str = "This fieldset groups multiple controls without naming the group. Add a direct legend or an explicit accessible name."; + +#[derive(Debug, Default, Clone)] +pub struct FieldsetRequiresLegend; + +declare_oxc_lint!( + /// Require grouped fieldset controls to have an accessible name. + FieldsetRequiresLegend, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require grouped fieldset controls to have an accessible name.", +); + +impl Rule for FieldsetRequiresLegend { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + let opening_element = &element.opening_element; + if resolve_jsx_element_type(opening_element, ctx) + .is_none_or(|(element_type, _)| element_type != "fieldset") + || opening_element + .attributes + .iter() + .any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_))) + { + return; + } + let mut descendants = Vec::new(); + collect_static_jsx_opening_elements(&element.children, &mut descendants); + let field_count = descendants + .iter() + .filter(|descendant| { + resolve_jsx_element_type(descendant, ctx) + .is_some_and(|(element_type, _)| FIELD_TAG_NAMES.contains(&element_type)) + }) + .take(2) + .count(); + if field_count < 2 { + return; + } + let has_direct_legend = element.children.iter().any(|child| { + matches!( + child, + JSXChild::Element(child_element) + if resolve_jsx_element_type(&child_element.opening_element, ctx) + .is_some_and(|(element_type, _)| element_type == "legend") + ) + }); + if has_direct_legend + || find_jsx_attribute(opening_element, "aria-label").is_some() + || find_jsx_attribute(opening_element, "aria-labelledby").is_some() + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } +} diff --git a/native/oxlint/rules/find-jsx-attribute.rs b/native/oxlint/rules/find-jsx-attribute.rs new file mode 100644 index 0000000000..76b6c86f32 --- /dev/null +++ b/native/oxlint/rules/find-jsx-attribute.rs @@ -0,0 +1,16 @@ +fn find_jsx_attribute<'a, 'b>( + opening_element: &'b oxc_ast::ast::JSXOpeningElement<'a>, + target_name: &str, +) -> Option<&'b oxc_ast::ast::JSXAttribute<'a>> { + opening_element.attributes.iter().find_map(|attribute| { + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = attribute else { + return None; + }; + matches!( + &attribute.name, + oxc_ast::ast::JSXAttributeName::Identifier(identifier) + if identifier.name == target_name + ) + .then_some(attribute.as_ref()) + }) +} diff --git a/native/oxlint/rules/first-js-whitespace-token.rs b/native/oxlint/rules/first-js-whitespace-token.rs new file mode 100644 index 0000000000..b3e3d929ea --- /dev/null +++ b/native/oxlint/rules/first-js-whitespace-token.rs @@ -0,0 +1,6 @@ +fn first_js_whitespace_token(value: &str) -> Option<&str> { + value + .trim_matches(|character| is_js_whitespace(character)) + .split(|character| is_js_whitespace(character)) + .find(|token| !token.is_empty()) +} diff --git a/native/oxlint/rules/for-each-named-import.rs b/native/oxlint/rules/for-each-named-import.rs new file mode 100644 index 0000000000..c71483fb76 --- /dev/null +++ b/native/oxlint/rules/for-each-named-import.rs @@ -0,0 +1,15 @@ +pub fn for_each_named_import( + import_declaration: &oxc_ast::ast::ImportDeclaration<'_>, + mut visit: impl FnMut(&oxc_ast::ast::ImportSpecifier<'_>), +) { + let Some(specifiers) = &import_declaration.specifiers else { + return; + }; + for specifier in specifiers { + if let oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) = + specifier + { + visit(import_specifier); + } + } +} diff --git a/native/oxlint/rules/for-each-value-import.rs b/native/oxlint/rules/for-each-value-import.rs new file mode 100644 index 0000000000..4b4e9ec1c0 --- /dev/null +++ b/native/oxlint/rules/for-each-value-import.rs @@ -0,0 +1,20 @@ +pub fn for_each_value_import( + import_declaration: &oxc_ast::ast::ImportDeclaration<'_>, + mut visit: impl FnMut(&oxc_ast::ast::ImportSpecifier<'_>), +) { + if import_declaration.import_kind.is_type() { + return; + } + let Some(specifiers) = &import_declaration.specifiers else { + return; + }; + for specifier in specifiers { + let oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) = specifier + else { + continue; + }; + if import_specifier.import_kind.is_value() { + visit(import_specifier); + } + } +} diff --git a/native/oxlint/rules/forward-ref-uses-ref.rs b/native/oxlint/rules/forward-ref-uses-ref.rs new file mode 100644 index 0000000000..7da3f8be80 --- /dev/null +++ b/native/oxlint/rules/forward-ref-uses-ref.rs @@ -0,0 +1,53 @@ +use oxc_ast::{ + AstKind, + ast::Argument, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = + "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`."; + +#[derive(Debug, Default, Clone)] +pub struct ForwardRefUsesRef; + +declare_oxc_lint!( + /// Require forwardRef callbacks to accept a ref parameter. + ForwardRefUsesRef, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require forwardRef callbacks to accept a ref parameter.", +); + +impl Rule for ForwardRefUsesRef { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !is_react_api_call(call_expression, "forwardRef", ctx) { + return; + } + let Some(first_argument) = call_expression.arguments.first() else { + return; + }; + let (parameter_count, has_rest, span) = match first_argument { + Argument::ArrowFunctionExpression(function) => ( + function.params.parameters_count(), + function.params.rest.is_some(), + function.span, + ), + Argument::FunctionExpression(function) => ( + function.params.parameters_count(), + function.params.rest.is_some(), + function.span, + ), + _ => return, + }; + if parameter_count == 1 && !has_rest { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(span)); + } + } +} diff --git a/native/oxlint/rules/get-authoritative-jsx-attribute.rs b/native/oxlint/rules/get-authoritative-jsx-attribute.rs new file mode 100644 index 0000000000..cca97028c3 --- /dev/null +++ b/native/oxlint/rules/get-authoritative-jsx-attribute.rs @@ -0,0 +1,83 @@ +fn get_authoritative_jsx_attribute<'a, 'b>( + opening_element: &'b oxc_ast::ast::JSXOpeningElement<'a>, + target_name: &str, + is_case_sensitive: bool, +) -> Option<&'b oxc_ast::ast::JSXAttribute<'a>> { + for attribute in opening_element.attributes.iter().rev() { + match attribute { + oxc_ast::ast::JSXAttributeItem::SpreadAttribute(spread_attribute) + if can_expression_override_jsx_attribute( + &spread_attribute.argument, + target_name, + is_case_sensitive, + ) => + { + return None; + } + oxc_ast::ast::JSXAttributeItem::Attribute(attribute) + if jsx_attribute_name_matches(attribute, target_name, is_case_sensitive) => + { + return Some(attribute); + } + _ => {} + } + } + None +} + +fn can_expression_override_jsx_attribute( + expression: &oxc_ast::ast::Expression, + target_name: &str, + is_case_sensitive: bool, +) -> bool { + let oxc_ast::ast::Expression::ObjectExpression(object_expression) = + expression.get_inner_expression() + else { + return true; + }; + object_expression + .properties + .iter() + .any(|property| match property { + oxc_ast::ast::ObjectPropertyKind::SpreadProperty(spread_property) => { + can_expression_override_jsx_attribute( + &spread_property.argument, + target_name, + is_case_sensitive, + ) + } + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) => { + let Some(property_name) = object_property.key.static_name() else { + return true; + }; + names_match(property_name.as_ref(), target_name, is_case_sensitive) + } + }) +} + +fn jsx_attribute_name_matches( + attribute: &oxc_ast::ast::JSXAttribute, + target_name: &str, + is_case_sensitive: bool, +) -> bool { + match &attribute.name { + oxc_ast::ast::JSXAttributeName::Identifier(identifier) => { + names_match(identifier.name.as_str(), target_name, is_case_sensitive) + } + oxc_ast::ast::JSXAttributeName::NamespacedName(namespaced_name) => { + let attribute_name = format!( + "{}:{}", + namespaced_name.namespace.name, namespaced_name.name.name + ); + names_match(&attribute_name, target_name, is_case_sensitive) + } + } +} + +fn names_match(left: &str, right: &str, is_case_sensitive: bool) -> bool { + if is_case_sensitive { + left == right + } else { + left.eq_ignore_ascii_case(right) + } +} diff --git a/native/oxlint/rules/get-direct-string-literal-attribute-value.rs b/native/oxlint/rules/get-direct-string-literal-attribute-value.rs new file mode 100644 index 0000000000..0bb675f70d --- /dev/null +++ b/native/oxlint/rules/get-direct-string-literal-attribute-value.rs @@ -0,0 +1,8 @@ +fn get_direct_string_literal_attribute_value<'a>( + value: &'a oxc_ast::ast::JSXAttributeValue<'a>, +) -> Option<&'a str> { + let oxc_ast::ast::JSXAttributeValue::StringLiteral(string_literal) = value else { + return None; + }; + Some(string_literal.value.as_str()) +} diff --git a/native/oxlint/rules/get-effective-nonzero-tailwind-tracking.rs b/native/oxlint/rules/get-effective-nonzero-tailwind-tracking.rs new file mode 100644 index 0000000000..123304020e --- /dev/null +++ b/native/oxlint/rules/get-effective-nonzero-tailwind-tracking.rs @@ -0,0 +1,66 @@ +fn get_effective_nonzero_tailwind_tracking<'a>( + tokens: &'a [TailwindClassNameToken<'a>], +) -> Option<&'a str> { + let effective_utility = get_effective_tailwind_class_name_token(tokens, |utility| { + utility.starts_with("tracking-") || utility.starts_with("-tracking-") + })?; + if matches!( + effective_utility, + "tracking-tight" + | "tracking-tighter" + | "tracking-wide" + | "tracking-wider" + | "tracking-widest" + ) { + return Some(effective_utility); + } + parse_arbitrary_tracking_value(effective_utility) + .is_some_and(|value| value != 0.0) + .then_some(effective_utility) +} + +fn parse_arbitrary_tracking_value(utility: &str) -> Option { + const LENGTH_UNITS: [&str; 31] = [ + "cap", "ch", "cm", "dvh", "dvw", "em", "ex", "ic", "in", "lh", "lvh", "lvw", "mm", "pc", + "pt", "px", "q", "rcap", "rch", "rem", "rex", "ric", "rlh", "svh", "svw", "vb", "vh", "vi", + "vmax", "vmin", "vw", + ]; + let lowercase_utility = utility.to_ascii_lowercase(); + let arbitrary_value = lowercase_utility + .strip_prefix("tracking-[") + .or_else(|| lowercase_utility.strip_prefix("-tracking-["))? + .strip_suffix(']')?; + let arbitrary_value = arbitrary_value + .strip_prefix("length:") + .unwrap_or(arbitrary_value); + let unit = LENGTH_UNITS + .iter() + .filter(|unit| arbitrary_value.ends_with(**unit)) + .max_by_key(|unit| unit.len())?; + let number = &arbitrary_value[..arbitrary_value.len() - unit.len()]; + is_signed_decimal_literal(number) + .then(|| number.parse::().ok()) + .flatten() +} + +fn is_signed_decimal_literal(value: &str) -> bool { + let unsigned_value = value.strip_prefix('-').unwrap_or(value); + if unsigned_value.is_empty() { + return false; + } + let mut decimal_point_count = 0; + let mut digit_count = 0; + for byte in unsigned_value.bytes() { + if byte.is_ascii_digit() { + digit_count += 1; + } else if byte == b'.' { + decimal_point_count += 1; + if decimal_point_count > 1 { + return false; + } + } else { + return false; + } + } + digit_count > 0 +} diff --git a/native/oxlint/rules/get-effective-static-style-property-string-value.rs b/native/oxlint/rules/get-effective-static-style-property-string-value.rs new file mode 100644 index 0000000000..185d760705 --- /dev/null +++ b/native/oxlint/rules/get-effective-static-style-property-string-value.rs @@ -0,0 +1,21 @@ +fn get_effective_static_style_property_string_value<'a>( + object_expression: &'a oxc_ast::ast::ObjectExpression<'a>, + target_name: &str, +) -> Option<(&'a oxc_ast::ast::ObjectProperty<'a>, &'a str)> { + for property in object_expression.properties.iter().rev() { + let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) = property else { + return None; + }; + let Some(property_name) = object_property.key.static_name() else { + return None; + }; + if property_name != target_name { + continue; + } + let oxc_ast::ast::Expression::StringLiteral(string_literal) = &object_property.value else { + return None; + }; + return Some((object_property, string_literal.value.as_str())); + } + None +} diff --git a/native/oxlint/rules/get-effective-static-style-property.rs b/native/oxlint/rules/get-effective-static-style-property.rs new file mode 100644 index 0000000000..ddd5480106 --- /dev/null +++ b/native/oxlint/rules/get-effective-static-style-property.rs @@ -0,0 +1,17 @@ +fn get_effective_static_style_property<'a>( + object_expression: &'a oxc_ast::ast::ObjectExpression<'a>, + target_name: &str, +) -> Option<&'a oxc_ast::ast::ObjectProperty<'a>> { + for property in object_expression.properties.iter().rev() { + let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) = property else { + return None; + }; + let Some(property_name) = object_property.key.static_name() else { + return None; + }; + if property_name == target_name { + return Some(object_property); + } + } + None +} diff --git a/native/oxlint/rules/get-effective-tailwind-class-name-token.rs b/native/oxlint/rules/get-effective-tailwind-class-name-token.rs new file mode 100644 index 0000000000..2ad97a5cc7 --- /dev/null +++ b/native/oxlint/rules/get-effective-tailwind-class-name-token.rs @@ -0,0 +1,21 @@ +fn get_effective_tailwind_class_name_token<'a>( + tokens: &'a [TailwindClassNameToken<'a>], + predicate: impl Fn(&str) -> bool, +) -> Option<&'a str> { + let applicable_tokens = tokens + .iter() + .filter(|token| token.variants.is_empty() && predicate(token.utility)) + .collect::>(); + let has_important_token = applicable_tokens.iter().any(|token| token.is_important); + let mut effective_utility = None; + for token in applicable_tokens { + if has_important_token && !token.is_important { + continue; + } + if effective_utility.is_some_and(|utility| utility != token.utility) { + return None; + } + effective_utility = Some(token.utility); + } + effective_utility +} diff --git a/native/oxlint/rules/get-inline-style-object-expression.rs b/native/oxlint/rules/get-inline-style-object-expression.rs new file mode 100644 index 0000000000..1b7b0a7adf --- /dev/null +++ b/native/oxlint/rules/get-inline-style-object-expression.rs @@ -0,0 +1,19 @@ +fn get_inline_style_object_expression<'a, 'b>( + attribute: &'b oxc_ast::ast::JSXAttribute<'a>, +) -> Option<&'b oxc_ast::ast::ObjectExpression<'a>> { + let oxc_ast::ast::JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + return None; + }; + if attribute_name.name != "style" { + return None; + } + let Some(oxc_ast::ast::JSXAttributeValue::ExpressionContainer(container)) = &attribute.value + else { + return None; + }; + let oxc_ast::ast::JSXExpression::ObjectExpression(object_expression) = &container.expression + else { + return None; + }; + Some(object_expression) +} diff --git a/native/oxlint/rules/get-opening-element-tag-name.rs b/native/oxlint/rules/get-opening-element-tag-name.rs new file mode 100644 index 0000000000..cb27fb17de --- /dev/null +++ b/native/oxlint/rules/get-opening-element-tag-name.rs @@ -0,0 +1,11 @@ +fn get_opening_element_tag_name<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, + ctx: &crate::context::LintContext<'a>, +) -> Option<&'a str> { + match &opening_element.name { + oxc_ast::ast::JSXElementName::MemberExpression(member_expression) => { + Some(member_expression.property.name.as_str()) + } + _ => resolve_jsx_element_type(opening_element, ctx).map(|(tag_name, _)| tag_name), + } +} diff --git a/native/oxlint/rules/get-static-class-name.rs b/native/oxlint/rules/get-static-class-name.rs new file mode 100644 index 0000000000..40516e6bd8 --- /dev/null +++ b/native/oxlint/rules/get-static-class-name.rs @@ -0,0 +1,25 @@ +fn get_static_class_name<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, +) -> Option<&'a str> { + let attribute = get_authoritative_jsx_attribute(opening_element, "className", true)?; + match attribute.value.as_ref()? { + oxc_ast::ast::JSXAttributeValue::StringLiteral(string_literal) => { + Some(string_literal.value.as_str()) + } + oxc_ast::ast::JSXAttributeValue::ExpressionContainer(container) => { + match container.expression.as_expression()? { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + Some(string_literal.value.as_str()) + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() + && template_literal.quasis.len() == 1 => + { + Some(template_literal.quasis[0].value.raw.as_str()) + } + _ => None, + } + } + _ => None, + } +} diff --git a/native/oxlint/rules/get-static-direct-jsx-elements.rs b/native/oxlint/rules/get-static-direct-jsx-elements.rs new file mode 100644 index 0000000000..8a958a9b6a --- /dev/null +++ b/native/oxlint/rules/get-static-direct-jsx-elements.rs @@ -0,0 +1,12 @@ +fn get_static_direct_jsx_elements<'a, 'b>( + element: &'b oxc_ast::ast::JSXElement<'a>, +) -> Vec<&'b oxc_ast::ast::JSXElement<'a>> { + element + .children + .iter() + .filter_map(|child| match child { + oxc_ast::ast::JSXChild::Element(element) => Some(element.as_ref()), + _ => None, + }) + .collect() +} diff --git a/native/oxlint/rules/get-static-effective-font-size.rs b/native/oxlint/rules/get-static-effective-font-size.rs new file mode 100644 index 0000000000..fc06328dc9 --- /dev/null +++ b/native/oxlint/rules/get-static-effective-font-size.rs @@ -0,0 +1,88 @@ +const ROOT_FONT_SIZE_PX: f64 = 16.0; + +fn get_static_effective_font_size( + opening_element: &oxc_ast::ast::JSXOpeningElement, + has_tailwind: bool, +) -> Option { + let class_name = get_static_class_name(opening_element); + let tailwind_font_size = has_tailwind + .then(|| class_name.and_then(|value| get_static_tailwind_font_size(value))) + .flatten(); + if has_tailwind && class_name.is_some_and(|value| has_important_tailwind_font_size(value)) { + return tailwind_font_size; + } + + let style_attribute = get_authoritative_jsx_attribute(opening_element, "style", true); + let Some(style_attribute) = style_attribute else { + return if opening_element.attributes.iter().any(|attribute| { + matches!( + attribute, + oxc_ast::ast::JSXAttributeItem::SpreadAttribute(_) + ) + }) { + None + } else { + tailwind_font_size + }; + }; + let style_expression = get_inline_style_object_expression(style_attribute)?; + if let Some(font_size_property) = + get_effective_static_style_property(style_expression, "fontSize") + { + return get_font_size_px(font_size_property); + } + style_expression + .properties + .iter() + .all(|property| { + matches!( + property, + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) + if object_property.key.static_name().is_some() + ) + }) + .then_some(tailwind_font_size) + .flatten() +} + +fn has_important_tailwind_font_size(class_name: &str) -> bool { + tailwind_class_name_tokens(class_name).iter().any(|token| { + token.variants.is_empty() + && token.is_important + && parse_static_tailwind_font_size(token.utility).is_some() + }) +} + +fn get_font_size_px(property: &oxc_ast::ast::ObjectProperty) -> Option { + if let Some(number_value) = get_static_style_property_number_value(property) { + return Some(number_value); + } + let oxc_ast::ast::Expression::StringLiteral(string_literal) = &property.value else { + return None; + }; + let string_value = string_literal + .value + .trim_matches(|character| is_js_whitespace(character)); + string_value + .strip_suffix("px") + .and_then(parse_static_font_size_decimal) + .or_else(|| { + string_value + .strip_suffix("rem") + .and_then(parse_static_font_size_decimal) + .map(|value| value * ROOT_FONT_SIZE_PX) + }) +} + +fn parse_static_font_size_decimal(value: &str) -> Option { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + || value.bytes().filter(|byte| *byte == b'.').count() > 1 + || !value.bytes().any(|byte| byte.is_ascii_digit()) + { + return None; + } + value.parse().ok() +} diff --git a/native/oxlint/rules/get-static-jsx-attribute-string-values.rs b/native/oxlint/rules/get-static-jsx-attribute-string-values.rs new file mode 100644 index 0000000000..83034b9291 --- /dev/null +++ b/native/oxlint/rules/get-static-jsx-attribute-string-values.rs @@ -0,0 +1,96 @@ +const MAX_CONST_STRING_ALIASES: usize = 4; + +fn get_static_jsx_attribute_string_values<'a>( + attribute: &'a oxc_ast::ast::JSXAttribute<'a>, + ctx: &crate::context::LintContext<'a>, +) -> Option> { + let value = attribute.value.as_ref()?; + match value { + oxc_ast::ast::JSXAttributeValue::StringLiteral(string_literal) => { + Some(vec![string_literal.value.to_string()]) + } + oxc_ast::ast::JSXAttributeValue::ExpressionContainer(container) => { + let expression = container.expression.as_expression()?; + resolve_static_string_values(expression, ctx, MAX_CONST_STRING_ALIASES, &mut Vec::new()) + } + _ => None, + } +} + +fn resolve_static_string_values<'a>( + expression: &'a oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, + remaining_const_aliases: usize, + resolving_symbol_ids: &mut Vec, +) -> Option> { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + Some(vec![string_literal.value.to_string()]) + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + Some(vec![ + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()) + .to_string(), + ]) + } + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => { + let mut consequent = resolve_static_string_values( + &conditional_expression.consequent, + ctx, + remaining_const_aliases, + &mut resolving_symbol_ids.clone(), + )?; + let alternate = resolve_static_string_values( + &conditional_expression.alternate, + ctx, + remaining_const_aliases, + &mut resolving_symbol_ids.clone(), + )?; + consequent.extend(alternate); + Some(consequent) + } + oxc_ast::ast::Expression::Identifier(identifier) if remaining_const_aliases > 0 => { + let symbol_id = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id()?; + if resolving_symbol_ids.contains(&symbol_id) { + return None; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return None; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return None; + }; + if !variable_declaration.kind.is_const() + || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return None; + } + let initializer = declarator.init.as_ref()?; + resolving_symbol_ids.push(symbol_id); + let values = resolve_static_string_values( + initializer, + ctx, + remaining_const_aliases - 1, + resolving_symbol_ids, + ); + resolving_symbol_ids.pop(); + values + } + _ => None, + } +} diff --git a/native/oxlint/rules/get-static-jsx-text.rs b/native/oxlint/rules/get-static-jsx-text.rs new file mode 100644 index 0000000000..70aa64df2a --- /dev/null +++ b/native/oxlint/rules/get-static-jsx-text.rs @@ -0,0 +1,55 @@ +fn get_static_jsx_text(element: &oxc_ast::ast::JSXElement) -> String { + join_static_jsx_child_text(&element.children) +} + +fn join_static_jsx_child_text(children: &[oxc_ast::ast::JSXChild]) -> String { + children + .iter() + .map(get_static_jsx_child_text) + .collect::>() + .join(" ") +} + +fn get_static_jsx_child_text(child: &oxc_ast::ast::JSXChild) -> String { + match child { + oxc_ast::ast::JSXChild::Text(text) => text.value.to_string(), + oxc_ast::ast::JSXChild::Element(element) => get_static_jsx_text(element), + oxc_ast::ast::JSXChild::Fragment(fragment) => { + join_static_jsx_child_text(&fragment.children) + } + oxc_ast::ast::JSXChild::ExpressionContainer(container) => container + .expression + .as_expression() + .map(get_static_expression_text) + .unwrap_or_default(), + _ => String::new(), + } +} + +fn get_static_expression_text(expression: &oxc_ast::ast::Expression) -> String { + match expression { + oxc_ast::ast::Expression::StringLiteral(string_literal) => string_literal.value.to_string(), + oxc_ast::ast::Expression::TemplateLiteral(template_literal) => template_literal + .quasis + .iter() + .map(|quasi| quasi.value.raw.as_str()) + .collect::>() + .join(" "), + oxc_ast::ast::Expression::JSXElement(element) => get_static_jsx_text(element), + oxc_ast::ast::Expression::JSXFragment(fragment) => { + join_static_jsx_child_text(&fragment.children) + } + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => format!( + "{} {}", + get_static_expression_text(&conditional_expression.consequent), + get_static_expression_text(&conditional_expression.alternate) + ), + oxc_ast::ast::Expression::LogicalExpression(logical_expression) => { + get_static_expression_text(&logical_expression.right) + } + oxc_ast::ast::Expression::ParenthesizedExpression(parenthesized_expression) => { + get_static_expression_text(&parenthesized_expression.expression) + } + _ => String::new(), + } +} diff --git a/native/oxlint/rules/get-static-jsx-tree-opening-elements.rs b/native/oxlint/rules/get-static-jsx-tree-opening-elements.rs new file mode 100644 index 0000000000..a87dd39b11 --- /dev/null +++ b/native/oxlint/rules/get-static-jsx-tree-opening-elements.rs @@ -0,0 +1,20 @@ +fn get_static_jsx_tree_opening_elements<'a>( + node: &crate::AstNode<'a>, + ctx: &crate::context::LintContext, +) -> Option>> { + if !is_static_jsx_tree_root(node, ctx) { + return None; + } + let mut opening_elements = Vec::new(); + match node.kind() { + oxc_ast::AstKind::JSXElement(element) => { + opening_elements.push(element.opening_element.as_ref()); + collect_static_jsx_opening_elements(&element.children, &mut opening_elements); + } + oxc_ast::AstKind::JSXFragment(fragment) => { + collect_static_jsx_opening_elements(&fragment.children, &mut opening_elements); + } + _ => return None, + } + Some(opening_elements) +} diff --git a/native/oxlint/rules/get-static-route-full-path.rs b/native/oxlint/rules/get-static-route-full-path.rs new file mode 100644 index 0000000000..9daff39240 --- /dev/null +++ b/native/oxlint/rules/get-static-route-full-path.rs @@ -0,0 +1,40 @@ +fn get_static_route_full_path<'a>( + route_object: &'a oxc_ast::ast::ObjectExpression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> Option { + let mut route_objects = Vec::new(); + let mut current_route = Some(route_object); + while let Some(route) = current_route { + route_objects.push(route); + let route_array_node = ctx.nodes().parent_node(route.node_id.get()); + let oxc_ast::AstKind::ArrayExpression(_) = route_array_node.kind() else { + break; + }; + let children_property_node = ctx.nodes().parent_node(route_array_node.id()); + let oxc_ast::AstKind::ObjectProperty(_) = children_property_node.kind() else { + break; + }; + let parent_route_node = ctx.nodes().parent_node(children_property_node.id()); + current_route = match parent_route_node.kind() { + oxc_ast::AstKind::ObjectExpression(parent_route) => Some(parent_route), + _ => None, + }; + } + route_objects.reverse(); + + let mut path_segments = Vec::::new(); + for route in route_objects { + let Some(path_property) = get_static_route_property(route, "path") else { + continue; + }; + let route_path = get_static_string_expression(&path_property.value)?; + if route_path.starts_with('/') { + path_segments.clear(); + } + let path_segment = route_path.trim_matches('/'); + if !path_segment.is_empty() { + path_segments.push(path_segment.to_string()); + } + } + Some(format!("/{}", path_segments.join("/"))) +} diff --git a/native/oxlint/rules/get-static-route-property.rs b/native/oxlint/rules/get-static-route-property.rs new file mode 100644 index 0000000000..9959bfab25 --- /dev/null +++ b/native/oxlint/rules/get-static-route-property.rs @@ -0,0 +1,12 @@ +fn get_static_route_property<'a>( + route_object: &'a oxc_ast::ast::ObjectExpression<'a>, + property_name: &str, +) -> Option<&'a oxc_ast::ast::ObjectProperty<'a>> { + route_object.properties.iter().find_map(|property| { + let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) = property else { + return None; + }; + (object_property.key.static_name().as_deref() == Some(property_name)) + .then_some(object_property.as_ref()) + }) +} diff --git a/native/oxlint/rules/get-static-string-expression.rs b/native/oxlint/rules/get-static-string-expression.rs new file mode 100644 index 0000000000..72557714ad --- /dev/null +++ b/native/oxlint/rules/get-static-string-expression.rs @@ -0,0 +1,22 @@ +fn get_static_string_expression<'a>( + expression: &'a oxc_ast::ast::Expression<'a>, +) -> Option<&'a str> { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + Some(string_literal.value.as_str()) + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + Some( + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()), + ) + } + _ => None, + } +} diff --git a/native/oxlint/rules/get-static-style-property-number-value.rs b/native/oxlint/rules/get-static-style-property-number-value.rs new file mode 100644 index 0000000000..6adf00ef6c --- /dev/null +++ b/native/oxlint/rules/get-static-style-property-number-value.rs @@ -0,0 +1,14 @@ +fn get_static_style_property_number_value(property: &oxc_ast::ast::ObjectProperty) -> Option { + match &property.value { + oxc_ast::ast::Expression::NumericLiteral(number) => Some(number.value), + oxc_ast::ast::Expression::UnaryExpression(unary) + if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation => + { + match unary.argument.get_inner_expression() { + oxc_ast::ast::Expression::NumericLiteral(number) => Some(-number.value), + _ => None, + } + } + _ => None, + } +} diff --git a/native/oxlint/rules/get-static-style-property-string-value.rs b/native/oxlint/rules/get-static-style-property-string-value.rs new file mode 100644 index 0000000000..9c08bdcf84 --- /dev/null +++ b/native/oxlint/rules/get-static-style-property-string-value.rs @@ -0,0 +1,15 @@ +fn get_static_style_property_string_value<'a>( + property: &'a oxc_ast::ast::ObjectPropertyKind<'a>, + target_name: &str, +) -> Option<(&'a oxc_ast::ast::ObjectProperty<'a>, &'a str)> { + let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(object_property) = property else { + return None; + }; + if object_property.key.static_name().as_deref() != Some(target_name) { + return None; + } + let oxc_ast::ast::Expression::StringLiteral(string_literal) = &object_property.value else { + return None; + }; + Some((object_property, string_literal.value.as_str())) +} diff --git a/native/oxlint/rules/get-static-tailwind-font-size.rs b/native/oxlint/rules/get-static-tailwind-font-size.rs new file mode 100644 index 0000000000..0ee223575e --- /dev/null +++ b/native/oxlint/rules/get-static-tailwind-font-size.rs @@ -0,0 +1,7 @@ +fn get_static_tailwind_font_size(class_name: &str) -> Option { + let tokens = tailwind_class_name_tokens(class_name); + let effective_utility = get_effective_tailwind_class_name_token(&tokens, |utility| { + parse_static_tailwind_font_size(utility).is_some() + })?; + parse_static_tailwind_font_size(effective_utility) +} diff --git a/native/oxlint/rules/get-string-literal-attribute-value.rs b/native/oxlint/rules/get-string-literal-attribute-value.rs new file mode 100644 index 0000000000..260ae53f69 --- /dev/null +++ b/native/oxlint/rules/get-string-literal-attribute-value.rs @@ -0,0 +1,31 @@ +fn get_string_literal_attribute_value<'a>( + attribute: &'a oxc_ast::ast::JSXAttribute<'a>, +) -> Option<&'a str> { + match attribute.value.as_ref()? { + oxc_ast::ast::JSXAttributeValue::StringLiteral(string_literal) => { + Some(string_literal.value.as_str()) + } + oxc_ast::ast::JSXAttributeValue::ExpressionContainer(container) => { + match container.expression.as_expression()?.get_inner_expression() { + oxc_ast::ast::Expression::StringLiteral(string_literal) => { + Some(string_literal.value.as_str()) + } + oxc_ast::ast::Expression::TemplateLiteral(template_literal) + if template_literal.expressions.is_empty() + && template_literal.quasis.len() == 1 => + { + let quasi = &template_literal.quasis[0]; + Some( + quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()), + ) + } + _ => None, + } + } + _ => None, + } +} diff --git a/native/oxlint/rules/get-tailwind-visibility-at-breakpoints.rs b/native/oxlint/rules/get-tailwind-visibility-at-breakpoints.rs new file mode 100644 index 0000000000..51e849aea9 --- /dev/null +++ b/native/oxlint/rules/get-tailwind-visibility-at-breakpoints.rs @@ -0,0 +1,138 @@ +const TAILWIND_BREAKPOINT_NAMES: [&str; 6] = ["", "sm", "md", "lg", "xl", "2xl"]; + +#[derive(Clone, Copy)] +struct TailwindResponsiveVariantScope { + maximum_breakpoint_index: usize, + minimum_breakpoint_index: usize, + specificity: usize, +} + +#[derive(Clone, Copy)] +struct TailwindScopedVisibilityEffect { + effect: TailwindVisibilityEffect, + is_important: bool, + scope: TailwindResponsiveVariantScope, +} + +fn get_tailwind_visibility_at_breakpoints(class_name: &str) -> Option> { + let mut scoped_effects = Vec::new(); + for token in tailwind_class_name_tokens(class_name) { + let effect = match get_tailwind_visibility_effect(token.utility) { + TailwindVisibilityEffectResolution::Known(effect) => effect, + TailwindVisibilityEffectResolution::NotRelevant => continue, + TailwindVisibilityEffectResolution::Unknown => return None, + }; + let scope = get_responsive_variant_scope(&token.variants)?; + if scope.minimum_breakpoint_index >= scope.maximum_breakpoint_index { + continue; + } + scoped_effects.push(TailwindScopedVisibilityEffect { + effect, + is_important: token.is_important, + scope, + }); + } + + let mut visibility_at_breakpoints = Vec::with_capacity(TAILWIND_BREAKPOINT_NAMES.len()); + for breakpoint_index in 0..TAILWIND_BREAKPOINT_NAMES.len() { + let display_visibility = resolve_visibility_property( + &scoped_effects, + breakpoint_index, + TailwindVisibilityProperty::Display, + )?; + let visibility_visibility = resolve_visibility_property( + &scoped_effects, + breakpoint_index, + TailwindVisibilityProperty::Visibility, + )?; + visibility_at_breakpoints.push(display_visibility && visibility_visibility); + } + Some(visibility_at_breakpoints) +} + +fn get_responsive_variant_scope(variants: &[&str]) -> Option { + let mut minimum_breakpoint_index = 0; + let mut maximum_breakpoint_index = TAILWIND_BREAKPOINT_NAMES.len(); + for variant in variants { + if let Some(minimum_variant_index) = get_tailwind_breakpoint_index(variant) + && minimum_variant_index > 0 + { + minimum_breakpoint_index = minimum_breakpoint_index.max(minimum_variant_index); + continue; + } + if let Some(maximum_variant) = variant.strip_prefix("max-") + && let Some(maximum_variant_index) = get_tailwind_breakpoint_index(maximum_variant) + && maximum_variant_index > 0 + { + maximum_breakpoint_index = maximum_breakpoint_index.min(maximum_variant_index); + continue; + } + return None; + } + Some(TailwindResponsiveVariantScope { + maximum_breakpoint_index, + minimum_breakpoint_index, + specificity: variants.len(), + }) +} + +fn get_tailwind_breakpoint_index(breakpoint_name: &str) -> Option { + TAILWIND_BREAKPOINT_NAMES + .iter() + .position(|candidate| *candidate == breakpoint_name) +} + +fn resolve_visibility_property( + scoped_effects: &[TailwindScopedVisibilityEffect], + breakpoint_index: usize, + property: TailwindVisibilityProperty, +) -> Option { + let is_applicable = |scoped_effect: &&TailwindScopedVisibilityEffect| { + scoped_effect.effect.property == property + && breakpoint_index >= scoped_effect.scope.minimum_breakpoint_index + && breakpoint_index < scoped_effect.scope.maximum_breakpoint_index + }; + let applicable_effects = scoped_effects + .iter() + .filter(is_applicable) + .collect::>(); + if applicable_effects.is_empty() { + return Some(true); + } + let has_important_effect = applicable_effects.iter().any(|effect| effect.is_important); + let highest_importance_effects = applicable_effects + .into_iter() + .filter(|effect| !has_important_effect || effect.is_important) + .collect::>(); + let maximum_specificity = highest_importance_effects + .iter() + .map(|effect| effect.scope.specificity) + .max()?; + let highest_specificity_effects = highest_importance_effects + .into_iter() + .filter(|effect| effect.scope.specificity == maximum_specificity) + .collect::>(); + let maximum_minimum_breakpoint = highest_specificity_effects + .iter() + .map(|effect| effect.scope.minimum_breakpoint_index) + .max()?; + let latest_minimum_effects = highest_specificity_effects + .into_iter() + .filter(|effect| effect.scope.minimum_breakpoint_index == maximum_minimum_breakpoint) + .collect::>(); + let minimum_maximum_breakpoint = latest_minimum_effects + .iter() + .map(|effect| effect.scope.maximum_breakpoint_index) + .min()?; + let mut resolved_visibility = None; + for effect in latest_minimum_effects + .into_iter() + .filter(|effect| effect.scope.maximum_breakpoint_index == minimum_maximum_breakpoint) + { + if resolved_visibility.is_some_and(|is_visible| is_visible != effect.effect.is_visible) { + return None; + } + resolved_visibility = Some(effect.effect.is_visible); + } + resolved_visibility +} diff --git a/native/oxlint/rules/get-tailwind-visibility-effect.rs b/native/oxlint/rules/get-tailwind-visibility-effect.rs new file mode 100644 index 0000000000..2feb993e23 --- /dev/null +++ b/native/oxlint/rules/get-tailwind-visibility-effect.rs @@ -0,0 +1,123 @@ +#[derive(Clone, Copy, PartialEq, Eq)] +enum TailwindVisibilityProperty { + Display, + Visibility, +} + +#[derive(Clone, Copy)] +struct TailwindVisibilityEffect { + is_visible: bool, + property: TailwindVisibilityProperty, +} + +enum TailwindVisibilityEffectResolution { + Known(TailwindVisibilityEffect), + NotRelevant, + Unknown, +} + +fn get_tailwind_visibility_effect(utility: &str) -> TailwindVisibilityEffectResolution { + let known_effect = match utility { + "hidden" => Some(TailwindVisibilityEffect { + is_visible: false, + property: TailwindVisibilityProperty::Display, + }), + "block" | "contents" | "flex" | "flow-root" | "grid" | "inline" | "inline-block" + | "inline-flex" | "inline-grid" | "inline-table" | "list-item" | "table" + | "table-caption" | "table-cell" | "table-column" | "table-column-group" + | "table-footer-group" | "table-header-group" | "table-row" | "table-row-group" => { + Some(TailwindVisibilityEffect { + is_visible: true, + property: TailwindVisibilityProperty::Display, + }) + } + "collapse" | "invisible" => Some(TailwindVisibilityEffect { + is_visible: false, + property: TailwindVisibilityProperty::Visibility, + }), + "visible" => Some(TailwindVisibilityEffect { + is_visible: true, + property: TailwindVisibilityProperty::Visibility, + }), + _ => None, + }; + if let Some(effect) = known_effect { + return TailwindVisibilityEffectResolution::Known(effect); + } + + if let Some(arbitrary_display_value) = get_arbitrary_css_property_value(utility, "display") { + let display_value = normalize_tailwind_arbitrary_utility_value(arbitrary_display_value) + .trim() + .to_ascii_lowercase(); + if display_value == "none" { + return TailwindVisibilityEffectResolution::Known(TailwindVisibilityEffect { + is_visible: false, + property: TailwindVisibilityProperty::Display, + }); + } + if is_visible_arbitrary_display_value(&display_value) { + return TailwindVisibilityEffectResolution::Known(TailwindVisibilityEffect { + is_visible: true, + property: TailwindVisibilityProperty::Display, + }); + } + return TailwindVisibilityEffectResolution::Unknown; + } + + let Some(arbitrary_visibility_value) = get_arbitrary_css_property_value(utility, "visibility") + else { + return TailwindVisibilityEffectResolution::NotRelevant; + }; + let visibility_value = normalize_tailwind_arbitrary_utility_value(arbitrary_visibility_value) + .trim() + .to_ascii_lowercase(); + match visibility_value.as_str() { + "visible" => TailwindVisibilityEffectResolution::Known(TailwindVisibilityEffect { + is_visible: true, + property: TailwindVisibilityProperty::Visibility, + }), + "hidden" | "collapse" => { + TailwindVisibilityEffectResolution::Known(TailwindVisibilityEffect { + is_visible: false, + property: TailwindVisibilityProperty::Visibility, + }) + } + _ => TailwindVisibilityEffectResolution::Unknown, + } +} + +fn get_arbitrary_css_property_value<'a>(utility: &'a str, property_name: &str) -> Option<&'a str> { + let property_prefix = format!("[{property_name}:"); + let lowercase_utility = utility.to_ascii_lowercase(); + if !lowercase_utility.starts_with(&property_prefix) || !utility.ends_with(']') { + return None; + } + Some(&utility[property_prefix.len()..utility.len() - 1]) +} + +fn is_visible_arbitrary_display_value(value: &str) -> bool { + matches!( + value, + "block" + | "contents" + | "flex" + | "flow-root" + | "grid" + | "inline" + | "inline block" + | "inline flex" + | "inline flow-root" + | "inline grid" + | "inline table" + | "list-item" + | "table" + | "table-caption" + | "table-cell" + | "table-column" + | "table-column-group" + | "table-footer-group" + | "table-header-group" + | "table-row" + | "table-row-group" + ) +} diff --git a/native/oxlint/rules/has-active-route-property.rs b/native/oxlint/rules/has-active-route-property.rs new file mode 100644 index 0000000000..8e06ff0dfe --- /dev/null +++ b/native/oxlint/rules/has-active-route-property.rs @@ -0,0 +1,8 @@ +fn has_active_route_property<'a>( + route_object: &'a oxc_ast::ast::ObjectExpression<'a>, + property_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + get_static_route_property(route_object, property_name) + .is_some_and(|property| !is_definitely_falsy_expression(&property.value, ctx)) +} diff --git a/native/oxlint/rules/has-capability-or-unspecified.rs b/native/oxlint/rules/has-capability-or-unspecified.rs new file mode 100644 index 0000000000..f9883bdec2 --- /dev/null +++ b/native/oxlint/rules/has-capability-or-unspecified.rs @@ -0,0 +1,19 @@ +fn has_capability_or_unspecified(ctx: &crate::context::LintContext, capability: &str) -> bool { + let Some(settings) = ctx + .settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + else { + return true; + }; + let Some(capabilities) = settings.get("capabilities") else { + return true; + }; + capabilities.as_array().is_some_and(|capabilities| { + capabilities + .iter() + .any(|value| value.as_str() == Some(capability)) + }) +} diff --git a/native/oxlint/rules/has-responsive-axis-prefix.rs b/native/oxlint/rules/has-responsive-axis-prefix.rs new file mode 100644 index 0000000000..9f3158fbdf --- /dev/null +++ b/native/oxlint/rules/has-responsive-axis-prefix.rs @@ -0,0 +1,13 @@ +fn has_responsive_axis_prefix(class_name_value: &str, axis_prefix: &str) -> bool { + class_name_value.split_whitespace().any(|class_token| { + let Some((responsive_prefix, utility)) = class_token.split_once(':') else { + return false; + }; + !responsive_prefix.is_empty() + && responsive_prefix + .bytes() + .all(|character| character.is_ascii_alphanumeric() || character == b'_') + && utility.starts_with(axis_prefix) + && utility.as_bytes().get(axis_prefix.len()) == Some(&b'-') + }) +} diff --git a/native/oxlint/rules/heading-has-content.rs b/native/oxlint/rules/heading-has-content.rs new file mode 100644 index 0000000000..351f2e32d2 --- /dev/null +++ b/native/oxlint/rules/heading-has-content.rs @@ -0,0 +1,65 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, rule::Rule, utils::has_jsx_prop_ignore_case}; + +const DEFAULT_HEADING_TAGS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"]; +const MESSAGE: &str = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`."; + +#[derive(Debug, Default, Clone)] +pub struct HeadingHasContent; + +declare_oxc_lint!( + /// Require accessible heading content. + HeadingHasContent, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require accessible heading content.", +); + +impl Rule for HeadingHasContent { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let element_type = get_element_type(ctx, opening_element); + if !DEFAULT_HEADING_TAGS.contains(&element_type.as_ref()) + && !configured_heading_components(ctx).is_some_and(|components| { + components + .iter() + .any(|component| component == element_type.as_ref()) + }) + { + return; + } + if let AstKind::JSXElement(element) = ctx.nodes().parent_kind(node.id()) + && object_has_accessible_child(element, ctx) + { + return; + } + if is_hidden_from_screen_reader(opening_element, ctx) + || ["aria-label", "aria-labelledby"] + .iter() + .any(|attribute_name| { + has_jsx_prop_ignore_case(opening_element, attribute_name).is_some() + }) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } +} + +fn configured_heading_components<'a>( + ctx: &'a LintContext<'_>, +) -> Option<&'a Vec> { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("headingHasContent")) + .and_then(|settings| settings.get("components")) + .and_then(serde_json::Value::as_array) +} diff --git a/native/oxlint/rules/hooks-no-nan-in-deps.rs b/native/oxlint/rules/hooks-no-nan-in-deps.rs new file mode 100644 index 0000000000..3629846c0c --- /dev/null +++ b/native/oxlint/rules/hooks-no-nan-in-deps.rs @@ -0,0 +1,60 @@ +use oxc_ast::{ast::Expression, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const HOOKS_WITH_DEPENDENCY_ARRAYS: [&str; 6] = [ + "useEffect", + "useLayoutEffect", + "useInsertionEffect", + "useCallback", + "useMemo", + "useImperativeHandle", +]; +const MESSAGE: &str = "`NaN` in a dependency array never compares as changed with `Object.is`, so normalize the value before passing it as a dependency."; + +#[derive(Debug, Default, Clone)] +pub struct HooksNoNanInDeps; + +declare_oxc_lint!( + /// Disallow global NaN values in React hook dependency arrays. + HooksNoNanInDeps, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow global NaN values in React hook dependency arrays.", +); + +impl Rule for HooksNoNanInDeps { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !is_react_hook_call(call_expression, &HOOKS_WITH_DEPENDENCY_ARRAYS, ctx) { + return; + } + let dependency_index = usize::from(is_react_hook_call( + call_expression, + &["useImperativeHandle"], + ctx, + )) + 1; + let Some(Expression::ArrayExpression(dependency_array)) = call_expression + .arguments + .get(dependency_index) + .and_then(oxc_ast::ast::Argument::as_expression) + else { + return; + }; + for dependency in dependency_array + .elements + .iter() + .filter_map(oxc_ast::ast::ArrayExpressionElement::as_expression) + { + if is_global_nan_value(dependency, ctx) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(dependency.span())); + } + } + } +} diff --git a/native/oxlint/rules/html-label-has-single-control.rs b/native/oxlint/rules/html-label-has-single-control.rs new file mode 100644 index 0000000000..2298d1b5ff --- /dev/null +++ b/native/oxlint/rules/html-label-has-single-control.rs @@ -0,0 +1,52 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const LABELABLE_TAG_NAMES: [&str; 7] = [ + "button", "input", "meter", "output", "progress", "select", "textarea", +]; +const MESSAGE: &str = "This label contains multiple native controls, but a label can identify only one control. Split the labels or name the group with fieldset and legend."; + +#[derive(Debug, Default, Clone)] +pub struct HtmlLabelHasSingleControl; + +declare_oxc_lint!( + /// Disallow labels that wrap multiple native controls. + HtmlLabelHasSingleControl, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow labels that wrap multiple native controls.", +); + +impl Rule for HtmlLabelHasSingleControl { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + if resolve_jsx_element_type(&element.opening_element, ctx) + .is_none_or(|(element_type, _)| element_type != "label") + { + return; + } + let mut opening_elements = Vec::new(); + collect_static_jsx_opening_elements(&element.children, &mut opening_elements); + let control_count = opening_elements + .into_iter() + .filter(|opening_element| { + resolve_jsx_element_type(opening_element, ctx).is_some_and( + |(element_type, _)| LABELABLE_TAG_NAMES.contains(&element_type), + ) + }) + .take(2) + .count(); + if control_count < 2 { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(MESSAGE).with_label(element.opening_element.span), + ); + } +} diff --git a/native/oxlint/rules/html-no-nested-form.rs b/native/oxlint/rules/html-no-nested-form.rs new file mode 100644 index 0000000000..05a3d0727d --- /dev/null +++ b/native/oxlint/rules/html-no-nested-form.rs @@ -0,0 +1,46 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "This form is nested inside another form, which is invalid HTML and can make controls submit through the wrong form. Separate the forms."; + +#[derive(Debug, Default, Clone)] +pub struct HtmlNoNestedForm; + +declare_oxc_lint!( + /// Disallow nested form elements. + HtmlNoNestedForm, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow nested form elements.", +); + +impl Rule for HtmlNoNestedForm { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if !is_form_element(opening_element, ctx) + || !ctx.nodes().ancestors(node.id()).skip(1).any(|ancestor| { + matches!( + ancestor.kind(), + AstKind::JSXElement(element) if is_form_element(&element.opening_element, ctx) + ) + }) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } +} + +fn is_form_element<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, + ctx: &LintContext<'a>, +) -> bool { + resolve_jsx_element_type(opening_element, ctx) + .is_some_and(|(element_type, _)| element_type == "form") +} diff --git a/native/oxlint/rules/html-xml-lang-mismatch.rs b/native/oxlint/rules/html-xml-lang-mismatch.rs new file mode 100644 index 0000000000..b7e73a1d18 --- /dev/null +++ b/native/oxlint/rules/html-xml-lang-mismatch.rs @@ -0,0 +1,68 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Default, Clone)] +pub struct HtmlXmlLangMismatch; + +declare_oxc_lint!( + /// Require matching base languages on html lang and xml:lang. + HtmlXmlLangMismatch, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require matching base languages on html lang and xml:lang.", +); + +impl Rule for HtmlXmlLangMismatch { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if resolve_jsx_element_type(opening_element, ctx).map(|(name, _)| name) != Some("html") { + return; + } + let Some(language_attribute) = + get_authoritative_jsx_attribute(opening_element, "lang", false) + else { + return; + }; + let Some(xml_language_attribute) = + get_authoritative_jsx_attribute(opening_element, "xml:lang", false) + else { + return; + }; + let Some(language) = get_string_literal_attribute_value(language_attribute) else { + return; + }; + let Some(xml_language) = get_string_literal_attribute_value(xml_language_attribute) else { + return; + }; + let base_language_name = get_base_language(language); + let base_xml_language_name = get_base_language(xml_language); + if base_language_name.is_empty() + || base_xml_language_name.is_empty() + || base_language_name == base_xml_language_name + { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "lang declares {base_language_name}, but xml:lang declares {base_xml_language_name}. Use the same base language so assistive technology chooses one pronunciation model." + )) + .with_label(xml_language_attribute.span), + ); + } +} + +fn get_base_language(language: &str) -> String { + language + .trim() + .to_lowercase() + .split('-') + .next() + .unwrap_or_default() + .to_string() +} diff --git a/native/oxlint/rules/identifier-initializer.rs b/native/oxlint/rules/identifier-initializer.rs new file mode 100644 index 0000000000..bd5a00f3ee --- /dev/null +++ b/native/oxlint/rules/identifier-initializer.rs @@ -0,0 +1,14 @@ +fn identifier_initializer<'a>( + identifier: &oxc_ast::ast::IdentifierReference<'a>, + ctx: &crate::context::LintContext<'a>, +) -> Option<&'a oxc_ast::ast::Expression<'a>> { + let symbol_id = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id()?; + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return None; + }; + declarator.init.as_ref() +} diff --git a/native/oxlint/rules/iframe-title-unique.rs b/native/oxlint/rules/iframe-title-unique.rs new file mode 100644 index 0000000000..8f39b58849 --- /dev/null +++ b/native/oxlint/rules/iframe-title-unique.rs @@ -0,0 +1,71 @@ +use std::collections::HashSet; + +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct IframeTitleUnique; + +declare_oxc_lint!( + /// Require unique frame titles within one static JSX tree. + IframeTitleUnique, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require unique frame titles within one static JSX tree.", +); + +impl Rule for IframeTitleUnique { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let Some(opening_elements) = get_static_jsx_tree_opening_elements(node, ctx) else { + return; + }; + let mut seen_titles = HashSet::new(); + for opening_element in opening_elements { + let Some((element_type, _)) = resolve_jsx_element_type(opening_element, ctx) else { + continue; + }; + if element_type != "iframe" && element_type != "frame" { + continue; + } + let Some(title_attribute) = + get_authoritative_jsx_attribute(opening_element, "title", false) + else { + continue; + }; + let Some(raw_title) = get_string_literal_attribute_value(title_attribute) else { + continue; + }; + let normalized_title = normalize_frame_title(raw_title); + if normalized_title.is_empty() || seen_titles.insert(normalized_title) { + continue; + } + let trimmed_title = raw_title.trim_matches(is_js_whitespace); + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Another frame in this static JSX tree already uses the title \"{trimmed_title}\". Give each frame a title that identifies its distinct purpose." + )) + .with_label(title_attribute.span), + ); + } + } +} + +fn normalize_frame_title(title: &str) -> String { + let mut normalized_title = String::new(); + let mut has_pending_space = false; + for character in title.chars() { + if is_js_whitespace(character) { + has_pending_space = !normalized_title.is_empty(); + continue; + } + if has_pending_space { + normalized_title.push(' '); + has_pending_space = false; + } + normalized_title.extend(character.to_lowercase()); + } + normalized_title +} diff --git a/native/oxlint/rules/imported-module-api-matches.rs b/native/oxlint/rules/imported-module-api-matches.rs new file mode 100644 index 0000000000..496441887f --- /dev/null +++ b/native/oxlint/rules/imported-module-api-matches.rs @@ -0,0 +1,60 @@ +fn imported_module_api_matches<'a>( + expression: &oxc_ast::ast::Expression<'a>, + expected_api_name: &str, + expected_module_source: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let expression = expression.get_inner_expression(); + if let oxc_ast::ast::Expression::Identifier(identifier) = expression { + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + return ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && entry.module_request.name() == expected_module_source + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && matches!( + &entry.import_name, + crate::module_record::ImportImportName::Name(imported_name) + if imported_name.name() == expected_api_name + ) + }); + } + let Some(member_expression) = expression.as_member_expression() else { + return false; + }; + if member_expression.static_property_name() != Some(expected_api_name) { + return false; + } + let oxc_ast::ast::Expression::Identifier(identifier) = + member_expression.object().get_inner_expression() + else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && entry.module_request.name() == expected_module_source + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && matches!( + entry.import_name, + crate::module_record::ImportImportName::NamespaceObject + ) + }) +} diff --git a/native/oxlint/rules/ink-newline-inside-text.rs b/native/oxlint/rules/ink-newline-inside-text.rs new file mode 100644 index 0000000000..91a51dd0e4 --- /dev/null +++ b/native/oxlint/rules/ink-newline-inside-text.rs @@ -0,0 +1,17 @@ +use oxc_macros::declare_oxc_lint; + +use crate::rule::Rule; + +#[derive(Debug, Default, Clone)] +pub struct InkNewlineInsideText; + +declare_oxc_lint!( + /// Retired Ink Newline placement rule. + InkNewlineInsideText, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Retired Ink Newline placement rule.", +); + +impl Rule for InkNewlineInsideText {} diff --git a/native/oxlint/rules/ink-no-bare-process-exit.rs b/native/oxlint/rules/ink-no-bare-process-exit.rs new file mode 100644 index 0000000000..de6b08d343 --- /dev/null +++ b/native/oxlint/rules/ink-no-bare-process-exit.rs @@ -0,0 +1,52 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const MESSAGE: &str = "`process.exit()` in an Ink input handler bypasses Ink's terminal cleanup."; + +#[derive(Debug, Default, Clone)] +pub struct InkNoBareProcessExit; + +declare_oxc_lint!( + /// Require Ink input handlers to exit through Ink's application API. + InkNoBareProcessExit, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow bare process exits in Ink input handlers.", +); + +impl Rule for InkNoBareProcessExit { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(member_expression) = call_expression.callee.as_member_expression() else { + return; + }; + if member_expression.static_property_name() != Some("exit") + || !is_proven_global_namespace_reference(member_expression.object(), "process", ctx) + { + return; + } + let Some(handler_function) = crate::ast_util::get_enclosing_function(node, ctx) else { + return; + }; + let handler_parent = ctx.nodes().parent_node(handler_function.id()); + let AstKind::CallExpression(use_input_call) = handler_parent.kind() else { + return; + }; + if !use_input_call.arguments.first().is_some_and(|argument| { + argument + .as_expression() + .is_some_and(|expression| expression.span() == handler_function.span()) + }) || !imported_module_api_matches(&use_input_call.callee, "useInput", "ink", ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } +} diff --git a/native/oxlint/rules/ink-no-direct-raw-mode.rs b/native/oxlint/rules/ink-no-direct-raw-mode.rs new file mode 100644 index 0000000000..204229a2eb --- /dev/null +++ b/native/oxlint/rules/ink-no-direct-raw-mode.rs @@ -0,0 +1,65 @@ +use oxc_ast::{ast::Expression, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const MESSAGE: &str = "Changing terminal raw mode during render is an untracked side effect."; + +#[derive(Debug, Default, Clone)] +pub struct InkNoDirectRawMode; + +declare_oxc_lint!( + /// Disallow changing Ink terminal raw mode during React render. + InkNoDirectRawMode, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow Ink raw-mode changes during render.", +); + +impl Rule for InkNoDirectRawMode { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !is_ink_set_raw_mode_call(call_expression, ctx) + || !is_render_phase_component_or_hook(node, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } +} + +fn is_ink_set_raw_mode_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + ctx: &LintContext<'a>, +) -> bool { + if let Some(member_expression) = call_expression.callee.as_member_expression() { + if member_expression.static_property_name() != Some("setRawMode") { + return false; + } + let stdin_object = member_expression.object().get_inner_expression(); + return is_use_stdin_call(stdin_object, ctx) + || matches!( + stdin_object, + Expression::Identifier(identifier) + if identifier_initializer(identifier, ctx) + .is_some_and(|initializer| is_use_stdin_call(initializer, ctx)) + ); + } + let Expression::Identifier(identifier) = call_expression.callee.get_inner_expression() else { + return false; + }; + identifier.name == "setRawMode" + && identifier_initializer(identifier, ctx) + .is_some_and(|initializer| is_use_stdin_call(initializer, ctx)) +} + +fn is_use_stdin_call<'a>(expression: &Expression<'a>, ctx: &LintContext<'a>) -> bool { + let Expression::CallExpression(call_expression) = expression.get_inner_expression() else { + return false; + }; + imported_module_api_matches(&call_expression.callee, "useStdin", "ink", ctx) +} diff --git a/native/oxlint/rules/ink-no-dom-host-elements.rs b/native/oxlint/rules/ink-no-dom-host-elements.rs new file mode 100644 index 0000000000..966cfafdd6 --- /dev/null +++ b/native/oxlint/rules/ink-no-dom-host-elements.rs @@ -0,0 +1,50 @@ +use oxc_ast::{ast::JSXElementName, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, globals::HTML_TAG, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct InkNoDomHostElements; + +declare_oxc_lint!( + /// Disallow DOM host elements inside Ink trees. + InkNoDomHostElements, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow DOM host elements inside Ink trees.", +); + +impl Rule for InkNoDomHostElements { + fn run_once(&self, ctx: &LintContext<'_>) { + let ink_tree = module_jsx_tree_index("ink", ctx); + if ink_tree.is_empty() { + return; + } + for node in ctx.nodes().iter() { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + continue; + }; + let JSXElementName::Identifier(identifier) = &opening_element.name else { + continue; + }; + if !HTML_TAG.contains(identifier.name.as_str()) { + continue; + } + let Some(element_span) = owning_jsx_element_span(node, ctx) else { + continue; + }; + if !ink_tree.contains_or_is_inside(element_span) { + continue; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "DOM host `<{}>` cannot be rendered by Ink.", + identifier.name + )) + .with_label(opening_element.span), + ); + } + } +} diff --git a/native/oxlint/rules/ink-no-dom-router.rs b/native/oxlint/rules/ink-no-dom-router.rs new file mode 100644 index 0000000000..b3a5169dc6 --- /dev/null +++ b/native/oxlint/rules/ink-no-dom-router.rs @@ -0,0 +1,83 @@ +use oxc_ast::{ast::Expression, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule}; + +const DOM_ROUTER_COMPONENT_NAMES: [&str; 4] = ["BrowserRouter", "HashRouter", "Link", "NavLink"]; +const DOM_ROUTER_FACTORY_NAMES: [&str; 2] = ["createBrowserRouter", "createHashRouter"]; +const ROUTER_MODULE_SOURCES: [&str; 2] = ["react-router", "react-router-dom"]; + +#[derive(Debug, Default, Clone)] +pub struct InkNoDomRouter; + +declare_oxc_lint!( + /// Disallow DOM routers inside Ink trees. + InkNoDomRouter, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow DOM routers inside Ink trees.", +); + +impl Rule for InkNoDomRouter { + fn run_once(&self, ctx: &LintContext<'_>) { + let ink_tree = module_jsx_tree_index("ink", ctx); + if ink_tree.is_empty() { + return; + } + for node in ctx.nodes().iter() { + match node.kind() { + AstKind::JSXOpeningElement(opening_element) => { + let Some(component_name) = + ROUTER_MODULE_SOURCES.iter().find_map(|module_source| { + resolve_imported_jsx_component_name(opening_element, module_source, ctx) + }) + else { + continue; + }; + if !DOM_ROUTER_COMPONENT_NAMES.contains(&component_name) { + continue; + } + let Some(element_span) = owning_jsx_element_span(node, ctx) else { + continue; + }; + if ink_tree.contains_or_is_inside(element_span) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "`{component_name}` depends on DOM history; use a memory router with Ink." + )) + .with_label(opening_element.span), + ); + } + } + AstKind::CallExpression(call_expression) => { + let Expression::Identifier(identifier) = + call_expression.callee.get_inner_expression() + else { + continue; + }; + let Some(factory_name) = DOM_ROUTER_FACTORY_NAMES.iter().find(|factory_name| { + direct_named_import_matches( + identifier, + &[*factory_name], + &ROUTER_MODULE_SOURCES, + ctx, + ) + }) else { + continue; + }; + if ink_tree.is_inside(call_expression.span) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "`{factory_name}` requires a DOM; use `createMemoryRouter` with Ink." + )) + .with_label(call_expression.span), + ); + } + } + _ => {} + } + } + } +} diff --git a/native/oxlint/rules/ink-no-focus-in-render.rs b/native/oxlint/rules/ink-no-focus-in-render.rs new file mode 100644 index 0000000000..ccbd87bd15 --- /dev/null +++ b/native/oxlint/rules/ink-no-focus-in-render.rs @@ -0,0 +1,69 @@ +use oxc_ast::{ast::Expression, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const FOCUS_METHOD_NAMES: [&str; 3] = ["focus", "focusNext", "focusPrevious"]; +const MESSAGE: &str = "Changing Ink focus during render can trigger render loops."; + +#[derive(Debug, Default, Clone)] +pub struct InkNoFocusInRender; + +declare_oxc_lint!( + /// Disallow changing Ink focus during React render. + InkNoFocusInRender, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow Ink focus changes during render.", +); + +impl Rule for InkNoFocusInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !is_focus_manager_method_call(call_expression, ctx) + || !is_render_phase_component_or_hook(node, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } +} + +fn is_focus_manager_method_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + ctx: &LintContext<'a>, +) -> bool { + if let Some(member_expression) = call_expression.callee.as_member_expression() { + if !member_expression + .static_property_name() + .is_some_and(|method_name| FOCUS_METHOD_NAMES.contains(&method_name)) + { + return false; + } + let focus_manager = member_expression.object().get_inner_expression(); + return is_use_focus_manager_call(focus_manager, ctx) + || matches!( + focus_manager, + Expression::Identifier(identifier) + if identifier_initializer(identifier, ctx) + .is_some_and(|initializer| is_use_focus_manager_call(initializer, ctx)) + ); + } + let Expression::Identifier(identifier) = call_expression.callee.get_inner_expression() else { + return false; + }; + FOCUS_METHOD_NAMES.contains(&identifier.name.as_str()) + && identifier_initializer(identifier, ctx) + .is_some_and(|initializer| is_use_focus_manager_call(initializer, ctx)) +} + +fn is_use_focus_manager_call<'a>(expression: &Expression<'a>, ctx: &LintContext<'a>) -> bool { + let Expression::CallExpression(call_expression) = expression.get_inner_expression() else { + return false; + }; + imported_module_api_matches(&call_expression.callee, "useFocusManager", "ink", ctx) +} diff --git a/native/oxlint/rules/ink-no-layout-inside-text.rs b/native/oxlint/rules/ink-no-layout-inside-text.rs new file mode 100644 index 0000000000..bf85278fc4 --- /dev/null +++ b/native/oxlint/rules/ink-no-layout-inside-text.rs @@ -0,0 +1,62 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const LAYOUT_COMPONENT_NAMES: [&str; 3] = ["Box", "Spacer", "Static"]; +const TEXT_COMPONENT_NAMES: [&str; 2] = ["Text", "Transform"]; + +#[derive(Debug, Default, Clone)] +pub struct InkNoLayoutInsideText; + +declare_oxc_lint!( + /// Disallow Ink layout components inside Ink text components. + InkNoLayoutInsideText, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow Ink layout components inside text.", +); + +impl Rule for InkNoLayoutInsideText { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let Some(element_name) = resolve_imported_jsx_component_name(opening_element, "ink", ctx) + else { + return; + }; + if !LAYOUT_COMPONENT_NAMES.contains(&element_name) { + return; + } + let Some(parent_ink_element_name) = nearest_parent_ink_element_name(node, ctx) else { + return; + }; + if !TEXT_COMPONENT_NAMES.contains(&parent_ink_element_name) { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Ink `<{element_name}>` cannot render inside `<{parent_ink_element_name}>`." + )) + .with_label(opening_element.span), + ); + } +} + +fn nearest_parent_ink_element_name<'a, 'b>( + node: &AstNode<'a>, + ctx: &'b LintContext<'a>, +) -> Option<&'b str> { + ctx.nodes().ancestors(node.id()).find_map(|ancestor| { + let AstKind::JSXElement(element) = ancestor.kind() else { + return None; + }; + if element.opening_element.node_id.get() == node.id() { + return None; + } + resolve_imported_jsx_component_name(&element.opening_element, "ink", ctx) + }) +} diff --git a/native/oxlint/rules/ink-no-measure-element-in-render.rs b/native/oxlint/rules/ink-no-measure-element-in-render.rs new file mode 100644 index 0000000000..b3125f9355 --- /dev/null +++ b/native/oxlint/rules/ink-no-measure-element-in-render.rs @@ -0,0 +1,33 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const MESSAGE: &str = "Calling `measureElement` during render reads layout before Ink commits it."; + +#[derive(Debug, Default, Clone)] +pub struct InkNoMeasureElementInRender; + +declare_oxc_lint!( + /// Disallow Ink element measurement during React render. + InkNoMeasureElementInRender, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow Ink measurement during render.", +); + +impl Rule for InkNoMeasureElementInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !imported_module_api_matches(&call_expression.callee, "measureElement", "ink", ctx) + || !is_render_phase_component_or_hook(node, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } +} diff --git a/native/oxlint/rules/ink-suspense-requires-concurrent.rs b/native/oxlint/rules/ink-suspense-requires-concurrent.rs new file mode 100644 index 0000000000..e69a59572a --- /dev/null +++ b/native/oxlint/rules/ink-suspense-requires-concurrent.rs @@ -0,0 +1,17 @@ +use oxc_macros::declare_oxc_lint; + +use crate::rule::Rule; + +#[derive(Debug, Default, Clone)] +pub struct InkSuspenseRequiresConcurrent; + +declare_oxc_lint!( + /// Retired Ink Suspense rendering mode rule. + InkSuspenseRequiresConcurrent, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Retired Ink Suspense rendering mode rule.", +); + +impl Rule for InkSuspenseRequiresConcurrent {} diff --git a/native/oxlint/rules/is-create-element-call.rs b/native/oxlint/rules/is-create-element-call.rs new file mode 100644 index 0000000000..d30d13fcc7 --- /dev/null +++ b/native/oxlint/rules/is-create-element-call.rs @@ -0,0 +1,33 @@ +fn is_create_element_call(call_expression: &oxc_ast::ast::CallExpression) -> bool { + match &call_expression.callee { + oxc_ast::ast::Expression::Identifier(identifier) => identifier.name == "createElement", + oxc_ast::ast::Expression::StaticMemberExpression(member_expression) => { + member_expression.property.name == "createElement" + && !member_chain_contains_document(&member_expression.object) + } + oxc_ast::ast::Expression::ComputedMemberExpression(member_expression) => { + matches!( + &member_expression.expression, + oxc_ast::ast::Expression::StringLiteral(property) if property.value == "createElement" + ) && !member_chain_contains_document(&member_expression.object) + } + _ => false, + } +} + +fn member_chain_contains_document(expression: &oxc_ast::ast::Expression) -> bool { + match expression { + oxc_ast::ast::Expression::Identifier(identifier) => identifier.name == "document", + oxc_ast::ast::Expression::StaticMemberExpression(member_expression) => { + member_expression.property.name == "document" + || member_chain_contains_document(&member_expression.object) + } + oxc_ast::ast::Expression::ComputedMemberExpression(member_expression) => { + matches!( + &member_expression.expression, + oxc_ast::ast::Expression::Identifier(property) if property.name == "document" + ) || member_chain_contains_document(&member_expression.object) + } + _ => false, + } +} diff --git a/native/oxlint/rules/is-definitely-falsy-expression.rs b/native/oxlint/rules/is-definitely-falsy-expression.rs new file mode 100644 index 0000000000..17250863b2 --- /dev/null +++ b/native/oxlint/rules/is-definitely-falsy-expression.rs @@ -0,0 +1,22 @@ +fn is_definitely_falsy_expression<'a>( + expression: &'a oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::BooleanLiteral(literal) => !literal.value, + oxc_ast::ast::Expression::NullLiteral(_) => true, + oxc_ast::ast::Expression::NumericLiteral(literal) => literal.value == 0.0, + oxc_ast::ast::Expression::StringLiteral(literal) => literal.value.is_empty(), + oxc_ast::ast::Expression::UnaryExpression(unary_expression) + if unary_expression.operator == oxc_syntax::operator::UnaryOperator::Void => + { + true + } + oxc_ast::ast::Expression::Identifier(identifier) if identifier.name == "undefined" => ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none(), + _ => false, + } +} diff --git a/native/oxlint/rules/is-focusable-jsx-opening-element.rs b/native/oxlint/rules/is-focusable-jsx-opening-element.rs new file mode 100644 index 0000000000..7927d25796 --- /dev/null +++ b/native/oxlint/rules/is-focusable-jsx-opening-element.rs @@ -0,0 +1,188 @@ +use oxc_ast::ast::{ + Expression, JSXAttribute, JSXAttributeItem, JSXAttributeValue, JSXExpression, + JSXOpeningElement, ObjectPropertyKind, PropertyKey, +}; + +use crate::utils::{get_string_literal_prop_value, has_jsx_prop_ignore_case}; + +fn is_focusable_jsx_opening_element( + opening_element: &JSXOpeningElement, + tag_name: &str, + include_negative_tab_index: bool, +) -> bool { + let tab_index_value = has_jsx_prop_ignore_case(opening_element, "tabIndex") + .and_then(JSXAttributeItem::as_attribute) + .and_then(|attribute| attribute.value.as_ref()) + .and_then(|value| parse_static_jsx_number(value)); + if (tab_index_value.is_some_and(|value| value < 0.0) && !include_negative_tab_index) + || has_jsx_prop_ignore_case(opening_element, "hidden").is_some() + || has_static_hiding_inline_style(opening_element) + || has_hiding_class_name(opening_element) + { + return false; + } + tab_index_value.is_some_and(|value| value >= 0.0 || include_negative_tab_index) + || is_natively_focusable(tag_name, opening_element) +} + +fn is_natively_focusable(tag_name: &str, opening_element: &JSXOpeningElement) -> bool { + if matches!(tag_name, "button" | "input" | "select" | "textarea") + && has_jsx_prop_ignore_case(opening_element, "disabled") + .and_then(JSXAttributeItem::as_attribute) + .is_some_and(|attribute| !is_statically_false_boolean_attribute(attribute)) + { + return false; + } + match tag_name { + "button" | "embed" | "select" | "summary" | "textarea" => true, + "input" => !has_jsx_prop_ignore_case(opening_element, "type") + .and_then(get_string_literal_prop_value) + .is_some_and(|value| value.eq_ignore_ascii_case("hidden")), + "a" | "area" => has_jsx_prop_ignore_case(opening_element, "href").is_some(), + "audio" | "video" => has_jsx_prop_ignore_case(opening_element, "controls") + .and_then(JSXAttributeItem::as_attribute) + .is_some_and(|attribute| !is_statically_false_boolean_attribute(attribute)), + _ => false, + } +} + +fn is_statically_false_boolean_attribute(attribute: &JSXAttribute) -> bool { + matches!( + attribute.value.as_ref(), + Some(JSXAttributeValue::ExpressionContainer(container)) + if matches!( + &container.expression, + JSXExpression::BooleanLiteral(boolean_literal) if !boolean_literal.value + ) + ) +} + +fn has_static_hiding_inline_style(opening_element: &JSXOpeningElement) -> bool { + let Some(JSXAttributeItem::Attribute(style_attribute)) = + has_jsx_prop_ignore_case(opening_element, "style") + else { + return false; + }; + let Some(JSXAttributeValue::ExpressionContainer(container)) = style_attribute.value.as_ref() + else { + return false; + }; + let JSXExpression::ObjectExpression(object_expression) = &container.expression else { + return false; + }; + object_expression.properties.iter().any(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + let Some(property_name) = get_static_property_name(&property.key) else { + return false; + }; + let Expression::StringLiteral(string_literal) = &property.value else { + return false; + }; + matches!( + (property_name, string_literal.value.as_str()), + ("display", "none") | ("visibility", "hidden") + ) + }) +} + +fn get_static_property_name<'a>(property_key: &'a PropertyKey<'a>) -> Option<&'a str> { + match property_key { + PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.as_str()), + PropertyKey::StringLiteral(string_literal) => Some(string_literal.value.as_str()), + _ => None, + } +} + +fn has_hiding_class_name(opening_element: &JSXOpeningElement) -> bool { + let Some(JSXAttributeItem::Attribute(class_name_attribute)) = + has_jsx_prop_ignore_case(opening_element, "className") + else { + return false; + }; + let Some(class_name_text) = class_name_text(class_name_attribute) else { + return false; + }; + has_hiding_class_token(&class_name_text) +} + +fn class_name_text(attribute: &JSXAttribute) -> Option { + match attribute.value.as_ref()? { + JSXAttributeValue::StringLiteral(string_literal) => Some(string_literal.value.to_string()), + JSXAttributeValue::ExpressionContainer(container) => match &container.expression { + JSXExpression::StringLiteral(string_literal) => Some(string_literal.value.to_string()), + JSXExpression::TemplateLiteral(template_literal) => Some( + template_literal + .quasis + .iter() + .map(|quasi| { + quasi + .value + .cooked + .as_ref() + .map_or("", |cooked| cooked.as_str()) + }) + .collect::>() + .join(" "), + ), + _ => None, + }, + _ => None, + } +} + +fn has_hiding_class_token(class_name_text: &str) -> bool { + let mut token = String::new(); + let mut bracket_depth = 0_u32; + let mut parenthesis_depth = 0_u32; + let mut quote = None; + let mut is_escaped = false; + for character in class_name_text.chars() { + if is_escaped { + is_escaped = false; + token.push(character); + continue; + } + if character == '\\' { + is_escaped = true; + token.push(character); + continue; + } + if let Some(active_quote) = quote { + if character == active_quote { + quote = None; + } + token.push(character); + continue; + } + if matches!(character, '\'' | '"') { + quote = Some(character); + token.push(character); + continue; + } + match character { + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + '(' => parenthesis_depth += 1, + ')' => parenthesis_depth = parenthesis_depth.saturating_sub(1), + _ => {} + } + if bracket_depth == 0 + && parenthesis_depth == 0 + && (character.is_whitespace() || character == '\u{feff}') + { + if is_hiding_class_token(&token) { + return true; + } + token.clear(); + } else { + token.push(character); + } + } + is_hiding_class_token(&token) +} + +fn is_hiding_class_token(token: &str) -> bool { + token == "hidden" || token.ends_with("-hidden") +} diff --git a/native/oxlint/rules/is-global-nan-value.rs b/native/oxlint/rules/is-global-nan-value.rs new file mode 100644 index 0000000000..3816c3c934 --- /dev/null +++ b/native/oxlint/rules/is-global-nan-value.rs @@ -0,0 +1,116 @@ +fn is_global_nan_value<'a>( + expression: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + is_global_nan_value_inner(expression, ctx, &mut rustc_hash::FxHashSet::default()) +} + +fn is_global_nan_value_inner<'a>( + expression: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut rustc_hash::FxHashSet, +) -> bool { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::Identifier(identifier) => { + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return identifier.name == "NaN"; + }; + if !visited_symbol_ids.insert(symbol_id) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() { + return false; + } + let Some(initializer) = &declarator.init else { + return false; + }; + if declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + { + return is_global_nan_value_inner(initializer, ctx, visited_symbol_ids); + } + match &declarator.id { + oxc_ast::ast::BindingPattern::ObjectPattern(pattern) => is_global_number_expression( + initializer, + ctx, + ) && pattern + .properties + .iter() + .any(|property| { + property_key_matches_name(&property.key, "NaN") + && matches!( + &property.value, + oxc_ast::ast::BindingPattern::BindingIdentifier(binding_identifier) + if binding_identifier.symbol_id() == symbol_id + ) + }), + oxc_ast::ast::BindingPattern::ArrayPattern(pattern) => { + let Some(binding_index) = pattern.elements.iter().position(|element| { + matches!( + element, + Some(oxc_ast::ast::BindingPattern::BindingIdentifier(binding_identifier)) + if binding_identifier.symbol_id() == symbol_id + ) + }) else { + return false; + }; + let oxc_ast::ast::Expression::ArrayExpression(array_expression) = + initializer.get_inner_expression() + else { + return false; + }; + if array_expression.elements[..binding_index] + .iter() + .any(|element| { + matches!( + element, + oxc_ast::ast::ArrayExpressionElement::SpreadElement(_) + ) + }) + { + return false; + } + array_expression + .elements + .get(binding_index) + .and_then(oxc_ast::ast::ArrayExpressionElement::as_expression) + .is_some_and(|array_value| { + is_global_nan_value_inner(array_value, ctx, visited_symbol_ids) + }) + } + _ => false, + } + } + oxc_ast::ast::Expression::StaticMemberExpression(member_expression) => { + member_expression.property.name == "NaN" + && is_global_number_expression(&member_expression.object, ctx) + } + _ => false, + } +} + +fn is_global_number_expression<'a>( + expression: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + matches!( + expression.get_inner_expression(), + oxc_ast::ast::Expression::Identifier(identifier) + if identifier.name == "Number" + && ctx.scoping().get_reference(identifier.reference_id()).symbol_id().is_none() + ) +} diff --git a/native/oxlint/rules/is-in-project-directory.rs b/native/oxlint/rules/is-in-project-directory.rs new file mode 100644 index 0000000000..3753889369 --- /dev/null +++ b/native/oxlint/rules/is-in-project-directory.rs @@ -0,0 +1,29 @@ +fn is_in_project_directory(ctx: &crate::context::LintContext, directory_path: &str) -> bool { + let filename = ctx.file_path().to_string_lossy().replace('\\', "/"); + if filename.is_empty() { + return false; + } + let directory_segment = format!("/{directory_path}/"); + let root_directory = ctx + .settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("rootDirectory")) + .and_then(serde_json::Value::as_str) + .map(|root_directory| root_directory.replace('\\', "/")); + if let Some(root_directory) = root_directory { + let normalized_root_directory = root_directory.trim_end_matches('/'); + let root_directory_prefix = format!("{normalized_root_directory}/"); + if let Some(relative_filename) = filename.strip_prefix(&root_directory_prefix) { + return relative_filename.starts_with(&format!("{directory_path}/")) + || relative_filename.contains(&directory_segment); + } + } + filename + .get(1..) + .is_some_and(|filename_without_leading_character| { + filename_without_leading_character.contains(&directory_segment) + }) +} diff --git a/native/oxlint/rules/is-inside-excluded-typography-ancestor.rs b/native/oxlint/rules/is-inside-excluded-typography-ancestor.rs new file mode 100644 index 0000000000..926ebddc3d --- /dev/null +++ b/native/oxlint/rules/is-inside-excluded-typography-ancestor.rs @@ -0,0 +1,55 @@ +const TYPOGRAPHY_PUNCTUATION_EXCLUDED_TAG_NAMES: [&str; 26] = [ + "code", + "pre", + "kbd", + "samp", + "var", + "tt", + "markdown", + "markdownblock", + "markdowncontent", + "markdownrenderer", + "markdowntext", + "markdownview", + "mdx", + "mdxcontent", + "mdxremote", + "md", + "prose", + "richtext", + "article", + "blockquote", + "quote", + "trans", + "translation", + "translated", + "fbt", + "fbs", +]; + +fn is_inside_excluded_typography_ancestor( + node: &crate::AstNode, + ctx: &crate::context::LintContext, +) -> bool { + ctx.nodes().ancestors(node.id()).any(|ancestor| { + let oxc_ast::AstKind::JSXElement(element) = ancestor.kind() else { + return false; + }; + if get_opening_element_tag_name(&element.opening_element, ctx).is_some_and(|tag_name| { + TYPOGRAPHY_PUNCTUATION_EXCLUDED_TAG_NAMES + .iter() + .any(|excluded_name| tag_name.eq_ignore_ascii_case(excluded_name)) + }) { + return true; + } + let Some(translate_attribute) = find_jsx_attribute(&element.opening_element, "translate") + else { + return false; + }; + matches!( + translate_attribute.value.as_ref(), + Some(oxc_ast::ast::JSXAttributeValue::StringLiteral(string_literal)) + if string_literal.value == "no" + ) + }) +} diff --git a/native/oxlint/rules/is-inside-navigation.rs b/native/oxlint/rules/is-inside-navigation.rs new file mode 100644 index 0000000000..dc89819fe2 --- /dev/null +++ b/native/oxlint/rules/is-inside-navigation.rs @@ -0,0 +1,31 @@ +fn is_inside_navigation(node: &crate::AstNode, ctx: &crate::context::LintContext) -> bool { + ctx.nodes().ancestors(node.id()).any(|ancestor| { + let oxc_ast::AstKind::JSXElement(element) = ancestor.kind() else { + return false; + }; + if matches!( + &element.opening_element.name, + oxc_ast::ast::JSXElementName::Identifier(identifier) + if identifier.name == "nav" || identifier.name == "aside" + ) { + return true; + } + element + .opening_element + .attributes + .iter() + .find_map(|attribute| { + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = attribute else { + return None; + }; + matches!( + &attribute.name, + oxc_ast::ast::JSXAttributeName::Identifier(identifier) + if identifier.name.eq_ignore_ascii_case("role") + ) + .then_some(attribute.as_ref()) + }) + .and_then(|attribute| get_string_literal_attribute_value(attribute)) + .is_some_and(|role| role.eq_ignore_ascii_case("navigation")) + }) +} diff --git a/native/oxlint/rules/is-inside-stable-react-initializer.rs b/native/oxlint/rules/is-inside-stable-react-initializer.rs new file mode 100644 index 0000000000..1b2a8fb2de --- /dev/null +++ b/native/oxlint/rules/is-inside-stable-react-initializer.rs @@ -0,0 +1,41 @@ +fn is_inside_stable_react_initializer<'a>( + node: &crate::AstNode<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(mut enclosing_function) = crate::ast_util::get_enclosing_function(node, ctx) else { + return false; + }; + loop { + let callback_root = transparent_expression_root(enclosing_function, ctx); + let parent = ctx.nodes().parent_node(callback_root.id()); + if let oxc_ast::AstKind::CallExpression(call_expression) = parent.kind() + && expression_is_argument_at( + &call_expression.arguments, + 0, + oxc_span::GetSpan::span(callback_root), + ) + && (is_react_api_call(call_expression, "useState", ctx) + || (is_react_api_call(call_expression, "useMemo", ctx) + && call_expression + .arguments + .get(1) + .is_some_and(|argument| !argument.is_spread()))) + { + return true; + } + let Some(outer_function) = + ctx.nodes() + .ancestors(enclosing_function.id()) + .find(|ancestor| { + matches!( + ancestor.kind(), + oxc_ast::AstKind::Function(_) + | oxc_ast::AstKind::ArrowFunctionExpression(_) + ) + }) + else { + return false; + }; + enclosing_function = outer_function; + } +} diff --git a/native/oxlint/rules/is-inside-statically-hidden-jsx-subtree.rs b/native/oxlint/rules/is-inside-statically-hidden-jsx-subtree.rs new file mode 100644 index 0000000000..961a41032a --- /dev/null +++ b/native/oxlint/rules/is-inside-statically-hidden-jsx-subtree.rs @@ -0,0 +1,53 @@ +fn is_inside_statically_hidden_jsx_subtree<'a>( + node: &crate::AstNode<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + ctx.nodes().ancestors(node.id()).any(|ancestor| { + let oxc_ast::AstKind::JSXElement(element) = ancestor.kind() else { + return false; + }; + is_statically_hidden_opening_element(&element.opening_element, ctx) + }) +} + +fn is_statically_hidden_opening_element<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + if is_statically_hidden_from_screen_reader(opening_element, ctx) { + return true; + } + let style_attribute = get_authoritative_jsx_attribute(opening_element, "style", true); + if let Some(style_expression) = + style_attribute.and_then(|attribute| get_inline_style_object_expression(attribute)) + { + if get_effective_static_style_property(style_expression, "display") + .and_then(get_object_property_string_value) + .is_some_and(|value| value.eq_ignore_ascii_case("none")) + { + return true; + } + if get_effective_static_style_property(style_expression, "visibility") + .and_then(get_object_property_string_value) + .is_some_and(|value| { + value.eq_ignore_ascii_case("hidden") || value.eq_ignore_ascii_case("collapse") + }) + { + return true; + } + } + let Some(class_name) = get_static_class_name(opening_element) else { + return false; + }; + get_tailwind_visibility_at_breakpoints(class_name) + .is_some_and(|visibility| visibility.iter().all(|is_visible| !is_visible)) +} + +fn get_object_property_string_value<'a>( + property: &'a oxc_ast::ast::ObjectProperty<'a>, +) -> Option<&'a str> { + let oxc_ast::ast::Expression::StringLiteral(string_literal) = &property.value else { + return None; + }; + Some(string_literal.value.as_str()) +} diff --git a/native/oxlint/rules/is-js-whitespace.rs b/native/oxlint/rules/is-js-whitespace.rs new file mode 100644 index 0000000000..93daff8c9c --- /dev/null +++ b/native/oxlint/rules/is-js-whitespace.rs @@ -0,0 +1,20 @@ +fn is_js_whitespace(character: char) -> bool { + matches!( + character, + '\u{0009}' + | '\u{000A}' + | '\u{000B}' + | '\u{000C}' + | '\u{000D}' + | '\u{0020}' + | '\u{00A0}' + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) +} diff --git a/native/oxlint/rules/is-layout-transition-property.rs b/native/oxlint/rules/is-layout-transition-property.rs new file mode 100644 index 0000000000..d21971019e --- /dev/null +++ b/native/oxlint/rules/is-layout-transition-property.rs @@ -0,0 +1,32 @@ +fn is_layout_transition_property(property_name: &str) -> bool { + const LAYOUT_TRANSITION_PROPERTIES: [&str; 27] = [ + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height", + "top", + "right", + "bottom", + "left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "border-width", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "line-height", + "column-width", + ]; + LAYOUT_TRANSITION_PROPERTIES.contains(&property_name) +} diff --git a/native/oxlint/rules/is-motion-hook-result-expression.rs b/native/oxlint/rules/is-motion-hook-result-expression.rs new file mode 100644 index 0000000000..193d0eda03 --- /dev/null +++ b/native/oxlint/rules/is-motion-hook-result-expression.rs @@ -0,0 +1,54 @@ +fn is_motion_hook_result_expression<'a>( + expression: &oxc_ast::ast::Expression<'a>, + hook_names: &[&str], + ctx: &crate::context::LintContext<'a>, +) -> bool { + is_motion_hook_result_expression_internal(expression, hook_names, ctx, &mut Vec::new()) +} + +fn is_motion_hook_result_expression_internal<'a>( + expression: &oxc_ast::ast::Expression<'a>, + hook_names: &[&str], + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + let expression = expression.get_inner_expression(); + if let oxc_ast::ast::Expression::CallExpression(call_expression) = expression { + return hook_names.iter().any(|hook_name| { + motion_react_api_path_matches(&call_expression.callee, &[*hook_name], ctx) + }); + } + let oxc_ast::ast::Expression::Identifier(identifier) = expression else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + visited_symbol_ids.push(symbol_id); + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + if !matches!( + parent.kind(), + oxc_ast::AstKind::VariableDeclaration(variable_declaration) + if variable_declaration.kind.is_const() + ) || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return false; + } + declarator.init.as_ref().is_some_and(|initializer| { + is_motion_hook_result_expression_internal(initializer, hook_names, ctx, visited_symbol_ids) + }) +} diff --git a/native/oxlint/rules/is-next-file-active.rs b/native/oxlint/rules/is-next-file-active.rs new file mode 100644 index 0000000000..ea6a0dba00 --- /dev/null +++ b/native/oxlint/rules/is-next-file-active.rs @@ -0,0 +1,108 @@ +const DEPENDENCY_SECTION_NAMES: [&str; 4] = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; + +trait NextFileContext { + fn next_file_path(&self) -> &std::path::Path; + fn next_root_directory(&self) -> Option<&str>; +} + +impl NextFileContext for crate::context::LintContext<'_> { + fn next_file_path(&self) -> &std::path::Path { + self.file_path() + } + + fn next_root_directory(&self) -> Option<&str> { + get_next_root_directory(self.settings().json.as_ref()) + } +} + +impl NextFileContext for crate::context::ContextHost<'_> { + fn next_file_path(&self) -> &std::path::Path { + self.file_path() + } + + fn next_root_directory(&self) -> Option<&str> { + get_next_root_directory(self.settings().json.as_ref()) + } +} + +fn is_next_file_active(ctx: &impl NextFileContext) -> bool { + let Some(package_directory) = find_nearest_package_directory(ctx.next_file_path()) else { + return true; + }; + let package_json_path = package_directory.join("package.json"); + let Some(manifest) = std::fs::read_to_string(package_json_path) + .ok() + .and_then(|manifest| serde_json::from_str::(&manifest).ok()) + .and_then(|manifest| manifest.as_object().cloned()) + else { + return true; + }; + if DEPENDENCY_SECTION_NAMES.iter().any(|section_name| { + manifest + .get(*section_name) + .and_then(serde_json::Value::as_object) + .is_some_and(|section| section.contains_key("next")) + }) { + return true; + } + let declares_any_dependency = DEPENDENCY_SECTION_NAMES.iter().any(|section_name| { + manifest + .get(*section_name) + .and_then(serde_json::Value::as_object) + .is_some_and(|section| !section.is_empty()) + }); + if !declares_any_dependency { + return true; + } + let Some(root_directory) = ctx.next_root_directory() else { + return true; + }; + !is_package_within_project_root(&package_directory, root_directory) +} + +fn get_next_root_directory( + settings: Option<&serde_json::Map>, +) -> Option<&str> { + settings + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("rootDirectory")) + .and_then(serde_json::Value::as_str) + .filter(|root_directory| !root_directory.is_empty()) +} + +fn find_nearest_package_directory(file_path: &std::path::Path) -> Option { + let mut current_directory = file_path.parent(); + while let Some(directory) = current_directory { + if directory.join("package.json").is_file() { + return Some(directory.to_path_buf()); + } + current_directory = directory.parent(); + } + None +} + +fn is_package_within_project_root( + package_directory: &std::path::Path, + root_directory: &str, +) -> bool { + let resolved_package_directory = std::fs::canonicalize(package_directory) + .unwrap_or_else(|_| package_directory.to_path_buf()) + .to_string_lossy() + .replace('\\', "/"); + let normalized_root_directory = root_directory.replace('\\', "/"); + if resolved_package_directory == normalized_root_directory { + return false; + } + let root_prefix = if normalized_root_directory.ends_with('/') { + normalized_root_directory + } else { + format!("{normalized_root_directory}/") + }; + resolved_package_directory.starts_with(&root_prefix) +} diff --git a/native/oxlint/rules/is-non-production-file.rs b/native/oxlint/rules/is-non-production-file.rs new file mode 100644 index 0000000000..2f776eceaf --- /dev/null +++ b/native/oxlint/rules/is-non-production-file.rs @@ -0,0 +1,200 @@ +const NON_PRODUCTION_PATH_SEGMENTS: &[&str] = &[ + "/test/", + "/tests/", + "/testing/", + "/__tests__/", + "/__test__/", + "/__fixtures__/", + "/fixtures/", + "/__mocks__/", + "/mocks/", + "/testUtils/", + "/test-utils/", + "/test-stubs/", + "/testutils/", + "/cypress/", + "/playwright/", + "/.storybook/", + "/.dumi/", + "/stories/", + "/__stories__/", + "/playground/", + "/playgrounds/", + "/examples/", + "/example/", + "/demo/", + "/demos/", + "/sandbox/", + "/sandboxes/", + "/e2e/", + "/e2e-tests/", + "/specs/", + "/spec/", + "/integration-tests/", + "/integration/", + "/it/", + "/benchmarks/", + "/benchmark/", + "/__benchmarks__/", + "/perf/", + "/perf-tests/", + "/scripts/", + "/cli/", + "/bin/", + "/tooling/", + "/tools/", + "/codemods/", + "/codemod/", + "/migrations/", + "/migration/", + "/generators/", + "/generator/", + "/runbooks/", + "/devtools/", + "/internal-tools/", + "/seeds/", + "/seed/", + "/dev-seeder/", +]; + +const NON_PRODUCTION_FILENAME_SUFFIXES: &[&str] = &[ + ".test.", + ".spec.", + ".cy.", + ".stories.", + ".story.", + ".bench.", + ".benchmark.", + ".e2e.", + ".integration-spec.", + ".int-spec.", + ".mock.", + ".mocks.", + ".fixture.", +]; + +const NON_PRODUCTION_BASENAMES: &[&str] = &[ + "setuptests.js", + "setuptests.ts", + "setuptests.jsx", + "setuptests.tsx", + "setupvitest.js", + "setupvitest.ts", + "setupvitest.jsx", + "setupvitest.tsx", + "setupjest.js", + "setupjest.ts", + "vitest.setup.js", + "vitest.setup.ts", + "vitest.setup.mjs", + "vitest.config.ts", + "vitest.config.js", + "vitest.config.mts", + "vitest.config.mjs", + "jest.setup.js", + "jest.setup.ts", + "jest.setup.jsx", + "jest.setup.tsx", + "jest.config.js", + "jest.config.ts", + "jest.config.mjs", + "playwright.config.ts", + "playwright.config.js", + "cypress.config.ts", + "cypress.config.js", + "karma.conf.js", + "karma.conf.ts", + "vite.config.ts", + "vite.config.js", + "vite.config.mts", + "vite.config.mjs", + "webpack.config.ts", + "webpack.config.js", + "webpack.config.mjs", + "rollup.config.ts", + "rollup.config.js", + "rollup.config.mjs", + "esbuild.config.ts", + "esbuild.config.js", + "esbuild.config.mjs", + "tsup.config.ts", + "tsup.config.js", + "tsup.config.mjs", + "rsbuild.config.ts", + "rsbuild.config.js", + "rspack.config.ts", + "rspack.config.js", + "next.config.ts", + "next.config.js", + "next.config.mjs", + "remix.config.js", + "remix.config.ts", + "astro.config.ts", + "astro.config.js", + "astro.config.mjs", + "tailwind.config.ts", + "tailwind.config.js", + "tailwind.config.mjs", + "postcss.config.ts", + "postcss.config.js", + "postcss.config.mjs", + "biome.config.ts", + "biome.config.js", + "drizzle.config.ts", + "drizzle.config.js", + "prisma.config.ts", + "prisma.config.js", + "knip.config.ts", + "knip.config.js", + "knip.config.mjs", + "lint-staged.config.js", + "lint-staged.config.mjs", +]; + +const SOURCE_ROOT_SEGMENTS: &[&str] = &[ + "/src/", + "/app/", + "/lib/", + "/components/", + "/pages/", + "/features/", + "/modules/", + "/packages/", + "/apps/", + "/frontend/", + "/client/", +]; + +fn is_non_production_file(ctx: &crate::context::ContextHost) -> bool { + let filename = ctx.file_path().to_string_lossy().replace('\\', "/"); + let basename = filename.rsplit('/').next().unwrap_or(filename.as_ref()); + let lowercase_basename = basename.to_lowercase(); + if NON_PRODUCTION_BASENAMES.contains(&lowercase_basename.as_str()) + || NON_PRODUCTION_FILENAME_SUFFIXES + .iter() + .any(|suffix| basename.contains(suffix)) + { + return true; + } + if ["/.storybook/", "/.dumi/"] + .iter() + .any(|segment| filename.contains(segment)) + { + return true; + } + let scoped_filename = SOURCE_ROOT_SEGMENTS + .iter() + .filter_map(|segment| filename.rfind(segment)) + .max() + .map_or(filename.as_ref(), |source_root_index| { + &filename[source_root_index..] + }); + NON_PRODUCTION_PATH_SEGMENTS.iter().any(|segment| { + let haystack = if segment.starts_with("/.") { + filename.as_ref() + } else { + scoped_filename + }; + haystack.contains(segment) + }) +} diff --git a/native/oxlint/rules/is-proven-global-namespace-reference.rs b/native/oxlint/rules/is-proven-global-namespace-reference.rs new file mode 100644 index 0000000000..cfcec8573e --- /dev/null +++ b/native/oxlint/rules/is-proven-global-namespace-reference.rs @@ -0,0 +1,150 @@ +fn is_proven_global_namespace_reference<'a>( + expression: &oxc_ast::ast::Expression<'a>, + namespace_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + is_proven_global_namespace_reference_inner( + expression, + namespace_name, + ctx, + &mut rustc_hash::FxHashSet::default(), + ) +} + +fn is_proven_global_namespace_reference_inner<'a>( + expression: &oxc_ast::ast::Expression<'a>, + namespace_name: &str, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut rustc_hash::FxHashSet, +) -> bool { + let expression = expression.get_inner_expression(); + let oxc_ast::ast::Expression::Identifier(identifier) = expression else { + return expression + .as_member_expression() + .is_some_and(|member_expression| { + member_expression.static_property_name() == Some(namespace_name) + && is_proven_global_object_reference( + member_expression.object(), + ctx, + &mut visited_symbol_ids.clone(), + ) + }); + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return identifier.name == namespace_name; + }; + if !visited_symbol_ids.insert(symbol_id) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() { + return false; + } + let Some(initializer) = &declarator.init else { + return false; + }; + if declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + { + return is_proven_global_namespace_reference_inner( + initializer, + namespace_name, + ctx, + visited_symbol_ids, + ); + } + let oxc_ast::ast::BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return false; + }; + pattern.properties.iter().any(|property| { + property_key_matches_name(&property.key, namespace_name) + && binding_pattern_has_symbol(&property.value, symbol_id) + && is_proven_global_object_reference(initializer, ctx, &mut visited_symbol_ids.clone()) + }) +} + +fn is_proven_global_object_reference<'a>( + expression: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut rustc_hash::FxHashSet, +) -> bool { + let oxc_ast::ast::Expression::Identifier(identifier) = expression.get_inner_expression() else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return matches!( + identifier.name.as_str(), + "global" | "globalThis" | "self" | "window" + ); + }; + if !visited_symbol_ids.insert(symbol_id) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() { + return false; + } + let Some(initializer) = &declarator.init else { + return false; + }; + if declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + { + return is_proven_global_object_reference(initializer, ctx, visited_symbol_ids); + } + let oxc_ast::ast::BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return false; + }; + pattern.properties.iter().any(|property| { + matches!( + property.key.static_name().as_deref(), + Some("global" | "globalThis" | "self" | "window") + ) && binding_pattern_has_symbol(&property.value, symbol_id) + && is_proven_global_object_reference(initializer, ctx, &mut visited_symbol_ids.clone()) + }) +} + +fn binding_pattern_has_symbol( + pattern: &oxc_ast::ast::BindingPattern, + symbol_id: oxc_semantic::SymbolId, +) -> bool { + match pattern { + oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) => { + identifier.symbol_id() == symbol_id + } + oxc_ast::ast::BindingPattern::AssignmentPattern(assignment) => { + matches!( + &assignment.left, + oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) + if identifier.symbol_id() == symbol_id + ) + } + _ => false, + } +} diff --git a/native/oxlint/rules/is-proven-intrinsic-jsx-element.rs b/native/oxlint/rules/is-proven-intrinsic-jsx-element.rs new file mode 100644 index 0000000000..5f8651d8d4 --- /dev/null +++ b/native/oxlint/rules/is-proven-intrinsic-jsx-element.rs @@ -0,0 +1,91 @@ +fn is_proven_intrinsic_jsx_element<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let identifier = match &opening_element.name { + oxc_ast::ast::JSXElementName::Identifier(identifier) => { + return identifier + .name + .chars() + .next() + .is_some_and(|first_character| { + first_character.to_lowercase().to_string() == first_character.to_string() + }); + } + oxc_ast::ast::JSXElementName::IdentifierReference(identifier) => identifier, + _ => return false, + }; + if resolve_jsx_element_type(opening_element, ctx) + .and_then(|(element_type, _)| element_type.chars().next()) + .is_some_and(|first_character| { + first_character.to_lowercase().to_string() == first_character.to_string() + }) + { + return true; + } + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + is_intrinsic_const_symbol(symbol_id, ctx, &mut Vec::new()) +} + +fn is_intrinsic_const_symbol<'a>( + symbol_id: oxc_semantic::SymbolId, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() + || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return false; + } + let Some(initializer) = declarator.init.as_ref() else { + return false; + }; + visited_symbol_ids.push(symbol_id); + let is_intrinsic = is_intrinsic_expression(initializer, ctx, visited_symbol_ids); + visited_symbol_ids.pop(); + is_intrinsic +} + +fn is_intrinsic_expression<'a>( + expression: &'a oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + match expression { + oxc_ast::ast::Expression::StringLiteral(_) => true, + oxc_ast::ast::Expression::Identifier(identifier) => ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_some_and(|symbol_id| is_intrinsic_const_symbol(symbol_id, ctx, visited_symbol_ids)), + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => { + is_intrinsic_expression(&conditional_expression.consequent, ctx, visited_symbol_ids) + && is_intrinsic_expression( + &conditional_expression.alternate, + ctx, + visited_symbol_ids, + ) + } + _ => false, + } +} diff --git a/native/oxlint/rules/is-pure-black-color.rs b/native/oxlint/rules/is-pure-black-color.rs new file mode 100644 index 0000000000..45b0b75889 --- /dev/null +++ b/native/oxlint/rules/is-pure-black-color.rs @@ -0,0 +1,20 @@ +fn is_pure_black_color(value: &str) -> bool { + let normalized_value = value + .trim_matches(|character| is_js_whitespace(character)) + .to_ascii_lowercase(); + if normalized_value == "#000" || normalized_value == "#000000" { + return true; + } + let Some(rgb_components) = normalized_value + .strip_prefix("rgb(") + .and_then(|value| value.strip_suffix(')')) + else { + return false; + }; + let mut components = rgb_components.split(','); + (0..3).all(|_| { + components.next().is_some_and(|component| { + component.trim_matches(|character| is_js_whitespace(character)) == "0" + }) + }) && components.next().is_none() +} diff --git a/native/oxlint/rules/is-react-api-call.rs b/native/oxlint/rules/is-react-api-call.rs new file mode 100644 index 0000000000..e9415883c4 --- /dev/null +++ b/native/oxlint/rules/is-react-api-call.rs @@ -0,0 +1,163 @@ +const REACT_RUNTIME_MODULE_SOURCES: [&str; 5] = [ + "react", + "react-dom", + "preact/compat", + "preact/hooks", + "@wordpress/element", +]; + +fn is_react_api_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + api_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let callee = call_expression.callee.get_inner_expression(); + if let oxc_ast::ast::Expression::Identifier(identifier) = callee { + return is_named_react_api_import(identifier, api_name, ctx) + || is_destructured_react_api_binding(identifier, api_name, ctx); + } + let Some(member_expression) = callee.as_member_expression() else { + return false; + }; + member_expression.static_property_name() == Some(api_name) + && is_react_namespace_receiver(member_expression.object().get_inner_expression(), ctx) +} + +fn is_named_react_api_import<'a>( + identifier: &oxc_ast::ast::IdentifierReference<'a>, + api_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(symbol_id) = resolve_const_identifier_alias(identifier, ctx) else { + return false; + }; + matching_react_import(symbol_id, ctx).is_some_and(|entry| { + matches!( + &entry.import_name, + crate::module_record::ImportImportName::Name(imported_name) + if imported_name.name() == api_name + ) + }) +} + +fn is_react_namespace_receiver<'a>( + receiver: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let oxc_ast::ast::Expression::Identifier(identifier) = receiver else { + return false; + }; + if let Some(symbol_id) = resolve_const_identifier_alias(identifier, ctx) + && let Some(entry) = matching_react_import(symbol_id, ctx) + { + return matches!( + &entry.import_name, + crate::module_record::ImportImportName::Default(_) + | crate::module_record::ImportImportName::NamespaceObject + ) || matches!( + &entry.import_name, + crate::module_record::ImportImportName::Name(imported_name) + if imported_name.name() == "default" + ); + } + identifier.name == "React" + && ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none() +} + +fn is_destructured_react_api_binding<'a>( + identifier: &oxc_ast::ast::IdentifierReference<'a>, + api_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() { + return false; + } + let oxc_ast::ast::BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return false; + }; + let has_matching_property = pattern.properties.iter().any(|property| { + if property.computed || !property_key_matches_name(&property.key, api_name) { + return false; + } + matches!( + &property.value, + oxc_ast::ast::BindingPattern::BindingIdentifier(binding_identifier) + if binding_identifier.symbol_id() == symbol_id + ) + }); + has_matching_property + && declarator.init.as_ref().is_some_and(|initializer| { + is_react_namespace_receiver(initializer.get_inner_expression(), ctx) + }) +} + +fn resolve_const_identifier_alias<'a>( + identifier: &oxc_ast::ast::IdentifierReference<'a>, + ctx: &crate::context::LintContext<'a>, +) -> Option { + let mut symbol_id = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id()?; + let mut visited_symbol_ids = Vec::new(); + loop { + if visited_symbol_ids.contains(&symbol_id) { + return None; + } + visited_symbol_ids.push(symbol_id); + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return Some(symbol_id); + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return Some(symbol_id); + }; + if !variable_declaration.kind.is_const() + || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return None; + } + let Some(oxc_ast::ast::Expression::Identifier(next_identifier)) = + declarator.init.as_ref().map(|initializer| initializer.get_inner_expression()) + else { + return Some(symbol_id); + }; + symbol_id = ctx + .scoping() + .get_reference(next_identifier.reference_id()) + .symbol_id()?; + } +} + +fn matching_react_import<'a>( + symbol_id: oxc_semantic::SymbolId, + ctx: &'a crate::context::LintContext<'_>, +) -> Option<&'a crate::module_record::ImportEntry> { + ctx.module_record().import_entries.iter().find(|entry| { + REACT_RUNTIME_MODULE_SOURCES.contains(&entry.module_request.name()) + && ctx.scoping().get_root_binding(entry.local_name.name().into()) == Some(symbol_id) + }) +} diff --git a/native/oxlint/rules/is-react-es6-component.rs b/native/oxlint/rules/is-react-es6-component.rs new file mode 100644 index 0000000000..28002290d2 --- /dev/null +++ b/native/oxlint/rules/is-react-es6-component.rs @@ -0,0 +1,33 @@ +fn is_react_es6_component(node: &crate::AstNode<'_>) -> bool { + let oxc_ast::AstKind::Class(class) = node.kind() else { + return false; + }; + let Some(heritage) = &class.heritage else { + return false; + }; + match &heritage.expression { + oxc_ast::ast::Expression::Identifier(identifier) => { + matches!(identifier.name.as_str(), "Component" | "PureComponent") + } + oxc_ast::ast::Expression::StaticMemberExpression(member_expression) => { + matches!( + &member_expression.object, + oxc_ast::ast::Expression::Identifier(identifier) if identifier.name == "React" + ) && matches!( + member_expression.property.name.as_str(), + "Component" | "PureComponent" + ) + } + oxc_ast::ast::Expression::ComputedMemberExpression(member_expression) => { + matches!( + &member_expression.object, + oxc_ast::ast::Expression::Identifier(identifier) if identifier.name == "React" + ) && matches!( + &member_expression.expression, + oxc_ast::ast::Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "Component" | "PureComponent") + ) + } + _ => false, + } +} diff --git a/native/oxlint/rules/is-react-hook-call.rs b/native/oxlint/rules/is-react-hook-call.rs new file mode 100644 index 0000000000..718a6d0025 --- /dev/null +++ b/native/oxlint/rules/is-react-hook-call.rs @@ -0,0 +1,88 @@ +fn is_react_hook_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + hook_names: &[&str], + ctx: &crate::context::LintContext<'a>, +) -> bool { + if hook_names + .iter() + .any(|hook_name| is_react_api_call(call_expression, hook_name, ctx)) + { + return true; + } + is_react_hook_callee( + &call_expression.callee, + hook_names, + ctx, + &mut rustc_hash::FxHashSet::default(), + ) +} + +fn is_react_hook_callee<'a>( + expression: &oxc_ast::ast::Expression<'a>, + hook_names: &[&str], + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut rustc_hash::FxHashSet, +) -> bool { + match expression.get_inner_expression() { + oxc_ast::ast::Expression::Identifier(identifier) => { + if hook_names.iter().any(|hook_name| { + is_named_react_api_import(identifier, hook_name, ctx) + || is_destructured_react_api_binding(identifier, hook_name, ctx) + }) { + return true; + } + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return hook_names.contains(&identifier.name.as_str()); + }; + if !visited_symbol_ids.insert(symbol_id) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + variable_declaration.kind.is_const() + && declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + && declarator.init.as_ref().is_some_and(|initializer| { + is_react_hook_callee(initializer, hook_names, ctx, visited_symbol_ids) + }) + } + oxc_ast::ast::Expression::ConditionalExpression(conditional_expression) => { + let mut consequent_visited_symbol_ids = visited_symbol_ids.clone(); + let mut alternate_visited_symbol_ids = visited_symbol_ids.clone(); + is_react_hook_callee( + &conditional_expression.consequent, + hook_names, + ctx, + &mut consequent_visited_symbol_ids, + ) && is_react_hook_callee( + &conditional_expression.alternate, + hook_names, + ctx, + &mut alternate_visited_symbol_ids, + ) + } + expression => expression + .as_member_expression() + .is_some_and(|member_expression| { + member_expression + .static_property_name() + .is_some_and(|property_name| hook_names.contains(&property_name)) + && is_react_namespace_receiver( + member_expression.object().get_inner_expression(), + ctx, + ) + }), + } +} diff --git a/native/oxlint/rules/is-react-router-file-active.rs b/native/oxlint/rules/is-react-router-file-active.rs new file mode 100644 index 0000000000..71aac99077 --- /dev/null +++ b/native/oxlint/rules/is-react-router-file-active.rs @@ -0,0 +1,126 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, LazyLock, Mutex}, +}; + +#[derive(Debug)] +struct ReactRouterPackageSummary { + directory: PathBuf, + has_any_dependency: bool, + has_react_router_dependency: bool, +} + +static REACT_ROUTER_PACKAGE_SUMMARIES: LazyLock< + Mutex>>>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); + +fn is_react_router_file_active(ctx: &crate::context::ContextHost<'_>) -> bool { + let Some(package_summary) = nearest_react_router_package_summary(ctx.file_path()) else { + return true; + }; + if package_summary.has_react_router_dependency || !package_summary.has_any_dependency { + return true; + } + !react_doctor_root_directory(ctx) + .as_deref() + .is_some_and(|root_directory| { + is_nested_package_within_root(&package_summary.directory, root_directory) + }) +} + +fn react_doctor_root_directory(ctx: &crate::context::ContextHost<'_>) -> Option { + ctx.settings() + .json + .as_ref()? + .get("react-doctor")? + .as_object()? + .get("rootDirectory")? + .as_str() + .filter(|root_directory| !root_directory.is_empty()) + .map(PathBuf::from) +} + +fn is_nested_package_within_root(package_directory: &Path, root_directory: &Path) -> bool { + let resolved_package_directory = package_directory + .canonicalize() + .unwrap_or_else(|_| package_directory.to_path_buf()); + resolved_package_directory != root_directory + && resolved_package_directory.starts_with(root_directory) +} + +fn nearest_react_router_package_summary( + file_path: &Path, +) -> Option> { + let mut directory = file_path.parent()?; + let mut visited_directories = Vec::new(); + loop { + let cached_package_summary = { + let cache = REACT_ROUTER_PACKAGE_SUMMARIES + .lock() + .expect("react router package cache lock should not be poisoned"); + cache.get(directory).cloned() + }; + if let Some(package_summary) = cached_package_summary { + cache_react_router_package_summary(&visited_directories, package_summary.clone()); + return package_summary; + } + visited_directories.push(directory.to_path_buf()); + let package_json_path = directory.join("package.json"); + if package_json_path.is_file() { + let package_summary = read_react_router_package_summary(directory, &package_json_path); + cache_react_router_package_summary(&visited_directories, package_summary.clone()); + return package_summary; + } + let Some(parent_directory) = directory.parent() else { + cache_react_router_package_summary(&visited_directories, None); + return None; + }; + directory = parent_directory; + } +} + +fn cache_react_router_package_summary( + directories: &[PathBuf], + package_summary: Option>, +) { + let mut cache = REACT_ROUTER_PACKAGE_SUMMARIES + .lock() + .expect("react router package cache lock should not be poisoned"); + for directory in directories { + cache.insert(directory.clone(), package_summary.clone()); + } +} + +fn read_react_router_package_summary( + package_directory: &Path, + package_json_path: &Path, +) -> Option> { + let package_json = std::fs::read_to_string(package_json_path).ok()?; + let manifest = serde_json::from_str::(&package_json).ok()?; + let manifest = manifest.as_object()?; + let mut has_any_dependency = false; + let mut has_react_router_dependency = false; + for section_name in [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] { + let Some(dependencies) = manifest + .get(section_name) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + has_any_dependency |= !dependencies.is_empty(); + has_react_router_dependency |= ["@react-router/dev", "react-router-dom", "react-router"] + .iter() + .any(|dependency_name| dependencies.contains_key(*dependency_name)); + } + Some(Arc::new(ReactRouterPackageSummary { + directory: package_directory.to_path_buf(), + has_any_dependency, + has_react_router_dependency, + })) +} diff --git a/native/oxlint/rules/is-react-router-framework-file-active.rs b/native/oxlint/rules/is-react-router-framework-file-active.rs new file mode 100644 index 0000000000..1b6d3dedb9 --- /dev/null +++ b/native/oxlint/rules/is-react-router-framework-file-active.rs @@ -0,0 +1,70 @@ +fn is_react_router_framework_file_active(ctx: &crate::context::ContextHost<'_>) -> bool { + let Some((package_directory, has_any_dependency, has_framework_dependency)) = + nearest_react_router_framework_package_status(ctx.file_path()) + else { + return true; + }; + let is_project_package = react_doctor_framework_root_directory(ctx) + .as_deref() + .is_some_and(|root_directory| { + let resolved_package_directory = package_directory + .canonicalize() + .unwrap_or(package_directory); + resolved_package_directory.starts_with(root_directory) + }); + if is_project_package && has_any_dependency { + return has_framework_dependency; + } + is_react_router_file_active(ctx) +} + +fn react_doctor_framework_root_directory( + ctx: &crate::context::ContextHost<'_>, +) -> Option { + ctx.settings() + .json + .as_ref()? + .get("react-doctor")? + .as_object()? + .get("rootDirectory")? + .as_str() + .filter(|root_directory| !root_directory.is_empty()) + .map(std::path::PathBuf::from) +} + +fn nearest_react_router_framework_package_status( + file_path: &std::path::Path, +) -> Option<(std::path::PathBuf, bool, bool)> { + let mut directory = file_path.parent()?; + loop { + let package_json_path = directory.join("package.json"); + if package_json_path.is_file() { + let package_json = std::fs::read_to_string(package_json_path).ok()?; + let manifest = serde_json::from_str::(&package_json).ok()?; + let manifest = manifest.as_object()?; + let mut has_any_dependency = false; + let mut has_framework_dependency = false; + for section_name in [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] { + let Some(dependencies) = manifest + .get(section_name) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + has_any_dependency |= !dependencies.is_empty(); + has_framework_dependency |= dependencies.contains_key("@react-router/dev"); + } + return Some(( + directory.to_path_buf(), + has_any_dependency, + has_framework_dependency, + )); + } + directory = directory.parent()?; + } +} diff --git a/native/oxlint/rules/is-render-phase-component-or-hook.rs b/native/oxlint/rules/is-render-phase-component-or-hook.rs new file mode 100644 index 0000000000..fc2a520c02 --- /dev/null +++ b/native/oxlint/rules/is-render-phase-component-or-hook.rs @@ -0,0 +1,159 @@ +use oxc_span::GetSpan; + +const SYNCHRONOUS_ITERATION_METHOD_NAMES: [&str; 14] = [ + "map", + "filter", + "forEach", + "flatMap", + "reduce", + "reduceRight", + "some", + "every", + "find", + "findIndex", + "findLast", + "findLastIndex", + "sort", + "toSorted", +]; + +fn is_render_phase_component_or_hook<'a>( + node: &crate::AstNode<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(mut function_node) = crate::ast_util::get_enclosing_function(node, ctx) else { + return false; + }; + loop { + if component_or_hook_function_name(function_node, ctx).is_some() { + return true; + } + if !function_executes_during_render(function_node, ctx) { + return false; + } + let Some(outer_function) = ctx.nodes().ancestors(function_node.id()).find(|ancestor| { + matches!( + ancestor.kind(), + oxc_ast::AstKind::Function(_) | oxc_ast::AstKind::ArrowFunctionExpression(_) + ) + }) else { + return false; + }; + function_node = outer_function; + } +} + +fn function_executes_during_render<'a>( + function_node: &crate::AstNode<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let expression_root = transparent_expression_root(function_node, ctx); + let parent = ctx.nodes().parent_node(expression_root.id()); + if let oxc_ast::AstKind::NewExpression(new_expression) = parent.kind() { + return expression_is_argument_at(&new_expression.arguments, 0, expression_root.span()) + && matches!( + new_expression.callee.get_inner_expression(), + oxc_ast::ast::Expression::Identifier(identifier) + if identifier.name == "Promise" + && ctx.scoping().get_reference(identifier.reference_id()).symbol_id().is_none() + ); + } + let oxc_ast::AstKind::CallExpression(call_expression) = parent.kind() else { + return false; + }; + if call_expression.callee.span() == expression_root.span() { + return true; + } + if expression_is_argument_at(&call_expression.arguments, 0, expression_root.span()) { + if is_react_api_call(call_expression, "useMemo", ctx) + || is_react_api_call(call_expression, "useState", ctx) + || is_react_api_call(call_expression, "startTransition", ctx) + { + return true; + } + if let Some(member_expression) = call_expression + .callee + .get_inner_expression() + .as_member_expression() + && member_expression + .static_property_name() + .is_some_and(|property_name| { + SYNCHRONOUS_ITERATION_METHOD_NAMES.contains(&property_name) + }) + { + return true; + } + } + expression_is_argument_at(&call_expression.arguments, 1, expression_root.span()) + && is_global_array_from_call(call_expression, ctx) +} + +fn is_global_array_from_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let callee = call_expression.callee.get_inner_expression(); + if is_global_array_from_member(callee, ctx) { + return true; + } + let oxc_ast::ast::Expression::Identifier(identifier) = callee else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + matches!( + parent.kind(), + oxc_ast::AstKind::VariableDeclaration(variable_declaration) + if variable_declaration.kind.is_const() + ) && declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + && declarator + .init + .as_ref() + .is_some_and(|initializer| is_global_array_from_member(initializer, ctx)) +} + +fn is_global_array_from_member<'a>( + expression: &oxc_ast::ast::Expression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let Some(member_expression) = expression.get_inner_expression().as_member_expression() else { + return false; + }; + let oxc_ast::ast::Expression::Identifier(identifier) = + member_expression.object().get_inner_expression() + else { + return false; + }; + member_expression.static_property_name() == Some("from") + && identifier.name == "Array" + && ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none() +} + +fn expression_is_argument_at( + arguments: &[oxc_ast::ast::Argument<'_>], + argument_index: usize, + expression_span: oxc_span::Span, +) -> bool { + arguments.get(argument_index).is_some_and(|argument| { + argument + .as_expression() + .is_some_and(|expression| expression.span() == expression_span) + }) +} diff --git a/native/oxlint/rules/is-static-jsx-tree-root.rs b/native/oxlint/rules/is-static-jsx-tree-root.rs new file mode 100644 index 0000000000..291105de65 --- /dev/null +++ b/native/oxlint/rules/is-static-jsx-tree-root.rs @@ -0,0 +1,16 @@ +fn is_static_jsx_tree_root( + node: &crate::AstNode, + ctx: &crate::context::LintContext, +) -> bool { + let mut has_jsx_ancestor = false; + for ancestor in ctx.nodes().ancestors(node.id()).skip(1) { + match ancestor.kind() { + oxc_ast::AstKind::JSXExpressionContainer(_) => return false, + oxc_ast::AstKind::JSXElement(_) | oxc_ast::AstKind::JSXFragment(_) => { + has_jsx_ancestor = true; + } + _ => {} + } + } + !has_jsx_ancestor +} diff --git a/native/oxlint/rules/is-static-react-router-route-object.rs b/native/oxlint/rules/is-static-react-router-route-object.rs new file mode 100644 index 0000000000..f0024814c3 --- /dev/null +++ b/native/oxlint/rules/is-static-react-router-route-object.rs @@ -0,0 +1,57 @@ +use oxc_span::GetSpan; + +const STATIC_ROUTE_CONFIG_EXPORT_NAMES: [&str; 4] = [ + "createBrowserRouter", + "createHashRouter", + "createMemoryRouter", + "useRoutes", +]; +const STATIC_ROUTE_RUNTIME_MODULE_SOURCES: [&str; 5] = [ + "@react-router/cloudflare", + "@react-router/node", + "react-router/dom", + "react-router-dom", + "react-router", +]; + +fn is_static_react_router_route_object<'a>( + route_object: &'a oxc_ast::ast::ObjectExpression<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + let route_array_node = ctx.nodes().parent_node(route_object.node_id.get()); + let oxc_ast::AstKind::ArrayExpression(route_array) = route_array_node.kind() else { + return false; + }; + let array_parent = ctx.nodes().parent_node(route_array_node.id()); + match array_parent.kind() { + oxc_ast::AstKind::CallExpression(call_expression) => { + let oxc_ast::ast::Expression::Identifier(callee_identifier) = + call_expression.callee.get_inner_expression() + else { + return false; + }; + call_expression + .arguments + .first() + .and_then(oxc_ast::ast::Argument::as_expression) + .is_some_and(|argument| argument.span() == route_array.span) + && direct_named_import_matches( + callee_identifier, + &STATIC_ROUTE_CONFIG_EXPORT_NAMES, + &STATIC_ROUTE_RUNTIME_MODULE_SOURCES, + ctx, + ) + } + oxc_ast::AstKind::ObjectProperty(children_property) + if children_property.key.static_name().as_deref() == Some("children") => + { + let parent_route = ctx.nodes().parent_node(array_parent.id()); + matches!( + parent_route.kind(), + oxc_ast::AstKind::ObjectExpression(parent_route_object) + if is_static_react_router_route_object(parent_route_object, ctx) + ) + } + _ => false, + } +} diff --git a/native/oxlint/rules/is-statically-hidden-from-screen-reader.rs b/native/oxlint/rules/is-statically-hidden-from-screen-reader.rs new file mode 100644 index 0000000000..d5aefe91c3 --- /dev/null +++ b/native/oxlint/rules/is-statically-hidden-from-screen-reader.rs @@ -0,0 +1,92 @@ +fn is_statically_hidden_from_screen_reader<'a>( + opening_element: &'a oxc_ast::ast::JSXOpeningElement<'a>, + ctx: &crate::context::LintContext<'a>, +) -> bool { + if resolve_jsx_element_type(opening_element, ctx) + .is_some_and(|(tag_name, _)| tag_name.eq_ignore_ascii_case("input")) + { + let type_resolution = resolve_static_jsx_attribute(opening_element, "type", false); + if get_resolved_static_string(&type_resolution) + .is_some_and(|value| value.eq_ignore_ascii_case("hidden")) + { + return true; + } + } + + let hidden_resolution = resolve_static_jsx_attribute(opening_element, "hidden", false); + if hidden_resolution.is_present { + if hidden_resolution + .attribute + .is_some_and(|attribute| attribute.value.is_none()) + { + return true; + } + if let Some(static_string_value) = get_resolved_static_string(&hidden_resolution) { + return !static_string_value.is_empty(); + } + if get_resolved_expression(&hidden_resolution).is_some_and(is_truthy_static_literal) { + return true; + } + } + + let aria_hidden_resolution = + resolve_static_jsx_attribute(opening_element, "aria-hidden", false); + if !aria_hidden_resolution.is_present { + return false; + } + if get_resolved_static_string(&aria_hidden_resolution) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + return true; + } + if aria_hidden_resolution + .attribute + .is_some_and(|attribute| attribute.value.is_none()) + { + return true; + } + let Some(expression) = get_resolved_expression(&aria_hidden_resolution) else { + return false; + }; + matches!( + expression, + oxc_ast::ast::Expression::BooleanLiteral(boolean_literal) if boolean_literal.value + ) +} + +fn get_resolved_static_string<'a>( + resolution: &StaticJsxAttributeResolution<'a>, +) -> Option<&'a str> { + resolution + .attribute + .and_then(|attribute| get_string_literal_attribute_value(attribute)) + .or_else(|| { + resolution + .expression + .and_then(|expression| get_static_string_expression(expression)) + }) +} + +fn get_resolved_expression<'a>( + resolution: &StaticJsxAttributeResolution<'a>, +) -> Option<&'a oxc_ast::ast::Expression<'a>> { + if let Some(expression) = resolution.expression { + return Some(expression.get_inner_expression()); + } + let attribute = resolution.attribute?; + let oxc_ast::ast::JSXAttributeValue::ExpressionContainer(container) = + attribute.value.as_ref()? + else { + return None; + }; + Some(container.expression.as_expression()?.get_inner_expression()) +} + +fn is_truthy_static_literal(expression: &oxc_ast::ast::Expression) -> bool { + match expression { + oxc_ast::ast::Expression::BooleanLiteral(boolean_literal) => boolean_literal.value, + oxc_ast::ast::Expression::NumericLiteral(number_literal) => number_literal.value != 0.0, + oxc_ast::ast::Expression::StringLiteral(string_literal) => !string_literal.value.is_empty(), + _ => false, + } +} diff --git a/native/oxlint/rules/is-svg-layout-transition-exempt-element.rs b/native/oxlint/rules/is-svg-layout-transition-exempt-element.rs new file mode 100644 index 0000000000..061334db61 --- /dev/null +++ b/native/oxlint/rules/is-svg-layout-transition-exempt-element.rs @@ -0,0 +1,35 @@ +fn is_svg_layout_transition_exempt_element( + opening_element: &oxc_ast::ast::JSXOpeningElement, +) -> bool { + const SVG_LAYOUT_TRANSITION_EXEMPT_ELEMENT_NAMES: [&str; 24] = [ + "svg", + "g", + "rect", + "circle", + "ellipse", + "image", + "line", + "path", + "polygon", + "polyline", + "text", + "tspan", + "textPath", + "use", + "marker", + "mask", + "pattern", + "symbol", + "defs", + "clipPath", + "linearGradient", + "radialGradient", + "stop", + "filter", + ]; + matches!( + &opening_element.name, + oxc_ast::ast::JSXElementName::Identifier(identifier) + if SVG_LAYOUT_TRANSITION_EXEMPT_ELEMENT_NAMES.contains(&identifier.name.as_str()) + ) +} diff --git a/native/oxlint/rules/is-technical-label-text.rs b/native/oxlint/rules/is-technical-label-text.rs new file mode 100644 index 0000000000..a0a68befe5 --- /dev/null +++ b/native/oxlint/rules/is-technical-label-text.rs @@ -0,0 +1,46 @@ +fn is_technical_label_text(text: &str) -> bool { + if is_uppercase_technical_token(text) { + return true; + } + let terminal_segments = text + .split(" — ") + .filter(|segment| !segment.is_empty()) + .collect::>(); + terminal_segments.len() > 1 + && terminal_segments.iter().all(|segment| { + segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + || is_uppercase_technical_token(segment) + || is_delimited_technical_token(segment) + }) +} + +fn is_uppercase_technical_token(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + && bytes.all(|byte| { + byte.is_ascii_uppercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'.' | b':' | b'/' | b'-') + }) +} + +fn is_delimited_technical_token(value: &str) -> bool { + let mut has_delimiter = false; + let mut segment_has_character = false; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() { + segment_has_character = true; + continue; + } + if !matches!(byte, b'-' | b'_' | b'.' | b'/' | b':') || !segment_has_character { + return false; + } + has_delimiter = true; + segment_has_character = false; + } + has_delimiter && segment_has_character +} diff --git a/native/oxlint/rules/is-top-level-page-copy-root.rs b/native/oxlint/rules/is-top-level-page-copy-root.rs new file mode 100644 index 0000000000..ed5b0ce2ab --- /dev/null +++ b/native/oxlint/rules/is-top-level-page-copy-root.rs @@ -0,0 +1,23 @@ +fn is_top_level_page_copy_root( + element: &oxc_ast::ast::JSXElement, + node: &crate::AstNode, + ctx: &crate::context::LintContext, +) -> bool { + if !is_page_copy_root(element) { + return false; + } + !ctx.nodes().ancestors(node.id()).any(|ancestor| { + matches!( + ancestor.kind(), + oxc_ast::AstKind::JSXElement(ancestor_element) if is_page_copy_root(ancestor_element) + ) + }) +} + +fn is_page_copy_root(element: &oxc_ast::ast::JSXElement) -> bool { + matches!( + &element.opening_element.name, + oxc_ast::ast::JSXElementName::Identifier(identifier) + if identifier.name == "article" || identifier.name == "main" + ) +} diff --git a/native/oxlint/rules/is-type-only-import.rs b/native/oxlint/rules/is-type-only-import.rs new file mode 100644 index 0000000000..ab7d849bec --- /dev/null +++ b/native/oxlint/rules/is-type-only-import.rs @@ -0,0 +1,16 @@ +pub fn is_type_only_import(import_declaration: &oxc_ast::ast::ImportDeclaration<'_>) -> bool { + if import_declaration.import_kind.is_type() { + return true; + } + let Some(specifiers) = &import_declaration.specifiers else { + return false; + }; + !specifiers.is_empty() + && specifiers.iter().all(|specifier| { + matches!( + specifier, + oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) + if import_specifier.import_kind.is_type() + ) + }) +} diff --git a/native/oxlint/rules/js-async-reduce-without-awaited-acc.rs b/native/oxlint/rules/js-async-reduce-without-awaited-acc.rs new file mode 100644 index 0000000000..bd86acceef --- /dev/null +++ b/native/oxlint/rules/js-async-reduce-without-awaited-acc.rs @@ -0,0 +1,155 @@ +use oxc_ast::{ + ast::{BindingPattern, Expression, FormalParameters}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_syntax::node::NodeId; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Default, Clone)] +pub struct JsAsyncReduceWithoutAwaitedAcc; + +enum FirstParameterShape { + Identifier(String), + Destructured, +} + +declare_oxc_lint!( + /// Warns when an async reduce callback does not await its accumulator. + JsAsyncReduceWithoutAwaitedAcc, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Warns when an async reduce callback does not await its accumulator.", +); + +impl Rule for JsAsyncReduceWithoutAwaitedAcc { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(reduce_call) = node.kind() else { + return; + }; + let Some(method_name) = reduce_call + .callee + .as_member_expression() + .and_then(|member_expression| member_expression.static_property_name()) + .filter(|method_name| matches!(*method_name, "reduce" | "reduceRight")) + else { + return; + }; + let Some(reducer) = reduce_call + .arguments + .first() + .and_then(oxc_ast::ast::Argument::as_expression) + .map(Expression::get_inner_expression) + else { + return; + }; + let (parameters, reducer_node_id, reducer_span) = match reducer { + Expression::ArrowFunctionExpression(function) if function.r#async => { + (&function.params, function.node_id.get(), function.span) + } + Expression::FunctionExpression(function) if function.r#async => { + (&function.params, function.node_id.get(), function.span) + } + _ => return, + }; + let Some(first_parameter) = classify_first_parameter(parameters) else { + return; + }; + let (has_direct_await, awaited_accumulator_names) = + collect_direct_awaits(reducer_node_id, ctx); + if !has_direct_await { + return; + } + + match first_parameter { + FirstParameterShape::Destructured => { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "This async `.{method_name}` reducer destructures its accumulator, but every run after the first gets a Promise, so the pieces come out empty & the work is lost. Await it first: `async (previous, item) => {{ const [...] = await previous; ...; return [...]; }}`, & seed with `Promise.resolve([...])`" + )) + .with_label(reducer_span), + ); + } + FirstParameterShape::Identifier(accumulator_name) => { + if awaited_accumulator_names + .iter() + .any(|awaited_name| awaited_name == &accumulator_name) + { + return; + } + let previous_parameter_name = ["previous", "prev", "priorResult"] + .into_iter() + .find(|candidate| *candidate != accumulator_name) + .unwrap_or("priorResult"); + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "This async `.{method_name}` reducer never awaits its accumulator \"{accumulator_name}\", so each run gets a Promise instead of the real value & the work is lost. Reassign it at the top (`{accumulator_name} = await {accumulator_name};`), or rewrite as `async ({previous_parameter_name}, item) => {{ const {accumulator_name} = await {previous_parameter_name}; ...; return {accumulator_name}; }}`, & seed with `Promise.resolve(...)`" + )) + .with_label(reducer_span), + ); + } + } + } +} + +fn classify_first_parameter(parameters: &FormalParameters<'_>) -> Option { + let pattern = ¶meters.items.first()?.pattern; + match pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(FirstParameterShape::Identifier(identifier.name.to_string())) + } + BindingPattern::ArrayPattern(_) | BindingPattern::ObjectPattern(_) => { + Some(FirstParameterShape::Destructured) + } + BindingPattern::AssignmentPattern(assignment) => match &assignment.left { + BindingPattern::BindingIdentifier(identifier) => { + Some(FirstParameterShape::Identifier(identifier.name.to_string())) + } + BindingPattern::ArrayPattern(_) | BindingPattern::ObjectPattern(_) => { + Some(FirstParameterShape::Destructured) + } + BindingPattern::AssignmentPattern(_) => None, + }, + } +} + +fn collect_direct_awaits(reducer_node_id: NodeId, ctx: &LintContext<'_>) -> (bool, Vec) { + let mut has_direct_await = false; + let mut awaited_accumulator_names = Vec::new(); + for candidate in ctx.nodes().iter() { + let AstKind::AwaitExpression(await_expression) = candidate.kind() else { + continue; + }; + if !is_in_same_function_scope(candidate, reducer_node_id, ctx) { + continue; + } + has_direct_await = true; + if let Expression::Identifier(identifier) = await_expression.argument.get_inner_expression() + { + awaited_accumulator_names.push(identifier.name.to_string()); + } + } + (has_direct_await, awaited_accumulator_names) +} + +fn is_in_same_function_scope( + candidate: &AstNode<'_>, + reducer_node_id: NodeId, + ctx: &LintContext<'_>, +) -> bool { + for ancestor in ctx.nodes().ancestors(candidate.id()) { + if ancestor.id() == reducer_node_id { + return true; + } + if matches!( + ancestor.kind(), + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) { + return false; + } + } + false +} diff --git a/native/oxlint/rules/js-cache-property-access.rs b/native/oxlint/rules/js-cache-property-access.rs new file mode 100644 index 0000000000..ce35a6cdb0 --- /dev/null +++ b/native/oxlint/rules/js-cache-property-access.rs @@ -0,0 +1,255 @@ +use oxc_ast::{ + AstKind, + ast::{Expression, MemberExpression, SimpleAssignmentTarget, Statement}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::node::NodeId; +use rustc_hash::FxHashSet; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const PROPERTY_ACCESS_REPEAT_THRESHOLD: usize = 3; + +#[derive(Debug, Default, Clone)] +pub struct JsCachePropertyAccess; + +struct PropertyRead { + key: String, + count: usize, + first_span: Span, +} + +declare_oxc_lint!( + /// Disallow repeated deep property reads inside one loop. + JsCachePropertyAccess, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow repeated deep property reads inside one loop.", +); + +impl Rule for JsCachePropertyAccess { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let Some(loop_body) = loop_body(node.kind()) else { + return; + }; + let loop_body_span = loop_body.span(); + let mut property_reads = Vec::::new(); + let mut written_access_prefixes = FxHashSet::::default(); + let mut called_receiver_prefixes = FxHashSet::::default(); + + for candidate in ctx.nodes().iter() { + if !loop_body_span.contains_inclusive(candidate.span()) + || !is_in_same_loop_function_scope(candidate, node.id(), ctx) + { + continue; + } + match candidate.kind() { + AstKind::AssignmentExpression(assignment_expression) => { + if let Some(write_target) = assignment_expression + .left + .as_simple_assignment_target() + .and_then(build_assignment_target_key) + { + written_access_prefixes.insert(write_target); + } + } + AstKind::UpdateExpression(update_expression) => { + if let Some(write_target) = + build_assignment_target_key(&update_expression.argument) + { + written_access_prefixes.insert(write_target); + } + } + AstKind::CallExpression(call_expression) => { + let Some(receiver_key) = call_expression + .callee + .as_member_expression() + .and_then(|member_expression| { + build_expression_access_key(member_expression.object()) + }) + else { + continue; + }; + if receiver_key.contains('.') { + called_receiver_prefixes.insert(receiver_key); + } + } + AstKind::StaticMemberExpression(member_expression) => { + let candidate_span = member_expression.span; + if is_nested_member_object(candidate_span, candidate, ctx) + || is_call_callee(candidate_span, candidate, ctx) + || is_write_target(candidate_span, candidate, ctx) + { + continue; + } + let Some(key) = build_static_member_access_key(member_expression) else { + continue; + }; + if key.split('.').count() < 3 || key.ends_with(".length") { + continue; + } + if let Some(existing_read) = property_reads + .iter_mut() + .find(|property_read| property_read.key == key) + { + existing_read.count += 1; + } else { + property_reads.push(PropertyRead { + key, + count: 1, + first_span: candidate_span, + }); + } + } + _ => {} + } + } + + for property_read in property_reads { + if property_read.count < PROPERTY_ACCESS_REPEAT_THRESHOLD + || extends_unstable_prefix( + &property_read.key, + &written_access_prefixes, + &called_receiver_prefixes, + ) + { + continue; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "This may slow the loop because {} is read {} times inside it; if the value stays unchanged between those reads, cache it immediately before the first read", + property_read.key, property_read.count + )) + .with_label(property_read.first_span), + ); + } + } +} + +fn loop_body<'a>(kind: AstKind<'a>) -> Option<&'a Statement<'a>> { + match kind { + AstKind::ForStatement(statement) => Some(&statement.body), + AstKind::ForInStatement(statement) => Some(&statement.body), + AstKind::ForOfStatement(statement) => Some(&statement.body), + AstKind::WhileStatement(statement) => Some(&statement.body), + AstKind::DoWhileStatement(statement) => Some(&statement.body), + _ => None, + } +} + +fn is_in_same_loop_function_scope( + candidate: &AstNode<'_>, + loop_node_id: NodeId, + ctx: &LintContext<'_>, +) -> bool { + for ancestor in ctx.nodes().ancestors(candidate.id()) { + if ancestor.id() == loop_node_id { + return true; + } + if matches!( + ancestor.kind(), + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) { + return false; + } + } + false +} + +fn build_assignment_target_key(target: &SimpleAssignmentTarget<'_>) -> Option { + if let Some(member_expression) = target.as_member_expression() { + return build_member_access_key(member_expression); + } + if let Some(identifier_name) = target.get_identifier_name() { + return Some(identifier_name.to_string()); + } + None +} + +fn build_member_access_key(member_expression: &MemberExpression<'_>) -> Option { + let MemberExpression::StaticMemberExpression(member_expression) = member_expression else { + return None; + }; + build_static_member_access_key(member_expression) +} + +fn build_static_member_access_key( + member_expression: &oxc_ast::ast::StaticMemberExpression<'_>, +) -> Option { + let object_key = build_expression_access_key(&member_expression.object)?; + Some(format!("{object_key}.{}", member_expression.property.name)) +} + +fn build_expression_access_key(expression: &Expression<'_>) -> Option { + match expression { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + Expression::ThisExpression(_) => Some("this".to_string()), + Expression::StaticMemberExpression(member_expression) => { + build_static_member_access_key(member_expression) + } + _ => None, + } +} + +fn is_nested_member_object(span: Span, node: &AstNode<'_>, ctx: &LintContext<'_>) -> bool { + match ctx.nodes().parent_node(node.id()).kind() { + AstKind::StaticMemberExpression(member_expression) => { + member_expression.object.span() == span + } + AstKind::ComputedMemberExpression(member_expression) => { + member_expression.object.span() == span + } + AstKind::PrivateFieldExpression(member_expression) => { + member_expression.object.span() == span + } + _ => false, + } +} + +fn is_call_callee(span: Span, node: &AstNode<'_>, ctx: &LintContext<'_>) -> bool { + matches!( + ctx.nodes().parent_node(node.id()).kind(), + AstKind::CallExpression(call_expression) if call_expression.callee.span() == span + ) +} + +fn is_write_target(span: Span, node: &AstNode<'_>, ctx: &LintContext<'_>) -> bool { + match ctx.nodes().parent_node(node.id()).kind() { + AstKind::AssignmentExpression(assignment_expression) => { + assignment_expression.left.span() == span + } + AstKind::UpdateExpression(update_expression) => update_expression.argument.span() == span, + _ => false, + } +} + +fn extends_unstable_prefix( + key: &str, + written_access_prefixes: &FxHashSet, + called_receiver_prefixes: &FxHashSet, +) -> bool { + let mut access_prefix = String::new(); + for segment in key.split('.') { + if !access_prefix.is_empty() { + access_prefix.push('.'); + } + access_prefix.push_str(segment); + if written_access_prefixes.contains(&access_prefix) + || called_receiver_prefixes.contains(&access_prefix) + { + return true; + } + } + false +} diff --git a/native/oxlint/rules/js-cache-storage.rs b/native/oxlint/rules/js-cache-storage.rs new file mode 100644 index 0000000000..1944d17854 --- /dev/null +++ b/native/oxlint/rules/js-cache-storage.rs @@ -0,0 +1,124 @@ +use oxc_ast::{AstKind, ast::Expression}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; +use oxc_syntax::node::NodeId; +use rustc_hash::FxHashMap; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const DUPLICATE_STORAGE_READ_THRESHOLD: usize = 2; +const STORAGE_OBJECT_NAMES: [&str; 2] = ["localStorage", "sessionStorage"]; +const ARRAY_ITERATION_CALLBACK_METHOD_NAMES: [&str; 6] = + ["forEach", "map", "filter", "reduce", "some", "every"]; + +#[derive(Debug, Default, Clone)] +pub struct JsCacheStorage; + +declare_oxc_lint!( + /// Disallow repeated reads from web storage within one function. + JsCacheStorage, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow repeated reads from web storage within one function.", +); + +impl Rule for JsCacheStorage { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run_once(&self, ctx: &LintContext<'_>) { + let mut storage_read_counts = FxHashMap::<(Option, String), usize>::default(); + for node in ctx.nodes().iter() { + let AstKind::CallExpression(call_expression) = node.kind() else { + continue; + }; + let Some(member_expression) = call_expression.callee.as_member_expression() else { + continue; + }; + if member_expression_identifier_property_name(member_expression) != Some("getItem") { + continue; + } + let Expression::Identifier(receiver) = + member_expression.object().get_inner_expression() + else { + continue; + }; + if !STORAGE_OBJECT_NAMES.contains(&receiver.name.as_str()) { + continue; + } + let Some(storage_key) = call_expression + .arguments + .first() + .and_then(oxc_ast::ast::Argument::as_expression) + .and_then(static_storage_key) + else { + continue; + }; + let function_owner_id = effective_function_owner_id(node, ctx); + let read_count = storage_read_counts + .entry((function_owner_id, storage_key.clone())) + .or_default(); + *read_count += 1; + if *read_count != DUPLICATE_STORAGE_READ_THRESHOLD { + continue; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "This is slow because {}.getItem(\"{storage_key}\") runs several times & re-parses the data each call, so read it once & reuse the value", + receiver.name + )) + .with_label(call_expression.span), + ); + } + } +} + +fn static_storage_key(expression: &Expression<'_>) -> Option { + match expression.get_inner_expression() { + Expression::StringLiteral(string_literal) => Some(string_literal.value.to_string()), + Expression::NumericLiteral(number_literal) => Some(number_literal.value.to_string()), + Expression::BooleanLiteral(boolean_literal) => Some(boolean_literal.value.to_string()), + Expression::NullLiteral(_) => Some("null".to_string()), + _ => None, + } +} + +fn effective_function_owner_id(node: &AstNode<'_>, ctx: &LintContext<'_>) -> Option { + ctx.nodes() + .ancestors(node.id()) + .filter(|ancestor| { + matches!( + ancestor.kind(), + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) + }) + .find(|function_node| !is_inline_iteration_callback(function_node, ctx)) + .map(AstNode::id) +} + +fn is_inline_iteration_callback<'a>(function_node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool { + let callback_root = transparent_expression_root(function_node, ctx); + let parent = ctx.nodes().parent_node(callback_root.id()); + let AstKind::CallExpression(call_expression) = parent.kind() else { + return false; + }; + if !call_expression.arguments.first().is_some_and(|argument| { + argument + .as_expression() + .is_some_and(|expression| expression.span() == callback_root.span()) + }) { + return false; + } + call_expression + .callee + .as_member_expression() + .and_then(member_expression_identifier_property_name) + .is_some_and(|method_name| ARRAY_ITERATION_CALLBACK_METHOD_NAMES.contains(&method_name)) +} diff --git a/native/oxlint/rules/js-early-exit.rs b/native/oxlint/rules/js-early-exit.rs new file mode 100644 index 0000000000..a8170ebf05 --- /dev/null +++ b/native/oxlint/rules/js-early-exit.rs @@ -0,0 +1,64 @@ +use oxc_ast::{ast::Statement, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const DEEP_NESTING_THRESHOLD: usize = 3; + +#[derive(Debug, Default, Clone)] +pub struct JsEarlyExit; + +declare_oxc_lint!( + /// Prefer early exits over deeply nested single-branch conditions. + JsEarlyExit, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer early exits over deeply nested single-branch conditions.", +); + +impl Rule for JsEarlyExit { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::IfStatement(if_statement) = node.kind() else { + return; + }; + if if_statement.alternate.is_some() { + return; + } + let mut nested_if_count = 0; + let mut current_statement = &if_statement.consequent; + loop { + let Statement::BlockStatement(block_statement) = current_statement else { + break; + }; + let [Statement::IfStatement(inner_if_statement)] = block_statement.body.as_slice() + else { + break; + }; + if inner_if_statement.alternate.is_some() { + break; + } + nested_if_count += 1; + current_statement = &inner_if_statement.consequent; + } + if nested_if_count < DEEP_NESTING_THRESHOLD { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "This is hard to follow because there are {} levels of nested if statements, so return early to keep it flat", + nested_if_count + 1 + )) + .with_label(if_statement.span), + ); + } +} diff --git a/native/oxlint/rules/js-flatmap-filter.rs b/native/oxlint/rules/js-flatmap-filter.rs new file mode 100644 index 0000000000..26b1222a5d --- /dev/null +++ b/native/oxlint/rules/js-flatmap-filter.rs @@ -0,0 +1,109 @@ +use oxc_ast::{ + ast::{ArrayExpressionElement, BindingPattern, Expression}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const SMALL_LITERAL_ARRAY_MAX_ELEMENTS: usize = 8; +const MESSAGE: &str = "This loops over your list twice because .map().filter(Boolean) makes two passes, so use .flatMap() to change & drop items in one pass"; + +#[derive(Debug, Default, Clone)] +pub struct JsFlatmapFilter; + +declare_oxc_lint!( + /// Prefer flatMap over map followed by filter(Boolean). + JsFlatmapFilter, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer flatMap over map followed by filter(Boolean).", +); + +impl Rule for JsFlatmapFilter { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(filter_call) = node.kind() else { + return; + }; + let Some(filter_member) = filter_call.callee.as_member_expression() else { + return; + }; + if member_expression_identifier_property_name(filter_member) != Some("filter") + || !filter_call + .arguments + .first() + .and_then(oxc_ast::ast::Argument::as_expression) + .is_some_and(is_boolean_filter_callback) + { + return; + } + let Expression::CallExpression(map_call) = filter_member.object().get_inner_expression() + else { + return; + }; + let Some(map_member) = map_call.callee.as_member_expression() else { + return; + }; + if member_expression_identifier_property_name(map_member) != Some("map") { + return; + } + let receiver = map_member.object().get_inner_expression(); + if is_bounded_pipeline_source(receiver) || is_small_literal_array(receiver) { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(filter_call.span)); + } +} + +fn is_boolean_filter_callback(expression: &Expression<'_>) -> bool { + match strip_parenthesized_expression(expression) { + Expression::Identifier(identifier) => identifier.name == "Boolean", + Expression::ArrowFunctionExpression(arrow_function) => { + let [parameter] = arrow_function.params.items.as_slice() else { + return false; + }; + let BindingPattern::BindingIdentifier(parameter_identifier) = ¶meter.pattern else { + return false; + }; + matches!( + arrow_function.get_expression(), + Some(Expression::Identifier(body_identifier)) + if body_identifier.name == parameter_identifier.name + ) + } + _ => false, + } +} + +fn is_bounded_pipeline_source(expression: &Expression<'_>) -> bool { + let Expression::CallExpression(call_expression) = expression else { + return false; + }; + call_expression + .callee + .as_member_expression() + .and_then(member_expression_identifier_property_name) + .is_some_and(|property_name| matches!(property_name, "slice" | "split")) +} + +fn is_small_literal_array(expression: &Expression<'_>) -> bool { + let Expression::ArrayExpression(array_expression) = expression else { + return false; + }; + !array_expression.elements.is_empty() + && array_expression.elements.len() <= SMALL_LITERAL_ARRAY_MAX_ELEMENTS + && array_expression + .elements + .iter() + .all(|element| !matches!(element, ArrayExpressionElement::SpreadElement(_))) +} diff --git a/native/oxlint/rules/js-tosorted-immutable.rs b/native/oxlint/rules/js-tosorted-immutable.rs new file mode 100644 index 0000000000..3df38f3bea --- /dev/null +++ b/native/oxlint/rules/js-tosorted-immutable.rs @@ -0,0 +1,252 @@ +use oxc_ast::{ + AstKind, + ast::{ArrayExpressionElement, Expression, MemberExpression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_syntax::node::NodeId; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const MESSAGE: &str = "This wastes work because [...array].sort() copies the array just to sort it, so use array.toSorted() to sort without the extra copy (ES2023)"; +const FRESH_ARRAY_PRODUCING_METHOD_NAMES: [&str; 9] = [ + "values", "keys", "entries", "map", "filter", "flatMap", "slice", "concat", "from", +]; +const ITERATOR_PRODUCING_METHOD_NAMES: [&str; 3] = ["values", "keys", "entries"]; + +#[derive(Debug, Default, Clone)] +pub struct JsTosortedImmutable; + +declare_oxc_lint!( + /// Prefer toSorted over copying an array before sort. + JsTosortedImmutable, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer toSorted over copying an array before sort.", +); + +impl Rule for JsTosortedImmutable { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(sort_member) = call_expression.callee.as_member_expression() else { + return; + }; + if member_expression_identifier_property_name(sort_member) != Some("sort") { + return; + } + let Expression::ArrayExpression(array_expression) = sort_member.object().get_inner_expression() + else { + return; + }; + let [ArrayExpressionElement::SpreadElement(spread_element)] = + array_expression.elements.as_slice() + else { + return; + }; + let spread_argument = spread_element.argument.get_inner_expression(); + if is_fresh_or_iterator_allocation(spread_argument) + || is_spread_of_non_array_iterable_binding(spread_argument, ctx) + || has_size_read_on_same_expression(spread_argument, node, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } +} + +fn is_fresh_or_iterator_allocation(expression: &Expression<'_>) -> bool { + match expression { + Expression::ArrayExpression(_) | Expression::NewExpression(_) => true, + Expression::CallExpression(call_expression) => call_expression + .callee + .as_member_expression() + .and_then(member_expression_identifier_property_name) + .is_some_and(|method_name| FRESH_ARRAY_PRODUCING_METHOD_NAMES.contains(&method_name)), + _ => false, + } +} + +fn is_spread_of_non_array_iterable_binding<'a>( + expression: &Expression<'a>, + ctx: &LintContext<'a>, +) -> bool { + let Expression::Identifier(identifier) = expression else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + let declaration = ctx.symbol_declaration(symbol_id); + let AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + declarator + .init + .as_ref() + .map(Expression::get_inner_expression) + .is_some_and(is_non_array_iterable_allocation) +} + +fn is_non_array_iterable_allocation(expression: &Expression<'_>) -> bool { + match expression { + Expression::NewExpression(new_expression) => !matches!( + new_expression.callee.get_inner_expression(), + Expression::Identifier(identifier) if identifier.name == "Array" + ), + Expression::CallExpression(call_expression) => call_expression + .callee + .as_member_expression() + .and_then(member_expression_identifier_property_name) + .is_some_and(|method_name| ITERATOR_PRODUCING_METHOD_NAMES.contains(&method_name)), + _ => false, + } +} + +fn has_size_read_on_same_expression<'a>( + spread_argument: &Expression<'a>, + node: &AstNode<'a>, + ctx: &LintContext<'a>, +) -> bool { + let scope_owner_id = ctx + .nodes() + .ancestors(node.id()) + .find(|ancestor| { + matches!( + ancestor.kind(), + AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) + ) + }) + .map(AstNode::id); + ctx.nodes().iter().any(|candidate| { + is_node_within_scope(candidate, scope_owner_id, ctx) + && member_object_with_identifier_property(candidate.kind(), "size") + .is_some_and(|object| { + are_simple_expressions_structurally_equal(object, spread_argument) + }) + }) +} + +fn is_node_within_scope( + node: &AstNode<'_>, + scope_owner_id: Option, + ctx: &LintContext<'_>, +) -> bool { + scope_owner_id.is_none_or(|owner_id| { + node.id() == owner_id + || ctx + .nodes() + .ancestors(node.id()) + .any(|ancestor| ancestor.id() == owner_id) + }) +} + +fn member_object_with_identifier_property<'a>( + kind: AstKind<'a>, + expected_property_name: &str, +) -> Option<&'a Expression<'a>> { + match kind { + AstKind::StaticMemberExpression(member_expression) + if member_expression.property.name == expected_property_name => + { + Some(&member_expression.object) + } + AstKind::ComputedMemberExpression(member_expression) + if matches!( + &member_expression.expression, + Expression::Identifier(identifier) if identifier.name == expected_property_name + ) => + { + Some(&member_expression.object) + } + _ => None, + } +} + +fn are_simple_expressions_structurally_equal( + first: &Expression<'_>, + second: &Expression<'_>, +) -> bool { + let first = first.get_inner_expression(); + let second = second.get_inner_expression(); + match (first, second) { + (Expression::ThisExpression(_), Expression::ThisExpression(_)) + | (Expression::NullLiteral(_), Expression::NullLiteral(_)) => true, + (Expression::Identifier(first), Expression::Identifier(second)) => { + first.name == second.name + } + (Expression::StringLiteral(first), Expression::StringLiteral(second)) => { + first.value == second.value + } + (Expression::BooleanLiteral(first), Expression::BooleanLiteral(second)) => { + first.value == second.value + } + (Expression::NumericLiteral(first), Expression::NumericLiteral(second)) => { + first.value == second.value + } + (Expression::CallExpression(first), Expression::CallExpression(second)) => { + are_simple_expressions_structurally_equal(&first.callee, &second.callee) + && first.arguments.len() == second.arguments.len() + && first.arguments.iter().zip(&second.arguments).all( + |(first_argument, second_argument)| { + let (Some(first_argument), Some(second_argument)) = ( + first_argument.as_expression(), + second_argument.as_expression(), + ) else { + return false; + }; + are_simple_expressions_structurally_equal( + first_argument, + second_argument, + ) + }, + ) + } + _ => match (first.as_member_expression(), second.as_member_expression()) { + (Some(first_member), Some(second_member)) => { + are_member_expressions_structurally_equal(first_member, second_member) + } + _ => false, + }, + } +} + +fn are_member_expressions_structurally_equal( + first: &MemberExpression<'_>, + second: &MemberExpression<'_>, +) -> bool { + match (first, second) { + ( + MemberExpression::StaticMemberExpression(first), + MemberExpression::StaticMemberExpression(second), + ) => { + first.property.name == second.property.name + && are_simple_expressions_structurally_equal(&first.object, &second.object) + } + ( + MemberExpression::ComputedMemberExpression(first), + MemberExpression::ComputedMemberExpression(second), + ) => { + are_simple_expressions_structurally_equal(&first.object, &second.object) + && are_simple_expressions_structurally_equal( + &first.expression, + &second.expression, + ) + } + _ => false, + } +} diff --git a/native/oxlint/rules/jsx-boolean-value.rs b/native/oxlint/rules/jsx-boolean-value.rs new file mode 100644 index 0000000000..d35488ebca --- /dev/null +++ b/native/oxlint/rules/jsx-boolean-value.rs @@ -0,0 +1,101 @@ +use oxc_ast::{ + AstKind, + ast::{Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const STYLE_MESSAGE: &str = "This boolean prop style disagrees with the project setting, so equivalent true props are harder to scan consistently."; + +#[derive(Debug, Default, Clone)] +pub struct JsxBooleanValue; + +declare_oxc_lint!( + /// Enforce consistent boolean prop notation. + JsxBooleanValue, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Enforce consistent boolean prop notation.", +); + +impl Rule for JsxBooleanValue { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let settings = ctx + .settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("jsxBooleanValue")); + let mode = settings + .and_then(|settings| settings.get("mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or("never"); + let always = settings + .and_then(|settings| settings.get("always")) + .and_then(serde_json::Value::as_array); + let never = settings + .and_then(|settings| settings.get("never")) + .and_then(serde_json::Value::as_array); + let assume_undefined_is_false = settings + .and_then(|settings| settings.get("assumeUndefinedIsFalse")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + continue; + }; + let is_always_exception = setting_contains(always, attribute_name.name.as_str()); + let is_never_exception = setting_contains(never, attribute_name.name.as_str()); + if mode == "never" && attribute.value.is_none() && is_always_exception { + ctx.diagnostic(OxcDiagnostic::warn(STYLE_MESSAGE).with_label(attribute.span)); + continue; + } + if mode == "always" && attribute.value.is_none() && !is_never_exception { + ctx.diagnostic(OxcDiagnostic::warn(STYLE_MESSAGE).with_label(attribute.span)); + continue; + } + let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value else { + continue; + }; + let Some(Expression::BooleanLiteral(boolean_literal)) = + container.expression.as_expression() + else { + continue; + }; + if mode == "never" + && !is_always_exception + && (boolean_literal.value || assume_undefined_is_false) + { + let message = if boolean_literal.value { + STYLE_MESSAGE.to_string() + } else { + format!( + "`{}={{false}}` does nothing, so the explicit false value adds noise without changing output.", + attribute_name.name + ) + }; + ctx.diagnostic(OxcDiagnostic::warn(message).with_label(attribute.span)); + } else if mode == "always" && is_never_exception { + ctx.diagnostic(OxcDiagnostic::warn(STYLE_MESSAGE).with_label(attribute.span)); + } + } + } +} + +fn setting_contains(settings: Option<&Vec>, attribute_name: &str) -> bool { + settings.is_some_and(|settings| { + settings + .iter() + .any(|setting| setting.as_str() == Some(attribute_name)) + }) +} diff --git a/native/oxlint/rules/jsx-fragments.rs b/native/oxlint/rules/jsx-fragments.rs new file mode 100644 index 0000000000..304904370a --- /dev/null +++ b/native/oxlint/rules/jsx-fragments.rs @@ -0,0 +1,113 @@ +use oxc_ast::{ + AstKind, + ast::{JSXElementName, JSXMemberExpressionObject}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + module_record::ImportImportName, + rule::Rule, +}; + +const SYNTAX_MESSAGE: &str = "`` is used where shorthand fragments are configured, so similar wrappers look different across the codebase."; +const ELEMENT_MESSAGE: &str = "Fragment shorthand is used where explicit fragments are configured, so similar wrappers look different across the codebase."; + +#[derive(Debug, Default, Clone)] +pub struct JsxFragments; + +declare_oxc_lint!( + /// Enforce the configured React fragment form. + JsxFragments, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Enforce the configured React fragment form.", +); + +impl Rule for JsxFragments { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXElement(element) if fragment_mode(ctx) == "syntax" => { + if element.closing_element.is_none() + || !element.opening_element.attributes.is_empty() + || !is_react_fragment(&element.opening_element.name, ctx) + { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(SYNTAX_MESSAGE).with_label(element.opening_element.span), + ); + } + AstKind::JSXFragment(fragment) if fragment_mode(ctx) == "element" => { + ctx.diagnostic( + OxcDiagnostic::warn(ELEMENT_MESSAGE) + .with_label(fragment.opening_fragment.span()), + ); + } + _ => {} + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() + } +} + +fn fragment_mode<'a>(ctx: &'a LintContext<'_>) -> &'a str { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("jsxFragments")) + .and_then(|settings| settings.get("mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or("syntax") +} + +fn is_react_fragment<'a>(name: &JSXElementName<'a>, ctx: &LintContext<'a>) -> bool { + match name { + JSXElementName::IdentifierReference(identifier) => { + let Some(import_entry) = resolve_identifier_import(identifier, ctx) else { + return identifier.name == "Fragment" + && ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none(); + }; + import_entry.module_request.name() == "react" + && matches!( + &import_entry.import_name, + ImportImportName::Name(imported_name) if imported_name.name() == "Fragment" + ) + } + JSXElementName::MemberExpression(member_expression) => { + if member_expression.property.name != "Fragment" { + return false; + } + let JSXMemberExpressionObject::IdentifierReference(identifier) = + &member_expression.object + else { + return false; + }; + let Some(import_entry) = resolve_identifier_import(identifier, ctx) else { + return identifier.name == "React" + && ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none(); + }; + import_entry.module_request.name() == "react" + && match &import_entry.import_name { + ImportImportName::Default(_) | ImportImportName::NamespaceObject => true, + ImportImportName::Name(imported_name) => imported_name.name() == "default", + } + } + _ => false, + } +} diff --git a/native/oxlint/rules/jsx-max-depth.rs b/native/oxlint/rules/jsx-max-depth.rs new file mode 100644 index 0000000000..4469bc5c37 --- /dev/null +++ b/native/oxlint/rules/jsx-max-depth.rs @@ -0,0 +1,242 @@ +use oxc_ast::{ + ast::{Expression, JSXChild}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_semantic::SymbolId; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::node::NodeId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const CURATED_MAX_DEPTH: f64 = 14.0; +const UPSTREAM_MAX_DEPTH: f64 = 2.0; + +#[derive(Debug, Default, Clone)] +pub struct JsxMaxDepth; + +#[derive(Clone, Copy)] +struct DeepLeafCandidate { + span: Span, + depth: u32, +} + +declare_oxc_lint!( + /// Limit JSX nesting depth. + JsxMaxDepth, + react_doctor_native, + style, + version = "0.1.0", + short_description = "Limit JSX nesting depth.", +); + +impl Rule for JsxMaxDepth { + fn run_once(&self, ctx: &LintContext<'_>) { + let max_depth = configured_max_depth(ctx); + let mut deepest_leaf_per_tree = FxHashMap::::default(); + for node in ctx.nodes().iter() { + if !matches!( + node.kind(), + AstKind::JSXElement(_) | AstKind::JSXFragment(_) + ) || !is_leaf_jsx_node(node) + { + continue; + } + let ancestor_depth = jsx_ancestor_depth(node, ctx); + let child_depth = calculate_node_jsx_depth(node, ctx, &mut FxHashSet::default()); + let total_depth = ancestor_depth + child_depth; + if f64::from(total_depth) <= max_depth { + continue; + } + let tree_root = outermost_jsx_ancestor_id(node, ctx); + let candidate = DeepLeafCandidate { + span: node.span(), + depth: total_depth, + }; + deepest_leaf_per_tree + .entry(tree_root) + .and_modify(|existing| { + if candidate.depth > existing.depth { + *existing = candidate; + } + }) + .or_insert(candidate); + } + for candidate in deepest_leaf_per_tree.values() { + let message = format!( + "This JSX is hard to read at {} levels deep, past the limit of {max_depth}.", + candidate.depth + ); + ctx.diagnostic(OxcDiagnostic::warn(message).with_label(candidate.span)); + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() && !is_non_production_file(ctx) + } +} + +fn configured_max_depth(ctx: &LintContext) -> f64 { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("jsxMaxDepth")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("max")) + .and_then(serde_json::Value::as_f64) + .unwrap_or_else(|| { + if uses_curated_behavior(ctx) { + CURATED_MAX_DEPTH + } else { + UPSTREAM_MAX_DEPTH + } + }) +} + +fn uses_curated_behavior(ctx: &LintContext) -> bool { + ctx.settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(serde_json::Value::as_object) + .and_then(|settings| settings.get("portedRuleMode")) + .and_then(serde_json::Value::as_str) + == Some("curated") +} + +fn calculate_variable_jsx_depth( + symbol_id: SymbolId, + ctx: &LintContext<'_>, + visited_symbol_ids: &mut FxHashSet, +) -> u32 { + if !visited_symbol_ids.insert(symbol_id) { + return 0; + } + let declaration = ctx.symbol_declaration(symbol_id); + let AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return 0; + }; + declarator.init.as_ref().map_or(0, |initializer| { + calculate_expression_jsx_depth(initializer, ctx, visited_symbol_ids) + }) +} + +fn calculate_expression_jsx_depth( + expression: &Expression<'_>, + ctx: &LintContext<'_>, + visited_symbol_ids: &mut FxHashSet, +) -> u32 { + match expression.get_inner_expression() { + Expression::JSXElement(element) => { + calculate_jsx_children_depth(&element.children, ctx, visited_symbol_ids) + } + Expression::JSXFragment(fragment) => { + calculate_jsx_children_depth(&fragment.children, ctx, visited_symbol_ids) + } + Expression::Identifier(identifier) => ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .map_or(0, |symbol_id| { + calculate_variable_jsx_depth(symbol_id, ctx, visited_symbol_ids) + }), + _ => 0, + } +} + +fn calculate_node_jsx_depth( + node: &AstNode<'_>, + ctx: &LintContext<'_>, + visited_symbol_ids: &mut FxHashSet, +) -> u32 { + match node.kind() { + AstKind::JSXElement(element) => { + calculate_jsx_children_depth(&element.children, ctx, visited_symbol_ids) + } + AstKind::JSXFragment(fragment) => { + calculate_jsx_children_depth(&fragment.children, ctx, visited_symbol_ids) + } + _ => 0, + } +} + +fn calculate_jsx_children_depth( + children: &[JSXChild<'_>], + ctx: &LintContext<'_>, + visited_symbol_ids: &mut FxHashSet, +) -> u32 { + let mut maximum_depth = 0; + for child in children { + let depth = match child { + JSXChild::Element(element) => { + calculate_jsx_children_depth(&element.children, ctx, visited_symbol_ids) + 1 + } + JSXChild::Fragment(fragment) => { + calculate_jsx_children_depth(&fragment.children, ctx, visited_symbol_ids) + 1 + } + JSXChild::ExpressionContainer(container) => container + .expression + .as_expression() + .map_or(0, |expression| { + let resolved_depth = + calculate_expression_jsx_depth(expression, ctx, visited_symbol_ids); + if resolved_depth > 0 { + resolved_depth + 1 + } else { + 0 + } + }), + _ => 0, + }; + maximum_depth = maximum_depth.max(depth); + } + maximum_depth +} + +fn is_leaf_jsx_node(node: &AstNode<'_>) -> bool { + let children = match node.kind() { + AstKind::JSXElement(element) => &element.children, + AstKind::JSXFragment(fragment) => &fragment.children, + _ => return true, + }; + !children + .iter() + .any(|child| matches!(child, JSXChild::Element(_) | JSXChild::Fragment(_))) +} + +fn jsx_ancestor_depth(node: &AstNode<'_>, ctx: &LintContext<'_>) -> u32 { + u32::try_from( + ctx.nodes() + .ancestors(node.id()) + .filter(|ancestor| { + matches!( + ancestor.kind(), + AstKind::JSXElement(_) | AstKind::JSXFragment(_) + ) + }) + .count(), + ) + .unwrap_or(u32::MAX) +} + +fn outermost_jsx_ancestor_id(node: &AstNode<'_>, ctx: &LintContext<'_>) -> NodeId { + ctx.nodes() + .ancestors(node.id()) + .filter(|ancestor| { + matches!( + ancestor.kind(), + AstKind::JSXElement(_) | AstKind::JSXFragment(_) + ) + }) + .map(AstNode::id) + .last() + .unwrap_or_else(|| node.id()) +} diff --git a/native/oxlint/rules/jsx-no-comment-textnodes.rs b/native/oxlint/rules/jsx-no-comment-textnodes.rs new file mode 100644 index 0000000000..6a81746a0c --- /dev/null +++ b/native/oxlint/rules/jsx-no-comment-textnodes.rs @@ -0,0 +1,137 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeName, JSXChild, JSXElementName, JSXExpression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +const MESSAGE: &str = + "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX."; +const LITERAL_TEXT_TAGS: [&str; 5] = ["code", "pre", "kbd", "samp", "tt"]; +const STYLING_ATTRIBUTE_NAMES: [&str; 3] = ["className", "class", "style"]; + +#[derive(Debug, Default, Clone)] +pub struct JsxNoCommentTextnodes; + +declare_oxc_lint!( + /// Disallow accidental comment text rendered as JSX children. + JsxNoCommentTextnodes, + react_doctor_native, + suspicious, + version = "0.1.0", + short_description = "Disallow accidental comment text rendered as JSX children.", +); + +impl Rule for JsxNoCommentTextnodes { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXText(jsx_text) = node.kind() else { + return; + }; + if !has_comment_like_pattern(&jsx_text.value, follows_expression_container(node, ctx)) + || is_inside_literal_text_tag(node, ctx) + || is_deliberate_styled_comment_token(node, ctx) + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(jsx_text.span)); + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() + } +} + +fn has_comment_like_pattern(text: &str, follows_expression_container: bool) -> bool { + text.split('\n').enumerate().any(|(line_index, raw_line)| { + let trimmed = raw_line.trim(); + if trimmed.starts_with("/*") { + return true; + } + let Some(comment_body) = trimmed.strip_prefix("//") else { + return false; + }; + let trimmed_comment_body = comment_body.trim_start(); + if trimmed_comment_body.is_empty() { + return false; + } + !(line_index == 0 + && follows_expression_container + && trimmed_comment_body + .as_bytes() + .first() + .is_some_and(u8::is_ascii_digit)) + }) +} + +fn follows_expression_container(node: &AstNode, ctx: &LintContext) -> bool { + let parent = ctx.nodes().parent_node(node.id()); + let children = match parent.kind() { + AstKind::JSXElement(element) => &element.children, + AstKind::JSXFragment(fragment) => &fragment.children, + _ => return false, + }; + let Some(child_index) = children + .iter() + .position(|child| child.span() == node.span()) + else { + return false; + }; + if child_index == 0 { + return false; + } + matches!( + &children[child_index - 1], + JSXChild::ExpressionContainer(container) + if !matches!(container.expression, JSXExpression::EmptyExpression(_)) + ) +} + +fn is_inside_literal_text_tag(node: &AstNode, ctx: &LintContext) -> bool { + ctx.nodes().ancestors(node.id()).any(|ancestor| { + let AstKind::JSXElement(element) = ancestor.kind() else { + return false; + }; + matches!( + &element.opening_element.name, + JSXElementName::Identifier(identifier) + if LITERAL_TEXT_TAGS.contains(&identifier.name.as_str()) + ) + }) +} + +fn is_deliberate_styled_comment_token(node: &AstNode, ctx: &LintContext) -> bool { + let AstKind::JSXText(jsx_text) = node.kind() else { + return false; + }; + let trimmed = jsx_text.value.trim(); + if !trimmed.starts_with("//") && !trimmed.starts_with("/*") { + return false; + } + let parent = ctx.nodes().parent_node(node.id()); + let AstKind::JSXElement(element) = parent.kind() else { + return false; + }; + if element.children.iter().any(|child| match child { + JSXChild::Text(text) => text.span != jsx_text.span && !text.value.trim().is_empty(), + _ => child.span() != jsx_text.span, + }) { + return false; + } + element.opening_element.attributes.iter().any(|attribute| { + let JSXAttributeItem::Attribute(attribute) = attribute else { + return false; + }; + matches!( + &attribute.name, + JSXAttributeName::Identifier(identifier) + if STYLING_ATTRIBUTE_NAMES.contains(&identifier.name.as_str()) + ) + }) +} diff --git a/native/oxlint/rules/jsx-no-constructed-context-values.rs b/native/oxlint/rules/jsx-no-constructed-context-values.rs new file mode 100644 index 0000000000..4e358b6cab --- /dev/null +++ b/native/oxlint/rules/jsx-no-constructed-context-values.rs @@ -0,0 +1,257 @@ +use oxc_ast::{ + AstKind, + ast::{ + Expression, IdentifierReference, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, + JSXElementName, JSXMemberExpressionObject, + }, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + module_record::ImportImportName, + rule::Rule, +}; + +const CONTEXT_MODULE_SOURCES: [&str; 3] = ["react", "use-context-selector", "react-tracked"]; +const MESSAGE: &str = + "Every reader of this context redraws on each render because you build its `value` inline."; + +#[derive(Debug, Default, Clone)] +pub struct JsxNoConstructedContextValues; + +declare_oxc_lint!( + /// Disallow inline-constructed React context values. + JsxNoConstructedContextValues, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow inline-constructed React context values.", +); + +impl Rule for JsxNoConstructedContextValues { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if !is_context_provider_name(&opening_element.name, ctx) + || crate::ast_util::get_enclosing_function(node, ctx).is_none() + { + return; + } + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + continue; + }; + if attribute_name.name != "value" { + continue; + } + let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value else { + continue; + }; + let Some(expression) = container.expression.as_expression() else { + continue; + }; + if is_constructed_context_value(expression) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(attribute.span)); + } + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() && !is_non_production_file(ctx) + } +} + +fn is_context_provider_name<'a>(name: &JSXElementName<'a>, ctx: &LintContext<'a>) -> bool { + match name { + JSXElementName::MemberExpression(member_expression) + if member_expression.property.name == "Provider" => + { + let JSXMemberExpressionObject::IdentifierReference(identifier) = + &member_expression.object + else { + return false; + }; + is_known_context_identifier(identifier, true, ctx) + } + JSXElementName::IdentifierReference(identifier) => { + is_context_module_named_import(identifier, ctx) + || is_known_context_identifier(identifier, false, ctx) + } + _ => false, + } +} + +fn is_known_context_identifier<'a>( + identifier: &IdentifierReference<'a>, + allow_context_named_import: bool, + ctx: &LintContext<'a>, +) -> bool { + if allow_context_named_import && is_context_named_import(identifier, ctx) { + return true; + } + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + if is_context_binding_symbol(symbol_id, ctx) { + return true; + } + let symbol_scope_id = ctx.scoping().symbol_scope_id(symbol_id); + ctx.nodes().iter().any(|candidate| { + let AstKind::VariableDeclarator(declarator) = candidate.kind() else { + return false; + }; + let Some(binding_identifier) = declarator.id.get_binding_identifier() else { + return false; + }; + binding_identifier.name == identifier.name + && ctx + .scoping() + .symbol_scope_id(binding_identifier.symbol_id()) + == symbol_scope_id + && is_stable_top_level_context_symbol(binding_identifier.symbol_id(), ctx) + && is_context_declarator(candidate, declarator, binding_identifier.symbol_id(), ctx) + }) +} + +fn is_context_binding_symbol<'a>(symbol_id: oxc_semantic::SymbolId, ctx: &LintContext<'a>) -> bool { + if !is_stable_top_level_context_symbol(symbol_id, ctx) { + return false; + } + let declaration = ctx.symbol_declaration(symbol_id); + let AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + is_context_declarator(declaration, declarator, symbol_id, ctx) +} + +fn is_stable_top_level_context_symbol( + symbol_id: oxc_semantic::SymbolId, + ctx: &LintContext<'_>, +) -> bool { + ctx.scoping() + .scope_flags(ctx.scoping().symbol_scope_id(symbol_id)) + .is_top() + && !ctx + .scoping() + .get_resolved_references(symbol_id) + .any(oxc_semantic::Reference::is_write) +} + +fn is_context_declarator<'a>( + declaration: &AstNode<'a>, + declarator: &oxc_ast::ast::VariableDeclarator<'a>, + symbol_id: oxc_semantic::SymbolId, + ctx: &LintContext<'a>, +) -> bool { + let parent = ctx.nodes().parent_node(declaration.id()); + let AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() + || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return false; + } + let Some(Expression::CallExpression(call_expression)) = declarator + .init + .as_ref() + .map(Expression::get_inner_expression) + else { + return false; + }; + module_api_path_matches( + &call_expression.callee, + &["createContext"], + &CONTEXT_MODULE_SOURCES, + true, + ctx, + ) || is_global_react_create_context_call(call_expression, ctx) +} + +fn is_context_named_import<'a>( + identifier: &IdentifierReference<'a>, + ctx: &LintContext<'a>, +) -> bool { + resolve_identifier_import(identifier, ctx).is_some_and(|entry| { + identifier.name.ends_with("Context") + || matches!( + &entry.import_name, + ImportImportName::Name(imported_name) if imported_name.name().ends_with("Context") + ) + }) +} + +fn is_context_module_named_import<'a>( + identifier: &IdentifierReference<'a>, + ctx: &LintContext<'a>, +) -> bool { + is_context_named_import(identifier, ctx) + && resolve_identifier_import(identifier, ctx).is_some_and(|entry| { + entry + .module_request + .name() + .rsplit('/') + .next() + .is_some_and(|segment| segment == "context") + }) +} + +fn is_global_react_create_context_call<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + ctx: &LintContext<'a>, +) -> bool { + let Some(member_expression) = call_expression + .callee + .get_inner_expression() + .as_member_expression() + else { + return false; + }; + let Expression::Identifier(identifier) = member_expression.object().get_inner_expression() + else { + return false; + }; + identifier.name == "React" + && ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_none() + && member_expression.static_property_name() == Some("createContext") +} + +fn is_constructed_context_value(expression: &Expression<'_>) -> bool { + match expression.get_inner_expression() { + Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ClassExpression(_) + | Expression::NewExpression(_) + | Expression::JSXElement(_) + | Expression::JSXFragment(_) => true, + Expression::ConditionalExpression(conditional_expression) => { + is_constructed_context_value(&conditional_expression.consequent) + || is_constructed_context_value(&conditional_expression.alternate) + } + Expression::LogicalExpression(logical_expression) => { + is_constructed_context_value(&logical_expression.left) + || is_constructed_context_value(&logical_expression.right) + } + _ => false, + } +} diff --git a/native/oxlint/rules/jsx-no-duplicate-props.rs b/native/oxlint/rules/jsx-no-duplicate-props.rs new file mode 100644 index 0000000000..4e248c0257 --- /dev/null +++ b/native/oxlint/rules/jsx-no-duplicate-props.rs @@ -0,0 +1,47 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use rustc_hash::FxHashSet; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct JsxNoDuplicateProps; + +declare_oxc_lint!( + /// Disallow duplicate JSX props. + JsxNoDuplicateProps, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow duplicate JSX props.", +); + +impl Rule for JsxNoDuplicateProps { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let mut seen_prop_names = FxHashSet::default(); + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(identifier) = &attribute.name else { + continue; + }; + if !seen_prop_names.insert(identifier.name) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users can get the wrong value because React keeps only the last \"{}\" & drops the first.", + identifier.name + )) + .with_label(attribute.span), + ); + } + } + } +} diff --git a/native/oxlint/rules/jsx-no-script-url.rs b/native/oxlint/rules/jsx-no-script-url.rs new file mode 100644 index 0000000000..242cf53130 --- /dev/null +++ b/native/oxlint/rules/jsx-no-script-url.rs @@ -0,0 +1,126 @@ +use oxc_ast::{ + ast::{JSXAttributeItem, JSXAttributeName, JSXAttributeValue}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +const MESSAGE: &str = "A `javascript:` URL is an XSS hole that runs injected input as code."; + +#[derive(Debug, Default, Clone)] +pub struct JsxNoScriptUrl; + +declare_oxc_lint!( + /// Disallow javascript protocol URLs in JSX links. + JsxNoScriptUrl, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow javascript protocol URLs in JSX links.", +); + +impl Rule for JsxNoScriptUrl { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + if !matches!( + &opening_element.name, + oxc_ast::ast::JSXElementName::Identifier(_) + | oxc_ast::ast::JSXElementName::IdentifierReference(_) + ) { + return; + } + let Some((element_name, _)) = resolve_jsx_element_type(opening_element, ctx) else { + return; + }; + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(attribute_name) = &attribute.name else { + continue; + }; + if !is_link_prop_for_element(element_name, attribute_name.name.as_str(), ctx) { + continue; + } + let Some(JSXAttributeValue::StringLiteral(value)) = &attribute.value else { + continue; + }; + if is_javascript_url(value.value.as_str()) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(attribute.span)); + } + } + } +} + +fn is_link_prop_for_element(element_name: &str, attribute_name: &str, ctx: &LintContext) -> bool { + if element_name == "a" && attribute_name == "href" { + return true; + } + let Some(options) = ctx + .settings() + .json + .as_ref() + .and_then(|settings| settings.get("react-doctor")) + .and_then(|settings| settings.get("jsxNoScriptUrl")) + else { + return false; + }; + if configured_link_prop(options, "components", element_name, attribute_name) { + return true; + } + options + .get("includeFromSettings") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && configured_link_prop(options, "linkComponents", element_name, attribute_name) +} + +fn configured_link_prop( + options: &serde_json::Value, + map_name: &str, + element_name: &str, + attribute_name: &str, +) -> bool { + options + .get(map_name) + .and_then(|components| components.get(element_name)) + .and_then(serde_json::Value::as_array) + .is_some_and(|attribute_names| { + attribute_names + .iter() + .any(|name| name.as_str() == Some(attribute_name)) + }) +} + +fn is_javascript_url(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while bytes + .get(index) + .is_some_and(|byte| *byte <= 0x1f || *byte == b' ') + { + index += 1; + } + for (letter_index, expected_letter) in b"javascript".iter().enumerate() { + if bytes + .get(index) + .is_none_or(|byte| !byte.eq_ignore_ascii_case(expected_letter)) + { + return false; + } + index += 1; + if letter_index + 1 < b"javascript".len() { + while bytes + .get(index) + .is_some_and(|byte| matches!(byte, b'\r' | b'\n' | b'\t')) + { + index += 1; + } + } + } + bytes.get(index) == Some(&b':') +} diff --git a/native/oxlint/rules/jsx-props-no-spread-multi.rs b/native/oxlint/rules/jsx-props-no-spread-multi.rs new file mode 100644 index 0000000000..825b202b48 --- /dev/null +++ b/native/oxlint/rules/jsx-props-no-spread-multi.rs @@ -0,0 +1,75 @@ +use oxc_ast::{ + ast::{Expression, JSXAttributeItem, MemberExpression}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use rustc_hash::FxHashSet; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Default, Clone)] +pub struct JsxPropsNoSpreadMulti; + +declare_oxc_lint!( + /// Disallow spreading the same prop value more than once. + JsxPropsNoSpreadMulti, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow spreading the same prop value more than once.", +); + +impl Rule for JsxPropsNoSpreadMulti { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let mut seen_names = FxHashSet::default(); + let mut reported_names = FxHashSet::default(); + for attribute in &opening_element.attributes { + let JSXAttributeItem::SpreadAttribute(spread_attribute) = attribute else { + continue; + }; + let Some(name) = flatten_member_expression_name(&spread_attribute.argument) else { + continue; + }; + if !seen_names.insert(name.clone()) && reported_names.insert(name.clone()) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "The later spread of `{name}` silently overrides the earlier one." + )) + .with_label(spread_attribute.span), + ); + } + } + } +} + +fn flatten_member_expression_name(expression: &Expression) -> Option { + match expression.get_inner_expression() { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + Expression::ThisExpression(_) => Some("this".to_string()), + Expression::ChainExpression(chain_expression) => { + flatten_member_expression(chain_expression.expression.as_member_expression()?) + } + expression => flatten_member_expression(expression.as_member_expression()?), + } +} + +fn flatten_member_expression(member_expression: &MemberExpression) -> Option { + let object_name = flatten_member_expression_name(member_expression.object())?; + let property_name = match member_expression { + MemberExpression::StaticMemberExpression(member_expression) => { + member_expression.property.name.as_str() + } + MemberExpression::ComputedMemberExpression(member_expression) => { + let Expression::StringLiteral(string_literal) = &member_expression.expression else { + return None; + }; + string_literal.value.as_str() + } + MemberExpression::PrivateFieldExpression(_) => return None, + }; + Some(format!("{object_name}.{property_name}")) +} diff --git a/native/oxlint/rules/member-expression-identifier-property-name.rs b/native/oxlint/rules/member-expression-identifier-property-name.rs new file mode 100644 index 0000000000..ccace244ee --- /dev/null +++ b/native/oxlint/rules/member-expression-identifier-property-name.rs @@ -0,0 +1,16 @@ +fn member_expression_identifier_property_name<'a>( + member_expression: &'a oxc_ast::ast::MemberExpression<'a>, +) -> Option<&'a str> { + match member_expression { + oxc_ast::ast::MemberExpression::StaticMemberExpression(member_expression) => { + Some(member_expression.property.name.as_str()) + } + oxc_ast::ast::MemberExpression::ComputedMemberExpression(member_expression) => { + match &member_expression.expression { + oxc_ast::ast::Expression::Identifier(identifier) => Some(identifier.name.as_str()), + _ => None, + } + } + oxc_ast::ast::MemberExpression::PrivateFieldExpression(_) => None, + } +} diff --git a/native/oxlint/rules/module-api-path-matches.rs b/native/oxlint/rules/module-api-path-matches.rs new file mode 100644 index 0000000000..17570f22a1 --- /dev/null +++ b/native/oxlint/rules/module-api-path-matches.rs @@ -0,0 +1,126 @@ +fn module_api_path_matches<'a>( + expression: &oxc_ast::ast::Expression<'a>, + expected_path: &[&str], + module_sources: &[&str], + allow_default_namespace: bool, + ctx: &crate::context::LintContext<'a>, +) -> bool { + module_api_path_matches_internal( + expression, + expected_path, + module_sources, + allow_default_namespace, + ctx, + &mut Vec::new(), + ) +} + +fn module_api_path_matches_internal<'a>( + expression: &oxc_ast::ast::Expression<'a>, + expected_path: &[&str], + module_sources: &[&str], + allow_default_namespace: bool, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + let expression = expression.get_inner_expression(); + if let Some(member_expression) = expression.as_member_expression() { + let Some((expected_property, expected_receiver_path)) = expected_path.split_last() else { + return false; + }; + return member_expression.static_property_name() == Some(*expected_property) + && module_api_path_matches_internal( + member_expression.object(), + expected_receiver_path, + module_sources, + allow_default_namespace, + ctx, + visited_symbol_ids, + ); + } + let oxc_ast::ast::Expression::Identifier(identifier) = expression else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + visited_symbol_ids.push(symbol_id); + let declaration = ctx.symbol_declaration(symbol_id); + if let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() { + let parent = ctx.nodes().parent_node(declaration.id()); + let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() else { + return false; + }; + if !variable_declaration.kind.is_const() { + return false; + } + if declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + { + return declarator.init.as_ref().is_some_and(|initializer| { + module_api_path_matches_internal( + initializer, + expected_path, + module_sources, + allow_default_namespace, + ctx, + visited_symbol_ids, + ) + }); + } + if expected_path.len() != 1 { + return false; + } + let oxc_ast::ast::BindingPattern::ObjectPattern(pattern) = &declarator.id else { + return false; + }; + let has_matching_property = pattern.properties.iter().any(|property| { + property_key_matches_name(&property.key, expected_path[0]) + && matches!( + &property.value, + oxc_ast::ast::BindingPattern::BindingIdentifier(binding_identifier) + if binding_identifier.symbol_id() == symbol_id + ) + }); + return has_matching_property + && declarator.init.as_ref().is_some_and(|initializer| { + module_api_path_matches_internal( + initializer, + &[], + module_sources, + allow_default_namespace, + ctx, + visited_symbol_ids, + ) + }); + } + ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && module_sources.contains(&entry.module_request.name()) + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && match &entry.import_name { + crate::module_record::ImportImportName::NamespaceObject => expected_path.is_empty(), + crate::module_record::ImportImportName::Default(_) => { + allow_default_namespace && expected_path.is_empty() + } + crate::module_record::ImportImportName::Name(imported_name) => { + expected_path == [imported_name.name()] + || (allow_default_namespace + && expected_path.is_empty() + && imported_name.name() == "default") + } + } + }) +} diff --git a/native/oxlint/rules/module-jsx-tree-index.rs b/native/oxlint/rules/module-jsx-tree-index.rs new file mode 100644 index 0000000000..99771c282a --- /dev/null +++ b/native/oxlint/rules/module-jsx-tree-index.rs @@ -0,0 +1,76 @@ +use oxc_span::Span; + +struct ModuleJsxTreeIndex { + prefix_maximum_ends: Vec, + spans: Vec, +} + +impl ModuleJsxTreeIndex { + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + fn contains_or_is_inside(&self, span: Span) -> bool { + self.contains(span) || self.is_inside(span) + } + + fn contains(&self, span: Span) -> bool { + let descendant_index = self + .spans + .partition_point(|candidate| candidate.start < span.start); + self.spans + .get(descendant_index) + .is_some_and(|candidate| candidate.start < span.end) + } + + fn is_inside(&self, span: Span) -> bool { + let ancestor_count = self + .spans + .partition_point(|candidate| candidate.start <= span.start); + ancestor_count > 0 && self.prefix_maximum_ends[ancestor_count - 1] >= span.end + } +} + +fn module_jsx_tree_index( + module_source: &str, + ctx: &crate::context::LintContext<'_>, +) -> ModuleJsxTreeIndex { + let mut spans = ctx + .nodes() + .iter() + .filter_map(|node| { + let oxc_ast::AstKind::JSXElement(element) = node.kind() else { + return None; + }; + resolve_imported_jsx_component_name(&element.opening_element, module_source, ctx) + .map(|_| element.span) + }) + .collect::>(); + spans.sort_unstable_by_key(|span| span.start); + let mut maximum_end = 0; + let prefix_maximum_ends = spans + .iter() + .map(|span| { + maximum_end = maximum_end.max(span.end); + maximum_end + }) + .collect(); + ModuleJsxTreeIndex { + prefix_maximum_ends, + spans, + } +} + +fn owning_jsx_element_span( + opening_element_node: &crate::AstNode<'_>, + ctx: &crate::context::LintContext<'_>, +) -> Option { + ctx.nodes() + .ancestors(opening_element_node.id()) + .find_map(|ancestor| { + let oxc_ast::AstKind::JSXElement(element) = ancestor.kind() else { + return None; + }; + Some(element.span) + }) +} diff --git a/native/oxlint/rules/motion-animate-presence-requires-key.rs b/native/oxlint/rules/motion-animate-presence-requires-key.rs new file mode 100644 index 0000000000..3b7308cba6 --- /dev/null +++ b/native/oxlint/rules/motion-animate-presence-requires-key.rs @@ -0,0 +1,39 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "This direct AnimatePresence child has no key, so Motion cannot reliably match it with its exiting instance. Add a stable unique key."; + +#[derive(Debug, Default, Clone)] +pub struct MotionAnimatePresenceRequiresKey; + +declare_oxc_lint!( + /// Require keys on multiple direct AnimatePresence children. + MotionAnimatePresenceRequiresKey, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require keys on multiple direct AnimatePresence children.", +); + +impl Rule for MotionAnimatePresenceRequiresKey { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + if !motion_react_component_matches(&element.opening_element.name, "AnimatePresence", ctx) { + return; + } + let direct_elements = get_static_direct_jsx_elements(element); + if direct_elements.len() < 2 { + return; + } + for child in direct_elements { + if find_jsx_attribute(&child.opening_element, "key").is_none() { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(child.opening_element.span)); + } + } + } +} diff --git a/native/oxlint/rules/motion-animate-presence-wait-single-child.rs b/native/oxlint/rules/motion-animate-presence-wait-single-child.rs new file mode 100644 index 0000000000..b59126c2a5 --- /dev/null +++ b/native/oxlint/rules/motion-animate-presence-wait-single-child.rs @@ -0,0 +1,40 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "AnimatePresence `mode=\"wait\"` supports only one child at a time, but this tree has multiple direct children. Use one child or another mode."; + +#[derive(Debug, Default, Clone)] +pub struct MotionAnimatePresenceWaitSingleChild; + +declare_oxc_lint!( + /// Require a single direct child in AnimatePresence wait mode. + MotionAnimatePresenceWaitSingleChild, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require a single direct child in AnimatePresence wait mode.", +); + +impl Rule for MotionAnimatePresenceWaitSingleChild { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(element) = node.kind() else { + return; + }; + if !motion_react_component_matches(&element.opening_element.name, "AnimatePresence", ctx) { + return; + } + let Some(mode_attribute) = + get_authoritative_jsx_attribute(&element.opening_element, "mode", true) + else { + return; + }; + if get_string_literal_attribute_value(mode_attribute) == Some("wait") + && get_static_direct_jsx_elements(element).len() >= 2 + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(mode_attribute.span)); + } + } +} diff --git a/native/oxlint/rules/motion-create-in-render.rs b/native/oxlint/rules/motion-create-in-render.rs new file mode 100644 index 0000000000..fd5ba907cb --- /dev/null +++ b/native/oxlint/rules/motion-create-in-render.rs @@ -0,0 +1,33 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "motion.create() builds a new component type during this render, which resets identity and can break animation continuity. Hoist it or memoize the factory."; + +#[derive(Debug, Default, Clone)] +pub struct MotionCreateInRender; + +declare_oxc_lint!( + /// Disallow creating Motion component types during render. + MotionCreateInRender, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow creating Motion component types during render.", +); + +impl Rule for MotionCreateInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if motion_react_api_path_matches(&call_expression.callee, &["motion", "create"], ctx) + && is_render_phase_component_or_hook(node, ctx) + && !is_inside_stable_react_initializer(node, ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } + } +} diff --git a/native/oxlint/rules/motion-imperative-animation-in-render.rs b/native/oxlint/rules/motion-imperative-animation-in-render.rs new file mode 100644 index 0000000000..a672fd43e0 --- /dev/null +++ b/native/oxlint/rules/motion-imperative-animation-in-render.rs @@ -0,0 +1,159 @@ +use oxc_ast::{ + AstKind, + ast::{BindingPattern, Expression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const ANIMATION_CONTROL_HOOKS: [&str; 2] = ["useAnimation", "useAnimationControls"]; +const MOTION_VALUE_HOOKS: [&str; 5] = [ + "useMotionValue", + "useSpring", + "useTime", + "useTransform", + "useVelocity", +]; + +#[derive(Debug, Default, Clone)] +pub struct MotionImperativeAnimationInRender; + +declare_oxc_lint!( + /// Disallow imperative Motion animations during render. + MotionImperativeAnimationInRender, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow imperative Motion animations during render.", +); + +impl Rule for MotionImperativeAnimationInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(animation_kind) = imperative_animation_kind(call_expression, ctx) else { + return; + }; + if !is_render_phase_component_or_hook(node, ctx) { + return; + } + let operation = match animation_kind { + ImperativeAnimationKind::Animate => "This imperative animation", + ImperativeAnimationKind::Controls => "Animation controls start", + ImperativeAnimationKind::MotionValue => "This Motion value write", + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "{operation} during render, so React retries and re-renders can replay the side effect. Move it to an effect or event handler." + )) + .with_label(call_expression.span), + ); + } +} + +enum ImperativeAnimationKind { + Animate, + Controls, + MotionValue, +} + +fn imperative_animation_kind<'a>( + call_expression: &oxc_ast::ast::CallExpression<'a>, + ctx: &LintContext<'a>, +) -> Option { + if motion_react_api_path_matches(&call_expression.callee, &["animate"], ctx) + || is_use_animate_function(&call_expression.callee, ctx, &mut Vec::new()) + { + return Some(ImperativeAnimationKind::Animate); + } + let member_expression = call_expression + .callee + .get_inner_expression() + .as_member_expression()?; + match member_expression.static_property_name()? { + "start" + if is_motion_hook_result_expression( + member_expression.object(), + &ANIMATION_CONTROL_HOOKS, + ctx, + ) => + { + Some(ImperativeAnimationKind::Controls) + } + "set" | "jump" + if is_motion_hook_result_expression( + member_expression.object(), + &MOTION_VALUE_HOOKS, + ctx, + ) => + { + Some(ImperativeAnimationKind::MotionValue) + } + _ => None, + } +} + +fn is_use_animate_function<'a>( + expression: &Expression<'a>, + ctx: &LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + let Expression::Identifier(identifier) = expression.get_inner_expression() else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + visited_symbol_ids.push(symbol_id); + let declaration = ctx.symbol_declaration(symbol_id); + let AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + if !matches!( + parent.kind(), + AstKind::VariableDeclaration(variable_declaration) if variable_declaration.kind.is_const() + ) { + return false; + } + if declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + && let Some(initializer) = &declarator.init + && matches!( + initializer.get_inner_expression(), + Expression::Identifier(_) + ) + { + return is_use_animate_function(initializer, ctx, visited_symbol_ids); + } + let BindingPattern::ArrayPattern(array_pattern) = &declarator.id else { + return false; + }; + let Some(Some(BindingPattern::BindingIdentifier(animate_binding))) = + array_pattern.elements.get(1) + else { + return false; + }; + if animate_binding.symbol_id() != symbol_id { + return false; + } + let Some(Expression::CallExpression(initializer)) = declarator + .init + .as_ref() + .map(Expression::get_inner_expression) + else { + return false; + }; + motion_react_api_path_matches(&initializer.callee, &["useAnimate"], ctx) +} diff --git a/native/oxlint/rules/motion-react-api-path-matches.rs b/native/oxlint/rules/motion-react-api-path-matches.rs new file mode 100644 index 0000000000..0c1ad9310f --- /dev/null +++ b/native/oxlint/rules/motion-react-api-path-matches.rs @@ -0,0 +1,84 @@ +const MOTION_REACT_MODULE_SOURCES: [&str; 4] = [ + "framer-motion", + "framer-motion/client", + "motion/react", + "motion/react-client", +]; + +fn motion_react_api_path_matches<'a>( + expression: &oxc_ast::ast::Expression<'a>, + expected_path: &[&str], + ctx: &crate::context::LintContext<'a>, +) -> bool { + motion_react_api_path_matches_internal(expression, expected_path, ctx, &mut Vec::new()) +} + +fn motion_react_api_path_matches_internal<'a>( + expression: &oxc_ast::ast::Expression<'a>, + expected_path: &[&str], + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + let expression = expression.get_inner_expression(); + if let Some(member_expression) = expression.as_member_expression() { + let Some((expected_property, expected_receiver_path)) = expected_path.split_last() else { + return false; + }; + return member_expression.static_property_name() == Some(*expected_property) + && motion_react_api_path_matches_internal( + member_expression.object(), + expected_receiver_path, + ctx, + visited_symbol_ids, + ); + } + let oxc_ast::ast::Expression::Identifier(identifier) = expression else { + return false; + }; + let Some(symbol_id) = ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + else { + return false; + }; + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + visited_symbol_ids.push(symbol_id); + let declaration = ctx.symbol_declaration(symbol_id); + if let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() { + let parent = ctx.nodes().parent_node(declaration.id()); + if let oxc_ast::AstKind::VariableDeclaration(variable_declaration) = parent.kind() + && variable_declaration.kind.is_const() + && declarator + .id + .get_binding_identifier() + .is_some_and(|binding_identifier| binding_identifier.symbol_id() == symbol_id) + && let Some(initializer) = &declarator.init + { + return motion_react_api_path_matches_internal( + initializer, + expected_path, + ctx, + visited_symbol_ids, + ); + } + return false; + } + ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && MOTION_REACT_MODULE_SOURCES.contains(&entry.module_request.name()) + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && match &entry.import_name { + crate::module_record::ImportImportName::NamespaceObject => expected_path.is_empty(), + crate::module_record::ImportImportName::Name(imported_name) => { + expected_path == [imported_name.name()] + } + crate::module_record::ImportImportName::Default(_) => false, + } + }) +} diff --git a/native/oxlint/rules/motion-react-component-matches.rs b/native/oxlint/rules/motion-react-component-matches.rs new file mode 100644 index 0000000000..0851be9f7f --- /dev/null +++ b/native/oxlint/rules/motion-react-component-matches.rs @@ -0,0 +1,111 @@ +const MOTION_REACT_COMPONENT_MODULE_SOURCES: [&str; 3] = + ["framer-motion", "motion/react", "motion/react-client"]; + +fn motion_react_component_matches<'a>( + element_name: &oxc_ast::ast::JSXElementName<'a>, + component_name: &str, + ctx: &crate::context::LintContext<'a>, +) -> bool { + match element_name { + oxc_ast::ast::JSXElementName::IdentifierReference(identifier) => ctx + .scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_some_and(|symbol_id| { + motion_component_symbol_matches_import( + symbol_id, + component_name, + false, + ctx, + &mut Vec::new(), + ) + }), + oxc_ast::ast::JSXElementName::MemberExpression(member_expression) + if member_expression.property.name == component_name => + { + let oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) = + &member_expression.object + else { + return false; + }; + ctx.scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_some_and(|symbol_id| { + motion_component_symbol_matches_import( + symbol_id, + component_name, + true, + ctx, + &mut Vec::new(), + ) + }) + } + _ => false, + } +} + +fn motion_component_symbol_matches_import<'a>( + symbol_id: oxc_semantic::SymbolId, + component_name: &str, + requires_namespace: bool, + ctx: &crate::context::LintContext<'a>, + visited_symbol_ids: &mut Vec, +) -> bool { + if visited_symbol_ids.contains(&symbol_id) { + return false; + } + visited_symbol_ids.push(symbol_id); + if ctx.module_record().import_entries.iter().any(|entry| { + !entry.is_type + && MOTION_REACT_COMPONENT_MODULE_SOURCES.contains(&entry.module_request.name()) + && ctx + .scoping() + .get_root_binding(entry.local_name.name().into()) + == Some(symbol_id) + && match &entry.import_name { + crate::module_record::ImportImportName::NamespaceObject => requires_namespace, + crate::module_record::ImportImportName::Name(imported_name) => { + !requires_namespace && imported_name.name() == component_name + } + crate::module_record::ImportImportName::Default(_) => false, + } + }) { + return true; + } + let declaration = ctx.symbol_declaration(symbol_id); + let oxc_ast::AstKind::VariableDeclarator(declarator) = declaration.kind() else { + return false; + }; + let parent = ctx.nodes().parent_node(declaration.id()); + if !matches!( + parent.kind(), + oxc_ast::AstKind::VariableDeclaration(variable_declaration) + if variable_declaration.kind.is_const() + ) || declarator + .id + .get_binding_identifier() + .is_none_or(|binding_identifier| binding_identifier.symbol_id() != symbol_id) + { + return false; + } + let Some(oxc_ast::ast::Expression::Identifier(identifier)) = declarator + .init + .as_ref() + .map(oxc_ast::ast::Expression::get_inner_expression) + else { + return false; + }; + ctx.scoping() + .get_reference(identifier.reference_id()) + .symbol_id() + .is_some_and(|aliased_symbol_id| { + motion_component_symbol_matches_import( + aliased_symbol_id, + component_name, + requires_namespace, + ctx, + visited_symbol_ids, + ) + }) +} diff --git a/native/oxlint/rules/motion-use-transform-range-length.rs b/native/oxlint/rules/motion-use-transform-range-length.rs new file mode 100644 index 0000000000..5f3e4dc9b2 --- /dev/null +++ b/native/oxlint/rules/motion-use-transform-range-length.rs @@ -0,0 +1,61 @@ +use oxc_ast::{AstKind, ast::ArrayExpressionElement}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct MotionUseTransformRangeLength; + +declare_oxc_lint!( + /// Require equal static input and output ranges for Motion transforms. + MotionUseTransformRangeLength, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require equal static input and output ranges for Motion transforms.", +); + +impl Rule for MotionUseTransformRangeLength { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if !motion_react_api_path_matches(&call_expression.callee, &["useTransform"], ctx) { + return; + } + let Some(input_length) = static_array_argument_length(call_expression.arguments.get(1)) + else { + return; + }; + let Some(output_length) = static_array_argument_length(call_expression.arguments.get(2)) + else { + return; + }; + if input_length == output_length { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "useTransform receives {input_length} input stops but {output_length} output values. These ranges must have equal lengths." + )) + .with_label(call_expression.span), + ); + } +} + +fn static_array_argument_length(argument: Option<&oxc_ast::ast::Argument<'_>>) -> Option { + let oxc_ast::ast::Argument::ArrayExpression(array_expression) = argument? else { + return None; + }; + array_expression + .elements + .iter() + .all(|element| { + !matches!( + element, + ArrayExpressionElement::SpreadElement(_) | ArrayExpressionElement::Elision(_) + ) + }) + .then_some(array_expression.elements.len()) +} diff --git a/native/oxlint/rules/motion-value-constructor-in-render.rs b/native/oxlint/rules/motion-value-constructor-in-render.rs new file mode 100644 index 0000000000..8840b5a98b --- /dev/null +++ b/native/oxlint/rules/motion-value-constructor-in-render.rs @@ -0,0 +1,33 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "motionValue() creates a fresh reactive object during this render. Use useMotionValue() so React preserves the value across renders."; + +#[derive(Debug, Default, Clone)] +pub struct MotionValueConstructorInRender; + +declare_oxc_lint!( + /// Disallow constructing Motion values during render. + MotionValueConstructorInRender, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow constructing Motion values during render.", +); + +impl Rule for MotionValueConstructorInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + if motion_react_api_path_matches(&call_expression.callee, &["motionValue"], ctx) + && is_render_phase_component_or_hook(node, ctx) + && !is_inside_stable_react_initializer(node, ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } + } +} diff --git a/native/oxlint/rules/motion-value-subscription-in-render.rs b/native/oxlint/rules/motion-value-subscription-in-render.rs new file mode 100644 index 0000000000..07ae397d5e --- /dev/null +++ b/native/oxlint/rules/motion-value-subscription-in-render.rs @@ -0,0 +1,52 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MOTION_VALUE_HOOKS: [&str; 6] = [ + "useMotionTemplate", + "useMotionValue", + "useSpring", + "useTime", + "useTransform", + "useVelocity", +]; +const MESSAGE: &str = "This Motion value subscription is added during render, so re-renders can accumulate listeners. Use useMotionValueEvent() or subscribe inside an effect with cleanup."; + +#[derive(Debug, Default, Clone)] +pub struct MotionValueSubscriptionInRender; + +declare_oxc_lint!( + /// Disallow Motion value subscriptions during render. + MotionValueSubscriptionInRender, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow Motion value subscriptions during render.", +); + +impl Rule for MotionValueSubscriptionInRender { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(member_expression) = call_expression + .callee + .get_inner_expression() + .as_member_expression() + else { + return; + }; + if member_expression.static_property_name() == Some("on") + && is_motion_hook_result_expression( + member_expression.object(), + &MOTION_VALUE_HOOKS, + ctx, + ) + && is_render_phase_component_or_hook(node, ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span)); + } + } +} diff --git a/native/oxlint/rules/nextjs-async-client-component.rs b/native/oxlint/rules/nextjs-async-client-component.rs new file mode 100644 index 0000000000..1cbe41a3d9 --- /dev/null +++ b/native/oxlint/rules/nextjs-async-client-component.rs @@ -0,0 +1,130 @@ +use oxc_ast::{ + AstKind, + ast::{BindingPattern, Expression, FunctionType}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, +}; + +#[derive(Debug, Default, Clone)] +pub struct NextjsAsyncClientComponent; + +declare_oxc_lint!( + /// Disallow async Next.js client components. + NextjsAsyncClientComponent, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow async Next.js client components.", +); + +impl Rule for NextjsAsyncClientComponent { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if !ctx + .nodes() + .program() + .directives + .iter() + .any(|directive| directive.directive == "use client") + { + return; + } + match node.kind() { + AstKind::Function(function) + if function.r#type == FunctionType::FunctionDeclaration + && function.r#async + && function + .id + .as_ref() + .is_some_and(|identifier| is_uppercase_name(identifier.name.as_str())) => + { + let component_name = function.id.as_ref().unwrap().name.as_str(); + ctx.diagnostic( + async_client_component_diagnostic(component_name).with_label(function.span), + ); + } + AstKind::VariableDeclarator(declarator) => { + let BindingPattern::BindingIdentifier(identifier) = &declarator.id else { + return; + }; + if !is_uppercase_name(identifier.name.as_str()) { + return; + } + let Some(initializer) = &declarator.init else { + return; + }; + let component_function = unwrap_object_freeze_or_seal(initializer, ctx); + let is_async = match component_function { + Expression::ArrowFunctionExpression(function) => function.r#async, + Expression::FunctionExpression(function) => function.r#async, + _ => false, + }; + if is_async { + ctx.diagnostic( + async_client_component_diagnostic(identifier.name.as_str()) + .with_label(declarator.span), + ); + } + } + _ => {} + } + } +} + +fn async_client_component_diagnostic(component_name: &str) -> OxcDiagnostic { + OxcDiagnostic::warn(format!( + "Async client component \"{component_name}\" fails to render because client components can't be async." + )) +} + +fn is_uppercase_name(name: &str) -> bool { + name.as_bytes().first().is_some_and(u8::is_ascii_uppercase) +} + +fn unwrap_object_freeze_or_seal<'a, 'b>( + expression: &'b Expression<'a>, + ctx: &LintContext<'a>, +) -> &'b Expression<'a> { + let mut current_expression = expression.get_inner_expression(); + loop { + let Expression::CallExpression(call_expression) = current_expression else { + return current_expression; + }; + let Expression::StaticMemberExpression(member_expression) = + call_expression.callee.get_inner_expression() + else { + return current_expression; + }; + let Expression::Identifier(receiver) = member_expression.object.get_inner_expression() + else { + return current_expression; + }; + if receiver.name != "Object" + || ctx + .scoping() + .get_reference(receiver.reference_id()) + .symbol_id() + .is_some() + || !matches!(member_expression.property.name.as_str(), "freeze" | "seal") + { + return current_expression; + } + let Some(wrapped_expression) = call_expression + .arguments + .first() + .and_then(oxc_ast::ast::Argument::as_expression) + else { + return current_expression; + }; + current_expression = wrapped_expression.get_inner_expression(); + } +} diff --git a/native/oxlint/rules/nextjs-error-boundary-missing-use-client.rs b/native/oxlint/rules/nextjs-error-boundary-missing-use-client.rs new file mode 100644 index 0000000000..6aeef5ba73 --- /dev/null +++ b/native/oxlint/rules/nextjs-error-boundary-missing-use-client.rs @@ -0,0 +1,57 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const MESSAGE: &str = "This error boundary silently does nothing without 'use client'. Next.js requires error.tsx to be a Client Component."; +const SOURCE_FILE_EXTENSIONS: [&str; 6] = ["ts", "tsx", "js", "jsx", "mts", "mjs"]; + +#[derive(Debug, Default, Clone)] +pub struct NextjsErrorBoundaryMissingUseClient; + +declare_oxc_lint!( + /// Require use client in Next.js App Router error boundaries. + NextjsErrorBoundaryMissingUseClient, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require use client in App Router error boundaries.", +); + +impl Rule for NextjsErrorBoundaryMissingUseClient { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::Program(program) = node.kind() else { + return; + }; + let filename = ctx.file_path().to_string_lossy().replace('\\', "/"); + if !is_in_project_directory(ctx, "app") + || !is_next_file_active(ctx) + || !is_error_boundary_filename(&filename) + || program + .directives + .iter() + .any(|directive| directive.directive == "use client") + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(program_estree_span(program))); + } +} + +fn is_error_boundary_filename(filename: &str) -> bool { + let Some((path_without_extension, extension)) = filename.rsplit_once('.') else { + return false; + }; + SOURCE_FILE_EXTENSIONS.contains(&extension) + && (path_without_extension.ends_with("/error") + || path_without_extension.ends_with("/global-error")) +} diff --git a/native/oxlint/rules/nextjs-global-error-missing-html-body.rs b/native/oxlint/rules/nextjs-global-error-missing-html-body.rs new file mode 100644 index 0000000000..cabbac2d38 --- /dev/null +++ b/native/oxlint/rules/nextjs-global-error-missing-html-body.rs @@ -0,0 +1,74 @@ +use oxc_ast::{ast::JSXElementName, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, +}; + +#[derive(Debug, Default, Clone)] +pub struct NextjsGlobalErrorMissingHtmlBody; + +declare_oxc_lint!( + /// Require html and body elements in Next.js global error boundaries. + NextjsGlobalErrorMissingHtmlBody, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require a complete document in global error boundaries.", +); + +impl Rule for NextjsGlobalErrorMissingHtmlBody { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run_once(&self, ctx: &LintContext) { + let filename = ctx.file_path().to_string_lossy().replace('\\', "/"); + if !is_next_file_active(ctx) + || !is_in_project_directory(ctx, "app") + || !is_global_error_filename(&filename) + { + return; + } + let mut has_html = false; + let mut has_body = false; + for node in ctx.nodes() { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + continue; + }; + let JSXElementName::Identifier(identifier) = &opening_element.name else { + continue; + }; + match identifier.name.as_str() { + "html" => has_html = true, + "body" => has_body = true, + _ => {} + } + if has_html && has_body { + return; + } + } + let missing_tags = match (has_html, has_body) { + (false, false) => " and ", + (false, true) => "", + (true, false) => "", + (true, true) => return, + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "global-error.tsx is missing {missing_tags}. The root layout unmounts on error, so this page renders broken HTML." + )) + .with_label(program_estree_span(ctx.nodes().program())), + ); + } +} + +fn is_global_error_filename(filename: &str) -> bool { + let Some((path_without_extension, extension)) = filename.rsplit_once('.') else { + return false; + }; + matches!(extension, "ts" | "tsx" | "js" | "jsx" | "mts" | "mjs") + && path_without_extension.ends_with("/global-error") +} diff --git a/native/oxlint/rules/nextjs-image-missing-sizes.rs b/native/oxlint/rules/nextjs-image-missing-sizes.rs new file mode 100644 index 0000000000..a7cddb2d54 --- /dev/null +++ b/native/oxlint/rules/nextjs-image-missing-sizes.rs @@ -0,0 +1,68 @@ +use oxc_ast::{ + ast::{Expression, JSXAttributeItem, JSXAttributeValue, JSXElementName}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const MESSAGE: &str = + "next/image uses fill without sizes, so your users download the largest image."; + +#[derive(Debug, Default, Clone)] +pub struct NextjsImageMissingSizes; + +declare_oxc_lint!( + /// Require sizes when a Next.js Image uses fill. + NextjsImageMissingSizes, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Require sizes on fill images.", +); + +impl Rule for NextjsImageMissingSizes { + fn should_run(&self, ctx: &ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let is_image = match &opening_element.name { + JSXElementName::Identifier(identifier) => identifier.name == "Image", + JSXElementName::IdentifierReference(identifier) => identifier.name == "Image", + _ => false, + }; + if !is_image + || opening_element + .attributes + .iter() + .any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_))) + { + return; + } + let Some(fill_attribute) = find_jsx_attribute(opening_element, "fill") else { + return; + }; + let is_fill_active = match &fill_attribute.value { + None => true, + Some(JSXAttributeValue::ExpressionContainer(container)) => { + !matches!(container.expression.as_expression(), Some(Expression::BooleanLiteral(value)) if !value.value) + } + Some(_) => true, + }; + if is_fill_active + && find_jsx_attribute(opening_element, "sizes").is_none() + && is_next_file_active(ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening_element.span)); + } + } +} diff --git a/native/oxlint/rules/nextjs-inline-script-missing-id.rs b/native/oxlint/rules/nextjs-inline-script-missing-id.rs new file mode 100644 index 0000000000..2ee86eed11 --- /dev/null +++ b/native/oxlint/rules/nextjs-inline-script-missing-id.rs @@ -0,0 +1,51 @@ +use oxc_ast::{ + ast::{JSXAttributeItem, JSXElementName}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::{ContextHost, LintContext}, + rule::Rule, + AstNode, +}; + +const MESSAGE: &str = + "Without an id, Next.js can't track this inline ; +const identifiedInlineNextScript = ; +const externalNextScript = ; +const inaccessibleBody = ; +const spreadOverridesHiddenBody = ; +const hiddenBodyOverridesSpread = ; +const dynamicSpreadHiddenBody = ; +const conflictingDocumentLanguage = ; +const matchingDocumentLanguage = ; +const spreadOverridesDocumentLanguage = ; +const serverSideImageMap = Campus; +const disabledServerSideImageMap = Campus; +const dynamicServerSideImageMap = Campus; +const spreadOverridesServerSideImageMap = Campus; +const mixedSourceSet = ; +const consistentSourceSet = ; +const spreadOwnedMixedSourceSet = ; +const assertiveStatus =
Saved
; +const politeStatus =
Saved
; +const customAssertiveStatus = Saved; +const IntrinsicStatusTag = "div" as const; +const intrinsicAliasAssertiveStatus = Saved; +const AliasedStatusTag = IntrinsicStatusTag; +const aliasedAssertiveStatus = Saved; +const ConditionalStatusTag = isOutput ? "output" : "div"; +const conditionalAssertiveStatus = Saved; +const spreadAssertiveStatus =
Saved
; +const uninformativeButtonLabel =