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}