flowscope_core/linter/rules/
jj_001.rs1use 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 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 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 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
274fn 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 if is_trim_marker(first) {
333 return !matches!(chars.next(), Some(' '));
334 }
335
336 if first != ' ' {
337 return true; }
339
340 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 if is_trim_marker(prev) {
357 return !matches!(rchars.next(), Some(' '));
358 }
359
360 if prev != ' ' {
361 return true; }
363
364 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 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 assert!(detect("SELECT 1 from {{ref('foo')}}\n").is_some());
529 }
530
531 #[test]
532 fn flags_raw_jinja_expression_multiple_spaces() {
533 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 assert!(detect("SELECT 1 from {{+ref('foo')-}}\n").is_some());
541 }
542
543 #[test]
544 fn flags_raw_jinja_no_content() {
545 assert!(detect("SELECT {{\"\" -}}1\n").is_some());
547 }
548}