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    "schedule",
14    "date",
15    "date_format",
16    "now",
17    "scheduled_at",
18    "timezone",
19];
20/// Attributes and methods of `run.date`.
21pub use crate::dates::DATE_ATTRS;
22
23static SEGMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)\{\{.*?\}\}|\{%.*?%\}").unwrap());
24static VAR: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r#"(?:^|[^\w.])(env_var|var)\s*\(\s*(['"])([^'"]+)['"]\s*([,)])"#).unwrap());
26static RUN: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"(?:^|[^\w.])run\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?").unwrap());
28
29static MACRO: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r"(?s)\{%-?\s*macro\s+([A-Za-z_]\w*)\s*\(.*?%\}(.*?)\{%-?\s*endmacro\s*-?%\}").unwrap()
31});
32static CALL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w.])([A-Za-z_]\w*)\s*\(").unwrap());
33
34static REF: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r#"(?:^|[^\w.])ref\s*\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap());
36
37/// `root.attr` or `root['attr']` (`results`, `outputs`).
38static ATTRIBUTE: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(r#"(?:^|[^\w.])([A-Za-z_]\w*)\s*(?:\.\s*([A-Za-z_]\w*)|\[\s*['"]([^'"]+)['"]\s*\])"#).unwrap()
40});
41static TARGET: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w.])target\.([A-Za-z_]\w*)").unwrap());
42static SOURCE_ROLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"role\s*=\s*['"]source['"]"#).unwrap());
43
44/// Template names DRE 0.2 removed, inside Jinja blocks: `(name, what to write instead, line)`.
45pub fn removed_names(src: &str) -> Vec<(String, String, usize)> {
46    let mut out = Vec::new();
47    for seg in SEGMENT.find_iter(src) {
48        for c in TARGET.captures_iter(seg.as_str()) {
49            let field = &c[1];
50            if field == "name" {
51                continue;
52            }
53            let line = line_at(src, seg.start() + c.get(0).unwrap().start());
54            let instead = match field {
55                "type" => "`connection.type` (the query's connection)".to_string(),
56                "profile" => "`connection.name` (the query's connection)".to_string(),
57                f => format!(
58                    "`connection.{f}` (the query's connection) or `profile('<name>').{f}`; `target` is now only the environment, `target.name`"
59                ),
60            };
61            out.push((format!("target.{field}"), instead, line));
62        }
63        for c in RUN.captures_iter(seg.as_str()) {
64            let instead = match &c[1] {
65                "profile" => "`connection.name` (the query's connection)",
66                "source_type" => "`connection.type` (the query's connection)",
67                _ => continue,
68            };
69            let line = line_at(src, seg.start() + c.get(0).unwrap().start());
70            out.push((format!("run.{}", &c[1]), instead.to_string(), line));
71        }
72        for m in SOURCE_ROLE.find_iter(seg.as_str()) {
73            out.push((
74                "role='source'".into(),
75                "`role='connection'`".into(),
76                line_at(src, seg.start() + m.start()),
77            ));
78        }
79    }
80    out
81}
82
83/// Every `ref('name')` with a literal name inside Jinja blocks, with its 1-based line.
84pub fn refs(src: &str) -> Vec<(String, usize)> {
85    let mut out = Vec::new();
86    for seg in SEGMENT.find_iter(src) {
87        for c in REF.captures_iter(seg.as_str()) {
88            out.push((
89                c[1].to_string(),
90                line_at(src, seg.start() + c.get(1).unwrap().start()),
91            ));
92        }
93    }
94    out
95}
96
97/// A macro defined in a macro file.
98#[derive(Debug, Clone)]
99pub struct MacroDef {
100    pub name: String,
101    /// The macro's body.
102    pub body: String,
103    /// 0-based line offset of the body within its file.
104    pub line_offset: usize,
105}
106
107/// Every `{% macro name(...) %} ... {% endmacro %}` in a source.
108pub fn macro_defs(src: &str) -> Vec<MacroDef> {
109    MACRO
110        .captures_iter(src)
111        .map(|c| {
112            let body = c.get(2).unwrap();
113            MacroDef {
114                name: c[1].to_string(),
115                body: body.as_str().to_string(),
116                line_offset: line_at(src, body.start()) - 1,
117            }
118        })
119        .collect()
120}
121
122/// Names called like functions inside Jinja blocks (`name(`).
123pub fn called_names(src: &str) -> Vec<String> {
124    let mut out = Vec::new();
125    for seg in SEGMENT.find_iter(src) {
126        for c in CALL.captures_iter(seg.as_str()) {
127            out.push(c[1].to_string());
128        }
129    }
130    out
131}
132
133/// `name.<attr>` and `name['attr']` inside Jinja blocks: the attributes a template reads from
134/// the global `name` (`results`, `outputs`), with their 1-based lines. Dynamic access isn't seen.
135pub fn attributes(src: &str, name: &str) -> Vec<(String, usize)> {
136    let mut out = Vec::new();
137    for seg in SEGMENT.find_iter(src) {
138        for c in ATTRIBUTE.captures_iter(seg.as_str()).filter(|c| &c[1] == name) {
139            let m = c.get(2).or_else(|| c.get(3)).unwrap();
140            out.push((m.as_str().to_string(), line_at(src, seg.start() + m.start())));
141        }
142    }
143    out
144}
145
146/// Whether a string contains Jinja at all.
147pub fn is_templated(s: &str) -> bool {
148    s.contains("{{") || s.contains("{%")
149}
150
151/// Compile-check a template; returns `(line, message)` on a syntax error.
152pub fn check_syntax(name: &str, src: &str) -> Result<(), (Option<usize>, String)> {
153    let env = minijinja::Environment::new();
154    match env.template_from_named_str(name, src) {
155        Ok(_) => Ok(()),
156        Err(e) => Err((e.line(), e.detail().unwrap_or("syntax error").to_string())),
157    }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct Call {
162    /// `var` or `env_var`.
163    pub func: &'static str,
164    pub name: String,
165    pub has_default: bool,
166    /// 1-based line within the scanned source.
167    pub line: usize,
168}
169
170fn line_at(src: &str, offset: usize) -> usize {
171    src[..offset].matches('\n').count() + 1
172}
173
174/// Every `var('x')` / `env_var('X')` call inside Jinja blocks.
175pub fn calls(src: &str) -> Vec<Call> {
176    let mut out = Vec::new();
177    for seg in SEGMENT.find_iter(src) {
178        for c in VAR.captures_iter(seg.as_str()) {
179            let m = c.get(1).unwrap();
180            out.push(Call {
181                func: if m.as_str() == "var" { "var" } else { "env_var" },
182                name: c[3].to_string(),
183                has_default: &c[4] == ",",
184                line: line_at(src, seg.start() + m.start()),
185            });
186        }
187    }
188    out
189}
190
191/// `run.*` references inside Jinja blocks that aren't part of the runtime context.
192pub fn unknown_run_refs(src: &str) -> Vec<(String, usize)> {
193    let mut out = Vec::new();
194    for seg in SEGMENT.find_iter(src) {
195        for c in RUN.captures_iter(seg.as_str()) {
196            let attr = &c[1];
197            let line = line_at(src, seg.start() + c.get(0).unwrap().start());
198            if matches!(attr, "profile" | "source_type") {
199                // Reported by `removed_names`, with the replacement.
200                continue;
201            }
202            if !RUN_ATTRS.contains(&attr) {
203                out.push((format!("run.{attr}"), line));
204            } else if attr == "date"
205                && let Some(sub) = c.get(2)
206                && !DATE_ATTRS.contains(&sub.as_str())
207            {
208                out.push((format!("run.date.{}", sub.as_str()), line));
209            }
210        }
211    }
212    out
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn finds_var_calls_with_and_without_defaults() {
221        let src = "select {{ var('a') }},\n {{ var(\"b\", 1) }}, {{ env_var('HOME') }} from t";
222        let c = calls(src);
223        assert_eq!(c.len(), 3);
224        assert_eq!((c[0].name.as_str(), c[0].has_default, c[0].line), ("a", false, 1));
225        assert_eq!((c[1].name.as_str(), c[1].has_default, c[1].line), ("b", true, 2));
226        assert_eq!((c[2].func, c[2].name.as_str()), ("env_var", "HOME"));
227    }
228
229    #[test]
230    fn ignores_sql_outside_jinja_and_method_like_names() {
231        assert!(calls("select var('x') from t").is_empty());
232        assert!(calls("{{ my.var('x') }} {{ env_var_x('y') }}").is_empty());
233        assert!(unknown_run_refs("select run.dat from runs run").is_empty());
234    }
235
236    #[test]
237    fn flags_unknown_run_attributes() {
238        let src = "{{ run.report }} {{ run.dat }}\n{{ run.date.yyyymmdd }} {{ run.date.yymm }}";
239        assert_eq!(
240            unknown_run_refs(src),
241            vec![("run.dat".to_string(), 1), ("run.date.yymm".to_string(), 2)]
242        );
243    }
244}