Skip to main content

henad_build/
lib.rs

1//! Build-script support for Henad model crates.
2//!
3//! [`stamp_commit`] records the commit a crate was built from, a dirty flag and a hash of its sources, for
4//! `henad::build_info!` to read. [`ShaderBuild`] generates the Rust bindings of a crate's WGSL shaders, from its build
5//! script. Each shader is composed with the shared modules it reaches through `#import henad::<module>`, whose text
6//! comes from henad-core's [`SHARED_WGSL_MODULES`]. `henad::include_shaders!` then brings the two generated files
7//! into the crate, as the modules `shader_bindings` and `binding_decls`.
8//!
9//! ```no_run
10//! // build.rs
11//! fn main() -> Result<(), Box<dyn std::error::Error>> {
12//!     henad_build::stamp_commit();
13//!     henad_build::ShaderBuild::discover("src")?.generate()?;
14//!     Ok(())
15//! }
16//! ```
17//!
18//! A crate without shaders keeps the build script for its stamp, and drops the `ShaderBuild` line.
19//!
20//! # Names
21//!
22//! A shader's path below the shader root becomes three Rust names. `gpu_vote/step.wgsl` gives the module
23//! `shader_bindings::gpu_vote::step`, the `ShaderEntry` variant `GpuVoteStep` and the binding constant
24//! `binding_decls::bindings::GPU_VOTE_STEP`. The constant is the path's components upper-cased and joined by `_`, with
25//! the final `.wgsl` removed, so `my_wgsl/step.wgsl` gives `MY_WGSL_STEP`. Every component of a `.wgsl` file's path is
26//! therefore a Rust identifier and not a keyword.
27//!
28//! A file with a `#define_import_path` line is a module other shaders import, and never an entry point. An import
29//! resolves by file path alone. A module's import path mirrors its path below the shader root, or below the directory
30//! of the file that imports it, as `#define_import_path gpu_vote::state` in `gpu_vote/state.wgsl`. The bindings refer
31//! to an imported module by the import path it resolved through, and to a quoted import by the stem of its file name,
32//! as `#import "std.inc"` gives the module `std`.
33//!
34//! The root `henad` is reserved for the shared modules, regardless of case. A file named `henad.wgsl` or a directory
35//! named `henad` holding a `.wgsl` file would shadow them, and both are rejected. So is a `.wgsl` path whose first
36//! component is a name the generated bindings use at their root: `wgpu`, `bytemuck`, `std`, `core`, `alloc`, `_root`,
37//! `ShaderEntry`, `layout_asserts` or `bytemuck_impls`. An import is rejected too when its module path in the bindings
38//! starts with `henad` or one of those names.
39//!
40//! Note that each struct's layout assertion is named after the struct's module path and name in upper snake case.
41//! `gpu_vote::step::TallyParams` and `gpu_vote::step_tally::Params` both give
42//! `GPU_VOTE_STEP_TALLY_PARAMS_ASSERTS`, and rustc reports the two inside the generated file. Rename one of the
43//! structs.
44//!
45//! # Bindings
46//!
47//! `binding_decls::bindings` holds each entry point's `@group(0)` declarations, in `@binding` order, read from lines
48//! of one form: `@group(0) @binding(N) var<...> name: Type;`. A line holding `@binding` or `@group` in any other form
49//! fails the build. A compile-time assertion checks each list against the length of the layout `wgsl_bindgen`
50//! derives. A module declares no bindings, and a line holding `@binding` in a module fails the build too.
51//!
52//! # Versions
53//!
54//! henad-build pins `wgsl_bindgen` to 0.23.3 and fixes the code it generates. Cargo keeps one release per
55//! semver-compatible range in a lockfile. A build script that runs `wgsl_bindgen` itself therefore uses 0.23.3 as
56//! well. Use the same 0.x of henad and henad-build. `include_shaders!` fails the build when the shaders were composed
57//! against different shared WGSL than the linked henad provides.
58//!
59//! [`SHARED_WGSL_MODULES`]: henad_core::authoring::primitives::wgsl::SHARED_WGSL_MODULES
60
61#![cfg_attr(docsrs, feature(doc_cfg))]
62#![warn(missing_docs)]
63
64mod binding_lines;
65mod output;
66mod paths;
67mod stamp;
68
69#[cfg(test)]
70mod tests;
71
72use std::fmt;
73use std::path::{Path, PathBuf};
74
75/// Stamps the crate whose build script calls it with its commit, a dirty flag and a hash of its sources.
76///
77/// Sets `HENAD_BUILD_COMMIT`, `HENAD_BUILD_COMMIT_DATE`, `HENAD_BUILD_DIRTY` and `HENAD_BUILD_SOURCE_HASH`, and has
78/// Cargo rerun the script when any of them can change. `henad::build_info!` reads all four variables.
79///
80/// The commit comes from the package's `.cargo_vcs_info.json`, as in a registry download, or else from git when git
81/// tracks the crate's `Cargo.toml`. Otherwise the commit stays empty and the dirty flag unknown. The source hash is
82/// computed in every case, and tells two builds apart where no commit can: an uncommitted edit, or a project not
83/// under git. The dirty flag is unknown instead of clean when git does not track the lockfile, because no commit
84/// then records it.
85///
86/// A commit reruns the script once the crate sits in a git repository, even before git tracks the crate. A crate
87/// built before `git init` keeps an empty commit until a file under `src` or the manifest changes, or
88/// `cargo clean -p <package>` runs.
89///
90/// Note that the dirty flag and the source hash cover the files under `src`, the manifest and, outside a package, the
91/// nearest `Cargo.lock`. Dotfiles, editor backups and the `.orig` and `.rej` files that a merge or a patch leaves
92/// behind are excluded. A file a model reads at compile time, through `include_bytes!` or `include_str!`, belongs
93/// under `src` for the stamp to see it. A symlink to a directory is not followed. A shader that [`ShaderBuild`]
94/// compiles from a linked directory, or imports from outside `src`, changes neither the dirty flag nor the source
95/// hash. No stamp records data that a model reads from a path at run time.
96pub fn stamp_commit() {
97    stamp::print(stamp::StampScope::Commit);
98}
99
100/// Sets the stamp of [`stamp_commit`] for henad-explore, whose stamp represents the engine.
101///
102/// The commit and the dirty flag come from git only inside Henad's own tree, where the dirty flag and the source hash
103/// cover henad-core, henad-build, henad-compute and henad-explore with the workspace's lockfile. Also sets
104/// `HENAD_BUILD_CRATE_HASH`, the hash of henad-explore's own `src` and manifest, and `HENAD_BUILD_STAMP_VERSION`,
105/// henad-build's own version.
106#[doc(hidden)]
107pub fn stamp_engine_commit() {
108    stamp::print(stamp::StampScope::Engine);
109}
110
111/// Sets the source hash over the crate's own `src` and manifest, and the commit only from a `.cargo_vcs_info.json`.
112///
113/// Watches no git path and no lockfile. henad-compute and henad-models call it.
114#[doc(hidden)]
115pub fn stamp_source_hash() {
116    stamp::print(stamp::StampScope::SourceHash);
117}
118
119/// One crate's shader-binding generation, run from its build script.
120#[derive(Debug, Clone)]
121pub struct ShaderBuild {
122    root: PathBuf,
123    /// Entry points, relative to `root` or absolute.
124    entries: Vec<PathBuf>,
125    /// Whether the entry points were added one by one through [`Self::entry_point`].
126    explicit: bool,
127}
128
129impl ShaderBuild {
130    /// Returns a build of every `.wgsl` file under `shader_root` without a `#define_import_path` line.
131    ///
132    /// A symlink to a directory is followed, and a directory reached twice is walked once. Note that the build stamp of
133    /// [`stamp_commit`] follows no such link, and an edit to a shader in a linked directory changes no stamp.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`ShaderBuildError`] that lists the files. Files that are not `.wgsl` are never checked.
138    ///
139    /// - [`ShaderBuildError::InvalidName`] for a `.wgsl` path below the root with a component that is not a Rust
140    ///   identifier or is a keyword, and for an entry point whose `ShaderEntry` variant is not a Rust identifier
141    ///   (`self_.wgsl` gives `Self`).
142    /// - [`ShaderBuildError::NameCollision`] for two entry points whose module paths, `ShaderEntry` variants or
143    ///   binding constants collide, and for an entry point whose module contains another entry point's module.
144    /// - [`ShaderBuildError::ReservedName`] for a file named `henad.wgsl` or a directory named `henad` holding a
145    ///   `.wgsl` file at any depth under the root, for a `.wgsl` path starting with a name the generated bindings use,
146    ///   and for a root whose last component is `henad`.
147    /// - [`ShaderBuildError::Io`] for a file or directory that cannot be read.
148    pub fn discover(shader_root: impl AsRef<Path>) -> Result<Self, ShaderBuildError> {
149        let root = shader_root.as_ref().to_path_buf();
150        paths::check_root(&root)?;
151        let files = paths::wgsl_files(&root)?;
152        paths::check_reserved(&root, &files)?;
153        let mut entries = Vec::new();
154        for file in &files {
155            paths::check_components(&root, file)?;
156            let path = root.join(file);
157            let source = read(&path)?;
158            if !paths::is_module(&source) {
159                entries.push(file.clone());
160            }
161        }
162        paths::check_collisions(&root, &entries)?;
163        Ok(Self {
164            root,
165            entries,
166            explicit: false,
167        })
168    }
169
170    /// Returns a build with no entry points until [`Self::entry_point`] adds them.
171    pub fn new(shader_root: impl AsRef<Path>) -> Self {
172        Self {
173            root: shader_root.as_ref().to_path_buf(),
174            entries: Vec::new(),
175            explicit: true,
176        }
177    }
178
179    /// Adds the entry point `path`, relative to the shader root.
180    ///
181    /// Note that the path is checked when [`Self::generate`] runs, in the same way as a discovered entry point: it lies
182    /// under the root, and each of its components is a Rust identifier and not a keyword.
183    pub fn entry_point(mut self, path: impl AsRef<Path>) -> Self {
184        self.entries.push(path.as_ref().to_path_buf());
185        self
186    }
187
188    /// Writes `shader_bindings.rs` and `binding_decls.rs` to `OUT_DIR`, each only when its bytes change.
189    ///
190    /// Prints a `cargo:rerun-if-changed` line for the shader root, which Cargo watches recursively, one for each entry
191    /// point [`Self::entry_point`] added, and one for each file outside the root that an entry point imports. No line
192    /// refers to a path under `OUT_DIR`.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`ShaderBuildError`] in these cases:
197    ///
198    /// - [`ShaderBuildError::Environment`] when `OUT_DIR` is not set, and [`ShaderBuildError::Io`] for a file that
199    ///   cannot be read or written.
200    /// - [`ShaderBuildError::ReservedName`] for a shader root whose last component is `henad` or a reserved name
201    ///   under the root, and [`ShaderBuildError::ReservedImport`] for a module imported under a reserved name.
202    /// - [`ShaderBuildError::OutsideRoot`] for an entry point outside the root, and [`ShaderBuildError::InvalidName`]
203    ///   or [`ShaderBuildError::NameCollision`] for an entry point whose path gives no Rust name or repeats another
204    ///   entry's name.
205    /// - [`ShaderBuildError::BindingLine`], [`ShaderBuildError::UnsupportedBinding`],
206    ///   [`ShaderBuildError::ModuleBinding`] and [`ShaderBuildError::BindingGap`] for bindings that the reader rejects.
207    /// - [`ShaderBuildError::Compose`] for a shader that does not compose.
208    #[expect(clippy::print_stdout, reason = "a build script talks to Cargo through stdout")]
209    pub fn generate(self) -> Result<(), ShaderBuildError> {
210        let out_dir = std::env::var_os("OUT_DIR").ok_or(ShaderBuildError::Environment { variable: "OUT_DIR" })?;
211        let report = self.generate_in(Path::new(&out_dir))?;
212        for path in &report.watched {
213            // Each line uses the single-colon form. The double-colon form needs Cargo 1.77 and would raise every
214            // downstream crate's MSRV.
215            println!("cargo:rerun-if-changed={}", path.display());
216        }
217        for warning in &report.warnings {
218            println!("cargo:warning={warning}");
219        }
220        Ok(())
221    }
222
223    /// Writes both files to `out_dir`, and returns the paths for Cargo to watch and the warnings to print.
224    fn generate_in(self, out_dir: &Path) -> Result<Report, ShaderBuildError> {
225        let root = std::path::absolute(&self.root).map_err(|source| ShaderBuildError::Io {
226            path: self.root.clone(),
227            source,
228        })?;
229        paths::check_root(&root)?;
230        let files = paths::wgsl_files(&root)?;
231        paths::check_reserved(&root, &files)?;
232
233        let mut entries = Vec::with_capacity(self.entries.len());
234        for entry in &self.entries {
235            let relative = if entry.is_absolute() {
236                let Ok(relative) = entry.strip_prefix(&root) else {
237                    return Err(ShaderBuildError::OutsideRoot { path: entry.clone() });
238                };
239                relative.to_path_buf()
240            } else {
241                entry.clone()
242            };
243            paths::check_components(&root, &relative)?;
244            entries.push(relative);
245        }
246        paths::check_collisions(&root, &entries)?;
247
248        let mut sources = Vec::with_capacity(files.len());
249        let mut warnings = Vec::new();
250        for file in &files {
251            let path = root.join(file);
252            let source = read(&path)?;
253            if paths::defines_reserved_path(&source) {
254                warnings.push(format!(
255                    "{} declares an import path under `henad::`, a root reserved for the shared modules. An import \
256                     resolves by file path, and never reads this line.",
257                    path.display()
258                ));
259            }
260            if paths::is_module(&source) && !entries.contains(file) {
261                binding_lines::refuse_bindings(&path, &source)?;
262            }
263            sources.push((file.clone(), source));
264        }
265
266        let generated = output::generate(&root, &entries, &sources, out_dir)?;
267
268        let mut watched = vec![root.clone()];
269        if self.explicit {
270            watched.extend(entries.iter().map(|entry| root.join(entry)));
271        }
272        watched.extend(generated.outside_root);
273        Ok(Report {
274            watched,
275            warnings,
276            bound: generated.bound,
277        })
278    }
279}
280
281/// Result of a generation: the paths for Cargo to watch, the warnings to print, and whether the `wgsl_bindgen` pass
282/// ran.
283#[derive(Debug)]
284struct Report {
285    watched: Vec<PathBuf>,
286    warnings: Vec<String>,
287    #[cfg_attr(not(test), expect(dead_code, reason = "only the tests ask whether the pass ran"))]
288    bound: bool,
289}
290
291/// Returns the text of `path`.
292fn read(path: &Path) -> Result<String, ShaderBuildError> {
293    std::fs::read_to_string(path).map_err(|source| ShaderBuildError::Io {
294        path: path.to_path_buf(),
295        source,
296    })
297}
298
299/// Reason a shader build fails.
300///
301/// `Debug` writes the same text as `Display`, so a build script that returns the error shows its guidance.
302#[non_exhaustive]
303pub enum ShaderBuildError {
304    /// A component of a `.wgsl` file's path below the shader root is not a Rust identifier, or is a keyword.
305    ///
306    /// The same error reports an entry point whose `ShaderEntry` variant is not a Rust identifier, as `self_.wgsl`
307    /// gives `Self`.
308    InvalidName {
309        /// Shader file.
310        path: PathBuf,
311        /// Path component or `ShaderEntry` variant that was rejected, or the whole relative path when one of its
312        /// components is `.`, `..` or not valid Unicode.
313        component: String,
314    },
315    /// Two shaders give the same Rust name, or one shader's module contains another shader's module.
316    NameCollision {
317        /// Earlier shader of the two, in entry order.
318        first: PathBuf,
319        /// Later shader of the two.
320        second: PathBuf,
321        /// Kind of the name: `module`, `variant` for a `ShaderEntry` variant, or `constant` for a binding constant.
322        kind: &'static str,
323        /// Name the two shaders share.
324        name: String,
325    },
326    /// A shader path or root that uses a reserved name.
327    ///
328    /// The name `henad` is reserved regardless of case, for the last component of the shader root and for every
329    /// component of a `.wgsl` file's path below it. A name the generated bindings use, such as `wgpu` or `_root`, is
330    /// reserved for the first component of a `.wgsl` path.
331    ReservedName {
332        /// Shader root, or the shader file whose path uses the name.
333        path: PathBuf,
334        /// Reserved name, `henad` in lower case or a name the generated bindings use.
335        name: String,
336    },
337    /// A file imported through an import path that makes it the module `name` at the root of the generated
338    /// bindings, where `name` is `henad` in any letter case or a name the generated bindings use.
339    ///
340    /// The bindings name a quoted import after the stem of its file name. `#import "std.inc"` gives the module `std`.
341    ReservedImport {
342        /// Imported file.
343        path: PathBuf,
344        /// Import path that reached the file, with its quotes when quoted.
345        import_path: String,
346        /// First component of the module path the bindings give the file.
347        name: String,
348    },
349    /// An entry point given to [`ShaderBuild::entry_point`] lies outside the shader root.
350    OutsideRoot {
351        /// Absolute path of the entry point, as given.
352        path: PathBuf,
353    },
354    /// A line holding `@binding` or `@group` in a form that the binding parser does not accept.
355    BindingLine {
356        /// Entry point the line belongs to.
357        path: PathBuf,
358        /// Line number, counted from 1.
359        line: usize,
360        /// Text of the line, trimmed and without its comments.
361        text: String,
362        /// Reason the line is rejected, as in "`var<` has no closing `>`".
363        reason: &'static str,
364    },
365    /// A `@group(0)` binding of a kind no Henad pass binds: a sampler, a sampled texture, or an address space other
366    /// than `uniform`, `storage, read` and `storage, read_write`.
367    UnsupportedBinding {
368        /// Entry point the line belongs to.
369        path: PathBuf,
370        /// Line number, counted from 1.
371        line: usize,
372        /// Text of the line, trimmed and without its comments.
373        text: String,
374        /// Kind of the binding, as in "a sampled texture or a sampler".
375        reason: &'static str,
376    },
377    /// A file that is not an entry point declares a binding on the given line.
378    ///
379    /// Such a file is a module, with a `#define_import_path` line, or another file a shader imports.
380    ModuleBinding {
381        /// Module or imported file.
382        path: PathBuf,
383        /// Line number, counted from 1.
384        line: usize,
385        /// Text of the line, trimmed and without its comments.
386        text: String,
387    },
388    /// The `@group(0)` indices of a shader, sorted, do not run from 0 with no gap or repeat.
389    BindingGap {
390        /// Entry point the indices belong to.
391        path: PathBuf,
392        /// `@binding` indices of the shader's `@group(0)` lines, sorted.
393        indices: Vec<u32>,
394    },
395    /// `wgsl_bindgen` could not compose the shaders or generate their bindings.
396    Compose {
397        /// Message of the `wgsl_bindgen` error or panic, or a message that lists the files of an import cycle.
398        message: String,
399    },
400    /// A file or directory could not be read or written.
401    Io {
402        /// File or directory the operation failed on.
403        path: PathBuf,
404        /// Error from the operating system.
405        source: std::io::Error,
406    },
407    /// Cargo did not set a variable a build script reads.
408    Environment {
409        /// Name of the variable, as in `OUT_DIR`.
410        variable: &'static str,
411    },
412}
413
414impl fmt::Display for ShaderBuildError {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        match self {
417            Self::InvalidName { path, component } => {
418                if component.is_empty() {
419                    write!(f, "{}: the path gives an empty Rust name. ", path.display())?;
420                } else {
421                    write!(
422                        f,
423                        "{}: `{component}` is not a Rust identifier, or is a keyword. ",
424                        path.display()
425                    )?;
426                }
427                f.write_str(
428                    "Each component of a shader's path below the shader root becomes part of a Rust name, and the \
429                     components joined in PascalCase name the shader's `ShaderEntry` variant",
430                )
431            }
432            Self::NameCollision {
433                first,
434                second,
435                kind,
436                name,
437            } => write!(
438                f,
439                "{} and {} both give the {kind} `{name}`",
440                first.display(),
441                second.display()
442            ),
443            Self::ReservedName { path, name } if name.eq_ignore_ascii_case("henad") => write!(
444                f,
445                "{}: the name `henad`, in any case, is reserved for the shared modules, and a shader file or \
446                 directory of that name would shadow them",
447                path.display()
448            ),
449            Self::ReservedName { path, name } => write!(
450                f,
451                "{}: the generated bindings use the name `{name}` at their root, and a shader path starting with it \
452                 would shadow it",
453                path.display()
454            ),
455            Self::ReservedImport {
456                path,
457                import_path,
458                name,
459            } => {
460                write!(
461                    f,
462                    "{}: the import path `{import_path}` makes this file the module `{name}`. ",
463                    path.display()
464                )?;
465                if name.eq_ignore_ascii_case("henad") {
466                    f.write_str("The name `henad`, in any case, is reserved for the shared modules. ")?;
467                } else {
468                    f.write_str("The generated bindings use that name at their root. ")?;
469                }
470                f.write_str("Import the file under another path")
471            }
472            Self::OutsideRoot { path } => write!(f, "{}: an entry point lies outside the shader root", path.display()),
473            Self::BindingLine {
474                path,
475                line,
476                text,
477                reason,
478            } => write!(
479                f,
480                "{}:{line}: {reason} in `{text}`. Write each binding on one line, as \
481                 `@group(0) @binding(N) var<...> name: Type;`",
482                path.display()
483            ),
484            Self::UnsupportedBinding {
485                path,
486                line,
487                text,
488                reason,
489            } => write!(
490                f,
491                "{}:{line}: `{text}` binds {reason}. Group 0 of an entry point holds storage buffers, uniforms and \
492                 storage textures. Move a render shader out of the shader root, or name the entry points with \
493                 `ShaderBuild::new`",
494                path.display()
495            ),
496            Self::ModuleBinding { path, line, text } => write!(
497                f,
498                "{}:{line}: `{text}` declares a binding in a module another shader imports. Bindings belong in the \
499                 entry shader",
500                path.display()
501            ),
502            Self::BindingGap { path, indices } => write!(
503                f,
504                "{}: the @group(0) @binding indices {indices:?} do not run from 0 with no gap or repeat",
505                path.display()
506            ),
507            Self::Compose { message } => write!(f, "shader composition failed: {message}"),
508            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
509            Self::Environment { variable } => write!(f, "`{variable}` is not set. Call this from a build script"),
510        }
511    }
512}
513
514impl fmt::Debug for ShaderBuildError {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        fmt::Display::fmt(self, f)
517    }
518}
519
520impl std::error::Error for ShaderBuildError {
521    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
522        match self {
523            Self::Io { source, .. } => Some(source),
524            _ => None,
525        }
526    }
527}