1use crate::catalog::{Catalog, Kind};
8use crate::settings::SettingsNode;
9use crate::settings::table;
10use crate::source::Span;
11use crate::wir::{Action, Program, Value};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum IncompletenessKind {
15 RawSetting,
16 UnknownAction,
17 UnknownValue,
18 OpaqueAction,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ResidualClassification {
23 ProjectDefinedConstruct,
24 SourceDeclaredVariable,
25 ProducerExtension,
26 LegacyOpaque,
27 UnresolvedIdentifier,
28}
29
30impl ResidualClassification {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::ProjectDefinedConstruct => "project-defined-construct",
34 Self::SourceDeclaredVariable => "source-declared-variable",
35 Self::ProducerExtension => "producer-extension",
36 Self::LegacyOpaque => "legacy-opaque-construct",
37 Self::UnresolvedIdentifier => "truly-unresolved-identifier",
38 }
39 }
40
41 pub fn evidence(self) -> &'static str {
42 match self {
43 Self::ProjectDefinedConstruct => {
44 "source settings or construct was preserved without a canonical catalog identity"
45 }
46 Self::SourceDeclaredVariable => {
47 "the identifier matches a variable declaration in the parsed source program"
48 }
49 Self::ProducerExtension => {
50 "the source uses an action-shaped identity outside the canonical catalog and no declaration resolves it"
51 }
52 Self::LegacyOpaque => {
53 "the parser preserved a legacy raw construct without a canonical contract"
54 }
55 Self::UnresolvedIdentifier => {
56 "the identifier matches neither a source declaration nor a canonical catalog identity"
57 }
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SemanticIssue {
64 pub kind: IncompletenessKind,
65 pub name: String,
66 pub span: Option<Span>,
67 pub classification: ResidualClassification,
68}
69
70pub fn inspect(program: &Program, catalog: &Catalog) -> Vec<SemanticIssue> {
73 let mut issues = Vec::new();
74 if let Some(settings) = &program.settings {
75 for node in &settings.children {
76 inspect_setting(node, &mut issues);
77 }
78 }
79 for action in program.actions.iter() {
80 inspect_action(action, program, catalog, &mut issues);
81 }
82 for value in program.values.iter() {
83 inspect_value(value, program, catalog, &mut issues);
84 }
85 issues
86}
87
88fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
89 match node {
90 SettingsNode::Workshop { .. } => {}
91 SettingsNode::Group { children, .. } => {
92 for child in children {
93 inspect_setting(child, issues);
94 }
95 }
96 SettingsNode::Raw { name, span, .. } => issues.push(SemanticIssue {
97 kind: IncompletenessKind::RawSetting,
98 name: name.clone(),
99 span: *span,
100 classification: ResidualClassification::ProjectDefinedConstruct,
101 }),
102 SettingsNode::List {
103 name,
104 elements,
105 span,
106 } => {
107 let known = match name.as_str() {
108 "enabledMaps" | "disabledMaps" => elements
109 .iter()
110 .all(|element| table::map_name(&element.value).is_some()),
111 "enabledHeroes" | "disabledHeroes" => elements
112 .iter()
113 .all(|element| table::hero_name(&element.value).is_some()),
114 _ => true,
115 };
116 if !known {
117 issues.push(SemanticIssue {
118 kind: IncompletenessKind::RawSetting,
119 name: name.clone(),
120 span: *span,
121 classification: ResidualClassification::ProjectDefinedConstruct,
122 });
123 }
124 }
125 SettingsNode::Number { .. }
126 | SettingsNode::Bool { .. }
127 | SettingsNode::Flag { .. }
128 | SettingsNode::String { .. } => {}
129 }
130}
131
132fn inspect_action(
133 action: &Action,
134 program: &Program,
135 catalog: &Catalog,
136 issues: &mut Vec<SemanticIssue>,
137) {
138 match action {
139 Action::Call { name, span, .. } => {
140 let kind = if name == "rawWorkshopAction" {
141 Some(IncompletenessKind::OpaqueAction)
142 } else if catalog.entry(Kind::Action, name).is_none() {
143 Some(IncompletenessKind::UnknownAction)
144 } else {
145 None
146 };
147 if let Some(kind) = kind {
148 let classification = if kind == IncompletenessKind::OpaqueAction {
149 ResidualClassification::LegacyOpaque
150 } else {
151 ResidualClassification::ProducerExtension
152 };
153 issues.push(SemanticIssue {
154 kind,
155 name: name.clone(),
156 span: *span,
157 classification,
158 });
159 }
160 }
161 Action::If {
162 branches,
163 else_body,
164 ..
165 } => {
166 for branch in branches {
167 inspect_action_id(branch.body.as_slice(), program, catalog, issues);
168 }
169 if let Some(body) = else_body {
170 inspect_action_id(body.as_slice(), program, catalog, issues);
171 }
172 }
173 Action::While { body, .. }
174 | Action::ForGlobalVariable { body, .. }
175 | Action::ForPlayerVariable { body, .. } => {
176 inspect_action_id(body.as_slice(), program, catalog, issues);
177 }
178 Action::SetGlobalVariable { .. }
179 | Action::ModifyGlobalVariable { .. }
180 | Action::SetPlayerVariable { .. }
181 | Action::ModifyPlayerVariable { .. }
182 | Action::AssignMember { .. }
183 | Action::CallSubroutine { .. }
184 | Action::Debug { .. }
185 | Action::Print { .. } => {}
186 }
187}
188
189fn inspect_action_id(
190 ids: &[crate::wir::ActionId],
191 program: &Program,
192 catalog: &Catalog,
193 issues: &mut Vec<SemanticIssue>,
194) {
195 for id in ids {
196 if let Some(action) = program.actions.get(*id) {
197 inspect_action(action, program, catalog, issues);
198 }
199 }
200}
201
202fn inspect_value(
203 node: &crate::wir::ValueNode,
204 program: &Program,
205 catalog: &Catalog,
206 issues: &mut Vec<SemanticIssue>,
207) {
208 if let Value::Call { name, args } = &node.value {
209 let canonical_helper = matches!(
213 name.as_str(),
214 "memberAccess"
215 | "+"
216 | "-"
217 | "*"
218 | "/"
219 | "%"
220 | "add"
221 | "subtract"
222 | "multiply"
223 | "divide"
224 | "modulo"
225 | "min"
226 | "max"
227 | "raiseToPower"
228 | "appendToArray"
229 | "removeFromArray"
230 | "removeFromArrayByIndex"
231 ) && (args.is_empty()
232 || matches!(name.as_str(), "memberAccess" | "+" | "-" | "*" | "/" | "%"));
233 if canonical_helper {
234 return;
235 }
236 if catalog.entry(Kind::Value, name).is_none()
237 && catalog.entry(Kind::Operator, name).is_none()
238 {
239 issues.push(SemanticIssue {
240 kind: IncompletenessKind::UnknownValue,
241 name: name.clone(),
242 span: node.span,
243 classification: if program
244 .global_variables
245 .iter()
246 .any(|variable| variable.name == *name)
247 || program
248 .player_variables
249 .iter()
250 .any(|variable| variable.name == *name)
251 {
252 ResidualClassification::SourceDeclaredVariable
253 } else {
254 ResidualClassification::UnresolvedIdentifier
255 },
256 });
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::settings::{Settings, SettingsNode};
265
266 #[test]
267 fn reports_preserved_and_unknown_nodes() {
268 let catalog = Catalog::builtin().expect("builtin catalog");
269 let mut program = Program {
270 settings: Some(Settings {
271 span: None,
272 children: vec![SettingsNode::Raw {
273 name: "Future Setting".to_string(),
274 value: "opaque".to_string(),
275 span: None,
276 }],
277 }),
278 ..Program::default()
279 };
280 program.actions.push(Action::Call {
281 name: "rawWorkshopAction".to_string(),
282 args: Vec::new(),
283 span: None,
284 });
285 program.actions.push(Action::Call {
286 name: "futureAction".to_string(),
287 args: Vec::new(),
288 span: None,
289 });
290 program.values.push(crate::wir::ValueNode::new(
291 Value::Call {
292 name: "futureValue".to_string(),
293 args: Vec::new(),
294 },
295 None,
296 ));
297
298 let issues = inspect(&program, &catalog);
299 assert!(
300 issues
301 .iter()
302 .any(|issue| issue.kind == IncompletenessKind::RawSetting)
303 );
304 assert!(
305 issues
306 .iter()
307 .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
308 );
309 assert!(
310 issues
311 .iter()
312 .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
313 );
314 assert!(
315 issues
316 .iter()
317 .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
318 );
319 assert!(issues.iter().any(|issue| {
320 issue.kind == IncompletenessKind::RawSetting
321 && issue.classification == ResidualClassification::ProjectDefinedConstruct
322 }));
323 assert!(issues.iter().any(|issue| {
324 issue.kind == IncompletenessKind::OpaqueAction
325 && issue.classification == ResidualClassification::LegacyOpaque
326 }));
327 assert!(issues.iter().any(|issue| {
328 issue.kind == IncompletenessKind::UnknownValue
329 && issue.classification == ResidualClassification::UnresolvedIdentifier
330 }));
331 }
332}