1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DiagnosticSeverity {
29 Error,
31 Warning,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct SettingsDiagnostic {
44 pub severity: DiagnosticSeverity,
46 pub error: WorkshopError,
49 pub suggestion: Option<String>,
55}
56
57pub 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
74pub 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
85pub(crate) struct UncataloguedMember<'a> {
87 pub(crate) name: &'a str,
89 pub(crate) span: Option<crate::source::Span>,
90 pub(crate) suggestion: Option<String>,
92}
93
94pub(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
143pub(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 Rejected(SettingsDiagnostic),
170 Member(&'a SettingsNode, &'p [PathPart<'a>]),
172 OpaqueLeaf(&'a SettingsNode),
174}
175
176fn 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 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 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 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
305fn unemittable_hero_member(node: &SettingsNode, hero: &str) -> Option<SettingsDiagnostic> {
312 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
349fn 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 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 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 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 (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}