Skip to main content

codegen/app/
generation.rs

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    // Previously successful, byte-identical specs reuse their verified artifacts. Every cache
163    // miss is composed and validated before any new Rust is emitted.
164    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}