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}