Skip to main content

jsslint_core/
lsp.rs

1//! Pure projections from `Violation`/`Fix` to LSP-shaped plain data —
2//! mirrors `texlint/lsp/conversions.py`. Deliberately returns plain
3//! structs, not `lsp_types` wire types: this module has no LSP
4//! protocol library dependency, matching `conversions.py`'s own
5//! design ("importable without `pygls`; the actual LSP server ...
6//! lives in a sibling module"). `jsslint-cli`'s protocol-speaking
7//! server converts these into `lsp_types` structs for transmission.
8
9use crate::report::{Fix, Severity, Violation};
10
11pub fn lsp_severity(sev: Severity) -> u8 {
12    match sev {
13        Severity::Error => 1,
14        Severity::Warning => 2,
15        Severity::Info => 3,
16    }
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Position {
21    pub line: u32,
22    pub character: u32,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Range {
27    pub start: Position,
28    pub end: Position,
29}
30
31#[derive(Debug, Clone)]
32pub struct Diagnostic {
33    pub range: Range,
34    pub severity: u8,
35    pub code: String,
36    pub source: String,
37    pub message: String,
38    pub code_description_href: Option<String>,
39}
40
41#[derive(Debug, Clone)]
42pub struct TextEdit {
43    pub range: Range,
44    pub new_text: String,
45}
46
47#[derive(Debug, Clone)]
48pub struct CodeAction {
49    pub title: String,
50    pub kind: String,
51    pub uri: String,
52    pub edit: TextEdit,
53}
54
55/// Extent of the contiguous non-whitespace run starting exactly at
56/// `col` (codepoint index into `text`), or `text`'s length when `col`
57/// is out of bounds or lands on whitespace. Mirrors
58/// `conversions.py`'s `_TOKEN_AT_RE = re.compile(r"\S+")` matched via
59/// `.match(text, col)` (anchored at `col`, `None` on no match).
60fn token_end(chars: &[char], col: usize) -> usize {
61    if col >= chars.len() || chars[col].is_whitespace() {
62        return chars.len();
63    }
64    let mut i = col;
65    while i < chars.len() && !chars[i].is_whitespace() {
66        i += 1;
67    }
68    i
69}
70
71/// Projects a `Violation` to an LSP `Diagnostic`. Mirrors
72/// `conversions.py::violation_to_diagnostic`. With `source`, the range
73/// spans the token at the violation's column (or the whole rstripped
74/// line when the violation carries no column); without it, the range
75/// is zero-width. `character`/`line` here are codepoint-indexed, not
76/// UTF-16-code-unit-indexed as the LSP spec technically requires —
77/// faithfully reproducing Python's own naive-but-consistent choice
78/// (`str` indexing), not "fixing" it.
79pub fn violation_to_diagnostic(
80    v: &Violation,
81    guide_url: Option<&str>,
82    source: Option<&str>,
83) -> Diagnostic {
84    let line = v.line.saturating_sub(1);
85    let mut col = v.column.unwrap_or(1).saturating_sub(1) as usize;
86    let mut end_col = col;
87
88    if let Some(source) = source {
89        let lines: Vec<&str> = source.lines().collect();
90        if (line as usize) < lines.len() {
91            let text: Vec<char> = lines[line as usize].trim_end().chars().collect();
92            if v.column.is_none() {
93                col = 0;
94                end_col = text.len();
95            } else {
96                col = col.min(text.len());
97                end_col = token_end(&text, col);
98            }
99        }
100    }
101
102    Diagnostic {
103        range: Range {
104            start: Position {
105                line,
106                character: col as u32,
107            },
108            end: Position {
109                line,
110                character: end_col as u32,
111            },
112        },
113        severity: lsp_severity(v.severity),
114        code: v.rule_id.clone(),
115        source: "jss-lint".to_string(),
116        message: v.message.clone(),
117        code_description_href: guide_url.map(|s| s.to_string()),
118    }
119}
120
121/// Converts a 0-based codepoint offset into `text` to an LSP
122/// `Position`. Mirrors `conversions.py::_byte_offset_to_lsp_position`
123/// — misnamed on the Python side too (`Fix.start`/`.end` are codepoint
124/// offsets, not byte offsets; see `report::Fix`'s doc comment for the
125/// project-wide correction), faithfully replicated rather than
126/// renamed away from its Python counterpart's quirk.
127fn offset_to_lsp_position(text: &str, offset: usize) -> Position {
128    let chunk: Vec<char> = text.chars().take(offset).collect();
129    let line = chunk.iter().filter(|&&c| c == '\n').count() as u32;
130    let character = match chunk.iter().rposition(|&c| c == '\n') {
131        Some(idx) => (chunk.len() - idx - 1) as u32,
132        None => chunk.len() as u32,
133    };
134    Position { line, character }
135}
136
137/// Mirrors `conversions.py::fix_to_text_edit`.
138pub fn fix_to_text_edit(fix: &Fix, source: &str) -> TextEdit {
139    TextEdit {
140        range: Range {
141            start: offset_to_lsp_position(source, fix.start),
142            end: offset_to_lsp_position(source, fix.end),
143        },
144        new_text: fix.replacement.clone(),
145    }
146}
147
148/// Projects a `Violation` carrying a `Fix` to an LSP `CodeAction`;
149/// `None` when the violation has no fix. Mirrors
150/// `conversions.py::violation_to_code_action`.
151pub fn violation_to_code_action(v: &Violation, source: &str, uri: &str) -> Option<CodeAction> {
152    let fix = v.fix.as_ref()?;
153    Some(CodeAction {
154        title: fix.description.clone(),
155        kind: "quickfix".to_string(),
156        uri: uri.to_string(),
157        edit: fix_to_text_edit(fix, source),
158    })
159}