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