Skip to main content

rigsql_rules/references/
rf06.rs

1use rigsql_core::{Segment, SegmentType};
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6/// RF06: Unnecessary quoting of identifiers.
7///
8/// If a quoted identifier (e.g., `"my_col"`) contains only alphanumeric
9/// characters and underscores, and starts with a letter or underscore,
10/// it could be written as a bare identifier. The quotes are unnecessary.
11#[derive(Debug, Default)]
12pub struct RuleRF06;
13
14impl Rule for RuleRF06 {
15    fn code(&self) -> &'static str {
16        "RF06"
17    }
18    fn name(&self) -> &'static str {
19        "references.quoting"
20    }
21    fn description(&self) -> &'static str {
22        "Unnecessary quoting of identifiers."
23    }
24    fn explanation(&self) -> &'static str {
25        "Quoted identifiers that contain only alphanumeric characters, underscores, \
26         and start with a letter or underscore do not need to be quoted. Removing \
27         unnecessary quotes improves readability. Quoting should be reserved for \
28         identifiers that genuinely require it (e.g., reserved words, spaces, special characters)."
29    }
30    fn groups(&self) -> &[RuleGroup] {
31        &[RuleGroup::References]
32    }
33    fn is_fixable(&self) -> bool {
34        true
35    }
36
37    fn crawl_type(&self) -> CrawlType {
38        CrawlType::Segment(vec![SegmentType::QuotedIdentifier])
39    }
40
41    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
42        let Segment::Token(t) = ctx.segment else {
43            return vec![];
44        };
45
46        let text = &t.token.text;
47
48        // Strip surrounding quotes (double quotes, backticks, or brackets)
49        let inner = strip_quotes(text);
50        let Some(inner) = inner else {
51            return vec![];
52        };
53
54        if inner.is_empty() {
55            return vec![];
56        }
57
58        // Check if the inner text could be a bare identifier:
59        // starts with letter or underscore, and only contains alphanumeric + underscore
60        let first = inner.chars().next().unwrap();
61        if !(first.is_ascii_alphabetic() || first == '_') {
62            return vec![];
63        }
64
65        let is_simple = inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
66
67        if is_simple {
68            vec![LintViolation::with_fix_and_msg_key(
69                self.code(),
70                format!("Identifier '{}' does not need quoting.", text),
71                t.token.span,
72                vec![SourceEdit::replace(t.token.span, inner.to_string())],
73                "rules.RF06.msg",
74                vec![("name".to_string(), text.to_string())],
75            )]
76        } else {
77            vec![]
78        }
79    }
80}
81
82fn strip_quotes(text: &str) -> Option<&str> {
83    if text.len() < 2 {
84        return None;
85    }
86    let bytes = text.as_bytes();
87    match (bytes[0], bytes[bytes.len() - 1]) {
88        (b'"', b'"') | (b'`', b'`') => Some(&text[1..text.len() - 1]),
89        (b'[', b']') => Some(&text[1..text.len() - 1]),
90        _ => None,
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::test_utils::lint_sql;
98
99    #[test]
100    fn test_rf06_flags_unnecessary_quoting() {
101        let violations = lint_sql("SELECT \"my_col\" FROM t", RuleRF06);
102        assert!(
103            !violations.is_empty(),
104            "Should flag unnecessarily quoted identifier"
105        );
106        assert!(violations[0].message.contains("my_col"));
107        assert!(!violations[0].fixes.is_empty(), "Should provide a fix");
108    }
109
110    #[test]
111    fn test_rf06_accepts_necessary_quoting() {
112        let violations = lint_sql("SELECT \"my-col\" FROM t", RuleRF06);
113        assert_eq!(violations.len(), 0);
114    }
115
116    #[test]
117    fn test_rf06_accepts_bare_identifiers() {
118        let violations = lint_sql("SELECT my_col FROM t", RuleRF06);
119        assert_eq!(violations.len(), 0);
120    }
121}