Skip to main content

workshop_rs/analysis/
semantic.rs

1//! Semantic-completeness inspection for permissive raw Workshop parsing.
2//!
3//! Inspection is defined over the public canonical `Program` model and is
4//! independent from structural validation: residuals observable in the public
5//! model are reported even when the program cannot be materialized to
6//! internal WIR. Structural validity deliberately remains separate from this
7//! report: a preserved node can be structurally valid while still being
8//! unsuitable for definitive analysis, and a structurally invalid program can
9//! still carry inspectable semantic residuals.
10
11use 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}
33
34impl ResidualClassification {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::ProjectDefinedConstruct => "project-defined-construct",
38            Self::SourceDeclaredVariable => "source-declared-variable",
39            Self::ProducerExtension => "producer-extension",
40            Self::LegacyOpaque => "legacy-opaque-construct",
41            Self::UnresolvedIdentifier => "truly-unresolved-identifier",
42        }
43    }
44
45    pub fn evidence(self) -> &'static str {
46        match self {
47            Self::ProjectDefinedConstruct => {
48                "source settings or construct was preserved without a canonical catalog identity"
49            }
50            Self::SourceDeclaredVariable => {
51                "the identifier matches a variable declaration in the parsed source program"
52            }
53            Self::ProducerExtension => {
54                "the source uses an action-shaped identity outside the canonical catalog and no declaration resolves it"
55            }
56            Self::LegacyOpaque => {
57                "the parser preserved a legacy raw construct without a canonical contract"
58            }
59            Self::UnresolvedIdentifier => {
60                "the identifier matches neither a source declaration nor a canonical catalog identity"
61            }
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct SemanticIssue {
69    pub kind: IncompletenessKind,
70    pub name: String,
71    pub span: Option<Span>,
72    pub classification: ResidualClassification,
73    /// The canonical spelling `name` was close to, when exactly one was.
74    pub suggestion: Option<String>,
75}
76
77/// Report preserved or catalog-unknown constructs that must not be treated as
78/// fully understood by downstream analysis.
79///
80/// Residual inspection is defined over the public canonical
81/// [`crate::Program`] model and is independent from structural validation: it
82/// may be used on `Program` values that [`Program::validate`] rejects, and an
83/// internal WIR materialization failure never projects to an empty inventory.
84/// Callers that also require structural validity call
85/// [`Program::validate`](crate::Program::validate) separately; inspection
86/// does not report validation errors.
87///
88/// [`Program::validate`]: crate::Program::validate
89pub fn inspect(program: &crate::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
90    let mut issues = Vec::new();
91    if let Some(settings) = &program.settings {
92        for member in crate::settings::check::verbatim_members(settings) {
93            issues.push(SemanticIssue {
94                kind: IncompletenessKind::RawSetting,
95                name: member.name.to_string(),
96                span: member.span,
97                classification: ResidualClassification::ProjectDefinedConstruct,
98                suggestion: member.suggestion,
99            });
100        }
101        for node in &settings.children {
102            inspect_setting(node, &mut issues);
103        }
104    }
105    for (rule, rule_data) in program.rules.iter().enumerate() {
106        for (position, action) in rule_data.actions.iter().enumerate() {
107            inspect_action(action, program, rule, position, catalog, &mut issues);
108        }
109    }
110    for (rule, rule_data) in program.rules.iter().enumerate() {
111        for (condition, condition_data) in rule_data.conditions.iter().enumerate() {
112            inspect_value_tree(
113                &condition_data.value,
114                &mut Vec::new(),
115                &|path| program.condition_value_node_span(rule, condition, path),
116                program,
117                catalog,
118                &mut issues,
119            );
120        }
121        let mut position = 0;
122        inspect_action_values(
123            &rule_data.actions,
124            &mut position,
125            false,
126            rule,
127            program,
128            catalog,
129            &mut issues,
130        );
131    }
132    issues
133}
134
135#[cfg(test)]
136pub(crate) fn inspect_wir(program: &crate::wir::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
137    let program = crate::Program::from_wir(program.clone())
138        .expect("test WIR materializes as a public program");
139    inspect(&program, catalog)
140}
141
142fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
143    match node {
144        SettingsNode::Workshop { .. } => {}
145        SettingsNode::Group { children, .. } => {
146            for child in children {
147                inspect_setting(child, issues);
148            }
149        }
150
151        SettingsNode::List { name, elements, .. } => {
152            let element_known: fn(&crate::settings::SettingsListElement) -> bool =
153                match name.as_str() {
154                    "enabledMaps" | "disabledMaps" => {
155                        |element: &crate::settings::SettingsListElement| {
156                            table::map_name(&element.value).is_some()
157                        }
158                    }
159                    "enabledHeroes" | "disabledHeroes" => {
160                        |element: &crate::settings::SettingsListElement| {
161                            table::hero_name(&element.value).is_some()
162                        }
163                    }
164                    _ => |_| true,
165                };
166            for element in elements {
167                if !element_known(element) {
168                    issues.push(SemanticIssue {
169                        kind: IncompletenessKind::RawSetting,
170                        name: element.value.clone(),
171                        span: element.span,
172                        classification: ResidualClassification::ProjectDefinedConstruct,
173                        suggestion: None,
174                    });
175                }
176            }
177        }
178        SettingsNode::Number { .. }
179        | SettingsNode::Bool { .. }
180        | SettingsNode::Flag { .. }
181        | SettingsNode::String { .. }
182        | SettingsNode::Raw { .. }
183        | SettingsNode::Verbatim { .. } => {}
184    }
185}
186
187fn inspect_action(
188    action: &crate::Action,
189    program: &crate::Program,
190    rule: usize,
191    position: usize,
192    catalog: &Catalog,
193    issues: &mut Vec<SemanticIssue>,
194) {
195    match action {
196        crate::Action::Call { name, .. } => {
197            let kind = if name == "rawWorkshopAction" {
198                Some(IncompletenessKind::OpaqueAction)
199            } else if catalog.entry(Kind::Action, name).is_none() {
200                Some(IncompletenessKind::UnknownAction)
201            } else {
202                None
203            };
204            if let Some(kind) = kind {
205                let classification = if kind == IncompletenessKind::OpaqueAction {
206                    ResidualClassification::LegacyOpaque
207                } else {
208                    ResidualClassification::ProducerExtension
209                };
210                issues.push(SemanticIssue {
211                    kind,
212                    name: name.clone(),
213                    span: program.action_span(rule, position),
214                    classification,
215                    suggestion: None,
216                });
217            }
218        }
219        crate::Action::Disabled { action } => {
220            inspect_action(action, program, rule, position, catalog, issues);
221        }
222        _ => {}
223    }
224}
225
226/// Visit every action value argument in the order internal materialization
227/// pushes value nodes: an `If` header precedes its body, while `While`,
228/// `For`, and `Else If` headers follow their bodies. Control-flow terminators
229/// stop a body scan without being consumed; a stray terminator at stream top
230/// level is visited like any other action, so malformed rules still expose
231/// every observable value. `stop_at_terminator` distinguishes the two scans.
232fn inspect_action_values(
233    actions: &[crate::Action],
234    position: &mut usize,
235    stop_at_terminator: bool,
236    rule: usize,
237    program: &crate::Program,
238    catalog: &Catalog,
239    issues: &mut Vec<SemanticIssue>,
240) {
241    while *position < actions.len() {
242        let index = *position;
243        let current = match &actions[index] {
244            crate::Action::Disabled { action } => action.as_ref(),
245            action => action,
246        };
247        match current {
248            crate::Action::ElseIf { .. } | crate::Action::Else | crate::Action::End
249                if stop_at_terminator =>
250            {
251                return;
252            }
253            crate::Action::If { .. } => {
254                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
255                *position += 1;
256                inspect_action_values(actions, position, true, rule, program, catalog, issues);
257                while matches!(actions.get(*position), Some(crate::Action::ElseIf { .. })) {
258                    let elseif = *position;
259                    *position += 1;
260                    inspect_action_values(actions, position, true, rule, program, catalog, issues);
261                    inspect_action_arguments(
262                        rule,
263                        elseif,
264                        &actions[elseif],
265                        program,
266                        catalog,
267                        issues,
268                    );
269                }
270                if matches!(actions.get(*position), Some(crate::Action::Else)) {
271                    *position += 1;
272                    inspect_action_values(actions, position, true, rule, program, catalog, issues);
273                }
274                if matches!(actions.get(*position), Some(crate::Action::End)) {
275                    *position += 1;
276                }
277            }
278            crate::Action::While { .. }
279            | crate::Action::ForGlobalVariable { .. }
280            | crate::Action::ForPlayerVariable { .. } => {
281                *position += 1;
282                inspect_action_values(actions, position, true, rule, program, catalog, issues);
283                if matches!(actions.get(*position), Some(crate::Action::End)) {
284                    *position += 1;
285                }
286                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
287            }
288            _ => {
289                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
290                *position += 1;
291            }
292        }
293    }
294}
295
296/// Inspect the direct value arguments of one public action in their mapped
297/// order.
298fn inspect_action_arguments(
299    rule: usize,
300    action: usize,
301    action_data: &crate::Action,
302    program: &crate::Program,
303    catalog: &Catalog,
304    issues: &mut Vec<SemanticIssue>,
305) {
306    for (argument, value) in action_argument_values(action_data).iter().enumerate() {
307        inspect_value_tree(
308            value,
309            &mut Vec::new(),
310            &|path| program.action_argument_value_node_span(rule, action, argument, path),
311            program,
312            catalog,
313            issues,
314        );
315    }
316}
317
318/// Walk a public value tree in post-order — children before their node, the
319/// order value nodes take when materialized to the internal arena — flagging
320/// every `Value::Call` identity the catalog does not resolve.
321fn inspect_value_tree(
322    value: &crate::Value,
323    path: &mut Vec<usize>,
324    span_at: &impl Fn(&[usize]) -> Option<Span>,
325    program: &crate::Program,
326    catalog: &Catalog,
327    issues: &mut Vec<SemanticIssue>,
328) {
329    for (index, child) in value_children(value).into_iter().enumerate() {
330        path.push(index);
331        inspect_value_tree(child, path, span_at, program, catalog, issues);
332        path.pop();
333    }
334    if let crate::Value::Call { name, args } = value {
335        // These names are canonical helpers rather than Workshop
336        // builtins: memberAccess preserves dynamic receiver properties, and
337        // infix operators are lowered to their source spelling for emission.
338        let canonical_helper = name == crate::wir::AMBIGUOUS_ENUM_CALL
339            || crate::wir::is_canonical_helper_call(name, args.len());
340        if canonical_helper {
341            return;
342        }
343        if catalog.entry(Kind::Value, name).is_none()
344            && catalog.entry(Kind::Operator, name).is_none()
345        {
346            issues.push(SemanticIssue {
347                kind: IncompletenessKind::UnknownValue,
348                name: name.clone(),
349                span: span_at(path),
350                classification: if program
351                    .global_variables
352                    .iter()
353                    .any(|variable| variable.name == *name)
354                    || program
355                        .player_variables
356                        .iter()
357                        .any(|variable| variable.name == *name)
358                {
359                    ResidualClassification::SourceDeclaredVariable
360                } else {
361                    ResidualClassification::UnresolvedIdentifier
362                },
363                suggestion: None,
364            });
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::settings::{Settings, SettingsNode};
373    use crate::{Action, Condition, Event, Program, Rule, Value};
374
375    #[test]
376    fn reports_preserved_and_unknown_nodes() {
377        let catalog = Catalog::builtin().expect("builtin catalog");
378        let mut program = Program::new();
379        program.settings = Some(Settings {
380            span: None,
381            children: vec![SettingsNode::Raw {
382                name: "Future Setting".to_string(),
383                value: "opaque".to_string(),
384                span: None,
385            }],
386        });
387        program.rule(
388            Rule::new("residuals", Event::Global)
389                .condition(Condition::new(Value::call("futureValue", [])))
390                .action(Action::call("rawWorkshopAction", []))
391                .action(Action::call("futureAction", [])),
392        );
393
394        let issues = inspect(&program, &catalog);
395        assert!(
396            issues
397                .iter()
398                .any(|issue| issue.kind == IncompletenessKind::RawSetting)
399        );
400        assert!(
401            issues
402                .iter()
403                .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
404        );
405        assert!(
406            issues
407                .iter()
408                .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
409        );
410        assert!(
411            issues
412                .iter()
413                .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
414        );
415        assert!(issues.iter().any(|issue| {
416            issue.kind == IncompletenessKind::RawSetting
417                && issue.classification == ResidualClassification::ProjectDefinedConstruct
418        }));
419        assert!(issues.iter().any(|issue| {
420            issue.kind == IncompletenessKind::OpaqueAction
421                && issue.classification == ResidualClassification::LegacyOpaque
422        }));
423        assert!(issues.iter().any(|issue| {
424            issue.kind == IncompletenessKind::UnknownValue
425                && issue.classification == ResidualClassification::UnresolvedIdentifier
426        }));
427    }
428}