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, targets_templated};
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```jinja
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```jinja
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 targets_templated!();
116
117 fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
118 let Some(templated_file) = &context.templated_file else {
120 return Vec::new();
121 };
122
123 if !templated_file.is_templated() {
125 return Vec::new();
126 }
127
128 let mut results = Vec::new();
129 let mut all_source_fixes = Vec::new();
130
131 for raw_slice in templated_file.raw_sliced() {
134 let slice_type = raw_slice.slice_type();
137
138 if !matches!(
142 slice_type,
143 TemplateSliceKind::Templated
144 | TemplateSliceKind::BlockStart
145 | TemplateSliceKind::BlockEnd
146 | TemplateSliceKind::BlockMid
147 | TemplateSliceKind::Comment
148 ) {
149 continue;
150 }
151
152 let raw = raw_slice.raw();
153
154 if !raw.starts_with('{') || !raw.ends_with('}') {
156 continue;
157 }
158
159 let Some(components) = get_whitespace_ends(raw) else {
161 continue;
162 };
163
164 let leading_ok = is_acceptable_whitespace(&components.leading_ws);
166 let trailing_ok = is_acceptable_whitespace(&components.trailing_ws);
167
168 if !leading_ok || !trailing_ok {
169 let fixed_tag = format!(
171 "{} {} {}",
172 components.opening, components.content, components.closing
173 );
174
175 let description = if !leading_ok && !trailing_ok {
176 format!(
177 "Jinja tags should have a single whitespace on either side: `{}` -> `{}`",
178 raw, fixed_tag
179 )
180 } else if !leading_ok {
181 format!(
182 "Jinja tags should have a single whitespace on the left side: `{}` -> `{}`",
183 raw, fixed_tag
184 )
185 } else {
186 format!(
187 "Jinja tags should have a single whitespace on the right side: `{}` -> \
188 `{}`",
189 raw, fixed_tag
190 )
191 };
192
193 let source_slice = raw_slice.source_slice();
195 let templated_slice = 0..0;
198
199 all_source_fixes.push(SourceFix::new(
200 SmolStr::new(&fixed_tag),
201 source_slice.clone(),
202 templated_slice,
203 ));
204
205 let position_marker = PositionMarker::new(
210 source_slice.clone(),
211 source_slice,
212 templated_file.clone(),
213 None,
214 None,
215 );
216
217 let anchor =
218 SegmentBuilder::token(context.tables.next_id(), raw, SyntaxKind::TemplateLoop)
219 .with_position(position_marker)
220 .finish();
221
222 results.push(LintResult::new(
224 Some(anchor),
225 vec![], Some(description),
227 None,
228 ));
229 }
230 }
231
232 if !all_source_fixes.is_empty() && !results.is_empty() {
234 let raw_segments = context.segment.get_raw_segments();
236 if let Some(anchor_seg) = raw_segments.first() {
237 let inner_token = SegmentBuilder::token(
238 context.tables.next_id(),
239 anchor_seg.raw().as_ref(),
240 anchor_seg.get_type(),
241 )
242 .with_position(anchor_seg.get_position_marker().cloned().unwrap())
243 .finish();
244
245 let fix_segment = SegmentBuilder::node(
246 context.tables.next_id(),
247 SyntaxKind::File,
248 context.dialect.name,
249 vec![inner_token],
250 )
251 .with_source_fixes(all_source_fixes)
252 .with_position(anchor_seg.get_position_marker().cloned().unwrap())
253 .finish();
254
255 let fix = LintFix::replace(anchor_seg.clone(), vec![fix_segment], None);
256
257 for result in &mut results {
259 result.fixes = vec![fix.clone()];
260 }
261 }
262 }
263
264 results
265 }
266
267 fn is_fix_compatible(&self) -> bool {
268 true
269 }
270
271 fn crawl_behaviour(&self) -> Crawler {
272 RootOnlyCrawler.into()
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn test_get_whitespace_ends_basic() {
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_no_whitespace() {
293 let result = get_whitespace_ends("{{foo}}").unwrap();
294 assert_eq!(result.opening, "{{");
295 assert_eq!(result.leading_ws, "");
296 assert_eq!(result.content, "foo");
297 assert_eq!(result.trailing_ws, "");
298 assert_eq!(result.closing, "}}");
299 }
300
301 #[test]
302 fn test_get_whitespace_ends_excessive_whitespace() {
303 let result = get_whitespace_ends("{{ foo }}").unwrap();
304 assert_eq!(result.opening, "{{");
305 assert_eq!(result.leading_ws, " ");
306 assert_eq!(result.content, "foo");
307 assert_eq!(result.trailing_ws, " ");
308 assert_eq!(result.closing, "}}");
309 }
310
311 #[test]
312 fn test_get_whitespace_ends_block() {
313 let result = get_whitespace_ends("{% if x %}").unwrap();
314 assert_eq!(result.opening, "{%");
315 assert_eq!(result.leading_ws, " ");
316 assert_eq!(result.content, "if x");
317 assert_eq!(result.trailing_ws, " ");
318 assert_eq!(result.closing, "%}");
319 }
320
321 #[test]
322 fn test_get_whitespace_ends_comment() {
323 let result = get_whitespace_ends("{# comment #}").unwrap();
324 assert_eq!(result.opening, "{#");
325 assert_eq!(result.leading_ws, " ");
326 assert_eq!(result.content, "comment");
327 assert_eq!(result.trailing_ws, " ");
328 assert_eq!(result.closing, "#}");
329 }
330
331 #[test]
332 fn test_get_whitespace_ends_with_modifier() {
333 let result = get_whitespace_ends("{{- foo -}}").unwrap();
334 assert_eq!(result.opening, "{{-");
335 assert_eq!(result.leading_ws, " ");
336 assert_eq!(result.content, "foo");
337 assert_eq!(result.trailing_ws, " ");
338 assert_eq!(result.closing, "-}}");
339 }
340
341 #[test]
342 fn test_is_acceptable_whitespace() {
343 assert!(is_acceptable_whitespace(" "));
344 assert!(is_acceptable_whitespace("\n"));
345 assert!(is_acceptable_whitespace(" \n "));
346 assert!(!is_acceptable_whitespace(""));
347 assert!(!is_acceptable_whitespace(" "));
348 assert!(!is_acceptable_whitespace("\t"));
349 }
350}