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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/oxc_linter/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<serde_json::Value, serde_json::Error>> {
None
}

#[expect(unused_variables)]
#[cfg(feature = "ruledocs")]
fn schema(generator: &mut SchemaGenerator) -> Option<Schema> {
Expand Down
240 changes: 237 additions & 3 deletions crates/oxc_linter/src/rules/typescript/no_floating_promises.rs
Original file line number Diff line number Diff line change
@@ -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<NoFloatingPromisesConfig>);

#[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<TypeOrValueSpecifier>,
/// Allows specific Promise types to be ignored, specified as type or value specifiers.
pub allow_for_known_safe_promises: Vec<TypeOrValueSpecifier>,
/// 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<String>,
}

#[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<String>),
}

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
Expand Down Expand Up @@ -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::<DefaultRuleConfig<NoFloatingPromisesConfig>>(value)
.unwrap_or_default()
.into_inner(),
))
}

fn to_configuration(&self) -> Option<Result<serde_json::Value, serde_json::Error>> {
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);
}
}
90 changes: 88 additions & 2 deletions crates/oxc_linter/src/tsgolint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -551,6 +556,7 @@ impl TsGoLintState {
///
/// ```json
/// {
/// "version": 2,
/// "configs": [
/// {
/// "file_paths": ["/absolute/path/to/file.ts", "/another/file.ts"],
Expand Down Expand Up @@ -578,9 +584,33 @@ pub struct Config {
pub rules: Vec<Rule>,
}

#[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<serde_json::Value>,
}

impl PartialOrd for Rule {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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
Expand Down Expand Up @@ -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");
}
}
Loading
Loading