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)]
63#[non_exhaustive]
64pub struct SemanticIssue {
65    pub kind: IncompletenessKind,
66    pub name: String,
67    pub span: Option<Span>,
68    pub classification: ResidualClassification,
69}
70
71/// Report preserved or catalog-unknown constructs that must not be treated as
72/// fully understood by downstream analysis.
73pub fn inspect(program: &crate::Program, catalog: &Catalog) -> Vec<SemanticIssue> {
74    let Ok(storage) = program.to_wir() else {
75        return Vec::new();
76    };
77    inspect_wir(&storage, catalog)
78}
79
80#[doc(hidden)]
81pub(crate) fn inspect_wir(program: &Program, catalog: &Catalog) -> Vec<SemanticIssue> {
82    let mut issues = Vec::new();
83    if let Some(settings) = &program.settings {
84        for node in &settings.children {
85            inspect_setting(node, &mut issues);
86        }
87    }
88    for action in program.actions.iter() {
89        inspect_action(action, program, catalog, &mut issues);
90    }
91    for value in program.values.iter() {
92        inspect_value(value, program, catalog, &mut issues);
93    }
94    issues
95}
96
97fn inspect_setting(node: &SettingsNode, issues: &mut Vec<SemanticIssue>) {
98    match node {
99        SettingsNode::Workshop { .. } => {}
100        SettingsNode::Group { children, .. } => {
101            for child in children {
102                inspect_setting(child, issues);
103            }
104        }
105        SettingsNode::Raw { name, span, .. } => issues.push(SemanticIssue {
106            kind: IncompletenessKind::RawSetting,
107            name: name.clone(),
108            span: *span,
109            classification: ResidualClassification::ProjectDefinedConstruct,
110        }),
111        SettingsNode::List {
112            name,
113            elements,
114            span,
115        } => {
116            let known = match name.as_str() {
117                "enabledMaps" | "disabledMaps" => elements
118                    .iter()
119                    .all(|element| table::map_name(&element.value).is_some()),
120                "enabledHeroes" | "disabledHeroes" => elements
121                    .iter()
122                    .all(|element| table::hero_name(&element.value).is_some()),
123                _ => true,
124            };
125            if !known {
126                issues.push(SemanticIssue {
127                    kind: IncompletenessKind::RawSetting,
128                    name: name.clone(),
129                    span: *span,
130                    classification: ResidualClassification::ProjectDefinedConstruct,
131                });
132            }
133        }
134        SettingsNode::Number { .. }
135        | SettingsNode::Bool { .. }
136        | SettingsNode::Flag { .. }
137        | SettingsNode::String { .. } => {}
138    }
139}
140
141fn inspect_action(
142    action: &Action,
143    program: &Program,
144    catalog: &Catalog,
145    issues: &mut Vec<SemanticIssue>,
146) {
147    match action {
148        Action::Call { name, span, .. } => {
149            let kind = if name == "rawWorkshopAction" {
150                Some(IncompletenessKind::OpaqueAction)
151            } else if catalog.entry(Kind::Action, name).is_none() {
152                Some(IncompletenessKind::UnknownAction)
153            } else {
154                None
155            };
156            if let Some(kind) = kind {
157                let classification = if kind == IncompletenessKind::OpaqueAction {
158                    ResidualClassification::LegacyOpaque
159                } else {
160                    ResidualClassification::ProducerExtension
161                };
162                issues.push(SemanticIssue {
163                    kind,
164                    name: name.clone(),
165                    span: *span,
166                    classification,
167                });
168            }
169        }
170        Action::If {
171            branches,
172            else_body,
173            ..
174        } => {
175            for branch in branches {
176                inspect_action_id(branch.body.as_slice(), program, catalog, issues);
177            }
178            if let Some(body) = else_body {
179                inspect_action_id(body.as_slice(), program, catalog, issues);
180            }
181        }
182        Action::While { body, .. }
183        | Action::ForGlobalVariable { body, .. }
184        | Action::ForPlayerVariable { body, .. } => {
185            inspect_action_id(body.as_slice(), program, catalog, issues);
186        }
187        Action::Disabled { action, .. } => {
188            inspect_action_id(std::slice::from_ref(action), program, catalog, issues);
189        }
190        Action::SetGlobalVariable { .. }
191        | Action::ModifyGlobalVariable { .. }
192        | Action::SetPlayerVariable { .. }
193        | Action::ModifyPlayerVariable { .. }
194        | Action::AssignMember { .. }
195        | Action::CallSubroutine { .. } => {}
196    }
197}
198
199fn inspect_action_id(
200    ids: &[crate::wir::ActionId],
201    program: &Program,
202    catalog: &Catalog,
203    issues: &mut Vec<SemanticIssue>,
204) {
205    for id in ids {
206        if let Some(action) = program.actions.get(*id) {
207            inspect_action(action, program, catalog, issues);
208        }
209    }
210}
211
212fn inspect_value(
213    node: &crate::wir::ValueNode,
214    program: &Program,
215    catalog: &Catalog,
216    issues: &mut Vec<SemanticIssue>,
217) {
218    if let Value::Call { name, args } = &node.value {
219        // These names are canonical WIR helpers rather than Workshop
220        // builtins: memberAccess preserves dynamic receiver properties, and
221        // infix operators are lowered to their source spelling for emission.
222        let canonical_helper = matches!(
223            name.as_str(),
224            crate::wir::AMBIGUOUS_ENUM_CALL
225                | "memberAccess"
226                | "+"
227                | "-"
228                | "*"
229                | "/"
230                | "%"
231                | "add"
232                | "subtract"
233                | "multiply"
234                | "divide"
235                | "modulo"
236                | "min"
237                | "max"
238                | "raiseToPower"
239                | "appendToArray"
240                | "removeFromArray"
241                | "removeFromArrayByValue"
242                | "removeFromArrayByIndex"
243        ) && (args.is_empty()
244            || matches!(
245                name.as_str(),
246                crate::wir::AMBIGUOUS_ENUM_CALL | "memberAccess" | "+" | "-" | "*" | "/" | "%"
247            ));
248        if canonical_helper {
249            return;
250        }
251        if catalog.entry(Kind::Value, name).is_none()
252            && catalog.entry(Kind::Operator, name).is_none()
253        {
254            issues.push(SemanticIssue {
255                kind: IncompletenessKind::UnknownValue,
256                name: name.clone(),
257                span: node.span,
258                classification: if program
259                    .global_variables
260                    .iter()
261                    .any(|variable| variable.name == *name)
262                    || program
263                        .player_variables
264                        .iter()
265                        .any(|variable| variable.name == *name)
266                {
267                    ResidualClassification::SourceDeclaredVariable
268                } else {
269                    ResidualClassification::UnresolvedIdentifier
270                },
271            });
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::settings::{Settings, SettingsNode};
280
281    #[test]
282    fn reports_preserved_and_unknown_nodes() {
283        let catalog = Catalog::builtin().expect("builtin catalog");
284        let mut program = Program {
285            settings: Some(Settings {
286                span: None,
287                children: vec![SettingsNode::Raw {
288                    name: "Future Setting".to_string(),
289                    value: "opaque".to_string(),
290                    span: None,
291                }],
292            }),
293            ..Program::default()
294        };
295        program.actions.push(Action::Call {
296            name: "rawWorkshopAction".to_string(),
297            args: Vec::new(),
298            span: None,
299        });
300        program.actions.push(Action::Call {
301            name: "futureAction".to_string(),
302            args: Vec::new(),
303            span: None,
304        });
305        program.values.push(crate::wir::ValueNode::new(
306            Value::Call {
307                name: "futureValue".to_string(),
308                args: Vec::new(),
309            },
310            None,
311        ));
312
313        let issues = inspect_wir(&program, &catalog);
314        assert!(
315            issues
316                .iter()
317                .any(|issue| issue.kind == IncompletenessKind::RawSetting)
318        );
319        assert!(
320            issues
321                .iter()
322                .any(|issue| issue.kind == IncompletenessKind::OpaqueAction)
323        );
324        assert!(
325            issues
326                .iter()
327                .any(|issue| issue.kind == IncompletenessKind::UnknownAction)
328        );
329        assert!(
330            issues
331                .iter()
332                .any(|issue| issue.kind == IncompletenessKind::UnknownValue)
333        );
334        assert!(issues.iter().any(|issue| {
335            issue.kind == IncompletenessKind::RawSetting
336                && issue.classification == ResidualClassification::ProjectDefinedConstruct
337        }));
338        assert!(issues.iter().any(|issue| {
339            issue.kind == IncompletenessKind::OpaqueAction
340                && issue.classification == ResidualClassification::LegacyOpaque
341        }));
342        assert!(issues.iter().any(|issue| {
343            issue.kind == IncompletenessKind::UnknownValue
344                && issue.classification == ResidualClassification::UnresolvedIdentifier
345        }));
346    }
347}