1use std::collections::BTreeMap;
2
3use anyhow::{Context, bail};
4use wowlab_fs::{
5 checksum::Checksum,
6 path::{Component, Path, PathBuf},
7};
8use wowlab_manifest_schema::{CURRENT_SCHEMA_VERSION, Manifest, ManifestRepository};
9
10use super::{
11 cache::{CodegenCache, ManifestInputs, constant_output},
12 identifiers::{
13 validate_item_identifiers, validate_module_component, validate_named_identifiers,
14 validate_rust_identifier, validate_spec_identifiers,
15 },
16 spec_file::{generate_shared_file, generate_spec_file},
17};
18use crate::{
19 gen_barrel::{generate_shared_mod, generate_specs_mod, generate_top_mod},
20 gen_item_registry::generate_items_mod_file,
21 gen_items::generate_item_file,
22 helpers::{display_name_from_filename, module_name_from_filename, to_snake},
23};
24
25pub(super) struct LoadedSpec {
26 pub(super) path: PathBuf,
27 pub(super) manifest: Manifest,
28 pub(super) mod_name: String,
29 pub(super) display_name: String,
30 pub(super) source_path: String,
31}
32
33pub(super) struct GenerationResult {
34 pub(super) files: Vec<(String, String)>,
35 pub(super) dependencies: BTreeMap<String, Checksum>,
36}
37
38struct PreparedSpec {
39 mod_name: String,
40 output_path: String,
41 dependency: Checksum,
42 source: PreparedSpecSource,
43}
44
45enum PreparedSpecSource {
46 Loaded(Box<LoadedSpec>),
47 Reused(String),
48}
49
50fn prepare_specs_for_generation(
51 repository: &ManifestRepository,
52 manifests_dir: &Path,
53 inputs: &ManifestInputs,
54 cache: Option<&CodegenCache>,
55) -> anyhow::Result<Vec<PreparedSpec>> {
56 let specs_root = manifests_dir.join("specs");
57 let mut specs = Vec::new();
58 let mut diagnostics = Vec::new();
59
60 for path in repository.spec_paths()? {
61 let mod_name = repository.spec_slug(&path)?;
62 let display_name = display_name_from_filename(Path::new(&mod_name))?;
63 let source_path = path
64 .strip_prefix(&specs_root)
65 .with_context(|| {
66 format!(
67 "spec manifest is outside {}: {}",
68 specs_root.display(),
69 path.display()
70 )
71 })?
72 .to_str()
73 .with_context(|| format!("spec manifest path is not UTF-8: {}", path.display()))?
74 .to_owned();
75 let output_path = format!("specs/{mod_name}.rs");
76 let dependency = inputs.spec(&path)?;
77
78 if let Some(contents) = cache
79 .map(|cache| cache.reuse(&output_path, dependency))
80 .transpose()?
81 .flatten()
82 {
83 specs.push(PreparedSpec {
84 mod_name,
85 output_path,
86 dependency,
87 source: PreparedSpecSource::Reused(contents),
88 });
89
90 continue;
91 }
92
93 let manifest = repository
94 .load_spec(&path)
95 .with_context(|| format!("failed to load {}", path.display()))?;
96
97 if manifest.schema_version != CURRENT_SCHEMA_VERSION {
98 bail!(
99 "{}: unknown schema_version {} (expected {})",
100 path.display(),
101 manifest.schema_version,
102 CURRENT_SCHEMA_VERSION
103 );
104 }
105
106 diagnostics.extend(
107 manifest
108 .diagnostics()
109 .into_iter()
110 .map(|diagnostic| format!("{}: {diagnostic}", path.display())),
111 );
112
113 specs.push(PreparedSpec {
114 mod_name: mod_name.clone(),
115 output_path,
116 dependency,
117 source: PreparedSpecSource::Loaded(Box::new(LoadedSpec {
118 path,
119 manifest,
120 mod_name,
121 display_name,
122 source_path,
123 })),
124 });
125 }
126
127 if !diagnostics.is_empty() {
128 diagnostics.sort_unstable();
129
130 bail!(
131 "composed manifest diagnostics failed:\n{}",
132 diagnostics.join("\n")
133 );
134 }
135
136 for spec in &specs {
137 if let PreparedSpecSource::Loaded(loaded) = &spec.source {
138 validate_spec_identifiers(loaded)?;
139 }
140 }
141
142 Ok(specs)
143}
144
145#[cfg(test)]
146pub(super) fn generate_all(manifests_dir: &Path) -> anyhow::Result<Vec<(String, String)>> {
147 let repository = ManifestRepository::new(manifests_dir);
148 let paths = repository.toml_paths()?;
149 let inputs = ManifestInputs::capture(manifests_dir, &paths)?;
150
151 generate_all_incremental(manifests_dir, &inputs, None).map(|generation| generation.files)
152}
153
154pub(super) fn generate_all_incremental(
155 manifests_dir: &Path,
156 inputs: &ManifestInputs,
157 cache: Option<&CodegenCache>,
158) -> anyhow::Result<GenerationResult> {
159 let repository = ManifestRepository::new(manifests_dir);
160 let specs = prepare_specs_for_generation(&repository, manifests_dir, inputs, cache)?;
163 let mut files: Vec<(String, String)> = Vec::new();
164 let mut dependencies = BTreeMap::new();
165
166 generate_shared_outputs(&repository, inputs, &mut files, &mut dependencies, cache)?;
167 let spec_names = generate_spec_outputs(specs, &mut files, &mut dependencies)?;
168
169 push_generated(
170 &mut files,
171 &mut dependencies,
172 cache,
173 "mod.rs".into(),
174 constant_output("top-level module barrel"),
175 || generate_top_mod().context("generating top-level module barrel"),
176 )?;
177 push_generated(
178 &mut files,
179 &mut dependencies,
180 cache,
181 "specs/mod.rs".into(),
182 inputs.all_specs()?,
183 || generate_specs_mod(&spec_names).context("generating spec module barrel"),
184 )?;
185
186 generate_item_outputs(
187 &repository,
188 manifests_dir,
189 inputs,
190 &mut files,
191 &mut dependencies,
192 cache,
193 )?;
194
195 Ok(GenerationResult {
196 files,
197 dependencies,
198 })
199}
200
201fn generate_shared_outputs(
202 repository: &ManifestRepository,
203 inputs: &ManifestInputs,
204 files: &mut Vec<(String, String)>,
205 dependencies: &mut BTreeMap<String, Checksum>,
206 cache: Option<&CodegenCache>,
207) -> anyhow::Result<()> {
208 let mut shared_modules: BTreeMap<String, Vec<String>> = BTreeMap::new();
209
210 for shared_path in repository.shared_paths()? {
211 let bindings = repository.load_shared_bindings(&shared_path)?;
212
213 if bindings.effects.is_empty() && bindings.reported_spells.is_empty() {
214 continue;
215 }
216
217 let relative = shared_path.relative_path();
218 let group = relative
219 .components()
220 .next()
221 .and_then(|component| match component {
222 Component::Normal(value) => value.to_str(),
223 _ => None,
224 })
225 .map(str::to_owned)
226 .with_context(|| {
227 format!(
228 "shared manifest must be nested by group: {}",
229 relative.display()
230 )
231 })?;
232 let name = module_name_from_filename(relative)?;
233 let source_path = relative.to_str().with_context(|| {
234 format!("shared manifest path is not UTF-8: {}", relative.display())
235 })?;
236
237 validate_module_component(
238 &group,
239 &format!("shared manifest group in {}", relative.display()),
240 )?;
241 validate_module_component(
242 &name,
243 &format!("shared manifest module in {}", relative.display()),
244 )?;
245 validate_named_identifiers(bindings.effects.keys(), "effects", shared_path.path())?;
246
247 for reported in bindings.reported_spells.keys() {
248 validate_rust_identifier(
249 &reported.to_uppercase(),
250 &format!(
251 "{} [reported_spells.{reported}]",
252 shared_path.path().display()
253 ),
254 )?;
255 }
256
257 let output_path = format!("shared/{group}/{name}.rs");
258 let dependency = inputs.shared_file(shared_path.path())?;
259
260 push_generated(files, dependencies, cache, output_path, dependency, || {
261 generate_shared_file(&bindings, source_path).with_context(|| {
262 format!(
263 "generating shared file from {}",
264 shared_path.path().display()
265 )
266 })
267 })?;
268 shared_modules.entry(group).or_default().push(name);
269 }
270
271 for (group, modules) in &shared_modules {
272 let output_path = format!("shared/{group}/mod.rs");
273 let dependency = inputs.shared_group(group)?;
274
275 push_generated(files, dependencies, cache, output_path, dependency, || {
276 generate_shared_mod(modules)
277 .with_context(|| format!("generating shared module barrel for {group}"))
278 })?;
279 }
280
281 push_generated(
282 files,
283 dependencies,
284 cache,
285 "shared/mod.rs".into(),
286 inputs.all_shared()?,
287 || {
288 generate_shared_mod(&shared_modules.keys().cloned().collect::<Vec<_>>())
289 .context("generating top-level shared module barrel")
290 },
291 )?;
292
293 Ok(())
294}
295
296fn generate_spec_outputs(
297 specs: Vec<PreparedSpec>,
298 files: &mut Vec<(String, String)>,
299 dependencies: &mut BTreeMap<String, Checksum>,
300) -> anyhow::Result<Vec<String>> {
301 let mut spec_names = Vec::with_capacity(specs.len());
302
303 for spec in specs {
304 match spec.source {
305 PreparedSpecSource::Loaded(loaded) => {
306 push_generated(
307 files,
308 dependencies,
309 None,
310 spec.output_path,
311 spec.dependency,
312 || {
313 generate_spec_file(
314 &loaded.manifest,
315 &loaded.source_path,
316 &loaded.mod_name,
317 &loaded.display_name,
318 )
319 .with_context(|| {
320 format!("generating spec file from {}", loaded.path.display())
321 })
322 },
323 )?;
324 }
325 PreparedSpecSource::Reused(contents) => {
326 push_output(
327 files,
328 dependencies,
329 spec.output_path,
330 spec.dependency,
331 contents,
332 )?;
333 }
334 }
335
336 spec_names.push(spec.mod_name);
337 }
338
339 Ok(spec_names)
340}
341
342fn generate_item_outputs(
343 repository: &ManifestRepository,
344 manifests_dir: &Path,
345 inputs: &ManifestInputs,
346 files: &mut Vec<(String, String)>,
347 dependencies: &mut BTreeMap<String, Checksum>,
348 cache: Option<&CodegenCache>,
349) -> anyhow::Result<()> {
350 let items_manifest = repository.load_items()?;
351 let items_path = manifests_dir.join("items.toml");
352
353 if items_manifest.schema_version != CURRENT_SCHEMA_VERSION {
354 bail!(
355 "{}: unknown schema_version {} (expected {})",
356 items_path.display(),
357 items_manifest.schema_version,
358 CURRENT_SCHEMA_VERSION
359 );
360 }
361
362 let items_dependency = inputs.items()?;
363
364 for (key, item) in &items_manifest.items {
365 validate_item_identifiers(key, item, &items_path)?;
366
367 if crate::gen_items::is_simple_equip_aura(item) {
368 continue;
369 }
370
371 let snake = to_snake(key);
372 let output_path = format!("items/{snake}.rs");
373
374 push_generated(
375 files,
376 dependencies,
377 cache,
378 output_path,
379 items_dependency,
380 || {
381 generate_item_file(key, item)
382 .with_context(|| format!("generating item file for {key}"))
383 },
384 )?;
385 }
386
387 push_generated(
388 files,
389 dependencies,
390 cache,
391 "items/mod.rs".into(),
392 items_dependency,
393 || {
394 generate_items_mod_file(&items_manifest)
395 .with_context(|| format!("generating items module from {}", items_path.display()))
396 },
397 )
398}
399
400fn push_generated(
401 files: &mut Vec<(String, String)>,
402 dependencies: &mut BTreeMap<String, Checksum>,
403 cache: Option<&CodegenCache>,
404 output_path: String,
405 dependency: Checksum,
406 generate: impl FnOnce() -> anyhow::Result<String>,
407) -> anyhow::Result<()> {
408 let contents = cache
409 .map(|cache| cache.reuse(&output_path, dependency))
410 .transpose()?
411 .flatten()
412 .map_or_else(generate, Ok)?;
413
414 push_output(files, dependencies, output_path, dependency, contents)
415}
416
417fn push_output(
418 files: &mut Vec<(String, String)>,
419 dependencies: &mut BTreeMap<String, Checksum>,
420 output_path: String,
421 dependency: Checksum,
422 contents: String,
423) -> anyhow::Result<()> {
424 if dependencies
425 .insert(output_path.clone(), dependency)
426 .is_some()
427 {
428 bail!("generator produced duplicate output path {output_path}");
429 }
430
431 files.push((output_path, contents));
432
433 Ok(())
434}