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