1use super::ast::{BinOp, Expr, Node, PathSeg};
10use super::parser::parse as parse_template;
11
12pub fn parse(src: &str) -> Result<Vec<LintConstruct>, String> {
17 let nodes = parse_template(src).map_err(|error| error.message())?;
18 let mut out = Vec::new();
19 walk_nodes(&nodes, &mut out);
20 Ok(out)
21}
22
23#[derive(Debug, Clone)]
27pub enum LintConstruct {
28 IfChain { branches: Vec<IfBranch> },
33 Section {
37 name: String,
38 line: usize,
39 col: usize,
40 },
41}
42
43#[derive(Debug, Clone)]
44pub struct IfBranch {
45 pub line: usize,
46 pub col: usize,
47 pub condition: ConditionShape,
48}
49
50#[derive(Debug, Clone)]
62pub enum ConditionShape {
63 ProviderIdentity(IdentityField),
66 CapabilityFlag {
71 flag: String,
72 },
73 Other,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum IdentityField {
78 Provider,
79 Model,
80 Family,
81}
82
83impl IdentityField {
84 pub fn as_str(self) -> &'static str {
85 match self {
86 IdentityField::Provider => "provider",
87 IdentityField::Model => "model",
88 IdentityField::Family => "family",
89 }
90 }
91}
92
93fn walk_nodes(nodes: &[Node], out: &mut Vec<LintConstruct>) {
94 for node in nodes {
95 walk_node(node, out);
96 }
97}
98
99fn walk_node(node: &Node, out: &mut Vec<LintConstruct>) {
100 match node {
101 Node::Text(_) | Node::Expr { .. } | Node::LegacyBareInterp { .. } => {}
102 Node::If {
103 branches,
104 else_branch,
105 line: _,
106 col: _,
107 } => {
108 let mut summary = Vec::with_capacity(branches.len());
109 for branch in branches {
110 summary.push(IfBranch {
111 line: branch.line,
112 col: branch.col,
113 condition: classify_condition(&branch.cond),
114 });
115 walk_nodes(&branch.body, out);
116 }
117 out.push(LintConstruct::IfChain { branches: summary });
118 if let Some(else_body) = else_branch {
119 walk_nodes(else_body, out);
120 }
121 }
122 Node::For { body, empty, .. } => {
123 walk_nodes(body, out);
124 if let Some(empty) = empty {
125 walk_nodes(empty, out);
126 }
127 }
128 Node::Include { .. } => {
129 }
133 Node::Section {
134 name,
135 body,
136 line,
137 col,
138 ..
139 } => {
140 out.push(LintConstruct::Section {
141 name: name.clone(),
142 line: *line,
143 col: *col,
144 });
145 walk_nodes(body, out);
146 }
147 }
148}
149
150fn classify_condition(expr: &Expr) -> ConditionShape {
152 if let Some(identity) = match_identity_compare(expr) {
153 return ConditionShape::ProviderIdentity(identity);
154 }
155 if let Some(capability) = match_capability_path(expr) {
156 return capability;
157 }
158 ConditionShape::Other
159}
160
161fn match_identity_compare(expr: &Expr) -> Option<IdentityField> {
164 let Expr::Binary(op, lhs, rhs) = expr else {
165 return None;
166 };
167 if !matches!(op, BinOp::Eq | BinOp::Neq) {
168 return None;
169 }
170 let path = match (lhs.as_ref(), rhs.as_ref()) {
171 (Expr::Path(p), Expr::Str(_)) | (Expr::Str(_), Expr::Path(p)) => p,
172 _ => return None,
173 };
174 if !path_starts_with_llm(path) {
175 return None;
176 }
177 match path.get(1) {
178 Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "provider" => {
179 Some(IdentityField::Provider)
180 }
181 Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "model" => {
182 Some(IdentityField::Model)
183 }
184 Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "family" => {
185 Some(IdentityField::Family)
186 }
187 _ => None,
188 }
189}
190
191fn match_capability_path(expr: &Expr) -> Option<ConditionShape> {
194 fn find_capability_path(expr: &Expr) -> Option<String> {
195 match expr {
196 Expr::Path(path) => capability_flag_from_path(path),
197 Expr::Unary(_, inner) => find_capability_path(inner),
198 Expr::Binary(_, lhs, rhs) => {
199 find_capability_path(lhs).or_else(|| find_capability_path(rhs))
200 }
201 Expr::Filter(inner, _, _) => find_capability_path(inner),
202 _ => None,
203 }
204 }
205 let flag = find_capability_path(expr)?;
206 Some(ConditionShape::CapabilityFlag { flag })
207}
208
209fn capability_flag_from_path(path: &[PathSeg]) -> Option<String> {
210 if !path_starts_with_llm(path) {
211 return None;
212 }
213 let Some(PathSeg::Field(name) | PathSeg::Key(name)) = path.get(1) else {
214 return None;
215 };
216 if name != "capabilities" {
217 return None;
218 }
219 let Some(PathSeg::Field(flag) | PathSeg::Key(flag)) = path.get(2) else {
220 return None;
221 };
222 Some(flag.clone())
223}
224
225fn path_starts_with_llm(path: &[PathSeg]) -> bool {
226 matches!(
227 path.first(),
228 Some(PathSeg::Field(name)) if name == "llm",
229 )
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn parse_ok(src: &str) -> Vec<LintConstruct> {
237 parse(src).expect("template should parse")
238 }
239
240 fn first_if(constructs: &[LintConstruct]) -> &[IfBranch] {
241 match constructs
242 .iter()
243 .find(|c| matches!(c, LintConstruct::IfChain { .. }))
244 .expect("if chain present")
245 {
246 LintConstruct::IfChain { branches } => branches.as_slice(),
247 _ => unreachable!(),
248 }
249 }
250
251 #[test]
252 fn provider_identity_eq_detected() {
253 let constructs = parse_ok("{{ if llm.provider == \"anthropic\" }}x{{ else }}y{{ end }}");
254 let branches = first_if(&constructs);
255 assert_eq!(branches.len(), 1);
256 assert!(matches!(
257 branches[0].condition,
258 ConditionShape::ProviderIdentity(IdentityField::Provider)
259 ));
260 }
261
262 #[test]
263 fn model_identity_neq_detected() {
264 let constructs = parse_ok("{{ if llm.model != \"gpt-5\" }}x{{ end }}");
265 let branches = first_if(&constructs);
266 assert!(matches!(
267 branches[0].condition,
268 ConditionShape::ProviderIdentity(IdentityField::Model)
269 ));
270 }
271
272 #[test]
273 fn capability_flag_detected_in_negation_and_filter() {
274 let constructs = parse_ok(
275 "{{ if !llm.capabilities.native_tools }}x{{ end }}\
276 {{ if llm.capabilities.prefers_xml_scaffolding | default: false }}y{{ end }}",
277 );
278 let if_chains: Vec<_> = constructs
279 .iter()
280 .filter_map(|c| match c {
281 LintConstruct::IfChain { branches } => Some(branches.clone()),
282 _ => None,
283 })
284 .collect();
285 assert_eq!(if_chains.len(), 2);
286 assert!(matches!(
287 if_chains[0][0].condition,
288 ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
289 ));
290 assert!(matches!(
291 if_chains[1][0].condition,
292 ConditionShape::CapabilityFlag { ref flag, .. } if flag == "prefers_xml_scaffolding"
293 ));
294 }
295
296 #[test]
297 fn elif_chain_lifts_per_branch_condition() {
298 let constructs = parse_ok(
299 "{{ if llm.provider == \"openai\" }}a\
300 {{ elif llm.capabilities.native_tools }}b\
301 {{ else }}c{{ end }}",
302 );
303 let branches = first_if(&constructs);
304 assert_eq!(branches.len(), 2);
305 assert!(matches!(
306 branches[0].condition,
307 ConditionShape::ProviderIdentity(IdentityField::Provider)
308 ));
309 assert!(matches!(
310 branches[1].condition,
311 ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
312 ));
313 }
314
315 #[test]
316 fn unrelated_condition_falls_through_to_other() {
317 let constructs = parse_ok("{{ if score > 0.5 }}a{{ end }}");
318 let branches = first_if(&constructs);
319 assert!(matches!(branches[0].condition, ConditionShape::Other));
320 }
321
322 #[test]
323 fn sections_listed_in_source_order() {
324 let constructs = parse_ok(
325 "{{ section \"task\" }}t{{ endsection }}\
326 {{ section \"output_format\" }}o{{ endsection }}",
327 );
328 let names: Vec<_> = constructs
329 .iter()
330 .filter_map(|c| match c {
331 LintConstruct::Section { name, .. } => Some(name.clone()),
332 _ => None,
333 })
334 .collect();
335 assert_eq!(names, vec!["task", "output_format"]);
336 }
337}