Skip to main content

sqruff_lib/rules/jinja/
jj01.rs

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
16/// Represents the parsed components of a Jinja tag.
17struct JinjaTagComponents {
18    opening: String,
19    leading_ws: String,
20    content: String,
21    trailing_ws: String,
22    closing: String,
23}
24
25/// Parse the whitespace structure of a Jinja tag.
26///
27/// Given a raw Jinja tag like `{{ my_variable }}`, this function extracts:
28/// - opening: `{{`
29/// - leading_ws: ` `
30/// - content: `my_variable`
31/// - trailing_ws: ` `
32/// - closing: `}}`
33fn get_whitespace_ends(raw: &str) -> Option<JinjaTagComponents> {
34    // Regex to match Jinja tags: {{ }}, {% %}, {# #}
35    // Captures: opening bracket (with optional modifier), content, closing bracket (with optional
36    // modifier)
37    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    // Extract leading and trailing whitespace from inner content
46    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
65/// Check if whitespace is acceptable.
66///
67/// Whitespace is acceptable if it's either:
68/// - exactly a single space, OR
69/// - contains at least one newline (multi-line formatting is OK)
70fn 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        // This rule only applies when we have a templated file
117        let Some(templated_file) = &context.templated_file else {
118            return Vec::new();
119        };
120
121        // Check if this is a templated file (not just a plain SQL file)
122        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        // Get the source-only slices (these are the template tags that don't render to output)
130        // and also check the raw sliced file for templated sections
131        for raw_slice in templated_file.raw_sliced() {
132            // Only check templated sections (not literal SQL)
133            // The slice_type tells us what kind of template construct this is
134            let slice_type = raw_slice.slice_type();
135
136            // We want to check template expressions and statements, not literal SQL
137            // "templated" = {{ expr }}, "block_start" = {% if %}, "block_end" = {% endif %},
138            // "block_mid" = {% else %}, "comment" = {# comment #}
139            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            // Check if it looks like a Jinja tag (starts with { and ends with })
153            if !raw.starts_with('{') || !raw.ends_with('}') {
154                continue;
155            }
156
157            // Parse the whitespace structure
158            let Some(components) = get_whitespace_ends(raw) else {
159                continue;
160            };
161
162            // Check leading and trailing whitespace
163            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                // Build the expected corrected tag
168                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                // Create a source fix for this jinja tag
192                let source_slice = raw_slice.source_slice();
193                // For templated_slice, we use an empty range since template tags
194                // don't have a direct mapping to the templated output
195                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                // Create an anchor segment with the correct source position for
204                // this violation. We use the source index as the templated_slice
205                // start so that source_position() looks up the right location in
206                // source_newlines.
207                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                // Report violation with the correctly-positioned anchor
221                results.push(LintResult::new(
222                    Some(anchor),
223                    vec![], // Fixes will be added below after collecting all
224                    Some(description),
225                    None,
226                ));
227            }
228        }
229
230        // If we have source fixes, create a single fix that contains all of them
231        if !all_source_fixes.is_empty() && !results.is_empty() {
232            // Find the first raw segment to use as an anchor for the fix
233            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                // Add the fix to all results
256                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        // Run once per file at the root level
271        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}