Skip to main content

harn_vm/stdlib/template/
lint.rs

1//! AST surface that `harn-lint` consumes to enforce `.harn.prompt`
2//! drift-prevention rules (#1669).
3//!
4//! The template parser and AST are otherwise internal — exposing a
5//! shallow read-only view through this module keeps the lint crate
6//! free of template-engine internals while still giving rules enough
7//! structure to walk conditionals, sections, and includes.
8
9use super::ast::{BinOp, Expr, Node, PathSeg};
10use super::error::TemplateParseError;
11use super::parser::parse as parse_template;
12use crate::runtime_limits::RuntimeLimits;
13
14const TEMPLATE_LINT_AST_MAX_DEPTH: usize = RuntimeLimits::DEFAULT.max_template_ast_depth;
15
16/// Parse a template source string into a flat list of lintable
17/// constructs (conditionals + sections). Returns `Err` when the
18/// template doesn't parse — callers should surface that failure to the
19/// user before linting.
20pub fn parse(src: &str) -> Result<Vec<LintConstruct>, TemplateParseError> {
21    let nodes = parse_template(src).map_err(TemplateParseError::from)?;
22    let mut out = Vec::new();
23    walk_nodes(&nodes, &mut out, 0)?;
24    out.extend(filter_uses(src)?);
25    Ok(out)
26}
27
28/// One lintable construct. Rules use these to reason about counts
29/// (e.g. branch explosion) and individual call sites (e.g. provider-identity
30/// comparisons and filter names).
31#[derive(Debug, Clone)]
32pub enum LintConstruct {
33    /// An `{{ if .. }}` / `{{ elif }}` chain. One entry per condition
34    /// in the chain (the trailing `{{ else }}` is implicit and not
35    /// listed). Conditions are flattened across `elif` to make
36    /// branch-count rules straightforward.
37    IfChain { branches: Vec<IfBranch> },
38    /// A `{{ section "..." }}` block. Sections are themselves
39    /// capability-adaptive but never look identity-driven; rules use
40    /// this to count capability-aware partials.
41    Section {
42        name: String,
43        line: usize,
44        col: usize,
45    },
46    /// A filter named after `|`, with the exact byte range of its name.
47    ///
48    /// The parser has already accepted the surrounding expression. This
49    /// shallow view lets lint and editor diagnostics validate the name
50    /// against the engine's filter registry without exposing the template AST.
51    Filter {
52        name: String,
53        start: usize,
54        end: usize,
55    },
56}
57
58#[derive(Debug, Clone)]
59pub struct IfBranch {
60    pub line: usize,
61    pub col: usize,
62    pub condition: ConditionShape,
63}
64
65/// Coarse classification of an `{{ if expr }}` condition. The lint
66/// rules don't need to evaluate or fully reconstruct expressions —
67/// just enough structure to detect the two failure patterns called
68/// out in #1669:
69///
70/// - Identity comparisons (`llm.provider == "..."`).
71/// - Capability-flag branches (`llm.capabilities.<flag>`), which the
72///   variant-explosion rule counts.
73///
74/// Conditions outside these shapes resolve to `Other` and don't
75/// participate in either rule.
76#[derive(Debug, Clone)]
77pub enum ConditionShape {
78    /// `llm.provider == "..."` / `llm.model == "..."` /
79    /// `llm.family == "..."` (or `!=`).
80    ProviderIdentity(IdentityField),
81    /// Any path-based condition mentioning `llm.capabilities.<flag>`
82    /// (including negation and use as a comparison operand). The
83    /// variant-explosion rule counts every branch with this shape.
84    /// Source position lives on the surrounding [`IfBranch`].
85    CapabilityFlag {
86        flag: String,
87    },
88    Other,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum IdentityField {
93    Provider,
94    Model,
95    Family,
96}
97
98impl IdentityField {
99    pub fn as_str(self) -> &'static str {
100        match self {
101            IdentityField::Provider => "provider",
102            IdentityField::Model => "model",
103            IdentityField::Family => "family",
104        }
105    }
106}
107
108fn walk_nodes(
109    nodes: &[Node],
110    out: &mut Vec<LintConstruct>,
111    depth: usize,
112) -> Result<(), TemplateParseError> {
113    for node in nodes {
114        walk_node(node, out, depth)?;
115    }
116    Ok(())
117}
118
119fn walk_node(
120    node: &Node,
121    out: &mut Vec<LintConstruct>,
122    depth: usize,
123) -> Result<(), TemplateParseError> {
124    if depth > TEMPLATE_LINT_AST_MAX_DEPTH {
125        return Err(lint_depth_error(node));
126    }
127
128    match node {
129        Node::Text(_) | Node::Expr { .. } | Node::LegacyBareInterp { .. } => {}
130        Node::If {
131            branches,
132            else_branch,
133            line: _,
134            col: _,
135        } => {
136            let mut summary = Vec::with_capacity(branches.len());
137            for branch in branches {
138                summary.push(IfBranch {
139                    line: branch.line,
140                    col: branch.col,
141                    condition: classify_condition(&branch.cond),
142                });
143                walk_nodes(&branch.body, out, depth + 1)?;
144            }
145            out.push(LintConstruct::IfChain { branches: summary });
146            if let Some(else_body) = else_branch {
147                walk_nodes(else_body, out, depth + 1)?;
148            }
149        }
150        Node::For { body, empty, .. } => {
151            walk_nodes(body, out, depth + 1)?;
152            if let Some(empty) = empty {
153                walk_nodes(empty, out, depth + 1)?;
154            }
155        }
156        Node::Include { .. } => {
157            // Include resolution happens at render time. Linting only
158            // walks the calling template; the included partial gets
159            // linted independently when the linter encounters it.
160        }
161        Node::Section {
162            name,
163            body,
164            line,
165            col,
166            ..
167        } => {
168            out.push(LintConstruct::Section {
169                name: name.clone(),
170                line: *line,
171                col: *col,
172            });
173            walk_nodes(body, out, depth + 1)?;
174        }
175    }
176    Ok(())
177}
178
179fn filter_uses(src: &str) -> Result<Vec<LintConstruct>, TemplateParseError> {
180    let tokens = super::lexer::tokenize(src).map_err(TemplateParseError::from)?;
181    let mut filters = Vec::new();
182    for token in tokens {
183        let super::lexer::Token::Directive { start, end, .. } = token else {
184            continue;
185        };
186        scan_directive_filters(src, start, end, &mut filters);
187    }
188    Ok(filters)
189}
190
191fn scan_directive_filters(src: &str, start: usize, end: usize, filters: &mut Vec<LintConstruct>) {
192    let bytes = src.as_bytes();
193    let mut quote = None;
194    let mut cursor = start;
195    while cursor < end {
196        let byte = bytes[cursor];
197        if let Some(delimiter) = quote {
198            if byte == b'\\' {
199                cursor = (cursor + 2).min(end);
200                continue;
201            }
202            if byte == delimiter {
203                quote = None;
204            }
205            cursor += 1;
206            continue;
207        }
208        if matches!(byte, b'"' | b'\'') {
209            quote = Some(byte);
210            cursor += 1;
211            continue;
212        }
213        if byte != b'|' {
214            cursor += 1;
215            continue;
216        }
217        if bytes.get(cursor + 1) == Some(&b'|') {
218            cursor += 2;
219            continue;
220        }
221
222        cursor += 1;
223        while cursor < end && bytes[cursor].is_ascii_whitespace() {
224            cursor += 1;
225        }
226        let name_start = cursor;
227        while cursor < end && (bytes[cursor].is_ascii_alphanumeric() || bytes[cursor] == b'_') {
228            cursor += 1;
229        }
230        if cursor > name_start {
231            #[expect(
232                clippy::string_slice,
233                reason = "name bounds sit on ASCII filter-name bytes"
234            )]
235            filters.push(LintConstruct::Filter {
236                name: src[name_start..cursor].to_string(),
237                start: name_start,
238                end: cursor,
239            });
240        }
241    }
242}
243
244fn lint_depth_error(node: &Node) -> TemplateParseError {
245    let (line, col) = node_location(node).unwrap_or((1, 1));
246    TemplateParseError {
247        message: format!("template lint AST depth exceeded ({TEMPLATE_LINT_AST_MAX_DEPTH} levels)"),
248        line,
249        col,
250    }
251}
252
253fn node_location(node: &Node) -> Option<(usize, usize)> {
254    match node {
255        Node::Expr { line, col, .. }
256        | Node::If { line, col, .. }
257        | Node::For { line, col, .. }
258        | Node::Include { line, col, .. }
259        | Node::Section { line, col, .. } => Some((*line, *col)),
260        Node::Text(_) | Node::LegacyBareInterp { .. } => None,
261    }
262}
263
264/// Classify the top-level shape of an `{{ if expr }}` condition.
265fn classify_condition(expr: &Expr) -> ConditionShape {
266    if let Some(identity) = match_identity_compare(expr) {
267        return ConditionShape::ProviderIdentity(identity);
268    }
269    if let Some(capability) = match_capability_path(expr) {
270        return capability;
271    }
272    ConditionShape::Other
273}
274
275/// Match `llm.<provider|model|family> == "..."` or `!= "..."`,
276/// returning the LHS identity field that was compared.
277fn match_identity_compare(expr: &Expr) -> Option<IdentityField> {
278    let Expr::Binary(op, lhs, rhs) = expr else {
279        return None;
280    };
281    if !matches!(op, BinOp::Eq | BinOp::Neq) {
282        return None;
283    }
284    let path = match (lhs.as_ref(), rhs.as_ref()) {
285        (Expr::Path(p), Expr::Str(_)) | (Expr::Str(_), Expr::Path(p)) => p,
286        _ => return None,
287    };
288    if !path_starts_with_llm(path) {
289        return None;
290    }
291    match path.get(1) {
292        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "provider" => {
293            Some(IdentityField::Provider)
294        }
295        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "model" => {
296            Some(IdentityField::Model)
297        }
298        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "family" => {
299            Some(IdentityField::Family)
300        }
301        _ => None,
302    }
303}
304
305/// Match `llm.capabilities.<flag>` (possibly negated by `!`) or
306/// `llm.capabilities.<flag> == <literal>`, returning the flag name.
307fn match_capability_path(expr: &Expr) -> Option<ConditionShape> {
308    fn find_capability_path(expr: &Expr) -> Option<String> {
309        let mut stack = vec![expr];
310        while let Some(expr) = stack.pop() {
311            match expr {
312                Expr::Path(path) => {
313                    if let Some(flag) = capability_flag_from_path(path) {
314                        return Some(flag);
315                    }
316                }
317                Expr::Unary(_, inner) => stack.push(inner),
318                Expr::Binary(_, lhs, rhs) => {
319                    stack.push(rhs);
320                    stack.push(lhs);
321                }
322                Expr::Filter(inner, _, _) => stack.push(inner),
323                _ => {}
324            }
325        }
326        None
327    }
328    let flag = find_capability_path(expr)?;
329    Some(ConditionShape::CapabilityFlag { flag })
330}
331
332fn capability_flag_from_path(path: &[PathSeg]) -> Option<String> {
333    if !path_starts_with_llm(path) {
334        return None;
335    }
336    let Some(PathSeg::Field(name) | PathSeg::Key(name)) = path.get(1) else {
337        return None;
338    };
339    if name != "capabilities" {
340        return None;
341    }
342    let Some(PathSeg::Field(flag) | PathSeg::Key(flag)) = path.get(2) else {
343        return None;
344    };
345    Some(flag.clone())
346}
347
348fn path_starts_with_llm(path: &[PathSeg]) -> bool {
349    matches!(
350        path.first(),
351        Some(PathSeg::Field(name)) if name == "llm",
352    )
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn parse_ok(src: &str) -> Vec<LintConstruct> {
360        parse(src).expect("template should parse")
361    }
362
363    fn filters(src: &str) -> Vec<(String, usize, usize)> {
364        parse_ok(src)
365            .into_iter()
366            .filter_map(|construct| match construct {
367                LintConstruct::Filter { name, start, end } => Some((name, start, end)),
368                _ => None,
369            })
370            .collect()
371    }
372
373    #[test]
374    fn filter_uses_carry_exact_name_ranges() {
375        let source = "{{ name | uppr | default: \"| not_a_filter\" }}";
376        let found = filters(source);
377        assert_eq!(
378            found
379                .iter()
380                .map(|(name, _, _)| name.as_str())
381                .collect::<Vec<_>>(),
382            ["uppr", "default"]
383        );
384        for (name, start, end) in found {
385            #[expect(clippy::string_slice, reason = "test input is ASCII")]
386            let snippet = &source[start..end];
387            assert_eq!(snippet, name);
388        }
389    }
390
391    #[test]
392    fn logical_or_comments_and_raw_text_are_not_filters() {
393        let source = concat!(
394            "{{ if a || b }}x{{ end }}\n",
395            "{{# ignored | nope #}}\n",
396            "{{ raw }}{{ value | nope }}{{ endraw }}\n",
397        );
398        assert!(filters(source).is_empty());
399    }
400
401    fn first_if(constructs: &[LintConstruct]) -> &[IfBranch] {
402        match constructs
403            .iter()
404            .find(|c| matches!(c, LintConstruct::IfChain { .. }))
405            .expect("if chain present")
406        {
407            LintConstruct::IfChain { branches } => branches.as_slice(),
408            _ => unreachable!(),
409        }
410    }
411
412    #[test]
413    fn provider_identity_eq_detected() {
414        let constructs = parse_ok("{{ if llm.provider == \"anthropic\" }}x{{ else }}y{{ end }}");
415        let branches = first_if(&constructs);
416        assert_eq!(branches.len(), 1);
417        assert!(matches!(
418            branches[0].condition,
419            ConditionShape::ProviderIdentity(IdentityField::Provider)
420        ));
421    }
422
423    #[test]
424    fn model_identity_neq_detected() {
425        let constructs = parse_ok("{{ if llm.model != \"gpt-5\" }}x{{ end }}");
426        let branches = first_if(&constructs);
427        assert!(matches!(
428            branches[0].condition,
429            ConditionShape::ProviderIdentity(IdentityField::Model)
430        ));
431    }
432
433    #[test]
434    fn capability_flag_detected_in_negation_and_filter() {
435        let constructs = parse_ok(
436            "{{ if !llm.capabilities.native_tools }}x{{ end }}\
437             {{ if llm.capabilities.prefers_xml_scaffolding | default: false }}y{{ end }}",
438        );
439        let if_chains: Vec<_> = constructs
440            .iter()
441            .filter_map(|c| match c {
442                LintConstruct::IfChain { branches } => Some(branches.clone()),
443                _ => None,
444            })
445            .collect();
446        assert_eq!(if_chains.len(), 2);
447        assert!(matches!(
448            if_chains[0][0].condition,
449            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
450        ));
451        assert!(matches!(
452            if_chains[1][0].condition,
453            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "prefers_xml_scaffolding"
454        ));
455    }
456
457    #[test]
458    fn capability_flag_detection_handles_wide_binary_expression() {
459        let mut terms = (0..300).map(|idx| format!("flag{idx}")).collect::<Vec<_>>();
460        terms.push("llm.capabilities.native_tools".to_string());
461        let src = format!("{{{{ if {} }}}}x{{{{ end }}}}", terms.join(" or "));
462
463        let constructs = parse_ok(&src);
464        let branches = first_if(&constructs);
465
466        assert!(matches!(
467            branches[0].condition,
468            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
469        ));
470    }
471
472    #[test]
473    fn parse_reports_template_control_depth_limit() {
474        let depth = RuntimeLimits::DEFAULT.max_template_ast_depth + 1;
475        let mut src = String::new();
476        for _ in 0..depth {
477            src.push_str("{{ if true }}");
478        }
479        src.push('x');
480        for _ in 0..depth {
481            src.push_str("{{ end }}");
482        }
483
484        let err = parse(&src).expect_err("depth limit");
485
486        assert!(err.message.contains("template nesting depth exceeded"));
487        assert!(err.message.contains(&format!(
488            "({} levels)",
489            RuntimeLimits::DEFAULT.max_template_ast_depth
490        )));
491    }
492
493    #[test]
494    fn parse_reports_template_expression_depth_limit() {
495        let depth = RuntimeLimits::DEFAULT.max_template_ast_depth + 1;
496        let condition = format!("{}llm.capabilities.native_tools", "!".repeat(depth));
497        let src = format!("{{{{ if {condition} }}}}x{{{{ end }}}}");
498
499        let err = parse(&src).expect_err("depth limit");
500
501        assert!(err.message.contains("template expression depth exceeded"));
502        assert!(err.message.contains(&format!(
503            "({} levels)",
504            RuntimeLimits::DEFAULT.max_template_ast_depth
505        )));
506    }
507
508    #[test]
509    fn elif_chain_lifts_per_branch_condition() {
510        let constructs = parse_ok(
511            "{{ if llm.provider == \"openai\" }}a\
512             {{ elif llm.capabilities.native_tools }}b\
513             {{ else }}c{{ end }}",
514        );
515        let branches = first_if(&constructs);
516        assert_eq!(branches.len(), 2);
517        assert!(matches!(
518            branches[0].condition,
519            ConditionShape::ProviderIdentity(IdentityField::Provider)
520        ));
521        assert!(matches!(
522            branches[1].condition,
523            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
524        ));
525    }
526
527    #[test]
528    fn unrelated_condition_falls_through_to_other() {
529        let constructs = parse_ok("{{ if score > 0.5 }}a{{ end }}");
530        let branches = first_if(&constructs);
531        assert!(matches!(branches[0].condition, ConditionShape::Other));
532    }
533
534    #[test]
535    fn sections_listed_in_source_order() {
536        let constructs = parse_ok(
537            "{{ section \"task\" }}t{{ endsection }}\
538             {{ section \"output_format\" }}o{{ endsection }}",
539        );
540        let names: Vec<_> = constructs
541            .iter()
542            .filter_map(|c| match c {
543                LintConstruct::Section { name, .. } => Some(name.clone()),
544                _ => None,
545            })
546            .collect();
547        assert_eq!(names, vec!["task", "output_format"]);
548    }
549}