use crate::linter::rule::{LintContext, LintRule};
use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
use sqlparser::ast::Statement;
use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer};
pub struct JinjaPadding;
impl LintRule for JinjaPadding {
fn code(&self) -> &'static str {
issue_codes::LINT_JJ_001
}
fn name(&self) -> &'static str {
"Jinja padding"
}
fn description(&self) -> &'static str {
"Jinja tags should have a single whitespace on either side."
}
fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
let Some((start, end)) = jinja_padding_violation_span(ctx) else {
return Vec::new();
};
let mut issue = Issue::info(
issue_codes::LINT_JJ_001,
"Jinja tag spacing appears inconsistent.",
)
.with_statement(ctx.statement_index)
.with_span(ctx.span_from_statement_offset(start, end));
let edits: Vec<IssuePatchEdit> = jinja_padding_autofix_edits(ctx.statement_sql())
.into_iter()
.map(|edit| {
IssuePatchEdit::new(
ctx.span_from_statement_offset(edit.start, edit.end),
edit.replacement,
)
})
.collect();
if !edits.is_empty() {
issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
}
vec![issue]
}
}
fn jinja_padding_violation_span(ctx: &LintContext) -> Option<(usize, usize)> {
let sql = ctx.statement_sql();
if let Some(tokens) = token_spans_for_context(ctx).or_else(|| token_spans(sql, ctx.dialect())) {
for token in &tokens {
if let Some(span) = token_text_violation(sql, token) {
return Some(span);
}
}
for pair in tokens.windows(2) {
let left = &pair[0];
let right = &pair[1];
if is_open_delimiter_tokens(&left.token, &right.token) {
let delimiter_start = left.start;
let delimiter_end = right.end;
if has_incorrect_padding_after(sql, delimiter_end) {
return Some((delimiter_start, delimiter_end));
}
}
if is_close_delimiter_tokens(&left.token, &right.token) {
let delimiter_start = left.start;
let delimiter_end = right.end;
if has_incorrect_padding_before(sql, delimiter_start) {
return Some((delimiter_start, delimiter_end));
}
}
}
}
let edits = jinja_padding_autofix_edits(sql);
if let Some(edit) = edits.first() {
return Some((edit.start, edit.end));
}
None
}
struct TokenSpan {
token: Token,
start: usize,
end: usize,
}
fn token_spans(sql: &str, dialect: Dialect) -> Option<Vec<TokenSpan>> {
let dialect = dialect.to_sqlparser_dialect();
let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
let tokens: Vec<TokenWithSpan> = tokenizer.tokenize_with_location().ok()?;
let mut out = Vec::with_capacity(tokens.len());
for token in tokens {
let start = line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let end = line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
if start < end {
out.push(TokenSpan {
token: token.token,
start,
end,
});
}
}
Some(out)
}
fn token_spans_for_context(ctx: &LintContext) -> Option<Vec<TokenSpan>> {
let offset = ctx.statement_range.start;
ctx.with_document_tokens(|tokens| {
if tokens.is_empty() {
return None;
}
let mut out = Vec::new();
for token in tokens {
let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
continue;
};
if start < ctx.statement_range.start || end > ctx.statement_range.end {
continue;
}
if start < end {
out.push(TokenSpan {
token: token.token.clone(),
start: start - offset,
end: end - offset,
});
}
}
if out.is_empty() {
None
} else {
Some(out)
}
})
}
fn token_text_violation(sql: &str, token: &TokenSpan) -> Option<(usize, usize)> {
let text = &sql[token.start..token.end];
for pattern in &OPEN_DELIMITERS {
for (idx, _) in text.match_indices(pattern) {
let delimiter_start = token.start + idx;
let delimiter_end = delimiter_start + pattern.len();
if has_incorrect_padding_after(sql, delimiter_end) {
return Some((delimiter_start, delimiter_end));
}
}
}
for pattern in &CLOSE_DELIMITERS {
for (idx, _) in text.match_indices(pattern) {
let delimiter_start = token.start + idx;
if has_incorrect_padding_before(sql, delimiter_start) {
return Some((delimiter_start, delimiter_start + pattern.len()));
}
}
}
None
}
#[derive(Debug)]
struct JinjaPaddingEdit {
start: usize,
end: usize,
replacement: String,
}
fn jinja_padding_autofix_edits(sql: &str) -> Vec<JinjaPaddingEdit> {
let mut edits =
normalize_template_tag_padding_edits(sql, b"{{", b"}}", |b| b != b'{' && b != b'}');
edits.extend(normalize_template_tag_padding_edits(
sql,
b"{%",
b"%}",
|b| b != b'%',
));
edits.sort_by_key(|edit| (edit.start, edit.end));
edits.dedup_by(|left, right| {
left.start == right.start && left.end == right.end && left.replacement == right.replacement
});
edits
}
fn normalize_template_tag_padding_edits<F>(
sql: &str,
open: &[u8],
close: &[u8],
inner_ok: F,
) -> Vec<JinjaPaddingEdit>
where
F: Fn(u8) -> bool,
{
let bytes = sql.as_bytes();
let mut edits = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
let mut replaced = false;
if i + open.len() <= bytes.len() && &bytes[i..i + open.len()] == open {
let mut j = i + open.len();
while j + close.len() <= bytes.len() {
if &bytes[j..j + close.len()] == close {
let inner = &sql[i + open.len()..j];
if !inner.is_empty() && inner.as_bytes().iter().copied().all(&inner_ok) {
let open_text =
std::str::from_utf8(open).expect("template delimiter is ascii");
let close_text =
std::str::from_utf8(close).expect("template delimiter is ascii");
let trimmed = inner.trim();
let (open_marker, content, close_marker) = extract_trim_markers(trimmed);
let content = content.trim();
let replacement = format!(
"{open_text}{open_marker} {content} {close_marker}{close_text}"
);
let end = j + close.len();
if replacement != sql[i..end] {
edits.push(JinjaPaddingEdit {
start: i,
end,
replacement,
});
}
i = end;
replaced = true;
}
break;
}
j += 1;
}
if replaced {
continue;
}
}
i += 1;
}
edits
}
fn extract_trim_markers(content: &str) -> (&str, &str, &str) {
let bytes = content.as_bytes();
let mut start = 0;
let mut end = bytes.len();
let open_marker = if !bytes.is_empty() && (bytes[0] == b'+' || bytes[0] == b'-') {
start = 1;
&content[..1]
} else {
""
};
let close_marker = if end > start && (bytes[end - 1] == b'+' || bytes[end - 1] == b'-') {
end -= 1;
&content[end..end + 1]
} else {
""
};
(open_marker, &content[start..end], close_marker)
}
const OPEN_DELIMITERS: [&str; 3] = ["{{", "{%", "{#"];
const CLOSE_DELIMITERS: [&str; 3] = ["}}", "%}", "#}"];
fn is_open_delimiter_tokens(left: &Token, right: &Token) -> bool {
matches!(
(left, right),
(Token::LBrace, Token::LBrace)
| (Token::LBrace, Token::Mod)
| (Token::LBrace, Token::Sharp)
)
}
fn is_close_delimiter_tokens(left: &Token, right: &Token) -> bool {
matches!(
(left, right),
(Token::RBrace, Token::RBrace)
| (Token::Mod, Token::RBrace)
| (Token::Sharp, Token::RBrace)
)
}
fn has_incorrect_padding_after(sql: &str, delimiter_end: usize) -> bool {
let remainder = match sql.get(delimiter_end..) {
Some(r) => r,
None => return true,
};
let mut chars = remainder.chars();
let first = match chars.next() {
Some(ch) => ch,
None => return true,
};
if is_trim_marker(first) {
return !matches!(chars.next(), Some(' '));
}
if first != ' ' {
return true; }
let spaces = 1 + chars.take_while(|ch| *ch == ' ').count();
spaces > 1
}
fn has_incorrect_padding_before(sql: &str, delimiter_start: usize) -> bool {
if delimiter_start == 0 {
return true;
}
let mut rchars = sql[..delimiter_start].chars().rev();
let prev = match rchars.next() {
Some(ch) => ch,
None => return true,
};
if is_trim_marker(prev) {
return !matches!(rchars.next(), Some(' '));
}
if prev != ' ' {
return true; }
let spaces = 1 + rchars.take_while(|ch| *ch == ' ').count();
spaces > 1
}
fn is_trim_marker(ch: char) -> bool {
ch == '-' || ch == '+'
}
fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
if line == 0 || column == 0 {
return None;
}
let mut current_line = 1usize;
let mut current_col = 1usize;
for (offset, ch) in sql.char_indices() {
if current_line == line && current_col == column {
return Some(offset);
}
if ch == '\n' {
current_line += 1;
current_col = 1;
} else {
current_col += 1;
}
}
if current_line == line && current_col == column {
return Some(sql.len());
}
None
}
fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
let start = line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let end = line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_sql;
use crate::types::IssueAutofixApplicability;
fn run(sql: &str) -> Vec<Issue> {
let statements = parse_sql(sql).expect("parse");
let rule = JinjaPadding;
statements
.iter()
.enumerate()
.flat_map(|(index, statement)| {
rule.check(
statement,
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: index,
},
)
})
.collect()
}
fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
let autofix = issue.autofix.as_ref()?;
let mut out = sql.to_string();
let mut edits = autofix.edits.clone();
edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
for edit in edits.iter().rev() {
out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
}
Some(out)
}
#[test]
fn flags_missing_padding_in_jinja_expression() {
let sql = "SELECT '{{foo}}' AS templated";
let issues = run(sql);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
assert_eq!(
issues[0].span.expect("expected span").start,
sql.find("{{").expect("expected opening delimiter"),
);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT '{{ foo }}' AS templated");
}
#[test]
fn does_not_flag_padded_jinja_expression() {
assert!(run("SELECT '{{ foo }}' AS templated").is_empty());
}
#[test]
fn flags_missing_padding_in_jinja_statement_tag() {
let sql = "SELECT '{%for x in y %}' AS templated";
let issues = run(sql);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT '{% for x in y %}' AS templated");
}
#[test]
fn flags_missing_padding_before_statement_close_tag() {
let sql = "SELECT '{% for x in y%}' AS templated";
let issues = run(sql);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT '{% for x in y %}' AS templated");
}
#[test]
fn flags_missing_padding_in_jinja_comment_tag() {
let issues = run("SELECT '{#comment#}' AS templated");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_JJ_001);
assert!(
issues[0].autofix.is_none(),
"comment-tag JJ001 findings are report-only in current core autofix scope"
);
}
#[test]
fn allows_jinja_trim_markers() {
assert!(run("SELECT '{{- foo -}}' AS templated").is_empty());
assert!(run("SELECT '{%- if x -%}' AS templated").is_empty());
assert!(run("SELECT '{{+ foo +}}' AS templated").is_empty());
assert!(run("SELECT '{%+ if x -%}' AS templated").is_empty());
}
#[test]
fn allows_raw_jinja_with_trim_markers_and_correct_spacing() {
assert!(detect("SELECT 1 from {%+ if true -%} foo {%- endif %}\n").is_none());
}
fn detect(sql: &str) -> Option<(usize, usize)> {
jinja_padding_violation_span(&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
})
}
#[test]
fn flags_raw_jinja_expression_no_space() {
assert!(detect("SELECT 1 from {{ref('foo')}}\n").is_some());
}
#[test]
fn flags_raw_jinja_expression_multiple_spaces() {
assert!(detect("SELECT 1 from {{ ref('foo') }}\n").is_some());
}
#[test]
fn flags_raw_jinja_expression_plus_trim_no_space() {
assert!(detect("SELECT 1 from {{+ref('foo')-}}\n").is_some());
}
#[test]
fn flags_raw_jinja_no_content() {
assert!(detect("SELECT {{\"\" -}}1\n").is_some());
}
}