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