Skip to main content

zenocore/
analysis.rs

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        // A. Include Analysis
35        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                    // Cyclic dependency detected
44                    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        // Skip root node logic, just recurse
70        if node.name == "root" {
71            for child in &node.children {
72                self.walk(child, Some("root"), res);
73            }
74            return;
75        }
76
77        // B. Slot Validation
78        if !node.name.is_empty() && !node.name.starts_with('$') {
79            if let Some(meta) = self.engine.docs.get(&node.name) {
80                // 1. Check Main Value Type
81                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                // 2. Check Required Attributes
95                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                        // Positional Value Satisfaction
114                        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                // 3. Check Required Blocks
137                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                // 4. Check Type for Constant Values
158                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                // 5. Recurse nested slots (Only children that are NOT attributes)
174                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                // If not registered, check if it's a known keyword in the executor/slots
182                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        // Default recursion
217        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        // Clean up temp files
257        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}