1use crate::catalog::{Catalog, Kind};
8use crate::core::source::Span;
9use crate::settings::SettingsNode;
10use crate::settings::table;
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)]
63#[non_exhaustive]
64pub struct SemanticIssue {
65 pub kind: IncompletenessKind,
66 pub name: String,
67 pub span: Option<Span>,
68 pub classification: ResidualClassification,
69}
70
71pub fn inspect(program: &crate::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
74 let Ok(storage) = program.to_wir() else {
75 return Vec::new();
76 };
77 inspect_wir(&storage, catalog)
78}
79
80#[doc(hidden)]
81pub(crate) fn inspect_wir(program: &Program, catalog: &Catalog) -> Vec<SemanticIssue> {
82 let mut issues = Vec::new();
83 if let Some(settings) = &program.settings {
84 for node in &settings.children {
85 inspect_setting(node, &mut issues);
86 }
87 }
88 for action in program.actions.iter() {
89 inspect_action(action, program, catalog, &mut issues);
90 }
91 for value in program.values.iter() {
92 inspect_value(value, program, catalog, &mut issues);
93 }
94 issues
95}
96
97fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
98 match node {
99 SettingsNode::Workshop { .. } => {}
100 SettingsNode::Group { children, .. } => {
101 for child in children {
102 inspect_setting(child, issues);
103 }
104 }
105 SettingsNode::Raw { name, span, .. } => issues.push(SemanticIssue {
106 kind: IncompletenessKind::RawSetting,
107 name: name.clone(),
108 span: *span,
109 classification: ResidualClassification::ProjectDefinedConstruct,
110 }),
111 SettingsNode::List {
112 name,
113 elements,
114 span,
115 } => {
116 let known = match name.as_str() {
117 "enabledMaps" | "disabledMaps" => elements
118 .iter()
119 .all(|element| table::map_name(&element.value).is_some()),
120 "enabledHeroes" | "disabledHeroes" => elements
121 .iter()
122 .all(|element| table::hero_name(&element.value).is_some()),
123 _ => true,
124 };
125 if !known {
126 issues.push(SemanticIssue {
127 kind: IncompletenessKind::RawSetting,
128 name: name.clone(),
129 span: *span,
130 classification: ResidualClassification::ProjectDefinedConstruct,
131 });
132 }
133 }
134 SettingsNode::Number { .. }
135 | SettingsNode::Bool { .. }
136 | SettingsNode::Flag { .. }
137 | SettingsNode::String { .. } => {}
138 }
139}
140
141fn inspect_action(
142 action: &Action,
143 program: &Program,
144 catalog: &Catalog,
145 issues: &mut Vec<SemanticIssue>,
146) {
147 match action {
148 Action::Call { name, span, .. } => {
149 let kind = if name == "rawWorkshopAction" {
150 Some(IncompletenessKind::OpaqueAction)
151 } else if catalog.entry(Kind::Action, name).is_none() {
152 Some(IncompletenessKind::UnknownAction)
153 } else {
154 None
155 };
156 if let Some(kind) = kind {
157 let classification = if kind == IncompletenessKind::OpaqueAction {
158 ResidualClassification::LegacyOpaque
159 } else {
160 ResidualClassification::ProducerExtension
161 };
162 issues.push(SemanticIssue {
163 kind,
164 name: name.clone(),
165 span: *span,
166 classification,
167 });
168 }
169 }
170 Action::If {
171 branches,
172 else_body,
173 ..
174 } => {
175 for branch in branches {
176 inspect_action_id(branch.body.as_slice(), program, catalog, issues);
177 }
178 if let Some(body) = else_body {
179 inspect_action_id(body.as_slice(), program, catalog, issues);
180 }
181 }
182 Action::While { body, .. }
183 | Action::ForGlobalVariable { body, .. }
184 | Action::ForPlayerVariable { body, .. } => {
185 inspect_action_id(body.as_slice(), program, catalog, issues);
186 }
187 Action::Disabled { action, .. } => {
188 inspect_action_id(std::slice::from_ref(action), program, catalog, issues);
189 }
190 Action::SetGlobalVariable { .. }
191 | Action::ModifyGlobalVariable { .. }
192 | Action::SetPlayerVariable { .. }
193 | Action::ModifyPlayerVariable { .. }
194 | Action::AssignMember { .. }
195 | Action::CallSubroutine { .. } => {}
196 }
197}
198
199fn inspect_action_id(
200 ids: &[crate::wir::ActionId],
201 program: &Program,
202 catalog: &Catalog,
203 issues: &mut Vec<SemanticIssue>,
204) {
205 for id in ids {
206 if let Some(action) = program.actions.get(*id) {
207 inspect_action(action, program, catalog, issues);
208 }
209 }
210}
211
212fn inspect_value(
213 node: &crate::wir::ValueNode,
214 program: &Program,
215 catalog: &Catalog,
216 issues: &mut Vec<SemanticIssue>,
217) {
218 if let Value::Call { name, args } = &node.value {
219 let canonical_helper = matches!(
223 name.as_str(),
224 crate::wir::AMBIGUOUS_ENUM_CALL
225 | "memberAccess"
226 | "+"
227 | "-"
228 | "*"
229 | "/"
230 | "%"
231 | "add"
232 | "subtract"
233 | "multiply"
234 | "divide"
235 | "modulo"
236 | "min"
237 | "max"
238 | "raiseToPower"
239 | "appendToArray"
240 | "removeFromArray"
241 | "removeFromArrayByValue"
242 | "removeFromArrayByIndex"
243 ) && (args.is_empty()
244 || matches!(
245 name.as_str(),
246 crate::wir::AMBIGUOUS_ENUM_CALL | "memberAccess" | "+" | "-" | "*" | "/" | "%"
247 ));
248 if canonical_helper {
249 return;
250 }
251 if catalog.entry(Kind::Value, name).is_none()
252 && catalog.entry(Kind::Operator, name).is_none()
253 {
254 issues.push(SemanticIssue {
255 kind: IncompletenessKind::UnknownValue,
256 name: name.clone(),
257 span: node.span,
258 classification: if program
259 .global_variables
260 .iter()
261 .any(|variable| variable.name == *name)
262 || program
263 .player_variables
264 .iter()
265 .any(|variable| variable.name == *name)
266 {
267 ResidualClassification::SourceDeclaredVariable
268 } else {
269 ResidualClassification::UnresolvedIdentifier
270 },
271 });
272 }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::settings::{Settings, SettingsNode};
280
281 #[test]
282 fn reports_preserved_and_unknown_nodes() {
283 let catalog = Catalog::builtin().expect("builtin catalog");
284 let mut program = Program {
285 settings: Some(Settings {
286 span: None,
287 children: vec![SettingsNode::Raw {
288 name: "Future Setting".to_string(),
289 value: "opaque".to_string(),
290 span: None,
291 }],
292 }),
293 ..Program::default()
294 };
295 program.actions.push(Action::Call {
296 name: "rawWorkshopAction".to_string(),
297 args: Vec::new(),
298 span: None,
299 });
300 program.actions.push(Action::Call {
301 name: "futureAction".to_string(),
302 args: Vec::new(),
303 span: None,
304 });
305 program.values.push(crate::wir::ValueNode::new(
306 Value::Call {
307 name: "futureValue".to_string(),
308 args: Vec::new(),
309 },
310 None,
311 ));
312
313 let issues = inspect_wir(&program, &catalog);
314 assert!(
315 issues
316 .iter()
317 .any(|issue| issue.kind == IncompletenessKind::RawSetting)
318 );
319 assert!(
320 issues
321 .iter()
322 .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
323 );
324 assert!(
325 issues
326 .iter()
327 .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
328 );
329 assert!(
330 issues
331 .iter()
332 .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
333 );
334 assert!(issues.iter().any(|issue| {
335 issue.kind == IncompletenessKind::RawSetting
336 && issue.classification == ResidualClassification::ProjectDefinedConstruct
337 }));
338 assert!(issues.iter().any(|issue| {
339 issue.kind == IncompletenessKind::OpaqueAction
340 && issue.classification == ResidualClassification::LegacyOpaque
341 }));
342 assert!(issues.iter().any(|issue| {
343 issue.kind == IncompletenessKind::UnknownValue
344 && issue.classification == ResidualClassification::UnresolvedIdentifier
345 }));
346 }
347}