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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DiagnosticSeverity {
12 Error,
14 Warning,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct SettingsDiagnostic {
27 pub severity: DiagnosticSeverity,
29 pub error: WorkshopError,
32 pub suggestion: Option<String>,
38}
39
40pub 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
54pub 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 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 (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}