Skip to main content

arc_lang/
validator.rs

1/// Arc validator — semantic validation with machine-readable JSON output.
2/// Designed for agent repair loops: clear error codes, actionable suggestions.
3
4use crate::ast::*;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7
8#[derive(Debug, Serialize, Deserialize)]
9pub struct ValidationResult {
10    pub valid: bool,
11    pub errors: Vec<Diagnostic>,
12    pub fixable: bool,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub fixed_source: Option<String>,
15}
16
17/// Validate a parsed document. Returns diagnostics and a resolved document
18/// where undeclared nodes are added as implicit `service` nodes.
19pub fn validate(doc: &Document, parse_diagnostics: &[Diagnostic]) -> (ValidationResult, Document) {
20    let mut diagnostics: Vec<Diagnostic> = parse_diagnostics.to_vec();
21    let mut resolved = doc.clone();
22
23    // 1. Check for duplicate node IDs
24    let mut seen_ids: HashMap<&str, &Node> = HashMap::new();
25    for node in &doc.nodes {
26        if let Some(existing) = seen_ids.get(node.id.as_str()) {
27            diagnostics.push(Diagnostic {
28                line: node.span.line,
29                col: node.span.col,
30                code: "E010".into(),
31                message: format!("Duplicate node ID '{}' (first declared at line {})", node.id, existing.span.line),
32                suggestion: Some(format!("Rename to '{}_2' or use a unique identifier", node.id)),
33                severity: Severity::Error,
34            });
35        } else {
36            seen_ids.insert(&node.id, node);
37        }
38    }
39
40    // 2. Check for undeclared nodes in connections (warn + auto-add)
41    let declared: HashSet<String> = doc.nodes.iter().map(|n| n.id.clone()).collect();
42    let mut auto_added: HashSet<String> = HashSet::new();
43
44    for conn in &doc.connections {
45        for ref_id in [&conn.from, &conn.to] {
46            if !declared.contains(ref_id.as_str()) && !auto_added.contains(ref_id.as_str()) {
47                diagnostics.push(Diagnostic {
48                    line: conn.span.line,
49                    col: conn.span.col,
50                    code: "W010".into(),
51                    message: format!("Node '{}' used but not declared, treating as service", ref_id),
52                    suggestion: Some(format!("Add: service {}", ref_id)),
53                    severity: Severity::Warning,
54                });
55                // Add implicit node
56                resolved.nodes.push(Node {
57                    node_type: NodeType::Service,
58                    id: ref_id.clone(),
59                    label: None,
60                    tags: Vec::new(),
61                    span: conn.span.clone(),
62                });
63                auto_added.insert(ref_id.clone());
64            }
65        }
66    }
67
68    // 3. Check for undeclared nodes in groups
69    fn check_group_refs(
70        group: &Group,
71        declared: &HashSet<String>,
72        auto_added: &mut HashSet<String>,
73        diagnostics: &mut Vec<Diagnostic>,
74        resolved_nodes: &mut Vec<Node>,
75    ) {
76        for member in &group.members {
77            match member {
78                GroupMember::NodeRef(id) => {
79                    if !declared.contains(id) && !auto_added.contains(id) {
80                        diagnostics.push(Diagnostic {
81                            line: group.span.line,
82                            col: group.span.col,
83                            code: "W011".into(),
84                            message: format!("Node '{}' referenced in group '{}' but not declared", id, group.label),
85                            suggestion: Some(format!("Add: service {}", id)),
86                            severity: Severity::Warning,
87                        });
88                        resolved_nodes.push(Node {
89                            node_type: NodeType::Service,
90                            id: id.clone(),
91                            label: None,
92                            tags: Vec::new(),
93                            span: group.span.clone(),
94                        });
95                        auto_added.insert(id.clone());
96                    }
97                }
98                GroupMember::NodeRefList(ids) => {
99                    for id in ids {
100                        if !declared.contains(id) && !auto_added.contains(id) {
101                            diagnostics.push(Diagnostic {
102                                line: group.span.line,
103                                col: group.span.col,
104                                code: "W011".into(),
105                                message: format!("Node '{}' referenced in group '{}' but not declared", id, group.label),
106                                suggestion: Some(format!("Add: service {}", id)),
107                                severity: Severity::Warning,
108                            });
109                            resolved_nodes.push(Node {
110                                node_type: NodeType::Service,
111                                id: id.clone(),
112                                label: None,
113                                tags: Vec::new(),
114                                span: group.span.clone(),
115                            });
116                            auto_added.insert(id.clone());
117                        }
118                    }
119                }
120                GroupMember::Group(g) => {
121                    check_group_refs(g, declared, auto_added, diagnostics, resolved_nodes);
122                }
123                _ => {}
124            }
125        }
126    }
127
128    for group in &doc.groups {
129        check_group_refs(group, &declared, &mut auto_added, &mut diagnostics, &mut resolved.nodes);
130    }
131
132    // 4. Check for unused nodes (declared but never connected or grouped)
133    let mut referenced: HashSet<&str> = HashSet::new();
134    for conn in &doc.connections {
135        referenced.insert(&conn.from);
136        referenced.insert(&conn.to);
137    }
138    fn collect_group_member_ids<'a>(group: &'a Group, ids: &mut HashSet<&'a str>) {
139        for member in &group.members {
140            match member {
141                GroupMember::NodeRef(id) => { ids.insert(id); }
142                GroupMember::NodeRefList(list) => { for id in list { ids.insert(id); } }
143                GroupMember::Node(n) => { ids.insert(&n.id); }
144                GroupMember::Connection(c) => { ids.insert(&c.from); ids.insert(&c.to); }
145                GroupMember::Group(g) => collect_group_member_ids(g, ids),
146            }
147        }
148    }
149    for group in &doc.groups {
150        collect_group_member_ids(group, &mut referenced);
151    }
152
153    for node in &doc.nodes {
154        if !referenced.contains(node.id.as_str()) {
155            diagnostics.push(Diagnostic {
156                line: node.span.line,
157                col: node.span.col,
158                code: "I001".into(),
159                message: format!("Node '{}' is declared but never used in connections or groups", node.id),
160                suggestion: None,
161                severity: Severity::Info,
162            });
163        }
164    }
165
166    // 5. Check for self-connections
167    for conn in &doc.connections {
168        if conn.from == conn.to {
169            diagnostics.push(Diagnostic {
170                line: conn.span.line,
171                col: conn.span.col,
172                code: "W012".into(),
173                message: format!("Self-connection on '{}' — is this intentional?", conn.from),
174                suggestion: None,
175                severity: Severity::Warning,
176            });
177        }
178    }
179
180    // Sort diagnostics by line, then severity
181    diagnostics.sort_by(|a, b| {
182        a.line.cmp(&b.line)
183            .then(severity_order(&a.severity).cmp(&severity_order(&b.severity)))
184    });
185
186    let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
187    let fixable = diagnostics.iter().all(|d| d.suggestion.is_some() || d.severity != Severity::Error);
188
189    let result = ValidationResult {
190        valid: !has_errors,
191        errors: diagnostics,
192        fixable,
193        fixed_source: None, // TODO: implement auto-fix
194    };
195
196    (result, resolved)
197}
198
199fn severity_order(s: &Severity) -> u8 {
200    match s {
201        Severity::Error => 0,
202        Severity::Warning => 1,
203        Severity::Info => 2,
204    }
205}