From e2d28fe9a040b8a184b8dec0e4f352da9fd08b5b Mon Sep 17 00:00:00 2001 From: overlookmotel <557937+overlookmotel@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:51:51 +0000 Subject: [PATCH] feat(linter/plugins): implement suggestions (#18963) Implement support for suggestions. Implementation exactly matches ESLint's. The tests demonstrate that: * `--fix-suggestions` applies suggestions. * `--fix` does *not* apply suggestions. * When a rule provides multiple suggestions, only the 1st is applied. I've also confirmed that suggestions show up correctly in VSCode (once language server supports JS plugins). --- apps/oxlint/src-js/plugins/fix.ts | 72 +++++- apps/oxlint/src-js/plugins/report.ts | 25 +- .../test/fixtures/suggestions/.oxlintrc.json | 9 + .../test/fixtures/suggestions/files/index.js | 15 ++ .../suggestions/fix-suggestions.snap.md | 32 +++ .../test/fixtures/suggestions/fix.snap.md | 114 +++++++++ .../test/fixtures/suggestions/options.json | 4 + .../test/fixtures/suggestions/output.snap.md | 114 +++++++++ .../test/fixtures/suggestions/plugin.ts | 238 ++++++++++++++++++ crates/oxc_linter/src/context/host.rs | 2 +- crates/oxc_linter/src/external_linter.rs | 8 + crates/oxc_linter/src/lib.rs | 45 +++- 12 files changed, 659 insertions(+), 19 deletions(-) create mode 100644 apps/oxlint/test/fixtures/suggestions/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/suggestions/files/index.js create mode 100644 apps/oxlint/test/fixtures/suggestions/fix-suggestions.snap.md create mode 100644 apps/oxlint/test/fixtures/suggestions/fix.snap.md create mode 100644 apps/oxlint/test/fixtures/suggestions/options.json create mode 100644 apps/oxlint/test/fixtures/suggestions/output.snap.md create mode 100644 apps/oxlint/test/fixtures/suggestions/plugin.ts diff --git a/apps/oxlint/src-js/plugins/fix.ts b/apps/oxlint/src-js/plugins/fix.ts index 965fdf3ec4476..6bf8de20c268c 100644 --- a/apps/oxlint/src-js/plugins/fix.ts +++ b/apps/oxlint/src-js/plugins/fix.ts @@ -1,8 +1,9 @@ +import { getMessage } from "./report.ts"; import { typeAssertIs } from "../utils/asserts.ts"; import type { RuleDetails } from "./load.ts"; import type { Range, Ranged } from "./location.ts"; -import type { Diagnostic } from "./report.ts"; +import type { Diagnostic, Suggestion, SuggestionReport } from "./report.ts"; // Type of `fix` function. // `fix` can return a single fix, an array of fixes, or any iterator that yields fixes. @@ -85,6 +86,69 @@ export function getFixes(diagnostic: Diagnostic, ruleDetails: RuleDetails): Fix[ return fixes; } +/** + * Get suggestions from a `Diagnostic`. + * + * Returns `null` if no `suggest` array, or if it produces no suggestions + * (e.g. all fix functions return falsy values). + * + * Throws if rule is not marked with `meta.hasSuggestions` but produces suggestions. + * + * @param diagnostic - Diagnostic object + * @param ruleDetails - `RuleDetails` object, containing rule-specific details + * @returns Non-empty array of `SuggestionReport` objects, or `null` if none + * @throws {Error} If rule is not marked with `meta.hasSuggestions` but produces suggestions + * @throws {TypeError} If a suggestion's `fix` is not a function, or message is invalid + */ +export function getSuggestions( + diagnostic: Diagnostic, + ruleDetails: RuleDetails, +): SuggestionReport[] | null { + if (!Object.hasOwn(diagnostic, "suggest")) return null; + const { suggest } = diagnostic; + if (suggest == null) return null; + + const suggestLen = suggest.length; + if (suggestLen === 0) return null; + + const suggestions: SuggestionReport[] = []; + for (let i = 0; i < suggestLen; i++) { + const suggestion = suggest[i]; + + // Validate fix is a function (matches ESLint) + const { fix } = suggestion; + if (typeof fix !== "function") throw new TypeError("Suggestion without a fix function"); + + // Get suggestion message + let messageId: string | null = null; + if (Object.hasOwn(suggestion, "messageId")) { + (messageId as string | null | undefined) = suggestion.messageId; + if (messageId === undefined) messageId = null; + } + + const message = getMessage( + Object.hasOwn(suggestion, "desc") ? suggestion.desc : null, + messageId, + suggestion, + ruleDetails, + ); + + // Call fix function - drop suggestion if fix function produces no fixes + const fixes = getFixesFromFixFn(fix, suggestion); + if (fixes !== null) suggestions.push({ message, fixes }); + } + + if (suggestions.length === 0) return null; + + // Check rule has suggestions enabled. + // This check is skipped if no suggestions are produced, matching what ESLint does. + if (ruleDetails.hasSuggestions === false) { + throw new Error("Rules with suggestions must set `meta.hasSuggestions` to `true`."); + } + + return suggestions; +} + /** * Call a `FixFn` and process its return value into an array of `Fix` objects. * @@ -110,9 +174,9 @@ export function getFixes(diagnostic: Diagnostic, ruleDetails: RuleDetails): Fix[ * @returns Non-empty array of `Fix` objects, or `null` if none * @throws {Error} If `fixFn` returns any invalid `Fix` objects */ -function getFixesFromFixFn(fixFn: FixFn, thisArg: Diagnostic): Fix[] | null { - // In ESLint, `fix` is called with `this` as a clone of the `diagnostic` object. - // We just use the original `diagnostic` object - that should be close enough. +function getFixesFromFixFn(fixFn: FixFn, thisArg: Diagnostic | Suggestion): Fix[] | null { + // In ESLint, `fix` is called with `this` as a clone of the `Diagnostic` or `Suggestion` object. + // We just use the original object - that should be close enough. let fixes = fixFn.call(thisArg, FIXER); // ESLint ignores falsy values diff --git a/apps/oxlint/src-js/plugins/report.ts b/apps/oxlint/src-js/plugins/report.ts index 474f920a343af..af038e7a1d846 100644 --- a/apps/oxlint/src-js/plugins/report.ts +++ b/apps/oxlint/src-js/plugins/report.ts @@ -3,7 +3,7 @@ */ import { filePath } from "./context.ts"; -import { getFixes } from "./fix.ts"; +import { getFixes, getSuggestions } from "./fix.ts"; import { initLines, lines, lineStartIndices, debugAssertLinesIsInitialized } from "./location.ts"; import { sourceText } from "./source_code.ts"; import { debugAssertIsNonNull, typeAssertIs } from "../utils/asserts.ts"; @@ -34,7 +34,7 @@ interface DiagnosticBase { loc?: LocationWithOptionalEnd | LineColumn; data?: DiagnosticData | null | undefined; fix?: FixFn; - suggest?: Suggestion[]; + suggest?: Suggestion[] | null | undefined; } /** @@ -52,15 +52,22 @@ export type DiagnosticData = Record; interface SuggestionBase { desc?: string; messageId?: string; - fix: FixFn; data?: DiagnosticData | null | undefined; + fix: FixFn; +} + +/** + * Suggested fix in form sent to Rust. + */ +export interface SuggestionReport { + message: string; + fixes: Fix[]; } // Diagnostic in form sent to Rust. @@ -71,6 +78,7 @@ export interface DiagnosticReport { end: number; ruleIndex: number; fixes: Fix[] | null; + suggestions: SuggestionReport[] | null; messageId: string | null; // Only used in conformance tests loc?: LocationWithOptionalEnd | null; @@ -183,6 +191,7 @@ export function report(diagnostic: Diagnostic, ruleDetails: RuleDetails): void { end, ruleIndex: ruleDetails.ruleIndex, fixes: getFixes(diagnostic, ruleDetails), + suggestions: getSuggestions(diagnostic, ruleDetails), }); // We need the original location in conformance tests @@ -190,21 +199,21 @@ export function report(diagnostic: Diagnostic, ruleDetails: RuleDetails): void { } /** - * Get message from a diagnostic. + * Get message from a diagnostic or suggestion. * * Resolve message from `messageId` if present, and interpolate placeholders {{key}} with data values. * * @param message - Provided message string * @param messageId - Provided message ID - * @param descriptor - Diagnostic object + * @param descriptor - Diagnostic or suggestion object * @param ruleDetails - `RuleDetails` object, containing rule-specific `messages` * @returns Message string * @throws {Error|TypeError} If neither `message` nor `messageId` provided, or of wrong type */ -function getMessage( +export function getMessage( message: string | null | undefined, messageId: string | null, - descriptor: Diagnostic, + descriptor: Diagnostic | Suggestion, ruleDetails: RuleDetails, ): string { // Resolve from `messageId` if present, otherwise use `message` diff --git a/apps/oxlint/test/fixtures/suggestions/.oxlintrc.json b/apps/oxlint/test/fixtures/suggestions/.oxlintrc.json new file mode 100644 index 0000000000000..55e5d2dfb2c93 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { + "correctness": "off" + }, + "rules": { + "suggestions-plugin/suggestions": "error" + } +} diff --git a/apps/oxlint/test/fixtures/suggestions/files/index.js b/apps/oxlint/test/fixtures/suggestions/files/index.js new file mode 100644 index 0000000000000..07d7b51b966f3 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/files/index.js @@ -0,0 +1,15 @@ +debugger; + +let a = 1; +let b = 2; +let c = 3; +let d = 4; +let e = 5; +let f = 6; +let g = 7; +let h = 8; +let i = 9; +let j = 10; +let k = 11; + +debugger; diff --git a/apps/oxlint/test/fixtures/suggestions/fix-suggestions.snap.md b/apps/oxlint/test/fixtures/suggestions/fix-suggestions.snap.md new file mode 100644 index 0000000000000..fb2e98a236c82 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/fix-suggestions.snap.md @@ -0,0 +1,32 @@ +# Exit code +0 + +# stdout +``` +Found 0 warnings and 0 errors. +Finished in Xms on 1 file with 1 rules using X threads. +``` + +# stderr +``` +``` + +# File altered: files/index.js +``` + + +let daddy = 1; +let abacus = 2; +let magic = 3; +let damned = 4; +let elephant = 5; +let feck = 6; +let numpty = 7; +let dangermouse = 8; +let granular = 9; +let cowabunga = 10; +let kaboom = 11; + + + +``` diff --git a/apps/oxlint/test/fixtures/suggestions/fix.snap.md b/apps/oxlint/test/fixtures/suggestions/fix.snap.md new file mode 100644 index 0000000000000..fb37c6c56e3b9 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/fix.snap.md @@ -0,0 +1,114 @@ +# Exit code +1 + +# stdout +``` + x suggestions-plugin(suggestions): Remove debugger statement + ,-[files/index.js:1:1] + 1 | debugger; + : ^^^^^^^^^ + 2 | + `---- + + x suggestions-plugin(suggestions): Replace "a" with "daddy" + ,-[files/index.js:3:5] + 2 | + 3 | let a = 1; + : ^ + 4 | let b = 2; + `---- + + x suggestions-plugin(suggestions): Replace "b" with "abacus" + ,-[files/index.js:4:5] + 3 | let a = 1; + 4 | let b = 2; + : ^ + 5 | let c = 3; + `---- + + x suggestions-plugin(suggestions): Prefix "c" with "magi" + ,-[files/index.js:5:5] + 4 | let b = 2; + 5 | let c = 3; + : ^ + 6 | let d = 4; + `---- + + x suggestions-plugin(suggestions): Prefix "d" with "damne" + ,-[files/index.js:6:5] + 5 | let c = 3; + 6 | let d = 4; + : ^ + 7 | let e = 5; + `---- + + x suggestions-plugin(suggestions): Postfix "e" with "lephant" + ,-[files/index.js:7:5] + 6 | let d = 4; + 7 | let e = 5; + : ^ + 8 | let f = 6; + `---- + + x suggestions-plugin(suggestions): Postfix "f" with "eck" + ,-[files/index.js:8:5] + 7 | let e = 5; + 8 | let f = 6; + : ^ + 9 | let g = 7; + `---- + + x suggestions-plugin(suggestions): Replace "g" with "numpty" + ,-[files/index.js:9:5] + 8 | let f = 6; + 9 | let g = 7; + : ^ + 10 | let h = 8; + `---- + + x suggestions-plugin(suggestions): Replace "h" with "dangermouse" + ,-[files/index.js:10:5] + 9 | let g = 7; + 10 | let h = 8; + : ^ + 11 | let i = 9; + `---- + + x suggestions-plugin(suggestions): Replace "i" with "granular" + ,-[files/index.js:11:5] + 10 | let h = 8; + 11 | let i = 9; + : ^ + 12 | let j = 10; + `---- + + x suggestions-plugin(suggestions): Replace "j" with "cowabunga" + ,-[files/index.js:12:5] + 11 | let i = 9; + 12 | let j = 10; + : ^ + 13 | let k = 11; + `---- + + x suggestions-plugin(suggestions): Replace "k" with "kaboom" + ,-[files/index.js:13:5] + 12 | let j = 10; + 13 | let k = 11; + : ^ + 14 | + `---- + + x suggestions-plugin(suggestions): Remove debugger statement + ,-[files/index.js:15:1] + 14 | + 15 | debugger; + : ^^^^^^^^^ + `---- + +Found 0 warnings and 13 errors. +Finished in Xms on 1 file with 1 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/suggestions/options.json b/apps/oxlint/test/fixtures/suggestions/options.json new file mode 100644 index 0000000000000..3a8d4e477980d --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/options.json @@ -0,0 +1,4 @@ +{ + "fix": true, + "fixSuggestions": true +} diff --git a/apps/oxlint/test/fixtures/suggestions/output.snap.md b/apps/oxlint/test/fixtures/suggestions/output.snap.md new file mode 100644 index 0000000000000..fb37c6c56e3b9 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/output.snap.md @@ -0,0 +1,114 @@ +# Exit code +1 + +# stdout +``` + x suggestions-plugin(suggestions): Remove debugger statement + ,-[files/index.js:1:1] + 1 | debugger; + : ^^^^^^^^^ + 2 | + `---- + + x suggestions-plugin(suggestions): Replace "a" with "daddy" + ,-[files/index.js:3:5] + 2 | + 3 | let a = 1; + : ^ + 4 | let b = 2; + `---- + + x suggestions-plugin(suggestions): Replace "b" with "abacus" + ,-[files/index.js:4:5] + 3 | let a = 1; + 4 | let b = 2; + : ^ + 5 | let c = 3; + `---- + + x suggestions-plugin(suggestions): Prefix "c" with "magi" + ,-[files/index.js:5:5] + 4 | let b = 2; + 5 | let c = 3; + : ^ + 6 | let d = 4; + `---- + + x suggestions-plugin(suggestions): Prefix "d" with "damne" + ,-[files/index.js:6:5] + 5 | let c = 3; + 6 | let d = 4; + : ^ + 7 | let e = 5; + `---- + + x suggestions-plugin(suggestions): Postfix "e" with "lephant" + ,-[files/index.js:7:5] + 6 | let d = 4; + 7 | let e = 5; + : ^ + 8 | let f = 6; + `---- + + x suggestions-plugin(suggestions): Postfix "f" with "eck" + ,-[files/index.js:8:5] + 7 | let e = 5; + 8 | let f = 6; + : ^ + 9 | let g = 7; + `---- + + x suggestions-plugin(suggestions): Replace "g" with "numpty" + ,-[files/index.js:9:5] + 8 | let f = 6; + 9 | let g = 7; + : ^ + 10 | let h = 8; + `---- + + x suggestions-plugin(suggestions): Replace "h" with "dangermouse" + ,-[files/index.js:10:5] + 9 | let g = 7; + 10 | let h = 8; + : ^ + 11 | let i = 9; + `---- + + x suggestions-plugin(suggestions): Replace "i" with "granular" + ,-[files/index.js:11:5] + 10 | let h = 8; + 11 | let i = 9; + : ^ + 12 | let j = 10; + `---- + + x suggestions-plugin(suggestions): Replace "j" with "cowabunga" + ,-[files/index.js:12:5] + 11 | let i = 9; + 12 | let j = 10; + : ^ + 13 | let k = 11; + `---- + + x suggestions-plugin(suggestions): Replace "k" with "kaboom" + ,-[files/index.js:13:5] + 12 | let j = 10; + 13 | let k = 11; + : ^ + 14 | + `---- + + x suggestions-plugin(suggestions): Remove debugger statement + ,-[files/index.js:15:1] + 14 | + 15 | debugger; + : ^^^^^^^^^ + `---- + +Found 0 warnings and 13 errors. +Finished in Xms on 1 file with 1 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/suggestions/plugin.ts b/apps/oxlint/test/fixtures/suggestions/plugin.ts new file mode 100644 index 0000000000000..6dec236aaad52 --- /dev/null +++ b/apps/oxlint/test/fixtures/suggestions/plugin.ts @@ -0,0 +1,238 @@ +import type { Node, Plugin, Rule, Suggestion } from "#oxlint/plugins"; + +const rule: Rule = { + meta: { + hasSuggestions: true, + }, + create(context) { + let debuggerCount = 0; + return { + DebuggerStatement(node) { + debuggerCount++; + + let thisIsSuggestion; + + const suggestion: Suggestion = { + desc: "Do it", + fix(fixer) { + thisIsSuggestion = this === suggestion; + if (debuggerCount === 1) return fixer.remove(node); + return fixer.removeRange([node.start, node.end]); + }, + }; + + context.report({ + message: "Remove debugger statement", + node, + suggest: [suggestion], + }); + + if (!thisIsSuggestion) { + context.report({ message: `this in fix function is not suggestion object`, node }); + } + }, + Identifier(node) { + switch (node.name) { + case "a": + return context.report({ + message: 'Replace "a" with "daddy"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.replaceText(node, "daddy"); + }, + }, + ], + }); + + case "b": + return context.report({ + message: 'Replace "b" with "abacus"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.replaceTextRange([node.start, node.end], "abacus"); + }, + }, + ], + }); + + case "c": + return context.report({ + message: 'Prefix "c" with "magi"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.insertTextBefore(node, "magi"); + }, + }, + ], + }); + + case "d": + return context.report({ + message: 'Prefix "d" with "damne"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.insertTextBeforeRange([node.start, node.end], "damne"); + }, + }, + ], + }); + + case "e": + return context.report({ + message: 'Postfix "e" with "lephant"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.insertTextAfter(node, "lephant"); + }, + }, + ], + }); + + case "f": + return context.report({ + message: 'Postfix "f" with "eck"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.insertTextAfterRange([node.start, node.end], "eck"); + }, + }, + ], + }); + + case "g": + return context.report({ + message: 'Replace "g" with "numpty"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + // Fixes can be in any order + return [ + fixer.insertTextAfter(node, "ty"), + // Test that any object with `range` property works + fixer.replaceText({ range: [node.start, node.end] } as Node, "mp"), + fixer.insertTextBefore(node, "nu"), + ]; + }, + }, + ], + }); + + case "h": + return context.report({ + message: 'Replace "h" with "dangermouse"', + node, + suggest: [ + { + desc: "Do it", + fix(fixer) { + // Fixes can be in any order + const { range } = node; + return [ + fixer.replaceTextRange(range, "er"), + fixer.insertTextAfterRange(range, "mouse"), + fixer.insertTextBeforeRange(range, "dang"), + ]; + }, + }, + ], + }); + + case "i": + return context.report({ + message: 'Replace "i" with "granular"', + node, + suggest: [ + { + desc: "Do it", + // `fix` can be a generator function + *fix(fixer) { + yield fixer.insertTextBefore(node, "gra"); + yield fixer.replaceText(node, "nu"); + // Test that any object with `range` property works + yield fixer.insertTextAfter({ range: [node.start, node.end] } as Node, "lar"); + }, + }, + ], + }); + + case "j": + return context.report({ + message: 'Replace "j" with "cowabunga"', + node, + suggest: [ + { + desc: "Do it", + // `fix` can be a generator function + *fix(fixer) { + // Fixes can be in any order + const { range } = node; + yield fixer.insertTextAfterRange(range, "bunga"); + yield fixer.replaceTextRange(range, "a"); + yield fixer.insertTextBeforeRange(range, "cow"); + }, + }, + ], + }); + + case "k": + return context.report({ + message: 'Replace "k" with "kaboom"', + node, + // `--fix-suggestions` will apply only the first suggestion + suggest: [ + { + desc: "Do it", + fix(fixer) { + return fixer.insertTextAfter(node, "aboom"); + }, + }, + { + desc: "Do something else", + fix(fixer) { + return fixer.insertTextBefore(node, "prefix1"); + }, + }, + { + desc: "Do another thing", + fix(fixer) { + return fixer.insertTextBefore(node, "prefix2"); + }, + }, + ], + }); + } + }, + }; + }, +}; + +const plugin: Plugin = { + meta: { + name: "suggestions-plugin", + }, + rules: { + suggestions: rule, + }, +}; + +export default plugin; diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index adf0744a47d1a..23e1a530488ad 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -139,7 +139,7 @@ pub struct ContextHost<'a> { /// /// Set via the `--fix`, `--fix-suggestions`, and `--fix-dangerously` CLI /// flags. - pub(super) fix: FixKind, + pub(crate) fix: FixKind, /// Path to the file being linted. pub(super) file_path: Box, /// Extension of the file being linted. diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index e87d31ab27258..1d1c6e867ee70 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -77,6 +77,7 @@ pub struct LintFileResult { pub start: u32, pub end: u32, pub fixes: Option>, + pub suggestions: Option>, } #[derive(Clone, Debug, Deserialize)] @@ -86,6 +87,13 @@ pub struct JsFix { pub text: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JsSuggestion { + pub message: String, + pub fixes: Vec, +} + #[derive(Clone)] pub struct ExternalLinter { pub(crate) load_plugin: ExternalLinterLoadPluginCb, diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index 2687781636bbc..a4a4abd6dd580 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -640,8 +640,8 @@ impl Linter { continue; } - // Convert `JSFix`s fixes to `PossibleFixes`, including converting spans back to UTF-8 - let fix = if let Some(fixes) = diagnostic.fixes { + // Convert a `Vec` to a `Fix`, including converting spans back to UTF-8 + let create_fix = |fixes: Vec| { debug_assert!(!fixes.is_empty()); // JS should send `None` instead of `Some([])` let is_single = fixes.len() == 1; @@ -656,11 +656,11 @@ impl Linter { }); if is_single { - PossibleFixes::Single(fixes.into_iter().next().unwrap()) + Some(fixes.into_iter().next().unwrap()) } else { let fixes = fixes.collect::>(); match CompositeFix::merge_fixes_fallible(fixes, source_text) { - Ok(fix) => PossibleFixes::Single(fix), + Ok(fix) => Some(fix), Err(err) => { let message = format!( "Plugin `{plugin_name}/{rule_name}` returned invalid fixes.\nFile path: {path}\n{err}" @@ -669,10 +669,43 @@ impl Linter { OxcDiagnostic::error(message), PossibleFixes::None, )); - PossibleFixes::None + None } } } + }; + + // Convert fix + let fix = diagnostic.fixes.and_then(create_fix); + + // Convert suggestions (only if fix kind allows suggestions), and combine with fix + let possible_fixes = if let Some(suggestions) = diagnostic.suggestions + && ctx_host.fix.can_apply(FixKind::Suggestion) + { + let mut fixes = + Vec::with_capacity(usize::from(fix.is_some()) + suggestions.len()); + if let Some(fix) = fix { + fixes.push(fix); + } + + for suggestion in suggestions { + if let Some(fix) = create_fix(suggestion.fixes) { + fixes.push( + fix.with_message(suggestion.message) + .with_kind(FixKind::Suggestion), + ); + } + } + + if fixes.is_empty() { + PossibleFixes::None + } else if fixes.len() == 1 { + PossibleFixes::Single(fixes.into_iter().next().unwrap()) + } else { + PossibleFixes::Multiple(fixes) + } + } else if let Some(fix) = fix { + PossibleFixes::Single(fix) } else { PossibleFixes::None }; @@ -682,7 +715,7 @@ impl Linter { .with_label(span) .with_error_code(plugin_name.to_string(), rule_name.to_string()) .with_severity(severity.into()), - fix, + possible_fixes, )); } }