Skip to main content

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}