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