Skip to main content

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