Skip to main content

workshop_rs/settings/
check.rs

1//! Locale-independent emission acceptance checks for settings trees.
2//!
3//! [`crate::Program::validate`] runs [`check_emission`] on every program, so
4//! checking raw Workshop input reports the same settings errors emission
5//! would. The raw parser leaves unresolvable leaf members as verbatim
6//! [`SettingsNode::Raw`] payloads; [`check_emission`] reports every other
7//! member the emitter would reject, without producing Workshop text.
8//!
9//! Member acceptance is shared with emission. Locale and hero display-name
10//! resolution can still fail inside emission and are not checked here.
11
12use crate::core::error::WorkshopError;
13use crate::core::suggest;
14
15use super::member::{self, Member, rejected};
16use super::table;
17use super::{PathPart, Settings, SettingsNode};
18
19/// One rejected settings member: the [`WorkshopError`] a caller would see,
20/// plus the canonical spelling it was close to when exactly one candidate
21/// qualifies. The error's message already names the suggestion; the field
22/// lets callers apply or render it without parsing text.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct SettingsDiagnostic {
26    /// The rejection, with its source span and any suggestion in the message.
27    pub error: WorkshopError,
28    /// The single canonical spelling the rejected input was close to (a case
29    /// or accent difference, or a small edit distance). `None` when no
30    /// candidate is close or several are. The spelling is a Workshop display
31    /// name (`enabled maps`, `Château Guillard`) — the form source text
32    /// should carry — not a canonical table key.
33    pub suggestion: Option<String>,
34}
35
36/// Check that emission accepts `settings`, returning the structured
37/// diagnostics a caller can apply: one per offending member, each carrying
38/// the node's recorded source span when present.
39pub fn check_emission_diagnostics(settings: &Settings) -> Vec<SettingsDiagnostic> {
40    let mut diagnostics = Vec::new();
41    for child in &settings.children {
42        match child {
43            SettingsNode::Workshop { children, .. } => {
44                check_workshop_children(children, &mut diagnostics)
45            }
46            SettingsNode::Group { name, children, .. } => match name.as_str() {
47                "main" | "lobby" => {
48                    for member in children {
49                        check_member(member, &[PathPart::Part(name)], &mut diagnostics);
50                    }
51                }
52                "gamemodes" => check_modes(children, &mut diagnostics),
53                "heroes" => check_heroes(children, &mut diagnostics),
54                "extensions" => {
55                    for member in children {
56                        check_member(member, &[PathPart::Part("extensions")], &mut diagnostics);
57                    }
58                }
59                _ => check_opaque_children(children, &mut diagnostics),
60            },
61            other => diagnostics.push(rejected(
62                other.span(),
63                "settings block children must be groups".to_string(),
64                None,
65            )),
66        }
67    }
68    diagnostics
69}
70
71/// Check that emission accepts `settings`, returning one error per offending
72/// member. Each error carries the node's recorded source span when present.
73pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
74    check_emission_diagnostics(settings)
75        .into_iter()
76        .map(|diagnostic| diagnostic.error)
77        .collect()
78}
79
80fn check_workshop_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
81    for child in children {
82        match child {
83            SettingsNode::Group { children, .. } | SettingsNode::Workshop { children, .. } => {
84                check_workshop_children(children, errors);
85            }
86            SettingsNode::Raw { .. } => {}
87            other => errors.push(rejected(
88                other.span(),
89                "settings.workshop contains a typed builtin setting".to_string(),
90                None,
91            )),
92        }
93    }
94}
95
96fn check_modes(modes: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
97    for mode in modes {
98        let SettingsNode::Group { name, children, .. } = mode else {
99            errors.push(rejected(
100                mode.span(),
101                "mode entries must be groups".to_string(),
102                None,
103            ));
104            continue;
105        };
106        for member in children {
107            // `enabled` bools are consumed by the mode header and never reach
108            // the emission table.
109            if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
110                continue;
111            }
112            check_member(
113                member,
114                &[PathPart::Part("gamemodes"), PathPart::Part(name)],
115                errors,
116            );
117        }
118    }
119}
120
121fn check_heroes(teams: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
122    for team in teams {
123        let SettingsNode::Group { name, children, .. } = team else {
124            errors.push(rejected(
125                team.span(),
126                "team entries must be groups".to_string(),
127                None,
128            ));
129            continue;
130        };
131        if table::team_name(name).is_none() {
132            errors.push(rejected(
133                team.span(),
134                format!("unknown team '{name}'"),
135                suggest::suggest(name, table::team_spellings()),
136            ));
137            continue;
138        }
139        for member in children {
140            match member {
141                SettingsNode::Group { name, children, .. } => {
142                    if table::hero_name(name).is_none() {
143                        errors.push(rejected(
144                            member.span(),
145                            format!("unknown hero '{name}'"),
146                            suggest::suggest(name, table::hero_spellings()),
147                        ));
148                        continue;
149                    }
150                    for inner in children {
151                        check_member(
152                            inner,
153                            &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
154                            errors,
155                        );
156                    }
157                }
158                other => check_member(other, &[PathPart::Part("heroes"), PathPart::Team], errors),
159            }
160        }
161    }
162}
163
164fn check_opaque_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
165    for child in children {
166        match child {
167            // Mirrors `emit_opaque_group`: nested groups pass through
168            // verbatim; only leaf members are table-checked.
169            SettingsNode::Group { children, .. } => check_opaque_children(children, errors),
170            other => check_member(other, &[], errors),
171        }
172    }
173}
174
175fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
176    if matches!(node, SettingsNode::Raw { .. }) {
177        return;
178    }
179    let name = node.name();
180    let mut full = path.to_vec();
181    full.push(PathPart::Part(name));
182    match member::lookup(node, &full).and_then(|entry| member::accept(node, entry)) {
183        Ok(Member::List { elements, kind }) => {
184            for element in elements {
185                if let Err(diagnostic) = kind.resolve(element, name) {
186                    errors.push(*diagnostic);
187                }
188            }
189        }
190        Ok(_) => {}
191        Err(diagnostic) => errors.push(*diagnostic),
192    }
193}