Skip to main content

typst_library/foundations/
path.rs

1use ecow::{EcoString, eco_format};
2use typst_syntax::{FileId, PathError, RootedPath, Spanned, VirtualPath, VirtualRoot};
3
4use crate::diag::{
5    At, HintedStrResult, HintedString, SourceResult, StrResult, bail, error,
6};
7use crate::foundations::{Repr, Str, cast, func, scope, ty};
8
9/// A file system path.
10///
11/// When splitting up your project or package across multiple files, or
12/// referencing resources such as images or bibliographies, you'll need to
13/// interact with _paths._
14///
15/// = Path strings <path-strings>
16/// Commonly, paths are simply expressed as @str[strings]. Built-in functions
17/// that expect paths typically also accept strings. For instance, you can
18/// write:
19///
20/// ```typ
21/// #figure(
22///   // Path to an image
23///   image("tiger.jpg"),
24///   caption: [A tiger],
25/// )
26///
27/// // Path to a Typst file
28/// #include "chapter.typ"
29/// ```
30///
31/// There are two kinds of such path strings: Relative and absolute.
32///
33/// - A *relative path* resolves in relation to the parent directory of the
34///   Typst file where the function is called. While this is the default, a path
35///   can also be explicitly specified as being relative by starting it with
36///   `./`.
37///
38///   ```typ
39///   #image("images/logo.png")
40///   #image("./images/logo.png") // This is equivalent
41///   ```
42///
43/// - An *absolute path* always resolves relative to the
44///   @path:project-root[_root_] of the project. Such a path is indicated by a
45///   leading `/`:
46///
47///   ```typ
48///   #image("/assets/logo.png")
49///   ```
50///
51/// Paths consist of segments that are separated by forward slashes, with
52/// interior segments indicating directories and the final one a file or a
53/// directory. There are two path components that are treated specially:
54///
55/// - The segment `.` refers to the _current_ directory. This is why
56///   `{"./image.png"}` and `{"image.png"}` are equivalent.
57///
58/// - The segment `..` refers to the _parent_ directory. If you have three files
59///   `main.typ`, `utils.typ`, and `text/chapter1.typ`, then you can reference
60///   your utility file from chapter 1 through the path `{"../utils.typ"}`.
61///
62/// = #short-or-long[Path Type][The path type] <path-type>
63/// For most typical usage of paths, strings are all you need. However,
64/// sometimes you need a bit more control. For instance, you may want to resolve
65/// a path relative to the file you are currently writing in, but then pass it
66/// to a package and let the package read from the path. This is where the path
67/// type comes in.
68///
69/// With it, you can fully resolve a path string relative to the file where you
70/// construct it. Any following operations performed with the path (such as a
71/// file read or an image load), will then behave the same regardless of where
72/// in the code they occur.
73///
74/// Here's an example of how we could have a `main.typ` with a `data.json` file
75/// directly next to it and still let a package we've built read that file.
76///
77/// ```typ
78/// // This is main.typ, with data.json next to it.
79/// #import "@local/my-pkg:0.1.0": process
80/// #let data-path = path("data.json")
81/// #process(data-path)
82/// ```
83///
84/// = Roots <roots>
85/// == #short-or-long[Project Root][The project root] <project-root>
86/// For security and reproducibility reasons, Typst encapsulates file access. A
87/// Typst project can only access paths within its _project root._ If you try to
88/// create or access a path outside of this root, you'll get an error:
89///
90/// ```typ
91/// // ❌ Error: path `"../secret.txt"` would escape the project root
92/// #path("../secret.txt")
93/// ```
94///
95/// By default, the project root is the parent directory of the main Typst file.
96/// If you wish to use another folder as the root of your project, you can use
97/// the CLI's `--root` flag:
98///
99/// ```bash
100/// typst compile --root .. file.typ
101/// ```
102///
103/// Make sure that the main file is contained in the folder's subtree, so that
104/// Typst can access it.
105///
106/// In the web app, the project itself is the root directory. You can always
107/// read all files within it, no matter which one is previewed (via the eye
108/// toggle next to each Typst file in the file panel).
109///
110/// == Package roots <package-roots>
111/// Just like the project, each package you import has its own root. Within a
112/// package, absolute paths point to the package root rather than the project
113/// root. On its own, code in a package cannot construct a path that lives in
114/// the project or another package.
115///
116/// If you need to provide a package with resources from the project (such as a
117/// logo image), you can do so by explicitly creating a path to the resource in
118/// your code with the @path.constructor[path constructor]. You can then pass
119/// the resulting path to the package. An example of this is shown in the
120/// section @path:path-type["The path type"] above.
121///
122/// Alternatively, you can perform the path operation in your code and pass the
123/// result to the package. This could, for example, be the result of a @read
124/// call or a complete image (e.g. as a named parameter
125/// `{logo: image("mylogo.svg")}`). Note that if you pass an image to a package
126/// like this, you can still customize the image's appearance with a set rule
127/// within the package.
128///
129/// = Further operations <further-operations>
130/// For now, the path type's purpose is limited to correctly handling and
131/// transferring paths across files in your project and packages. In the future,
132/// it may enable additional capabilities like checking for the existence of a
133/// file or enumerating files in a directory.
134#[ty(scope, name = "path")]
135#[derive(Debug, Clone, PartialEq, Hash)]
136type RootedPath;
137
138#[scope(ext)]
139impl RootedPath {
140    /// Creates a path from a string.
141    ///
142    /// ```typ
143    /// // A relative path without a leading slash.
144    /// // May optionally start with `./`.
145    /// #path("relative/path/to/file.typ")
146    /// #path("./relative/path/to/file.typ")
147    ///
148    /// // An absolute path with a leading slash.
149    /// #path("/absolute/path/to/file.typ")
150    /// ```
151    #[func(constructor)]
152    pub fn construct(
153        /// Converts a string or path to a path.
154        ///
155        /// If this is a @path:path-strings[path string]:
156        /// - If the path is absolute, it is resolved relative to the root of
157        ///   the project or package in which this function is called.
158        /// - If the path is relative, it is resolved relative to the file where
159        ///   this function is called.
160        ///
161        /// If this is already a `path`, it is returned unchanged.
162        path: Spanned<PathOrStr>,
163    ) -> SourceResult<RootedPath> {
164        path.v.resolve_if_some(path.span.id()).at(path.span)
165    }
166}
167
168impl Repr for RootedPath {
169    fn repr(&self) -> EcoString {
170        // The package spec is hard to reasonably express, but I'm also not sure
171        // whether we want to expose it. For the path itself, we always use an
172        // absolute one as that's the most portable representation.
173        eco_format!("path({})", self.vpath().get_with_slash().repr())
174    }
175}
176
177/// A string or a path.
178///
179/// This type is commonly accepted by functions that read from a path.
180#[derive(Debug, Clone, PartialEq, Hash)]
181pub enum PathOrStr {
182    Path(RootedPath),
183    Str(Str),
184}
185
186impl PathOrStr {
187    /// Resolves this path or string relative to the file that resides at
188    /// `within`.
189    ///
190    /// The path string may be absolute or relative. If relative, it's resolved
191    /// relative to the parent directory of `within` (which should point to a
192    /// file rather than a directory).
193    pub fn resolve(&self, within: FileId) -> HintedStrResult<RootedPath> {
194        Ok(match self {
195            PathOrStr::Path(v) => v.clone(),
196            PathOrStr::Str(v) => {
197                let root = within.root();
198                let base = within.vpath();
199                let resolved = match base.parent() {
200                    Some(parent) => parent.join(v),
201                    None => base.join(v),
202                }
203                .map_err(|err| format_resolve_error(err, root, v))?;
204                RootedPath::new(root.clone(), resolved)
205            }
206        })
207    }
208
209    /// [Resolves](Self::resolve) the path if `within` is `Some(_)` or returns
210    /// an error that the file system could not be accessed, otherwise.
211    pub fn resolve_if_some(&self, within: Option<FileId>) -> HintedStrResult<RootedPath> {
212        self.resolve(within.ok_or("cannot access file system from here")?)
213    }
214}
215
216cast! {
217    PathOrStr,
218    self => match self {
219        Self::Path(v) => v.into_value(),
220        Self::Str(v) => v.into_value(),
221    },
222    v: RootedPath => Self::Path(v),
223    v: Str => Self::Str(v),
224}
225
226/// Format the user-facing error message for path resolving.
227fn format_resolve_error(err: PathError, root: &VirtualRoot, path: &str) -> HintedString {
228    match err {
229        PathError::Escapes => {
230            let kind = match root {
231                VirtualRoot::Project => "project",
232                VirtualRoot::Package(_) => "package",
233            };
234            let mut diag = error!(
235                "path `{}` would escape the {kind} root", path.repr();
236                hint: "cannot access files outside of the {kind} sandbox";
237            );
238            if *root == VirtualRoot::Project {
239                diag.hint("you can adjust the project root with the `--root` argument");
240            }
241            diag
242        }
243        PathError::Backslash => error!(
244            "path must not contain a backslash";
245            hint: "use forward slashes instead: `{}`",
246            path.replace("\\", "/").repr();
247            hint: "in earlier Typst versions, backslashes indicated path separators on Windows";
248            hint: "this behavior is no longer supported as it is not portable";
249        ),
250    }
251}
252
253/// A path in bundle output.
254///
255/// Unlike `PathOrStr`, a string cast through this is always an absolute path
256/// instead of being resolve relative to a file. This is not used for normal
257/// paths in Typst files, but rather for output file paths in bundle mode.
258#[derive(Debug, Clone, Eq, PartialEq, Hash)]
259pub struct BundlePath(VirtualPath);
260
261impl BundlePath {
262    /// Wraps a virtual path, ensuring it has at least one component.
263    pub fn new(path: VirtualPath) -> StrResult<Self> {
264        if path.is_root() {
265            bail!("path must have at least one component");
266        }
267        Ok(Self(path))
268    }
269
270    /// Extracts the contained virtual path.
271    pub fn into_inner(self) -> VirtualPath {
272        self.0
273    }
274}
275
276impl AsRef<VirtualPath> for BundlePath {
277    fn as_ref(&self) -> &VirtualPath {
278        &self.0
279    }
280}
281
282cast! {
283    BundlePath,
284    self => self.0.into_with_slash().into_value(),
285    v: Str => Self::new(
286        VirtualPath::new(&v).map_err(|err| format_bundle_error(err, &v))?
287    )?
288}
289
290/// Format the user-facing error message for virtual path casts.
291fn format_bundle_error(err: PathError, path: &str) -> HintedString {
292    match err {
293        PathError::Escapes => {
294            error!("path `{}` would escape the bundle root", path.repr())
295        }
296        PathError::Backslash => error!(
297            "path must not contain a backslash";
298            hint: "use forward slashes instead: `{}`",
299            path.replace("\\", "/").repr();
300        ),
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use typst_syntax::{VirtualPath, VirtualRoot};
307
308    use super::*;
309
310    #[test]
311    fn test_resolve() {
312        let path =
313            |p| RootedPath::new(VirtualRoot::Project, VirtualPath::new(p).unwrap());
314        let id = |p| path(p).intern();
315        let id1 = id("src/main.typ");
316        let resolve = |s: &str| {
317            PathOrStr::Str(s.into())
318                .resolve(id1)
319                .map_err(|err| err.message().clone())
320        };
321        assert_eq!(resolve("works.bib"), Ok(path("src/works.bib")));
322        assert_eq!(resolve(""), Ok(path("/src")));
323        assert_eq!(resolve("."), Ok(path("/src")));
324        assert_eq!(resolve(".."), Ok(path("/")));
325        assert_eq!(
326            resolve("../.."),
327            Err("path `\"../..\"` would escape the project root".into())
328        );
329        assert_eq!(resolve("a\\b"), Err("path must not contain a backslash".into()));
330    }
331}