Skip to main content

workshop_rs/analysis/
semantic.rs

1//! Semantic-completeness inspection for permissive raw Workshop parsing.
2//!
3//! Structural WIR validation deliberately remains separate from this report:
4//! a preserved node can be structurally valid while still being unsuitable
5//! for definitive analysis.
6
7use crate::catalog::{Catalog, Kind};
8use crate::core::source::Span;
9use crate::settings::SettingsNode;
10use crate::settings::table;
11use crate::wir::{Action, Program, Value};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum IncompletenessKind {
15    RawSetting,
16    UnknownAction,
17    UnknownValue,
18    OpaqueAction,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ResidualClassification {
23    ProjectDefinedConstruct,
24    SourceDeclaredVariable,
25    ProducerExtension,
26    LegacyOpaque,
27    UnresolvedIdentifier,
28}
29
30impl ResidualClassification {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::ProjectDefinedConstruct => "project-defined-construct",
34            Self::SourceDeclaredVariable => "source-declared-variable",
35            Self::ProducerExtension => "producer-extension",
36            Self::LegacyOpaque => "legacy-opaque-construct",
37            Self::UnresolvedIdentifier => "truly-unresolved-identifier",
38        }
39    }
40
41    pub fn evidence(self) -> &'static str {
42        match self {
43            Self::ProjectDefinedConstruct => {
44                "source settings or construct was preserved without a canonical catalog identity"
45            }
46            Self::SourceDeclaredVariable => {
47                "the identifier matches a variable declaration in the parsed source program"
48            }
49            Self::ProducerExtension => {
50                "the source uses an action-shaped identity outside the canonical catalog and no declaration resolves it"
51            }
52            Self::LegacyOpaque => {
53                "the parser preserved a legacy raw construct without a canonical contract"
54            }
55            Self::UnresolvedIdentifier => {
56                "the identifier matches neither a source declaration nor a canonical catalog identity"
57            }
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SemanticIssue {
64    pub kind: IncompletenessKind,
65    pub name: String,
66    pub span: Option<Span>,
67    pub classification: ResidualClassification,
68}
69
70/// Report preserved or catalog-unknown constructs that must not be treated as
71/// fully understood by downstream analysis.
72pub fn inspect(program: &crate::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
73    let Ok(storage) = program.to_wir() else {
74        return Vec::new();
75    };
76    inspect_wir(&storage, catalog)
77}
78
79#[doc(hidden)]
80pub(crate) fn inspect_wir(program: &Program, catalog: &Catalog) -> Vec<SemanticIssue> {
81    let mut issues = Vec::new();
82    if let Some(settings) = &program.settings {
83        for node in &settings.children {
84            inspect_setting(node, &mut issues);
85        }
86    }
87    for action in program.actions.iter() {
88        inspect_action(action, program, catalog, &mut issues);
89    }
90    for value in program.values.iter() {
91        inspect_value(value, program, catalog, &mut issues);
92    }
93    issues
94}
95
96fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
97    match node {
98        SettingsNode::Workshop { .. } => {}
99        SettingsNode::Group { children, .. } => {
100            for child in children {
101                inspect_setting(child, issues);
102            }
103        }
104        SettingsNode::Raw { name, span, .. } => issues.push(SemanticIssue {
105            kind: IncompletenessKind::RawSetting,
106            name: name.clone(),
107            span: *span,
108            classification: ResidualClassification::ProjectDefinedConstruct,
109        }),
110        SettingsNode::List {
111            name,
112            elements,
113            span,
114        } => {
115            let known = match name.as_str() {
116                "enabledMaps" | "disabledMaps" => elements
117                    .iter()
118                    .all(|element| table::map_name(&element.value).is_some()),
119                "enabledHeroes" | "disabledHeroes" => elements
120                    .iter()
121                    .all(|element| table::hero_name(&element.value).is_some()),
122                _ => true,
123            };
124            if !known {
125                issues.push(SemanticIssue {
126                    kind: IncompletenessKind::RawSetting,
127                    name: name.clone(),
128                    span: *span,
129                    classification: ResidualClassification::ProjectDefinedConstruct,
130                });
131            }
132        }
133        SettingsNode::Number { .. }
134        | SettingsNode::Bool { .. }
135        | SettingsNode::Flag { .. }
136        | SettingsNode::String { .. } => {}
137    }
138}
139
140fn inspect_action(
141    action: &Action,
142    program: &Program,
143    catalog: &Catalog,
144    issues: &mut Vec<SemanticIssue>,
145) {
146    match action {
147        Action::Call { name, span, .. } => {
148            let kind = if name == "rawWorkshopAction" {
149                Some(IncompletenessKind::OpaqueAction)
150            } else if catalog.entry(Kind::Action, name).is_none() {
151                Some(IncompletenessKind::UnknownAction)
152            } else {
153                None
154            };
155            if let Some(kind) = kind {
156                let classification = if kind == IncompletenessKind::OpaqueAction {
157                    ResidualClassification::LegacyOpaque
158                } else {
159                    ResidualClassification::ProducerExtension
160                };
161                issues.push(SemanticIssue {
162                    kind,
163                    name: name.clone(),
164                    span: *span,
165                    classification,
166                });
167            }
168        }
169        Action::If {
170            branches,
171            else_body,
172            ..
173        } => {
174            for branch in branches {
175                inspect_action_id(branch.body.as_slice(), program, catalog, issues);
176            }
177            if let Some(body) = else_body {
178                inspect_action_id(body.as_slice(), program, catalog, issues);
179            }
180        }
181        Action::While { body, .. }
182        | Action::ForGlobalVariable { body, .. }
183        | Action::ForPlayerVariable { body, .. } => {
184            inspect_action_id(body.as_slice(), program, catalog, issues);
185        }
186        Action::Disabled { action, .. } => {
187            inspect_action_id(std::slice::from_ref(action), program, catalog, issues);
188        }
189        Action::SetGlobalVariable { .. }
190        | Action::ModifyGlobalVariable { .. }
191        | Action::SetPlayerVariable { .. }
192        | Action::ModifyPlayerVariable { .. }
193        | Action::AssignMember { .. }
194        | Action::CallSubroutine { .. } => {}
195    }
196}
197
198fn inspect_action_id(
199    ids: &[crate::wir::ActionId],
200    program: &Program,
201    catalog: &Catalog,
202    issues: &mut Vec<SemanticIssue>,
203) {
204    for id in ids {
205        if let Some(action) = program.actions.get(*id) {
206            inspect_action(action, program, catalog, issues);
207        }
208    }
209}
210
211fn inspect_value(
212    node: &crate::wir::ValueNode,
213    program: &Program,
214    catalog: &Catalog,
215    issues: &mut Vec<SemanticIssue>,
216) {
217    if let Value::Call { name, args } = &node.value {
218        // These names are canonical WIR helpers rather than Workshop
219        // builtins: memberAccess preserves dynamic receiver properties, and
220        // infix operators are lowered to their source spelling for emission.
221        let canonical_helper = matches!(
222            name.as_str(),
223            crate::wir::AMBIGUOUS_ENUM_CALL
224                | "memberAccess"
225                | "+"
226                | "-"
227                | "*"
228                | "/"
229                | "%"
230                | "add"
231                | "subtract"
232                | "multiply"
233                | "divide"
234                | "modulo"
235                | "min"
236                | "max"
237                | "raiseToPower"
238                | "appendToArray"
239                | "removeFromArray"
240                | "removeFromArrayByValue"
241                | "removeFromArrayByIndex"
242        ) && (args.is_empty()
243            || matches!(
244                name.as_str(),
245                crate::wir::AMBIGUOUS_ENUM_CALL | "memberAccess" | "+" | "-" | "*" | "/" | "%"
246            ));
247        if canonical_helper {
248            return;
249        }
250        if catalog.entry(Kind::Value, name).is_none()
251            && catalog.entry(Kind::Operator, name).is_none()
252        {
253            issues.push(SemanticIssue {
254                kind: IncompletenessKind::UnknownValue,
255                name: name.clone(),
256                span: node.span,
257                classification: if program
258                    .global_variables
259                    .iter()
260                    .any(|variable| variable.name == *name)
261                    || program
262                        .player_variables
263                        .iter()
264                        .any(|variable| variable.name == *name)
265                {
266                    ResidualClassification::SourceDeclaredVariable
267                } else {
268                    ResidualClassification::UnresolvedIdentifier
269                },
270            });
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::settings::{Settings, SettingsNode};
279
280    #[test]
281    fn reports_preserved_and_unknown_nodes() {
282        let catalog = Catalog::builtin().expect("builtin catalog");
283        let mut program = Program {
284            settings: Some(Settings {
285                span: None,
286                children: vec![SettingsNode::Raw {
287                    name: "Future Setting".to_string(),
288                    value: "opaque".to_string(),
289                    span: None,
290                }],
291            }),
292            ..Program::default()
293        };
294        program.actions.push(Action::Call {
295            name: "rawWorkshopAction".to_string(),
296            args: Vec::new(),
297            span: None,
298        });
299        program.actions.push(Action::Call {
300            name: "futureAction".to_string(),
301            args: Vec::new(),
302            span: None,
303        });
304        program.values.push(crate::wir::ValueNode::new(
305            Value::Call {
306                name: "futureValue".to_string(),
307                args: Vec::new(),
308            },
309            None,
310        ));
311
312        let issues = inspect_wir(&program, &catalog);
313        assert!(
314            issues
315                .iter()
316                .any(|issue| issue.kind == IncompletenessKind::RawSetting)
317        );
318        assert!(
319            issues
320                .iter()
321                .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
322        );
323        assert!(
324            issues
325                .iter()
326                .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
327        );
328        assert!(
329            issues
330                .iter()
331                .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
332        );
333        assert!(issues.iter().any(|issue| {
334            issue.kind == IncompletenessKind::RawSetting
335                && issue.classification == ResidualClassification::ProjectDefinedConstruct
336        }));
337        assert!(issues.iter().any(|issue| {
338            issue.kind == IncompletenessKind::OpaqueAction
339                && issue.classification == ResidualClassification::LegacyOpaque
340        }));
341        assert!(issues.iter().any(|issue| {
342            issue.kind == IncompletenessKind::UnknownValue
343                && issue.classification == ResidualClassification::UnresolvedIdentifier
344        }));
345    }
346}