Skip to content

Commit 5d935a9

Browse files
committed
feat(linter): allow configuring tsgolint rules
1 parent 4e844c1 commit 5d935a9

File tree

4 files changed

+342
-5
lines changed

4 files changed

+342
-5
lines changed

crates/oxc_linter/src/rule.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ pub trait Rule: Sized + Default + fmt::Debug {
1919
Self::default()
2020
}
2121

22+
/// Serialize rule configuration to JSON. Only used for sending rule configurations
23+
/// to another linter. This allows oxlint to handle the parsing and error handling.
24+
/// Type-aware rules implemented in tsgolint will need to override this method.
25+
///
26+
/// - Returns `None` if no configuration should be serialized (default)
27+
/// - Returns `Some(Err(_))` if serialization fails
28+
/// - Returns `Some(Ok(_))` if serialization succeeds
29+
fn to_configuration(&self) -> Option<Result<serde_json::Value, serde_json::Error>> {
30+
None
31+
}
32+
2233
#[expect(unused_variables)]
2334
#[cfg(feature = "ruledocs")]
2435
fn schema(generator: &mut SchemaGenerator) -> Option<Schema> {

crates/oxc_linter/src/rules/typescript/no_floating_promises.rs

Lines changed: 237 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,144 @@
11
use oxc_macros::declare_oxc_lint;
2+
use schemars::JsonSchema;
3+
use serde::{Deserialize, Serialize};
24

3-
use crate::rule::Rule;
5+
use crate::rule::{DefaultRuleConfig, Rule};
46

57
#[derive(Debug, Default, Clone)]
6-
pub struct NoFloatingPromises;
8+
pub struct NoFloatingPromises(Box<NoFloatingPromisesConfig>);
9+
10+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11+
#[serde(rename_all = "camelCase", default)]
12+
pub struct NoFloatingPromisesConfig {
13+
/// Allows specific calls to be ignored, specified as type or value specifiers.
14+
pub allow_for_known_safe_calls: Vec<TypeOrValueSpecifier>,
15+
/// Allows specific Promise types to be ignored, specified as type or value specifiers.
16+
pub allow_for_known_safe_promises: Vec<TypeOrValueSpecifier>,
17+
/// Check for thenable objects that are not necessarily Promises.
18+
pub check_thenables: bool,
19+
/// Ignore immediately invoked function expressions (IIFEs).
20+
#[serde(rename = "ignoreIIFE")]
21+
pub ignore_iife: bool,
22+
/// Ignore Promises that are void expressions.
23+
pub ignore_void: bool,
24+
}
25+
26+
/// Type or value specifier for matching specific declarations
27+
///
28+
/// Supports four types of specifiers:
29+
///
30+
/// 1. **String specifier** (deprecated): Universal match by name
31+
/// ```json
32+
/// "Promise"
33+
/// ```
34+
///
35+
/// 2. **File specifier**: Match types/values declared in local files
36+
/// ```json
37+
/// { "from": "file", "name": "MyType" }
38+
/// { "from": "file", "name": ["Type1", "Type2"] }
39+
/// { "from": "file", "name": "MyType", "path": "./types.ts" }
40+
/// ```
41+
///
42+
/// 3. **Lib specifier**: Match TypeScript built-in lib types
43+
/// ```json
44+
/// { "from": "lib", "name": "Promise" }
45+
/// { "from": "lib", "name": ["Promise", "PromiseLike"] }
46+
/// ```
47+
///
48+
/// 4. **Package specifier**: Match types/values from npm packages
49+
/// ```json
50+
/// { "from": "package", "name": "Observable", "package": "rxjs" }
51+
/// { "from": "package", "name": ["Observable", "Subject"], "package": "rxjs" }
52+
/// ```
53+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54+
#[serde(untagged)]
55+
pub enum TypeOrValueSpecifier {
56+
/// Universal string specifier - matches all types and values with this name regardless of declaration source.
57+
/// Not recommended - will be removed in a future major version.
58+
String(String),
59+
/// Describes specific types or values declared in local files.
60+
File(FileSpecifier),
61+
/// Describes specific types or values declared in TypeScript's built-in lib.*.d.ts types.
62+
Lib(LibSpecifier),
63+
/// Describes specific types or values imported from packages.
64+
Package(PackageSpecifier),
65+
}
66+
67+
/// Describes specific types or values declared in local files.
68+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69+
#[serde(rename_all = "camelCase")]
70+
pub struct FileSpecifier {
71+
/// Must be "file"
72+
from: FileFrom,
73+
/// The name(s) of the type or value to match
74+
name: NameSpecifier,
75+
/// Optional file path to specify where the types or values must be declared.
76+
/// If omitted, all files will be matched.
77+
#[serde(skip_serializing_if = "Option::is_none")]
78+
path: Option<String>,
79+
}
80+
81+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82+
#[serde(rename_all = "lowercase")]
83+
enum FileFrom {
84+
File,
85+
}
86+
87+
/// Describes specific types or values declared in TypeScript's built-in lib.*.d.ts types.
88+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89+
#[serde(rename_all = "camelCase")]
90+
pub struct LibSpecifier {
91+
/// Must be "lib"
92+
from: LibFrom,
93+
/// The name(s) of the lib type or value to match
94+
name: NameSpecifier,
95+
}
96+
97+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98+
#[serde(rename_all = "lowercase")]
99+
enum LibFrom {
100+
Lib,
101+
}
102+
103+
/// Describes specific types or values imported from packages.
104+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
105+
#[serde(rename_all = "camelCase")]
106+
pub struct PackageSpecifier {
107+
/// Must be "package"
108+
from: PackageFrom,
109+
/// The name(s) of the type or value to match
110+
name: NameSpecifier,
111+
/// The package name to match
112+
package: String,
113+
}
114+
115+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
116+
#[serde(rename_all = "lowercase")]
117+
enum PackageFrom {
118+
Package,
119+
}
120+
121+
/// Name specifier that can be a single string or array of strings
122+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123+
#[serde(untagged)]
124+
pub enum NameSpecifier {
125+
/// Single name
126+
Single(String),
127+
/// Multiple names
128+
Multiple(Vec<String>),
129+
}
130+
131+
impl Default for NoFloatingPromisesConfig {
132+
fn default() -> Self {
133+
Self {
134+
allow_for_known_safe_calls: Vec::new(),
135+
allow_for_known_safe_promises: Vec::new(),
136+
check_thenables: false,
137+
ignore_iife: false,
138+
ignore_void: true,
139+
}
140+
}
141+
}
7142

8143
declare_oxc_lint!(
9144
/// ### What it does
@@ -74,6 +209,105 @@ declare_oxc_lint!(
74209
typescript,
75210
correctness,
76211
pending,
212+
config = NoFloatingPromisesConfig,
77213
);
78214

79-
impl Rule for NoFloatingPromises {}
215+
impl Rule for NoFloatingPromises {
216+
fn from_configuration(value: serde_json::Value) -> Self {
217+
Self(Box::new(
218+
serde_json::from_value::<DefaultRuleConfig<NoFloatingPromisesConfig>>(value)
219+
.unwrap_or_default()
220+
.into_inner(),
221+
))
222+
}
223+
224+
fn to_configuration(&self) -> Option<Result<serde_json::Value, serde_json::Error>> {
225+
Some(serde_json::to_value(&*self.0))
226+
}
227+
}
228+
229+
#[cfg(test)]
230+
mod tests {
231+
use super::*;
232+
use serde_json::json;
233+
234+
#[test]
235+
fn test_default_config() {
236+
let rule = NoFloatingPromises::default();
237+
let config = rule.to_configuration().unwrap().unwrap();
238+
239+
// Verify the default values
240+
assert_eq!(config["allowForKnownSafeCalls"], json!([]));
241+
assert_eq!(config["allowForKnownSafePromises"], json!([]));
242+
assert_eq!(config["checkThenables"], json!(false));
243+
assert_eq!(config["ignoreIIFE"], json!(false));
244+
assert_eq!(config["ignoreVoid"], json!(true));
245+
}
246+
247+
#[test]
248+
fn test_from_configuration() {
249+
let config_value = json!([{
250+
"allowForKnownSafeCalls": [{"from": "package", "name": "foo", "package": "some-package"}],
251+
"checkThenables": true,
252+
"ignoreVoid": false
253+
}]);
254+
255+
let rule = NoFloatingPromises::from_configuration(config_value);
256+
257+
assert!(rule.0.check_thenables);
258+
assert!(!rule.0.ignore_void);
259+
assert_eq!(rule.0.allow_for_known_safe_calls.len(), 1);
260+
}
261+
262+
#[test]
263+
fn test_round_trip() {
264+
let original_config = json!([{
265+
"allowForKnownSafeCalls": [{"from": "package", "name": "bar", "package": "test-pkg"}],
266+
"allowForKnownSafePromises": [{"from": "lib", "name": "Promise"}],
267+
"checkThenables": true,
268+
"ignoreIIFE": true,
269+
"ignoreVoid": false
270+
}]);
271+
272+
let rule = NoFloatingPromises::from_configuration(original_config);
273+
let serialized = rule.to_configuration().unwrap().unwrap();
274+
275+
// Verify all fields are present in serialized output
276+
assert_eq!(
277+
serialized["allowForKnownSafeCalls"],
278+
json!([{"from": "package", "name": "bar", "package": "test-pkg"}])
279+
);
280+
assert_eq!(
281+
serialized["allowForKnownSafePromises"],
282+
json!([{"from": "lib", "name": "Promise"}])
283+
);
284+
assert_eq!(serialized["checkThenables"], json!(true));
285+
assert_eq!(serialized["ignoreIIFE"], json!(true));
286+
assert_eq!(serialized["ignoreVoid"], json!(false));
287+
}
288+
289+
#[test]
290+
fn test_all_specifier_types() {
291+
let config_value = json!([{
292+
"allowForKnownSafeCalls": [
293+
"SomeType", // string specifier
294+
{"from": "file", "name": "MyType", "path": "./types.ts"}, // file specifier with path
295+
{"from": "file", "name": ["Type1", "Type2"]}, // file specifier with multiple names
296+
{"from": "lib", "name": "Promise"}, // lib specifier
297+
{"from": "package", "name": "Observable", "package": "rxjs"} // package specifier
298+
],
299+
"checkThenables": false,
300+
"ignoreVoid": true
301+
}]);
302+
303+
let rule = NoFloatingPromises::from_configuration(config_value);
304+
305+
assert_eq!(rule.0.allow_for_known_safe_calls.len(), 5);
306+
assert!(!rule.0.check_thenables);
307+
assert!(rule.0.ignore_void);
308+
309+
// Verify serialization preserves all types
310+
let serialized = rule.to_configuration().unwrap().unwrap();
311+
assert_eq!(serialized["allowForKnownSafeCalls"].as_array().unwrap().len(), 5);
312+
}
313+
}

crates/oxc_linter/src/tsgolint.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,12 @@ impl TsGoLintState {
522522
.iter()
523523
.filter_map(|(rule, status)| {
524524
if status.is_warn_deny() && rule.is_tsgolint_rule() {
525-
Some(Rule { name: rule.name().to_string() })
525+
let rule_name = rule.name().to_string();
526+
let options = match rule.to_configuration() {
527+
Some(Ok(config)) => Some(config),
528+
Some(Err(_)) | None => None,
529+
};
530+
Some(Rule { name: rule_name, options })
526531
} else {
527532
None
528533
}
@@ -551,6 +556,7 @@ impl TsGoLintState {
551556
///
552557
/// ```json
553558
/// {
559+
/// "version": 2,
554560
/// "configs": [
555561
/// {
556562
/// "file_paths": ["/absolute/path/to/file.ts", "/another/file.ts"],
@@ -578,9 +584,33 @@ pub struct Config {
578584
pub rules: Vec<Rule>,
579585
}
580586

581-
#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq, PartialOrd, Ord)]
587+
#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]
582588
pub struct Rule {
583589
pub name: String,
590+
#[serde(skip_serializing_if = "Option::is_none")]
591+
pub options: Option<serde_json::Value>,
592+
}
593+
594+
impl PartialOrd for Rule {
595+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
596+
Some(self.cmp(other))
597+
}
598+
}
599+
600+
impl Ord for Rule {
601+
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
602+
// First compare by name
603+
match self.name.cmp(&other.name) {
604+
std::cmp::Ordering::Equal => {
605+
// If names are equal, compare by serialized options
606+
// Serialize to canonical JSON string for comparison
607+
let self_options = self.options.as_ref().map(|v| serde_json::to_string(v).ok());
608+
let other_options = other.options.as_ref().map(|v| serde_json::to_string(v).ok());
609+
self_options.cmp(&other_options)
610+
}
611+
other_ordering => other_ordering,
612+
}
613+
}
584614
}
585615

586616
/// Diagnostic kind discriminator
@@ -1285,4 +1315,60 @@ mod test {
12851315
assert_eq!(payload.fixes.len(), 1);
12861316
assert_eq!(payload.suggestions.len(), 0);
12871317
}
1318+
1319+
#[test]
1320+
fn test_btreeset_preserves_rules_with_different_options() {
1321+
use super::Rule;
1322+
use std::collections::BTreeSet;
1323+
1324+
// Create two rules with the same name but different options
1325+
let rule1 = Rule {
1326+
name: "no-floating-promises".to_string(),
1327+
options: Some(serde_json::json!({"ignoreVoid": true})),
1328+
};
1329+
1330+
let rule2 = Rule {
1331+
name: "no-floating-promises".to_string(),
1332+
options: Some(serde_json::json!({"ignoreVoid": false})),
1333+
};
1334+
1335+
let rule3 = Rule { name: "no-floating-promises".to_string(), options: None };
1336+
1337+
// Insert into BTreeSet
1338+
let mut rules = BTreeSet::new();
1339+
rules.insert(rule1.clone());
1340+
rules.insert(rule2.clone());
1341+
rules.insert(rule3.clone());
1342+
1343+
// All three distinct rules should be preserved
1344+
assert_eq!(rules.len(), 3, "BTreeSet should preserve all rules with different options");
1345+
1346+
// Verify all rules are present
1347+
assert!(rules.contains(&rule1), "Rule with ignoreVoid: true should be present");
1348+
assert!(rules.contains(&rule2), "Rule with ignoreVoid: false should be present");
1349+
assert!(rules.contains(&rule3), "Rule with no options should be present");
1350+
}
1351+
1352+
#[test]
1353+
fn test_btreeset_deduplicates_identical_rules() {
1354+
use super::Rule;
1355+
use std::collections::BTreeSet;
1356+
1357+
let rule1 = Rule {
1358+
name: "no-floating-promises".to_string(),
1359+
options: Some(serde_json::json!({"ignoreVoid": true})),
1360+
};
1361+
1362+
let rule2 = Rule {
1363+
name: "no-floating-promises".to_string(),
1364+
options: Some(serde_json::json!({"ignoreVoid": true})),
1365+
};
1366+
1367+
let mut rules = BTreeSet::new();
1368+
rules.insert(rule1);
1369+
rules.insert(rule2);
1370+
1371+
// Identical rules should be deduplicated
1372+
assert_eq!(rules.len(), 1, "BTreeSet should deduplicate identical rules");
1373+
}
12881374
}

0 commit comments

Comments
 (0)