1use crate::catalog::{Catalog, Kind};
12use crate::core::source::Span;
13use crate::program::{action_argument_values, value_children};
14use crate::settings::SettingsNode;
15use crate::settings::table;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum IncompletenessKind {
19 RawSetting,
20 UnknownAction,
21 UnknownValue,
22 OpaqueAction,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ResidualClassification {
27 ProjectDefinedConstruct,
28 SourceDeclaredVariable,
29 ProducerExtension,
30 LegacyOpaque,
31 UnresolvedIdentifier,
32 CatalogSpellingNearMiss,
36}
37
38impl ResidualClassification {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::ProjectDefinedConstruct => "project-defined-construct",
42 Self::SourceDeclaredVariable => "source-declared-variable",
43 Self::ProducerExtension => "producer-extension",
44 Self::LegacyOpaque => "legacy-opaque-construct",
45 Self::UnresolvedIdentifier => "truly-unresolved-identifier",
46 Self::CatalogSpellingNearMiss => "catalog-spelling-near-miss",
47 }
48 }
49
50 pub fn evidence(self) -> &'static str {
51 match self {
52 Self::ProjectDefinedConstruct => {
53 "source settings or construct was preserved without a canonical catalog identity"
54 }
55 Self::SourceDeclaredVariable => {
56 "the identifier matches a variable declaration in the parsed source program"
57 }
58 Self::ProducerExtension => {
59 "the source uses an action-shaped identity outside the canonical catalog and no declaration resolves it"
60 }
61 Self::LegacyOpaque => {
62 "the parser preserved a legacy raw construct without a canonical contract"
63 }
64 Self::UnresolvedIdentifier => {
65 "the identifier matches neither a source declaration nor a canonical catalog identity"
66 }
67 Self::CatalogSpellingNearMiss => {
68 "the spelling is within a small edit distance of exactly one declared settings member"
69 }
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct SemanticIssue {
77 pub kind: IncompletenessKind,
78 pub name: String,
79 pub span: Option<Span>,
80 pub classification: ResidualClassification,
81 pub suggestion: Option<String>,
83}
84
85pub fn inspect(program: &crate::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
98 let mut issues = Vec::new();
99 if let Some(settings) = &program.settings {
100 for member in crate::settings::check::uncatalogued_members(settings) {
101 issues.push(SemanticIssue {
102 kind: IncompletenessKind::RawSetting,
103 name: member.name.to_string(),
104 span: member.span,
105 classification: match member.suggestion {
108 Some(_) => ResidualClassification::CatalogSpellingNearMiss,
109 None => ResidualClassification::ProjectDefinedConstruct,
110 },
111 suggestion: member.suggestion,
112 });
113 }
114 for node in &settings.children {
115 inspect_setting(node, &mut issues);
116 }
117 }
118 for (rule, rule_data) in program.rules.iter().enumerate() {
119 for (position, action) in rule_data.actions.iter().enumerate() {
120 inspect_action(action, program, rule, position, catalog, &mut issues);
121 }
122 }
123 for (rule, rule_data) in program.rules.iter().enumerate() {
124 for (condition, condition_data) in rule_data.conditions.iter().enumerate() {
125 inspect_value_tree(
126 &condition_data.value,
127 &mut Vec::new(),
128 &|path| program.condition_value_node_span(rule, condition, path),
129 program,
130 catalog,
131 &mut issues,
132 );
133 }
134 let mut position = 0;
135 inspect_action_values(
136 &rule_data.actions,
137 &mut position,
138 false,
139 rule,
140 program,
141 catalog,
142 &mut issues,
143 );
144 }
145 issues
146}
147
148#[cfg(test)]
149pub(crate) fn inspect_wir(program: &crate::wir::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
150 let program = crate::Program::from_wir(program.clone())
151 .expect("test WIR materializes as a public program");
152 inspect(&program, catalog)
153}
154
155fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
156 match node {
157 SettingsNode::Workshop { .. } => {}
158 SettingsNode::Group { children, .. } => {
159 for child in children {
160 inspect_setting(child, issues);
161 }
162 }
163
164 SettingsNode::List { name, elements, .. } => {
165 let element_known: fn(&crate::settings::SettingsListElement) -> bool =
166 match name.as_str() {
167 "enabledMaps" | "disabledMaps" => {
168 |element: &crate::settings::SettingsListElement| {
169 table::map_name(&element.value).is_some()
170 }
171 }
172 "enabledHeroes" | "disabledHeroes" => {
173 |element: &crate::settings::SettingsListElement| {
174 table::hero_name(&element.value).is_some()
175 }
176 }
177 _ => |_| true,
178 };
179 for element in elements {
180 if !element_known(element) {
181 issues.push(SemanticIssue {
182 kind: IncompletenessKind::RawSetting,
183 name: element.value.clone(),
184 span: element.span,
185 classification: ResidualClassification::ProjectDefinedConstruct,
186 suggestion: None,
187 });
188 }
189 }
190 }
191 SettingsNode::Number { .. }
192 | SettingsNode::Bool { .. }
193 | SettingsNode::Flag { .. }
194 | SettingsNode::String { .. }
195 | SettingsNode::Raw { .. }
196 | SettingsNode::RawValue { .. } => {}
197 }
198}
199
200fn inspect_action(
201 action: &crate::Action,
202 program: &crate::Program,
203 rule: usize,
204 position: usize,
205 catalog: &Catalog,
206 issues: &mut Vec<SemanticIssue>,
207) {
208 match action {
209 crate::Action::Call { name, .. } => {
210 let kind = if name == "rawWorkshopAction" {
211 Some(IncompletenessKind::OpaqueAction)
212 } else if catalog.entry(Kind::Action, name).is_none() {
213 Some(IncompletenessKind::UnknownAction)
214 } else {
215 None
216 };
217 if let Some(kind) = kind {
218 let classification = if kind == IncompletenessKind::OpaqueAction {
219 ResidualClassification::LegacyOpaque
220 } else {
221 ResidualClassification::ProducerExtension
222 };
223 issues.push(SemanticIssue {
224 kind,
225 name: name.clone(),
226 span: program.action_span(rule, position),
227 classification,
228 suggestion: None,
229 });
230 }
231 }
232 crate::Action::Disabled { action } => {
233 inspect_action(action, program, rule, position, catalog, issues);
234 }
235 _ => {}
236 }
237}
238
239fn inspect_action_values(
246 actions: &[crate::Action],
247 position: &mut usize,
248 stop_at_terminator: bool,
249 rule: usize,
250 program: &crate::Program,
251 catalog: &Catalog,
252 issues: &mut Vec<SemanticIssue>,
253) {
254 while *position < actions.len() {
255 let index = *position;
256 let current = match &actions[index] {
257 crate::Action::Disabled { action } => action.as_ref(),
258 action => action,
259 };
260 match current {
261 crate::Action::ElseIf { .. } | crate::Action::Else | crate::Action::End
262 if stop_at_terminator =>
263 {
264 return;
265 }
266 crate::Action::If { .. } => {
267 inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
268 *position += 1;
269 inspect_action_values(actions, position, true, rule, program, catalog, issues);
270 while matches!(actions.get(*position), Some(crate::Action::ElseIf { .. })) {
271 let elseif = *position;
272 *position += 1;
273 inspect_action_values(actions, position, true, rule, program, catalog, issues);
274 inspect_action_arguments(
275 rule,
276 elseif,
277 &actions[elseif],
278 program,
279 catalog,
280 issues,
281 );
282 }
283 if matches!(actions.get(*position), Some(crate::Action::Else)) {
284 *position += 1;
285 inspect_action_values(actions, position, true, rule, program, catalog, issues);
286 }
287 if matches!(actions.get(*position), Some(crate::Action::End)) {
288 *position += 1;
289 }
290 }
291 crate::Action::While { .. }
292 | crate::Action::ForGlobalVariable { .. }
293 | crate::Action::ForPlayerVariable { .. } => {
294 *position += 1;
295 inspect_action_values(actions, position, true, rule, program, catalog, issues);
296 if matches!(actions.get(*position), Some(crate::Action::End)) {
297 *position += 1;
298 }
299 inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
300 }
301 _ => {
302 inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
303 *position += 1;
304 }
305 }
306 }
307}
308
309fn inspect_action_arguments(
312 rule: usize,
313 action: usize,
314 action_data: &crate::Action,
315 program: &crate::Program,
316 catalog: &Catalog,
317 issues: &mut Vec<SemanticIssue>,
318) {
319 for (argument, value) in action_argument_values(action_data).iter().enumerate() {
320 inspect_value_tree(
321 value,
322 &mut Vec::new(),
323 &|path| program.action_argument_value_node_span(rule, action, argument, path),
324 program,
325 catalog,
326 issues,
327 );
328 }
329}
330
331fn inspect_value_tree(
335 value: &crate::Value,
336 path: &mut Vec<usize>,
337 span_at: &impl Fn(&[usize]) -> Option<Span>,
338 program: &crate::Program,
339 catalog: &Catalog,
340 issues: &mut Vec<SemanticIssue>,
341) {
342 for (index, child) in value_children(value).into_iter().enumerate() {
343 path.push(index);
344 inspect_value_tree(child, path, span_at, program, catalog, issues);
345 path.pop();
346 }
347 if let crate::Value::Call { name, args } = value {
348 let canonical_helper = name == crate::wir::AMBIGUOUS_ENUM_CALL
352 || crate::wir::is_canonical_helper_call(name, args.len());
353 if canonical_helper {
354 return;
355 }
356 if catalog.entry(Kind::Value, name).is_none()
357 && catalog.entry(Kind::Operator, name).is_none()
358 {
359 issues.push(SemanticIssue {
360 kind: IncompletenessKind::UnknownValue,
361 name: name.clone(),
362 span: span_at(path),
363 classification: if program
364 .global_variables
365 .iter()
366 .any(|variable| variable.name == *name)
367 || program
368 .player_variables
369 .iter()
370 .any(|variable| variable.name == *name)
371 {
372 ResidualClassification::SourceDeclaredVariable
373 } else {
374 ResidualClassification::UnresolvedIdentifier
375 },
376 suggestion: None,
377 });
378 }
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::settings::{Settings, SettingsNode};
386 use crate::{Action, Condition, Event, Program, Rule, Value};
387
388 #[test]
389 fn reports_preserved_and_unknown_nodes() {
390 let catalog = Catalog::builtin().expect("builtin catalog");
391 let mut program = Program::new();
392 program.settings = Some(Settings {
393 span: None,
394 children: vec![SettingsNode::Raw {
395 name: "Future Setting".to_string(),
396 value: "opaque".to_string(),
397 span: None,
398 }],
399 });
400 program.rule(
401 Rule::new("residuals", Event::Global)
402 .condition(Condition::new(Value::call("futureValue", [])))
403 .action(Action::call("rawWorkshopAction", []))
404 .action(Action::call("futureAction", [])),
405 );
406
407 let issues = inspect(&program, &catalog);
408 assert!(
409 issues
410 .iter()
411 .any(|issue| issue.kind == IncompletenessKind::RawSetting)
412 );
413 assert!(
414 issues
415 .iter()
416 .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
417 );
418 assert!(
419 issues
420 .iter()
421 .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
422 );
423 assert!(
424 issues
425 .iter()
426 .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
427 );
428 assert!(issues.iter().any(|issue| {
429 issue.kind == IncompletenessKind::RawSetting
430 && issue.classification == ResidualClassification::ProjectDefinedConstruct
431 }));
432 assert!(issues.iter().any(|issue| {
433 issue.kind == IncompletenessKind::OpaqueAction
434 && issue.classification == ResidualClassification::LegacyOpaque
435 }));
436 assert!(issues.iter().any(|issue| {
437 issue.kind == IncompletenessKind::UnknownValue
438 && issue.classification == ResidualClassification::UnresolvedIdentifier
439 }));
440 }
441}