Skip to main content

nvrim/
linters.rs

1//! Linter parsing helpers.
2
3use nvim_oxi::Dictionary;
4use serde::Deserialize;
5
6use crate::diagnostics::DiagnosticSeverity;
7
8/// [`Dictionary`] of linters parsers.
9pub fn dict() -> Dictionary {
10    dict! {
11        "sqruff": dict! {
12            "parser": fn_from!(parser)
13        },
14    }
15}
16
17/// Parse raw `sqruff` JSON output into Nvim diagnostic [`Dictionary`].
18#[expect(
19    clippy::needless_pass_by_value,
20    reason = "nvim function binding requires owned Lua-converted arguments"
21)]
22fn parser(maybe_output: Option<nvim_oxi::String>) -> Vec<Dictionary> {
23    let Some(output) = &maybe_output else {
24        ytil_noxi::notify::warn(format!("sqruff output missing output={maybe_output:?}"));
25        return vec![];
26    };
27    let output = output.to_string_lossy();
28
29    if output.trim().is_empty() {
30        ytil_noxi::notify::warn(format!("sqruff output is an empty string output={maybe_output:?}"));
31        return vec![];
32    }
33
34    let parsed_output = match serde_json::from_str::<SqruffOutput>(&output) {
35        Ok(parsed_output) => parsed_output,
36        Err(err) => {
37            ytil_noxi::notify::error(format!(
38                "error parsing sqruff output | output={output:?} error={err:#?}"
39            ));
40            return vec![];
41        }
42    };
43
44    parsed_output
45        .messages
46        .into_iter()
47        .map(diagnostic_dict_from_msg)
48        .collect()
49}
50
51/// Parsed `sqruff` top-level output structure.
52#[derive(Debug, Deserialize)]
53#[cfg_attr(test, derive(Eq, PartialEq))]
54struct SqruffOutput {
55    #[serde(rename = "<string>", default)]
56    messages: Vec<SqruffMessage>,
57}
58
59/// Single `sqruff` lint message entry.
60#[derive(Debug, Deserialize)]
61#[cfg_attr(test, derive(Eq, PartialEq))]
62struct SqruffMessage {
63    code: Option<String>,
64    message: String,
65    range: Range,
66    severity: DiagnosticSeverity,
67    source: String,
68}
69
70/// Source span covering the offending text range.
71#[derive(Debug, Deserialize)]
72#[cfg_attr(test, derive(Eq, PartialEq))]
73struct Range {
74    start: Position,
75    end: Position,
76}
77
78/// Line/column pair (1-based as emitted by `sqruff`).
79#[derive(Debug, Deserialize)]
80#[cfg_attr(test, derive(Eq, PartialEq))]
81struct Position {
82    character: u32,
83    line: u32,
84}
85
86/// Convert a single [`SqruffMessage`] into an Nvim [`Dictionary`].
87fn diagnostic_dict_from_msg(msg: SqruffMessage) -> Dictionary {
88    dict! {
89        "lnum": msg.range.start.line.saturating_sub(1),
90        "end_lnum": msg.range.end.line.saturating_sub(1),
91        "col": msg.range.start.character.saturating_sub(1),
92        "end_col": msg.range.end.character.saturating_sub(1),
93        "message": msg.message,
94        "code": msg.code.map_or_else(nvim_oxi::Object::nil, nvim_oxi::Object::from),
95        "source": msg.source,
96        "severity": msg.severity.to_number(),
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use nvim_oxi::Object;
103    use test_that::prelude::*;
104
105    use super::*;
106
107    #[test]
108    fn test_diagnostic_dict_from_msg_returns_the_expected_dict_from_msg() {
109        let msg = SqruffMessage {
110            code: Some("R001".to_string()),
111            message: "Example message".to_string(),
112            range: Range {
113                start: Position { line: 3, character: 7 },
114                end: Position { line: 4, character: 10 },
115            },
116            severity: DiagnosticSeverity::Warn,
117            source: "sqruff".to_string(),
118        };
119
120        let res = diagnostic_dict_from_msg(msg);
121
122        let expected = dict! {
123            "lnum": 2,
124            "end_lnum": 3,
125            "col": 6,
126            "end_col": 9,
127            "message": "Example message".to_string(),
128            "code": Object::from(nvim_oxi::String::from("R001")),
129            "source": "sqruff".to_string(),
130            "severity": DiagnosticSeverity::Warn.to_number(),
131        };
132        assert_that!(res, eq(expected));
133    }
134
135    #[test]
136    fn test_sqruff_output_deserializes_empty_messages() {
137        let value = serde_json::json!({
138            "<string>": []
139        });
140
141        assert_that!(
142            serde_json::from_value::<SqruffOutput>(value),
143            ok(eq(SqruffOutput { messages: vec![] }))
144        );
145    }
146
147    #[test]
148    fn test_sqruff_output_deserializes_single_message_with_code() {
149        let value = serde_json::json!({
150            "<string>": [
151                {
152                    "code": "R001",
153                    "message": "Msg",
154                    "range": {"start": {"line": 2, "character": 5}, "end": {"line": 2, "character": 10}},
155                    "severity": "2",
156                    "source": "sqruff"
157                }
158            ]
159        });
160
161        assert_that!(
162            serde_json::from_value::<SqruffOutput>(value),
163            ok(eq(SqruffOutput {
164                messages: vec![SqruffMessage {
165                    code: Some("R001".into()),
166                    message: "Msg".into(),
167                    range: Range {
168                        start: Position { line: 2, character: 5 },
169                        end: Position { line: 2, character: 10 },
170                    },
171                    severity: DiagnosticSeverity::Warn,
172                    source: "sqruff".into(),
173                }],
174            }))
175        );
176    }
177
178    #[test]
179    fn test_sqruff_output_deserializes_multiple_messages_mixed_code() {
180        let value = serde_json::json!({
181            "<string>": [
182                {
183                    "code": "R001",
184                    "message": "HasCode",
185                    "range": {"start": {"line": 3, "character": 7}, "end": {"line": 3, "character": 12}},
186                    "severity": "2",
187                    "source": "sqruff"
188                },
189                {
190                    "code": null,
191                    "message": "NoCode",
192                    "range": {"start": {"line": 1, "character": 1}, "end": {"line": 1, "character": 2}},
193                    "severity": "1",
194                    "source": "sqruff"
195                }
196            ]
197        });
198
199        assert_that!(
200            serde_json::from_value::<SqruffOutput>(value),
201            ok(eq(SqruffOutput {
202                messages: vec![
203                    SqruffMessage {
204                        code: Some("R001".into()),
205                        message: "HasCode".into(),
206                        range: Range {
207                            start: Position { line: 3, character: 7 },
208                            end: Position { line: 3, character: 12 },
209                        },
210                        severity: DiagnosticSeverity::Warn,
211                        source: "sqruff".into(),
212                    },
213                    SqruffMessage {
214                        code: None,
215                        message: "NoCode".into(),
216                        range: Range {
217                            start: Position { line: 1, character: 1 },
218                            end: Position { line: 1, character: 2 },
219                        },
220                        severity: DiagnosticSeverity::Error,
221                        source: "sqruff".into(),
222                    },
223                ],
224            }))
225        );
226    }
227}