use crate::manifest::{LoadedManifest, Pass};
use anyhow::{Context, Result, bail};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
const MAX_INCLUDE_DEPTH: usize = 64;
#[derive(Debug, Clone)]
pub struct ExpandedPassSource {
pub text: String,
pub dependencies: BTreeSet<PathBuf>,
}
pub type SourceGraph = BTreeMap<String, ExpandedPassSource>;
pub fn expand_all(loaded: &LoadedManifest) -> Result<SourceGraph> {
let mut graph = BTreeMap::new();
for pass in &loaded.manifest.passes {
graph.insert(pass.name.clone(), expand_pass(loaded, pass)?);
}
Ok(graph)
}
pub fn expand_pass(loaded: &LoadedManifest, pass: &Pass) -> Result<ExpandedPassSource> {
let source = checked_file(
&loaded.root,
&loaded.root.join(&pass.source),
"shader source",
)?;
let include_dirs = loaded
.manifest
.shader
.include_dirs
.iter()
.map(|dir| checked_dir(&loaded.root, &loaded.root.join(dir)))
.collect::<Result<Vec<_>>>()?;
let mut dependencies = BTreeSet::new();
let mut stack = Vec::new();
let expanded = expand_file(
&loaded.root,
&source,
&include_dirs,
&mut dependencies,
&mut stack,
0,
)
.with_context(|| format!("while expanding pass '{}'", pass.name))?;
let declarations = crate::uniforms::declarations(&loaded.manifest.uniforms);
let text = if declarations.is_empty() {
expanded
} else {
format!(
"{declarations}#line 1
{expanded}"
)
};
Ok(ExpandedPassSource { text, dependencies })
}
fn expand_file(
root: &Path,
path: &Path,
include_dirs: &[PathBuf],
dependencies: &mut BTreeSet<PathBuf>,
stack: &mut Vec<PathBuf>,
depth: usize,
) -> Result<String> {
if depth > MAX_INCLUDE_DEPTH {
bail!("GLSL include nesting exceeds {MAX_INCLUDE_DEPTH} levels");
}
let canonical = checked_file(root, path, "GLSL include")?;
if let Some(index) = stack.iter().position(|candidate| candidate == &canonical) {
let mut cycle = stack[index..]
.iter()
.map(|entry| display_relative(root, entry))
.collect::<Vec<_>>();
cycle.push(display_relative(root, &canonical));
bail!("GLSL include cycle: {}", cycle.join(" -> "));
}
dependencies.insert(canonical.clone());
stack.push(canonical.clone());
let source = fs::read_to_string(&canonical)
.with_context(|| format!("failed to read GLSL source {}", canonical.display()))?;
let mut expanded = String::with_capacity(source.len());
for (index, line) in source.lines().enumerate() {
let line_number = index + 1;
if let Some(include) = parse_include(line)? {
let resolved =
resolve_include(root, &canonical, include, include_dirs).with_context(|| {
format!(
"{}:{} includes {:?}",
display_relative(root, &canonical),
line_number,
include
)
})?;
expanded.push_str(&format!(
"// shadertoy-cli include begin: {}\n#line 1\n",
display_relative(root, &resolved)
));
expanded.push_str(&expand_file(
root,
&resolved,
include_dirs,
dependencies,
stack,
depth + 1,
)?);
if !expanded.ends_with('\n') {
expanded.push('\n');
}
expanded.push_str(&format!(
"// shadertoy-cli include end: {}\n#line {}\n",
display_relative(root, &resolved),
line_number + 1
));
} else {
expanded.push_str(line);
expanded.push('\n');
}
}
stack.pop();
Ok(expanded)
}
fn parse_include(line: &str) -> Result<Option<&str>> {
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
return Ok(None);
}
let directive = trimmed[1..].trim_start();
let Some(rest) = directive.strip_prefix("include") else {
return Ok(None);
};
if rest
.chars()
.next()
.is_some_and(|character| !character.is_whitespace() && character != '"')
{
return Ok(None);
}
let rest = rest.trim_start();
if !rest.starts_with('"') {
bail!("only quoted GLSL includes are supported; use #include \"path.glsl\"");
}
let Some(end) = rest[1..].find('"') else {
bail!("unterminated GLSL #include path");
};
let include = &rest[1..end + 1];
if include.is_empty() {
bail!("GLSL #include path must not be empty");
}
let trailing = rest[end + 2..].trim();
if !trailing.is_empty() && !trailing.starts_with("//") {
bail!("unexpected tokens after GLSL #include");
}
Ok(Some(include))
}
fn resolve_include(
root: &Path,
including_file: &Path,
include: &str,
include_dirs: &[PathBuf],
) -> Result<PathBuf> {
validate_include_path(include)?;
let relative = Path::new(include);
let mut candidates = Vec::with_capacity(include_dirs.len() + 1);
if let Some(parent) = including_file.parent() {
candidates.push(parent.join(relative));
}
candidates.extend(include_dirs.iter().map(|dir| dir.join(relative)));
for candidate in candidates {
if candidate.is_file() {
return checked_file(root, &candidate, "GLSL include");
}
}
bail!("GLSL include {:?} was not found", include)
}
fn validate_include_path(include: &str) -> Result<()> {
let normalized = include.replace('\\', "/");
let path = Path::new(&normalized);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
bail!("GLSL include path must stay within the project root");
}
Ok(())
}
fn checked_file(root: &Path, path: &Path, label: &str) -> Result<PathBuf> {
if !path.is_file() {
bail!("{label} does not exist at {}", path.display());
}
let canonical_root = fs::canonicalize(root)
.with_context(|| format!("failed to resolve project root {}", root.display()))?;
let canonical = fs::canonicalize(path)
.with_context(|| format!("failed to resolve {label} {}", path.display()))?;
if !canonical.starts_with(&canonical_root) {
bail!(
"{label} resolves outside the project root: {}",
path.display()
);
}
Ok(canonical)
}
fn checked_dir(root: &Path, path: &Path) -> Result<PathBuf> {
if !path.is_dir() {
bail!(
"shader include directory does not exist: {}",
path.display()
);
}
let canonical_root = fs::canonicalize(root)
.with_context(|| format!("failed to resolve project root {}", root.display()))?;
let canonical = fs::canonicalize(path)
.with_context(|| format!("failed to resolve include directory {}", path.display()))?;
if !canonical.starts_with(&canonical_root) {
bail!(
"shader include directory resolves outside the project root: {}",
path.display()
);
}
Ok(canonical)
}
fn display_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::LoadedManifest;
use tempfile::tempdir;
fn fixture() -> (tempfile::TempDir, LoadedManifest) {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("shaders/lib")).unwrap();
fs::write(
dir.path().join("ShaderToy.toml"),
r#"format = 1
[project]
name = "include-test"
[shader]
include_dirs = ["shaders/lib"]
[[pass]]
name = "image"
kind = "image"
source = "shaders/image.frag"
"#,
)
.unwrap();
let loaded = LoadedManifest::load(dir.path()).unwrap();
(dir, loaded)
}
#[test]
fn expands_nested_includes_and_tracks_dependencies() {
let (dir, loaded) = fixture();
fs::write(
dir.path().join("shaders/image.frag"),
"#include \"lib/common.glsl\"\nvoid mainImage(out vec4 c, in vec2 p){ c=foo(); }\n",
)
.unwrap();
fs::write(
dir.path().join("shaders/lib/common.glsl"),
"#include \"math.glsl\"\nvec4 foo(){return vec4(one());}\n",
)
.unwrap();
fs::write(
dir.path().join("shaders/lib/math.glsl"),
"float one(){return 1.0;}\n",
)
.unwrap();
let pass = &loaded.manifest.passes[0];
let expanded = expand_pass(&loaded, pass).unwrap();
assert!(expanded.text.contains("float one()"));
assert!(expanded.text.contains("vec4 foo()"));
assert_eq!(expanded.dependencies.len(), 3);
}
#[test]
fn rejects_include_cycles() {
let (dir, loaded) = fixture();
fs::write(
dir.path().join("shaders/image.frag"),
"#include \"lib/a.glsl\"\nvoid mainImage(out vec4 c, in vec2 p){c=vec4(1.0);}\n",
)
.unwrap();
fs::write(
dir.path().join("shaders/lib/a.glsl"),
"#include \"b.glsl\"\n",
)
.unwrap();
fs::write(
dir.path().join("shaders/lib/b.glsl"),
"#include \"a.glsl\"\n",
)
.unwrap();
let error = expand_pass(&loaded, &loaded.manifest.passes[0]).unwrap_err();
let error = format!("{error:#}");
assert!(error.contains("include cycle"));
}
}