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