Skip to main content

wesl/
toml_cfg.rs

1//! `wesl.toml` configuration parsing and file scanning.
2//!
3//! This module handles reading wesl.toml configuration files and building
4//! module hierarchies from glob patterns.
5//!
6//! ```toml
7//! # Version of WESL used in this project.
8//! edition = "2026_pre"
9//!
10//! # Optional, can be auto-inferred from the existence of a package manager file.
11//! # Inclusion of this field is encouraged.
12//! package-manager = "npm"
13//!
14//! # Where are the shaders located. This is the path of `package::`.
15//! root = "./shaders"
16//!
17//! # Optional
18//! include = [ "shaders/**/*.wesl", "shaders/**/*.wgsl" ]
19//!
20//! # Optional.
21//! # Some projects have large folders that we shouldn't react to.
22//! exclude = [ "**/test" ]
23//!
24//! # Lists all used dependencies
25//! [dependencies]
26//! # Shorthand for `foolib = { package = "foolib" }`
27//! foolib = {}
28//! # Can be used for renaming packages. Now bevy in my code is called "cute_bevy".
29//! cute_bevy = { package = "bevy" }
30//! # File path to a folder with a wesl.toml. Simplest kind of dependency.
31//! mylib = { path = "../mylib" }
32//! ```
33
34//!
35
36use std::{
37    collections::{HashMap, HashSet},
38    path::{Path, PathBuf},
39};
40
41use serde::{Deserialize, Serialize};
42
43use crate::{
44    error::TomlError,
45    package::{PackageModule, RESERVED_MOD_NAMES, is_mod_ident},
46};
47
48/// Parsed wesl.toml configuration.
49#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct WeslToml {
51    /// Package configuration.
52    #[serde(flatten)]
53    pub package: PackageConfig,
54    /// The `[dependencies]` section.
55    #[serde(default)]
56    pub dependencies: DependenciesConfig,
57}
58
59/// Package configuration fields.
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct PackageConfig {
62    /// WESL edition (required).
63    pub edition: WeslEdition,
64    /// Package manager: "npm" or "cargo". Auto-detected if not specified.
65    // TODO: auto-detect is not implemented, it just defaults to cargo.
66    #[serde(default)]
67    pub package_manager: PackageManager,
68    /// Package root directory for package:: syntax. Default: "./shaders/"
69    #[serde(default = "default_root")]
70    pub root: PathBuf,
71    /// Glob patterns for files to include. Default: all .wesl/.wgsl in root.
72    #[serde(default = "default_include")]
73    pub include: Vec<String>,
74    /// Glob patterns for files to exclude. Default: empty.
75    #[serde(default = "default_exclude")]
76    pub exclude: Vec<String>,
77}
78
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
80#[serde(rename = "snake_case")]
81pub enum WeslEdition {
82    #[serde(rename = "2026_pre")]
83    Unstable2026,
84}
85
86#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum PackageManager {
89    // TODO: the spec says: "can be inferred from the existence of certain files"
90    #[default]
91    Cargo,
92    Npm,
93}
94
95fn default_root() -> PathBuf {
96    PathBuf::from("./shaders/")
97}
98fn default_include() -> Vec<String> {
99    vec!["**/*.wesl".to_string(), "**/*.wgsl".to_string()]
100}
101fn default_exclude() -> Vec<String> {
102    vec!["**/node_modules/".to_string()]
103}
104
105/// The `[dependencies]` section of wesl.toml.
106#[derive(Clone, Debug, Default, Serialize, Deserialize)]
107#[serde(
108    untagged,
109    try_from = "DependenciesConfigProxy",
110    into = "DependenciesConfigProxy"
111)]
112pub enum DependenciesConfig {
113    /// No dependencies specified.
114    #[default]
115    None,
116    /// Automatic dependency detection: `dependencies = "auto"`
117    Auto,
118    /// Explicit dependency table.
119    Manual(HashMap<String, DependencySpec>),
120}
121
122/// Intermediate data type for serialization / deserialization
123#[derive(Serialize, Deserialize)]
124#[serde(untagged)]
125enum DependenciesConfigProxy {
126    None,
127    Auto(String),
128    Manual(HashMap<String, DependencySpec>),
129}
130
131impl TryFrom<DependenciesConfigProxy> for DependenciesConfig {
132    type Error = TomlError;
133
134    fn try_from(cfg: DependenciesConfigProxy) -> Result<Self, Self::Error> {
135        match cfg {
136            DependenciesConfigProxy::None => Ok(DependenciesConfig::None),
137            DependenciesConfigProxy::Auto(s) if s == "auto" => Ok(DependenciesConfig::Auto),
138            DependenciesConfigProxy::Auto(_) => Err(TomlError::ExpectedAuto),
139            DependenciesConfigProxy::Manual(map) => Ok(DependenciesConfig::Manual(map)),
140        }
141    }
142}
143
144impl From<DependenciesConfig> for DependenciesConfigProxy {
145    fn from(dep: DependenciesConfig) -> Self {
146        match dep {
147            DependenciesConfig::None => DependenciesConfigProxy::None,
148            DependenciesConfig::Auto => DependenciesConfigProxy::Auto("auto".into()),
149            DependenciesConfig::Manual(map) => DependenciesConfigProxy::Manual(map),
150        }
151    }
152}
153
154/// A single dependency specification.
155#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(
157    untagged,
158    try_from = "DependencySpecProxy",
159    into = "DependencySpecProxy"
160)]
161pub enum DependencySpec {
162    /// Auto: `mydep = { }`
163    Auto,
164    /// Package name (for renaming): `mydep = { package = "actual_name" }`
165    Package(String),
166    /// Local path: `mydep = { path = "../lib" }`
167    Path(String),
168}
169
170/// Intermediate data type for serialization / deserialization
171#[derive(Clone, Debug, Serialize, Deserialize)]
172#[serde(untagged)]
173pub enum DependencySpecProxy {
174    Package { package: String },
175    Path { path: String },
176    // note, this one has to come last so the other two take priority.
177    Auto {},
178}
179
180impl From<DependencySpecProxy> for DependencySpec {
181    fn from(cfg: DependencySpecProxy) -> Self {
182        match cfg {
183            DependencySpecProxy::Auto {} => DependencySpec::Auto,
184            DependencySpecProxy::Package { package } => DependencySpec::Package(package),
185            DependencySpecProxy::Path { path } => DependencySpec::Path(path),
186        }
187    }
188}
189
190impl From<DependencySpec> for DependencySpecProxy {
191    fn from(dep: DependencySpec) -> Self {
192        match dep {
193            DependencySpec::Auto => DependencySpecProxy::Auto {},
194            DependencySpec::Package(package) => DependencySpecProxy::Package { package },
195            DependencySpec::Path(path) => DependencySpecProxy::Path { path },
196        }
197    }
198}
199
200/// Warning emitted during file scanning (non-fatal).
201#[derive(Debug, Clone)]
202pub enum ScanWarning {
203    /// A path component is not a valid WGSL identifier; file was skipped.
204    InvalidIdentifier { component: String, file: PathBuf },
205    /// A path component is a reserved module name; file was skipped.
206    ReservedModName { name: String, file: PathBuf },
207}
208
209impl std::fmt::Display for ScanWarning {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            Self::InvalidIdentifier { component, file } => {
213                write!(
214                    f,
215                    "skipped file: component `{component}` is not a valid WGSL identifier in {file:?}"
216                )
217            }
218            Self::ReservedModName { name, file } => {
219                write!(
220                    f,
221                    "skipped file: module name `{name}` is reserved in {file:?}"
222                )
223            }
224        }
225    }
226}
227
228/// Result of scanning files, including any non-fatal warnings.
229#[derive(Debug)]
230pub struct ScanResult {
231    /// The package root module containing the scanned file hierarchy.
232    pub module: PackageModule,
233    /// Warnings encountered during scanning.
234    pub warnings: Vec<ScanWarning>,
235}
236
237impl WeslToml {
238    /// Parse a wesl.toml file from a path.
239    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TomlError> {
240        let content = std::fs::read_to_string(path.as_ref())?;
241        Self::parse_str(&content)
242    }
243
244    /// Parse a wesl.toml from string content.
245    pub fn parse_str(content: &str) -> Result<Self, TomlError> {
246        toml::from_str(content).map_err(TomlError::TomlParse)
247    }
248}
249
250/// Scan files based on a WeslToml and build a Module hierarchy.
251///
252/// This is the main entry point called by `PackageBuilder::scan_toml`.
253pub fn scan_from_config(
254    pkg_name: &str,
255    toml_dir: &Path,
256    config: &WeslToml,
257) -> Result<ScanResult, TomlError> {
258    let pkg_root_path = std::path::absolute(toml_dir.join(&config.package.root))?;
259
260    let include = compile_patterns(&config.package.include)?;
261    let exclude = compile_patterns(&config.package.exclude)?;
262
263    let matched_files = walk_directory(toml_dir, &pkg_root_path, &include, &exclude)?;
264
265    if matched_files.is_empty() {
266        return Err(TomlError::NoFilesMatched);
267    }
268
269    let (module, warnings) = build_module_hierarchy(pkg_name, &matched_files, &pkg_root_path)?;
270    Ok(ScanResult { module, warnings })
271}
272
273/// Compile glob pattern strings into `glob::Pattern` values.
274fn compile_patterns(patterns: &[String]) -> Result<Vec<glob::Pattern>, TomlError> {
275    patterns
276        .iter()
277        .map(|p| {
278            let stripped = p.strip_prefix("./").unwrap_or(p);
279            glob::Pattern::new(stripped).map_err(|e| TomlError::InvalidGlob(p.clone(), e))
280        })
281        .collect()
282}
283
284/// Walk `pkg_root_dir` recursively, collecting files that match any include pattern
285/// and no exclude pattern. Patterns are matched against paths relative to
286/// `toml_dir` (the directory containing wesl.toml). Directories containing
287/// their own `wesl.toml` are treated as separate packages and skipped entirely.
288fn walk_directory(
289    toml_dir: &Path,
290    pkg_root_dir: &Path,
291    include: &[glob::Pattern],
292    exclude: &[glob::Pattern],
293) -> Result<HashSet<PathBuf>, TomlError> {
294    let mut files = HashSet::new();
295    let mut stack = vec![pkg_root_dir.to_path_buf()];
296
297    while let Some(dir) = stack.pop() {
298        let entries = match std::fs::read_dir(&dir) {
299            Ok(rd) => rd,
300            Err(e) => return Err(TomlError::Io(e)),
301        };
302
303        for entry in entries {
304            let path = entry?.path();
305            let rel = path.strip_prefix(toml_dir).unwrap_or(&path);
306
307            if glob_match(exclude, rel) {
308                // skip excluded paths
309            } else if path.is_dir() {
310                let is_nested_pkg = path != toml_dir && path.join("wesl.toml").is_file();
311                if !is_nested_pkg {
312                    stack.push(path);
313                }
314            } else if path.is_file() && glob_match(include, rel) {
315                files.insert(path);
316            }
317        }
318    }
319
320    Ok(files)
321}
322
323/// Check if a path matches any of the given glob patterns.
324fn glob_match(patterns: &[glob::Pattern], path: &Path) -> bool {
325    let opts = glob::MatchOptions {
326        case_sensitive: true,
327        require_literal_separator: false,
328        require_literal_leading_dot: false,
329    };
330    patterns.iter().any(|pat| pat.matches_path_with(path, opts))
331}
332
333struct FileEntry {
334    path: PathBuf,
335    module_components: Vec<String>,
336}
337
338/// Build a Module hierarchy from a flat list of files.
339fn build_module_hierarchy(
340    pkg_name: &str,
341    files: &HashSet<PathBuf>,
342    pkg_root_path: &Path,
343) -> Result<(PackageModule, Vec<ScanWarning>), TomlError> {
344    let (entries, warnings) = derive_module_paths(files, pkg_root_path)?;
345    let module = build_module_tree(pkg_name, entries)?;
346    Ok((module, warnings))
347}
348
349/// Extract module path components from a relative file path.
350///
351/// Combines the parent directory components with the file stem.
352fn path_to_components(relative: &Path) -> Vec<String> {
353    let mut components = Vec::new();
354    if let Some(parent) = relative.parent() {
355        components.extend(parent.iter().map(|c| c.to_string_lossy().to_string()));
356    }
357    if let Some(stem) = relative.file_stem() {
358        components.push(stem.to_string_lossy().to_string());
359    }
360    components
361}
362
363/// Validate module path components.
364///
365/// Returns `None` if valid, or `Some(warning)` if the file should be skipped.
366fn validate_components(components: &[String], file_path: &Path) -> Option<ScanWarning> {
367    for comp in components {
368        if RESERVED_MOD_NAMES.contains(&comp.as_str()) {
369            return Some(ScanWarning::ReservedModName {
370                name: comp.clone(),
371                file: file_path.to_path_buf(),
372            });
373        }
374        if !is_mod_ident(comp) {
375            return Some(ScanWarning::InvalidIdentifier {
376                component: comp.clone(),
377                file: file_path.to_path_buf(),
378            });
379        }
380    }
381    None
382}
383
384/// Derive module path components from file paths by stripping the package root prefix.
385fn derive_module_paths(
386    files: &HashSet<PathBuf>,
387    pkg_root_dir: &Path,
388) -> Result<(Vec<FileEntry>, Vec<ScanWarning>), TomlError> {
389    let mut entries = Vec::new();
390    let mut warnings = Vec::new();
391    for file_path in files {
392        let relative = file_path.strip_prefix(pkg_root_dir).map_err(|_| {
393            TomlError::FileOutsideRoot(file_path.clone(), pkg_root_dir.to_path_buf())
394        })?;
395
396        let components = path_to_components(relative);
397
398        if !components.is_empty() {
399            if let Some(warning) = validate_components(&components, file_path) {
400                warnings.push(warning);
401            } else {
402                entries.push(FileEntry {
403                    path: file_path.clone(),
404                    module_components: components,
405                });
406            }
407        }
408    }
409
410    Ok((entries, warnings))
411}
412
413/// Intermediate tree node used while building the module hierarchy.
414///
415/// The `path` field tracks which file was assigned to detect conflicts
416/// (e.g., both `main.wesl` and `main.wgsl` mapping to the same module).
417struct ModuleNode {
418    path: Option<PathBuf>,
419    source: String,
420    children: HashMap<String, ModuleNode>,
421}
422
423impl ModuleNode {
424    fn new() -> Self {
425        Self {
426            path: None,
427            source: String::new(),
428            children: HashMap::new(),
429        }
430    }
431
432    fn into_module(self, name: String) -> PackageModule {
433        let submodules = self
434            .children
435            .into_iter()
436            .map(|(name, node)| node.into_module(name))
437            .collect();
438
439        PackageModule {
440            name,
441            source: self.source,
442            submodules,
443        }
444    }
445}
446
447/// Build a tree of Modules from flat file entries.
448fn build_module_tree(pkg_name: &str, entries: Vec<FileEntry>) -> Result<PackageModule, TomlError> {
449    let mut root = ModuleNode::new();
450
451    for entry in entries {
452        let Some((leaf, parents)) = entry.module_components.split_last() else {
453            continue;
454        };
455
456        // Traverse/create intermediate nodes for parent components
457        let mut current = &mut root;
458        for comp in parents {
459            current = current
460                .children
461                .entry(comp.clone())
462                .or_insert_with(ModuleNode::new);
463        }
464
465        // Create/update the leaf node for the actual module
466        let node = current
467            .children
468            .entry(leaf.clone())
469            .or_insert_with(ModuleNode::new);
470
471        if let Some(existing_path) = &node.path {
472            let module_name = entry.module_components.join("::");
473            return Err(TomlError::ConflictingFiles(
474                module_name,
475                vec![existing_path.clone(), entry.path.clone()],
476            ));
477        }
478
479        node.path = Some(entry.path.clone());
480        node.source = std::fs::read_to_string(&entry.path).map_err(TomlError::Io)?;
481    }
482
483    Ok(root.into_module(pkg_name.to_string()))
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::path::Path;
490
491    fn fixtures_dir() -> &'static Path {
492        Path::new(concat!(
493            env!("CARGO_MANIFEST_DIR"),
494            "/tests/fixtures/toml_cfg"
495        ))
496    }
497
498    #[test]
499    fn parse_example_toml() {
500        let toml_str = r#"
501            edition = "2026_pre"
502            package_manager = "npm"
503            root = "./shaders"
504            include = [ "shaders/**/*.wesl", "shaders/**/*.wgsl" ]
505            exclude = [ "**/test" ]
506
507            [dependencies]
508            foolib = {}
509            cute_bevy = { package = "bevy" }
510            mylib = { path = "../mylib" }
511
512            [package]
513        "#;
514
515        let parsed: WeslToml = toml::from_str(toml_str).unwrap();
516
517        assert_eq!(parsed.package.edition, WeslEdition::Unstable2026);
518        assert_eq!(parsed.package.package_manager, PackageManager::Npm);
519        assert_eq!(parsed.package.root, PathBuf::from("./shaders"));
520        assert!(
521            parsed
522                .package
523                .include
524                .contains(&"shaders/**/*.wesl".to_string())
525        );
526
527        match parsed.dependencies {
528            DependenciesConfig::Manual(deps) => {
529                assert!(matches!(deps.get("foolib").unwrap(), DependencySpec::Auto));
530                println!("x {:?}", deps);
531                println!("x {:?}", deps.get("cute_bevy").unwrap());
532                assert!(matches!(
533                    deps.get("cute_bevy").unwrap(),
534                    DependencySpec::Package(_)
535                ));
536                assert!(matches!(
537                    deps.get("mylib").unwrap(),
538                    DependencySpec::Path(_)
539                ));
540            }
541            _ => panic!("expected manual dependencies"),
542        }
543    }
544
545    #[test]
546    fn test_config_parsing() {
547        // Basic config with defaults
548        let basic = WeslToml::parse_str("edition = \"2026_pre\"").unwrap();
549        assert_eq!(basic.package.edition, WeslEdition::Unstable2026);
550        assert_eq!(basic.package.root, default_root());
551        assert_eq!(basic.package.include, default_include());
552        assert_eq!(basic.package.exclude, default_exclude());
553
554        // Config with custom root
555        let with_root = WeslToml::parse_str("edition = \"2026_pre\"\nroot = \"./src/\"").unwrap();
556        assert_eq!(with_root.package.edition, WeslEdition::Unstable2026);
557        assert_eq!(with_root.package.root, Path::new("./src/"));
558
559        // Explicit empty exclude overrides default
560        let no_exclude = WeslToml::parse_str("edition = \"2026_pre\"\nexclude = []").unwrap();
561        assert!(no_exclude.package.exclude.is_empty());
562
563        // Missing edition
564        let missing = WeslToml::parse_str("root = \"./shaders/\"");
565        assert!(matches!(missing, Err(TomlError::TomlParse(_))));
566
567        // Dependencies variants
568        let with_deps =
569            WeslToml::parse_str("edition = \"2026_pre\"\n\n[dependencies]\nfoo = {}").unwrap();
570        assert!(matches!(
571            with_deps.dependencies,
572            DependenciesConfig::Manual(_)
573        ));
574
575        let auto_deps =
576            WeslToml::parse_str("edition = \"2026_pre\"\ndependencies = \"auto\"").unwrap();
577        assert!(matches!(auto_deps.dependencies, DependenciesConfig::Auto));
578    }
579
580    #[test]
581    fn test_scan_from_config() {
582        let base = fixtures_dir().join("basic");
583        let config = WeslToml::parse_str("edition = \"2026_pre\"\nroot = \"./shaders/\"").unwrap();
584        let result = scan_from_config("my_pkg", &base, &config).unwrap();
585
586        assert_eq!(result.module.name, "my_pkg");
587        assert_eq!(result.module.submodules.len(), 2);
588        assert!(result.warnings.is_empty());
589
590        let main_mod = result
591            .module
592            .submodules
593            .iter()
594            .find(|m| m.name == "main")
595            .unwrap();
596        assert_eq!(main_mod.source.trim(), "// main");
597
598        let utils = result
599            .module
600            .submodules
601            .iter()
602            .find(|m| m.name == "utils")
603            .unwrap();
604        assert_eq!(utils.submodules[0].name, "math");
605    }
606
607    #[test]
608    fn test_conflicting_files_error() {
609        let base = fixtures_dir().join("conflict");
610        let config = WeslToml::parse_str("edition = \"2026_pre\"\nroot = \"./shaders/\"").unwrap();
611        let result = scan_from_config("my_pkg", &base, &config);
612
613        assert!(matches!(result, Err(TomlError::ConflictingFiles(_, _))));
614    }
615
616    #[test]
617    fn test_exclude_directory() {
618        let base = fixtures_dir().join("exclude");
619        let config = WeslToml::parse_str(
620            r#"
621            edition = "2026_pre"
622            root = "./shaders/"
623            exclude = ["**/test"]
624            "#,
625        )
626        .unwrap();
627
628        let result = scan_from_config("my_pkg", &base, &config).unwrap();
629
630        // Should only have main, not the test/fixture
631        assert_eq!(result.module.submodules.len(), 1);
632        assert_eq!(result.module.submodules[0].name, "main");
633    }
634
635    #[test]
636    fn test_overlapping_patterns_deduplicated() {
637        // Overlapping patterns matching the same files should be deduplicated
638        let base = fixtures_dir().join("basic");
639        let config = WeslToml::parse_str(
640            r#"
641            edition = "2026_pre"
642            root = "./shaders/"
643            include = ["shaders/**/*.wesl", "shaders/**/*.wesl"]
644            "#,
645        )
646        .unwrap();
647        let result = scan_from_config("my_pkg", &base, &config).unwrap();
648
649        // Should still be 2 modules (main + utils), not duplicated
650        assert_eq!(result.module.submodules.len(), 2);
651    }
652
653    #[test]
654    fn test_nested_toml_cfg_excluded() {
655        // A subdirectory with its own wesl.toml should be excluded from scanning
656        let base = fixtures_dir().join("nested");
657        let config = WeslToml::parse_str(
658            r#"
659            edition = "2026_pre"
660            root = "./shaders/"
661            "#,
662        )
663        .unwrap();
664        let result = scan_from_config("my_pkg", &base, &config).unwrap();
665
666        // Should only contain `main`, not `subpkg/inner`
667        assert_eq!(result.module.submodules.len(), 1);
668        assert_eq!(result.module.submodules[0].name, "main");
669    }
670}