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
7//! [`SettingsNode::RawGroup`] for a block of them — under an unknown key or
8//! a catalogued non-list key) and accepted;
9//! [`check_emission`] reports every other member the emitter would
10//! reject, without producing Workshop text. A carried member close to
11//! exactly one declared spelling is additionally reported as a
12//! [`DiagnosticSeverity::Warning`] diagnostic — a likely misspelling — by
13//! [`check_emission_diagnostics`].
14//!
15//! Member acceptance is shared with emission. Locale and hero display-name
16//! resolution can still fail inside emission and are not checked here.
17
18use crate::core::error::WorkshopError;
19use crate::core::suggest;
20
21use super::member::{self, Member, rejected};
22use super::table;
23use super::{PathPart, Settings, SettingsNode};
24
25/// Whether a settings diagnostic rejects the member or only advises about
26/// it.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DiagnosticSeverity {
29    /// Emission rejects the member; [`crate::Program::validate`] fails.
30    Error,
31    /// The member is carried and emitted as written, but it is close to a
32    /// declared spelling and is likely a misspelling. Warnings never fail
33    /// validation.
34    Warning,
35}
36
37/// One reported settings member: the [`WorkshopError`] a caller would see,
38/// plus the canonical spelling it was close to when exactly one candidate
39/// qualifies. The error's message already names the suggestion; the field
40/// lets callers apply or render it without parsing text.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct SettingsDiagnostic {
44    /// Whether the diagnostic rejects the member or only advises about it.
45    pub severity: DiagnosticSeverity,
46    /// The rejection or advisory, with its source span and any suggestion in
47    /// the message. Warnings carry [`WorkshopError::Warning`].
48    pub error: WorkshopError,
49    /// The single canonical spelling the reported input was close to (a case
50    /// or accent difference, or a small edit distance). `None` when no
51    /// candidate is close or several are. The spelling is a Workshop display
52    /// name (`enabled maps`, `Château Guillard`) — the form source text
53    /// should carry — not a canonical table key.
54    pub suggestion: Option<String>,
55}
56
57/// Check that emission accepts `settings`, returning the structured
58/// diagnostics a caller can apply: one per offending member, each carrying
59/// the node's recorded source span when present.
60/// Check that emission accepts `settings`, returning the structured
61/// diagnostics a caller can apply: one per offending member (rejected, or a
62/// carried near-miss of a declared spelling), each carrying the node's
63/// recorded source span when present.
64pub fn check_emission_diagnostics(settings: &Settings) -> Vec<SettingsDiagnostic> {
65    let mut diagnostics = Vec::new();
66    walk(settings, &mut |visit| match visit {
67        Visit::Rejected(diagnostic) => diagnostics.push(diagnostic),
68        Visit::Member(node, path) => check_member(node, path, &mut diagnostics),
69        Visit::OpaqueLeaf(node) => check_member(node, &[], &mut diagnostics),
70    });
71    diagnostics
72}
73
74/// Check that emission accepts `settings`, returning one error per offending
75/// member. Warnings are excluded: they describe accepted members. Each error
76/// carries the node's recorded source span when present.
77pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
78    check_emission_diagnostics(settings)
79        .into_iter()
80        .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
81        .map(|diagnostic| diagnostic.error)
82        .collect()
83}
84
85/// A settings member emitted as written rather than through the catalog.
86pub(crate) struct UncataloguedMember<'a> {
87    /// The uncatalogued key, or the undeclared value of a catalogued key.
88    pub(crate) name: &'a str,
89    pub(crate) span: Option<crate::source::Span>,
90    /// The canonical spelling the name was close to, if exactly one was.
91    pub(crate) suggestion: Option<String>,
92}
93
94/// The members emission writes as authored: uncatalogued members and blocks,
95/// and catalogued keys carrying an undeclared value. Members inside an
96/// uncatalogued top-level group are reported individually; a member block is
97/// reported as one member.
98pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec<UncataloguedMember<'_>> {
99    let mut members = Vec::new();
100    walk(settings, &mut |visit| match visit {
101        Visit::Rejected(_) => {}
102        Visit::Member(node, path) => {
103            let mut full = path.to_vec();
104            full.push(PathPart::Part(node.name()));
105            let catalogued = table::lookup(&full);
106            let member = match (node, catalogued) {
107                (
108                    SettingsNode::Raw { .. }
109                    | SettingsNode::Group { .. }
110                    | SettingsNode::RawGroup { .. },
111                    _,
112                ) => UncataloguedMember {
113                    name: node.name(),
114                    span: node.span(),
115                    suggestion: carried_suggestion(node, path),
116                },
117                (SettingsNode::RawValue { value, .. }, Some(_)) => UncataloguedMember {
118                    name: value,
119                    span: node.span(),
120                    suggestion: carried_suggestion(node, path),
121                },
122                (SettingsNode::RawValue { .. }, None) => UncataloguedMember {
123                    name: node.name(),
124                    span: node.span(),
125                    suggestion: carried_suggestion(node, path),
126                },
127                _ => return,
128            };
129            members.push(member);
130        }
131        Visit::OpaqueLeaf(node @ (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. })) => {
132            members.push(UncataloguedMember {
133                name: node.name(),
134                span: node.span(),
135                suggestion: None,
136            });
137        }
138        Visit::OpaqueLeaf(_) => {}
139    });
140    members
141}
142
143/// The declared spelling a carried (verbatim-emitted) member was close to,
144/// when exactly one qualifies: a carried key is compared with the leaf
145/// spellings valid under `path`, and a carried value under a catalogued enum
146/// key is compared with that enum's declared spellings. `None` for members
147/// that are not carried, or whose spelling is close to nothing.
148pub(crate) fn carried_suggestion(node: &SettingsNode, path: &[PathPart<'_>]) -> Option<String> {
149    let mut full = path.to_vec();
150    full.push(PathPart::Part(node.name()));
151    match (node, table::lookup(&full)) {
152        (SettingsNode::Raw { .. }, _)
153        | (SettingsNode::Group { .. } | SettingsNode::RawGroup { .. }, None) => {
154            suggest::suggest(node.name(), table::key_spellings(path).into_iter())
155        }
156        (SettingsNode::RawValue { value, .. }, Some(entry)) => match entry.kind {
157            table::KeyKind::Enum(domain) => suggest::suggest(value, table::enum_spellings(domain)),
158            _ => None,
159        },
160        (SettingsNode::RawValue { .. }, None) => {
161            suggest::suggest(node.name(), table::key_spellings(path).into_iter())
162        }
163        _ => None,
164    }
165}
166
167enum Visit<'a, 'p> {
168    /// A structural problem emission rejects before reaching any member.
169    Rejected(SettingsDiagnostic),
170    /// A member at a catalogued position, with its parent path.
171    Member(&'a SettingsNode, &'p [PathPart<'a>]),
172    /// A leaf inside an uncatalogued top-level group.
173    OpaqueLeaf(&'a SettingsNode),
174}
175
176/// Visit every settings member the way emission reaches it.
177fn walk<'a>(settings: &'a Settings, visit: &mut dyn FnMut(Visit<'a, '_>)) {
178    for child in &settings.children {
179        match child {
180            SettingsNode::Workshop { children, .. } => walk_workshop(children, visit),
181            SettingsNode::Group { name, children, .. } => match name.as_str() {
182                "main" | "lobby" | "extensions" => {
183                    for member in children {
184                        visit(Visit::Member(member, &[PathPart::Part(name)]));
185                    }
186                }
187                "gamemodes" => walk_modes(children, visit),
188                "heroes" => walk_heroes(children, visit),
189                _ => walk_opaque(children, visit),
190            },
191            // Pass-through groups emit verbatim wherever they land; only
192            // their leaves need a written form.
193            SettingsNode::RawGroup { children, .. } => walk_opaque(children, visit),
194            other => {
195                visit(Visit::Rejected(rejected(
196                    other.span(),
197                    "settings block children must be groups".to_string(),
198                    None,
199                )));
200                // A stray raw member is still a preserved residual.
201                if matches!(
202                    other,
203                    SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }
204                ) {
205                    visit(Visit::OpaqueLeaf(other));
206                }
207            }
208        }
209    }
210}
211
212fn walk_workshop<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
213    for child in children {
214        match child {
215            SettingsNode::Group { children, .. }
216            | SettingsNode::Workshop { children, .. }
217            | SettingsNode::RawGroup { children, .. } => {
218                walk_workshop(children, visit);
219            }
220            SettingsNode::Raw { .. } | SettingsNode::RawValue { .. } => {}
221            other => visit(Visit::Rejected(rejected(
222                other.span(),
223                "settings.workshop contains a typed builtin setting".to_string(),
224                None,
225            ))),
226        }
227    }
228}
229
230fn walk_modes<'a>(modes: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
231    for mode in modes {
232        if let SettingsNode::RawGroup { children, .. } = mode {
233            walk_opaque(children, visit);
234            continue;
235        }
236        let SettingsNode::Group { name, children, .. } = mode else {
237            visit(Visit::Rejected(rejected(
238                mode.span(),
239                "mode entries must be groups".to_string(),
240                None,
241            )));
242            continue;
243        };
244        let path = [PathPart::Part("gamemodes"), PathPart::Part(name)];
245        for member in children {
246            // `enabled` bools are consumed by the mode header and never reach
247            // the emission table.
248            if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
249                continue;
250            }
251            visit(Visit::Member(member, &path));
252        }
253    }
254}
255
256fn walk_heroes<'a>(teams: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
257    const TEAM: [PathPart<'static>; 2] = [PathPart::Part("heroes"), PathPart::Team];
258    const HERO: [PathPart<'static>; 3] = [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero];
259    for team in teams {
260        if let SettingsNode::RawGroup { children, .. } = team {
261            walk_opaque(children, visit);
262            continue;
263        }
264        let SettingsNode::Group { name, children, .. } = team else {
265            visit(Visit::Rejected(rejected(
266                team.span(),
267                "team entries must be groups".to_string(),
268                None,
269            )));
270            continue;
271        };
272        if table::team_name(name).is_none() {
273            visit(Visit::Rejected(rejected(
274                team.span(),
275                format!("unknown team '{name}'"),
276                suggest::suggest(name, table::team_spellings()),
277            )));
278            continue;
279        }
280        for member in children {
281            match member {
282                SettingsNode::RawGroup { children, .. } => walk_opaque(children, visit),
283                SettingsNode::Group { name, children, .. } => {
284                    if table::hero_name(name).is_none() {
285                        visit(Visit::Rejected(rejected(
286                            member.span(),
287                            format!("unknown hero '{name}'"),
288                            suggest::suggest(name, table::hero_spellings()),
289                        )));
290                        continue;
291                    }
292                    for inner in children {
293                        if let Some(diagnostic) = unemittable_hero_member(inner, name) {
294                            visit(Visit::Rejected(diagnostic));
295                        }
296                        visit(Visit::Member(inner, &HERO));
297                    }
298                }
299                other => visit(Visit::Member(other, &TEAM)),
300            }
301        }
302    }
303}
304
305/// A hero-scoped member the emitter can never spell: the key derives from an
306/// ability slot the hero provably lacks and no reviewed per-hero label row
307/// exists to substitute, so emission fails with a missing-mapping error.
308/// The rejection is reported here so `check` agrees with emission; a hero
309/// absent from gameplay data is not provable and is left to the emit-time
310/// failure the module header documents.
311fn unemittable_hero_member(node: &SettingsNode, hero: &str) -> Option<SettingsDiagnostic> {
312    // `Raw` and `RawGroup` members emit verbatim and never resolve a
313    // display name.
314    if matches!(
315        node,
316        SettingsNode::Raw { .. } | SettingsNode::RawGroup { .. }
317    ) {
318        return None;
319    }
320    let key = node.name();
321    let slot = table::ability_slot_for_path(&[
322        PathPart::Part("heroes"),
323        PathPart::Team,
324        PathPart::Hero,
325        PathPart::Part(key),
326    ])?;
327    if table::hero_setting_name(hero, key, "en-US").is_some() {
328        return None;
329    }
330    hero_provably_lacks_slot(hero, slot).then(|| {
331        rejected(
332            node.span(),
333            format!("hero '{hero}' has no '{slot}' ability for settings key '{key}'"),
334            None,
335        )
336    })
337}
338
339fn hero_provably_lacks_slot(hero: &str, slot: &str) -> bool {
340    crate::gameplay::data::builtin_ref()
341        .ok()
342        .and_then(|catalog| catalog.hero(&crate::gameplay::HeroId::new(hero)))
343        .is_some_and(|hero| {
344            hero.abilities_in_slot(&crate::gameplay::LogicalSlot::new(slot))
345                .is_empty()
346        })
347}
348
349/// Build the warning for a carried member close to one declared spelling:
350/// a misspelled key names the declared key, a misspelled value under a
351/// catalogued key names the declared enum member.
352fn warn_near_miss(
353    node: &SettingsNode,
354    path: &[PathPart<'_>],
355    spelling: String,
356) -> SettingsDiagnostic {
357    let mut full = path.to_vec();
358    full.push(PathPart::Part(node.name()));
359    let message = match node {
360        // A catalogued key carrying an undeclared value: the value is the
361        // misspelled spelling.
362        SettingsNode::RawValue { value, .. } if table::lookup(&full).is_some() => format!(
363            "settings value '{value}' for key '{}' is not declared",
364            table::path_string(&full)
365        ),
366        other => format!("settings key '{}' is not declared", other.name()),
367    };
368    let message = suggest::with_suggestion_text(message, Some(&spelling));
369    SettingsDiagnostic {
370        severity: DiagnosticSeverity::Warning,
371        error: WorkshopError::warning(message, node.span()),
372        suggestion: Some(spelling),
373    }
374}
375
376fn walk_opaque<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
377    for child in children {
378        match child {
379            // Mirrors `emit_opaque_group`: nested groups pass through
380            // verbatim; only leaf members are table-checked.
381            SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. } => {
382                walk_opaque(children, visit)
383            }
384            other => visit(Visit::OpaqueLeaf(other)),
385        }
386    }
387}
388
389fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
390    let name = node.name();
391    let mut full = path.to_vec();
392    full.push(PathPart::Part(name));
393    // A carried member close to exactly one declared spelling is a likely
394    // misspelling: accept it, but report a warning naming that spelling.
395    // Members of an uncatalogued block are checked with an empty path
396    // because they belong to opaque project data, not to any catalogued
397    // position, so they never suggest.
398    if !path.is_empty() {
399        if let Some(spelling) = carried_suggestion(node, path) {
400            errors.push(warn_near_miss(node, path, spelling));
401        }
402    }
403    match (node, table::lookup(&full)) {
404        (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }, _) => return,
405        // Mirrors `settings_member`: a block is carried as written whether
406        // or not its key is catalogued; only its leaves must have a written
407        // form (#412).
408        (SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. }, _) => {
409            for child in children {
410                check_member(child, &[], errors);
411            }
412            return;
413        }
414        _ => {}
415    }
416    match member::lookup(node, &full).and_then(|entry| member::accept(node, entry)) {
417        Ok(Member::List { elements, kind }) => {
418            for element in elements {
419                if let Err(diagnostic) = kind.resolve(element, name) {
420                    errors.push(*diagnostic);
421                }
422            }
423        }
424        Ok(_) => {}
425        Err(diagnostic) => errors.push(*diagnostic),
426    }
427}