#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
mod binding_lines;
mod output;
mod paths;
mod stamp;
#[cfg(test)]
mod tests;
use std::fmt;
use std::path::{Path, PathBuf};
pub fn stamp_commit() {
stamp::print(stamp::StampScope::Commit);
}
#[doc(hidden)]
pub fn stamp_engine_commit() {
stamp::print(stamp::StampScope::Engine);
}
#[doc(hidden)]
pub fn stamp_source_hash() {
stamp::print(stamp::StampScope::SourceHash);
}
#[derive(Debug, Clone)]
pub struct ShaderBuild {
root: PathBuf,
entries: Vec<PathBuf>,
explicit: bool,
}
impl ShaderBuild {
pub fn discover(shader_root: impl AsRef<Path>) -> Result<Self, ShaderBuildError> {
let root = shader_root.as_ref().to_path_buf();
paths::check_root(&root)?;
let files = paths::wgsl_files(&root)?;
paths::check_reserved(&root, &files)?;
let mut entries = Vec::new();
for file in &files {
paths::check_components(&root, file)?;
let path = root.join(file);
let source = read(&path)?;
if !paths::is_module(&source) {
entries.push(file.clone());
}
}
paths::check_collisions(&root, &entries)?;
Ok(Self {
root,
entries,
explicit: false,
})
}
pub fn new(shader_root: impl AsRef<Path>) -> Self {
Self {
root: shader_root.as_ref().to_path_buf(),
entries: Vec::new(),
explicit: true,
}
}
pub fn entry_point(mut self, path: impl AsRef<Path>) -> Self {
self.entries.push(path.as_ref().to_path_buf());
self
}
#[expect(clippy::print_stdout, reason = "a build script talks to Cargo through stdout")]
pub fn generate(self) -> Result<(), ShaderBuildError> {
let out_dir = std::env::var_os("OUT_DIR").ok_or(ShaderBuildError::Environment { variable: "OUT_DIR" })?;
let report = self.generate_in(Path::new(&out_dir))?;
for path in &report.watched {
println!("cargo:rerun-if-changed={}", path.display());
}
for warning in &report.warnings {
println!("cargo:warning={warning}");
}
Ok(())
}
fn generate_in(self, out_dir: &Path) -> Result<Report, ShaderBuildError> {
let root = std::path::absolute(&self.root).map_err(|source| ShaderBuildError::Io {
path: self.root.clone(),
source,
})?;
paths::check_root(&root)?;
let files = paths::wgsl_files(&root)?;
paths::check_reserved(&root, &files)?;
let mut entries = Vec::with_capacity(self.entries.len());
for entry in &self.entries {
let relative = if entry.is_absolute() {
let Ok(relative) = entry.strip_prefix(&root) else {
return Err(ShaderBuildError::OutsideRoot { path: entry.clone() });
};
relative.to_path_buf()
} else {
entry.clone()
};
paths::check_components(&root, &relative)?;
entries.push(relative);
}
paths::check_collisions(&root, &entries)?;
let mut sources = Vec::with_capacity(files.len());
let mut warnings = Vec::new();
for file in &files {
let path = root.join(file);
let source = read(&path)?;
if paths::defines_reserved_path(&source) {
warnings.push(format!(
"{} declares an import path under `henad::`, a root reserved for the shared modules. An import \
resolves by file path, and never reads this line.",
path.display()
));
}
if paths::is_module(&source) && !entries.contains(file) {
binding_lines::refuse_bindings(&path, &source)?;
}
sources.push((file.clone(), source));
}
let generated = output::generate(&root, &entries, &sources, out_dir)?;
let mut watched = vec![root.clone()];
if self.explicit {
watched.extend(entries.iter().map(|entry| root.join(entry)));
}
watched.extend(generated.outside_root);
Ok(Report {
watched,
warnings,
bound: generated.bound,
})
}
}
#[derive(Debug)]
struct Report {
watched: Vec<PathBuf>,
warnings: Vec<String>,
#[cfg_attr(not(test), expect(dead_code, reason = "only the tests ask whether the pass ran"))]
bound: bool,
}
fn read(path: &Path) -> Result<String, ShaderBuildError> {
std::fs::read_to_string(path).map_err(|source| ShaderBuildError::Io {
path: path.to_path_buf(),
source,
})
}
#[non_exhaustive]
pub enum ShaderBuildError {
InvalidName {
path: PathBuf,
component: String,
},
NameCollision {
first: PathBuf,
second: PathBuf,
kind: &'static str,
name: String,
},
ReservedName {
path: PathBuf,
name: String,
},
ReservedImport {
path: PathBuf,
import_path: String,
name: String,
},
OutsideRoot {
path: PathBuf,
},
BindingLine {
path: PathBuf,
line: usize,
text: String,
reason: &'static str,
},
UnsupportedBinding {
path: PathBuf,
line: usize,
text: String,
reason: &'static str,
},
ModuleBinding {
path: PathBuf,
line: usize,
text: String,
},
BindingGap {
path: PathBuf,
indices: Vec<u32>,
},
Compose {
message: String,
},
Io {
path: PathBuf,
source: std::io::Error,
},
Environment {
variable: &'static str,
},
}
impl fmt::Display for ShaderBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidName { path, component } => {
if component.is_empty() {
write!(f, "{}: the path gives an empty Rust name. ", path.display())?;
} else {
write!(
f,
"{}: `{component}` is not a Rust identifier, or is a keyword. ",
path.display()
)?;
}
f.write_str(
"Each component of a shader's path below the shader root becomes part of a Rust name, and the \
components joined in PascalCase name the shader's `ShaderEntry` variant",
)
}
Self::NameCollision {
first,
second,
kind,
name,
} => write!(
f,
"{} and {} both give the {kind} `{name}`",
first.display(),
second.display()
),
Self::ReservedName { path, name } if name.eq_ignore_ascii_case("henad") => write!(
f,
"{}: the name `henad`, in any case, is reserved for the shared modules, and a shader file or \
directory of that name would shadow them",
path.display()
),
Self::ReservedName { path, name } => write!(
f,
"{}: the generated bindings use the name `{name}` at their root, and a shader path starting with it \
would shadow it",
path.display()
),
Self::ReservedImport {
path,
import_path,
name,
} => {
write!(
f,
"{}: the import path `{import_path}` makes this file the module `{name}`. ",
path.display()
)?;
if name.eq_ignore_ascii_case("henad") {
f.write_str("The name `henad`, in any case, is reserved for the shared modules. ")?;
} else {
f.write_str("The generated bindings use that name at their root. ")?;
}
f.write_str("Import the file under another path")
}
Self::OutsideRoot { path } => write!(f, "{}: an entry point lies outside the shader root", path.display()),
Self::BindingLine {
path,
line,
text,
reason,
} => write!(
f,
"{}:{line}: {reason} in `{text}`. Write each binding on one line, as \
`@group(0) @binding(N) var<...> name: Type;`",
path.display()
),
Self::UnsupportedBinding {
path,
line,
text,
reason,
} => write!(
f,
"{}:{line}: `{text}` binds {reason}. Group 0 of an entry point holds storage buffers, uniforms and \
storage textures. Move a render shader out of the shader root, or name the entry points with \
`ShaderBuild::new`",
path.display()
),
Self::ModuleBinding { path, line, text } => write!(
f,
"{}:{line}: `{text}` declares a binding in a module another shader imports. Bindings belong in the \
entry shader",
path.display()
),
Self::BindingGap { path, indices } => write!(
f,
"{}: the @group(0) @binding indices {indices:?} do not run from 0 with no gap or repeat",
path.display()
),
Self::Compose { message } => write!(f, "shader composition failed: {message}"),
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::Environment { variable } => write!(f, "`{variable}` is not set. Call this from a build script"),
}
}
}
impl fmt::Debug for ShaderBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl std::error::Error for ShaderBuildError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}