sqruff_lib/rules/jinja/
jj01.rs

1use ahash::AHashMap;
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::segments::SegmentBuilder;
7use sqruff_lib_core::parser::segments::fix::SourceFix;
8
9use crate::core::config::Value;
10use crate::core::rules::context::RuleContext;
11use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
12use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
13
14/// Represents the parsed components of a Jinja tag.
15struct JinjaTagComponents {
16    opening: String,
17    leading_ws: String,
18    content: String,
19    trailing_ws: String,
20    closing: String,
21}
22
23/// Parse the whitespace structure of a Jinja tag.
24///
25/// Given a raw Jinja tag like `{{ my_variable }}`, this function extracts:
26/// - opening: `{{`
27/// - leading_ws: ` `
28/// - content: `my_variable`
29/// - trailing_ws: ` `
30/// - closing: `}}`
31fn get_whitespace_ends(raw: &str) -> Option<JinjaTagComponents> {
32    // Regex to match Jinja tags: {{ }}, {% %}, {# #}
33    // Captures: opening bracket (with optional modifier), content, closing bracket (with optional
34    // modifier)
35    let re = Regex::new(r"^(\{[\{%#][-+]?)(.*?)([-+]?[\}%#]\})$").ok()?;
36
37    let captures = re.captures(raw)?;
38
39    let opening = captures.get(1)?.as_str().to_string();
40    let inner = captures.get(2)?.as_str();
41    let closing = captures.get(3)?.as_str().to_string();
42
43    // Extract leading and trailing whitespace from inner content
44    let inner_len = inner.len();
45    let trimmed_start = inner.trim_start();
46    let leading_ws_len = inner_len - trimmed_start.len();
47    let leading_ws = inner[..leading_ws_len].to_string();
48
49    let trimmed = trimmed_start.trim_end();
50    let trailing_ws = trimmed_start[trimmed.len()..].to_string();
51
52    let content = trimmed.to_string();
53
54    Some(JinjaTagComponents {
55        opening,
56        leading_ws,
57        content,
58        trailing_ws,
59        closing,
60    })
61}
62
63/// Check if whitespace is acceptable.
64///
65/// Whitespace is acceptable if it's either:
66/// - exactly a single space, OR
67/// - contains at least one newline (multi-line formatting is OK)
68fn is_acceptable_whitespace(ws: &str) -> bool {
69    ws == " " || ws.contains('\n')
70}
71
72#[derive(Default, Debug, Clone)]
73pub struct RuleJJ01;
74
75impl Rule for RuleJJ01 {
76    fn load_from_config(&self, _config: &AHashMap<String, Value>) -> Result<ErasedRule, String> {
77        Ok(RuleJJ01.erased())
78    }
79
80    fn name(&self) -> &'static str {
81        "jinja.padding"
82    }
83
84    fn description(&self) -> &'static str {
85        "Jinja tags should have a single whitespace on either side."
86    }
87
88    fn long_description(&self) -> &'static str {
89        r#"
90**Anti-pattern**
91
92Jinja tags with either no whitespace or very long whitespace are hard to read.
93
94```sql
95SELECT {{a}} from {{ref('foo')}}
96```
97
98**Best practice**
99
100A single whitespace surrounding Jinja tags, alternatively longer gaps containing
101newlines are acceptable.
102
103```sql
104SELECT {{ a }} from {{ ref('foo') }};
105```
106"#
107    }
108
109    fn groups(&self) -> &'static [RuleGroups] {
110        &[RuleGroups::All, RuleGroups::Core, RuleGroups::Jinja]
111    }
112
113    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
114        // This rule only applies when we have a templated file
115        let Some(templated_file) = &context.templated_file else {
116            return Vec::new();
117        };
118
119        // Check if this is a templated file (not just a plain SQL file)
120        if !templated_file.is_templated() {
121            return Vec::new();
122        }
123
124        let mut results = Vec::new();
125        let mut source_fixes = Vec::new();
126
127        // Get the source-only slices (these are the template tags that don't render to output)
128        // and also check the raw sliced file for templated sections
129        for raw_slice in templated_file.raw_sliced() {
130            // Only check templated sections (not literal SQL)
131            // The slice_type tells us what kind of template construct this is
132            let slice_type = raw_slice.slice_type();
133
134            // We want to check template expressions and statements, not literal SQL
135            // "templated" = {{ expr }}, "block_start" = {% if %}, "block_end" = {% endif %},
136            // "block_mid" = {% else %}, "comment" = {# comment #}
137            if !matches!(
138                slice_type,
139                "templated" | "block_start" | "block_end" | "block_mid" | "comment"
140            ) {
141                continue;
142            }
143
144            let raw = raw_slice.raw();
145
146            // Check if it looks like a Jinja tag (starts with { and ends with })
147            if !raw.starts_with('{') || !raw.ends_with('}') {
148                continue;
149            }
150
151            // Parse the whitespace structure
152            let Some(components) = get_whitespace_ends(raw) else {
153                continue;
154            };
155
156            // Check leading and trailing whitespace
157            let leading_ok = is_acceptable_whitespace(&components.leading_ws);
158            let trailing_ok = is_acceptable_whitespace(&components.trailing_ws);
159
160            if !leading_ok || !trailing_ok {
161                // Build the expected corrected tag
162                let fixed_tag = format!(
163                    "{} {} {}",
164                    components.opening, components.content, components.closing
165                );
166
167                let description = if !leading_ok && !trailing_ok {
168                    format!(
169                        "Jinja tags should have a single whitespace on either side: `{}` -> `{}`",
170                        raw, fixed_tag
171                    )
172                } else if !leading_ok {
173                    format!(
174                        "Jinja tags should have a single whitespace on the left side: `{}` -> `{}`",
175                        raw, fixed_tag
176                    )
177                } else {
178                    format!(
179                        "Jinja tags should have a single whitespace on the right side: `{}` -> \
180                         `{}`",
181                        raw, fixed_tag
182                    )
183                };
184
185                // Create a source fix for this jinja tag
186                let source_slice = raw_slice.source_slice();
187                // For templated_slice, we use an empty range since template tags
188                // don't have a direct mapping to the templated output
189                let templated_slice = 0..0;
190
191                source_fixes.push(SourceFix::new(
192                    SmolStr::new(&fixed_tag),
193                    source_slice,
194                    templated_slice,
195                ));
196
197                // Report violation
198                results.push(LintResult::new(
199                    Some(context.segment.clone()),
200                    vec![], // Fixes will be added below after collecting all
201                    Some(description),
202                    None,
203                ));
204            }
205        }
206
207        // If we have source fixes, create a single fix that contains all of them
208        if !source_fixes.is_empty() && !results.is_empty() {
209            // Find the first raw segment to use as an anchor
210            // We can't use the root segment because apply_fixes only looks at children
211            let raw_segments = context.segment.get_raw_segments();
212            if let Some(anchor_seg) = raw_segments.first() {
213                // Create a segment that carries the source fixes
214                // The segment must have the same raw text as the anchor for is_just_source_edit
215                // We wrap the anchor's content in a node that has source_fixes
216                let inner_token = SegmentBuilder::token(
217                    context.tables.next_id(),
218                    anchor_seg.raw().as_ref(),
219                    anchor_seg.get_type(),
220                )
221                .with_position(anchor_seg.get_position_marker().cloned().unwrap())
222                .finish();
223
224                let fix_segment = SegmentBuilder::node(
225                    context.tables.next_id(),
226                    SyntaxKind::File,
227                    context.dialect.name,
228                    vec![inner_token],
229                )
230                .with_source_fixes(source_fixes)
231                .with_position(anchor_seg.get_position_marker().cloned().unwrap())
232                .finish();
233
234                // Create a LintFix::Replace with the first raw segment as anchor
235                let fix = LintFix::replace(anchor_seg.clone(), vec![fix_segment], None);
236
237                // Add the fix to all results
238                for result in &mut results {
239                    result.fixes = vec![fix.clone()];
240                }
241            }
242        }
243
244        results
245    }
246
247    fn is_fix_compatible(&self) -> bool {
248        true
249    }
250
251    fn crawl_behaviour(&self) -> Crawler {
252        // Run once per file at the root level
253        RootOnlyCrawler.into()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_get_whitespace_ends_basic() {
263        let result = get_whitespace_ends("{{ foo }}").unwrap();
264        assert_eq!(result.opening, "{{");
265        assert_eq!(result.leading_ws, " ");
266        assert_eq!(result.content, "foo");
267        assert_eq!(result.trailing_ws, " ");
268        assert_eq!(result.closing, "}}");
269    }
270
271    #[test]
272    fn test_get_whitespace_ends_no_whitespace() {
273        let result = get_whitespace_ends("{{foo}}").unwrap();
274        assert_eq!(result.opening, "{{");
275        assert_eq!(result.leading_ws, "");
276        assert_eq!(result.content, "foo");
277        assert_eq!(result.trailing_ws, "");
278        assert_eq!(result.closing, "}}");
279    }
280
281    #[test]
282    fn test_get_whitespace_ends_excessive_whitespace() {
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_block() {
293        let result = get_whitespace_ends("{% if x %}").unwrap();
294        assert_eq!(result.opening, "{%");
295        assert_eq!(result.leading_ws, " ");
296        assert_eq!(result.content, "if x");
297        assert_eq!(result.trailing_ws, " ");
298        assert_eq!(result.closing, "%}");
299    }
300
301    #[test]
302    fn test_get_whitespace_ends_comment() {
303        let result = get_whitespace_ends("{# comment #}").unwrap();
304        assert_eq!(result.opening, "{#");
305        assert_eq!(result.leading_ws, " ");
306        assert_eq!(result.content, "comment");
307        assert_eq!(result.trailing_ws, " ");
308        assert_eq!(result.closing, "#}");
309    }
310
311    #[test]
312    fn test_get_whitespace_ends_with_modifier() {
313        let result = get_whitespace_ends("{{- foo -}}").unwrap();
314        assert_eq!(result.opening, "{{-");
315        assert_eq!(result.leading_ws, " ");
316        assert_eq!(result.content, "foo");
317        assert_eq!(result.trailing_ws, " ");
318        assert_eq!(result.closing, "-}}");
319    }
320
321    #[test]
322    fn test_is_acceptable_whitespace() {
323        assert!(is_acceptable_whitespace(" "));
324        assert!(is_acceptable_whitespace("\n"));
325        assert!(is_acceptable_whitespace("  \n  "));
326        assert!(!is_acceptable_whitespace(""));
327        assert!(!is_acceptable_whitespace("  "));
328        assert!(!is_acceptable_whitespace("\t"));
329    }
330}