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, targets_templated};
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```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        // This rule only applies when we have a templated file
119        let Some(templated_file) = &context.templated_file else {
120            return Vec::new();
121        };
122
123        // Check if this is a templated file (not just a plain SQL file)
124        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        // Get the source-only slices (these are the template tags that don't render to output)
132        // and also check the raw sliced file for templated sections
133        for raw_slice in templated_file.raw_sliced() {
134            // Only check templated sections (not literal SQL)
135            // The slice_type tells us what kind of template construct this is
136            let slice_type = raw_slice.slice_type();
137
138            // We want to check template expressions and statements, not literal SQL
139            // "templated" = {{ expr }}, "block_start" = {% if %}, "block_end" = {% endif %},
140            // "block_mid" = {% else %}, "comment" = {# comment #}
141            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            // Check if it looks like a Jinja tag (starts with { and ends with })
155            if !raw.starts_with('{') || !raw.ends_with('}') {
156                continue;
157            }
158
159            // Parse the whitespace structure
160            let Some(components) = get_whitespace_ends(raw) else {
161                continue;
162            };
163
164            // Check leading and trailing whitespace
165            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                // Build the expected corrected tag
170                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                // Create a source fix for this jinja tag
194                let source_slice = raw_slice.source_slice();
195                // For templated_slice, we use an empty range since template tags
196                // don't have a direct mapping to the templated output
197                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                // Create an anchor segment with the correct source position for
206                // this violation. We use the source index as the templated_slice
207                // start so that source_position() looks up the right location in
208                // source_newlines.
209                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                // Report violation with the correctly-positioned anchor
223                results.push(LintResult::new(
224                    Some(anchor),
225                    vec![], // Fixes will be added below after collecting all
226                    Some(description),
227                    None,
228                ));
229            }
230        }
231
232        // If we have source fixes, create a single fix that contains all of them
233        if !all_source_fixes.is_empty() && !results.is_empty() {
234            // Find the first raw segment to use as an anchor for the fix
235            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                // Add the fix to all results
258                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        // Run once per file at the root level
273        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}