1use 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
16pub 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#[derive(Debug, Clone)]
32pub enum LintConstruct {
33 IfChain { branches: Vec<IfBranch> },
38 Section {
42 name: String,
43 line: usize,
44 col: usize,
45 },
46 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#[derive(Debug, Clone)]
77pub enum ConditionShape {
78 ProviderIdentity(IdentityField),
81 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 }
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
264fn 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
275fn 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
305fn 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}