Skip to main content

workshop_rs/analysis/
semantic.rs

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    /// A carried settings member whose spelling is close to exactly one
23    /// declared key or enum member — a likely misspelling, not a new
24    /// project-defined construct.
25    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    /// The canonical spelling `name` was close to, when exactly one was.
72    pub suggestion: Option<String>,
73}
74
75/// Report preserved or catalog-unknown constructs that must not be treated as
76/// fully understood by downstream analysis.
77///
78/// Residual inspection is defined over the public canonical
79/// [`crate::Program`] model and is independent from structural validation: it
80/// may be used on `Program` values that [`Program::validate`] rejects, and an
81/// internal WIR materialization failure never projects to an empty inventory.
82/// Callers that also require structural validity call
83/// [`Program::validate`](crate::Program::validate) separately; inspection
84/// does not report validation errors.
85///
86/// [`Program::validate`]: crate::Program::validate
87pub 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}