Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/lib-core/src/parser/segments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ impl ErasedSegment {
}

#[track_caller]
pub(crate) fn make_mut(&mut self) -> &mut NodeOrToken {
pub fn make_mut(&mut self) -> &mut NodeOrToken {
Rc::make_mut(&mut self.value)
}

Expand Down Expand Up @@ -977,7 +977,7 @@ pub struct NodeOrToken {
syntax_kind: SyntaxKind,
class_types: SyntaxSet,
position_marker: Option<PositionMarker>,
kind: NodeOrTokenKind,
pub kind: NodeOrTokenKind,
code_idx: OnceCell<Rc<Vec<usize>>>,
hash: OnceCell<u64>,
}
Expand All @@ -1003,7 +1003,7 @@ pub struct NodeData {
dialect: DialectKind,
segments: Vec<ErasedSegment>,
raw: OnceCell<SmolStr>,
source_fixes: Vec<SourceFix>,
pub source_fixes: Vec<SourceFix>,
descendant_type_set: OnceCell<SyntaxSet>,
raw_segments_with_ancestors: OnceCell<Vec<(ErasedSegment, Vec<PathStep>)>>,
}
Expand Down
12 changes: 12 additions & 0 deletions crates/lib-core/src/templaters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ impl TemplatedFile {
let inner = &*self.inner;
serde_yaml::to_string(inner).unwrap()
}

pub fn raw_slices(&self) -> &[RawFileSlice] {
&self.inner.raw_sliced
}
}

impl From<String> for TemplatedFile {
Expand Down Expand Up @@ -580,6 +584,14 @@ impl RawFileSlice {
block_idx: block_idx.unwrap_or(0),
}
}

pub fn raw(&self) -> &str {
&self.raw
}

pub fn slice_type(&self) -> &str {
&self.slice_type
}
}

impl RawFileSlice {
Expand Down
1 change: 1 addition & 0 deletions crates/lib/src/core/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub enum RuleGroups {
Layout,
References,
Structure,
Jinja,
}

impl LintResult {
Expand Down
2 changes: 2 additions & 0 deletions crates/lib/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod aliasing;
pub mod ambiguous;
pub mod capitalisation;
pub mod convention;
pub mod jinja;
pub mod layout;
pub mod references;
pub mod structure;
Expand All @@ -17,6 +18,7 @@ pub fn rules() -> Vec<ErasedRule> {
ambiguous::rules(),
capitalisation::rules(),
convention::rules(),
jinja::rules(),
layout::rules(),
references::rules(),
structure::rules()
Expand Down
8 changes: 8 additions & 0 deletions crates/lib/src/rules/jinja.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use crate::core::rules::ErasedRule;

pub mod jj01;

pub fn rules() -> Vec<ErasedRule> {
use crate::core::rules::Erased as _;
vec![jj01::RuleJJ01.erased()]
}
205 changes: 205 additions & 0 deletions crates/lib/src/rules/jinja/jj01.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
use ahash::AHashMap;
use sqruff_lib_core::lint_fix::LintFix;
use sqruff_lib_core::parser::segments::fix::SourceFix;
use sqruff_lib_core::parser::segments::{ErasedSegment, NodeOrTokenKind, SegmentBuilder};

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};

#[derive(Debug, Default, Clone)]
pub struct RuleJJ01;

impl RuleJJ01 {
fn get_whitespace_ends(s: &str) -> (String, String, String, String, String) {
assert!(s.starts_with('{') && s.ends_with('}'));
let mut main = &s[2..s.len() - 2];
let mut pre = &s[..2];
let mut post = &s[s.len() - 2..];
let modifier_chars = ['+', '-'];
if !main.is_empty() && modifier_chars.contains(&main.chars().next().unwrap()) {
pre = &s[..3];
main = &s[3..s.len() - 2];
}
if !main.is_empty() && modifier_chars.contains(&main.chars().last().unwrap()) {
post = &s[s.len() - 3..];
main = &main[..main.len() - 1];
}
let inner = main.trim();
let pos = main.find(inner).unwrap_or(0);
(
pre.to_string(),
main[..pos].to_string(),
inner.to_string(),
main[pos + inner.len()..].to_string(),
post.to_string(),
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Jinja Tag Parsing Fails on Short Strings

The get_whitespace_ends function assumes a minimum length for Jinja tags when performing slicing, but its input validation only checks for opening and closing braces. This can cause panics for short strings like "{}" or "{}}" due to invalid slice indices.

Fix in Cursor Fix in Web


fn find_raw_at_src_idx(segment: &ErasedSegment, src_idx: usize) -> Option<ErasedSegment> {
if segment.segments().is_empty() {
return None;
}
for seg in segment.segments() {
if let Some(pos_marker) = seg.get_position_marker() {
let src_slice = pos_marker.source_slice.clone();
if src_slice.end <= src_idx {
continue;
}
if seg.segments().is_empty() {
return Some(seg.clone());
Comment on lines +40 to +51

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Anchor Jinja fixes to the wrong segment when no AST node exists

The helper used by JJ01 to locate the segment for a raw Jinja slice only checks source_slice.end <= src_idx before returning a leaf. When a Jinja tag does not produce any SQL tokens (e.g. control tags like {% if %} or {% endif %}), there is no segment whose source_slice covers that tag. find_raw_at_src_idx therefore returns the first segment that follows the tag rather than failing, which means the subsequent LintFix uses the templated slice of an unrelated SQL token. Applying the fix will either modify the wrong templated region or fail due to mismatched templated/source slices. The search should ensure src_slice.start <= src_idx < src_slice.end and return None otherwise so that fixes target the correct segment.

Useful? React with 👍 / 👎.

} else {
if let Some(res) = Self::find_raw_at_src_idx(seg, src_idx) {
return Some(res);
}
}
}
}
None
}
}

impl Rule for RuleJJ01 {
fn load_from_config(&self, _config: &AHashMap<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#"Jinja tags should have a single whitespace on either side.

**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') }};
SELECT {{ a }} from {{
ref('foo')
}};
```"#
}

fn groups(&self) -> &'static [RuleGroups] {
&[RuleGroups::All, RuleGroups::Core, RuleGroups::Jinja]
}

fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
let pos_marker = match context.segment.get_position_marker() {
Some(pm) => pm,
None => return Vec::new(),
};
if pos_marker.is_literal() {
return Vec::new();
}
let templater = context
.config
.get("templater", "core")
.as_string()
.unwrap_or("raw");
if templater != "jinja" && templater != "dbt" {
return Vec::new();
}
let templated_file = match &context.templated_file {
Some(tf) => tf,
None => return Vec::new(),
};
let mut results = Vec::new();
for raw_slice in templated_file.raw_slices() {
match raw_slice.slice_type() {
"templated" | "block_start" | "block_end" => {}
_ => continue,
}
let stripped = raw_slice.raw().trim();
if stripped.is_empty() || !stripped.starts_with('{') || !stripped.ends_with('}') {
continue;
}
let (tag_pre, ws_pre, inner, ws_post, tag_post) = Self::get_whitespace_ends(stripped);
let mut pre_fix: Option<String> = None;
let mut post_fix: Option<String> = None;
if ws_pre.is_empty() || (ws_pre != " " && !ws_pre.contains('\n')) {
pre_fix = Some(" ".into());
}
if ws_post.is_empty() || (ws_post != " " && !ws_post.contains('\n')) {
post_fix = Some(" ".into());
}
if pre_fix.is_none() && post_fix.is_none() {
continue;
}
let fixed = format!(
"{}{}{}{}{}",
tag_pre,
pre_fix.clone().unwrap_or(ws_pre.clone()),
inner,
post_fix.clone().unwrap_or(ws_post.clone()),
tag_post
);
let src_idx = raw_slice.source_idx;
let position = raw_slice
.raw()
.find(stripped.chars().next().unwrap())
.unwrap_or(0);
let Some(raw_seg) = Self::find_raw_at_src_idx(&context.segment, src_idx) else {
continue;
};
if !raw_seg.get_source_fixes().is_empty() {
continue;
}
let source_fix = SourceFix::new(
fixed.clone().into(),
src_idx + position..src_idx + position + stripped.len(),
raw_seg
.get_position_marker()
.unwrap()
.templated_slice
.clone(),
);
let mut edit_seg = SegmentBuilder::node(
context.tables.next_id(),
raw_seg.get_type(),
context.dialect.name,
vec![raw_seg.clone()],
)
.with_position(raw_seg.get_position_marker().unwrap().clone())
.finish();
if let NodeOrTokenKind::Node(node) = &mut edit_seg.make_mut().kind {
node.source_fixes.push(source_fix);
}
let fix = LintFix::replace(raw_seg.clone(), vec![edit_seg], None);
results.push(LintResult::new(
Some(raw_seg),
vec![fix],
Some(format!(
"Jinja tags should have a single whitespace on either side: {}",
stripped
)),
None,
));
}
results
}

fn is_fix_compatible(&self) -> bool {
true
}

fn crawl_behaviour(&self) -> Crawler {
RootOnlyCrawler.into()
}
}
99 changes: 99 additions & 0 deletions crates/lib/test/fixtures/rules/std_rule_cases/JJ01.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
rule: JJ01

test_simple:
pass_str: SELECT 1 from {{ "foo" }}
configs:
core:
templater: jinja

test_simple_modified:
# Test that the plus/minus notation works fine.
pass_str: SELECT 1 from {%+ if true -%} foo {%- endif %}
configs:
core:
templater: jinja

test_simple_modified_fail:
# Test that the plus/minus notation works fine.
fail_str: SELECT 1 from {%+if true-%} {{"foo"}} {%-endif%}
fix_str: SELECT 1 from {%+ if true -%} {{ "foo" }} {%- endif %}
configs:
core:
templater: jinja

test_fail_jinja_tags_no_space:
fail_str: SELECT 1 from {{"foo"}}
fix_str: SELECT 1 from {{ "foo" }}
configs:
core:
templater: jinja

test_fail_jinja_tags_multiple_spaces:
fail_str: SELECT 1 from {{ "foo" }}
fix_str: SELECT 1 from {{ "foo" }}
configs:
core:
templater: jinja

test_fail_jinja_tags_no_space_2:
fail_str: SELECT 1 from {{+"foo"-}}
fix_str: SELECT 1 from {{+ "foo" -}}
configs:
core:
templater: jinja

test_pass_newlines:
# It's ok if there are newlines.
pass_str: |
SELECT 1 from {{
"foo"
}}
configs:
core:
templater: jinja

test_fail_templated_segment_contains_leading_literal:
fail_str: |
SELECT user_id
FROM
`{{"gcp_project"}}.{{"dataset"}}.campaign_performance`
fix_str: |
SELECT user_id
FROM
`{{ "gcp_project" }}.{{ "dataset" }}.campaign_performance`
configs:
core:
dialect: bigquery
templater: jinja

test_fail_segment_contains_multiple_templated_slices_last_one_bad:
fail_str: CREATE TABLE `{{ "project" }}.{{ "dataset" }}.{{"table"}}`
fix_str: CREATE TABLE `{{ "project" }}.{{ "dataset" }}.{{ "table" }}`
configs:
core:
dialect: bigquery
templater: jinja

test_fail_jinja_tags_no_space_no_content:
fail_str: SELECT {{""-}}1
fix_str: SELECT {{ "" -}}1
configs:
core:
templater: jinja

test_fail_jinja_tags_across_segment_boundaries:
fail_str: SELECT a{{-"1 + b"}}2
fix_str: SELECT a{{- "1 + b" }}2
configs:
core:
templater: jinja

test_pass_python_templater:
pass_str: SELECT * FROM hello.{my_table};
configs:
core:
templater: python
templater:
python:
context:
my_table: foo
Loading
Loading