sqruff_lib/rules/jinja/
jj01.rs1use ahash::AHashMap;
2use regex::Regex;
3use smol_str::SmolStr;
4use sqruff_lib_core::dialects::syntax::SyntaxKind;
5use sqruff_lib_core::lint_fix::LintFix;
6use sqruff_lib_core::parser::segments::SegmentBuilder;
7use sqruff_lib_core::parser::segments::fix::SourceFix;
8
9use crate::core::config::Value;
10use crate::core::rules::context::RuleContext;
11use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
12use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
13
14struct JinjaTagComponents {
16 opening: String,
17 leading_ws: String,
18 content: String,
19 trailing_ws: String,
20 closing: String,
21}
22
23fn get_whitespace_ends(raw: &str) -> Option<JinjaTagComponents> {
32 let re = Regex::new(r"^(\{[\{%#][-+]?)(.*?)([-+]?[\}%#]\})$").ok()?;
36
37 let captures = re.captures(raw)?;
38
39 let opening = captures.get(1)?.as_str().to_string();
40 let inner = captures.get(2)?.as_str();
41 let closing = captures.get(3)?.as_str().to_string();
42
43 let inner_len = inner.len();
45 let trimmed_start = inner.trim_start();
46 let leading_ws_len = inner_len - trimmed_start.len();
47 let leading_ws = inner[..leading_ws_len].to_string();
48
49 let trimmed = trimmed_start.trim_end();
50 let trailing_ws = trimmed_start[trimmed.len()..].to_string();
51
52 let content = trimmed.to_string();
53
54 Some(JinjaTagComponents {
55 opening,
56 leading_ws,
57 content,
58 trailing_ws,
59 closing,
60 })
61}
62
63fn is_acceptable_whitespace(ws: &str) -> bool {
69 ws == " " || ws.contains('\n')
70}
71
72#[derive(Default, Debug, Clone)]
73pub struct RuleJJ01;
74
75impl Rule for RuleJJ01 {
76 fn load_from_config(&self, _config: &AHashMap<String, Value>) -> Result<ErasedRule, String> {
77 Ok(RuleJJ01.erased())
78 }
79
80 fn name(&self) -> &'static str {
81 "jinja.padding"
82 }
83
84 fn description(&self) -> &'static str {
85 "Jinja tags should have a single whitespace on either side."
86 }
87
88 fn long_description(&self) -> &'static str {
89 r#"
90**Anti-pattern**
91
92Jinja tags with either no whitespace or very long whitespace are hard to read.
93
94```sql
95SELECT {{a}} from {{ref('foo')}}
96```
97
98**Best practice**
99
100A single whitespace surrounding Jinja tags, alternatively longer gaps containing
101newlines are acceptable.
102
103```sql
104SELECT {{ a }} from {{ ref('foo') }};
105```
106"#
107 }
108
109 fn groups(&self) -> &'static [RuleGroups] {
110 &[RuleGroups::All, RuleGroups::Core, RuleGroups::Jinja]
111 }
112
113 fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
114 let Some(templated_file) = &context.templated_file else {
116 return Vec::new();
117 };
118
119 if !templated_file.is_templated() {
121 return Vec::new();
122 }
123
124 let mut results = Vec::new();
125 let mut source_fixes = Vec::new();
126
127 for raw_slice in templated_file.raw_sliced() {
130 let slice_type = raw_slice.slice_type();
133
134 if !matches!(
138 slice_type,
139 "templated" | "block_start" | "block_end" | "block_mid" | "comment"
140 ) {
141 continue;
142 }
143
144 let raw = raw_slice.raw();
145
146 if !raw.starts_with('{') || !raw.ends_with('}') {
148 continue;
149 }
150
151 let Some(components) = get_whitespace_ends(raw) else {
153 continue;
154 };
155
156 let leading_ok = is_acceptable_whitespace(&components.leading_ws);
158 let trailing_ok = is_acceptable_whitespace(&components.trailing_ws);
159
160 if !leading_ok || !trailing_ok {
161 let fixed_tag = format!(
163 "{} {} {}",
164 components.opening, components.content, components.closing
165 );
166
167 let description = if !leading_ok && !trailing_ok {
168 format!(
169 "Jinja tags should have a single whitespace on either side: `{}` -> `{}`",
170 raw, fixed_tag
171 )
172 } else if !leading_ok {
173 format!(
174 "Jinja tags should have a single whitespace on the left side: `{}` -> `{}`",
175 raw, fixed_tag
176 )
177 } else {
178 format!(
179 "Jinja tags should have a single whitespace on the right side: `{}` -> \
180 `{}`",
181 raw, fixed_tag
182 )
183 };
184
185 let source_slice = raw_slice.source_slice();
187 let templated_slice = 0..0;
190
191 source_fixes.push(SourceFix::new(
192 SmolStr::new(&fixed_tag),
193 source_slice,
194 templated_slice,
195 ));
196
197 results.push(LintResult::new(
199 Some(context.segment.clone()),
200 vec![], Some(description),
202 None,
203 ));
204 }
205 }
206
207 if !source_fixes.is_empty() && !results.is_empty() {
209 let raw_segments = context.segment.get_raw_segments();
212 if let Some(anchor_seg) = raw_segments.first() {
213 let inner_token = SegmentBuilder::token(
217 context.tables.next_id(),
218 anchor_seg.raw().as_ref(),
219 anchor_seg.get_type(),
220 )
221 .with_position(anchor_seg.get_position_marker().cloned().unwrap())
222 .finish();
223
224 let fix_segment = SegmentBuilder::node(
225 context.tables.next_id(),
226 SyntaxKind::File,
227 context.dialect.name,
228 vec![inner_token],
229 )
230 .with_source_fixes(source_fixes)
231 .with_position(anchor_seg.get_position_marker().cloned().unwrap())
232 .finish();
233
234 let fix = LintFix::replace(anchor_seg.clone(), vec![fix_segment], None);
236
237 for result in &mut results {
239 result.fixes = vec![fix.clone()];
240 }
241 }
242 }
243
244 results
245 }
246
247 fn is_fix_compatible(&self) -> bool {
248 true
249 }
250
251 fn crawl_behaviour(&self) -> Crawler {
252 RootOnlyCrawler.into()
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_get_whitespace_ends_basic() {
263 let result = get_whitespace_ends("{{ foo }}").unwrap();
264 assert_eq!(result.opening, "{{");
265 assert_eq!(result.leading_ws, " ");
266 assert_eq!(result.content, "foo");
267 assert_eq!(result.trailing_ws, " ");
268 assert_eq!(result.closing, "}}");
269 }
270
271 #[test]
272 fn test_get_whitespace_ends_no_whitespace() {
273 let result = get_whitespace_ends("{{foo}}").unwrap();
274 assert_eq!(result.opening, "{{");
275 assert_eq!(result.leading_ws, "");
276 assert_eq!(result.content, "foo");
277 assert_eq!(result.trailing_ws, "");
278 assert_eq!(result.closing, "}}");
279 }
280
281 #[test]
282 fn test_get_whitespace_ends_excessive_whitespace() {
283 let result = get_whitespace_ends("{{ foo }}").unwrap();
284 assert_eq!(result.opening, "{{");
285 assert_eq!(result.leading_ws, " ");
286 assert_eq!(result.content, "foo");
287 assert_eq!(result.trailing_ws, " ");
288 assert_eq!(result.closing, "}}");
289 }
290
291 #[test]
292 fn test_get_whitespace_ends_block() {
293 let result = get_whitespace_ends("{% if x %}").unwrap();
294 assert_eq!(result.opening, "{%");
295 assert_eq!(result.leading_ws, " ");
296 assert_eq!(result.content, "if x");
297 assert_eq!(result.trailing_ws, " ");
298 assert_eq!(result.closing, "%}");
299 }
300
301 #[test]
302 fn test_get_whitespace_ends_comment() {
303 let result = get_whitespace_ends("{# comment #}").unwrap();
304 assert_eq!(result.opening, "{#");
305 assert_eq!(result.leading_ws, " ");
306 assert_eq!(result.content, "comment");
307 assert_eq!(result.trailing_ws, " ");
308 assert_eq!(result.closing, "#}");
309 }
310
311 #[test]
312 fn test_get_whitespace_ends_with_modifier() {
313 let result = get_whitespace_ends("{{- foo -}}").unwrap();
314 assert_eq!(result.opening, "{{-");
315 assert_eq!(result.leading_ws, " ");
316 assert_eq!(result.content, "foo");
317 assert_eq!(result.trailing_ws, " ");
318 assert_eq!(result.closing, "-}}");
319 }
320
321 #[test]
322 fn test_is_acceptable_whitespace() {
323 assert!(is_acceptable_whitespace(" "));
324 assert!(is_acceptable_whitespace("\n"));
325 assert!(is_acceptable_whitespace(" \n "));
326 assert!(!is_acceptable_whitespace(""));
327 assert!(!is_acceptable_whitespace(" "));
328 assert!(!is_acceptable_whitespace("\t"));
329 }
330}