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    /// A carried settings member whose spelling is close to exactly one
33    /// declared key or enum member — a likely misspelling, not a new
34    /// project-defined construct.
35    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    /// The canonical spelling `name` was close to, when exactly one was.
82    pub suggestion: Option<String>,
83}
84
85/// Report preserved or catalog-unknown constructs that must not be treated as
86/// fully understood by downstream analysis.
87///
88/// Residual inspection is defined over the public canonical
89/// [`crate::Program`] model and is independent from structural validation: it
90/// may be used on `Program` values that [`Program::validate`] rejects, and an
91/// internal WIR materialization failure never projects to an empty inventory.
92/// Callers that also require structural validity call
93/// [`Program::validate`](crate::Program::validate) separately; inspection
94/// does not report validation errors.
95///
96/// [`Program::validate`]: crate::Program::validate
97pub 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                // A member close to exactly one declared spelling is a
106                // likely misspelling of it, not a project-defined construct.
107                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
239/// Visit every action value argument in the order internal materialization
240/// pushes value nodes: an `If` header precedes its body, while `While`,
241/// `For`, and `Else If` headers follow their bodies. Control-flow terminators
242/// stop a body scan without being consumed; a stray terminator at stream top
243/// level is visited like any other action, so malformed rules still expose
244/// every observable value. `stop_at_terminator` distinguishes the two scans.
245fn 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
309/// Inspect the direct value arguments of one public action in their mapped
310/// order.
311fn 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
331/// Walk a public value tree in post-order — children before their node, the
332/// order value nodes take when materialized to the internal arena — flagging
333/// every `Value::Call` identity the catalog does not resolve.
334fn 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        // These names are canonical helpers rather than Workshop
349        // builtins: memberAccess preserves dynamic receiver properties, and
350        // infix operators are lowered to their source spelling for emission.
351        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}