1use nvim_oxi::Dictionary;
4use serde::Deserialize;
5
6use crate::diagnostics::DiagnosticSeverity;
7
8pub fn dict() -> Dictionary {
10 dict! {
11 "sqruff": dict! {
12 "parser": fn_from!(parser)
13 },
14 }
15}
16
17#[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#[derive(Debug, Deserialize)]
53#[cfg_attr(test, derive(Eq, PartialEq))]
54struct SqruffOutput {
55 #[serde(rename = "<string>", default)]
56 messages: Vec<SqruffMessage>,
57}
58
59#[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#[derive(Debug, Deserialize)]
72#[cfg_attr(test, derive(Eq, PartialEq))]
73struct Range {
74 start: Position,
75 end: Position,
76}
77
78#[derive(Debug, Deserialize)]
80#[cfg_attr(test, derive(Eq, PartialEq))]
81struct Position {
82 character: u32,
83 line: u32,
84}
85
86fn 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}