Skip to main content

flowscope_core/linter/rules/
jj_001.rs

1//! LINT_JJ_001: Jinja padding.
2//!
3//! SQLFluff JJ01 parity (current scope): detect inconsistent whitespace around
4//! Jinja delimiters.
5
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer};
10
11pub struct JinjaPadding;
12
13impl LintRule for JinjaPadding {
14    fn code(&self) -> &'static str {
15        issue_codes::LINT_JJ_001
16    }
17
18    fn name(&self) -> &'static str {
19        "Jinja padding"
20    }
21
22    fn description(&self) -> &'static str {
23        "Jinja tags should have a single whitespace on either side."
24    }
25
26    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
27        let Some((start, end)) = jinja_padding_violation_span(ctx) else {
28            return Vec::new();
29        };
30
31        let mut issue = Issue::info(
32            issue_codes::LINT_JJ_001,
33            "Jinja tag spacing appears inconsistent.",
34        )
35        .with_statement(ctx.statement_index)
36        .with_span(ctx.span_from_statement_offset(start, end));
37
38        let edits: Vec<IssuePatchEdit> = jinja_padding_autofix_edits(ctx.statement_sql())
39            .into_iter()
40            .map(|edit| {
41                IssuePatchEdit::new(
42                    ctx.span_from_statement_offset(edit.start, edit.end),
43                    edit.replacement,
44                )
45            })
46            .collect();
47        if !edits.is_empty() {
48            issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
49        }
50
51        vec![issue]
52    }
53}
54
55fn jinja_padding_violation_span(ctx: &LintContext) -> Option<(usize, usize)> {
56    let sql = ctx.statement_sql();
57
58    // Token-based detection (works well when sqlparser can tokenize the input).
59    if let Some(tokens) = token_spans_for_context(ctx).or_else(|| token_spans(sql, ctx.dialect())) {
60        for token in &tokens {
61            if let Some(span) = token_text_violation(sql, token) {
62                return Some(span);
63            }
64        }
65
66        for pair in tokens.windows(2) {
67            let left = &pair[0];
68            let right = &pair[1];
69            if is_open_delimiter_tokens(&left.token, &right.token) {
70                let delimiter_start = left.start;
71                let delimiter_end = right.end;
72                if has_incorrect_padding_after(sql, delimiter_end) {
73                    return Some((delimiter_start, delimiter_end));
74                }
75            }
76
77            if is_close_delimiter_tokens(&left.token, &right.token) {
78                let delimiter_start = left.start;
79                let delimiter_end = right.end;
80                if has_incorrect_padding_before(sql, delimiter_start) {
81                    return Some((delimiter_start, delimiter_end));
82                }
83            }
84        }
85    }
86
87    // Text-based fallback: check whether the autofix engine would produce any
88    // edits. This catches multi-space violations and cases where the token-based
89    // detection misses Jinja delimiters (e.g. when sqlparser splits them across
90    // tokens differently than expected).
91    let edits = jinja_padding_autofix_edits(sql);
92    if let Some(edit) = edits.first() {
93        return Some((edit.start, edit.end));
94    }
95
96    None
97}
98
99struct TokenSpan {
100    token: Token,
101    start: usize,
102    end: usize,
103}
104
105fn token_spans(sql: &str, dialect: Dialect) -> Option<Vec<TokenSpan>> {
106    let dialect = dialect.to_sqlparser_dialect();
107    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
108    let tokens: Vec<TokenWithSpan> = tokenizer.tokenize_with_location().ok()?;
109
110    let mut out = Vec::with_capacity(tokens.len());
111    for token in tokens {
112        let start = line_col_to_offset(
113            sql,
114            token.span.start.line as usize,
115            token.span.start.column as usize,
116        )?;
117        let end = line_col_to_offset(
118            sql,
119            token.span.end.line as usize,
120            token.span.end.column as usize,
121        )?;
122        if start < end {
123            out.push(TokenSpan {
124                token: token.token,
125                start,
126                end,
127            });
128        }
129    }
130
131    Some(out)
132}
133
134fn token_spans_for_context(ctx: &LintContext) -> Option<Vec<TokenSpan>> {
135    let offset = ctx.statement_range.start;
136    ctx.with_document_tokens(|tokens| {
137        if tokens.is_empty() {
138            return None;
139        }
140
141        let mut out = Vec::new();
142        for token in tokens {
143            let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
144                continue;
145            };
146            if start < ctx.statement_range.start || end > ctx.statement_range.end {
147                continue;
148            }
149            if start < end {
150                out.push(TokenSpan {
151                    token: token.token.clone(),
152                    start: start - offset,
153                    end: end - offset,
154                });
155            }
156        }
157
158        if out.is_empty() {
159            None
160        } else {
161            Some(out)
162        }
163    })
164}
165
166fn token_text_violation(sql: &str, token: &TokenSpan) -> Option<(usize, usize)> {
167    let text = &sql[token.start..token.end];
168
169    for pattern in &OPEN_DELIMITERS {
170        for (idx, _) in text.match_indices(pattern) {
171            let delimiter_start = token.start + idx;
172            let delimiter_end = delimiter_start + pattern.len();
173            if has_incorrect_padding_after(sql, delimiter_end) {
174                return Some((delimiter_start, delimiter_end));
175            }
176        }
177    }
178
179    for pattern in &CLOSE_DELIMITERS {
180        for (idx, _) in text.match_indices(pattern) {
181            let delimiter_start = token.start + idx;
182            if has_incorrect_padding_before(sql, delimiter_start) {
183                return Some((delimiter_start, delimiter_start + pattern.len()));
184            }
185        }
186    }
187
188    None
189}
190
191#[derive(Debug)]
192struct JinjaPaddingEdit {
193    start: usize,
194    end: usize,
195    replacement: String,
196}
197
198fn jinja_padding_autofix_edits(sql: &str) -> Vec<JinjaPaddingEdit> {
199    let mut edits =
200        normalize_template_tag_padding_edits(sql, b"{{", b"}}", |b| b != b'{' && b != b'}');
201    edits.extend(normalize_template_tag_padding_edits(
202        sql,
203        b"{%",
204        b"%}",
205        |b| b != b'%',
206    ));
207    edits.sort_by_key(|edit| (edit.start, edit.end));
208    edits.dedup_by(|left, right| {
209        left.start == right.start && left.end == right.end && left.replacement == right.replacement
210    });
211    edits
212}
213
214fn normalize_template_tag_padding_edits<F>(
215    sql: &str,
216    open: &[u8],
217    close: &[u8],
218    inner_ok: F,
219) -> Vec<JinjaPaddingEdit>
220where
221    F: Fn(u8) -> bool,
222{
223    let bytes = sql.as_bytes();
224    let mut edits = Vec::new();
225    let mut i = 0usize;
226
227    while i < bytes.len() {
228        let mut replaced = false;
229        if i + open.len() <= bytes.len() && &bytes[i..i + open.len()] == open {
230            let mut j = i + open.len();
231            while j + close.len() <= bytes.len() {
232                if &bytes[j..j + close.len()] == close {
233                    let inner = &sql[i + open.len()..j];
234                    if !inner.is_empty() && inner.as_bytes().iter().copied().all(&inner_ok) {
235                        let open_text =
236                            std::str::from_utf8(open).expect("template delimiter is ascii");
237                        let close_text =
238                            std::str::from_utf8(close).expect("template delimiter is ascii");
239
240                        // Detect trim markers (+/-) attached to delimiters.
241                        let trimmed = inner.trim();
242                        let (open_marker, content, close_marker) = extract_trim_markers(trimmed);
243                        let content = content.trim();
244
245                        let replacement = format!(
246                            "{open_text}{open_marker} {content} {close_marker}{close_text}"
247                        );
248                        let end = j + close.len();
249                        if replacement != sql[i..end] {
250                            edits.push(JinjaPaddingEdit {
251                                start: i,
252                                end,
253                                replacement,
254                            });
255                        }
256                        i = end;
257                        replaced = true;
258                    }
259                    break;
260                }
261                j += 1;
262            }
263            if replaced {
264                continue;
265            }
266        }
267
268        i += 1;
269    }
270
271    edits
272}
273
274/// Extracts optional trim markers from Jinja tag content.
275/// `+` and `-` at the start/end of content are trim markers.
276/// Returns (open_marker, remaining_content, close_marker).
277fn extract_trim_markers(content: &str) -> (&str, &str, &str) {
278    let bytes = content.as_bytes();
279    let mut start = 0;
280    let mut end = bytes.len();
281
282    let open_marker = if !bytes.is_empty() && (bytes[0] == b'+' || bytes[0] == b'-') {
283        start = 1;
284        &content[..1]
285    } else {
286        ""
287    };
288
289    let close_marker = if end > start && (bytes[end - 1] == b'+' || bytes[end - 1] == b'-') {
290        end -= 1;
291        &content[end..end + 1]
292    } else {
293        ""
294    };
295
296    (open_marker, &content[start..end], close_marker)
297}
298
299const OPEN_DELIMITERS: [&str; 3] = ["{{", "{%", "{#"];
300const CLOSE_DELIMITERS: [&str; 3] = ["}}", "%}", "#}"];
301
302fn is_open_delimiter_tokens(left: &Token, right: &Token) -> bool {
303    matches!(
304        (left, right),
305        (Token::LBrace, Token::LBrace)
306            | (Token::LBrace, Token::Mod)
307            | (Token::LBrace, Token::Sharp)
308    )
309}
310
311fn is_close_delimiter_tokens(left: &Token, right: &Token) -> bool {
312    matches!(
313        (left, right),
314        (Token::RBrace, Token::RBrace)
315            | (Token::Mod, Token::RBrace)
316            | (Token::Sharp, Token::RBrace)
317    )
318}
319
320fn has_incorrect_padding_after(sql: &str, delimiter_end: usize) -> bool {
321    let remainder = match sql.get(delimiter_end..) {
322        Some(r) => r,
323        None => return true,
324    };
325    let mut chars = remainder.chars();
326    let first = match chars.next() {
327        Some(ch) => ch,
328        None => return true,
329    };
330
331    // After a trim marker (+/-), require a space before content.
332    if is_trim_marker(first) {
333        return !matches!(chars.next(), Some(' '));
334    }
335
336    if first != ' ' {
337        return true; // missing padding entirely
338    }
339
340    // Check for excess whitespace (more than one space).
341    let spaces = 1 + chars.take_while(|ch| *ch == ' ').count();
342    spaces > 1
343}
344
345fn has_incorrect_padding_before(sql: &str, delimiter_start: usize) -> bool {
346    if delimiter_start == 0 {
347        return true;
348    }
349    let mut rchars = sql[..delimiter_start].chars().rev();
350    let prev = match rchars.next() {
351        Some(ch) => ch,
352        None => return true,
353    };
354
355    // Before a trim marker (+/-), require a space before the marker.
356    if is_trim_marker(prev) {
357        return !matches!(rchars.next(), Some(' '));
358    }
359
360    if prev != ' ' {
361        return true; // missing padding entirely
362    }
363
364    // Check for excess whitespace (more than one space).
365    let spaces = 1 + rchars.take_while(|ch| *ch == ' ').count();
366    spaces > 1
367}
368
369fn is_trim_marker(ch: char) -> bool {
370    ch == '-' || ch == '+'
371}
372
373fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
374    if line == 0 || column == 0 {
375        return None;
376    }
377
378    let mut current_line = 1usize;
379    let mut current_col = 1usize;
380
381    for (offset, ch) in sql.char_indices() {
382        if current_line == line && current_col == column {
383            return Some(offset);
384        }
385
386        if ch == '\n' {
387            current_line += 1;
388            current_col = 1;
389        } else {
390            current_col += 1;
391        }
392    }
393
394    if current_line == line && current_col == column {
395        return Some(sql.len());
396    }
397
398    None
399}
400
401fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
402    let start = line_col_to_offset(
403        sql,
404        token.span.start.line as usize,
405        token.span.start.column as usize,
406    )?;
407    let end = line_col_to_offset(
408        sql,
409        token.span.end.line as usize,
410        token.span.end.column as usize,
411    )?;
412    Some((start, end))
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::parser::parse_sql;
419    use crate::types::IssueAutofixApplicability;
420
421    fn run(sql: &str) -> Vec<Issue> {
422        let statements = parse_sql(sql).expect("parse");
423        let rule = JinjaPadding;
424        statements
425            .iter()
426            .enumerate()
427            .flat_map(|(index, statement)| {
428                rule.check(
429                    statement,
430                    &LintContext {
431                        sql,
432                        statement_range: 0..sql.len(),
433                        statement_index: index,
434                    },
435                )
436            })
437            .collect()
438    }
439
440    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
441        let autofix = issue.autofix.as_ref()?;
442        let mut out = sql.to_string();
443        let mut edits = autofix.edits.clone();
444        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
445        for edit in edits.iter().rev() {
446            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
447        }
448        Some(out)
449    }
450
451    #[test]
452    fn flags_missing_padding_in_jinja_expression() {
453        let sql = "SELECT '{{foo}}' AS templated";
454        let issues = run(sql);
455        assert_eq!(issues.len(), 1);
456        assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
457        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
458        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
459        assert_eq!(
460            issues[0].span.expect("expected span").start,
461            sql.find("{{").expect("expected opening delimiter"),
462        );
463        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
464        assert_eq!(fixed, "SELECT '{{ foo }}' AS templated");
465    }
466
467    #[test]
468    fn does_not_flag_padded_jinja_expression() {
469        assert!(run("SELECT '{{ foo }}' AS templated").is_empty());
470    }
471
472    #[test]
473    fn flags_missing_padding_in_jinja_statement_tag() {
474        let sql = "SELECT '{%for x in y %}' AS templated";
475        let issues = run(sql);
476        assert_eq!(issues.len(), 1);
477        assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
478        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
479        assert_eq!(fixed, "SELECT '{% for x in y %}' AS templated");
480    }
481
482    #[test]
483    fn flags_missing_padding_before_statement_close_tag() {
484        let sql = "SELECT '{% for x in y%}' AS templated";
485        let issues = run(sql);
486        assert_eq!(issues.len(), 1);
487        assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
488        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
489        assert_eq!(fixed, "SELECT '{% for x in y %}' AS templated");
490    }
491
492    #[test]
493    fn flags_missing_padding_in_jinja_comment_tag() {
494        let issues = run("SELECT '{#comment#}' AS templated");
495        assert_eq!(issues.len(), 1);
496        assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
497        assert!(
498            issues[0].autofix.is_none(),
499            "comment-tag JJ001 findings are report-only in current core autofix scope"
500        );
501    }
502
503    #[test]
504    fn allows_jinja_trim_markers() {
505        assert!(run("SELECT '{{- foo -}}' AS templated").is_empty());
506        assert!(run("SELECT '{%- if x -%}' AS templated").is_empty());
507        assert!(run("SELECT '{{+ foo +}}' AS templated").is_empty());
508        assert!(run("SELECT '{%+ if x -%}' AS templated").is_empty());
509    }
510
511    #[test]
512    fn allows_raw_jinja_with_trim_markers_and_correct_spacing() {
513        // SQLFluff: test_simple_modified — should pass
514        assert!(detect("SELECT 1 from {%+ if true -%} foo {%- endif %}\n").is_none());
515    }
516
517    fn detect(sql: &str) -> Option<(usize, usize)> {
518        jinja_padding_violation_span(&LintContext {
519            sql,
520            statement_range: 0..sql.len(),
521            statement_index: 0,
522        })
523    }
524
525    #[test]
526    fn flags_raw_jinja_expression_no_space() {
527        // SQLFluff: test_fail_jinja_tags_no_space
528        assert!(detect("SELECT 1 from {{ref('foo')}}\n").is_some());
529    }
530
531    #[test]
532    fn flags_raw_jinja_expression_multiple_spaces() {
533        // SQLFluff: test_fail_jinja_tags_multiple_spaces
534        assert!(detect("SELECT 1 from {{      ref('foo')       }}\n").is_some());
535    }
536
537    #[test]
538    fn flags_raw_jinja_expression_plus_trim_no_space() {
539        // SQLFluff: test_fail_jinja_tags_no_space_2
540        assert!(detect("SELECT 1 from {{+ref('foo')-}}\n").is_some());
541    }
542
543    #[test]
544    fn flags_raw_jinja_no_content() {
545        // SQLFluff: test_fail_jinja_tags_no_space_no_content
546        assert!(detect("SELECT {{\"\"  -}}1\n").is_some());
547    }
548}