diff --git a/crates/oxc_linter/src/rule.rs b/crates/oxc_linter/src/rule.rs index e276e67703984..ee5a55b4df55d 100644 --- a/crates/oxc_linter/src/rule.rs +++ b/crates/oxc_linter/src/rule.rs @@ -19,6 +19,17 @@ pub trait Rule: Sized + Default + fmt::Debug { Self::default() } + /// Serialize rule configuration to JSON. Only used for sending rule configurations + /// to another linter. This allows oxlint to handle the parsing and error handling. + /// Type-aware rules implemented in tsgolint will need to override this method. + /// + /// - Returns `None` if no configuration should be serialized (default) + /// - Returns `Some(Err(_))` if serialization fails + /// - Returns `Some(Ok(_))` if serialization succeeds + fn to_configuration(&self) -> Option> { + None + } + #[expect(unused_variables)] #[cfg(feature = "ruledocs")] fn schema(generator: &mut SchemaGenerator) -> Option { diff --git a/crates/oxc_linter/src/rules/typescript/no_floating_promises.rs b/crates/oxc_linter/src/rules/typescript/no_floating_promises.rs index 58196443e389b..c14c3d3e81318 100644 --- a/crates/oxc_linter/src/rules/typescript/no_floating_promises.rs +++ b/crates/oxc_linter/src/rules/typescript/no_floating_promises.rs @@ -1,9 +1,144 @@ use oxc_macros::declare_oxc_lint; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; -use crate::rule::Rule; +use crate::rule::{DefaultRuleConfig, Rule}; #[derive(Debug, Default, Clone)] -pub struct NoFloatingPromises; +pub struct NoFloatingPromises(Box); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", default)] +pub struct NoFloatingPromisesConfig { + /// Allows specific calls to be ignored, specified as type or value specifiers. + pub allow_for_known_safe_calls: Vec, + /// Allows specific Promise types to be ignored, specified as type or value specifiers. + pub allow_for_known_safe_promises: Vec, + /// Check for thenable objects that are not necessarily Promises. + pub check_thenables: bool, + /// Ignore immediately invoked function expressions (IIFEs). + #[serde(rename = "ignoreIIFE")] + pub ignore_iife: bool, + /// Ignore Promises that are void expressions. + pub ignore_void: bool, +} + +/// Type or value specifier for matching specific declarations +/// +/// Supports four types of specifiers: +/// +/// 1. **String specifier** (deprecated): Universal match by name +/// ```json +/// "Promise" +/// ``` +/// +/// 2. **File specifier**: Match types/values declared in local files +/// ```json +/// { "from": "file", "name": "MyType" } +/// { "from": "file", "name": ["Type1", "Type2"] } +/// { "from": "file", "name": "MyType", "path": "./types.ts" } +/// ``` +/// +/// 3. **Lib specifier**: Match TypeScript built-in lib types +/// ```json +/// { "from": "lib", "name": "Promise" } +/// { "from": "lib", "name": ["Promise", "PromiseLike"] } +/// ``` +/// +/// 4. **Package specifier**: Match types/values from npm packages +/// ```json +/// { "from": "package", "name": "Observable", "package": "rxjs" } +/// { "from": "package", "name": ["Observable", "Subject"], "package": "rxjs" } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum TypeOrValueSpecifier { + /// Universal string specifier - matches all types and values with this name regardless of declaration source. + /// Not recommended - will be removed in a future major version. + String(String), + /// Describes specific types or values declared in local files. + File(FileSpecifier), + /// Describes specific types or values declared in TypeScript's built-in lib.*.d.ts types. + Lib(LibSpecifier), + /// Describes specific types or values imported from packages. + Package(PackageSpecifier), +} + +/// Describes specific types or values declared in local files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileSpecifier { + /// Must be "file" + from: FileFrom, + /// The name(s) of the type or value to match + name: NameSpecifier, + /// Optional file path to specify where the types or values must be declared. + /// If omitted, all files will be matched. + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum FileFrom { + File, +} + +/// Describes specific types or values declared in TypeScript's built-in lib.*.d.ts types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LibSpecifier { + /// Must be "lib" + from: LibFrom, + /// The name(s) of the lib type or value to match + name: NameSpecifier, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum LibFrom { + Lib, +} + +/// Describes specific types or values imported from packages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PackageSpecifier { + /// Must be "package" + from: PackageFrom, + /// The name(s) of the type or value to match + name: NameSpecifier, + /// The package name to match + package: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum PackageFrom { + Package, +} + +/// Name specifier that can be a single string or array of strings +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum NameSpecifier { + /// Single name + Single(String), + /// Multiple names + Multiple(Vec), +} + +impl Default for NoFloatingPromisesConfig { + fn default() -> Self { + Self { + allow_for_known_safe_calls: Vec::new(), + allow_for_known_safe_promises: Vec::new(), + check_thenables: false, + ignore_iife: false, + ignore_void: true, + } + } +} declare_oxc_lint!( /// ### What it does @@ -74,6 +209,105 @@ declare_oxc_lint!( typescript, correctness, pending, + config = NoFloatingPromisesConfig, ); -impl Rule for NoFloatingPromises {} +impl Rule for NoFloatingPromises { + fn from_configuration(value: serde_json::Value) -> Self { + Self(Box::new( + serde_json::from_value::>(value) + .unwrap_or_default() + .into_inner(), + )) + } + + fn to_configuration(&self) -> Option> { + Some(serde_json::to_value(&*self.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_default_config() { + let rule = NoFloatingPromises::default(); + let config = rule.to_configuration().unwrap().unwrap(); + + // Verify the default values + assert_eq!(config["allowForKnownSafeCalls"], json!([])); + assert_eq!(config["allowForKnownSafePromises"], json!([])); + assert_eq!(config["checkThenables"], json!(false)); + assert_eq!(config["ignoreIIFE"], json!(false)); + assert_eq!(config["ignoreVoid"], json!(true)); + } + + #[test] + fn test_from_configuration() { + let config_value = json!([{ + "allowForKnownSafeCalls": [{"from": "package", "name": "foo", "package": "some-package"}], + "checkThenables": true, + "ignoreVoid": false + }]); + + let rule = NoFloatingPromises::from_configuration(config_value); + + assert!(rule.0.check_thenables); + assert!(!rule.0.ignore_void); + assert_eq!(rule.0.allow_for_known_safe_calls.len(), 1); + } + + #[test] + fn test_round_trip() { + let original_config = json!([{ + "allowForKnownSafeCalls": [{"from": "package", "name": "bar", "package": "test-pkg"}], + "allowForKnownSafePromises": [{"from": "lib", "name": "Promise"}], + "checkThenables": true, + "ignoreIIFE": true, + "ignoreVoid": false + }]); + + let rule = NoFloatingPromises::from_configuration(original_config); + let serialized = rule.to_configuration().unwrap().unwrap(); + + // Verify all fields are present in serialized output + assert_eq!( + serialized["allowForKnownSafeCalls"], + json!([{"from": "package", "name": "bar", "package": "test-pkg"}]) + ); + assert_eq!( + serialized["allowForKnownSafePromises"], + json!([{"from": "lib", "name": "Promise"}]) + ); + assert_eq!(serialized["checkThenables"], json!(true)); + assert_eq!(serialized["ignoreIIFE"], json!(true)); + assert_eq!(serialized["ignoreVoid"], json!(false)); + } + + #[test] + fn test_all_specifier_types() { + let config_value = json!([{ + "allowForKnownSafeCalls": [ + "SomeType", // string specifier + {"from": "file", "name": "MyType", "path": "./types.ts"}, // file specifier with path + {"from": "file", "name": ["Type1", "Type2"]}, // file specifier with multiple names + {"from": "lib", "name": "Promise"}, // lib specifier + {"from": "package", "name": "Observable", "package": "rxjs"} // package specifier + ], + "checkThenables": false, + "ignoreVoid": true + }]); + + let rule = NoFloatingPromises::from_configuration(config_value); + + assert_eq!(rule.0.allow_for_known_safe_calls.len(), 5); + assert!(!rule.0.check_thenables); + assert!(rule.0.ignore_void); + + // Verify serialization preserves all types + let serialized = rule.to_configuration().unwrap().unwrap(); + assert_eq!(serialized["allowForKnownSafeCalls"].as_array().unwrap().len(), 5); + } +} diff --git a/crates/oxc_linter/src/tsgolint.rs b/crates/oxc_linter/src/tsgolint.rs index 1d34e41528180..a60d090fd9867 100644 --- a/crates/oxc_linter/src/tsgolint.rs +++ b/crates/oxc_linter/src/tsgolint.rs @@ -522,7 +522,12 @@ impl TsGoLintState { .iter() .filter_map(|(rule, status)| { if status.is_warn_deny() && rule.is_tsgolint_rule() { - Some(Rule { name: rule.name().to_string() }) + let rule_name = rule.name().to_string(); + let options = match rule.to_configuration() { + Some(Ok(config)) => Some(config), + Some(Err(_)) | None => None, + }; + Some(Rule { name: rule_name, options }) } else { None } @@ -551,6 +556,7 @@ impl TsGoLintState { /// /// ```json /// { +/// "version": 2, /// "configs": [ /// { /// "file_paths": ["/absolute/path/to/file.ts", "/another/file.ts"], @@ -578,9 +584,33 @@ pub struct Config { pub rules: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq, PartialOrd, Ord)] +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)] pub struct Rule { pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +impl PartialOrd for Rule { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Rule { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // First compare by name + match self.name.cmp(&other.name) { + std::cmp::Ordering::Equal => { + // If names are equal, compare by serialized options + // Serialize to canonical JSON string for comparison + let self_options = self.options.as_ref().map(|v| serde_json::to_string(v).ok()); + let other_options = other.options.as_ref().map(|v| serde_json::to_string(v).ok()); + self_options.cmp(&other_options) + } + other_ordering => other_ordering, + } + } } /// Diagnostic kind discriminator @@ -1285,4 +1315,60 @@ mod test { assert_eq!(payload.fixes.len(), 1); assert_eq!(payload.suggestions.len(), 0); } + + #[test] + fn test_btreeset_preserves_rules_with_different_options() { + use super::Rule; + use std::collections::BTreeSet; + + // Create two rules with the same name but different options + let rule1 = Rule { + name: "no-floating-promises".to_string(), + options: Some(serde_json::json!({"ignoreVoid": true})), + }; + + let rule2 = Rule { + name: "no-floating-promises".to_string(), + options: Some(serde_json::json!({"ignoreVoid": false})), + }; + + let rule3 = Rule { name: "no-floating-promises".to_string(), options: None }; + + // Insert into BTreeSet + let mut rules = BTreeSet::new(); + rules.insert(rule1.clone()); + rules.insert(rule2.clone()); + rules.insert(rule3.clone()); + + // All three distinct rules should be preserved + assert_eq!(rules.len(), 3, "BTreeSet should preserve all rules with different options"); + + // Verify all rules are present + assert!(rules.contains(&rule1), "Rule with ignoreVoid: true should be present"); + assert!(rules.contains(&rule2), "Rule with ignoreVoid: false should be present"); + assert!(rules.contains(&rule3), "Rule with no options should be present"); + } + + #[test] + fn test_btreeset_deduplicates_identical_rules() { + use super::Rule; + use std::collections::BTreeSet; + + let rule1 = Rule { + name: "no-floating-promises".to_string(), + options: Some(serde_json::json!({"ignoreVoid": true})), + }; + + let rule2 = Rule { + name: "no-floating-promises".to_string(), + options: Some(serde_json::json!({"ignoreVoid": true})), + }; + + let mut rules = BTreeSet::new(); + rules.insert(rule1); + rules.insert(rule2); + + // Identical rules should be deduplicated + assert_eq!(rules.len(), 1, "BTreeSet should deduplicate identical rules"); + } } diff --git a/crates/oxc_macros/src/declare_all_lint_rules.rs b/crates/oxc_macros/src/declare_all_lint_rules.rs index 30042e9890d44..64854f02123e1 100644 --- a/crates/oxc_macros/src/declare_all_lint_rules.rs +++ b/crates/oxc_macros/src/declare_all_lint_rules.rs @@ -144,6 +144,12 @@ pub fn declare_all_lint_rules(metadata: AllLintRulesMeta) -> TokenStream { } } + pub fn to_configuration(&self) -> Option> { + match self { + #(Self::#struct_names(rule) => rule.to_configuration()),* + } + } + pub(super) fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { match self { #(Self::#struct_names(rule) => rule.run(node, ctx)),*