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