use hashbrown::HashMap;
use regex::Regex;
use smol_str::SmolStr;
use sqruff_lib_core::dialects::syntax::SyntaxKind;
use sqruff_lib_core::lint_fix::LintFix;
use sqruff_lib_core::parser::markers::PositionMarker;
use sqruff_lib_core::parser::segments::SegmentBuilder;
use sqruff_lib_core::parser::segments::fix::SourceFix;
use sqruff_lib_core::templaters::TemplateSliceKind;
use crate::core::config::Value;
use crate::core::rules::context::RuleContext;
use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups, targets_templated};
struct JinjaTagComponents {
opening: String,
leading_ws: String,
content: String,
trailing_ws: String,
closing: String,
}
fn get_whitespace_ends(raw: &str) -> Option<JinjaTagComponents> {
let re = Regex::new(r"^(\{[\{%#][-+]?)(.*?)([-+]?[\}%#]\})$").ok()?;
let captures = re.captures(raw)?;
let opening = captures.get(1)?.as_str().to_string();
let inner = captures.get(2)?.as_str();
let closing = captures.get(3)?.as_str().to_string();
let inner_len = inner.len();
let trimmed_start = inner.trim_start();
let leading_ws_len = inner_len - trimmed_start.len();
let leading_ws = inner[..leading_ws_len].to_string();
let trimmed = trimmed_start.trim_end();
let trailing_ws = trimmed_start[trimmed.len()..].to_string();
let content = trimmed.to_string();
Some(JinjaTagComponents {
opening,
leading_ws,
content,
trailing_ws,
closing,
})
}
fn is_acceptable_whitespace(ws: &str) -> bool {
ws == " " || ws.contains('\n')
}
#[derive(Default, Debug, Clone)]
pub struct RuleJJ01;
impl Rule for RuleJJ01 {
fn load_from_config(&self, _config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
Ok(RuleJJ01.erased())
}
fn name(&self) -> &'static str {
"jinja.padding"
}
fn description(&self) -> &'static str {
"Jinja tags should have a single whitespace on either side."
}
fn long_description(&self) -> &'static str {
r#"
**Anti-pattern**
Jinja tags with either no whitespace or very long whitespace are hard to read.
```jinja
SELECT {{a}} from {{ref('foo')}}
```
**Best practice**
A single whitespace surrounding Jinja tags, alternatively longer gaps containing
newlines are acceptable.
```jinja
SELECT {{ a }} from {{ ref('foo') }};
```
"#
}
fn groups(&self) -> &'static [RuleGroups] {
&[RuleGroups::All, RuleGroups::Core, RuleGroups::Jinja]
}
targets_templated!();
fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
let Some(templated_file) = &context.templated_file else {
return Vec::new();
};
if !templated_file.is_templated() {
return Vec::new();
}
let mut results = Vec::new();
let mut all_source_fixes = Vec::new();
for raw_slice in templated_file.raw_sliced() {
let slice_type = raw_slice.slice_type();
if !matches!(
slice_type,
TemplateSliceKind::Templated
| TemplateSliceKind::BlockStart
| TemplateSliceKind::BlockEnd
| TemplateSliceKind::BlockMid
| TemplateSliceKind::Comment
) {
continue;
}
let raw = raw_slice.raw();
if !raw.starts_with('{') || !raw.ends_with('}') {
continue;
}
let Some(components) = get_whitespace_ends(raw) else {
continue;
};
let leading_ok = is_acceptable_whitespace(&components.leading_ws);
let trailing_ok = is_acceptable_whitespace(&components.trailing_ws);
if !leading_ok || !trailing_ok {
let fixed_tag = format!(
"{} {} {}",
components.opening, components.content, components.closing
);
let description = if !leading_ok && !trailing_ok {
format!(
"Jinja tags should have a single whitespace on either side: `{}` -> `{}`",
raw, fixed_tag
)
} else if !leading_ok {
format!(
"Jinja tags should have a single whitespace on the left side: `{}` -> `{}`",
raw, fixed_tag
)
} else {
format!(
"Jinja tags should have a single whitespace on the right side: `{}` -> \
`{}`",
raw, fixed_tag
)
};
let source_slice = raw_slice.source_slice();
let templated_slice = 0..0;
all_source_fixes.push(SourceFix::new(
SmolStr::new(&fixed_tag),
source_slice.clone(),
templated_slice,
));
let position_marker = PositionMarker::new(
source_slice.clone(),
source_slice,
templated_file.clone(),
None,
None,
);
let anchor =
SegmentBuilder::token(context.tables.next_id(), raw, SyntaxKind::TemplateLoop)
.with_position(position_marker)
.finish();
results.push(LintResult::new(
Some(anchor),
vec![], Some(description),
None,
));
}
}
if !all_source_fixes.is_empty() && !results.is_empty() {
let raw_segments = context.segment.get_raw_segments();
if let Some(anchor_seg) = raw_segments.first() {
let inner_token = SegmentBuilder::token(
context.tables.next_id(),
anchor_seg.raw().as_ref(),
anchor_seg.get_type(),
)
.with_position(anchor_seg.get_position_marker().cloned().unwrap())
.finish();
let fix_segment = SegmentBuilder::node(
context.tables.next_id(),
SyntaxKind::File,
context.dialect.name,
vec![inner_token],
)
.with_source_fixes(all_source_fixes)
.with_position(anchor_seg.get_position_marker().cloned().unwrap())
.finish();
let fix = LintFix::replace(anchor_seg.clone(), vec![fix_segment], None);
for result in &mut results {
result.fixes = vec![fix.clone()];
}
}
}
results
}
fn is_fix_compatible(&self) -> bool {
true
}
fn crawl_behaviour(&self) -> Crawler {
RootOnlyCrawler.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_whitespace_ends_basic() {
let result = get_whitespace_ends("{{ foo }}").unwrap();
assert_eq!(result.opening, "{{");
assert_eq!(result.leading_ws, " ");
assert_eq!(result.content, "foo");
assert_eq!(result.trailing_ws, " ");
assert_eq!(result.closing, "}}");
}
#[test]
fn test_get_whitespace_ends_no_whitespace() {
let result = get_whitespace_ends("{{foo}}").unwrap();
assert_eq!(result.opening, "{{");
assert_eq!(result.leading_ws, "");
assert_eq!(result.content, "foo");
assert_eq!(result.trailing_ws, "");
assert_eq!(result.closing, "}}");
}
#[test]
fn test_get_whitespace_ends_excessive_whitespace() {
let result = get_whitespace_ends("{{ foo }}").unwrap();
assert_eq!(result.opening, "{{");
assert_eq!(result.leading_ws, " ");
assert_eq!(result.content, "foo");
assert_eq!(result.trailing_ws, " ");
assert_eq!(result.closing, "}}");
}
#[test]
fn test_get_whitespace_ends_block() {
let result = get_whitespace_ends("{% if x %}").unwrap();
assert_eq!(result.opening, "{%");
assert_eq!(result.leading_ws, " ");
assert_eq!(result.content, "if x");
assert_eq!(result.trailing_ws, " ");
assert_eq!(result.closing, "%}");
}
#[test]
fn test_get_whitespace_ends_comment() {
let result = get_whitespace_ends("{# comment #}").unwrap();
assert_eq!(result.opening, "{#");
assert_eq!(result.leading_ws, " ");
assert_eq!(result.content, "comment");
assert_eq!(result.trailing_ws, " ");
assert_eq!(result.closing, "#}");
}
#[test]
fn test_get_whitespace_ends_with_modifier() {
let result = get_whitespace_ends("{{- foo -}}").unwrap();
assert_eq!(result.opening, "{{-");
assert_eq!(result.leading_ws, " ");
assert_eq!(result.content, "foo");
assert_eq!(result.trailing_ws, " ");
assert_eq!(result.closing, "-}}");
}
#[test]
fn test_is_acceptable_whitespace() {
assert!(is_acceptable_whitespace(" "));
assert!(is_acceptable_whitespace("\n"));
assert!(is_acceptable_whitespace(" \n "));
assert!(!is_acceptable_whitespace(""));
assert!(!is_acceptable_whitespace(" "));
assert!(!is_acceptable_whitespace("\t"));
}
}