typst-library 0.15.0

Typst's standard library.
Documentation
use ecow::{EcoString, eco_format};
use typst_syntax::{FileId, PathError, RootedPath, Spanned, VirtualPath, VirtualRoot};

use crate::diag::{
    At, HintedStrResult, HintedString, SourceResult, StrResult, bail, error,
};
use crate::foundations::{Repr, Str, cast, func, scope, ty};

/// A file system path.
///
/// When splitting up your project or package across multiple files, or
/// referencing resources such as images or bibliographies, you'll need to
/// interact with _paths._
///
/// = Path strings <path-strings>
/// Commonly, paths are simply expressed as @str[strings]. Built-in functions
/// that expect paths typically also accept strings. For instance, you can
/// write:
///
/// ```typ
/// #figure(
///   // Path to an image
///   image("tiger.jpg"),
///   caption: [A tiger],
/// )
///
/// // Path to a Typst file
/// #include "chapter.typ"
/// ```
///
/// There are two kinds of such path strings: Relative and absolute.
///
/// - A *relative path* resolves in relation to the parent directory of the
///   Typst file where the function is called. While this is the default, a path
///   can also be explicitly specified as being relative by starting it with
///   `./`.
///
///   ```typ
///   #image("images/logo.png")
///   #image("./images/logo.png") // This is equivalent
///   ```
///
/// - An *absolute path* always resolves relative to the
///   @path:project-root[_root_] of the project. Such a path is indicated by a
///   leading `/`:
///
///   ```typ
///   #image("/assets/logo.png")
///   ```
///
/// Paths consist of segments that are separated by forward slashes, with
/// interior segments indicating directories and the final one a file or a
/// directory. There are two path components that are treated specially:
///
/// - The segment `.` refers to the _current_ directory. This is why
///   `{"./image.png"}` and `{"image.png"}` are equivalent.
///
/// - The segment `..` refers to the _parent_ directory. If you have three files
///   `main.typ`, `utils.typ`, and `text/chapter1.typ`, then you can reference
///   your utility file from chapter 1 through the path `{"../utils.typ"}`.
///
/// = #short-or-long[Path Type][The path type] <path-type>
/// For most typical usage of paths, strings are all you need. However,
/// sometimes you need a bit more control. For instance, you may want to resolve
/// a path relative to the file you are currently writing in, but then pass it
/// to a package and let the package read from the path. This is where the path
/// type comes in.
///
/// With it, you can fully resolve a path string relative to the file where you
/// construct it. Any following operations performed with the path (such as a
/// file read or an image load), will then behave the same regardless of where
/// in the code they occur.
///
/// Here's an example of how we could have a `main.typ` with a `data.json` file
/// directly next to it and still let a package we've built read that file.
///
/// ```typ
/// // This is main.typ, with data.json next to it.
/// #import "@local/my-pkg:0.1.0": process
/// #let data-path = path("data.json")
/// #process(data-path)
/// ```
///
/// = Roots <roots>
/// == #short-or-long[Project Root][The project root] <project-root>
/// For security and reproducibility reasons, Typst encapsulates file access. A
/// Typst project can only access paths within its _project root._ If you try to
/// create or access a path outside of this root, you'll get an error:
///
/// ```typ
/// // ❌ Error: path `"../secret.txt"` would escape the project root
/// #path("../secret.txt")
/// ```
///
/// By default, the project root is the parent directory of the main Typst file.
/// If you wish to use another folder as the root of your project, you can use
/// the CLI's `--root` flag:
///
/// ```bash
/// typst compile --root .. file.typ
/// ```
///
/// Make sure that the main file is contained in the folder's subtree, so that
/// Typst can access it.
///
/// In the web app, the project itself is the root directory. You can always
/// read all files within it, no matter which one is previewed (via the eye
/// toggle next to each Typst file in the file panel).
///
/// == Package roots <package-roots>
/// Just like the project, each package you import has its own root. Within a
/// package, absolute paths point to the package root rather than the project
/// root. On its own, code in a package cannot construct a path that lives in
/// the project or another package.
///
/// If you need to provide a package with resources from the project (such as a
/// logo image), you can do so by explicitly creating a path to the resource in
/// your code with the @path.constructor[path constructor]. You can then pass
/// the resulting path to the package. An example of this is shown in the
/// section @path:path-type["The path type"] above.
///
/// Alternatively, you can perform the path operation in your code and pass the
/// result to the package. This could, for example, be the result of a @read
/// call or a complete image (e.g. as a named parameter
/// `{logo: image("mylogo.svg")}`). Note that if you pass an image to a package
/// like this, you can still customize the image's appearance with a set rule
/// within the package.
///
/// = Further operations <further-operations>
/// For now, the path type's purpose is limited to correctly handling and
/// transferring paths across files in your project and packages. In the future,
/// it may enable additional capabilities like checking for the existence of a
/// file or enumerating files in a directory.
#[ty(scope, name = "path")]
#[derive(Debug, Clone, PartialEq, Hash)]
type RootedPath;

#[scope(ext)]
impl RootedPath {
    /// Creates a path from a string.
    ///
    /// ```typ
    /// // A relative path without a leading slash.
    /// // May optionally start with `./`.
    /// #path("relative/path/to/file.typ")
    /// #path("./relative/path/to/file.typ")
    ///
    /// // An absolute path with a leading slash.
    /// #path("/absolute/path/to/file.typ")
    /// ```
    #[func(constructor)]
    pub fn construct(
        /// Converts a string or path to a path.
        ///
        /// If this is a @path:path-strings[path string]:
        /// - If the path is absolute, it is resolved relative to the root of
        ///   the project or package in which this function is called.
        /// - If the path is relative, it is resolved relative to the file where
        ///   this function is called.
        ///
        /// If this is already a `path`, it is returned unchanged.
        path: Spanned<PathOrStr>,
    ) -> SourceResult<RootedPath> {
        path.v.resolve_if_some(path.span.id()).at(path.span)
    }
}

impl Repr for RootedPath {
    fn repr(&self) -> EcoString {
        // The package spec is hard to reasonably express, but I'm also not sure
        // whether we want to expose it. For the path itself, we always use an
        // absolute one as that's the most portable representation.
        eco_format!("path({})", self.vpath().get_with_slash().repr())
    }
}

/// A string or a path.
///
/// This type is commonly accepted by functions that read from a path.
#[derive(Debug, Clone, PartialEq, Hash)]
pub enum PathOrStr {
    Path(RootedPath),
    Str(Str),
}

impl PathOrStr {
    /// Resolves this path or string relative to the file that resides at
    /// `within`.
    ///
    /// The path string may be absolute or relative. If relative, it's resolved
    /// relative to the parent directory of `within` (which should point to a
    /// file rather than a directory).
    pub fn resolve(&self, within: FileId) -> HintedStrResult<RootedPath> {
        Ok(match self {
            PathOrStr::Path(v) => v.clone(),
            PathOrStr::Str(v) => {
                let root = within.root();
                let base = within.vpath();
                let resolved = match base.parent() {
                    Some(parent) => parent.join(v),
                    None => base.join(v),
                }
                .map_err(|err| format_resolve_error(err, root, v))?;
                RootedPath::new(root.clone(), resolved)
            }
        })
    }

    /// [Resolves](Self::resolve) the path if `within` is `Some(_)` or returns
    /// an error that the file system could not be accessed, otherwise.
    pub fn resolve_if_some(&self, within: Option<FileId>) -> HintedStrResult<RootedPath> {
        self.resolve(within.ok_or("cannot access file system from here")?)
    }
}

cast! {
    PathOrStr,
    self => match self {
        Self::Path(v) => v.into_value(),
        Self::Str(v) => v.into_value(),
    },
    v: RootedPath => Self::Path(v),
    v: Str => Self::Str(v),
}

/// Format the user-facing error message for path resolving.
fn format_resolve_error(err: PathError, root: &VirtualRoot, path: &str) -> HintedString {
    match err {
        PathError::Escapes => {
            let kind = match root {
                VirtualRoot::Project => "project",
                VirtualRoot::Package(_) => "package",
            };
            let mut diag = error!(
                "path `{}` would escape the {kind} root", path.repr();
                hint: "cannot access files outside of the {kind} sandbox";
            );
            if *root == VirtualRoot::Project {
                diag.hint("you can adjust the project root with the `--root` argument");
            }
            diag
        }
        PathError::Backslash => error!(
            "path must not contain a backslash";
            hint: "use forward slashes instead: `{}`",
            path.replace("\\", "/").repr();
            hint: "in earlier Typst versions, backslashes indicated path separators on Windows";
            hint: "this behavior is no longer supported as it is not portable";
        ),
    }
}

/// A path in bundle output.
///
/// Unlike `PathOrStr`, a string cast through this is always an absolute path
/// instead of being resolve relative to a file. This is not used for normal
/// paths in Typst files, but rather for output file paths in bundle mode.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct BundlePath(VirtualPath);

impl BundlePath {
    /// Wraps a virtual path, ensuring it has at least one component.
    pub fn new(path: VirtualPath) -> StrResult<Self> {
        if path.is_root() {
            bail!("path must have at least one component");
        }
        Ok(Self(path))
    }

    /// Extracts the contained virtual path.
    pub fn into_inner(self) -> VirtualPath {
        self.0
    }
}

impl AsRef<VirtualPath> for BundlePath {
    fn as_ref(&self) -> &VirtualPath {
        &self.0
    }
}

cast! {
    BundlePath,
    self => self.0.into_with_slash().into_value(),
    v: Str => Self::new(
        VirtualPath::new(&v).map_err(|err| format_bundle_error(err, &v))?
    )?
}

/// Format the user-facing error message for virtual path casts.
fn format_bundle_error(err: PathError, path: &str) -> HintedString {
    match err {
        PathError::Escapes => {
            error!("path `{}` would escape the bundle root", path.repr())
        }
        PathError::Backslash => error!(
            "path must not contain a backslash";
            hint: "use forward slashes instead: `{}`",
            path.replace("\\", "/").repr();
        ),
    }
}

#[cfg(test)]
mod tests {
    use typst_syntax::{VirtualPath, VirtualRoot};

    use super::*;

    #[test]
    fn test_resolve() {
        let path =
            |p| RootedPath::new(VirtualRoot::Project, VirtualPath::new(p).unwrap());
        let id = |p| path(p).intern();
        let id1 = id("src/main.typ");
        let resolve = |s: &str| {
            PathOrStr::Str(s.into())
                .resolve(id1)
                .map_err(|err| err.message().clone())
        };
        assert_eq!(resolve("works.bib"), Ok(path("src/works.bib")));
        assert_eq!(resolve(""), Ok(path("/src")));
        assert_eq!(resolve("."), Ok(path("/src")));
        assert_eq!(resolve(".."), Ok(path("/")));
        assert_eq!(
            resolve("../.."),
            Err("path `\"../..\"` would escape the project root".into())
        );
        assert_eq!(resolve("a\\b"), Err("path must not contain a backslash".into()));
    }
}