1use crate::core::error::WorkshopError;
15use crate::source::Span;
16
17use super::table::{self, KeyKind};
18use super::{PathPart, Settings, SettingsNode, suggest};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct SettingsDiagnostic {
27 pub error: WorkshopError,
29 pub suggestion: Option<String>,
35}
36
37pub fn check_emission_diagnostics(settings: &Settings) -> Vec<SettingsDiagnostic> {
41 let mut diagnostics = Vec::new();
42 for child in &settings.children {
43 match child {
44 SettingsNode::Workshop { children, .. } => {
45 check_workshop_children(children, &mut diagnostics)
46 }
47 SettingsNode::Group { name, children, .. } => match name.as_str() {
48 "main" | "lobby" => {
49 for member in children {
50 check_member(member, &[PathPart::Part(name)], &mut diagnostics);
51 }
52 }
53 "gamemodes" => check_modes(children, &mut diagnostics),
54 "heroes" => check_heroes(children, &mut diagnostics),
55 "extensions" => {
56 for member in children {
57 check_member(member, &[PathPart::Part("extensions")], &mut diagnostics);
58 }
59 }
60 _ => check_opaque_children(children, &mut diagnostics),
61 },
62 other => diagnostics.push(rejected(
63 other.span(),
64 "settings block children must be groups".to_string(),
65 None,
66 )),
67 }
68 }
69 diagnostics
70}
71
72pub fn check_emission(settings: &Settings) -> Vec<WorkshopError> {
75 check_emission_diagnostics(settings)
76 .into_iter()
77 .map(|diagnostic| diagnostic.error)
78 .collect()
79}
80
81fn rejected(span: Option<Span>, message: String, suggestion: Option<String>) -> SettingsDiagnostic {
82 let message = suggest::with_suggestion_text(message, suggestion.as_deref());
83 SettingsDiagnostic {
84 error: WorkshopError::malformed(message, span),
85 suggestion,
86 }
87}
88
89fn check_workshop_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
90 for child in children {
91 match child {
92 SettingsNode::Group { children, .. } | SettingsNode::Workshop { children, .. } => {
93 check_workshop_children(children, errors);
94 }
95 SettingsNode::Raw { .. } => {}
96 other => errors.push(rejected(
97 other.span(),
98 "settings.workshop contains a typed builtin setting".to_string(),
99 None,
100 )),
101 }
102 }
103}
104
105fn check_modes(modes: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
106 for mode in modes {
107 let SettingsNode::Group { name, children, .. } = mode else {
108 errors.push(rejected(
109 mode.span(),
110 "mode entries must be groups".to_string(),
111 None,
112 ));
113 continue;
114 };
115 for member in children {
116 if matches!(member, SettingsNode::Bool { name, .. } if name == "enabled") {
119 continue;
120 }
121 check_member(
122 member,
123 &[PathPart::Part("gamemodes"), PathPart::Part(name)],
124 errors,
125 );
126 }
127 }
128}
129
130fn check_heroes(teams: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
131 for team in teams {
132 let SettingsNode::Group { name, children, .. } = team else {
133 errors.push(rejected(
134 team.span(),
135 "team entries must be groups".to_string(),
136 None,
137 ));
138 continue;
139 };
140 if table::team_name(name).is_none() {
141 errors.push(rejected(
142 team.span(),
143 format!("unknown team '{name}'"),
144 suggest::suggest(name, table::team_spellings()),
145 ));
146 continue;
147 }
148 for member in children {
149 match member {
150 SettingsNode::Group { name, children, .. } => {
151 if table::hero_name(name).is_none() {
152 errors.push(rejected(
153 member.span(),
154 format!("unknown hero '{name}'"),
155 suggest::suggest(name, table::hero_spellings()),
156 ));
157 continue;
158 }
159 for inner in children {
160 check_member(
161 inner,
162 &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
163 errors,
164 );
165 }
166 }
167 other => check_member(other, &[PathPart::Part("heroes"), PathPart::Team], errors),
168 }
169 }
170 }
171}
172
173fn check_opaque_children(children: &[SettingsNode], errors: &mut Vec<SettingsDiagnostic>) {
174 for child in children {
175 match child {
176 SettingsNode::Group { children, .. } => check_opaque_children(children, errors),
179 other => check_member(other, &[], errors),
180 }
181 }
182}
183
184fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<SettingsDiagnostic>) {
185 if matches!(node, SettingsNode::Raw { .. }) {
186 return;
187 }
188 let name = node.name();
189 let mut full = path.to_vec();
190 full.push(PathPart::Part(name));
191 let Some(entry) = table::lookup(&full) else {
192 errors.push(rejected(
193 node.span(),
194 format!(
195 "settings key '{}' is outside the emission table",
196 table::path_string(&full)
197 ),
198 suggest::suggest(name, table::key_spellings(path).into_iter()),
199 ));
200 return;
201 };
202 match (node, &entry.kind) {
203 (SettingsNode::Flag { .. }, KeyKind::Flag)
204 | (SettingsNode::String { .. }, KeyKind::String)
205 | (SettingsNode::Number { .. }, KeyKind::Number | KeyKind::Percent)
206 | (SettingsNode::Bool { .. }, KeyKind::Bool | KeyKind::YesNo) => {}
207 (SettingsNode::String { value, .. }, KeyKind::Enum(domain)) => {
208 if table::enum_name(domain, value).is_none() {
209 errors.push(rejected(
210 node.span(),
211 format!("unknown value '{value}' for settings key '{name}'"),
212 suggest::suggest(value, table::enum_spellings(domain)),
213 ));
214 }
215 }
216 (SettingsNode::Bool { value, .. }, KeyKind::BoolEnum(domain)) => {
217 if !*value {
218 errors.push(rejected(
219 node.span(),
220 format!("unsupported false value for settings key '{name}'"),
221 None,
222 ));
223 } else if table::enum_name(domain, "enabled").is_none() {
224 errors.push(rejected(
225 node.span(),
226 format!("unknown value 'enabled' for settings key '{name}'"),
227 None,
228 ));
229 }
230 }
231 (SettingsNode::List { elements, .. }, KeyKind::ListMap) => {
232 for element in elements {
233 if table::map_name(&element.value).is_none() {
234 errors.push(rejected(
235 element.span,
236 format!("unknown map '{}' in settings list '{name}'", element.value),
237 suggest::suggest(&element.value, table::map_spellings()),
238 ));
239 }
240 }
241 }
242 (SettingsNode::List { elements, .. }, KeyKind::ListHero) => {
243 for element in elements {
244 if table::hero_name(&element.value).is_none() {
245 errors.push(rejected(
246 element.span,
247 format!("unknown hero '{}' in settings list '{name}'", element.value),
248 suggest::suggest(&element.value, table::hero_spellings()),
249 ));
250 }
251 }
252 }
253 _ => errors.push(rejected(
254 node.span(),
255 format!("settings key '{name}' does not match its table kind"),
256 None,
257 )),
258 }
259}