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