re_renderer/file_resolver.rs
1//! This module implements one half of our cross-platform #import system.
2//!
3//! The other half is provided as an extension to the build system, see the `build.rs` file
4//! at the root of this crate.
5//!
6//! While it is agnostic to the type of files being imported, in practice this is only used
7//! for shaders, thus this is what this documentation will linger on.
8//! In particular, integration with our hot-reloading capabilities can get tricky depending
9//! on the platform/target.
10//!
11//! ## Usage
12//!
13//! `#import <x/y/z/my_file.wgsl>`
14//!
15//! ### Syntax
16//!
17//! Import clauses follow the general form of `#import <x/y/z/my_file.wgsl>`.
18//! The path to be imported can be either absolute or relative to the path of the importer,
19//! or relative to any of the paths set in the search path (`RERUN_SHADER_PATH`).
20//!
21//! The actual parsing rules themselves are very barebones:
22//! - An import clause can only span one line.
23//! - An import clause line must start with `#import ` (exl. whitespaces).
24//! - Everything between the first `<` and the last `>` is interpreted as the import
25//! path, as-is. We do so because, between the 4 major platforms (Linux, macOS, Window, Web),
26//! basically any string is a valid path.
27//!
28//! Everything is `trim()`ed at every step, you do not need to worry about whitespaces.
29//!
30//! ### Resolution
31//!
32//! Resolution is done in three steps:
33//! 1. First, we try to interpret the imported path as absolute.
34//! 1.1. If this is possible and leads to an existing file, we're done.
35//! 1.2. Otherwise, we go to 2.
36//!
37//! 2. Second, we try to interpret the imported path as relative to the importer's.
38//! 2.1. If this leads to an existing file, we're done.
39//! 2.2. Otherwise, we go to 3.
40//!
41//! 3. Finally, we try to interpret the imported path as relative to all the directories
42//! present in the search path, in their prefined priority order, similar to e.g. how
43//! the standard `$PATH` environment variable behaves.
44//! 3.1. If this leads to an existing file, we're done.
45//! 3.2. Otherwise, resolution failed: throw an error.
46//!
47//! ### Interpolation
48//!
49//! Interpolation is done in the simplest way possible: the entire line containing the import
50//! clause is overwritten with the contents of the imported file.
51//! This is of course a recursive process.
52//!
53//! #### A word about `#pragma` semantics
54//!
55//! Imports can behave in two different ways: `#pragma once` and `#pragma many`.
56//!
57//! `#pragma once` means that each unique #import clause is only be resolved once even if it
58//! used several times, e.g. assuming that `a.txt` contains the string `"xyz"` then:
59//! ```raw
60//! #import <a.txt>
61//! #import <a.txt>
62//! ```
63//! becomes
64//! ```raw
65//! xyz
66//! ```
67//!
68//! `#pragma many` on the other hand will resolve the clause as many times as it is used:
69//! ```raw
70//! #import <a.txt>
71//! #import <a.txt>
72//! ```
73//! becomes
74//! ```raw
75//! xyz
76//! xyz
77//! ```
78//!
79//! At the moment, our import system only provides support for `#pragma once` semantics.
80//!
81//! ## Hot-reloading: platform specifics
82//!
83//! This import system transparently integrates with the renderer's hot-reloading capabilities.
84//! What that actually means in practice depends on the platform/target.
85//!
86//! A general over-simplification of what we're aiming for can be expressed as:
87//! > Be lazy in debug, be eager in release.
88//!
89//! When targeting native debug builds, we want everything to be as lazy as possible, everything
90//! to happen just-in-time, e.g.:
91//! - We always talk directly with the filesystem and check for missing files at the last moment.
92//! - We do resolution & interpolation just-in-time, e.g. just before calling
93//! `create_shader_module`.
94//! - Etc.
95//!
96//! On the web, we don't even have an actual filesystem to access at runtime, so not only we'd
97//! like to be as eager can be, we don't have much of a choice to begin with.
98//! That said, we don't want to be _too_ eager either: while we do have to make sure that every
99//! single shader that we're gonna use (whether directly or indirectly via an import) ends up
100//! in the final artifact one way or another, we still want to delay interpolation as much as
101//! we can, otherwise we'd be bloating the binary artifact with N copies of the exact same
102//! shader code.
103//!
104//! Still, we'd like to limit the number of differences between targets/platforms.
105//! And indeed, the current implementation uses a virtual filesystem approach to effectively
106//! remove any difference between how the different platforms behave at run-time.
107//!
108//! ### Debug builds (excl. web)
109//!
110//! Native debug builds are straightforward:
111//! - We handle resolution & interpolation just-in-time (i.e. when fetching file contents).
112//! - We always talk directly to the filesystem.
113//!
114//! No surprises there.
115//!
116//! ### Release builds (incl. web)
117//!
118//! Things are very different for release artifacts, as 1) we disable hot-reloading there and
119//! 2) we never interact with the OS filesystem at run-time.
120//! Still, in practice, we handle release builds just the same as debug ones.
121//!
122//! What happens there is we have a virtual, hermetic, in-memory filesystem that gets pre-loaded
123//! with all the shaders defined within the Cargo workspace.
124//! This happens in part through a build script that you can find at the root of this crate.
125//!
126//! From there, everything behaves exactly the same as usual. In fact, there is only one code
127//! path for all platforms at run-time.
128//!
129//! There are many issues to deal with along the way though: paths comparisons across
130//! environments and build-time/run-time, hermeticism, etc…
131//! We won't cover those here: please refer to the code if you're curious.
132//!
133//! ## For developers
134//!
135//! ### Canonicalization vs. Normalization
136//!
137//! Comparing paths can get tricky, especially when juggling target environments and
138//! run-time vs. compile-time constraints.
139//! For this reason you'll see plenty mentions of canonicalization and normalization all over
140//! the code: better make sure there's no confusion here.
141//!
142//! Canonicalization (i.e. `std::fs::canonicalize`) relies on syscalls to both normalize a path
143//! (including following symlinks!) and make sure the file it references actually exist.
144//!
145//! It's the strictest form of path normalization you can get (and therefore ideal), but
146//! requires 1) to have access to an actual filesystem at run-time and 2) that the file
147//! being referenced already exists.
148//!
149//! Normalization (not available in `std`) on the other hand is purely lexicographical: it
150//! normalizes paths as best as it can without ever touching the filesystem.
151//!
152//! See also "[Getting Dot-Dot Right](https://9p.io/sys/doc/lexnames.html)".
153//!
154//! ### Hermeticism
155//!
156//! When shipping release artifacts (whether web or otherwise), we want to avoid leaking state
157//! from the original build environments into the final binary (think: paths, timestamps, etc).
158//! We need to the build to be _hermetic_.
159//!
160//! Rust's `file!()` macro already takes care of that to some extent, and we need to match that
161//! behavior on our side (e.g. by not leaking local paths), otherwise we won't be able to
162//! compare paths at runtime.
163//!
164//! Think of it as `chroot`ing into our Cargo workspace :)
165//!
166//! In our case, there's an extra invariant on top on that: we must never embed shaders from
167//! outside the workspace into our release artifacts!
168//!
169//! ## Things we don't support
170//!
171//! - Async: everything in this module is done using standard synchronous APIs.
172//! - Compression, minification, etc: everything we embed is embedded as-is.
173//! - Importing via network requests: only the (virtual) filesystem is supported for now.
174//! - Implicit file suffixes: e.g. `#import <myshader>` for `myshader.wglsl`.
175//! - Embedding raw Naga modules: not yet, though we have everything in place for it.
176
177// TODO(cmc): might want to support implicitly dropping file suffixes at some point, e.g.
178// `#import <my_shader>` which works with "my_shader.wgsl"
179
180use std::path::{Path, PathBuf};
181use std::rc::Rc;
182
183use ahash::{HashMap, HashSet, HashSetExt as _};
184use anyhow::{Context as _, anyhow, bail, ensure};
185use clean_path::Clean as _;
186use itertools::Itertools as _;
187
188use crate::FileSystem;
189
190// ---
191
192/// Specifies where to look for imports when both absolute and relative resolution fail.
193///
194/// This is akin to the standard `$PATH` environment variable.
195#[derive(Clone, Debug, Default, PartialEq, Eq)]
196pub struct SearchPath {
197 /// All directories currently in the search path, in decreasing order of priority.
198 /// They are guaranteed to be normalized, but not canonicalized.
199 dirs: Vec<PathBuf>,
200}
201
202impl SearchPath {
203 pub fn from_env() -> Self {
204 const RERUN_SHADER_PATH: &str = "RERUN_SHADER_PATH";
205
206 std::env::var(RERUN_SHADER_PATH)
207 .map_or_else(|_| Ok(Self::default()), |s| s.parse())
208 .unwrap_or_else(|_| Self::default())
209 }
210
211 /// Push a path to search path.
212 ///
213 /// The path is normalized first, but not canonicalized.
214 pub fn push(&mut self, dir: impl AsRef<Path>) {
215 self.dirs.push(dir.as_ref().clean());
216 }
217
218 /// Insert a path into search path.
219 ///
220 /// The path is normalized first, but not canonicalized.
221 pub fn insert(&mut self, index: usize, dir: impl AsRef<Path>) {
222 self.dirs.insert(index, dir.as_ref().clean());
223 }
224
225 /// Returns an iterator over the directories in the search path, in decreasing
226 /// order of priority.
227 pub fn iter(&self) -> impl Iterator<Item = &Path> {
228 self.dirs.iter().map(|p| p.as_path())
229 }
230}
231
232impl std::str::FromStr for SearchPath {
233 type Err = anyhow::Error;
234
235 fn from_str(s: &str) -> Result<Self, Self::Err> {
236 let dirs: Vec<PathBuf> = s
237 .split(':')
238 .filter(|s| !s.is_empty())
239 .map(|s| {
240 s.parse()
241 .with_context(|| format!("couldn't parse {s:?} as PathBuf"))
242 })
243 .try_collect()?;
244
245 // We cannot check whether these actually are directories, since they are not
246 // guaranteed to even exist yet!
247 // Similarly, we cannot canonicalize here, but we can at least normalize.
248
249 Ok(Self {
250 dirs: dirs.into_iter().map(|dir| dir.clean()).collect(),
251 })
252 }
253}
254
255impl std::fmt::Display for SearchPath {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 let s = self
258 .dirs
259 .iter()
260 .map(|p| p.to_string_lossy())
261 .collect::<Vec<_>>()
262 .join(":");
263 f.write_str(&s)
264 }
265}
266
267// ---
268
269// TODO(cmc): codespan errors?
270
271/// A pre-parsed import clause, as in `#import <something>`.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct ImportClause {
274 /// The path being imported, as-is: neither canonicalized nor normalized.
275 path: PathBuf,
276}
277
278impl ImportClause {
279 pub const PREFIX: &'static str = "#import ";
280}
281
282impl<P: Into<PathBuf>> From<P> for ImportClause {
283 fn from(path: P) -> Self {
284 Self { path: path.into() }
285 }
286}
287
288impl std::str::FromStr for ImportClause {
289 type Err = anyhow::Error;
290
291 fn from_str(clause_str: &str) -> Result<Self, Self::Err> {
292 let s = clause_str.trim();
293
294 ensure!(
295 s.starts_with(Self::PREFIX),
296 "import clause must start with {prefix:?}, got {s:?}",
297 prefix = Self::PREFIX,
298 );
299 let s = s.trim_start_matches(Self::PREFIX).trim();
300
301 let rs = s.chars().rev().collect::<String>();
302
303 let splits = s
304 .find('<')
305 .and_then(|i0| rs.find('>').map(|i1| (i0 + 1, rs.len() - i1 - 1)));
306
307 if let Some((i0, i1)) = splits {
308 let s = &s[i0..i1];
309 ensure!(!s.is_empty(), "import clause must contain a non-empty path");
310
311 return s
312 .parse()
313 .with_context(|| format!("couldn't parse {s:?} as PathBuf"))
314 .map(|path| Self { path });
315 }
316
317 bail!("misformatted import clause: {clause_str:?}")
318 }
319}
320
321impl std::fmt::Display for ImportClause {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 f.write_fmt(format_args!("#import <{}>", self.path.to_string_lossy()))
324 }
325}
326
327#[cfg(test)]
328mod tests_import_clause {
329 use super::*;
330
331 #[test]
332 fn parsing_success() {
333 let testcases: [(&str, PathBuf, Option<&str>); 16] = [
334 (
335 "#import <my_constants>",
336 "my_constants".parse().unwrap(),
337 None,
338 ),
339 (
340 "#import <my_constants.wgsl>",
341 "my_constants.wgsl".parse().unwrap(),
342 None,
343 ),
344 (
345 "#import <x/y/z/my_constants>",
346 "x/y/z/my_constants".parse().unwrap(),
347 None,
348 ),
349 (
350 "#import <x/y/z/my_constants.wgsl>",
351 "x/y/z/my_constants.wgsl".parse().unwrap(),
352 None,
353 ),
354 (
355 "#import </x/y/z/my_constants>",
356 "/x/y/z/my_constants".parse().unwrap(),
357 None,
358 ),
359 (
360 "#import </x/y/z/my_constants.wgsl>",
361 "/x/y/z/my_constants.wgsl".parse().unwrap(),
362 None,
363 ),
364 (
365 "#import </x/y/z/my constants>",
366 "/x/y/z/my constants".parse().unwrap(),
367 None,
368 ),
369 (
370 "#import </x/y/z/my constants.wgsl>",
371 "/x/y/z/my constants.wgsl".parse().unwrap(),
372 None,
373 ),
374 (
375 "#import </x/y/z/my><constants>",
376 "/x/y/z/my><constants".parse().unwrap(),
377 None,
378 ),
379 (
380 "#import </x/y/z/my><constants.wgsl>",
381 "/x/y/z/my><constants.wgsl".parse().unwrap(),
382 None,
383 ),
384 (
385 " #import \t\t\t </x/y/z/my>\" \"<constants> \t\t\t",
386 "/x/y/z/my>\" \"<constants".parse().unwrap(),
387 "#import </x/y/z/my>\" \"<constants>".into(),
388 ),
389 (
390 " #import \t\t\t </x/y/z/my>\" \"<constants.wgsl> \t\t\t",
391 "/x/y/z/my>\" \"<constants.wgsl".parse().unwrap(),
392 "#import </x/y/z/my>\" \"<constants.wgsl>".into(),
393 ),
394 // Non-sense, but a valid path nonetheless ¯\_(ツ)_/¯
395 ("#import <<>>", "<>".parse().unwrap(), None),
396 // Technically valid non-sense yet again!
397 (
398 "#import <my_constants.wgsl> <my_other_constants.wgsl>",
399 "my_constants.wgsl> <my_other_constants.wgsl"
400 .parse()
401 .unwrap(),
402 None,
403 ),
404 // Some more of that.
405 (
406 "#import <my_constants.wgsl> \t\t\t #import <my_other_constants.wgsl>",
407 "my_constants.wgsl> \t\t\t #import <my_other_constants.wgsl"
408 .parse()
409 .unwrap(),
410 None,
411 ),
412 // Going into "absolutely terrifying" territory
413 (
414 "#import <my_multiline\r\npath.wgsl>",
415 "my_multiline\r\npath.wgsl".parse().unwrap(),
416 None,
417 ),
418 ];
419 let testcases = testcases
420 .into_iter()
421 .map(|(clause_str, path, clause_str_clean)| {
422 (clause_str, ImportClause::from(path), clause_str_clean)
423 });
424
425 for (clause_str, expected, expected_clause) in testcases {
426 eprintln!("test case: ({clause_str:?}, {expected:?})");
427
428 let clause = clause_str.parse::<ImportClause>().unwrap();
429 assert_eq!(expected, clause);
430
431 let clause_str_clean = clause.to_string();
432 if let Some(expected_clause) = expected_clause {
433 assert_eq!(expected_clause, clause_str_clean);
434 } else {
435 assert_eq!(clause_str, clause_str_clean);
436 }
437 }
438 }
439
440 #[test]
441 fn parsing_failure() {
442 let testcases = [
443 "#import <",
444 "#import <>",
445 "import my_constants",
446 "my_constants",
447 ];
448
449 for s in testcases {
450 eprintln!("test case: {s:?}");
451 assert!(s.parse::<ImportClause>().is_err());
452 }
453 }
454}
455
456// ---
457
458/// The recommended `FileResolver` type for the current platform/target.
459#[cfg(load_shaders_from_disk)]
460pub type RecommendedFileResolver = FileResolver<crate::OsFileSystem>;
461
462/// The recommended `FileResolver` type for the current platform/target.
463#[cfg(not(load_shaders_from_disk))]
464pub type RecommendedFileResolver = FileResolver<&'static crate::MemFileSystem>;
465
466/// Returns the recommended `FileResolver` for the current platform/target.
467pub fn new_recommended() -> RecommendedFileResolver {
468 let mut search_path = SearchPath::from_env();
469 search_path.push("crates/viewer/re_renderer/shader");
470 FileResolver::with_search_path(crate::get_filesystem(), search_path)
471}
472
473#[derive(Clone, Debug, Default)]
474pub struct InterpolatedFile {
475 pub contents: String,
476 pub imports: HashSet<PathBuf>,
477}
478
479/// The `FileResolver` handles both resolving import clauses and doing the actual string
480/// interpolation.
481#[derive(Default)]
482pub struct FileResolver<Fs> {
483 /// A handle to the filesystem being used.
484 /// Generally a `OsFileSystem` on native and a `MemFileSystem` on web and during tests.
485 fs: Fs,
486
487 /// The search path that we will go through when an import cannot be resolved neither
488 /// as an absolute path or a relative one.
489 search_path: SearchPath,
490}
491
492// Constructors
493impl<Fs: FileSystem> FileResolver<Fs> {
494 pub fn new(fs: Fs) -> Self {
495 Self {
496 fs,
497 search_path: Default::default(),
498 }
499 }
500
501 pub fn with_search_path(fs: Fs, search_path: SearchPath) -> Self {
502 Self { fs, search_path }
503 }
504}
505
506impl<Fs: FileSystem> FileResolver<Fs> {
507 pub fn populate(&self, path: impl AsRef<Path>) -> anyhow::Result<InterpolatedFile> {
508 re_tracing::profile_function!();
509
510 fn populate_rec<Fs: FileSystem>(
511 this: &FileResolver<Fs>,
512 path: impl AsRef<Path>,
513 interp_files: &mut HashMap<PathBuf, Rc<InterpolatedFile>>,
514 path_stack: &mut Vec<PathBuf>,
515 visited_stack: &mut HashSet<PathBuf>,
516 ) -> anyhow::Result<Rc<InterpolatedFile>> {
517 let path = path.as_ref().clean();
518
519 // Cycle detection
520 path_stack.push(path.clone());
521 ensure!(
522 visited_stack.insert(path.clone()),
523 "import cycle detected: {path_stack:?}"
524 );
525
526 // #pragma once
527 if interp_files.contains_key(&path) {
528 // Cycle detection
529 path_stack.pop().unwrap();
530 visited_stack.remove(&path);
531
532 return Ok(Default::default());
533 }
534
535 let contents = this.fs.read_to_string(&path)?;
536
537 // Using implicit Vec<Result> -> Result<Vec> collection.
538 let mut imports = HashSet::new();
539 let children: Result<Vec<_>, _> = contents
540 .lines()
541 .map(|line| {
542 if line.trim().starts_with(ImportClause::PREFIX) {
543 let clause = line.parse::<ImportClause>()?;
544 // We do not use `Path::parent` on purpose!
545 let cwd = path.join("..").clean();
546 let clause_path =
547 this.resolve_clause_path(cwd, &clause.path).ok_or_else(|| {
548 anyhow!("couldn't resolve import clause path at {:?}", clause.path)
549 })?;
550 imports.insert(clause_path.clone());
551 populate_rec(this, clause_path, interp_files, path_stack, visited_stack)
552 } else {
553 // Fake child, just the line itself.
554 Ok(Rc::new(InterpolatedFile {
555 contents: line.to_owned(),
556 ..Default::default()
557 }))
558 }
559 })
560 .collect();
561 let children = children?;
562
563 let interp = children.into_iter().fold(
564 InterpolatedFile {
565 imports,
566 ..Default::default()
567 },
568 |acc, child| InterpolatedFile {
569 contents: match (acc.contents.is_empty(), child.contents.is_empty()) {
570 (true, _) => child.contents.clone(),
571 (_, true) => acc.contents,
572 _ => [acc.contents.as_str(), child.contents.as_str()].join("\n"),
573 },
574 imports: acc.imports.union(&child.imports).cloned().collect(),
575 },
576 );
577
578 let interp = Rc::new(interp);
579 interp_files.insert(path.clone(), Rc::clone(&interp));
580
581 // Cycle detection
582 path_stack.pop().unwrap();
583 visited_stack.remove(&path);
584
585 Ok(interp)
586 }
587
588 let mut path_stack = Vec::new();
589 let mut visited_stack = HashSet::new();
590 let mut interp_files = HashMap::default();
591
592 populate_rec(
593 self,
594 path,
595 &mut interp_files,
596 &mut path_stack,
597 &mut visited_stack,
598 )
599 .map(|interp| (*interp).clone())
600 }
601
602 fn resolve_clause_path(
603 &self,
604 cwd: impl AsRef<Path>,
605 path: impl AsRef<Path>,
606 ) -> Option<PathBuf> {
607 let path = path.as_ref().clean();
608
609 // The imported path is absolute and points to an existing file, let's import that.
610 if path.is_absolute() && self.fs.exists(&path) {
611 return path.into();
612 }
613
614 // The imported path looks relative. Try to join it with the importer's and see if
615 // that leads somewhere… if it does: import that.
616 {
617 let path = cwd.as_ref().join(&path).clean();
618 if self.fs.exists(&path) {
619 return path.into();
620 }
621 }
622
623 // If the imported path isn't relative to the importer's, then maybe it is relative
624 // with regards to one of the search paths: let's try there.
625 for dir in self.search_path.iter() {
626 let dir = dir.join(&path).clean();
627 if self.fs.exists(&dir) {
628 return dir.into();
629 }
630 }
631
632 None
633 }
634}
635
636// TODO(cmc): might want an actual test using `RERUN_SHADER_PATH`
637#[cfg(test)]
638mod tests_file_resolver {
639 use unindent::unindent;
640
641 use super::*;
642 use crate::MemFileSystem;
643
644 #[test]
645 fn acyclic_interpolation() {
646 let fs = MemFileSystem::get();
647 {
648 fs.create_dir_all("/shaders1/common").unwrap();
649 fs.create_dir_all("/shaders1/a/b/c/d").unwrap();
650
651 fs.create_file(
652 "/shaders1/common/shader1.wgsl",
653 unindent(
654 r#"
655 my first shader!
656 #import </shaders1/common/shader4.wgsl>
657 "#,
658 )
659 .into(),
660 )
661 .unwrap();
662
663 fs.create_file(
664 "/shaders1/a/b/shader2.wgsl",
665 unindent(
666 r#"
667 #import </shaders1/common/shader1.wgsl>
668 #import <../../common/shader1.wgsl>
669
670 #import </shaders1/a/b/c/d/shader3.wgsl>
671 #import <c/d/shader3.wgsl>
672
673 my second shader!
674
675 #import <common/shader1.wgsl>
676 #import <shader1.wgsl>
677
678 #import <shader3.wgsl>
679 #import <a/b/c/d/shader3.wgsl>
680 "#,
681 )
682 .into(),
683 )
684 .unwrap();
685
686 fs.create_file(
687 "/shaders1/a/b/c/d/shader3.wgsl",
688 unindent(
689 r#"
690 #import </shaders1/common/shader1.wgsl>
691 #import <../../../../common/shader1.wgsl>
692 my third shader!
693 #import <common/shader1.wgsl>
694 #import <shader1.wgsl>
695 "#,
696 )
697 .into(),
698 )
699 .unwrap();
700
701 fs.create_file(
702 "/shaders1/common/shader4.wgsl",
703 unindent(r#"my fourth shader!"#).into(),
704 )
705 .unwrap();
706 }
707
708 let resolver = FileResolver::with_search_path(fs, {
709 let mut search_path = SearchPath::default();
710 search_path.push("/shaders1");
711 search_path.push("/shaders1/common");
712 search_path.push("/shaders1/a/b/c/d");
713 search_path
714 });
715
716 for _ in 0..3 {
717 // ^^^^ just making sure the stateful stuff behaves correctly
718
719 let shader1_interp = resolver.populate("/shaders1/common/shader1.wgsl").unwrap();
720
721 // Shader 1: resolve
722 let mut imports = shader1_interp.imports.into_iter().collect::<Vec<_>>();
723 imports.sort();
724 let expected: Vec<PathBuf> = vec!["/shaders1/common/shader4.wgsl".into()];
725 assert_eq!(expected, imports);
726
727 // Shader 1: interpolate
728 let contents = shader1_interp.contents;
729 let expected = unindent(
730 r#"
731 my first shader!
732 my fourth shader!"#,
733 );
734 assert_eq!(expected, contents);
735
736 let shader2_interp = resolver.populate("/shaders1/a/b/shader2.wgsl").unwrap();
737 // Shader 2: resolve
738 let mut imports = shader2_interp.imports.into_iter().collect::<Vec<_>>();
739 imports.sort();
740 let expected: Vec<PathBuf> = vec![
741 "/shaders1/a/b/c/d/shader3.wgsl".into(),
742 "/shaders1/common/shader1.wgsl".into(),
743 "/shaders1/common/shader4.wgsl".into(),
744 ];
745 assert_eq!(expected, imports);
746
747 // Shader 2: interpolate
748 let contents = shader2_interp.contents;
749 let expected = unindent(
750 r#"
751 my first shader!
752 my fourth shader!
753 my third shader!
754 my second shader!"#,
755 );
756 assert_eq!(expected, contents);
757
758 let shader3_interp = resolver.populate("/shaders1/a/b/c/d/shader3.wgsl").unwrap();
759
760 // Shader 3: resolve
761 let mut imports = shader3_interp.imports.into_iter().collect::<Vec<_>>();
762 imports.sort();
763 let expected: Vec<PathBuf> = vec![
764 "/shaders1/common/shader1.wgsl".into(),
765 "/shaders1/common/shader4.wgsl".into(),
766 ];
767 assert_eq!(expected, imports);
768
769 // Shader 3: interpolate
770 let contents = shader3_interp.contents;
771 let expected = unindent(
772 r#"
773 my first shader!
774 my fourth shader!
775 my third shader!"#,
776 );
777 assert_eq!(expected, contents);
778 }
779 }
780
781 #[test]
782 #[expect(clippy::should_panic_without_expect)] // TODO(cmc): check error contents
783 #[should_panic]
784 fn cyclic_direct() {
785 let fs = MemFileSystem::get();
786 {
787 fs.create_dir_all("/shaders2").unwrap();
788
789 fs.create_file(
790 "/shaders2/shader1.wgsl",
791 unindent(
792 r#"
793 #import </shaders2/shader2.wgsl>
794 my first shader!
795 "#,
796 )
797 .into(),
798 )
799 .unwrap();
800
801 fs.create_file(
802 "/shaders2/shader2.wgsl",
803 unindent(
804 r#"
805 #import </shaders2/shader1.wgsl>
806 my second shader!
807 "#,
808 )
809 .into(),
810 )
811 .unwrap();
812 }
813
814 let resolver = FileResolver::new(fs);
815
816 resolver
817 .populate("/shaders2/shader1.wgsl")
818 .map_err(re_error::format)
819 .unwrap();
820 }
821
822 #[test]
823 #[expect(clippy::should_panic_without_expect)] // TODO(cmc): check error contents
824 #[should_panic]
825 fn cyclic_indirect() {
826 let fs = MemFileSystem::get();
827 {
828 fs.create_dir_all("/shaders3").unwrap();
829
830 fs.create_file(
831 "/shaders3/shader1.wgsl",
832 unindent(
833 r#"
834 #import </shaders3/shader2.wgsl>
835 my first shader!
836 "#,
837 )
838 .into(),
839 )
840 .unwrap();
841
842 fs.create_file(
843 "/shaders3/shader2.wgsl",
844 unindent(
845 r#"
846 #import </shaders3/shader3.wgsl>
847 my second shader!
848 "#,
849 )
850 .into(),
851 )
852 .unwrap();
853
854 fs.create_file(
855 "/shaders3/shader3.wgsl",
856 unindent(
857 r#"
858 #import </shaders3/shader1.wgsl>
859 my third shader!
860 "#,
861 )
862 .into(),
863 )
864 .unwrap();
865 }
866
867 let resolver = FileResolver::new(fs);
868
869 resolver
870 .populate("/shaders3/shader1.wgsl")
871 .map_err(re_error::format)
872 .unwrap();
873 }
874}