Skip to main content

dre_core/
preflight.rs

1//! Jinja pre-flight: syntax checks and static scans of `var()`, `env_var()` and `run.*` call
2//! sites. Nothing is rendered with live values here.
3
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8/// `run.*` attributes the runtime context provides.
9pub const RUN_ATTRS: &[&str] = &[
10    "report",
11    "set",
12    "target",
13    "profile",
14    "source_type",
15    "schedule",
16    "date",
17    "date_format",
18    "now",
19    "scheduled_at",
20    "timezone",
21];
22/// Attributes and methods of `run.date`.
23pub use crate::dates::DATE_ATTRS;
24
25static SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)\{\{.*?\}\}|\{%.*?%\}").unwrap());
26static VAR: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r#"(?:^|[^\w.])(env_var|var)\s*\(\s*(['"])([^'"]+)['"]\s*([,)])"#).unwrap());
28static RUN: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"(?:^|[^\w.])run\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?").unwrap());
30
31static MACRO: LazyLock<Regex> = LazyLock::new(|| {
32    Regex::new(r"(?s)\{%-?\s*macro\s+([A-Za-z_]\w*)\s*\(.*?%\}(.*?)\{%-?\s*endmacro\s*-?%\}").unwrap()
33});
34static CALL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w.])([A-Za-z_]\w*)\s*\(").unwrap());
35
36static REF: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r#"(?:^|[^\w.])ref\s*\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap());
38
39/// Every `ref('name')` with a literal name inside Jinja blocks, with its 1-based line.
40pub fn refs(src: &str) -> Vec<(String, usize)> {
41    let mut out = Vec::new();
42    for seg in SEGMENT.find_iter(src) {
43        for c in REF.captures_iter(seg.as_str()) {
44            out.push((
45                c[1].to_string(),
46                line_at(src, seg.start() + c.get(1).unwrap().start()),
47            ));
48        }
49    }
50    out
51}
52
53/// A macro defined in a macro file.
54#[derive(Debug, Clone)]
55pub struct MacroDef {
56    pub name: String,
57    /// The macro's body.
58    pub body: String,
59    /// 0-based line offset of the body within its file.
60    pub line_offset: usize,
61}
62
63/// Every `{% macro name(...) %} ... {% endmacro %}` in a source.
64pub fn macro_defs(src: &str) -> Vec<MacroDef> {
65    MACRO
66        .captures_iter(src)
67        .map(|c| {
68            let body = c.get(2).unwrap();
69            MacroDef {
70                name: c[1].to_string(),
71                body: body.as_str().to_string(),
72                line_offset: line_at(src, body.start()) - 1,
73            }
74        })
75        .collect()
76}
77
78/// Names called like functions inside Jinja blocks (`name(`).
79pub fn called_names(src: &str) -> Vec<String> {
80    let mut out = Vec::new();
81    for seg in SEGMENT.find_iter(src) {
82        for c in CALL.captures_iter(seg.as_str()) {
83            out.push(c[1].to_string());
84        }
85    }
86    out
87}
88
89/// Whether a string contains Jinja at all.
90pub fn is_templated(s: &str) -> bool {
91    s.contains("{{") || s.contains("{%")
92}
93
94/// Compile-check a template; returns `(line, message)` on a syntax error.
95pub fn check_syntax(name: &str, src: &str) -> Result<(), (Option<usize>, String)> {
96    let env = minijinja::Environment::new();
97    match env.template_from_named_str(name, src) {
98        Ok(_) => Ok(()),
99        Err(e) => Err((e.line(), e.detail().unwrap_or("syntax error").to_string())),
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Call {
105    /// `var` or `env_var`.
106    pub func: &'static str,
107    pub name: String,
108    pub has_default: bool,
109    /// 1-based line within the scanned source.
110    pub line: usize,
111}
112
113fn line_at(src: &str, offset: usize) -> usize {
114    src[..offset].matches('\n').count() + 1
115}
116
117/// Every `var('x')` / `env_var('X')` call inside Jinja blocks.
118pub fn calls(src: &str) -> Vec<Call> {
119    let mut out = Vec::new();
120    for seg in SEGMENT.find_iter(src) {
121        for c in VAR.captures_iter(seg.as_str()) {
122            let m = c.get(1).unwrap();
123            out.push(Call {
124                func: if m.as_str() == "var" { "var" } else { "env_var" },
125                name: c[3].to_string(),
126                has_default: &c[4] == ",",
127                line: line_at(src, seg.start() + m.start()),
128            });
129        }
130    }
131    out
132}
133
134/// `run.*` references inside Jinja blocks that aren't part of the runtime context.
135pub fn unknown_run_refs(src: &str) -> Vec<(String, usize)> {
136    let mut out = Vec::new();
137    for seg in SEGMENT.find_iter(src) {
138        for c in RUN.captures_iter(seg.as_str()) {
139            let attr = &c[1];
140            let line = line_at(src, seg.start() + c.get(0).unwrap().start());
141            if !RUN_ATTRS.contains(&attr) {
142                out.push((format!("run.{attr}"), line));
143            } else if attr == "date"
144                && let Some(sub) = c.get(2)
145                && !DATE_ATTRS.contains(&sub.as_str())
146            {
147                out.push((format!("run.date.{}", sub.as_str()), line));
148            }
149        }
150    }
151    out
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn finds_var_calls_with_and_without_defaults() {
160        let src = "select {{ var('a') }},\n {{ var(\"b\", 1) }}, {{ env_var('HOME') }} from t";
161        let c = calls(src);
162        assert_eq!(c.len(), 3);
163        assert_eq!((c[0].name.as_str(), c[0].has_default, c[0].line), ("a", false, 1));
164        assert_eq!((c[1].name.as_str(), c[1].has_default, c[1].line), ("b", true, 2));
165        assert_eq!((c[2].func, c[2].name.as_str()), ("env_var", "HOME"));
166    }
167
168    #[test]
169    fn ignores_sql_outside_jinja_and_method_like_names() {
170        assert!(calls("select var('x') from t").is_empty());
171        assert!(calls("{{ my.var('x') }} {{ env_var_x('y') }}").is_empty());
172        assert!(unknown_run_refs("select run.dat from runs run").is_empty());
173    }
174
175    #[test]
176    fn flags_unknown_run_attributes() {
177        let src = "{{ run.report }} {{ run.dat }}\n{{ run.date.yyyymmdd }} {{ run.date.yymm }}";
178        assert_eq!(
179            unknown_run_refs(src),
180            vec![("run.dat".to_string(), 1), ("run.date.yymm".to_string(), 2)]
181        );
182    }
183}