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}
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 node in &settings.children {
91            inspect_setting(node, &mut issues);
92        }
93    }
94    for (rule, rule_data) in program.rules.iter().enumerate() {
95        for (position, action) in rule_data.actions.iter().enumerate() {
96            inspect_action(action, program, rule, position, catalog, &mut issues);
97        }
98    }
99    for (rule, rule_data) in program.rules.iter().enumerate() {
100        for (condition, condition_data) in rule_data.conditions.iter().enumerate() {
101            inspect_value_tree(
102                &condition_data.value,
103                &mut Vec::new(),
104                &|path| program.condition_value_node_span(rule, condition, path),
105                program,
106                catalog,
107                &mut issues,
108            );
109        }
110        let mut position = 0;
111        inspect_action_values(
112            &rule_data.actions,
113            &mut position,
114            false,
115            rule,
116            program,
117            catalog,
118            &mut issues,
119        );
120    }
121    issues
122}
123
124#[cfg(test)]
125pub(crate) fn inspect_wir(program: &crate::wir::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
126    let program = crate::Program::from_wir(program.clone())
127        .expect("test WIR materializes as a public program");
128    inspect(&program, catalog)
129}
130
131fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
132    match node {
133        SettingsNode::Workshop { .. } => {}
134        SettingsNode::Group { children, .. } => {
135            for child in children {
136                inspect_setting(child, issues);
137            }
138        }
139        SettingsNode::Raw { name, span, .. } => issues.push(SemanticIssue {
140            kind: IncompletenessKind::RawSetting,
141            name: name.clone(),
142            span: *span,
143            classification: ResidualClassification::ProjectDefinedConstruct,
144        }),
145        SettingsNode::List { name, elements, .. } => {
146            let element_known: fn(&crate::settings::SettingsListElement) -> bool =
147                match name.as_str() {
148                    "enabledMaps" | "disabledMaps" => {
149                        |element: &crate::settings::SettingsListElement| {
150                            table::map_name(&element.value).is_some()
151                        }
152                    }
153                    "enabledHeroes" | "disabledHeroes" => {
154                        |element: &crate::settings::SettingsListElement| {
155                            table::hero_name(&element.value).is_some()
156                        }
157                    }
158                    _ => |_| true,
159                };
160            for element in elements {
161                if !element_known(element) {
162                    issues.push(SemanticIssue {
163                        kind: IncompletenessKind::RawSetting,
164                        name: element.value.clone(),
165                        span: element.span,
166                        classification: ResidualClassification::ProjectDefinedConstruct,
167                    });
168                }
169            }
170        }
171        SettingsNode::Number { .. }
172        | SettingsNode::Bool { .. }
173        | SettingsNode::Flag { .. }
174        | SettingsNode::String { .. } => {}
175    }
176}
177
178fn inspect_action(
179    action: &crate::Action,
180    program: &crate::Program,
181    rule: usize,
182    position: usize,
183    catalog: &Catalog,
184    issues: &mut Vec<SemanticIssue>,
185) {
186    match action {
187        crate::Action::Call { name, .. } => {
188            let kind = if name == "rawWorkshopAction" {
189                Some(IncompletenessKind::OpaqueAction)
190            } else if catalog.entry(Kind::Action, name).is_none() {
191                Some(IncompletenessKind::UnknownAction)
192            } else {
193                None
194            };
195            if let Some(kind) = kind {
196                let classification = if kind == IncompletenessKind::OpaqueAction {
197                    ResidualClassification::LegacyOpaque
198                } else {
199                    ResidualClassification::ProducerExtension
200                };
201                issues.push(SemanticIssue {
202                    kind,
203                    name: name.clone(),
204                    span: program.action_span(rule, position),
205                    classification,
206                });
207            }
208        }
209        crate::Action::Disabled { action } => {
210            inspect_action(action, program, rule, position, catalog, issues);
211        }
212        _ => {}
213    }
214}
215
216/// Visit every action value argument in the order internal materialization
217/// pushes value nodes: an `If` header precedes its body, while `While`,
218/// `For`, and `Else If` headers follow their bodies. Control-flow terminators
219/// stop a body scan without being consumed; a stray terminator at stream top
220/// level is visited like any other action, so malformed rules still expose
221/// every observable value. `stop_at_terminator` distinguishes the two scans.
222fn inspect_action_values(
223    actions: &[crate::Action],
224    position: &mut usize,
225    stop_at_terminator: bool,
226    rule: usize,
227    program: &crate::Program,
228    catalog: &Catalog,
229    issues: &mut Vec<SemanticIssue>,
230) {
231    while *position < actions.len() {
232        let index = *position;
233        let current = match &actions[index] {
234            crate::Action::Disabled { action } => action.as_ref(),
235            action => action,
236        };
237        match current {
238            crate::Action::ElseIf { .. } | crate::Action::Else | crate::Action::End
239                if stop_at_terminator =>
240            {
241                return;
242            }
243            crate::Action::If { .. } => {
244                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
245                *position += 1;
246                inspect_action_values(actions, position, true, rule, program, catalog, issues);
247                while matches!(actions.get(*position), Some(crate::Action::ElseIf { .. })) {
248                    let elseif = *position;
249                    *position += 1;
250                    inspect_action_values(actions, position, true, rule, program, catalog, issues);
251                    inspect_action_arguments(
252                        rule,
253                        elseif,
254                        &actions[elseif],
255                        program,
256                        catalog,
257                        issues,
258                    );
259                }
260                if matches!(actions.get(*position), Some(crate::Action::Else)) {
261                    *position += 1;
262                    inspect_action_values(actions, position, true, rule, program, catalog, issues);
263                }
264                if matches!(actions.get(*position), Some(crate::Action::End)) {
265                    *position += 1;
266                }
267            }
268            crate::Action::While { .. }
269            | crate::Action::ForGlobalVariable { .. }
270            | crate::Action::ForPlayerVariable { .. } => {
271                *position += 1;
272                inspect_action_values(actions, position, true, rule, program, catalog, issues);
273                if matches!(actions.get(*position), Some(crate::Action::End)) {
274                    *position += 1;
275                }
276                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
277            }
278            _ => {
279                inspect_action_arguments(rule, index, &actions[index], program, catalog, issues);
280                *position += 1;
281            }
282        }
283    }
284}
285
286/// Inspect the direct value arguments of one public action in their mapped
287/// order.
288fn inspect_action_arguments(
289    rule: usize,
290    action: usize,
291    action_data: &crate::Action,
292    program: &crate::Program,
293    catalog: &Catalog,
294    issues: &mut Vec<SemanticIssue>,
295) {
296    for (argument, value) in action_argument_values(action_data).iter().enumerate() {
297        inspect_value_tree(
298            value,
299            &mut Vec::new(),
300            &|path| program.action_argument_value_node_span(rule, action, argument, path),
301            program,
302            catalog,
303            issues,
304        );
305    }
306}
307
308/// Walk a public value tree in post-order — children before their node, the
309/// order value nodes take when materialized to the internal arena — flagging
310/// every `Value::Call` identity the catalog does not resolve.
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        // These names are canonical helpers rather than Workshop
326        // builtins: memberAccess preserves dynamic receiver properties, and
327        // infix operators are lowered to their source spelling for emission.
328        let canonical_helper = name == crate::wir::AMBIGUOUS_ENUM_CALL
329            || crate::wir::is_canonical_helper_call(name, args.len());
330        if canonical_helper {
331            return;
332        }
333        if catalog.entry(Kind::Value, name).is_none()
334            && catalog.entry(Kind::Operator, name).is_none()
335        {
336            issues.push(SemanticIssue {
337                kind: IncompletenessKind::UnknownValue,
338                name: name.clone(),
339                span: span_at(path),
340                classification: if program
341                    .global_variables
342                    .iter()
343                    .any(|variable| variable.name == *name)
344                    || program
345                        .player_variables
346                        .iter()
347                        .any(|variable| variable.name == *name)
348                {
349                    ResidualClassification::SourceDeclaredVariable
350                } else {
351                    ResidualClassification::UnresolvedIdentifier
352                },
353            });
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::settings::{Settings, SettingsNode};
362    use crate::{Action, Condition, Event, Program, Rule, Value};
363
364    #[test]
365    fn reports_preserved_and_unknown_nodes() {
366        let catalog = Catalog::builtin().expect("builtin catalog");
367        let mut program = Program::new();
368        program.settings = Some(Settings {
369            span: None,
370            children: vec![SettingsNode::Raw {
371                name: "Future Setting".to_string(),
372                value: "opaque".to_string(),
373                span: None,
374            }],
375        });
376        program.rule(
377            Rule::new("residuals", Event::Global)
378                .condition(Condition::new(Value::call("futureValue", [])))
379                .action(Action::call("rawWorkshopAction", []))
380                .action(Action::call("futureAction", [])),
381        );
382
383        let issues = inspect(&program, &catalog);
384        assert!(
385            issues
386                .iter()
387                .any(|issue| issue.kind == IncompletenessKind::RawSetting)
388        );
389        assert!(
390            issues
391                .iter()
392                .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
393        );
394        assert!(
395            issues
396                .iter()
397                .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
398        );
399        assert!(
400            issues
401                .iter()
402                .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
403        );
404        assert!(issues.iter().any(|issue| {
405            issue.kind == IncompletenessKind::RawSetting
406                && issue.classification == ResidualClassification::ProjectDefinedConstruct
407        }));
408        assert!(issues.iter().any(|issue| {
409            issue.kind == IncompletenessKind::OpaqueAction
410                && issue.classification == ResidualClassification::LegacyOpaque
411        }));
412        assert!(issues.iter().any(|issue| {
413            issue.kind == IncompletenessKind::UnknownValue
414                && issue.classification == ResidualClassification::UnresolvedIdentifier
415        }));
416    }
417}