sqruff_lib/rules/capitalisation/
cp01.rs1use hashbrown::{HashMap, HashSet};
2use itertools::Itertools;
3use regex::Regex;
4use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
5use sqruff_lib_core::helpers::capitalize;
6use sqruff_lib_core::lint_fix::LintFix;
7use sqruff_lib_core::parser::segments::ErasedSegment;
8
9use crate::core::config::Value;
10use crate::core::rules::context::RuleContext;
11use crate::core::rules::crawlers::{Crawler, SegmentSeekerCrawler};
12use crate::core::rules::{Erased, ErasedRule, LintPhase, LintResult, Rule, RuleGroups};
13
14fn is_capitalizable(character: char) -> bool {
15 character.to_lowercase().ne(character.to_uppercase())
16}
17
18#[derive(Debug, Clone)]
19pub struct RuleCP01 {
20 pub(crate) capitalisation_policy: String,
21 pub(crate) ignore_words: Vec<String>,
22 pub(crate) ignore_words_regex: Vec<Regex>,
23 pub(crate) cap_policy_name: String,
24 pub(crate) skip_literals: bool,
25 pub(crate) exclude_parent_types: &'static [SyntaxKind],
26 pub(crate) description_elem: &'static str,
27}
28
29impl Default for RuleCP01 {
30 fn default() -> Self {
31 Self {
32 capitalisation_policy: "consistent".into(),
33 cap_policy_name: "capitalisation_policy".into(),
34 skip_literals: true,
35 exclude_parent_types: &[
36 SyntaxKind::DataType,
37 SyntaxKind::DatetimeTypeIdentifier,
38 SyntaxKind::PrimitiveType,
39 SyntaxKind::NakedIdentifier,
40 ],
41 description_elem: "Keywords",
42 ignore_words: Vec::new(),
43 ignore_words_regex: Vec::new(),
44 }
45 }
46}
47
48impl Rule for RuleCP01 {
49 fn load_from_config(&self, config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
50 Ok(RuleCP01 {
51 capitalisation_policy: config["capitalisation_policy"].as_string().unwrap().into(),
52 ignore_words: config["ignore_words"]
53 .map(|it| {
54 it.as_array()
55 .unwrap()
56 .iter()
57 .map(|it| it.as_string().unwrap().to_lowercase())
58 .collect()
59 })
60 .unwrap_or_default(),
61 ignore_words_regex: config["ignore_words_regex"]
62 .map(|it| {
63 it.as_array()
64 .unwrap()
65 .iter()
66 .map(|it| Regex::new(it.as_string().unwrap()).unwrap())
67 .collect()
68 })
69 .unwrap_or_default(),
70 ..Default::default()
71 }
72 .erased())
73 }
74
75 fn lint_phase(&self) -> LintPhase {
76 LintPhase::Post
77 }
78
79 fn name(&self) -> &'static str {
80 "capitalisation.keywords"
81 }
82
83 fn description(&self) -> &'static str {
84 "Inconsistent capitalisation of keywords."
85 }
86
87 fn long_description(&self) -> &'static str {
88 r#"
89**Anti-pattern**
90
91In this example, select is in lower-case whereas `FROM` is in upper-case.
92
93```sql
94select
95 a
96FROM foo
97```
98
99**Best practice**
100
101Make all keywords either in upper-case or in lower-case.
102
103```sql
104SELECT
105 a
106FROM foo
107
108-- Also good
109
110select
111 a
112from foo
113```
114"#
115 }
116
117 fn groups(&self) -> &'static [RuleGroups] {
118 &[
119 RuleGroups::All,
120 RuleGroups::Core,
121 RuleGroups::Capitalisation,
122 ]
123 }
124
125 fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
126 let parent = context.parent_stack.last().unwrap();
127
128 if self
129 .ignore_words
130 .contains(&context.segment.raw().to_lowercase())
131 {
132 return Vec::new();
133 }
134
135 if self
136 .ignore_words_regex
137 .iter()
138 .any(|regex| regex.is_match(context.segment.raw().as_ref()))
139 {
140 return Vec::new();
141 }
142
143 if (self.skip_literals && context.segment.is_type(SyntaxKind::Literal))
144 || !self.exclude_parent_types.is_empty()
145 && self
146 .exclude_parent_types
147 .iter()
148 .any(|&it| parent.is_type(it))
149 {
150 return vec![LintResult::new(None, Vec::new(), None, None)];
151 }
152
153 if parent.get_type() == SyntaxKind::FunctionName && parent.segments().len() != 1 {
154 return vec![LintResult::new(None, Vec::new(), None, None)];
155 }
156
157 vec![handle_segment(
158 self.description_elem,
159 &self.capitalisation_policy,
160 &self.cap_policy_name,
161 context.segment.clone(),
162 context,
163 )]
164 }
165
166 fn is_fix_compatible(&self) -> bool {
167 true
168 }
169
170 fn crawl_behaviour(&self) -> Crawler {
171 SegmentSeekerCrawler::new(
172 const {
173 SyntaxSet::new(&[
174 SyntaxKind::Keyword,
175 SyntaxKind::BinaryOperator,
176 SyntaxKind::DatePart,
177 ])
178 },
179 )
180 .into()
181 }
182}
183
184#[derive(Clone, Default)]
185struct RefutedCases(HashSet<&'static str>);
186
187#[derive(Clone)]
188struct LatestPossibleCase(String);
189
190pub fn handle_segment(
191 description_elem: &str,
192 extended_capitalisation_policy: &str,
193 cap_policy_name: &str,
194 seg: ErasedSegment,
195 context: &RuleContext,
196) -> LintResult {
197 let ignore_templated_areas = context
200 .config
201 .get("ignore_templated_areas", "core")
202 .as_bool()
203 .unwrap_or(true);
204 if seg.raw().is_empty() || (seg.is_templated() && ignore_templated_areas) {
205 return LintResult::new(None, Vec::new(), None, None);
206 }
207
208 let mut refuted_cases = context.try_get::<RefutedCases>().unwrap_or_default().0;
209
210 let mut first_letter_is_lowercase = false;
211 for ch in seg.raw().chars() {
212 if is_capitalizable(ch) {
213 first_letter_is_lowercase = Some(ch).into_iter().ne(ch.to_uppercase());
214 break;
215 }
216 first_letter_is_lowercase = false;
217 }
218
219 if first_letter_is_lowercase {
220 refuted_cases.extend(["upper", "capitalise", "pascal"]);
221 if seg.raw().as_str() != seg.raw().to_lowercase() {
222 refuted_cases.insert("lower");
223 }
224 } else {
225 refuted_cases.insert("lower");
226
227 let segment_raw = seg.raw();
228 if segment_raw.as_str() != segment_raw.to_uppercase() {
229 refuted_cases.insert("upper");
230 }
231 if segment_raw.as_str()
232 != segment_raw
233 .to_uppercase()
234 .chars()
235 .next()
236 .unwrap()
237 .to_string()
238 + segment_raw[1..].to_lowercase().as_str()
239 {
240 refuted_cases.insert("capitalise");
241 }
242 if !segment_raw.chars().all(|c| c.is_alphanumeric()) {
243 refuted_cases.insert("pascal");
244 }
245 }
246
247 context.set(RefutedCases(refuted_cases.clone()));
248
249 let concrete_policy = if extended_capitalisation_policy == "consistent" {
250 let cap_policy_opts = match cap_policy_name {
251 "capitalisation_policy" => ["upper", "lower", "capitalise"].as_slice(),
252 "extended_capitalisation_policy" => {
253 ["upper", "lower", "pascal", "capitalise"].as_slice()
254 }
255 _ => unimplemented!("Unknown capitalisation policy name: {cap_policy_name}"),
256 };
257
258 let possible_cases = cap_policy_opts
259 .iter()
260 .filter(|&it| !refuted_cases.contains(it))
261 .collect_vec();
262
263 if !possible_cases.is_empty() {
264 context.set(LatestPossibleCase(possible_cases[0].to_string()));
265 return LintResult::new(None, Vec::new(), None, None);
266 } else {
267 context
268 .try_get::<LatestPossibleCase>()
269 .unwrap_or_else(|| LatestPossibleCase("upper".into()))
270 .0
271 }
272 } else {
273 extended_capitalisation_policy.to_string()
274 };
275
276 let concrete_policy = concrete_policy.as_str();
277
278 let mut fixed_raw = seg.raw().to_string();
279 fixed_raw = match concrete_policy {
280 "upper" => fixed_raw.to_uppercase(),
281 "lower" => fixed_raw.to_lowercase(),
282 "capitalise" => capitalize(&fixed_raw),
283 "pascal" => {
284 let re = lazy_regex::regex!(r"([^a-zA-Z0-9]+|^)([a-zA-Z0-9])([a-zA-Z0-9]*)");
285 re.replace_all(&fixed_raw, |caps: ®ex::Captures| {
286 let mut replacement_string = String::from(&caps[1]);
287 let capitalized = caps[2].to_uppercase();
288 replacement_string.push_str(&capitalized);
289 replacement_string.push_str(&caps[3]);
290 replacement_string
291 })
292 .into()
293 }
294 _ => fixed_raw,
295 };
296
297 if fixed_raw == seg.raw().as_str() {
298 LintResult::new(None, Vec::new(), None, None)
299 } else {
300 let consistency = if extended_capitalisation_policy == "consistent" {
301 "consistently "
302 } else {
303 ""
304 };
305 let policy = match concrete_policy {
306 concrete_policy @ ("upper" | "lower") => format!("{concrete_policy} case."),
307 "capitalise" => "capitalised.".to_string(),
308 "pascal" => "pascal case.".to_string(),
309 _ => "".to_string(),
310 };
311
312 LintResult::new(
313 seg.clone().into(),
314 vec![LintFix::replace(
315 seg.clone(),
316 vec![seg.edit(context.tables.next_id(), fixed_raw.to_string().into(), None)],
317 None,
318 )],
319 format!("{description_elem} must be {consistency}{policy}").into(),
320 None,
321 )
322 }
323}