tinymist_task/
primitives.rs

1pub use tinymist_world::args::{ExportTarget, OutputFormat, PdfStandard, TaskWhen};
2
3use core::fmt;
4use std::hash::{Hash, Hasher};
5use std::num::NonZeroUsize;
6use std::ops::RangeInclusive;
7use std::path::PathBuf;
8use std::{path::Path, str::FromStr};
9
10use serde::{Deserialize, Serialize};
11use tinymist_std::ImmutPath;
12use tinymist_std::error::prelude::*;
13use tinymist_std::path::{PathClean, unix_slash};
14use tinymist_world::vfs::WorkspaceResolver;
15use tinymist_world::{CompilerFeat, CompilerWorld, EntryReader, EntryState};
16use typst::diag::EcoString;
17use typst::layout::PageRanges;
18use typst::syntax::FileId;
19use typst_shim::syntax::{RootedPathExt, VirtualPathExt};
20
21/// A scalar that is not NaN.
22#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
23pub struct Scalar(f32);
24
25impl TryFrom<f32> for Scalar {
26    type Error = &'static str;
27
28    fn try_from(value: f32) -> Result<Self, Self::Error> {
29        if value.is_nan() {
30            Err("NaN is not a valid scalar value")
31        } else {
32            Ok(Scalar(value))
33        }
34    }
35}
36
37impl Scalar {
38    /// Converts the scalar to an f32.
39    pub fn to_f32(self) -> f32 {
40        self.0
41    }
42}
43
44impl PartialEq for Scalar {
45    fn eq(&self, other: &Self) -> bool {
46        self.0 == other.0
47    }
48}
49
50impl Eq for Scalar {}
51
52impl Hash for Scalar {
53    fn hash<H: Hasher>(&self, state: &mut H) {
54        self.0.to_bits().hash(state);
55    }
56}
57
58impl PartialOrd for Scalar {
59    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
60        Some(self.cmp(other))
61    }
62}
63
64impl Ord for Scalar {
65    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
66        self.0.partial_cmp(&other.0).unwrap()
67    }
68}
69
70/// A project ID.
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub struct Id(String);
74
75impl Id {
76    /// Creates a new project Id.
77    pub fn new(s: String) -> Self {
78        Id(s)
79    }
80
81    /// Creates a new project Id from a world.
82    pub fn from_world<F: CompilerFeat>(world: &CompilerWorld<F>, ctx: CtxPath) -> Option<Self> {
83        let entry = world.entry_state();
84        let id = unix_slash(entry.main()?.vpath().as_rootless_path_compat());
85
86        // todo: entry root may not be set, so we should use the cwd
87        let path = &ResourcePath::from_user_sys(Path::new(&id), ctx);
88        Some(path.into())
89    }
90}
91
92impl From<&ResourcePath> for Id {
93    fn from(value: &ResourcePath) -> Self {
94        Id::new(value.to_string())
95    }
96}
97
98impl fmt::Display for Id {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104/// The path pattern that could be substituted.
105///
106/// # Examples
107/// - `$root` is the root of the project.
108/// - `$root/$dir` is the parent directory of the input (main) file.
109/// - `$root/main` will help store pdf file to `$root/main.pdf` constantly.
110/// - (default) `$root/$dir/$name` will help store pdf file along with the input
111///   file.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
113pub struct PathPattern(pub EcoString);
114
115impl fmt::Display for PathPattern {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(&self.0)
118    }
119}
120
121impl PathPattern {
122    /// Creates a new path pattern.
123    pub fn new(pattern: &str) -> Self {
124        Self(pattern.into())
125    }
126
127    /// Creates a new path pattern from a string.
128    pub fn relative_to(self, base: &Path) -> Self {
129        if self.0.is_empty() {
130            return self;
131        }
132
133        let path = Path::new(self.0.as_str());
134        if path.is_absolute() {
135            let rel_path = tinymist_std::path::diff(path, base);
136
137            match rel_path {
138                Some(rel) => PathPattern(unix_slash(&rel).into()),
139                None => self,
140            }
141        } else {
142            self
143        }
144    }
145
146    /// Substitutes the path pattern with `$root`, and `$dir/$name`.
147    pub fn substitute(&self, entry: &EntryState) -> Option<ImmutPath> {
148        self.substitute_impl(entry.root(), entry.main())
149    }
150
151    #[comemo::memoize]
152    fn substitute_impl(&self, root: Option<ImmutPath>, main: Option<FileId>) -> Option<ImmutPath> {
153        log::debug!("Check path {main:?} and root {root:?} with output directory {self:?}");
154
155        let (root, main) = root.zip(main)?;
156
157        // Files in packages are not exported
158        if WorkspaceResolver::is_package_file(main) {
159            return None;
160        }
161        // Files without a path are not exported
162        let path = main.vpath().realize(&root).ok()?;
163
164        // todo: handle untitled path
165        if let Ok(path) = path.strip_prefix("/untitled") {
166            let tmp = std::env::temp_dir();
167            let path = tmp.join("typst").join(path);
168            return Some(path.as_path().into());
169        }
170
171        let path = path.strip_prefix(&root).ok()?;
172        let dir = path.parent();
173        let file_name = path.file_name().unwrap_or_default();
174
175        let w = root.to_string_lossy();
176        let f = file_name.to_string_lossy();
177        let f = f.as_ref().strip_suffix(".typ").unwrap_or(f.as_ref());
178
179        if self.0.is_empty() {
180            let dest = dir
181                .map(|d| root.join(d).join(f))
182                .unwrap_or_else(|| root.join(f));
183            return Some(dest.clean().into());
184        }
185
186        // replace all $root
187        let mut path = self.0.replace("$root", &w);
188        if let Some(dir) = dir {
189            let d = dir.to_string_lossy();
190            let d = if d.is_empty() { "." } else { d.as_ref() };
191            path = path.replace("$dir", d);
192        }
193        path = path.replace("$name", f);
194
195        Some(Path::new(path.as_str()).clean().into())
196    }
197}
198
199/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the
200/// `CompileCommand.pages` argument, through the `FromStr` trait instead of a
201/// value parser, in order to generate better errors.
202///
203/// See also: <https://github.com/clap-rs/clap/issues/5065>
204#[derive(Debug, Clone, PartialEq, Eq, Hash)]
205pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
206
207impl Pages {
208    /// Selects the first page.
209    pub const FIRST: Pages = Pages(NonZeroUsize::new(1)..=NonZeroUsize::new(1));
210}
211
212impl FromStr for Pages {
213    type Err = &'static str;
214
215    fn from_str(value: &str) -> Result<Self, Self::Err> {
216        match value
217            .split('-')
218            .map(str::trim)
219            .collect::<Vec<_>>()
220            .as_slice()
221        {
222            [] | [""] => Err("page export range must not be empty"),
223            [single_page] => {
224                let page_number = parse_page_number(single_page)?;
225                Ok(Pages(Some(page_number)..=Some(page_number)))
226            }
227            ["", ""] => Err("page export range must have start or end"),
228            [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
229            ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
230            [start, end] => {
231                let start = parse_page_number(start)?;
232                let end = parse_page_number(end)?;
233                if start > end {
234                    Err("page export range must end at a page after the start")
235                } else {
236                    Ok(Pages(Some(start)..=Some(end)))
237                }
238            }
239            [_, _, _, ..] => Err("page export range must have a single hyphen"),
240        }
241    }
242}
243
244/// The ranges of the pages to be exported as specified by the user.
245pub fn exported_page_ranges(pages: &[Pages]) -> PageRanges {
246    PageRanges::new(pages.iter().map(|p| p.0.clone()).collect())
247}
248
249impl fmt::Display for Pages {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        let start = match self.0.start() {
252            Some(start) => start.to_string(),
253            None => String::from(""),
254        };
255        let end = match self.0.end() {
256            Some(end) => end.to_string(),
257            None => String::from(""),
258        };
259        write!(f, "{start}-{end}")
260    }
261}
262
263impl serde::Serialize for Pages {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: serde::Serializer,
267    {
268        serializer.serialize_str(&self.to_string())
269    }
270}
271
272impl<'de> serde::Deserialize<'de> for Pages {
273    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274    where
275        D: serde::Deserializer<'de>,
276    {
277        let value = String::deserialize(deserializer)?;
278        value.parse().map_err(serde::de::Error::custom)
279    }
280}
281
282/// Parses a single page number.
283fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
284    if value == "0" {
285        Err("page numbers start at one")
286    } else {
287        NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
288    }
289}
290
291/// A resource path.
292#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
293pub struct ResourcePath(EcoString, String);
294
295impl fmt::Display for ResourcePath {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        write!(f, "{}:{}", self.0, self.1)
298    }
299}
300
301impl FromStr for ResourcePath {
302    type Err = &'static str;
303
304    fn from_str(value: &str) -> Result<Self, Self::Err> {
305        let mut parts = value.split(':');
306        let scheme = parts.next().ok_or("missing scheme")?;
307        let path = parts.next().ok_or("missing path")?;
308        if parts.next().is_some() {
309            Err("too many colons")
310        } else {
311            Ok(ResourcePath(scheme.into(), path.to_string()))
312        }
313    }
314}
315
316impl serde::Serialize for ResourcePath {
317    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
318    where
319        S: serde::Serializer,
320    {
321        serializer.serialize_str(&self.to_string())
322    }
323}
324
325impl<'de> serde::Deserialize<'de> for ResourcePath {
326    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
327    where
328        D: serde::Deserializer<'de>,
329    {
330        let value = String::deserialize(deserializer)?;
331        value.parse().map_err(serde::de::Error::custom)
332    }
333}
334
335/// The path context.
336// todo: The ctx path looks not quite maintainable. But we only target to make
337// things correct, then back to make code good.
338pub type CtxPath<'a, 'b> = (/* cwd */ &'a Path, /* lock_dir */ &'b Path);
339
340impl ResourcePath {
341    /// Creates a new resource path from a user passing system path.
342    pub fn from_user_sys(inp: &Path, (cwd, lock_dir): CtxPath) -> Self {
343        let abs = if inp.is_absolute() {
344            inp.to_path_buf()
345        } else {
346            cwd.join(inp)
347        };
348        let resource_path = if let Some(rel) = tinymist_std::path::diff(&abs, lock_dir) {
349            rel
350        } else {
351            abs
352        };
353        // todo: clean is not posix compatible,
354        // for example /symlink/../file is not equivalent to /file
355        let rel = unix_slash(&resource_path.clean());
356        ResourcePath("file".into(), rel.to_string())
357    }
358
359    /// Creates a new resource path from a file id.
360    pub fn from_file_id(id: FileId) -> Self {
361        if let Some(package) = id.package_compat() {
362            ResourcePath(
363                "file_id".into(),
364                format!(
365                    "{package}{}",
366                    unix_slash(id.vpath().as_rooted_path_compat())
367                ),
368            )
369        } else {
370            ResourcePath(
371                "file_id".into(),
372                format!("$root{}", unix_slash(id.vpath().as_rooted_path_compat())),
373            )
374        }
375    }
376
377    /// Converts the resource path to a path relative to the `base` (usually the
378    /// directory storing the lockfile).
379    pub fn relative_to(&self, base: &Path) -> Option<Self> {
380        if self.0 == "file" {
381            let path = Path::new(&self.1);
382            if path.is_absolute() {
383                let rel_path = tinymist_std::path::diff(path, base)?;
384                Some(ResourcePath(self.0.clone(), unix_slash(&rel_path)))
385            } else {
386                Some(ResourcePath(self.0.clone(), self.1.clone()))
387            }
388        } else {
389            Some(self.clone())
390        }
391    }
392
393    /// Converts the resource path to a path relative to the `base` (usually the
394    /// directory storing the lockfile).
395    pub fn to_rel_path(&self, base: &Path) -> Option<PathBuf> {
396        if self.0 == "file" {
397            let path = Path::new(&self.1);
398            if path.is_absolute() {
399                Some(tinymist_std::path::diff(path, base).unwrap_or_else(|| path.to_owned()))
400            } else {
401                Some(path.to_owned())
402            }
403        } else {
404            None
405        }
406    }
407
408    /// Converts the resource path to an absolute file system path.
409    pub fn to_abs_path(&self, base: &Path) -> Option<PathBuf> {
410        if self.0 == "file" {
411            let path = Path::new(&self.1);
412            if path.is_absolute() {
413                Some(path.to_owned())
414            } else {
415                Some(base.join(path))
416            }
417        } else {
418            None
419        }
420    }
421}
422
423/// Utilities for output template processing.
424/// Copied from typst-cli.
425pub mod output_template {
426    const INDEXABLE: [&str; 3] = ["{p}", "{0p}", "{n}"];
427
428    /// Checks if the output template has indexable templates.
429    pub fn has_indexable_template(output: &str) -> bool {
430        INDEXABLE.iter().any(|template| output.contains(template))
431    }
432
433    /// Formats the output template with the given page number and total pages.
434    /// Note: `this_page` is 1-based.
435    pub fn format(output: &str, this_page: usize, total_pages: usize) -> String {
436        // Find the base 10 width of number `i`
437        fn width(i: usize) -> usize {
438            1 + i.checked_ilog10().unwrap_or(0) as usize
439        }
440
441        let other_templates = ["{t}"];
442        INDEXABLE
443            .iter()
444            .chain(other_templates.iter())
445            .fold(output.to_string(), |out, template| {
446                let replacement = match *template {
447                    "{p}" => format!("{this_page}"),
448                    "{0p}" | "{n}" => format!("{:01$}", this_page, width(total_pages)),
449                    "{t}" => format!("{total_pages}"),
450                    _ => unreachable!("unhandled template placeholder {template}"),
451                };
452                out.replace(template, replacement.as_str())
453            })
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use typst::syntax::VirtualPath;
461
462    fn test_root() -> PathBuf {
463        if cfg!(windows) {
464            PathBuf::from(r"C:\dummy-root")
465        } else {
466            PathBuf::from("/dummy-root")
467        }
468    }
469
470    fn test_entry(path: &str) -> EntryState {
471        let root = test_root();
472        EntryState::new_rooted(root.into(), Some(VirtualPath::new(path).unwrap()))
473    }
474
475    #[test]
476    fn test_substitute_path() {
477        let root = Path::new("/dummy-root");
478        let entry = EntryState::new_rooted(
479            root.into(),
480            Some(VirtualPath::new("/dir1/dir2/file.txt").unwrap()),
481        );
482
483        assert_eq!(
484            PathPattern::new("/substitute/$dir/$name").substitute(&entry),
485            Some(PathBuf::from("/substitute/dir1/dir2/file.txt").into())
486        );
487        assert_eq!(
488            PathPattern::new("/substitute/$dir/../$name").substitute(&entry),
489            Some(PathBuf::from("/substitute/dir1/file.txt").into())
490        );
491        assert_eq!(
492            PathPattern::new("/substitute/$name").substitute(&entry),
493            Some(PathBuf::from("/substitute/file.txt").into())
494        );
495        assert_eq!(
496            PathPattern::new("/substitute/target/$dir/$name").substitute(&entry),
497            Some(PathBuf::from("/substitute/target/dir1/dir2/file.txt").into())
498        );
499    }
500
501    #[test]
502    fn test_substitute_path_keeps_workspace_root_relative() {
503        let entry = test_entry("/Chapter 1.1.typ");
504
505        assert_eq!(
506            PathPattern::new("$dir/$name").substitute(&entry),
507            Some(PathBuf::from("Chapter 1.1").into())
508        );
509    }
510
511    #[test]
512    fn test_substitute_default_path_matches_documented_behavior() {
513        let root = test_root();
514        let entry = test_entry("/Chapter 1.1.typ");
515
516        assert_eq!(
517            PathPattern::default().substitute(&entry),
518            Some(root.join("Chapter 1.1").into())
519        );
520    }
521
522    #[test]
523    fn test_substitute_path_preserves_multi_dot_stem() {
524        let root = test_root();
525        let entry = test_entry("/chapters/Chapter 1.1.1.typ");
526
527        assert_eq!(
528            PathPattern::new("$root/out/$dir/$name").substitute(&entry),
529            Some(root.join("out/chapters/Chapter 1.1.1").into())
530        );
531    }
532
533    #[test]
534    fn test_substitute_path_preserves_name_without_extension() {
535        let root = test_root();
536        let entry = test_entry("/README");
537
538        assert_eq!(
539            PathPattern::new("$root/$dir/$name").substitute(&entry),
540            Some(root.join("README").into())
541        );
542    }
543}