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//! The checks mirror [`super::emitter`]'s member handling and must stay in
10//! sync with it: an empty result means `emit` will not reject a settings
11//! member for emission-table acceptance. Locale and hero display-name
12//! resolution can still fail inside emission and are not checked here.
13
14use crate::core::error::WorkshopError;
15use crate::source::Span;
16
17use super::table::{self, KeyKind};
18use super::{PathPart, Settings, SettingsNode, suggest};
19
20/// One rejected settings member: the [`WorkshopError`] a caller would see,
21/// plus the canonical spelling it was close to when exactly one candidate
22/// qualifies. The error's message already names the suggestion; the field
23/// lets callers apply or render it without parsing text.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct SettingsDiagnostic {
27    /// The rejection, with its source span and any suggestion in the message.
28    pub error: WorkshopError,
29    /// The single canonical spelling the rejected input was close to (a case
30    /// or accent difference, or a small edit distance). `None` when no
31    /// candidate is close or several are. The spelling is a Workshop display
32    /// name (`enabled maps`, `Château Guillard`) — the form source text
33    /// should carry — not a canonical table key.
34    pub suggestion: Option<String>,
35}
36
37/// Check that emission accepts `settings`, returning the structured
38/// diagnostics a caller can apply: one per offending member, each carrying
39/// the node's recorded source span when present.
40pub fn check_emission_diagnostics(settings: &Settings) -> Vec<SettingsDiagnostic> {
41    let mut diagnostics = Vec::new();
42    for child in &settings.children {
43        match child {
44            SettingsNode::Workshop { children, .. } => {
45                check_workshop_children(children, &mut diagnostics)
46            }
47            SettingsNode::Group { name, children, .. } => match name.as_str() {
48                "main" | "lobby" => {
49                    for member in children {
50                        check_member(member, &[PathPart::Part(name)], &mut diagnostics);
51                    }
52                }
53                "gamemodes" => check_modes(children, &mut diagnostics),
54                "heroes" => check_heroes(children, &mut diagnostics),
55                "extensions" => {
56                    for member in children {
57                        check_member(member, &[PathPart::Part("extensions")], &mut diagnostics);
58                    }
59                }
60                _ => check_opaque_children(children, &mut diagnostics),
61            },
62            other => diagnostics.push(rejected(
63                other.span(),
64                "settings block children must be groups".to_string(),
65                None,
66            )),
67        }
68    }
69    diagnostics
70}
71
72/// Check that emission accepts `settings`, returning one error per offending
73/// member. Each error carries the node's recorded source span when present.
74pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
75    check_emission_diagnostics(settings)
76        .into_iter()
77        .map(|diagnostic| diagnostic.error)
78        .collect()
79}
80
81fn rejected(span: Option<Span>, message: String, suggestion: Option<String>) -> SettingsDiagnostic {
82    let message = suggest::with_suggestion_text(message, suggestion.as_deref());
83    SettingsDiagnostic {
84        error: WorkshopError::malformed(message, span),
85        suggestion,
86    }
87}
88
89fn check_workshop_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
90    for child in children {
91        match child {
92            SettingsNode::Group { children, .. } | SettingsNode::Workshop { children, .. } => {
93                check_workshop_children(children, errors);
94            }
95            SettingsNode::Raw { .. } => {}
96            other => errors.push(rejected(
97                other.span(),
98                "settings.workshop contains a typed builtin setting".to_string(),
99                None,
100            )),
101        }
102    }
103}
104
105fn check_modes(modes: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
106    for mode in modes {
107        let SettingsNode::Group { name, children, .. } = mode else {
108            errors.push(rejected(
109                mode.span(),
110                "mode entries must be groups".to_string(),
111                None,
112            ));
113            continue;
114        };
115        for member in children {
116            // `enabled` bools are consumed by the mode header and never reach
117            // the emission table.
118            if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
119                continue;
120            }
121            check_member(
122                member,
123                &[PathPart::Part("gamemodes"), PathPart::Part(name)],
124                errors,
125            );
126        }
127    }
128}
129
130fn check_heroes(teams: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
131    for team in teams {
132        let SettingsNode::Group { name, children, .. } = team else {
133            errors.push(rejected(
134                team.span(),
135                "team entries must be groups".to_string(),
136                None,
137            ));
138            continue;
139        };
140        if table::team_name(name).is_none() {
141            errors.push(rejected(
142                team.span(),
143                format!("unknown team '{name}'"),
144                suggest::suggest(name, table::team_spellings()),
145            ));
146            continue;
147        }
148        for member in children {
149            match member {
150                SettingsNode::Group { name, children, .. } => {
151                    if table::hero_name(name).is_none() {
152                        errors.push(rejected(
153                            member.span(),
154                            format!("unknown hero '{name}'"),
155                            suggest::suggest(name, table::hero_spellings()),
156                        ));
157                        continue;
158                    }
159                    for inner in children {
160                        check_member(
161                            inner,
162                            &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
163                            errors,
164                        );
165                    }
166                }
167                other => check_member(other, &[PathPart::Part("heroes"), PathPart::Team], errors),
168            }
169        }
170    }
171}
172
173fn check_opaque_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
174    for child in children {
175        match child {
176            // Mirrors `emit_opaque_group`: nested groups pass through
177            // verbatim; only leaf members are table-checked.
178            SettingsNode::Group { children, .. } => check_opaque_children(children, errors),
179            other => check_member(other, &[], errors),
180        }
181    }
182}
183
184fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
185    if matches!(node, SettingsNode::Raw { .. }) {
186        return;
187    }
188    let name = node.name();
189    let mut full = path.to_vec();
190    full.push(PathPart::Part(name));
191    let Some(entry) = table::lookup(&full) else {
192        errors.push(rejected(
193            node.span(),
194            format!(
195                "settings key '{}' is outside the emission table",
196                table::path_string(&full)
197            ),
198            suggest::suggest(name, table::key_spellings(path).into_iter()),
199        ));
200        return;
201    };
202    match (node, &entry.kind) {
203        (SettingsNode::Flag { .. }, KeyKind::Flag)
204        | (SettingsNode::String { .. }, KeyKind::String)
205        | (SettingsNode::Number { .. }, KeyKind::Number | KeyKind::Percent)
206        | (SettingsNode::Bool { .. }, KeyKind::Bool | KeyKind::YesNo) => {}
207        (SettingsNode::String { value, .. }, KeyKind::Enum(domain)) => {
208            if table::enum_name(domain, value).is_none() {
209                errors.push(rejected(
210                    node.span(),
211                    format!("unknown value '{value}' for settings key '{name}'"),
212                    suggest::suggest(value, table::enum_spellings(domain)),
213                ));
214            }
215        }
216        (SettingsNode::Bool { value, .. }, KeyKind::BoolEnum(domain)) => {
217            if !*value {
218                errors.push(rejected(
219                    node.span(),
220                    format!("unsupported false value for settings key '{name}'"),
221                    None,
222                ));
223            } else if table::enum_name(domain, "enabled").is_none() {
224                errors.push(rejected(
225                    node.span(),
226                    format!("unknown value 'enabled' for settings key '{name}'"),
227                    None,
228                ));
229            }
230        }
231        (SettingsNode::List { elements, .. }, KeyKind::ListMap) => {
232            for element in elements {
233                if table::map_name(&element.value).is_none() {
234                    errors.push(rejected(
235                        element.span,
236                        format!("unknown map '{}' in settings list '{name}'", element.value),
237                        suggest::suggest(&element.value, table::map_spellings()),
238                    ));
239                }
240            }
241        }
242        (SettingsNode::List { elements, .. }, KeyKind::ListHero) => {
243            for element in elements {
244                if table::hero_name(&element.value).is_none() {
245                    errors.push(rejected(
246                        element.span,
247                        format!("unknown hero '{}' in settings list '{name}'", element.value),
248                        suggest::suggest(&element.value, table::hero_spellings()),
249                    ));
250                }
251            }
252        }
253        _ => errors.push(rejected(
254            node.span(),
255            format!("settings key '{name}' does not match its table kind"),
256            None,
257        )),
258    }
259}