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
104    use super::*;
105
106    #[test]
107    fn test_diagnostic_dict_from_msg_returns_the_expected_dict_from_msg() {
108        let msg = SqruffMessage {
109            code: Some("R001".to_string()),
110            message: "Example message".to_string(),
111            range: Range {
112                start: Position { line: 3, character: 7 },
113                end: Position { line: 4, character: 10 },
114            },
115            severity: DiagnosticSeverity::Warn,
116            source: "sqruff".to_string(),
117        };
118
119        let res = diagnostic_dict_from_msg(msg);
120
121        let expected = dict! {
122            "lnum": 2,
123            "end_lnum": 3,
124            "col": 6,
125            "end_col": 9,
126            "message": "Example message".to_string(),
127            "code": Object::from(nvim_oxi::String::from("R001")),
128            "source": "sqruff".to_string(),
129            "severity": DiagnosticSeverity::Warn.to_number(),
130        };
131        pretty_assertions::assert_eq!(res, expected);
132    }
133
134    #[test]
135    fn test_sqruff_output_deserializes_empty_messages() {
136        let value = serde_json::json!({
137            "<string>": []
138        });
139
140        assert2::assert!(let Ok(parsed) = serde_json::from_value::<SqruffOutput>(value));
141        pretty_assertions::assert_eq!(parsed, SqruffOutput { messages: vec![] });
142    }
143
144    #[test]
145    fn test_sqruff_output_deserializes_single_message_with_code() {
146        let value = serde_json::json!({
147            "<string>": [
148                {
149                    "code": "R001",
150                    "message": "Msg",
151                    "range": {"start": {"line": 2, "character": 5}, "end": {"line": 2, "character": 10}},
152                    "severity": "2",
153                    "source": "sqruff"
154                }
155            ]
156        });
157
158        assert2::assert!(let Ok(res) = serde_json::from_value::<SqruffOutput>(value));
159        pretty_assertions::assert_eq!(
160            res,
161            SqruffOutput {
162                messages: vec![SqruffMessage {
163                    code: Some("R001".into()),
164                    message: "Msg".into(),
165                    range: Range {
166                        start: Position { line: 2, character: 5 },
167                        end: Position { line: 2, character: 10 },
168                    },
169                    severity: DiagnosticSeverity::Warn,
170                    source: "sqruff".into(),
171                }],
172            }
173        );
174    }
175
176    #[test]
177    fn test_sqruff_output_deserializes_multiple_messages_mixed_code() {
178        let value = serde_json::json!({
179            "<string>": [
180                {
181                    "code": "R001",
182                    "message": "HasCode",
183                    "range": {"start": {"line": 3, "character": 7}, "end": {"line": 3, "character": 12}},
184                    "severity": "2",
185                    "source": "sqruff"
186                },
187                {
188                    "code": null,
189                    "message": "NoCode",
190                    "range": {"start": {"line": 1, "character": 1}, "end": {"line": 1, "character": 2}},
191                    "severity": "1",
192                    "source": "sqruff"
193                }
194            ]
195        });
196
197        assert2::assert!(let Ok(res) = serde_json::from_value::<SqruffOutput>(value));
198        pretty_assertions::assert_eq!(
199            res,
200            SqruffOutput {
201                messages: vec![
202                    SqruffMessage {
203                        code: Some("R001".into()),
204                        message: "HasCode".into(),
205                        range: Range {
206                            start: Position { line: 3, character: 7 },
207                            end: Position { line: 3, character: 12 },
208                        },
209                        severity: DiagnosticSeverity::Warn,
210                        source: "sqruff".into(),
211                    },
212                    SqruffMessage {
213                        code: None,
214                        message: "NoCode".into(),
215                        range: Range {
216                            start: Position { line: 1, character: 1 },
217                            end: Position { line: 1, character: 2 },
218                        },
219                        severity: DiagnosticSeverity::Error,
220                        source: "sqruff".into(),
221                    },
222                ],
223            }
224        );
225    }
226}