1use crate::diagnostic::Diagnostic;
2use crate::executor::Engine;
3use crate::parser::{Node, parse_file};
4use std::collections::HashSet;
5use std::path::Path;
6
7#[derive(Debug, Clone, Default)]
8pub struct AnalysisResult {
9 pub errors: Vec<Diagnostic>,
10 pub warnings: Vec<Diagnostic>,
11}
12
13pub struct Analyzer<'a> {
14 engine: &'a Engine,
15 visited: HashSet<String>,
16}
17
18impl<'a> Analyzer<'a> {
19 pub fn new(engine: &'a Engine) -> Self {
20 Self {
21 engine,
22 visited: HashSet::new(),
23 }
24 }
25
26 pub fn analyze(&mut self, root: &Node) -> AnalysisResult {
27 let mut res = AnalysisResult::default();
28 self.visited.clear();
29 self.walk(root, None, &mut res);
30 res
31 }
32
33 fn walk(&mut self, node: &Node, parent_name: Option<&str>, res: &mut AnalysisResult) {
34 if node.name == "include" {
36 let mut path = String::new();
37 if let Some(ref val) = node.value {
38 path = val.trim_matches(|c| c == '"' || c == '\'' || c == '`').to_string();
39 }
40
41 if !path.is_empty() {
42 if self.visited.contains(&path) {
43 res.warnings.push(Diagnostic {
45 r#type: "warning".to_string(),
46 message: format!("static warning: cyclic inclusion of '{}' detected", path),
47 filename: node.filename.clone(),
48 line: node.line,
49 col: node.col,
50 slot: Some("include".to_string()),
51 });
52 return;
53 }
54 self.visited.insert(path.clone());
55
56 let path_obj = Path::new(&path);
57 match parse_file(path_obj) {
58 Ok(included_root) => {
59 self.walk(&included_root, None, res);
60 }
61 Err(diag) => {
62 res.errors.push(diag);
63 }
64 }
65 }
66 return;
67 }
68
69 if node.name == "root" {
71 for child in &node.children {
72 self.walk(child, Some("root"), res);
73 }
74 return;
75 }
76
77 if !node.name.is_empty() && !node.name.starts_with('$') {
79 if let Some(meta) = self.engine.docs.get(&node.name) {
80 if !meta.value_type.is_empty() && meta.value_type != "any" {
82 if let Some(ref val_str) = node.value {
83 if !val_str.is_empty() && !val_str.starts_with('$') && !val_str.contains("??") {
84 let mut dummy = node.clone();
85 dummy.children = Vec::new();
86 let parsed_val = self.engine.resolve_shorthand_value(&dummy, &crate::scope::Scope::new(None));
87 if let Err(err) = self.engine.validate_value_type(&parsed_val, &meta.value_type, node, &node.name) {
88 res.errors.push(err);
89 }
90 }
91 }
92 }
93
94 let mut first_required = String::new();
96 for (name, input) in &meta.inputs {
97 if input.required {
98 first_required = name.clone();
99 break;
100 }
101 }
102
103 for (name, input) in &meta.inputs {
104 if input.required {
105 let mut found = false;
106 for child in &node.children {
107 if child.name == *name {
108 found = true;
109 break;
110 }
111 }
112
113 let is_common_positional = name == "id" || name == "spreadsheet_id" || name == "path" || name == "name" || name == "url" || name == "file";
115 if !found && (is_common_positional || *name == first_required) && node.value.is_some() {
116 if let Some(ref val_str) = node.value {
117 if !val_str.is_empty() {
118 found = true;
119 }
120 }
121 }
122
123 if !found {
124 res.errors.push(Diagnostic {
125 r#type: "error".to_string(),
126 message: format!("static error: missing required attribute '{}' for slot '{}'", name, node.name),
127 filename: node.filename.clone(),
128 line: node.line,
129 col: node.col,
130 slot: Some(node.name.clone()),
131 });
132 }
133 }
134 }
135
136 for block_name in &meta.required_blocks {
138 let mut found = false;
139 for child in &node.children {
140 if child.name == *block_name {
141 found = true;
142 break;
143 }
144 }
145 if !found {
146 res.errors.push(Diagnostic {
147 r#type: "error".to_string(),
148 message: format!("static error: missing required block '{}:' for slot '{}'", block_name, node.name),
149 filename: node.filename.clone(),
150 line: node.line,
151 col: node.col,
152 slot: Some(node.name.clone()),
153 });
154 }
155 }
156
157 for child in &node.children {
159 if let Some(input) = meta.inputs.get(&child.name) {
160 if !input.r#type.is_empty() && input.r#type != "any" {
161 if let Some(ref val_str) = child.value {
162 if !val_str.is_empty() && !val_str.starts_with('$') && !val_str.contains("??") {
163 let parsed_val = self.engine.resolve_shorthand_value(child, &crate::scope::Scope::new(None));
164 if let Err(err) = self.engine.validate_value_type(&parsed_val, &input.r#type, child, &node.name) {
165 res.errors.push(err);
166 }
167 }
168 }
169 }
170 }
171 }
172
173 for child in &node.children {
175 if !meta.inputs.contains_key(&child.name) {
176 self.walk(child, Some(&node.name), res);
177 }
178 }
179 return;
180 } else {
181 let keywords = [
183 "if", "for", "foreach", "while", "switch", "try", "do", "then", "else", "catch",
184 "as", "rules", "rules_map", "data", "break", "continue", "return", "scope.set", "var",
185 "logic.compare", "dump", "dd", "isset", "empty", "unless", "auth", "guest",
186 "auth.user", "auth.check", "can", "cannot", "json", "forelse", "error"
187 ];
188
189 if !keywords.contains(&node.name.as_str()) {
190 let mut is_call_arg = false;
191 if let Some(ref p_name) = parent_name {
192 if let Some(p_meta) = self.engine.docs.get(*p_name) {
193 if p_meta.inputs.is_empty() {
194 is_call_arg = true;
195 }
196 }
197 if *p_name == "call" || *p_name == "data" || *p_name == "array.pop" || *p_name == "date.now" || *p_name == "system.env" || *p_name == "coalesce" {
198 is_call_arg = true;
199 }
200 }
201
202 if !is_call_arg {
203 res.errors.push(Diagnostic {
204 r#type: "error".to_string(),
205 message: format!("static error: unknown slot '{}'", node.name),
206 filename: node.filename.clone(),
207 line: node.line,
208 col: node.col,
209 slot: Some(node.name.clone()),
210 });
211 }
212 }
213 }
214 }
215
216 for child in &node.children {
218 self.walk(child, Some(&node.name), res);
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::parser::parse_string;
227 use crate::slots::register_logic_slots;
228
229 #[test]
230 fn test_analyzer_unknown_slot() {
231 let mut engine = Engine::new();
232 register_logic_slots(&mut engine);
233
234 let root = parse_string("unknown_slot_name: 123", "test.zl").unwrap();
235 let mut analyzer = Analyzer::new(&engine);
236 let res = analyzer.analyze(&root);
237
238 assert!(!res.errors.is_empty());
239 assert!(res.errors[0].message.contains("unknown slot 'unknown_slot_name'"));
240 }
241
242 #[test]
243 fn test_analyzer_cyclic_includes() {
244 let engine = Engine::new();
245
246 let path_a = std::env::current_dir().unwrap().join("test_a.zl");
247 let path_b = std::env::current_dir().unwrap().join("test_b.zl");
248
249 std::fs::write(&path_a, "include: 'test_b.zl'").unwrap();
250 std::fs::write(&path_b, "include: 'test_a.zl'").unwrap();
251
252 let root = parse_file(&path_a).unwrap();
253 let mut analyzer = Analyzer::new(&engine);
254 let res = analyzer.analyze(&root);
255
256 let _ = std::fs::remove_file(&path_a);
258 let _ = std::fs::remove_file(&path_b);
259
260 assert!(!res.warnings.is_empty());
261 assert!(res.warnings[0].message.contains("cyclic inclusion"));
262 }
263}