Skip to content

Commit 7598b3e

Browse files
committed
feat(linter): allow configuring tsgolint rules (#15286)
This depends on the next version of `tsgolint` (after 0.6.0) to be released in order to work, otherwise not all `options` will not be accepted. This implements rule configuration for rules that run in tsgolint. This is achieved by serializing the rule configuration as JSON and sending it as part of the payload, which is now supported by tsgolint. This is implemented by adding a new `to_configuration` method, which does the opposite of `from_configuration`. This is only implemented and used for tsgolint rules, so that there's no additional performance cost unless you are using type-aware linting. Unfortunately, this does mean that we sort of have to maintain config parsing in two separate codebases, but I think we can improve this in the future with more tooling. --- Generated rule documentation: ```` ## Configuration This rule accepts a configuration object with the following properties: ### allowForKnownSafeCalls type: `array` default: `[]` Allows specific calls to be ignored, specified as type or value specifiers. #### allowForKnownSafeCalls[n] 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" } ``` ### allowForKnownSafePromises type: `array` default: `[]` Allows specific Promise types to be ignored, specified as type or value specifiers. #### allowForKnownSafePromises[n] 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" } ``` ### checkThenables type: `boolean` default: `false` Check for thenable objects that are not necessarily Promises. ### ignoreIIFE type: `boolean` default: `false` Ignore immediately invoked function expressions (IIFEs). ### ignoreVoid type: `boolean` default: `true` Ignore Promises that are void expressions. ````
1 parent e49d428 commit 7598b3e

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)