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