Skip to main content

typst_syntax/
path.rs

1//! Virtual, cross-platform reproducible path handling.
2
3use std::error;
4use std::ffi::OsStr;
5use std::fmt::{self, Debug, Formatter};
6use std::num::NonZeroU16;
7use std::ops::Deref;
8use std::path::{self, Path, PathBuf};
9use std::sync::{LazyLock, RwLock};
10
11use ecow::{EcoString, eco_format};
12use rustc_hash::FxHashMap;
13
14use crate::package::PackageSpec;
15
16/// A path in a specific virtual file system root.
17///
18/// This identifies a location in a project or package.
19#[derive(Clone, Eq, PartialEq, Hash)]
20pub struct RootedPath {
21    root: VirtualRoot,
22    vpath: VirtualPath,
23}
24
25impl RootedPath {
26    /// Create a rooted path from a root and a virtual path within the root.
27    pub fn new(root: VirtualRoot, vpath: VirtualPath) -> Self {
28        Self { root, vpath }
29    }
30
31    /// Turns this path into a `FileId`.
32    pub fn intern(self) -> FileId {
33        FileId::new(self)
34    }
35
36    /// The root this path resides in.
37    pub fn root(&self) -> &VirtualRoot {
38        &self.root
39    }
40
41    /// The package the path resides in, if any.
42    #[deprecated = "use `root` instead"]
43    pub fn package(&self) -> Option<&PackageSpec> {
44        match self.root() {
45            VirtualRoot::Project => None,
46            VirtualRoot::Package(package) => Some(package),
47        }
48    }
49
50    /// The absolute and normalized path to the file _within_ the project or
51    /// package.
52    pub fn vpath(&self) -> &VirtualPath {
53        &self.vpath
54    }
55
56    /// Maps the virtual path while retaining the root.
57    pub fn map(&self, f: impl FnOnce(&VirtualPath) -> VirtualPath) -> Self {
58        Self::new(self.root.clone(), f(&self.vpath))
59    }
60}
61
62impl Debug for RootedPath {
63    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
64        let vpath = self.vpath();
65        match self.root() {
66            VirtualRoot::Project => Debug::fmt(vpath, f),
67            VirtualRoot::Package(package) => write!(f, "{package:?}{vpath:?}"),
68        }
69    }
70}
71
72/// The root of a virtual file system.
73#[derive(Debug, Clone, Eq, PartialEq, Hash)]
74pub enum VirtualRoot {
75    /// The canonical root of the Typst project.
76    ///
77    /// This is what `TYPST_ROOT` defines.
78    Project,
79    /// A root in a package.
80    Package(PackageSpec),
81}
82
83/// The global interner for rooted paths.
84static INTERNER: LazyLock<RwLock<Interner>> = LazyLock::new(|| {
85    RwLock::new(Interner { to_id: FxHashMap::default(), from_id: Vec::new() })
86});
87
88/// An interner for rooted paths.
89struct Interner {
90    to_id: FxHashMap<&'static RootedPath, FileId>,
91    from_id: Vec<&'static RootedPath>,
92}
93
94/// An interned version of [`RootedPath`].
95///
96/// This type is globally interned and thus cheap to copy, compare, and hash.
97#[derive(Copy, Clone, Eq, PartialEq, Hash)]
98pub struct FileId(NonZeroU16);
99
100impl FileId {
101    /// Create a new interned file specification.
102    ///
103    /// This is the same as [`RootedPath::intern`].
104    #[track_caller]
105    pub fn new(path: RootedPath) -> Self {
106        // Try to find an existing entry that we can reuse.
107        //
108        // We could check with just a read lock, but if the pair is not yet
109        // present, we would then need to recheck after acquiring a write lock,
110        // which is probably not worth it.
111        let mut interner = INTERNER.write().unwrap();
112        if let Some(&id) = interner.to_id.get(&path) {
113            return id;
114        }
115
116        // Create a new entry forever by leaking the pair. We can't leak more
117        // than 2^16 pair (and typically will leak a lot less), so its not a
118        // big deal.
119        let num = u16::try_from(interner.from_id.len() + 1)
120            .and_then(NonZeroU16::try_from)
121            .expect("out of file ids");
122
123        let id = FileId(num);
124        let leaked = Box::leak(Box::new(path));
125        interner.to_id.insert(leaked, id);
126        interner.from_id.push(leaked);
127        id
128    }
129
130    /// Create a new unique ("fake") file specification, which is not accessible
131    /// by path.
132    ///
133    /// Caution: the ID returned by this method is the *only* identifier of the
134    /// file, constructing a file ID with a path will *not* reuse the ID even if
135    /// the path is the same. This method should only be used for generating
136    /// "virtual" file ids such as content read from stdin.
137    ///
138    /// While the returned ID is not otherwise accessible, the provided root and
139    /// path still matter as they define how paths within the identified file
140    /// will resolve. For the example of content read from stdin, it will define
141    /// how a relative path in the stdin content is resolved.
142    #[track_caller]
143    pub fn unique(path: RootedPath) -> Self {
144        let mut interner = INTERNER.write().unwrap();
145        let num = u16::try_from(interner.from_id.len() + 1)
146            .and_then(NonZeroU16::try_from)
147            .expect("out of file ids");
148
149        let id = FileId(num);
150        let leaked = Box::leak(Box::new(path));
151        interner.from_id.push(leaked);
152        id
153    }
154
155    /// Construct from a raw number.
156    ///
157    /// Should only be used with numbers retrieved via
158    /// [`into_raw`](Self::into_raw). Misuse may results in panics, but no
159    /// unsafety.
160    pub const fn from_raw(v: NonZeroU16) -> Self {
161        Self(v)
162    }
163
164    /// Extract the raw underlying number.
165    pub const fn into_raw(self) -> NonZeroU16 {
166        self.0
167    }
168
169    /// Get the static, interned rooted path.
170    pub fn get(&self) -> &'static RootedPath {
171        INTERNER.read().unwrap().from_id[usize::from(self.0.get() - 1)]
172    }
173}
174
175impl Deref for FileId {
176    type Target = RootedPath;
177
178    fn deref(&self) -> &Self::Target {
179        self.get()
180    }
181}
182
183impl Debug for FileId {
184    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
185        self.get().fmt(f)
186    }
187}
188
189/// A path in a virtual file system.
190#[derive(Clone, Eq, PartialEq, Hash)]
191pub struct VirtualPath(Segments);
192
193impl VirtualPath {
194    /// Creates a new virtual path.
195    pub fn new(path: impl AsRef<str>) -> Result<Self, PathError> {
196        let segments = Segments::normalize(components(path.as_ref()))?;
197        Ok(Self(segments))
198    }
199
200    /// Creates a virtual path from a real path and a real root.
201    ///
202    /// Returns `None` if the file path is not contained in the root (i.e. if
203    /// `root_path` is not a lexical prefix of `path`). No file system
204    /// operations are performed.
205    ///
206    /// This is the single function that translates from a real path to a
207    /// virtual path. Its counterpart is [`VirtualPath::realize`].
208    pub fn virtualize(root_path: &Path, path: &Path) -> Result<Self, VirtualizeError> {
209        let path = path.strip_prefix(root_path).map_err(|_| PathError::Escapes)?;
210        let mut segments = Segments::new();
211        for c in path.components() {
212            let comp = match c {
213                path::Component::RootDir => Component::Root,
214                path::Component::CurDir => Component::Current,
215                path::Component::ParentDir => Component::Parent,
216                path::Component::Normal(s) => {
217                    let string = s.to_str().ok_or(VirtualizeError::Utf8)?;
218                    let segment = Segment::new(string)
219                        .map_err(|s| VirtualizeError::Invalid(s.into()))?;
220                    Component::Normal(segment)
221                }
222                path::Component::Prefix(_) => return Err(PathError::Escapes.into()),
223            };
224            segments.push_component(comp)?;
225        }
226        Ok(Self(segments))
227    }
228
229    /// Turns the virtual path into an actual file system path (where the
230    /// project or package resides). You need to provide the appropriate `root`
231    /// path, relative to which this path will be resolved.
232    ///
233    /// This is the single function that translates from a virtual path to a
234    /// real path. Its counterpart is [`VirtualPath::virtualize`].
235    ///
236    /// This function has platform-specific output and returns an error if the
237    /// path contains platform-specific syntax that could lexically escape the
238    /// `root`. Currently, no file system operations are performed, though this
239    /// may change in the future.
240    ///
241    /// This can be used in the implementations of `World::source` and
242    /// `World::file`.
243    pub fn realize(&self, root: &Path) -> Result<PathBuf, RealizeError> {
244        let mut out = root.to_path_buf();
245        for s in self.0.iter() {
246            out.push(s.realize()?);
247        }
248        Ok(out)
249    }
250
251    /// Extracts the path with a leading slash.
252    pub fn into_with_slash(self) -> EcoString {
253        self.0.into_with_slash()
254    }
255
256    /// Returns the path with a leading slash.
257    pub fn get_with_slash(&self) -> &str {
258        self.0.get_with_slash()
259    }
260
261    /// Returns the path without a leading slash.
262    pub fn get_without_slash(&self) -> &str {
263        self.0.get_without_slash()
264    }
265
266    /// Whether this is the path `/`.
267    pub fn is_root(&self) -> bool {
268        self.0.is_empty()
269    }
270
271    /// Returns the file name portion of the path.
272    pub fn file_name(&self) -> Option<&str> {
273        self.0.last().map(Segment::get)
274    }
275
276    /// Returns the file name portion of the path without the extension.
277    pub fn file_stem(&self) -> Option<&str> {
278        let last = self.0.last()?;
279        let (before, after) = last.split_dot();
280        before.or(after)
281    }
282
283    /// Returns the file extension of the path.
284    pub fn extension(&self) -> Option<&str> {
285        let last = self.0.last()?;
286        let (before, after) = last.split_dot();
287        before.and(after)
288    }
289
290    /// Returns a modified path with an adjusted extension.
291    ///
292    /// # Panics
293    /// Panics if the resulting path segment would be invalid, e.g. because the
294    /// extension contains a forward or backslash.
295    #[track_caller]
296    pub fn with_extension(&self, ext: &str) -> Self {
297        let Some(stem) = self.file_stem() else { return self.clone() };
298        let buf = eco_format!("{stem}.{ext}");
299        let segment = Segment::new(&buf).expect("extension is invalid");
300
301        let mut segments = self.0.clone();
302        segments.pop();
303        segments.push(segment);
304        Self(segments)
305    }
306
307    /// Returns the path with its final component removed.
308    ///
309    /// Returns `None` if the path is already at the root.
310    pub fn parent(&self) -> Option<Self> {
311        let mut segments = self.0.clone();
312        if !segments.pop() {
313            return None;
314        }
315        Some(Self(segments))
316    }
317
318    /// Joins the given `path` to `self`.
319    pub fn join(&self, path: &str) -> Result<Self, PathError> {
320        let combined = self
321            .0
322            .iter()
323            .map(|c| Ok(Component::Normal(c)))
324            .chain(components(path));
325        let segments = Segments::normalize(combined)?;
326        Ok(Self(segments))
327    }
328
329    /// Expresses this path as a relative path from the given base path.
330    pub fn relative_from(&self, base: &Self) -> EcoString {
331        // Adapted from rustc's `path_relative_from` function (MIT).
332        // Copyright 2012-2015 The Rust Project Developers.
333        // See NOTICE for full attribution.
334        let mut ita = self.0.iter();
335        let mut itb = base.0.iter();
336        let mut buf: Vec<&str> = vec![];
337        loop {
338            match (ita.next(), itb.next()) {
339                (None, None) => break,
340                (Some(a), None) => {
341                    buf.push(a.get());
342                    buf.extend(ita.map(Segment::get));
343                    break;
344                }
345                (None, Some(_)) => buf.push(".."),
346                (Some(a), Some(b)) if buf.is_empty() && a == b => (),
347                (Some(a), Some(_)) => {
348                    buf.extend(std::iter::repeat_n("..", 1 + itb.count()));
349                    buf.push(a.get());
350                    buf.extend(ita.map(Segment::get));
351                    break;
352                }
353            }
354        }
355        buf.join("/").into()
356    }
357}
358
359impl VirtualPath {
360    /// Create a virtual path from a real path and a real root.
361    #[deprecated = "use `virtualize` with swapped arguments instead"]
362    pub fn within_root(path: &Path, root: &Path) -> Option<Self> {
363        Self::virtualize(root, path).ok()
364    }
365
366    /// Resolve the virtual path relative to an actual file system root
367    /// (where the project or package resides).
368    #[deprecated = "use `realize` instead"]
369    pub fn resolve(&self, root: &Path) -> Option<PathBuf> {
370        self.realize(root).ok()
371    }
372
373    /// Get the underlying path without a leading `/` or `\`.
374    #[deprecated = "use `get_without_slash` instead"]
375    pub fn as_rootless_path(&self) -> &Path {
376        Path::new(self.get_without_slash())
377    }
378
379    /// Get the underlying path with a leading `/` or `\`.
380    #[deprecated = "use `get_with_slash` instead"]
381    pub fn as_rooted_path(&self) -> &Path {
382        Path::new(self.get_with_slash())
383    }
384}
385
386impl Debug for VirtualPath {
387    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
388        self.get_with_slash().fmt(f)
389    }
390}
391
392/// A component in a virtual path.
393#[derive(Debug, Copy, Clone, Eq, PartialEq)]
394enum Component<'a> {
395    Root,
396    Current,
397    Parent,
398    Normal(Segment<'a>),
399}
400
401// Special symbols in virtual paths.
402const SEPARATOR: char = '/';
403const CURRENT: &str = ".";
404const PARENT: &str = "..";
405
406/// Splits a user-supplied path into its constituent parts.
407///
408/// This only splits and recognizes special segments. It does not check the
409/// validity of normal segments. This is done in [`Segments::push`].
410fn components(path: &str) -> impl Iterator<Item = Result<Component<'_>, PathError>> {
411    path.split(SEPARATOR).enumerate().map(|(i, s)| {
412        match s {
413            // A leading separator indicates an absolute path.
414            "" if i == 0 && !path.is_empty() => Ok(Component::Root),
415            // Consecutive separators have no effect.
416            "" => Ok(Component::Current),
417            CURRENT => Ok(Component::Current),
418            PARENT => Ok(Component::Parent),
419            other => match Segment::new(other) {
420                Ok(segment) => Ok(Component::Normal(segment)),
421                Err("\\") => Err(PathError::Backslash),
422                Err(_) => unreachable!(),
423            },
424        }
425    })
426}
427
428/// A segment in a normalized path.
429///
430/// A segments is never empty, `.`, or `..` and it never contains back- or
431/// forward slashes.
432#[derive(Debug, Copy, Clone, Eq, PartialEq)]
433struct Segment<'a>(&'a str);
434
435impl<'a> Segment<'a> {
436    fn new(segment: &'a str) -> Result<Self, &'a str> {
437        // These invariants are important to avoid the path from escaping the
438        // root after being realized, in particular the `..` part.
439        if matches!(segment, "" | CURRENT | PARENT) {
440            return Err(segment);
441        }
442
443        // Interior separators or backslashes are not allowed.
444        if let Some(m) = segment.matches([SEPARATOR, '\\']).next() {
445            return Err(m);
446        }
447
448        Ok(Self(segment))
449    }
450
451    fn new_unchecked(segment: &'a str) -> Self {
452        debug_assert!(Self::new(segment).is_ok());
453        Self(segment)
454    }
455
456    fn get(self) -> &'a str {
457        self.0
458    }
459
460    fn split_dot(self) -> (Option<&'a str>, Option<&'a str>) {
461        let mut iter = self.0.rsplitn(2, '.');
462        let after = iter.next();
463        let before = iter.next();
464        if before == Some("") { (Some(self.0), None) } else { (before, after) }
465    }
466
467    /// Ensures that this segment can be joined to an existing platform-specific
468    /// path without lexically escaping the root formed by that path.
469    ///
470    /// Sanity-checked against <https://pkg.go.dev/path/filepath#IsLocal>.
471    fn realize(self) -> Result<&'a OsStr, RealizeError> {
472        // We ensure that this segment corresponds to exactly one platform-level
473        // path component. Drive letters on Windows are rejected since they are
474        // `Prefix` components.
475        let mut iter = Path::new(self.get()).components();
476        match (iter.next(), iter.next()) {
477            // Exactly one normal component.
478            (Some(path::Component::Normal(s)), None) => {
479                // Forbid access to reserved files on Windows as they are
480                // conceptually not part of any root.
481                #[cfg(windows)]
482                if is_windows_reserved(self.get()) {
483                    return Err(RealizeError::Invalid(self.get().into()));
484                }
485                Ok(s)
486            }
487            // No or multiple components, starting with normal.
488            (None | Some(path::Component::Normal(_)), _) => {
489                Err(RealizeError::Invalid(self.get().into()))
490            }
491            // Non-normal component.
492            (Some(other), _) => {
493                Err(RealizeError::Invalid(other.as_os_str().to_string_lossy().into()))
494            }
495        }
496    }
497}
498
499/// Whether the give file name is reserved on Windows.
500///
501/// Follows <https://cs.opensource.google/go/go/+/refs/tags/go1.26.4:src/internal/filepathlite/path_windows.go;l=98>.
502/// See also: <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
503#[cfg(windows)]
504fn is_windows_reserved(name: &str) -> bool {
505    #[rustfmt::skip]
506    fn is_reserved_base(basename: &str) -> bool {
507        matches!(
508            basename,
509            "CON" | "PRN" | "AUX" | "NUL"
510                | "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6"
511                | "COM7" | "COM8" | "COM9" | "COM¹" | "COM²" | "COM³"
512                | "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6"
513                | "LPT7" | "LPT8" | "LPT9" | "LPT¹" | "LPT²" | "LPT³"
514                | "CONIN$" | "CONOUT$"
515        )
516    }
517
518    // Some Windows versions also allow arbitrary characters after a dot or
519    // colon in device names (and trailing spaces before that suffix).
520    let base = name.split(['.', ':']).next().unwrap_or(name).trim_end_matches(' ');
521    base.len() <= 7 && is_reserved_base(&EcoString::from(base).to_ascii_uppercase())
522}
523
524/// Stores a sequence of path segments as a string.
525///
526/// The underlying string always represents a normalized absolute path and is
527/// guaranteed to start with a slash. Segments are never empty, `.`, or `..` and
528/// they never contain back- or forward slashes.
529#[derive(Clone, Eq, PartialEq, Hash)]
530struct Segments(EcoString);
531
532impl Segments {
533    fn new() -> Self {
534        Self(EcoString::from(SEPARATOR))
535    }
536
537    fn normalize<'a>(
538        comps: impl IntoIterator<Item = Result<Component<'a>, PathError>>,
539    ) -> Result<Segments, PathError> {
540        let mut out = Segments::new();
541        for component in comps {
542            out.push_component(component?)?;
543        }
544        Ok(out)
545    }
546
547    fn is_empty(&self) -> bool {
548        self.0.len() == 1
549    }
550
551    fn into_with_slash(self) -> EcoString {
552        self.0
553    }
554
555    fn get_with_slash(&self) -> &str {
556        &self.0
557    }
558
559    fn get_without_slash(&self) -> &str {
560        self.0.strip_prefix(SEPARATOR).expect("path to start with slash")
561    }
562
563    fn clear(&mut self) {
564        self.0.truncate(1);
565    }
566
567    fn push_component(&mut self, component: Component) -> Result<(), PathError> {
568        match component {
569            // Root component resets the path.
570            Component::Root => self.clear(),
571            // Current component has no effect.
572            Component::Current => {}
573            // Parent component removes the last segment. If there is no
574            // segment, this indicates that the path would escape the root.
575            // In this case, we return an error.
576            Component::Parent => {
577                if !self.pop() {
578                    return Err(PathError::Escapes);
579                }
580            }
581            Component::Normal(segment) => self.push(segment),
582        }
583        Ok(())
584    }
585
586    fn push<'a>(&mut self, segment: Segment<'a>) {
587        if !self.is_empty() {
588            self.0.push(SEPARATOR);
589        }
590        self.0.push_str(segment.0);
591    }
592
593    fn pop(&mut self) -> bool {
594        if self.is_empty() {
595            return false;
596        }
597        let i = self.0.rfind(SEPARATOR).expect("to contain a slash");
598        self.0.truncate(std::cmp::max(1, i));
599        true
600    }
601
602    fn last(&self) -> Option<Segment<'_>> {
603        self.iter().next_back()
604    }
605
606    fn iter(&self) -> impl DoubleEndedIterator<Item = Segment<'_>> {
607        let mut iter = self.0[1..].split(SEPARATOR);
608        if self.is_empty() {
609            iter.next();
610        }
611        iter.map(Segment::new_unchecked)
612    }
613}
614
615/// An error that can occur on construction or modification of a
616/// [`VirtualPath`].
617#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
618pub enum PathError {
619    /// The constructed or modified path would escape the root. This would
620    /// for instance, when trying to join `..` to the path `/`.
621    ///
622    /// Note that a path might still escape through symlinks.
623    Escapes,
624    /// The path contains a backslash. This is not allowed as it leads to
625    /// cross-platform compatibility hazards (since Windows uses backslashes as
626    /// a path separator).
627    Backslash,
628}
629
630impl fmt::Display for PathError {
631    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
632        match self {
633            Self::Escapes => write!(f, "path escapes project root"),
634            Self::Backslash => write!(f, "path contains backslash"),
635        }
636    }
637}
638
639impl error::Error for PathError {}
640
641/// An error that can occur in [`VirtualPath::virtualize`].
642#[derive(Debug, Clone, Eq, PartialEq, Hash)]
643pub enum VirtualizeError {
644    /// A normal path error.
645    Path(PathError),
646    /// A path component contained an invalid string. This should almost never
647    /// occur under normal circumstances, but it could happen if some OS allows
648    /// forward slashes or dots in path components.
649    Invalid(EcoString),
650    /// The file path contains non-UTF-8 encodable bytes.
651    Utf8,
652}
653
654impl fmt::Display for VirtualizeError {
655    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
656        match self {
657            Self::Path(inner) => fmt::Display::fmt(inner, f),
658            Self::Invalid(component) => {
659                write!(f, "path contains invalid component `{component:?}`")
660            }
661            Self::Utf8 => write!(f, "path contains non-UTF-8 bytes"),
662        }
663    }
664}
665
666// NOTE: Because we opt to inline the formatting of the PathError we cannot
667// also return it as the error source, else it will be displayed twice in error
668// backtraces.
669impl error::Error for VirtualizeError {}
670
671impl From<PathError> for VirtualizeError {
672    fn from(err: PathError) -> Self {
673        Self::Path(err)
674    }
675}
676
677/// An error that can occur in [`VirtualPath::realize`].
678#[derive(Debug, Clone, Eq, PartialEq, Hash)]
679pub enum RealizeError {
680    /// A virtual path component was of a form that would be specially
681    /// interpreted on the current platform as opposed to designating just a
682    /// file or directory.
683    ///
684    /// For example, this occurs when a path segments contains a drive letter
685    /// prefix on Windows (i.e. the virtual path `/C:/System32/..`) as Windows
686    /// interprets interior drive letters as resetting the path to the cwd (if
687    /// the cwd is on the current drive) or resetting the path to root (if the
688    /// cwd is on another drive).
689    Invalid(EcoString),
690}
691
692impl fmt::Display for RealizeError {
693    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
694        match self {
695            Self::Invalid(component) => {
696                write!(f, "path contains invalid component `{component:?}`")
697            }
698        }
699    }
700}
701
702impl error::Error for RealizeError {}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[track_caller]
709    fn path(p: &str) -> VirtualPath {
710        VirtualPath::new(p).unwrap()
711    }
712
713    #[test]
714    fn test_new() {
715        #[track_caller]
716        fn test(path: &str, expected: Result<&str, PathError>) {
717            let path = VirtualPath::new(path);
718            assert_eq!(
719                path.as_ref().map(|s| s.get_with_slash()).map_err(Clone::clone),
720                expected
721            );
722        }
723
724        test("", Ok("/"));
725        test("a/./file.txt", Ok("/a/file.txt"));
726        test("file.txt", Ok("/file.txt"));
727        test("/file.txt", Ok("/file.txt"));
728        test("hello/world", Ok("/hello/world"));
729        test("hello/world/", Ok("/hello/world"));
730        test("a///b", Ok("/a/b"));
731        test("/a///b", Ok("/a/b"));
732        test("./world.txt", Ok("/world.txt"));
733        test("./world.txt/", Ok("/world.txt"));
734        test("hello/.././/wor/ld.typ.extra", Ok("/wor/ld.typ.extra"));
735        test("hello/.../world", Ok("/hello/.../world"));
736        test("\u{200b}..", Ok("/\u{200b}.."));
737        test("..", Err(PathError::Escapes));
738        test("../world.txt", Err(PathError::Escapes));
739        test("a\\world.txt", Err(PathError::Backslash));
740    }
741
742    #[test]
743    #[cfg(unix)]
744    fn test_virtualize_unix() {
745        test_virtualize("/", "/main.typ", Ok("/main.typ"));
746        test_virtualize("//a/b", "/a//b///c//d", Ok("/c/d"));
747        test_virtualize(
748            "/home/typst/desktop/",
749            "/home/typst/desktop/src/main.typ",
750            Ok("/src/main.typ"),
751        );
752        test_virtualize(
753            "/home/typst/desktop/",
754            "/home/typst/main.typ",
755            Err(PathError::Escapes.into()),
756        );
757    }
758
759    #[test]
760    #[cfg(windows)]
761    fn test_virtualize_windows() {
762        test_virtualize(
763            "C:\\Users\\typst\\Desktop",
764            "C:\\Users\\typst\\Desktop\\src\\main.typ",
765            Ok("/src/main.typ"),
766        );
767        test_virtualize(
768            "C:\\Users\\typst\\Desktop",
769            "C:\\Users\\typst\\main.typ",
770            Err(PathError::Escapes.into()),
771        );
772    }
773
774    #[track_caller]
775    fn test_virtualize(
776        root_path: impl AsRef<Path>,
777        path: impl AsRef<Path>,
778        expected: Result<&str, VirtualizeError>,
779    ) {
780        assert_eq!(
781            VirtualPath::virtualize(root_path.as_ref(), path.as_ref(),)
782                .as_ref()
783                .map(|v| v.get_with_slash())
784                .map_err(Clone::clone),
785            expected,
786        );
787    }
788
789    #[test]
790    fn test_realize() {
791        let p = path("src/text/main.typ");
792        assert_eq!(
793            p.realize(Path::new("/home/users/typst")),
794            Ok(PathBuf::from("/home/users/typst/src/text/main.typ")),
795        );
796    }
797
798    #[test]
799    #[cfg(windows)]
800    fn test_realize_windows() {
801        let root = Path::new("C:\\Users\\typst");
802        let invalid = |s: &str| RealizeError::Invalid(s.into());
803        assert_eq!(path("C:System32").realize(root), Err(invalid("C:")));
804        assert_eq!(path("D:Stuff").realize(root), Err(invalid("D:")));
805        assert_eq!(path("C:/System32").realize(root), Err(invalid("C:")));
806        assert_eq!(path("Foo/E:System").realize(root), Err(invalid("E:")));
807        assert_eq!(path("Foo/E:/System").realize(root), Err(invalid("E:")));
808        assert_eq!(path("F:").realize(root), Err(invalid("F:")));
809        assert_eq!(path("CON").realize(root), Err(invalid("CON")));
810        assert_eq!(path("A/CON .txt").realize(root), Err(invalid("CON .txt")));
811        assert_eq!(path("A/CON:foo/bar").realize(root), Err(invalid("CON:foo")));
812        assert_eq!(path("a/LPT\u{00b2} .baz/b").realize(root), Err(invalid("LPT² .baz")));
813    }
814
815    #[test]
816    fn test_file_ops() {
817        let p1 = path("src/text/file.typ");
818        assert_eq!(p1.file_name(), Some("file.typ"));
819        assert_eq!(p1.file_stem(), Some("file"));
820        assert_eq!(p1.extension(), Some("typ"));
821        assert_eq!(p1.with_extension("txt"), path("src/text/file.txt"));
822        assert_eq!(p1.parent(), Some(path("src/text")));
823
824        let p2 = path("src");
825        assert_eq!(p2.file_name(), Some("src"));
826        assert_eq!(p2.file_stem(), Some("src"));
827        assert_eq!(p2.extension(), None);
828        assert_eq!(p2.with_extension("txt"), path("src.txt"));
829        assert_eq!(p2.parent(), Some(path("/")));
830
831        let p3 = path("");
832        assert_eq!(p3.file_name(), None);
833        assert_eq!(p3.file_stem(), None);
834        assert_eq!(p3.extension(), None);
835        assert_eq!(p3.with_extension("txt"), p3);
836        assert_eq!(p3.parent(), None);
837    }
838
839    #[test]
840    fn test_join() {
841        let p1 = path("src");
842        assert_eq!(p1.join("a\\b"), Err(PathError::Backslash));
843        let p2 = p1.join("text").unwrap();
844        assert_eq!(p2.get_with_slash(), "/src/text");
845        let p3 = p2.join("..").unwrap();
846        assert_eq!(p1, p3);
847        assert_eq!(p3.get_with_slash(), "/src");
848        let p4 = p3.join("..").unwrap();
849        assert_eq!(p4.get_with_slash(), "/");
850        assert_eq!(p4.join(".."), Err(PathError::Escapes));
851    }
852
853    #[test]
854    fn test_relative_from() {
855        let p1 = path("src/text/main.typ");
856        assert_eq!(p1.relative_from(&path("/src/text")), "main.typ");
857        assert_eq!(p1.relative_from(&path("/src/data")), "../text/main.typ");
858        assert_eq!(p1.relative_from(&path("src/")), "text/main.typ");
859        assert_eq!(p1.relative_from(&path("/")), "src/text/main.typ");
860
861        let p2 = path("src");
862        assert_eq!(p2.relative_from(&path("src")), "");
863        assert_eq!(p2.relative_from(&path("src/data")), "..");
864    }
865
866    #[test]
867    fn test_segments() {
868        let mut s = Segments::new();
869        assert_eq!(s.get_with_slash(), "/");
870        assert_eq!(s.get_without_slash(), "");
871        s.push(Segment::new("to").unwrap());
872        assert_eq!(s.get_with_slash(), "/to");
873        s.push(Segment::new("hi.txt").unwrap());
874        assert_eq!(s.get_with_slash(), "/to/hi.txt");
875        assert_eq!(s.get_without_slash(), "to/hi.txt");
876        assert_eq!(s.last().map(Segment::get), Some("hi.txt"));
877        assert!(s.pop());
878        assert_eq!(s.get_with_slash(), "/to");
879        assert!(s.pop());
880        assert_eq!(s.get_with_slash(), "/");
881        assert!(!s.pop());
882        assert_eq!(s.get_with_slash(), "/");
883        assert_eq!(s.last(), None);
884    }
885
886    #[test]
887    fn test_segment() {
888        assert_eq!(Segment::new("\\b"), Err("\\"));
889        assert_eq!(Segment::new("a/b"), Err("/"));
890        assert_eq!(Segment::new(""), Err(""));
891        assert_eq!(Segment::new("."), Err("."));
892        assert_eq!(Segment::new(".."), Err(".."));
893    }
894}