1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct WeslToml {
51 #[serde(flatten)]
53 pub package: PackageConfig,
54 #[serde(default)]
56 pub dependencies: DependenciesConfig,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct PackageConfig {
62 pub edition: WeslEdition,
64 #[serde(default)]
67 pub package_manager: PackageManager,
68 #[serde(default = "default_root")]
70 pub root: PathBuf,
71 #[serde(default = "default_include")]
73 pub include: Vec<String>,
74 #[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 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
107#[serde(
108 untagged,
109 try_from = "DependenciesConfigProxy",
110 into = "DependenciesConfigProxy"
111)]
112pub enum DependenciesConfig {
113 #[default]
115 None,
116 Auto,
118 Manual(HashMap<String, DependencySpec>),
120}
121
122#[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#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(
157 untagged,
158 try_from = "DependencySpecProxy",
159 into = "DependencySpecProxy"
160)]
161pub enum DependencySpec {
162 Auto,
164 Package(String),
166 Path(String),
168}
169
170#[derive(Clone, Debug, Serialize, Deserialize)]
172#[serde(untagged)]
173pub enum DependencySpecProxy {
174 Package { package: String },
175 Path { path: String },
176 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#[derive(Debug, Clone)]
202pub enum ScanWarning {
203 InvalidIdentifier { component: String, file: PathBuf },
205 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#[derive(Debug)]
230pub struct ScanResult {
231 pub module: PackageModule,
233 pub warnings: Vec<ScanWarning>,
235}
236
237impl WeslToml {
238 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 pub fn parse_str(content: &str) -> Result<Self, TomlError> {
246 toml::from_str(content).map_err(TomlError::TomlParse)
247 }
248}
249
250pub 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
273fn 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
284fn 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 } 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
323fn 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
338fn 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
349fn 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
363fn 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
384fn 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
413struct 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
447fn 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 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 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 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 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 let no_exclude = WeslToml::parse_str("edition = \"2026_pre\"\nexclude = []").unwrap();
561 assert!(no_exclude.package.exclude.is_empty());
562
563 let missing = WeslToml::parse_str("root = \"./shaders/\"");
565 assert!(matches!(missing, Err(TomlError::TomlParse(_))));
566
567 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 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 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 assert_eq!(result.module.submodules.len(), 2);
651 }
652
653 #[test]
654 fn test_nested_toml_cfg_excluded() {
655 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 assert_eq!(result.module.submodules.len(), 1);
668 assert_eq!(result.module.submodules[0].name, "main");
669 }
670}