lithos_gotmpl_engine/
analyze.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2use std::collections::HashSet;
3
4use crate::ast::{ActionNode, Ast, Command, Expression, IfNode, Node, RangeNode, Span, WithNode};
5use crate::lexer::{Token, TokenKind};
6use crate::runtime::FunctionRegistry;
7
8pub fn analyze_template(ast: &Ast, registry: Option<&FunctionRegistry>) -> TemplateAnalysis {
9    let mut analyzer = Analyzer::new(registry);
10    analyzer.walk_block(&ast.root);
11    analyzer.finish()
12}
13
14#[derive(Debug, Clone)]
15pub struct TemplateAnalysis {
16    pub version: &'static str,
17    pub precision: Precision,
18    pub has_template_invocation: bool,
19    pub variables: Vec<VariableAccess>,
20    pub functions: Vec<FunctionCall>,
21    pub templates: Vec<TemplateCall>,
22    pub controls: Vec<ControlUsage>,
23    pub issues: Vec<AnalysisIssue>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Precision {
28    Precise,
29    Conservative,
30}
31
32#[derive(Debug, Clone)]
33pub struct VariableAccess {
34    pub path: String,
35    pub span: Span,
36    pub kind: VariableKind,
37    pub certainty: Certainty,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum VariableKind {
42    Dot,
43    Dollar,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Certainty {
48    Certain,
49    Uncertain,
50}
51
52#[derive(Debug, Clone)]
53pub struct FunctionCall {
54    pub name: String,
55    pub span: Span,
56    pub source: FunctionSource,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FunctionSource {
61    Registered,
62    Unknown,
63}
64
65#[derive(Debug, Clone)]
66pub struct TemplateCall {
67    pub span: Span,
68    pub name: Option<String>,
69    pub indirect: bool,
70}
71
72#[derive(Debug, Clone)]
73pub struct ControlUsage {
74    pub kind: ControlKind,
75    pub span: Span,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ControlKind {
80    If,
81    Range,
82    With,
83    Block,
84    Define,
85    Else,
86    End,
87}
88
89#[derive(Debug, Clone)]
90pub struct AnalysisIssue {
91    pub message: String,
92    pub span: Option<Span>,
93}
94
95struct Analyzer<'a> {
96    registry: Option<&'a FunctionRegistry>,
97    variables: Vec<VariableAccess>,
98    functions: Vec<FunctionCall>,
99    templates: Vec<TemplateCall>,
100    controls: Vec<ControlUsage>,
101    issues: Vec<AnalysisIssue>,
102    has_template: bool,
103    conservative: bool,
104    seen_vars: HashSet<(String, Span)>,
105}
106
107impl<'a> Analyzer<'a> {
108    fn new(registry: Option<&'a FunctionRegistry>) -> Self {
109        Self {
110            registry,
111            variables: Vec::new(),
112            functions: Vec::new(),
113            templates: Vec::new(),
114            controls: Vec::new(),
115            issues: Vec::new(),
116            has_template: false,
117            conservative: false,
118            seen_vars: HashSet::new(),
119        }
120    }
121
122    fn finish(self) -> TemplateAnalysis {
123        TemplateAnalysis {
124            version: env!("CARGO_PKG_VERSION"),
125            precision: if self.conservative {
126                Precision::Conservative
127            } else {
128                Precision::Precise
129            },
130            has_template_invocation: self.has_template,
131            variables: self.variables,
132            functions: self.functions,
133            templates: self.templates,
134            controls: self.controls,
135            issues: self.issues,
136        }
137    }
138
139    fn walk_block(&mut self, block: &crate::ast::Block) {
140        for node in &block.nodes {
141            match node {
142                Node::Action(action) => self.visit_action(action),
143                Node::If(if_node) => self.visit_if(if_node),
144                Node::Range(range_node) => self.visit_range(range_node),
145                Node::With(with_node) => self.visit_with(with_node),
146                Node::Text(_) | Node::Comment(_) => {}
147            }
148        }
149    }
150
151    fn visit_action(&mut self, action: &ActionNode) {
152        self.inspect_tokens(&action.tokens);
153        self.visit_pipeline(&action.pipeline, action.span);
154    }
155
156    fn visit_if(&mut self, node: &IfNode) {
157        self.inspect_tokens(&node.tokens);
158        self.controls.push(ControlUsage {
159            kind: ControlKind::If,
160            span: node.span,
161        });
162        self.visit_pipeline(&node.pipeline, node.span);
163        self.walk_block(&node.then_block);
164        if let Some(else_block) = &node.else_block {
165            self.walk_block(else_block);
166        }
167    }
168
169    fn visit_range(&mut self, node: &RangeNode) {
170        self.inspect_tokens(&node.tokens);
171        self.controls.push(ControlUsage {
172            kind: ControlKind::Range,
173            span: node.span,
174        });
175        self.visit_pipeline(&node.pipeline, node.span);
176        self.walk_block(&node.then_block);
177        if let Some(else_block) = &node.else_block {
178            self.walk_block(else_block);
179        }
180    }
181
182    fn visit_with(&mut self, node: &WithNode) {
183        self.inspect_tokens(&node.tokens);
184        self.controls.push(ControlUsage {
185            kind: ControlKind::With,
186            span: node.span,
187        });
188        self.visit_pipeline(&node.pipeline, node.span);
189        self.walk_block(&node.then_block);
190        if let Some(else_block) = &node.else_block {
191            self.walk_block(else_block);
192        }
193    }
194
195    fn inspect_tokens(&mut self, tokens: &[Token]) {
196        for token in tokens {
197            match token.kind {
198                TokenKind::LeftBracket
199                | TokenKind::RightBracket
200                | TokenKind::Declare
201                | TokenKind::Assign => {
202                    self.mark_conservative(
203                        "indexing or assignments are not fully analysed",
204                        Some(token.span),
205                    );
206                }
207                _ => {}
208            }
209        }
210    }
211
212    fn visit_pipeline(&mut self, pipeline: &crate::ast::Pipeline, span: Span) {
213        for command in &pipeline.commands {
214            self.visit_command(command, span);
215        }
216    }
217
218    fn visit_command(&mut self, command: &Command, span: Span) {
219        self.collect_expr(&command.target, span);
220
221        match &command.target {
222            Expression::Identifier(name) => {
223                let lowered = name.as_str();
224                if lowered == "template" || lowered == "block" {
225                    self.record_template(command, span, lowered == "block");
226                } else if let Some(control) = control_kind(lowered) {
227                    self.controls.push(ControlUsage {
228                        kind: control,
229                        span,
230                    });
231                } else {
232                    self.record_function(name.clone(), span);
233                }
234            }
235            Expression::Variable(name) => {
236                self.record_variable(name.clone(), span, VariableKind::Dollar, Certainty::Certain);
237            }
238            _ => {}
239        }
240
241        for arg in &command.args {
242            self.collect_expr(arg, span);
243        }
244    }
245
246    fn collect_expr(&mut self, expr: &Expression, span: Span) {
247        match expr {
248            Expression::Field(parts) => {
249                let (path, certainty) = normalize_field(parts);
250                self.record_variable(path, span, VariableKind::Dot, certainty);
251            }
252            Expression::Identifier(name) if name.starts_with('$') => {
253                self.record_variable(name.clone(), span, VariableKind::Dollar, Certainty::Certain);
254            }
255            Expression::PipelineExpr(pipeline) => {
256                self.visit_pipeline(pipeline, span);
257            }
258            Expression::Variable(name) => {
259                self.record_variable(name.clone(), span, VariableKind::Dollar, Certainty::Certain);
260            }
261            _ => {}
262        }
263    }
264
265    fn record_variable(
266        &mut self,
267        path: String,
268        span: Span,
269        kind: VariableKind,
270        certainty: Certainty,
271    ) {
272        let key = (path.clone(), span);
273        if self.seen_vars.insert(key) {
274            self.variables.push(VariableAccess {
275                path,
276                span,
277                kind,
278                certainty,
279            });
280        }
281    }
282
283    fn record_function(&mut self, name: String, span: Span) {
284        let source = if self
285            .registry
286            .map(|reg| reg.get(&name).is_some())
287            .unwrap_or(false)
288        {
289            FunctionSource::Registered
290        } else {
291            FunctionSource::Unknown
292        };
293        self.functions.push(FunctionCall { name, span, source });
294    }
295
296    fn record_template(&mut self, command: &Command, span: Span, is_block: bool) {
297        self.has_template = true;
298        let template_name = command.args.first();
299        let (name, indirect) = match template_name {
300            Some(Expression::StringLiteral(lit)) => (Some(lit.clone()), false),
301            Some(_) => (None, true),
302            None => (None, true),
303        };
304        if indirect {
305            self.mark_conservative("dynamic template invocation", Some(span));
306        }
307        self.templates.push(TemplateCall {
308            span,
309            name,
310            indirect,
311        });
312        if is_block {
313            self.controls.push(ControlUsage {
314                kind: ControlKind::Block,
315                span,
316            });
317        }
318    }
319
320    fn mark_conservative(&mut self, message: impl Into<String>, span: Option<Span>) {
321        self.conservative = true;
322        self.issues.push(AnalysisIssue {
323            message: message.into(),
324            span,
325        });
326    }
327}
328
329fn control_kind(name: &str) -> Option<ControlKind> {
330    match name {
331        "if" => Some(ControlKind::If),
332        "range" => Some(ControlKind::Range),
333        "with" => Some(ControlKind::With),
334        "block" => Some(ControlKind::Block),
335        "define" => Some(ControlKind::Define),
336        "else" => Some(ControlKind::Else),
337        "end" => Some(ControlKind::End),
338        _ => None,
339    }
340}
341
342fn normalize_field(parts: &[String]) -> (String, Certainty) {
343    if parts.is_empty() {
344        return (".".to_string(), Certainty::Certain);
345    }
346    let mut certainty = Certainty::Certain;
347    let mut normalized_parts = Vec::with_capacity(parts.len());
348    for part in parts {
349        if part.chars().all(|c| c.is_alphanumeric() || c == '_') {
350            normalized_parts.push(part.clone());
351        } else {
352            certainty = Certainty::Uncertain;
353            normalized_parts.push(part.clone());
354        }
355    }
356    (format!(".{}", normalized_parts.join(".")), certainty)
357}