1use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::linter::visit::visit_expressions;
9use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit};
10use sqlparser::ast::{BinaryOperator, Expr, Spanned, Statement};
11use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14enum PreferredNotEqualStyle {
15 Consistent,
16 CStyle,
17 Ansi,
18}
19
20impl PreferredNotEqualStyle {
21 fn from_config(config: &LintConfig) -> Self {
22 match config
23 .rule_option_str(issue_codes::LINT_CV_001, "preferred_not_equal_style")
24 .unwrap_or("consistent")
25 .to_ascii_lowercase()
26 .as_str()
27 {
28 "c_style" => Self::CStyle,
29 "ansi" => Self::Ansi,
30 _ => Self::Consistent,
31 }
32 }
33
34 fn violation(self, usage: &NotEqualUsage) -> bool {
35 match self {
36 Self::Consistent => usage.saw_angle_style && usage.saw_bang_style,
37 Self::CStyle => usage.saw_angle_style,
38 Self::Ansi => usage.saw_bang_style,
39 }
40 }
41
42 fn message(self) -> &'static str {
43 match self {
44 Self::Consistent => "Use consistent not-equal style.",
45 Self::CStyle => "Use `!=` for not-equal comparisons.",
46 Self::Ansi => "Use `<>` for not-equal comparisons.",
47 }
48 }
49
50 fn target_style(self, occurrences: &[NotEqualOccurrence]) -> Option<NotEqualStyle> {
51 match self {
52 Self::Consistent => occurrences.first().map(|o| o.style),
55 Self::CStyle => Some(NotEqualStyle::Bang),
56 Self::Ansi => Some(NotEqualStyle::Angle),
57 }
58 }
59
60 fn violating_occurrences(self, occurrences: &[NotEqualOccurrence]) -> Vec<NotEqualOccurrence> {
61 let Some(target_style) = self.target_style(occurrences) else {
62 return Vec::new();
63 };
64
65 occurrences
66 .iter()
67 .copied()
68 .filter(|occurrence| occurrence.style != target_style)
69 .collect()
70 }
71}
72
73pub struct ConventionNotEqual {
74 preferred_style: PreferredNotEqualStyle,
75}
76
77impl ConventionNotEqual {
78 pub fn from_config(config: &LintConfig) -> Self {
79 Self {
80 preferred_style: PreferredNotEqualStyle::from_config(config),
81 }
82 }
83}
84
85impl Default for ConventionNotEqual {
86 fn default() -> Self {
87 Self {
88 preferred_style: PreferredNotEqualStyle::Consistent,
89 }
90 }
91}
92
93impl LintRule for ConventionNotEqual {
94 fn code(&self) -> &'static str {
95 issue_codes::LINT_CV_001
96 }
97
98 fn name(&self) -> &'static str {
99 "Not-equal style"
100 }
101
102 fn description(&self) -> &'static str {
103 "Consistent usage of '!=' or '<>' for \"not equal to\" operator."
104 }
105
106 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
107 let tokens =
108 tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
109 let mut occurrences = statement_not_equal_occurrences_with_tokens(
110 statement,
111 ctx.statement_sql(),
112 tokens.as_deref(),
113 );
114 occurrences.sort_by_key(|occurrence| (occurrence.start, occurrence.end));
115 let usage = usage_from_occurrences(&occurrences);
116
117 if self.preferred_style.violation(&usage) {
118 let violating_occurrences = self.preferred_style.violating_occurrences(&occurrences);
119 let mut issue = Issue::info(issue_codes::LINT_CV_001, self.preferred_style.message())
120 .with_statement(ctx.statement_index);
121
122 if let (Some(target_style), Some(first_occurrence)) = (
123 self.preferred_style.target_style(&occurrences),
124 violating_occurrences.first().copied(),
125 ) {
126 let issue_span =
127 ctx.span_from_statement_offset(first_occurrence.start, first_occurrence.end);
128 let mut edits = Vec::new();
129 for occurrence in violating_occurrences {
130 if let Some((first_pos, second_pos)) = occurrence.split_positions {
131 let (first_replacement, second_replacement) =
132 target_style.split_replacements();
133 edits.push(IssuePatchEdit::new(
134 ctx.span_from_statement_offset(first_pos, first_pos + 1),
135 first_replacement,
136 ));
137 edits.push(IssuePatchEdit::new(
138 ctx.span_from_statement_offset(second_pos, second_pos + 1),
139 second_replacement,
140 ));
141 } else {
142 edits.push(IssuePatchEdit::new(
143 ctx.span_from_statement_offset(occurrence.start, occurrence.end),
144 target_style.replacement(),
145 ));
146 }
147 }
148 issue = issue
149 .with_span(issue_span)
150 .with_autofix_edits(IssueAutofixApplicability::Safe, edits);
151 }
152
153 vec![issue]
154 } else {
155 Vec::new()
156 }
157 }
158}
159
160#[derive(Default)]
161struct NotEqualUsage {
162 saw_angle_style: bool,
163 saw_bang_style: bool,
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167enum NotEqualStyle {
168 Angle,
169 Bang,
170}
171
172impl NotEqualStyle {
173 fn replacement(self) -> &'static str {
174 match self {
175 Self::Angle => "<>",
176 Self::Bang => "!=",
177 }
178 }
179
180 fn split_replacements(self) -> (&'static str, &'static str) {
181 match self {
182 Self::Angle => ("<", ">"),
183 Self::Bang => ("!", "="),
184 }
185 }
186}
187
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189struct NotEqualOccurrence {
190 style: NotEqualStyle,
191 start: usize,
192 end: usize,
193 split_positions: Option<(usize, usize)>,
194}
195
196fn statement_not_equal_occurrences_with_tokens(
197 statement: &Statement,
198 sql: &str,
199 tokens: Option<&[LocatedToken]>,
200) -> Vec<NotEqualOccurrence> {
201 let mut occurrences = Vec::new();
202 visit_expressions(statement, &mut |expr| {
203 let occurrence = match expr {
204 Expr::BinaryOp { left, op, right } if *op == BinaryOperator::NotEq => {
205 not_equal_occurrence_between(sql, left.as_ref(), right.as_ref(), tokens)
206 }
207 Expr::AnyOp {
208 left,
209 compare_op,
210 right,
211 ..
212 } if *compare_op == BinaryOperator::NotEq => {
213 not_equal_occurrence_between(sql, left.as_ref(), right.as_ref(), tokens)
214 }
215 Expr::AllOp {
216 left,
217 compare_op,
218 right,
219 } if *compare_op == BinaryOperator::NotEq => {
220 not_equal_occurrence_between(sql, left.as_ref(), right.as_ref(), tokens)
221 }
222 _ => None,
223 };
224
225 if let Some(occurrence) = occurrence {
226 occurrences.push(occurrence);
227 }
228 });
229
230 occurrences.extend(scan_split_not_equal_occurrences(sql));
231 occurrences.sort_by_key(|occurrence| (occurrence.start, occurrence.end));
232 occurrences.dedup_by(|left, right| {
233 left.style == right.style
234 && left.start == right.start
235 && left.end == right.end
236 && left.split_positions == right.split_positions
237 });
238
239 occurrences
240}
241
242fn usage_from_occurrences(occurrences: &[NotEqualOccurrence]) -> NotEqualUsage {
243 let mut usage = NotEqualUsage::default();
244 for occurrence in occurrences {
245 match occurrence.style {
246 NotEqualStyle::Angle => usage.saw_angle_style = true,
247 NotEqualStyle::Bang => usage.saw_bang_style = true,
248 }
249 }
250 usage
251}
252
253fn not_equal_occurrence_between(
254 sql: &str,
255 left: &Expr,
256 right: &Expr,
257 tokens: Option<&[LocatedToken]>,
258) -> Option<NotEqualOccurrence> {
259 let left_end = left.span().end;
260 let right_start = right.span().start;
261 if left_end.line == 0
262 || left_end.column == 0
263 || right_start.line == 0
264 || right_start.column == 0
265 {
266 return None;
267 }
268
269 let start = line_col_to_offset(sql, left_end.line as usize, left_end.column as usize)?;
270 let end = line_col_to_offset(sql, right_start.line as usize, right_start.column as usize)?;
271 if end < start {
272 return None;
273 }
274
275 if let Some(tokens) = tokens {
276 return not_equal_occurrence_in_tokens(sql, tokens, start, end);
277 }
278
279 None
280}
281
282fn not_equal_occurrence_in_tokens(
283 sql: &str,
284 tokens: &[LocatedToken],
285 start: usize,
286 end: usize,
287) -> Option<NotEqualOccurrence> {
288 for token in tokens {
289 if token.end <= start || token.start >= end {
290 continue;
291 }
292 if is_trivia_token(&token.token) {
293 continue;
294 }
295
296 if !matches!(token.token, Token::Neq) {
297 return None;
298 }
299 if token.end > sql.len() {
300 return None;
301 }
302
303 let raw = &sql[token.start..token.end];
304 let style = match raw {
305 "<>" => Some(NotEqualStyle::Angle),
306 "!=" => Some(NotEqualStyle::Bang),
307 _ => None,
308 }?;
309
310 return Some(NotEqualOccurrence {
311 style,
312 start: token.start,
313 end: token.end,
314 split_positions: None,
315 });
316 }
317
318 None
319}
320
321fn scan_split_not_equal_occurrences(sql: &str) -> Vec<NotEqualOccurrence> {
322 let mut occurrences = Vec::new();
323 let bytes = sql.as_bytes();
324 let mut index = 0usize;
325
326 while index < bytes.len() {
327 let (style, expected_second) = match bytes[index] {
328 b'<' => (NotEqualStyle::Angle, b'>'),
329 b'!' => (NotEqualStyle::Bang, b'='),
330 _ => {
331 index += 1;
332 continue;
333 }
334 };
335
336 let mut probe = index + 1;
337 let mut saw_separator = false;
338
339 while probe < bytes.len() {
340 match bytes[probe] {
341 b' ' | b'\t' | b'\n' | b'\r' => {
342 saw_separator = true;
343 probe += 1;
344 }
345 b'-' if probe + 1 < bytes.len() && bytes[probe + 1] == b'-' => {
346 saw_separator = true;
347 probe += 2;
348 while probe < bytes.len() && bytes[probe] != b'\n' {
349 probe += 1;
350 }
351 }
352 b'/' if probe + 1 < bytes.len() && bytes[probe + 1] == b'*' => {
353 saw_separator = true;
354 probe += 2;
355 while probe + 1 < bytes.len() {
356 if bytes[probe] == b'*' && bytes[probe + 1] == b'/' {
357 probe += 2;
358 break;
359 }
360 probe += 1;
361 }
362 }
363 _ => break,
364 }
365 }
366
367 if saw_separator && probe < bytes.len() && bytes[probe] == expected_second {
368 occurrences.push(NotEqualOccurrence {
369 style,
370 start: index,
371 end: probe + 1,
372 split_positions: Some((index, probe)),
373 });
374 index = probe + 1;
375 continue;
376 }
377
378 index += 1;
379 }
380
381 occurrences
382}
383
384#[derive(Clone)]
385struct LocatedToken {
386 token: Token,
387 start: usize,
388 end: usize,
389}
390
391fn tokenized(sql: &str, dialect: crate::types::Dialect) -> Option<Vec<LocatedToken>> {
392 let dialect = dialect.to_sqlparser_dialect();
393 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
394 let tokens = tokenizer.tokenize_with_location().ok()?;
395
396 let mut out = Vec::with_capacity(tokens.len());
397 for token in tokens {
398 let Some((start, end)) = token_with_span_offsets(sql, &token) else {
399 continue;
400 };
401 out.push(LocatedToken {
402 token: token.token,
403 start,
404 end,
405 });
406 }
407 Some(out)
408}
409
410fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
411 let statement_start = ctx.statement_range.start;
412 let from_document = ctx.with_document_tokens(|tokens| {
413 if tokens.is_empty() {
414 return None;
415 }
416
417 Some(
418 tokens
419 .iter()
420 .filter_map(|token| {
421 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
422 if start < ctx.statement_range.start || end > ctx.statement_range.end {
423 return None;
424 }
425
426 Some(LocatedToken {
427 token: token.token.clone(),
428 start: start - statement_start,
429 end: end - statement_start,
430 })
431 })
432 .collect::<Vec<_>>(),
433 )
434 });
435
436 if let Some(tokens) = from_document {
437 return Some(tokens);
438 }
439
440 tokenized(ctx.statement_sql(), ctx.dialect())
441}
442
443fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
444 let start = line_col_to_offset(
445 sql,
446 token.span.start.line as usize,
447 token.span.start.column as usize,
448 )?;
449 let end = line_col_to_offset(
450 sql,
451 token.span.end.line as usize,
452 token.span.end.column as usize,
453 )?;
454 Some((start, end))
455}
456
457fn is_trivia_token(token: &Token) -> bool {
458 matches!(
459 token,
460 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
461 | Token::Whitespace(Whitespace::SingleLineComment { .. })
462 | Token::Whitespace(Whitespace::MultiLineComment(_))
463 )
464}
465
466fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
467 if line == 0 || column == 0 {
468 return None;
469 }
470
471 let mut current_line = 1usize;
472 let mut current_col = 1usize;
473 for (offset, ch) in sql.char_indices() {
474 if current_line == line && current_col == column {
475 return Some(offset);
476 }
477 if ch == '\n' {
478 current_line += 1;
479 current_col = 1;
480 } else {
481 current_col += 1;
482 }
483 }
484
485 if current_line == line && current_col == column {
486 Some(sql.len())
487 } else {
488 None
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::parser::parse_sql;
496 use crate::types::IssueAutofixApplicability;
497
498 fn run(sql: &str) -> Vec<Issue> {
499 let statements = parse_sql(sql).expect("parse");
500 let rule = ConventionNotEqual::default();
501 statements
502 .iter()
503 .enumerate()
504 .flat_map(|(index, statement)| {
505 rule.check(
506 statement,
507 &LintContext {
508 sql,
509 statement_range: 0..sql.len(),
510 statement_index: index,
511 },
512 )
513 })
514 .collect()
515 }
516
517 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
518 let autofix = issue.autofix.as_ref()?;
519 let mut output = sql.to_string();
520 let mut edits = autofix.edits.clone();
521 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
522 for edit in edits.into_iter().rev() {
523 output.replace_range(edit.span.start..edit.span.end, &edit.replacement);
524 }
525 Some(output)
526 }
527
528 #[test]
529 fn flags_mixed_not_equal_styles() {
530 let sql = "SELECT * FROM t WHERE a <> b AND c != d";
533 let issues = run(sql);
534 assert_eq!(issues.len(), 1);
535 assert_eq!(issues[0].code, issue_codes::LINT_CV_001);
536
537 let bang_start = sql.find("!=").expect("bang operator");
538 let issue_span = issues[0].span.expect("issue span");
539 assert_eq!(issue_span.start, bang_start);
540 assert_eq!(issue_span.end, bang_start + 2);
541
542 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
543 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
544 assert_eq!(autofix.edits.len(), 1);
545 assert_eq!(autofix.edits[0].span.start, bang_start);
546 assert_eq!(autofix.edits[0].span.end, bang_start + 2);
547 assert_eq!(autofix.edits[0].replacement, "<>");
548 }
549
550 #[test]
551 fn does_not_flag_single_not_equal_style() {
552 assert!(run("SELECT * FROM t WHERE a <> b").is_empty());
553 assert!(run("SELECT * FROM t WHERE a != b").is_empty());
554 }
555
556 #[test]
557 fn does_not_flag_not_equal_tokens_inside_string_literal() {
558 assert!(run("SELECT 'a <> b and c != d' AS txt FROM t").is_empty());
559 }
560
561 #[test]
562 fn does_not_flag_not_equal_tokens_inside_comments() {
563 assert!(run("SELECT * FROM t -- a <> b and c != d").is_empty());
564 }
565
566 #[test]
567 fn c_style_preference_flags_angle_bracket_operator() {
568 let config = LintConfig {
569 enabled: true,
570 disabled_rules: vec![],
571 rule_configs: std::collections::BTreeMap::from([(
572 "convention.not_equal".to_string(),
573 serde_json::json!({"preferred_not_equal_style": "c_style"}),
574 )]),
575 };
576 let rule = ConventionNotEqual::from_config(&config);
577 let sql = "SELECT * FROM t WHERE a <> b";
578 let statements = parse_sql(sql).expect("parse");
579 let issues = rule.check(
580 &statements[0],
581 &LintContext {
582 sql,
583 statement_range: 0..sql.len(),
584 statement_index: 0,
585 },
586 );
587 assert_eq!(issues.len(), 1);
588 let angle_start = sql.find("<>").expect("angle operator");
589 let issue_span = issues[0].span.expect("issue span");
590 assert_eq!(issue_span.start, angle_start);
591 assert_eq!(issue_span.end, angle_start + 2);
592 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
593 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
594 assert_eq!(autofix.edits.len(), 1);
595 assert_eq!(autofix.edits[0].span.start, angle_start);
596 assert_eq!(autofix.edits[0].span.end, angle_start + 2);
597 assert_eq!(autofix.edits[0].replacement, "!=");
598 }
599
600 #[test]
601 fn c_style_preference_includes_all_angle_operator_edits() {
602 let config = LintConfig {
603 enabled: true,
604 disabled_rules: vec![],
605 rule_configs: std::collections::BTreeMap::from([(
606 "convention.not_equal".to_string(),
607 serde_json::json!({"preferred_not_equal_style": "c_style"}),
608 )]),
609 };
610 let rule = ConventionNotEqual::from_config(&config);
611 let sql = "SELECT * FROM t WHERE a <> b AND c <> d";
612 let statements = parse_sql(sql).expect("parse");
613 let issues = rule.check(
614 &statements[0],
615 &LintContext {
616 sql,
617 statement_range: 0..sql.len(),
618 statement_index: 0,
619 },
620 );
621 assert_eq!(issues.len(), 1);
622
623 let first_start = sql.find("<>").expect("first angle operator");
624 let second_start = sql[first_start + 2..]
625 .find("<>")
626 .map(|offset| first_start + 2 + offset)
627 .expect("second angle operator");
628
629 let issue_span = issues[0].span.expect("issue span");
630 assert_eq!(issue_span.start, first_start);
631 assert_eq!(issue_span.end, first_start + 2);
632
633 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
634 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
635 assert_eq!(autofix.edits.len(), 2);
636 assert_eq!(autofix.edits[0].span.start, first_start);
637 assert_eq!(autofix.edits[0].span.end, first_start + 2);
638 assert_eq!(autofix.edits[0].replacement, "!=");
639 assert_eq!(autofix.edits[1].span.start, second_start);
640 assert_eq!(autofix.edits[1].span.end, second_start + 2);
641 assert_eq!(autofix.edits[1].replacement, "!=");
642 }
643
644 #[test]
645 fn ansi_preference_flags_bang_operator() {
646 let config = LintConfig {
647 enabled: true,
648 disabled_rules: vec![],
649 rule_configs: std::collections::BTreeMap::from([(
650 "LINT_CV_001".to_string(),
651 serde_json::json!({"preferred_not_equal_style": "ansi"}),
652 )]),
653 };
654 let rule = ConventionNotEqual::from_config(&config);
655 let sql = "SELECT * FROM t WHERE a != b";
656 let statements = parse_sql(sql).expect("parse");
657 let issues = rule.check(
658 &statements[0],
659 &LintContext {
660 sql,
661 statement_range: 0..sql.len(),
662 statement_index: 0,
663 },
664 );
665 assert_eq!(issues.len(), 1);
666 let bang_start = sql.find("!=").expect("bang operator");
667 let issue_span = issues[0].span.expect("issue span");
668 assert_eq!(issue_span.start, bang_start);
669 assert_eq!(issue_span.end, bang_start + 2);
670 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
671 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
672 assert_eq!(autofix.edits.len(), 1);
673 assert_eq!(autofix.edits[0].span.start, bang_start);
674 assert_eq!(autofix.edits[0].span.end, bang_start + 2);
675 assert_eq!(autofix.edits[0].replacement, "<>");
676 }
677
678 #[test]
679 fn c_style_preference_fixes_split_angle_operator_around_comment() {
680 let config = LintConfig {
681 enabled: true,
682 disabled_rules: vec![],
683 rule_configs: std::collections::BTreeMap::from([(
684 "convention.not_equal".to_string(),
685 serde_json::json!({"preferred_not_equal_style": "c_style"}),
686 )]),
687 };
688 let rule = ConventionNotEqual::from_config(&config);
689 let sql = "SELECT * FROM X WHERE 1 <\n -- some comment\n> 2\n";
690 let statements = parse_sql("SELECT 1").expect("synthetic parse");
691 let issues = rule.check(
692 &statements[0],
693 &LintContext {
694 sql,
695 statement_range: 0..sql.len(),
696 statement_index: 0,
697 },
698 );
699 assert_eq!(issues.len(), 1);
700 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
701 assert_eq!(autofix.edits.len(), 2);
702 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
703 assert_eq!(
704 fixed,
705 "SELECT * FROM X WHERE 1 !\n -- some comment\n= 2\n"
706 );
707 }
708
709 #[test]
710 fn ansi_preference_fixes_split_bang_operator_around_comment() {
711 let config = LintConfig {
712 enabled: true,
713 disabled_rules: vec![],
714 rule_configs: std::collections::BTreeMap::from([(
715 "convention.not_equal".to_string(),
716 serde_json::json!({"preferred_not_equal_style": "ansi"}),
717 )]),
718 };
719 let rule = ConventionNotEqual::from_config(&config);
720 let sql = "SELECT * FROM X WHERE 1 !\n -- some comment\n= 2\n";
721 let statements = parse_sql("SELECT 1").expect("synthetic parse");
722 let issues = rule.check(
723 &statements[0],
724 &LintContext {
725 sql,
726 statement_range: 0..sql.len(),
727 statement_index: 0,
728 },
729 );
730 assert_eq!(issues.len(), 1);
731 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
732 assert_eq!(autofix.edits.len(), 2);
733 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
734 assert_eq!(
735 fixed,
736 "SELECT * FROM X WHERE 1 <\n -- some comment\n> 2\n"
737 );
738 }
739}