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