Skip to main content

workshop_rs/settings/
check.rs

1use crate::core::error::WorkshopError;
2use crate::core::suggest;
3
4use super::member::{self, Member, rejected};
5use super::table;
6use super::{PathPart, Settings, SettingsNode};
7
8/// Whether a settings diagnostic rejects the member or only advises about
9/// it.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DiagnosticSeverity {
12    /// Emission rejects the member; [`crate::Program::validate`] fails.
13    Error,
14    /// The member is carried and emitted as written, but it is close to a
15    /// declared spelling and is likely a misspelling. Warnings never fail
16    /// validation.
17    Warning,
18}
19
20/// One reported 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    /// Whether the diagnostic rejects the member or only advises about it.
28    pub severity: DiagnosticSeverity,
29    /// The rejection or advisory, with its source span and any suggestion in
30    /// the message. Warnings carry [`WorkshopError::Warning`].
31    pub error: WorkshopError,
32    /// The single canonical spelling the reported input was close to (a case
33    /// or accent difference, or a small edit distance). `None` when no
34    /// candidate is close or several are. The spelling is a Workshop display
35    /// name (`enabled maps`, `Château Guillard`) — the form source text
36    /// should carry — not a canonical table key.
37    pub suggestion: Option<String>,
38}
39
40/// Check that emission accepts `settings`, returning the structured
41/// diagnostics a caller can apply: one per offending member (rejected, or a
42/// carried near-miss of a declared spelling), each carrying the node's
43/// recorded source span when present.
44pub fn check_emission_diagnostics(settings: &Settings) -> Vec<SettingsDiagnostic> {
45    let mut diagnostics = Vec::new();
46    walk(settings, &mut |visit| match visit {
47        Visit::Rejected(diagnostic) => diagnostics.push(diagnostic),
48        Visit::Member(node, path) => check_member(node, path, &mut diagnostics),
49        Visit::OpaqueLeaf(node) => check_member(node, &[], &mut diagnostics),
50    });
51    diagnostics
52}
53
54/// Check that emission accepts `settings`, returning one error per offending
55/// member. Warnings are excluded: they describe accepted members. Each error
56/// carries the node's recorded source span when present.
57pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
58    check_emission_diagnostics(settings)
59        .into_iter()
60        .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
61        .map(|diagnostic| diagnostic.error)
62        .collect()
63}
64
65pub(crate) struct UncataloguedMember<'a> {
66    pub(crate) name: &'a str,
67    pub(crate) span: Option<crate::source::Span>,
68    pub(crate) suggestion: Option<String>,
69}
70
71pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec<UncataloguedMember<'_>> {
72    let mut members = Vec::new();
73    walk(settings, &mut |visit| match visit {
74        Visit::Rejected(_) => {}
75        Visit::Member(node, path) => {
76            let mut full = path.to_vec();
77            full.push(PathPart::Part(node.name()));
78            let catalogued = table::lookup(&full);
79            let member = match (node, catalogued) {
80                (
81                    SettingsNode::Raw { .. }
82                    | SettingsNode::Group { .. }
83                    | SettingsNode::RawGroup { .. },
84                    _,
85                ) => UncataloguedMember {
86                    name: node.name(),
87                    span: node.span(),
88                    suggestion: carried_suggestion(node, path),
89                },
90                (SettingsNode::RawValue { value, .. }, Some(_)) => UncataloguedMember {
91                    name: value,
92                    span: node.span(),
93                    suggestion: carried_suggestion(node, path),
94                },
95                (SettingsNode::RawValue { .. }, None) => UncataloguedMember {
96                    name: node.name(),
97                    span: node.span(),
98                    suggestion: carried_suggestion(node, path),
99                },
100                _ => return,
101            };
102            members.push(member);
103        }
104        Visit::OpaqueLeaf(node @ (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. })) => {
105            members.push(UncataloguedMember {
106                name: node.name(),
107                span: node.span(),
108                suggestion: None,
109            });
110        }
111        Visit::OpaqueLeaf(_) => {}
112    });
113    members
114}
115
116pub(crate) fn carried_suggestion(node: &SettingsNode, path: &[PathPart<'_>]) -> Option<String> {
117    let mut full = path.to_vec();
118    full.push(PathPart::Part(node.name()));
119    match (node, table::lookup(&full)) {
120        (SettingsNode::Raw { .. }, _)
121        | (SettingsNode::Group { .. } | SettingsNode::RawGroup { .. }, None) => {
122            suggest::suggest(node.name(), table::key_spellings(path).into_iter())
123        }
124        (SettingsNode::RawValue { value, .. }, Some(entry)) => match entry.kind {
125            table::KeyKind::Enum(domain) => suggest::suggest(value, table::enum_spellings(domain)),
126            _ => None,
127        },
128        (SettingsNode::RawValue { .. }, None) => {
129            suggest::suggest(node.name(), table::key_spellings(path).into_iter())
130        }
131        _ => None,
132    }
133}
134
135enum Visit<'a, 'p> {
136    Rejected(SettingsDiagnostic),
137    Member(&'a SettingsNode, &'p [PathPart<'a>]),
138    OpaqueLeaf(&'a SettingsNode),
139}
140
141fn walk<'a>(settings: &'a Settings, visit: &mut dyn FnMut(Visit<'a, '_>)) {
142    for child in &settings.children {
143        match child {
144            SettingsNode::Workshop { children, .. } => walk_workshop(children, visit),
145            SettingsNode::Group { name, children, .. } => match name.as_str() {
146                "main" | "lobby" | "extensions" => {
147                    for member in children {
148                        visit(Visit::Member(member, &[PathPart::Part(name)]));
149                    }
150                }
151                "gamemodes" => walk_modes(children, visit),
152                "heroes" => walk_heroes(children, visit),
153                _ => walk_opaque(children, visit),
154            },
155            SettingsNode::RawGroup { children, .. } => walk_opaque(children, visit),
156            other => reject_stray_member(other, visit),
157        }
158    }
159}
160
161fn reject_stray_member<'a>(node: &'a SettingsNode, visit: &mut dyn FnMut(Visit<'a, '_>)) {
162    visit(Visit::Rejected(rejected(
163        node.span(),
164        "settings block children must be groups".to_string(),
165        None,
166    )));
167    if matches!(
168        node,
169        SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }
170    ) {
171        visit(Visit::OpaqueLeaf(node));
172    }
173}
174
175fn walk_workshop<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
176    for child in children {
177        match child {
178            SettingsNode::Group { children, .. }
179            | SettingsNode::Workshop { children, .. }
180            | SettingsNode::RawGroup { children, .. } => {
181                walk_workshop(children, visit);
182            }
183            SettingsNode::Raw { .. } | SettingsNode::RawValue { .. } => {}
184            other => visit(Visit::Rejected(rejected(
185                other.span(),
186                "settings.workshop contains a typed builtin setting".to_string(),
187                None,
188            ))),
189        }
190    }
191}
192
193fn walk_modes<'a>(modes: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
194    for mode in modes {
195        if let SettingsNode::RawGroup { children, .. } = mode {
196            walk_opaque(children, visit);
197            continue;
198        }
199        let SettingsNode::Group { name, children, .. } = mode else {
200            visit(Visit::Rejected(rejected(
201                mode.span(),
202                "mode entries must be groups".to_string(),
203                None,
204            )));
205            continue;
206        };
207        let path = [PathPart::Part("gamemodes"), PathPart::Part(name)];
208        for member in children {
209            // `enabled` bools are consumed by the mode header and never reach
210            // the emission table.
211            if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
212                continue;
213            }
214            visit(Visit::Member(member, &path));
215        }
216    }
217}
218
219fn walk_heroes<'a>(teams: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
220    const TEAM: [PathPart<'static>; 2] = [PathPart::Part("heroes"), PathPart::Team];
221    const HERO: [PathPart<'static>; 3] = [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero];
222    for team in teams {
223        if let SettingsNode::RawGroup { children, .. } = team {
224            walk_opaque(children, visit);
225            continue;
226        }
227        let SettingsNode::Group { name, children, .. } = team else {
228            visit(Visit::Rejected(rejected(
229                team.span(),
230                "team entries must be groups".to_string(),
231                None,
232            )));
233            continue;
234        };
235        if table::team_name(name).is_none() {
236            visit(Visit::Rejected(rejected(
237                team.span(),
238                format!("unknown team '{name}'"),
239                suggest::suggest(name, table::team_spellings()),
240            )));
241            continue;
242        }
243        for member in children {
244            match member {
245                SettingsNode::RawGroup { children, .. } => walk_opaque(children, visit),
246                SettingsNode::Group { name, children, .. } => {
247                    if table::hero_name(name).is_none() {
248                        visit(Visit::Rejected(rejected(
249                            member.span(),
250                            format!("unknown hero '{name}'"),
251                            suggest::suggest(name, table::hero_spellings()),
252                        )));
253                        continue;
254                    }
255                    for inner in children {
256                        if let Some(diagnostic) = unemittable_hero_member(inner, name) {
257                            visit(Visit::Rejected(diagnostic));
258                        }
259                        visit(Visit::Member(inner, &HERO));
260                    }
261                }
262                other => visit(Visit::Member(other, &TEAM)),
263            }
264        }
265    }
266}
267
268fn unemittable_hero_member(node: &SettingsNode, hero: &str) -> Option<SettingsDiagnostic> {
269    if matches!(
270        node,
271        SettingsNode::Raw { .. } | SettingsNode::RawGroup { .. }
272    ) {
273        return None;
274    }
275    let key = node.name();
276    let slot = table::ability_slot_for_path(&[
277        PathPart::Part("heroes"),
278        PathPart::Team,
279        PathPart::Hero,
280        PathPart::Part(key),
281    ])?;
282    if table::hero_setting_name(hero, key, "en-US").is_some() {
283        return None;
284    }
285    hero_provably_lacks_slot(hero, slot).then(|| {
286        rejected(
287            node.span(),
288            format!("hero '{hero}' has no '{slot}' ability for settings key '{key}'"),
289            None,
290        )
291    })
292}
293
294fn hero_provably_lacks_slot(hero: &str, slot: &str) -> bool {
295    crate::gameplay::data::builtin_ref()
296        .ok()
297        .and_then(|catalog| catalog.hero(&crate::gameplay::HeroId::new(hero)))
298        .is_some_and(|hero| {
299            hero.abilities_in_slot(&crate::gameplay::LogicalSlot::new(slot))
300                .is_empty()
301        })
302}
303
304fn warn_near_miss(
305    node: &SettingsNode,
306    path: &[PathPart<'_>],
307    spelling: String,
308) -> SettingsDiagnostic {
309    let mut full = path.to_vec();
310    full.push(PathPart::Part(node.name()));
311    let message = match node {
312        SettingsNode::RawValue { value, .. } if table::lookup(&full).is_some() => format!(
313            "settings value '{value}' for key '{}' is not declared",
314            table::path_string(&full)
315        ),
316        other => format!("settings key '{}' is not declared", other.name()),
317    };
318    let message = suggest::with_suggestion_text(message, Some(&spelling));
319    SettingsDiagnostic {
320        severity: DiagnosticSeverity::Warning,
321        error: WorkshopError::warning(message, node.span()),
322        suggestion: Some(spelling),
323    }
324}
325
326fn walk_opaque<'a>(children: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_>)) {
327    for child in children {
328        match child {
329            SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. } => {
330                walk_opaque(children, visit)
331            }
332            other => visit(Visit::OpaqueLeaf(other)),
333        }
334    }
335}
336
337fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
338    let name = node.name();
339    let mut full = path.to_vec();
340    full.push(PathPart::Part(name));
341    if !path.is_empty() {
342        if let Some(spelling) = carried_suggestion(node, path) {
343            errors.push(warn_near_miss(node, path, spelling));
344        }
345    }
346    match (node, table::lookup(&full)) {
347        (SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }, _) => return,
348        // Mirrors `settings_member`: a block is carried as written whether
349        // or not its key is catalogued; only its leaves must have a written
350        // form (#412).
351        (SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. }, _) => {
352            for child in children {
353                check_member(child, &[], errors);
354            }
355            return;
356        }
357        _ => {}
358    }
359    match member::lookup(node, &full).and_then(|entry| member::accept(node, entry)) {
360        Ok(Member::List { elements, kind }) => {
361            for element in elements {
362                if let Err(diagnostic) = kind.resolve(element, name) {
363                    errors.push(*diagnostic);
364                }
365            }
366        }
367        Ok(_) => {}
368        Err(diagnostic) => errors.push(*diagnostic),
369    }
370}