Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 74 additions & 46 deletions crates/wesl/src/wesl_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,12 @@ pub fn scan_from_config(
base_dir: &Path,
config: &WeslToml,
) -> Result<ScanResult, ScanTomlError> {
let root_path = &config.package.root;
let root_path = base_dir.join(root_path);
let root_path = std::path::absolute(base_dir.join(&config.package.root))?;

let matched_files = collect_glob_filtered(base_dir, &config.package.include, Path::is_file)?;
let exclude_paths = collect_glob_filtered(base_dir, &config.package.exclude, |_| true)?;
let include = compile_patterns(&config.package.include)?;
let exclude = compile_patterns(&config.package.exclude)?;

let matched_files: HashSet<_> = matched_files
.into_iter()
.filter(|file| !is_excluded(file, &exclude_paths))
.collect();
let matched_files = walk_directory(base_dir, &root_path, &include, &exclude)?;

if matched_files.is_empty() {
return Err(ScanTomlError::NoFilesMatched);
Expand All @@ -291,42 +287,64 @@ pub fn scan_from_config(
Ok(ScanResult { module, warnings })
}

/// Collect paths matching glob patterns, filtered by a predicate.
fn collect_glob_filtered(
/// Compile glob pattern strings into `glob::Pattern` values.
fn compile_patterns(patterns: &[String]) -> Result<Vec<glob::Pattern>, ScanTomlError> {
patterns
.iter()
.map(|p| {
let stripped = p.strip_prefix("./").unwrap_or(p);
glob::Pattern::new(stripped).map_err(|e| ScanTomlError::InvalidGlob(p.clone(), e))
})
.collect()
}

/// Walk `root_dir` recursively, collecting files that match any include pattern
/// and no exclude pattern. Patterns are matched against paths relative to
/// `base_dir` (the directory containing wesl.toml). Directories containing
/// their own `wesl.toml` are treated as separate packages and skipped entirely.
fn walk_directory(
base_dir: &Path,
patterns: &[String],
filter: impl Fn(&Path) -> bool,
root_dir: &Path,
include: &[glob::Pattern],
exclude: &[glob::Pattern],
) -> Result<HashSet<PathBuf>, ScanTomlError> {
let mut paths = HashSet::new();

for pattern in patterns {
// Join with base_dir so glob resolves relative to wesl.toml, not cwd
let pattern_path = pattern
.strip_prefix("./")
.map_or_else(|| base_dir.join(pattern), |s| base_dir.join(s));
let mut files = HashSet::new();
let mut stack = vec![root_dir.to_path_buf()];

let pattern_str = pattern_path.to_string_lossy();
let glob_iter =
glob::glob(&pattern_str).map_err(|e| ScanTomlError::InvalidGlob(pattern.clone(), e))?;
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) => return Err(ScanTomlError::Io(e)),
};

for path in glob_iter.flatten().filter(|p| filter(p)) {
let canonical = path.canonicalize().unwrap_or(path);
paths.insert(canonical);
for entry in entries {
let path = entry?.path();
let rel = path.strip_prefix(base_dir).unwrap_or(&path);

if glob_match(exclude, rel) {
// skip excluded paths
} else if path.is_dir() {
let is_nested_pkg = path != root_dir && path.join("wesl.toml").is_file();
if !is_nested_pkg {
stack.push(path);
}
} else if path.is_file() && glob_match(include, rel) {
files.insert(path);
}
}
}

Ok(paths)
Ok(files)
}

/// Check if a file should be excluded.
///
/// A file is excluded if it matches an exclude path directly,
/// or if it's inside an excluded directory.
fn is_excluded(file: &Path, exclude_paths: &HashSet<PathBuf>) -> bool {
exclude_paths.contains(file)
|| exclude_paths
.iter()
.any(|excl| excl.is_dir() && file.starts_with(excl))
/// Check if a path matches any of the given glob patterns.
fn glob_match(patterns: &[glob::Pattern], path: &Path) -> bool {
let opts = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: false,
require_literal_leading_dot: false,
};
patterns.iter().any(|pat| pat.matches_path_with(path, opts))
}

struct FileEntry {
Expand Down Expand Up @@ -385,18 +403,10 @@ fn derive_module_paths(
files: &HashSet<PathBuf>,
root_path: &Path,
) -> Result<(Vec<FileEntry>, Vec<ScanWarning>), ScanTomlError> {
let canonical_root = root_path
.canonicalize()
.unwrap_or_else(|_| root_path.to_path_buf());

let mut entries = Vec::new();
let mut warnings = Vec::new();
for file_path in files {
let canonical_file = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.clone());

let relative = canonical_file.strip_prefix(&canonical_root).map_err(|_| {
let relative = file_path.strip_prefix(root_path).map_err(|_| {
ScanTomlError::FileOutsideRoot(file_path.clone(), root_path.to_path_buf())
})?;

Expand Down Expand Up @@ -641,13 +651,13 @@ mod tests {

#[test]
fn test_overlapping_patterns_deduplicated() {
// Overlapping patterns that resolve to the same files should be deduplicated
// Overlapping patterns matching the same files should be deduplicated
let base = fixtures_dir().join("basic");
let config = WeslToml::parse_str(
r#"
edition = "2026_pre"
root = "./shaders/"
include = ["./shaders/**/*.wesl", "./shaders/../shaders/**/*.wesl"]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this no longer a valid test case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's always been a somewhat pathological user configuration, but it's still a valid test case..
The scanning approach fixes it architecturally though,
so it would be reasonable to remove the test case to remove a little test bulk.
preference?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to have at least one test to check that you can't access files outside of the project root using ... Maybe one too for valid uses of ... Or perhaps forbid .. entirely, as you said it's pathological.

include = ["shaders/**/*.wesl", "shaders/**/*.wesl"]
"#,
)
.unwrap();
Expand All @@ -656,4 +666,22 @@ mod tests {
// Should still be 2 modules (main + utils), not duplicated
assert_eq!(result.module.submodules.len(), 2);
}

#[test]
fn test_nested_wesl_toml_excluded() {
// A subdirectory with its own wesl.toml should be excluded from scanning
let base = fixtures_dir().join("nested");
let config = WeslToml::parse_str(
r#"
edition = "2026_pre"
root = "./shaders/"
"#,
)
.unwrap();
let result = scan_from_config("my_pkg", &base, &config).unwrap();

// Should only contain `main`, not `subpkg/inner`
assert_eq!(result.module.submodules.len(), 1);
assert_eq!(result.module.submodules[0].name, "main");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// main
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// inner - should be excluded by nested wesl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
edition = "2026_pre"
Loading