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