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. Members the catalog does not declare are carried as written
6//! ([`SettingsNode::Raw`], [`SettingsNode::RawValue`], or a block of them) and
7//! accepted; [`check_emission`] reports every other member the emitter would
8//! reject, without producing Workshop text.
9//!
10//! Member acceptance is shared with emission. Locale and hero display-name
11//! resolution can still fail inside emission and are not checked here.
12
13use crate::core::error::WorkshopError;
14use crate::core::suggest;
15
16use super::member::{self, Member, rejected};
17use super::table;
18use super::{PathPart, Settings, SettingsNode};
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    walk(settings, &mut |visit| match visit {
43        Visit::Rejected(diagnostic) => diagnostics.push(diagnostic),
44        Visit::Member(node, path) => check_member(node, path, &mut diagnostics),
45        Visit::OpaqueLeaf(node) => check_member(node, &[], &mut diagnostics),
46    });
47    diagnostics
48}
49
50/// Check that emission accepts `settings`, returning one error per offending
51/// member. Each error carries the node's recorded source span when present.
52pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
53    check_emission_diagnostics(settings)
54        .into_iter()
55        .map(|diagnostic| diagnostic.error)
56        .collect()
57}
58
59/// A settings member emitted as written rather than through the catalog.
60pub(crate) struct UncataloguedMember<'a> {
61    /// The uncatalogued key, or the undeclared value of a catalogued key.
62    pub(crate) name: &'a str,
63    pub(crate) span: Option<crate::source::Span>,
64    /// The canonical spelling the name was close to, if exactly one was.
65    pub(crate) suggestion: Option<String>,
66}
67
68/// The members emission writes as authored: uncatalogued members and blocks,
69/// and catalogued keys carrying an undeclared value. Members inside an
70/// uncatalogued top-level group are reported individually; a member block is
71/// reported as one member.
72pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec<UncataloguedMember<'_>> {
73    let mut members = Vec::new();
74    walk(settings, &mut |visit| match visit {
75        Visit::Rejected(_) => {}
76        Visit::Member(node, path) => {
77            let mut full = path.to_vec();
78            full.push(PathPart::Part(node.name()));
79            let catalogued = table::lookup(&full);
80            let member = match (node, catalogued) {
81                (SettingsNode::Raw { .. }, _) | (SettingsNode::Group { .. }, None) => {
82                    UncataloguedMember {
83                        name: node.name(),
84                        span: node.span(),
85                        suggestion: suggest::suggest(
86                            node.name(),
87                            table::key_spellings(path).into_iter(),
88                        ),
89                    }
90                }
91                (SettingsNode::RawValue { value, .. }, Some(entry)) => UncataloguedMember {
92                    name: value,
93                    span: node.span(),
94                    suggestion: match entry.kind {
95                        table::KeyKind::Enum(domain) => {
96                            suggest::suggest(value, table::enum_spellings(domain))
97                        }
98                        _ => None,
99                    },
100                },
101                (SettingsNode::RawValue { .. }, None) => UncataloguedMember {
102                    name: node.name(),
103                    span: node.span(),
104                    suggestion: suggest::suggest(
105                        node.name(),
106                        table::key_spellings(path).into_iter(),
107                    ),
108                },
109                _ => return,
110            };
111            members.push(member);
112        }
113        Visit::OpaqueLeaf(node @ (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. })) => {
114            members.push(UncataloguedMember {
115                name: node.name(),
116                span: node.span(),
117                suggestion: None,
118            });
119        }
120        Visit::OpaqueLeaf(_) => {}
121    });
122    members
123}
124
125enum Visit<'a, 'p> {
126    /// A structural problem emission rejects before reaching any member.
127    Rejected(SettingsDiagnostic),
128    /// A member at a catalogued position, with its parent path.
129    Member(&'a SettingsNode, &'p [PathPart<'a>]),
130    /// A leaf inside an uncatalogued top-level group.
131    OpaqueLeaf(&'a SettingsNode),
132}
133
134/// Visit every settings member the way emission reaches it.
135fn walk<'a>(settings: &'a Settings, visit: &mut dyn FnMut(Visit<'a, '_>)) {
136    for child in &settings.children {
137        match child {
138            SettingsNode::Workshop { children, .. } => walk_workshop(children, visit),
139            SettingsNode::Group { name, children, .. } => match name.as_str() {
140                "main" | "lobby" | "extensions" => {
141                    for member in children {
142                        visit(Visit::Member(member, &[PathPart::Part(name)]));
143                    }
144                }
145                "gamemodes" => walk_modes(children, visit),
146                "heroes" => walk_heroes(children, visit),
147                _ => walk_opaque(children, visit),
148            },
149            other => {
150                visit(Visit::Rejected(rejected(
151                    other.span(),
152                    "settings block children must be groups".to_string(),
153                    None,
154                )));
155                // A stray raw member is still a preserved residual.
156                if matches!(
157                    other,
158                    SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }
159                ) {
160                    visit(Visit::OpaqueLeaf(other));
161                }
162            }
163        }
164    }
165}
166
167fn walk_workshop<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
168    for child in children {
169        match child {
170            SettingsNode::Group { children, .. } | SettingsNode::Workshop { children, .. } => {
171                walk_workshop(children, visit);
172            }
173            SettingsNode::Raw { .. } | SettingsNode::RawValue { .. } => {}
174            other => visit(Visit::Rejected(rejected(
175                other.span(),
176                "settings.workshop contains a typed builtin setting".to_string(),
177                None,
178            ))),
179        }
180    }
181}
182
183fn walk_modes<'a>(modes: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
184    for mode in modes {
185        let SettingsNode::Group { name, children, .. } = mode else {
186            visit(Visit::Rejected(rejected(
187                mode.span(),
188                "mode entries must be groups".to_string(),
189                None,
190            )));
191            continue;
192        };
193        let path = [PathPart::Part("gamemodes"), PathPart::Part(name)];
194        for member in children {
195            // `enabled` bools are consumed by the mode header and never reach
196            // the emission table.
197            if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
198                continue;
199            }
200            visit(Visit::Member(member, &path));
201        }
202    }
203}
204
205fn walk_heroes<'a>(teams: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
206    const TEAM: [PathPart<'static>; 2] = [PathPart::Part("heroes"), PathPart::Team];
207    const HERO: [PathPart<'static>; 3] = [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero];
208    for team in teams {
209        let SettingsNode::Group { name, children, .. } = team else {
210            visit(Visit::Rejected(rejected(
211                team.span(),
212                "team entries must be groups".to_string(),
213                None,
214            )));
215            continue;
216        };
217        if table::team_name(name).is_none() {
218            visit(Visit::Rejected(rejected(
219                team.span(),
220                format!("unknown team '{name}'"),
221                suggest::suggest(name, table::team_spellings()),
222            )));
223            continue;
224        }
225        for member in children {
226            match member {
227                SettingsNode::Group { name, children, .. } => {
228                    if table::hero_name(name).is_none() {
229                        visit(Visit::Rejected(rejected(
230                            member.span(),
231                            format!("unknown hero '{name}'"),
232                            suggest::suggest(name, table::hero_spellings()),
233                        )));
234                        continue;
235                    }
236                    for inner in children {
237                        visit(Visit::Member(inner, &HERO));
238                    }
239                }
240                other => visit(Visit::Member(other, &TEAM)),
241            }
242        }
243    }
244}
245
246fn walk_opaque<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
247    for child in children {
248        match child {
249            // Mirrors `emit_opaque_group`: nested groups pass through
250            // verbatim; only leaf members are table-checked.
251            SettingsNode::Group { children, .. } => walk_opaque(children, visit),
252            other => visit(Visit::OpaqueLeaf(other)),
253        }
254    }
255}
256
257fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
258    let name = node.name();
259    let mut full = path.to_vec();
260    full.push(PathPart::Part(name));
261    match (node, table::lookup(&full)) {
262        (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }, _) => return,
263        // Mirrors `settings_member`: an uncatalogued block is emitted as
264        // written; only its leaves must have a written form.
265        (SettingsNode::Group { children, .. }, None) => {
266            for child in children {
267                check_member(child, &[], errors);
268            }
269            return;
270        }
271        _ => {}
272    }
273    match member::lookup(node, &full).and_then(|entry| member::accept(node, entry)) {
274        Ok(Member::List { elements, kind }) => {
275            for element in elements {
276                if let Err(diagnostic) = kind.resolve(element, name) {
277                    errors.push(*diagnostic);
278                }
279            }
280        }
281        Ok(_) => {}
282        Err(diagnostic) => errors.push(*diagnostic),
283    }
284}