1use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Range};
2use sql_dialect_fmt_highlight::{HighlightKind, HighlightToken};
3use sql_dialect_fmt_text::LineIndex;
4
5use crate::{lsp_position, PositionEncoding};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct LintOptions {
10 pub select_wildcard: bool,
12 pub large_in_list: bool,
14 pub unsupported_embedded_language: bool,
16 pub large_in_list_threshold: usize,
18}
19
20impl Default for LintOptions {
21 fn default() -> Self {
22 Self {
23 select_wildcard: true,
24 large_in_list: true,
25 unsupported_embedded_language: true,
26 large_in_list_threshold: 100,
27 }
28 }
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
32pub enum LintCode {
33 SelectWildcard,
34 LargeInList,
35 UnsupportedEmbeddedLanguage,
36}
37
38impl LintCode {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 LintCode::SelectWildcard => "SDF001",
42 LintCode::LargeInList => "SDF002",
43 LintCode::UnsupportedEmbeddedLanguage => "SDF003",
44 }
45 }
46
47 fn from_str(value: &str) -> Option<Self> {
48 match value {
49 "SDF001" => Some(Self::SelectWildcard),
50 "SDF002" => Some(Self::LargeInList),
51 "SDF003" => Some(Self::UnsupportedEmbeddedLanguage),
52 _ => None,
53 }
54 }
55}
56
57pub fn diagnostic_lint_code(diagnostic: &Diagnostic) -> Option<LintCode> {
58 match diagnostic.code.as_ref()? {
59 NumberOrString::String(value) => LintCode::from_str(value),
60 NumberOrString::Number(_) => None,
61 }
62}
63
64pub(crate) fn diagnostics_with_encoding(
65 text: &str,
66 tokens: &[HighlightToken<'_>],
67 index: &LineIndex<'_>,
68 options: LintOptions,
69 encoding: PositionEncoding,
70) -> Vec<Diagnostic> {
71 let mut diagnostics = Vec::new();
72 if SelectWildcardRule::enabled(options) {
73 diagnostics.extend(SelectWildcardRule::diagnostics(
74 tokens, index, options, encoding,
75 ));
76 }
77 if LargeInListRule::enabled(options) {
78 diagnostics.extend(LargeInListRule::diagnostics(
79 tokens, index, options, encoding,
80 ));
81 }
82 if UnsupportedEmbeddedLanguageRule::enabled(options) {
83 diagnostics.extend(UnsupportedEmbeddedLanguageRule::diagnostics(
84 tokens, index, options, encoding,
85 ));
86 }
87 diagnostics
88 .into_iter()
89 .filter(|diagnostic| !is_suppressed(text, diagnostic))
90 .collect()
91}
92
93trait LintRule {
94 const CODE: LintCode;
95
96 fn enabled(options: LintOptions) -> bool;
97
98 fn diagnostics(
99 tokens: &[HighlightToken<'_>],
100 index: &LineIndex<'_>,
101 options: LintOptions,
102 encoding: PositionEncoding,
103 ) -> Vec<Diagnostic>;
104
105 fn warning(
106 index: &LineIndex<'_>,
107 range: std::ops::Range<usize>,
108 message: &str,
109 encoding: PositionEncoding,
110 ) -> Diagnostic {
111 Diagnostic {
112 range: Range::new(
113 lsp_position(index, range.start, encoding),
114 lsp_position(index, range.end, encoding),
115 ),
116 severity: Some(DiagnosticSeverity::WARNING),
117 code: Some(NumberOrString::String(Self::CODE.as_str().to_string())),
118 source: Some("sql-dialect-fmt".to_string()),
119 message: message.to_string(),
120 ..Default::default()
121 }
122 }
123}
124
125struct SelectWildcardRule;
126
127impl LintRule for SelectWildcardRule {
128 const CODE: LintCode = LintCode::SelectWildcard;
129
130 fn enabled(options: LintOptions) -> bool {
131 options.select_wildcard
132 }
133
134 fn diagnostics(
135 tokens: &[HighlightToken<'_>],
136 index: &LineIndex<'_>,
137 _options: LintOptions,
138 encoding: PositionEncoding,
139 ) -> Vec<Diagnostic> {
140 let mut diagnostics = Vec::new();
141 let mut in_select_list = false;
142 let mut paren_depth = 0usize;
143 let mut previous_significant: Option<&str> = None;
144
145 for token in tokens.iter().filter(|token| is_significant(token.kind)) {
146 if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("select") {
147 in_select_list = true;
148 paren_depth = 0;
149 previous_significant = Some(token.text);
150 continue;
151 }
152
153 if in_select_list {
154 match token.text {
155 "(" => paren_depth += 1,
156 ")" => paren_depth = paren_depth.saturating_sub(1),
157 ";" if paren_depth == 0 => in_select_list = false,
158 _ => {
159 if paren_depth == 0
160 && token.kind == HighlightKind::Keyword
161 && token.text.eq_ignore_ascii_case("from")
162 {
163 in_select_list = false;
164 } else if paren_depth == 0
165 && token.text == "*"
166 && previous_significant.is_some_and(is_wildcard_prefix)
167 {
168 diagnostics.push(Self::warning(
169 index,
170 token.range.clone(),
171 "avoid SELECT * in shared SQL; list columns explicitly",
172 encoding,
173 ));
174 }
175 }
176 }
177 }
178
179 previous_significant = Some(token.text);
180 }
181
182 diagnostics
183 }
184}
185
186struct LargeInListRule;
187
188impl LintRule for LargeInListRule {
189 const CODE: LintCode = LintCode::LargeInList;
190
191 fn enabled(options: LintOptions) -> bool {
192 options.large_in_list
193 }
194
195 fn diagnostics(
196 tokens: &[HighlightToken<'_>],
197 index: &LineIndex<'_>,
198 options: LintOptions,
199 encoding: PositionEncoding,
200 ) -> Vec<Diagnostic> {
201 #[derive(Debug)]
202 struct InList {
203 start: usize,
204 depth: usize,
205 commas: usize,
206 saw_top_level_item: bool,
207 possible_subquery: bool,
208 }
209
210 let mut diagnostics = Vec::new();
211 let mut pending_in: Option<usize> = None;
212 let mut list: Option<InList> = None;
213
214 for token in tokens.iter().filter(|token| is_significant(token.kind)) {
215 let mut close_list_at: Option<usize> = None;
216 if let Some(active) = list.as_mut() {
217 match token.text {
218 "(" => active.depth += 1,
219 ")" => {
220 active.depth = active.depth.saturating_sub(1);
221 if active.depth == 0 {
222 close_list_at = Some(token.range.end);
223 }
224 }
225 "," if active.depth == 1 => active.commas += 1,
226 _ if active.depth == 1 && !active.saw_top_level_item => {
227 active.saw_top_level_item = true;
228 if token.kind == HighlightKind::Keyword
229 && (token.text.eq_ignore_ascii_case("select")
230 || token.text.eq_ignore_ascii_case("with"))
231 {
232 active.possible_subquery = true;
233 }
234 }
235 _ => {}
236 }
237
238 if let Some(end) = close_list_at {
239 if let Some(active) = list.take() {
240 let item_count = if active.saw_top_level_item {
241 active.commas + 1
242 } else {
243 0
244 };
245 if !active.possible_subquery && item_count > options.large_in_list_threshold
246 {
247 diagnostics.push(Self::warning(
248 index,
249 active.start..end,
250 "large IN list; prefer a temp table, CTE, or semi-join when practical",
251 encoding,
252 ));
253 }
254 }
255 }
256 continue;
257 }
258
259 if let Some(start) = pending_in.take() {
260 if token.text == "(" {
261 list = Some(InList {
262 start,
263 depth: 1,
264 commas: 0,
265 saw_top_level_item: false,
266 possible_subquery: false,
267 });
268 continue;
269 }
270 }
271
272 if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("in") {
273 pending_in = Some(token.range.start);
274 }
275 }
276
277 diagnostics
278 }
279}
280
281struct UnsupportedEmbeddedLanguageRule;
282
283impl LintRule for UnsupportedEmbeddedLanguageRule {
284 const CODE: LintCode = LintCode::UnsupportedEmbeddedLanguage;
285
286 fn enabled(options: LintOptions) -> bool {
287 options.unsupported_embedded_language
288 }
289
290 fn diagnostics(
291 tokens: &[HighlightToken<'_>],
292 index: &LineIndex<'_>,
293 _options: LintOptions,
294 encoding: PositionEncoding,
295 ) -> Vec<Diagnostic> {
296 let mut diagnostics = Vec::new();
297 let mut expect_language_name = false;
298 let mut language_name: Option<(&str, std::ops::Range<usize>)> = None;
299 let mut saw_as_after_language = false;
300
301 for token in tokens {
302 match token.kind {
303 HighlightKind::Whitespace | HighlightKind::Comment => {}
304 HighlightKind::Punctuation if token.text == ";" => {
305 expect_language_name = false;
306 language_name = None;
307 saw_as_after_language = false;
308 }
309 HighlightKind::DollarString => {
310 if saw_as_after_language {
311 if let Some((word, range)) = language_name.take() {
312 if !is_supported_embedded_language(word) {
313 diagnostics.push(Self::warning(
314 index,
315 range,
316 &format!(
317 "unsupported embedded language {word}; expected SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA"
318 ),
319 encoding,
320 ));
321 }
322 }
323 }
324 expect_language_name = false;
325 saw_as_after_language = false;
326 }
327 HighlightKind::Keyword if token.text.eq_ignore_ascii_case("language") => {
328 expect_language_name = true;
329 language_name = None;
330 saw_as_after_language = false;
331 }
332 HighlightKind::Keyword | HighlightKind::Identifier | HighlightKind::Type
333 if expect_language_name =>
334 {
335 language_name = Some((token.text, token.range.clone()));
336 expect_language_name = false;
337 }
338 HighlightKind::Keyword
339 if language_name.is_some() && token.text.eq_ignore_ascii_case("as") =>
340 {
341 saw_as_after_language = true;
342 }
343 _ => {
344 expect_language_name = false;
345 }
346 }
347 }
348
349 diagnostics
350 }
351}
352
353fn is_suppressed(text: &str, diagnostic: &Diagnostic) -> bool {
354 let Some(code) = diagnostic_lint_code(diagnostic) else {
355 return false;
356 };
357 let line = diagnostic.range.start.line as usize;
358 if line == 0 {
359 return false;
360 }
361 let Some(previous_line) = text.lines().nth(line - 1) else {
362 return false;
363 };
364 line_suppresses_code(previous_line, code)
365}
366
367fn line_suppresses_code(line: &str, code: LintCode) -> bool {
368 let trimmed = line.trim_start();
369 let Some(comment) = trimmed.strip_prefix("--") else {
370 return false;
371 };
372 let Some((_, rest)) = comment.split_once("sql-dialect-fmt: disable-next-line") else {
373 return false;
374 };
375 let rest = rest.trim();
376 if rest.is_empty() {
377 return true;
378 }
379 rest.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ';')
380 .filter(|part| !part.is_empty())
381 .any(|part| {
382 part.eq_ignore_ascii_case(code.as_str())
383 || part.eq_ignore_ascii_case("all")
384 || part.eq_ignore_ascii_case("lint")
385 })
386}
387
388fn is_wildcard_prefix(text: &str) -> bool {
389 matches!(text, "," | ".")
390 || text.eq_ignore_ascii_case("select")
391 || text.eq_ignore_ascii_case("distinct")
392 || text.eq_ignore_ascii_case("all")
393}
394
395fn is_significant(kind: HighlightKind) -> bool {
396 !matches!(kind, HighlightKind::Whitespace | HighlightKind::Comment)
397}
398
399fn is_supported_embedded_language(word: &str) -> bool {
400 ["SQL", "JAVASCRIPT", "PYTHON", "JAVA", "SCALA"]
401 .iter()
402 .any(|candidate| candidate.eq_ignore_ascii_case(word))
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use sql_dialect_fmt_highlight::highlight;
409
410 fn lint_diagnostics(text: &str, options: LintOptions) -> Vec<Diagnostic> {
411 let index = LineIndex::new(text);
412 let highlighted = highlight(text);
413 diagnostics_with_encoding(
414 text,
415 &highlighted.tokens,
416 &index,
417 options,
418 PositionEncoding::Utf16,
419 )
420 }
421
422 #[test]
423 fn disable_next_line_suppresses_specific_lint_code() {
424 let text = "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT * FROM t;";
425 assert!(lint_diagnostics(text, LintOptions::default()).is_empty());
426 }
427
428 #[test]
429 fn disable_next_line_suppresses_all_lint_codes_when_code_is_omitted() {
430 let text = "-- sql-dialect-fmt: disable-next-line\nSELECT * FROM t;";
431 assert!(lint_diagnostics(text, LintOptions::default()).is_empty());
432 }
433
434 #[test]
435 fn disable_next_line_does_not_suppress_other_lines() {
436 let text =
437 "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT a FROM t;\nSELECT * FROM t;";
438 assert_eq!(lint_diagnostics(text, LintOptions::default()).len(), 1);
439 }
440}