nvrim/
linters.rs

1//! Linter parsing helpers.
2//!
3//! - Provides an extensible registry of linter parser functions (see [`dict`]).
4//! - Currently ships a hardened `sqruff` parser exposed under the `sqruff` table.
5//! - Parses tool JSON output into Nvim diagnostic dictionaries.
6//! - Designed so additional linters can be added with minimal boilerplate.
7//!
8//! # Rationale
9//! - Introduced after the upstream `nvim-lint` default `sqruff` parser failed to handle `vim.NIL`, crashing the Nvim
10//!   instance. This implementation treats absence / `nil` values defensively and never propagates a panic.
11//! - Centralizing parsers allows consistent error reporting & future sharing of common normalization logic (e.g.
12//!   severity mapping, range sanitation).
13
14use nvim_oxi::Dictionary;
15use serde::Deserialize;
16
17use crate::diagnostics::DiagnosticSeverity;
18
19/// [`Dictionary`] of linters parsers.
20pub fn dict() -> Dictionary {
21    dict! {
22        "sqruff": dict! {
23            "parser": fn_from!(parser)
24        },
25    }
26}
27
28/// Parse raw `sqruff` JSON output into Nvim diagnostic [`Dictionary`].
29///
30/// # Behavior
31/// - Empty / missing input: returns an empty vector and emits a warning.
32/// - Malformed JSON: returns an empty vector and emits an error notification.
33/// - Successful parse: converts each message into a diagnostic `Dictionary`.
34#[allow(clippy::needless_pass_by_value)]
35fn parser(maybe_output: Option<nvim_oxi::String>) -> Vec<Dictionary> {
36    let Some(output) = &maybe_output else {
37        ytil_noxi::notify::warn(format!("sqruff output missing output={maybe_output:?}"));
38        return vec![];
39    };
40    let output = output.to_string_lossy();
41
42    if output.trim().is_empty() {
43        ytil_noxi::notify::warn(format!("sqruff output is an empty string output={maybe_output:?}"));
44        return vec![];
45    }
46
47    let parsed_output = match serde_json::from_str::<SqruffOutput>(&output) {
48        Ok(parsed_output) => parsed_output,
49        Err(err) => {
50            ytil_noxi::notify::error(format!(
51                "error parsing sqruff output | output={output:?} error={err:#?}"
52            ));
53            return vec![];
54        }
55    };
56
57    parsed_output
58        .messages
59        .into_iter()
60        .map(diagnostic_dict_from_msg)
61        .collect()
62}
63
64/// Parsed `sqruff` top-level output structure.
65///
66/// - Holds a list of lint messages under the JSON key `<string>` produced by `sqruff`.
67///
68/// # Rationale
69/// Mirrors the external tool's JSON so deserialization stays trivial and
70/// downstream logic can iterate messages directly.
71#[derive(Debug, Deserialize)]
72#[cfg_attr(test, derive(Eq, PartialEq))]
73struct SqruffOutput {
74    #[serde(rename = "<string>", default)]
75    messages: Vec<SqruffMessage>,
76}
77
78/// Single `sqruff` lint message entry.
79#[derive(Debug, Deserialize)]
80#[cfg_attr(test, derive(Eq, PartialEq))]
81struct SqruffMessage {
82    /// Optional rule identifier emitted by `sqruff`.
83    code: Option<String>,
84    /// Human-readable lint explanation.
85    message: String,
86    /// 1-based inclusive start / exclusive end span reported by the tool.
87    range: Range,
88    /// Lint severity expressed as [`DiagnosticSeverity`].
89    severity: DiagnosticSeverity,
90    /// Source tool identifier (always `sqruff`).
91    source: String,
92}
93
94/// Source span covering the offending text range.
95///
96/// # Rationale
97/// Explicit start/end structs keep deserialization unambiguous and allow
98/// saturation adjustments when converting to line/column indices expected by
99/// Nvim (0-based internally after adjustment).
100#[derive(Debug, Deserialize)]
101#[cfg_attr(test, derive(Eq, PartialEq))]
102struct Range {
103    /// Start position (1-based line / column as emitted by `sqruff`).
104    start: Position,
105    /// End position (1-based line / column, exclusive column semantics when
106    /// converted to Nvim diagnostics after 0-based adjustment).
107    end: Position,
108}
109
110/// Line/column pair (1-based as emitted by `sqruff`).
111///
112/// # Rationale
113/// Maintains external numbering; translation to 0-based indices occurs when
114/// building Nvim dictionaries.
115#[derive(Debug, Deserialize)]
116#[cfg_attr(test, derive(Eq, PartialEq))]
117struct Position {
118    /// 1-based column number.
119    character: u32,
120    /// 1-based line number.
121    line: u32,
122}
123
124/// Convert a single [`SqruffMessage`] into an Nvim [`Dictionary`].
125fn diagnostic_dict_from_msg(msg: SqruffMessage) -> Dictionary {
126    dict! {
127        "lnum": msg.range.start.line.saturating_sub(1),
128        "end_lnum": msg.range.end.line.saturating_sub(1),
129        "col": msg.range.start.character.saturating_sub(1),
130        "end_col": msg.range.end.character.saturating_sub(1),
131        "message": msg.message,
132        "code": msg.code.map_or_else(nvim_oxi::Object::nil, nvim_oxi::Object::from),
133        "source": msg.source,
134        "severity": msg.severity.to_number(),
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use nvim_oxi::Object;
141
142    use super::*;
143
144    #[test]
145    fn diagnostic_dict_from_msg_returns_the_expected_dict_from_msg() {
146        let msg = SqruffMessage {
147            code: Some("R001".to_string()),
148            message: "Example message".to_string(),
149            range: Range {
150                start: Position { line: 3, character: 7 },
151                end: Position { line: 4, character: 10 },
152            },
153            severity: DiagnosticSeverity::Warn,
154            source: "sqruff".to_string(),
155        };
156
157        let res = diagnostic_dict_from_msg(msg);
158
159        let expected = dict! {
160            "lnum": 2,
161            "end_lnum": 3,
162            "col": 6,
163            "end_col": 9,
164            "message": "Example message".to_string(),
165            "code": Object::from(nvim_oxi::String::from("R001")),
166            "source": "sqruff".to_string(),
167            "severity": DiagnosticSeverity::Warn.to_number(),
168        };
169        pretty_assertions::assert_eq!(res, expected);
170    }
171
172    #[test]
173    fn sqruff_output_deserializes_empty_messages() {
174        let value = serde_json::json!({
175            "<string>": []
176        });
177
178        assert2::let_assert!(Ok(parsed) = serde_json::from_value::<SqruffOutput>(value));
179        pretty_assertions::assert_eq!(parsed, SqruffOutput { messages: vec![] });
180    }
181
182    #[test]
183    fn sqruff_output_deserializes_single_message_with_code() {
184        let value = serde_json::json!({
185            "<string>": [
186                {
187                    "code": "R001",
188                    "message": "Msg",
189                    "range": {"start": {"line": 2, "character": 5}, "end": {"line": 2, "character": 10}},
190                    "severity": "2",
191                    "source": "sqruff"
192                }
193            ]
194        });
195
196        assert2::let_assert!(Ok(res) = serde_json::from_value::<SqruffOutput>(value));
197        pretty_assertions::assert_eq!(
198            res,
199            SqruffOutput {
200                messages: vec![SqruffMessage {
201                    code: Some("R001".into()),
202                    message: "Msg".into(),
203                    range: Range {
204                        start: Position { line: 2, character: 5 },
205                        end: Position { line: 2, character: 10 },
206                    },
207                    severity: DiagnosticSeverity::Warn,
208                    source: "sqruff".into(),
209                }],
210            }
211        );
212    }
213
214    #[test]
215    fn sqruff_output_deserializes_multiple_messages_mixed_code() {
216        let value = serde_json::json!({
217            "<string>": [
218                {
219                    "code": "R001",
220                    "message": "HasCode",
221                    "range": {"start": {"line": 3, "character": 7}, "end": {"line": 3, "character": 12}},
222                    "severity": "2",
223                    "source": "sqruff"
224                },
225                {
226                    "code": null,
227                    "message": "NoCode",
228                    "range": {"start": {"line": 1, "character": 1}, "end": {"line": 1, "character": 2}},
229                    "severity": "1",
230                    "source": "sqruff"
231                }
232            ]
233        });
234
235        assert2::let_assert!(Ok(res) = serde_json::from_value::<SqruffOutput>(value));
236        pretty_assertions::assert_eq!(
237            res,
238            SqruffOutput {
239                messages: vec![
240                    SqruffMessage {
241                        code: Some("R001".into()),
242                        message: "HasCode".into(),
243                        range: Range {
244                            start: Position { line: 3, character: 7 },
245                            end: Position { line: 3, character: 12 },
246                        },
247                        severity: DiagnosticSeverity::Warn,
248                        source: "sqruff".into(),
249                    },
250                    SqruffMessage {
251                        code: None,
252                        message: "NoCode".into(),
253                        range: Range {
254                            start: Position { line: 1, character: 1 },
255                            end: Position { line: 1, character: 2 },
256                        },
257                        severity: DiagnosticSeverity::Error,
258                        source: "sqruff".into(),
259                    },
260                ],
261            }
262        );
263    }
264}