mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(linter): preserve source text for JS plugin ignore fixes (#25280)
Closes #25278 ## Overview This PR resolves an oxlint language server crash that could occur when requesting code actions for diagnostics reported by JS plugins, particularly when the diagnostic appears after the beginning of a file. JS plugin diagnostics can now safely provide regular fixes as well as line-level and file-level disable actions. ## Root cause 1. In `run_external_rules()`, the `semantic` is being taken to run the external JS plugin rules, leaving the former `semantic` inside the context host empty: https://github.com/oxc-project/oxc/blob/e23dccf338d568ac0c2be3b1223f213c797923f7/crates/oxc_linter/src/lib.rs#L539 2. The language server enables `with_ignore_fixes` to generate ignore fixes, but inside the `add_ignore_fix()`, it doesn't check the `section_source_text`. 3. The context host with the empty `semantic` passes the empty source text to the `add_ignore_fix()` as `section_source_text`, and the empty string just causes the crash. https://github.com/oxc-project/oxc/blob/e23dccf338d568ac0c2be3b1223f213c797923f7/crates/oxc_linter/src/context/host.rs#L329-L334 ## What changed A new property `source_text` is added to the context sub host, which stores a reference to the real source text string. Even if the `semantic` is taken and becomes empty, the reference can still get the real string and make things work. ```diff pub struct ContextSubHost<'a> { /// Semantic information about the file being linted, which includes scopes, symbols and AST nodes. /// See [`Semantic`]. pub(super) semantic: Semantic<'a>, // ... + /// Stable source text for this script section + /// which remains available even after `semantic` is taken while running JS plugins. + pub(super) source_text: &'a str, /// The source text offset of the sub host pub(super) source_text_offset: u32, } ``` And I added a new utility method to get the `source_text` from the context sub host. ```rs #[inline] pub fn source_text(&self) -> &'a str { self.current_sub_host().source_text() } ``` When passing source text to `add_ignore_fix()`, just use the new `source_text()` method. ```diff if self.with_ignore_fixes { - let source_text = self.semantic().source_text(); + let source_text = self.source_text(); for diagnostic in &mut diagnostics { diagnostic.add_ignore_fix(self.current_sub_host().source_text_offset, source_text); } } // (the same as other occurs ``` Also, I added the necessary check for the `section_source_text` in `add_ignore_fix()`. ## Notes This is my first contribution to oxc project. Thanks for reviewing!
This commit is contained in:
1 parent
9d97da14c8
commit
a92c5410a1
5 files changed
+97
-3
No files matched your search
@@ -30,6 +30,40 @@ debugger;
|
||||
--------------------"
|
||||
`;
|
||||
|
||||
exports[`LSP code actions > basic code actions > should handle js-plugin-fix/non-zero-offset.js 1`] = `
|
||||
"--- FILE -----------
|
||||
js-plugin-fix/non-zero-offset.js
|
||||
--- Original Content ---------
|
||||
const value = 1;
|
||||
debugger;
|
||||
|
||||
--- Code Actions ---------
|
||||
|
||||
Title : Fix this Remove debugger statement problem
|
||||
Preferred: Yes
|
||||
Resulting Content:
|
||||
const value = 1;
|
||||
|
||||
######
|
||||
|
||||
Title : Disable fixes-plugin/fixes for this line
|
||||
Preferred: No
|
||||
Resulting Content:
|
||||
const value = 1;
|
||||
// oxlint-disable-next-line fixes-plugin/fixes
|
||||
debugger;
|
||||
######
|
||||
|
||||
Title : Disable fixes-plugin/fixes for this whole file
|
||||
Preferred: No
|
||||
Resulting Content:
|
||||
// oxlint-disable fixes-plugin/fixes
|
||||
const value = 1;
|
||||
debugger;
|
||||
|
||||
--------------------"
|
||||
`;
|
||||
|
||||
exports[`LSP code actions > basic code actions > should handle js-plugin-fix/test.js 1`] = `
|
||||
"--- FILE -----------
|
||||
js-plugin-fix/test.js
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("LSP code actions", () => {
|
||||
["fix/test.ts", "typescript"],
|
||||
["suggestion/test.ts", "typescript"],
|
||||
["js-plugin-fix/test.js", "javascript"],
|
||||
["js-plugin-fix/non-zero-offset.js", "javascript"],
|
||||
["js-plugin-suggestion/test.js", "javascript"],
|
||||
])("should handle %s", async (path, languageId) => {
|
||||
expect(await fixFixture(FIXTURES_DIR, path, languageId)).toMatchSnapshot();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
const value = 1;
|
||||
debugger;
|
||||
@@ -47,6 +47,9 @@ pub struct ContextSubHost<'a> {
|
||||
/// Parser tokens collected during parsing.
|
||||
/// Empty if parsing failed, or tokens are disabled (no JS plugins).
|
||||
pub(super) parser_tokens: ArenaBox<'a, [Token]>,
|
||||
/// Stable source text for this script section
|
||||
/// which remains available even after `semantic` is taken while running JS plugins.
|
||||
pub(super) source_text: &'a str,
|
||||
/// The source text offset of the sub host
|
||||
pub(super) source_text_offset: u32,
|
||||
}
|
||||
@@ -71,6 +74,8 @@ impl<'a> ContextSubHost<'a> {
|
||||
"`LintContext` depends on `Semantic::cfg`, Build your semantic with cfg enabled(`SemanticBuilder::with_cfg`)."
|
||||
);
|
||||
|
||||
let source_text = semantic.source_text();
|
||||
|
||||
let disable_directives = DisableDirectivesBuilder::new()
|
||||
.with_respect_eslint_disable_directives(options.respect_eslint_disable_directives)
|
||||
.build(semantic.source_text(), semantic.comments());
|
||||
@@ -78,6 +83,7 @@ impl<'a> ContextSubHost<'a> {
|
||||
Self {
|
||||
semantic,
|
||||
module_record,
|
||||
source_text,
|
||||
source_text_offset,
|
||||
disable_directives,
|
||||
framework_options: options.framework_options,
|
||||
@@ -106,6 +112,11 @@ impl<'a> ContextSubHost<'a> {
|
||||
pub fn framework_options(&self) -> FrameworkOptions {
|
||||
self.framework_options
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn source_text(&self) -> &'a str {
|
||||
self.source_text
|
||||
}
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
@@ -313,12 +324,17 @@ impl<'a> ContextHost<'a> {
|
||||
&self.config.env
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn source_text(&self) -> &'a str {
|
||||
self.current_sub_host().source_text()
|
||||
}
|
||||
|
||||
/// Add a diagnostic message to the end of the list of diagnostics. Can be used
|
||||
/// by any rule to report issues.
|
||||
#[inline]
|
||||
pub(crate) fn push_diagnostic(&self, mut diagnostic: Message) {
|
||||
if self.with_ignore_fixes {
|
||||
let source_text = self.semantic().source_text();
|
||||
let source_text = self.source_text();
|
||||
diagnostic.add_ignore_fix(self.current_sub_host().source_text_offset, source_text);
|
||||
}
|
||||
if self.current_sub_host().source_text_offset != 0 {
|
||||
@@ -330,7 +346,7 @@ impl<'a> ContextHost<'a> {
|
||||
// Append a list of diagnostics. Only used in report_unused_directives.
|
||||
fn append_diagnostics(&self, mut diagnostics: Vec<Message>) {
|
||||
if self.with_ignore_fixes {
|
||||
let source_text = self.semantic().source_text();
|
||||
let source_text = self.source_text();
|
||||
for diagnostic in &mut diagnostics {
|
||||
diagnostic.add_ignore_fix(self.current_sub_host().source_text_offset, source_text);
|
||||
}
|
||||
@@ -361,7 +377,7 @@ impl<'a> ContextHost<'a> {
|
||||
// relate to lint result, check after linter run finish
|
||||
let unused_disable_comments = self.disable_directives().collect_unused_disable_comments();
|
||||
let fix_message = "remove unused disable directive";
|
||||
let source_text = self.semantic().source_text();
|
||||
let source_text = self.source_text();
|
||||
|
||||
for unused_disable_comment in unused_disable_comments {
|
||||
let span = unused_disable_comment.span;
|
||||
|
||||
@@ -19,6 +19,17 @@ impl Message {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = self.span.start as usize;
|
||||
let end = self.span.end as usize;
|
||||
|
||||
if start > end
|
||||
|| end > section_source_text.len()
|
||||
|| !section_source_text.is_char_boundary(start)
|
||||
|| !section_source_text.is_char_boundary(end)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(rule_name) = oxc_code_short_canonical_name(&self.error.code) else { return };
|
||||
|
||||
self.fixes.extend_fix(vec![
|
||||
@@ -359,8 +370,38 @@ fn find_description_start_offset(text: &[u8]) -> Option<usize> {
|
||||
#[cfg(test)]
|
||||
#[expect(clippy::cast_possible_truncation)]
|
||||
mod tests {
|
||||
use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_span::Span;
|
||||
|
||||
use crate::{Message, PossibleFixes};
|
||||
|
||||
fn message_with_span(span: Span) -> Message {
|
||||
Message::new(
|
||||
OxcDiagnostic::error("test diagnostic")
|
||||
.with_label(span)
|
||||
.with_error_code("test-plugin", "test-rule"),
|
||||
PossibleFixes::None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_fix_is_not_created_for_out_of_bounds_span() {
|
||||
let mut message = message_with_span(Span::new(1, 2));
|
||||
|
||||
message.add_ignore_fix(0, "");
|
||||
|
||||
assert!(message.fixes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_fix_is_not_created_for_non_utf8_boundary_span() {
|
||||
let mut message = message_with_span(Span::new(1, 2));
|
||||
|
||||
message.add_ignore_fix(0, "\u{e9}");
|
||||
|
||||
assert!(message.fixes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_for_section_js_file() {
|
||||
let source = "console.log('hello');";
|
||||
|
||||
Reference in new issue
Block a user