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