Skip to content

Commit 3374b8e

Browse files
committed
refactor(linter/language_server): move all lsp relevant code to oxc_language_server crate (#14430)
1 parent d24b74e commit 3374b8e

File tree

9 files changed

+372
-384
lines changed

9 files changed

+372
-384
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxc_language_server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ oxc_diagnostics = { workspace = true }
2828
oxc_formatter = { workspace = true }
2929
oxc_linter = { workspace = true, features = ["language_server"] }
3030
oxc_parser = { workspace = true }
31+
oxc_span = { workspace = true }
3132

3233
#
3334
env_logger = { workspace = true, features = ["humantime"] }

crates/oxc_language_server/src/linter/error_with_position.rs

Lines changed: 357 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
use std::{borrow::Cow, str::FromStr};
22

3-
use oxc_linter::{
4-
FixWithPosition, MessageWithPosition, PossibleFixesWithPosition, SpanPositionMessage,
5-
};
63
use tower_lsp_server::lsp_types::{
74
self, CodeDescription, DiagnosticRelatedInformation, DiagnosticSeverity, NumberOrString,
85
Position, Range, Uri,
96
};
107

11-
use oxc_diagnostics::Severity;
8+
use oxc_data_structures::rope::{Rope, get_line_column};
9+
use oxc_diagnostics::{OxcCode, OxcDiagnostic, Severity};
10+
use oxc_linter::{Fix, Message, PossibleFixes};
11+
use oxc_span::GetSpan;
1212

1313
#[derive(Debug, Clone, Default)]
1414
pub struct DiagnosticReport {
@@ -171,3 +171,356 @@ pub fn generate_inverted_diagnostics(
171171
}
172172
inverted_diagnostics
173173
}
174+
175+
#[derive(Clone, Debug)]
176+
pub struct SpanPositionMessage<'a> {
177+
/// A brief suggestion message describing the fix. Will be shown in
178+
/// editors via code actions.
179+
message: Option<Cow<'a, str>>,
180+
181+
start: SpanPosition,
182+
end: SpanPosition,
183+
}
184+
185+
impl<'a> SpanPositionMessage<'a> {
186+
pub fn new(start: SpanPosition, end: SpanPosition) -> Self {
187+
Self { start, end, message: None }
188+
}
189+
190+
#[must_use]
191+
pub fn with_message(mut self, message: Option<Cow<'a, str>>) -> Self {
192+
self.message = message;
193+
self
194+
}
195+
196+
pub fn start(&self) -> &SpanPosition {
197+
&self.start
198+
}
199+
200+
pub fn end(&self) -> &SpanPosition {
201+
&self.end
202+
}
203+
204+
pub fn message(&self) -> Option<&Cow<'a, str>> {
205+
self.message.as_ref()
206+
}
207+
}
208+
209+
#[derive(Clone, Debug, Default)]
210+
pub struct SpanPosition {
211+
pub line: u32,
212+
pub character: u32,
213+
}
214+
215+
impl SpanPosition {
216+
pub fn new(line: u32, column: u32) -> Self {
217+
Self { line, character: column }
218+
}
219+
}
220+
221+
pub fn offset_to_position(rope: &Rope, offset: u32, source_text: &str) -> SpanPosition {
222+
let (line, column) = get_line_column(rope, offset, source_text);
223+
SpanPosition::new(line, column)
224+
}
225+
226+
#[derive(Debug)]
227+
pub struct MessageWithPosition<'a> {
228+
pub message: Cow<'a, str>,
229+
pub labels: Option<Vec<SpanPositionMessage<'a>>>,
230+
pub help: Option<Cow<'a, str>>,
231+
pub severity: Severity,
232+
pub code: OxcCode,
233+
pub url: Option<Cow<'a, str>>,
234+
pub fixes: PossibleFixesWithPosition<'a>,
235+
}
236+
237+
// clippy: the source field is checked and assumed to be less than 4GB, and
238+
// we assume that the fix offset will not exceed 2GB in either direction
239+
#[expect(clippy::cast_possible_truncation)]
240+
pub fn oxc_diagnostic_to_message_with_position<'a>(
241+
diagnostic: OxcDiagnostic,
242+
source_text: &str,
243+
rope: &Rope,
244+
) -> MessageWithPosition<'a> {
245+
let inner = diagnostic.inner_owned();
246+
247+
let labels = inner.labels.as_ref().map(|labels| {
248+
labels
249+
.iter()
250+
.map(|labeled_span| {
251+
let offset = labeled_span.offset() as u32;
252+
let start_position = offset_to_position(rope, offset, source_text);
253+
let end_position =
254+
offset_to_position(rope, offset + labeled_span.len() as u32, source_text);
255+
let message = labeled_span.label().map(|label| Cow::Owned(label.to_string()));
256+
257+
SpanPositionMessage::new(start_position, end_position).with_message(message)
258+
})
259+
.collect::<Vec<_>>()
260+
});
261+
262+
MessageWithPosition {
263+
message: inner.message,
264+
severity: inner.severity,
265+
help: inner.help,
266+
url: inner.url,
267+
code: inner.code,
268+
labels,
269+
fixes: PossibleFixesWithPosition::None,
270+
}
271+
}
272+
273+
pub fn message_to_message_with_position<'a>(
274+
message: Message<'a>,
275+
source_text: &str,
276+
rope: &Rope,
277+
) -> MessageWithPosition<'a> {
278+
let code = message.error.code.clone();
279+
let error_offset = message.span().start;
280+
let section_offset = message.section_offset;
281+
282+
let mut result = oxc_diagnostic_to_message_with_position(message.error, source_text, rope);
283+
let fixes = match &message.fixes {
284+
PossibleFixes::None => PossibleFixesWithPosition::None,
285+
PossibleFixes::Single(fix) => {
286+
PossibleFixesWithPosition::Single(fix_to_fix_with_position(fix, rope, source_text))
287+
}
288+
PossibleFixes::Multiple(fixes) => PossibleFixesWithPosition::Multiple(
289+
fixes.iter().map(|fix| fix_to_fix_with_position(fix, rope, source_text)).collect(),
290+
),
291+
};
292+
293+
result.fixes = add_ignore_fixes(fixes, &code, error_offset, section_offset, rope, source_text);
294+
295+
result
296+
}
297+
298+
/// Possible fixes with position information.
299+
///
300+
/// This is similar to `PossibleFixes` but with position information.
301+
/// It also includes "ignore this line" and "ignore this rule" fixes for the Language Server.
302+
///
303+
/// The struct should be build with `message_to_message_with_position`
304+
/// or `oxc_diagnostic_to_message_with_position` function to ensure the ignore fixes are added correctly.
305+
#[derive(Debug)]
306+
pub enum PossibleFixesWithPosition<'a> {
307+
// No possible fixes.
308+
// This happens on parser/semantic errors.
309+
None,
310+
// A single possible fix.
311+
// This happens when a unused disable directive is reported.
312+
Single(FixWithPosition<'a>),
313+
// Multiple possible fixes.
314+
// This happens when a lint reports a violation, then ignore fixes are added.
315+
Multiple(Vec<FixWithPosition<'a>>),
316+
}
317+
318+
#[derive(Debug)]
319+
pub struct FixWithPosition<'a> {
320+
pub content: Cow<'a, str>,
321+
pub span: SpanPositionMessage<'a>,
322+
}
323+
324+
fn fix_to_fix_with_position<'a>(
325+
fix: &Fix<'a>,
326+
rope: &Rope,
327+
source_text: &str,
328+
) -> FixWithPosition<'a> {
329+
let start_position = offset_to_position(rope, fix.span.start, source_text);
330+
let end_position = offset_to_position(rope, fix.span.end, source_text);
331+
FixWithPosition {
332+
content: fix.content.clone(),
333+
span: SpanPositionMessage::new(start_position, end_position)
334+
.with_message(fix.message.as_ref().map(|label| Cow::Owned(label.to_string()))),
335+
}
336+
}
337+
338+
/// Add "ignore this line" and "ignore this rule" fixes to the existing fixes.
339+
/// These fixes will be added to the end of the existing fixes.
340+
/// If the existing fixes already contain an "remove unused disable directive" fix,
341+
/// then no ignore fixes will be added.
342+
fn add_ignore_fixes<'a>(
343+
fixes: PossibleFixesWithPosition<'a>,
344+
code: &OxcCode,
345+
error_offset: u32,
346+
section_offset: u32,
347+
rope: &Rope,
348+
source_text: &str,
349+
) -> PossibleFixesWithPosition<'a> {
350+
// do not append ignore code actions when the error is the ignore action
351+
if matches!(fixes, PossibleFixesWithPosition::Single(ref fix) if fix.span.message.as_ref().is_some_and(|message| message.starts_with("remove unused disable directive")))
352+
{
353+
return fixes;
354+
}
355+
356+
let mut new_fixes: Vec<FixWithPosition<'a>> = vec![];
357+
if let PossibleFixesWithPosition::Single(fix) = fixes {
358+
new_fixes.push(fix);
359+
} else if let PossibleFixesWithPosition::Multiple(existing_fixes) = fixes {
360+
new_fixes.extend(existing_fixes);
361+
}
362+
363+
if let Some(rule_name) = code.number.as_ref() {
364+
// TODO: doesn't support disabling multiple rules by name for a given line.
365+
new_fixes.push(disable_for_this_line(rule_name, error_offset, rope, source_text));
366+
new_fixes.push(disable_for_this_section(rule_name, section_offset, rope, source_text));
367+
}
368+
369+
if new_fixes.is_empty() {
370+
PossibleFixesWithPosition::None
371+
} else if new_fixes.len() == 1 {
372+
PossibleFixesWithPosition::Single(new_fixes.remove(0))
373+
} else {
374+
PossibleFixesWithPosition::Multiple(new_fixes)
375+
}
376+
}
377+
378+
fn disable_for_this_line<'a>(
379+
rule_name: &str,
380+
error_offset: u32,
381+
rope: &Rope,
382+
source_text: &str,
383+
) -> FixWithPosition<'a> {
384+
let mut start_position = offset_to_position(rope, error_offset, source_text);
385+
start_position.character = 0; // TODO: character should be set to match the first non-whitespace character in the source text to match the existing indentation.
386+
let end_position = start_position.clone();
387+
FixWithPosition {
388+
content: Cow::Owned(format!("// oxlint-disable-next-line {rule_name}\n")),
389+
span: SpanPositionMessage::new(start_position, end_position)
390+
.with_message(Some(Cow::Owned(format!("Disable {rule_name} for this line")))),
391+
}
392+
}
393+
394+
fn disable_for_this_section<'a>(
395+
rule_name: &str,
396+
section_offset: u32,
397+
rope: &Rope,
398+
source_text: &str,
399+
) -> FixWithPosition<'a> {
400+
let comment = format!("// oxlint-disable {rule_name}\n");
401+
402+
let (content, offset) = if section_offset == 0 {
403+
// JS files - insert at the beginning
404+
(Cow::Owned(comment), section_offset)
405+
} else {
406+
// Framework files - check for line breaks at section_offset
407+
let bytes = source_text.as_bytes();
408+
let current = bytes.get(section_offset as usize);
409+
let next = bytes.get((section_offset + 1) as usize);
410+
411+
match (current, next) {
412+
(Some(b'\n'), _) => {
413+
// LF at offset, insert after it
414+
(Cow::Owned(comment), section_offset + 1)
415+
}
416+
(Some(b'\r'), Some(b'\n')) => {
417+
// CRLF at offset, insert after both
418+
(Cow::Owned(comment), section_offset + 2)
419+
}
420+
_ => {
421+
// Not at line start, prepend newline
422+
(Cow::Owned("\n".to_owned() + &comment), section_offset)
423+
}
424+
}
425+
};
426+
427+
let position = offset_to_position(rope, offset, source_text);
428+
429+
FixWithPosition {
430+
content,
431+
span: SpanPositionMessage::new(position.clone(), position)
432+
.with_message(Some(Cow::Owned(format!("Disable {rule_name} for this file")))),
433+
}
434+
}
435+
436+
#[cfg(test)]
437+
mod test {
438+
use oxc_data_structures::rope::Rope;
439+
440+
use super::offset_to_position;
441+
442+
#[test]
443+
fn single_line() {
444+
let source = "foo.bar!;";
445+
assert_position(source, 0, (0, 0));
446+
assert_position(source, 4, (0, 4));
447+
assert_position(source, 9, (0, 9));
448+
}
449+
450+
#[test]
451+
fn multi_line() {
452+
let source = "console.log(\n foo.bar!\n);";
453+
assert_position(source, 0, (0, 0));
454+
assert_position(source, 12, (0, 12));
455+
assert_position(source, 13, (1, 0));
456+
assert_position(source, 23, (1, 10));
457+
assert_position(source, 24, (2, 0));
458+
assert_position(source, 26, (2, 2));
459+
}
460+
461+
#[test]
462+
fn multi_byte() {
463+
let source = "let foo = \n '👍';";
464+
assert_position(source, 10, (0, 10));
465+
assert_position(source, 11, (1, 0));
466+
assert_position(source, 14, (1, 3));
467+
assert_position(source, 18, (1, 5));
468+
assert_position(source, 19, (1, 6));
469+
}
470+
471+
#[test]
472+
#[should_panic(expected = "out of bounds")]
473+
fn out_of_bounds() {
474+
offset_to_position(&Rope::from_str("foo"), 100, "foo");
475+
}
476+
477+
#[test]
478+
fn disable_for_section_js_file() {
479+
let source = "console.log('hello');";
480+
let rope = Rope::from_str(source);
481+
let fix = super::disable_for_this_section("no-console", 0, &rope, source);
482+
483+
assert_eq!(fix.content, "// oxlint-disable no-console\n");
484+
assert_eq!(fix.span.start.line, 0);
485+
assert_eq!(fix.span.start.character, 0);
486+
}
487+
488+
#[test]
489+
fn disable_for_section_after_lf() {
490+
let source = "<script>\nconsole.log('hello');";
491+
let rope = Rope::from_str(source);
492+
let fix = super::disable_for_this_section("no-console", 8, &rope, source);
493+
494+
assert_eq!(fix.content, "// oxlint-disable no-console\n");
495+
assert_eq!(fix.span.start.line, 1);
496+
assert_eq!(fix.span.start.character, 0);
497+
}
498+
499+
#[test]
500+
fn disable_for_section_after_crlf() {
501+
let source = "<script>\r\nconsole.log('hello');";
502+
let rope = Rope::from_str(source);
503+
let fix = super::disable_for_this_section("no-console", 8, &rope, source);
504+
505+
assert_eq!(fix.content, "// oxlint-disable no-console\n");
506+
assert_eq!(fix.span.start.line, 1);
507+
assert_eq!(fix.span.start.character, 0);
508+
}
509+
510+
#[test]
511+
fn disable_for_section_mid_line() {
512+
let source = "const x = 5;";
513+
let rope = Rope::from_str(source);
514+
let fix = super::disable_for_this_section("no-unused-vars", 6, &rope, source);
515+
516+
assert_eq!(fix.content, "\n// oxlint-disable no-unused-vars\n");
517+
assert_eq!(fix.span.start.line, 0);
518+
assert_eq!(fix.span.start.character, 6);
519+
}
520+
521+
fn assert_position(source: &str, offset: u32, expected: (u32, u32)) {
522+
let position = offset_to_position(&Rope::from_str(source), offset, source);
523+
assert_eq!(position.line, expected.0);
524+
assert_eq!(position.character, expected.1);
525+
}
526+
}

0 commit comments

Comments
 (0)