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::error::prelude::*;
12use tinymist_std::path::{unix_slash, PathClean};
13use tinymist_std::ImmutPath;
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>) -> Option<Self> {
81        let entry = world.entry_state();
82        let id = unix_slash(entry.main()?.vpath().as_rootless_path());
83
84        let path = &ResourcePath::from_user_sys(Path::new(&id));
85        Some(path.into())
86    }
87}
88
89impl From<&ResourcePath> for Id {
90    fn from(value: &ResourcePath) -> Self {
91        Id::new(value.to_string())
92    }
93}
94
95impl fmt::Display for Id {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(&self.0)
98    }
99}
100
101/// The path pattern that could be substituted.
102///
103/// # Examples
104/// - `$root` is the root of the project.
105/// - `$root/$dir` is the parent directory of the input (main) file.
106/// - `$root/main` will help store pdf file to `$root/main.pdf` constantly.
107/// - (default) `$root/$dir/$name` will help store pdf file along with the input
108///   file.
109#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub struct PathPattern(pub EcoString);
111
112impl fmt::Display for PathPattern {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(&self.0)
115    }
116}
117
118impl PathPattern {
119    /// Creates a new path pattern.
120    pub fn new(pattern: &str) -> Self {
121        Self(pattern.into())
122    }
123
124    /// Substitutes the path pattern with `$root`, and `$dir/$name`.
125    pub fn substitute(&self, entry: &EntryState) -> Option<ImmutPath> {
126        self.substitute_impl(entry.root(), entry.main())
127    }
128
129    #[comemo::memoize]
130    fn substitute_impl(&self, root: Option<ImmutPath>, main: Option<FileId>) -> Option<ImmutPath> {
131        log::info!("Check path {main:?} and root {root:?} with output directory {self:?}");
132
133        let (root, main) = root.zip(main)?;
134
135        // Files in packages are not exported
136        if WorkspaceResolver::is_package_file(main) {
137            return None;
138        }
139        // Files without a path are not exported
140        let path = main.vpath().resolve(&root)?;
141
142        // todo: handle untitled path
143        if let Ok(path) = path.strip_prefix("/untitled") {
144            let tmp = std::env::temp_dir();
145            let path = tmp.join("typst").join(path);
146            return Some(path.as_path().into());
147        }
148
149        if self.0.is_empty() {
150            return Some(path.to_path_buf().clean().into());
151        }
152
153        let path = path.strip_prefix(&root).ok()?;
154        let dir = path.parent();
155        let file_name = path.file_name().unwrap_or_default();
156
157        let w = root.to_string_lossy();
158        let f = file_name.to_string_lossy();
159
160        // replace all $root
161        let mut path = self.0.replace("$root", &w);
162        if let Some(dir) = dir {
163            let d = dir.to_string_lossy();
164            path = path.replace("$dir", &d);
165        }
166        path = path.replace("$name", &f);
167
168        Some(Path::new(path.as_str()).clean().into())
169    }
170}
171
172/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the
173/// `CompileCommand.pages` argument, through the `FromStr` trait instead of a
174/// value parser, in order to generate better errors.
175///
176/// See also: <https://github.com/clap-rs/clap/issues/5065>
177#[derive(Debug, Clone, PartialEq, Eq, Hash)]
178pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
179
180impl Pages {
181    /// Selects the first page.
182    pub const FIRST: Pages = Pages(NonZeroUsize::new(1)..=None);
183}
184
185impl FromStr for Pages {
186    type Err = &'static str;
187
188    fn from_str(value: &str) -> Result<Self, Self::Err> {
189        match value
190            .split('-')
191            .map(str::trim)
192            .collect::<Vec<_>>()
193            .as_slice()
194        {
195            [] | [""] => Err("page export range must not be empty"),
196            [single_page] => {
197                let page_number = parse_page_number(single_page)?;
198                Ok(Pages(Some(page_number)..=Some(page_number)))
199            }
200            ["", ""] => Err("page export range must have start or end"),
201            [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
202            ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
203            [start, end] => {
204                let start = parse_page_number(start)?;
205                let end = parse_page_number(end)?;
206                if start > end {
207                    Err("page export range must end at a page after the start")
208                } else {
209                    Ok(Pages(Some(start)..=Some(end)))
210                }
211            }
212            [_, _, _, ..] => Err("page export range must have a single hyphen"),
213        }
214    }
215}
216
217impl fmt::Display for Pages {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        let start = match self.0.start() {
220            Some(start) => start.to_string(),
221            None => String::from(""),
222        };
223        let end = match self.0.end() {
224            Some(end) => end.to_string(),
225            None => String::from(""),
226        };
227        write!(f, "{start}-{end}")
228    }
229}
230
231impl serde::Serialize for Pages {
232    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
233    where
234        S: serde::Serializer,
235    {
236        serializer.serialize_str(&self.to_string())
237    }
238}
239
240impl<'de> serde::Deserialize<'de> for Pages {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: serde::Deserializer<'de>,
244    {
245        let value = String::deserialize(deserializer)?;
246        value.parse().map_err(serde::de::Error::custom)
247    }
248}
249
250/// Parses a single page number.
251fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
252    if value == "0" {
253        Err("page numbers start at one")
254    } else {
255        NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
256    }
257}
258
259/// A resource path.
260#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
261pub struct ResourcePath(EcoString, String);
262
263impl fmt::Display for ResourcePath {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(f, "{}:{}", self.0, self.1)
266    }
267}
268
269impl FromStr for ResourcePath {
270    type Err = &'static str;
271
272    fn from_str(value: &str) -> Result<Self, Self::Err> {
273        let mut parts = value.split(':');
274        let scheme = parts.next().ok_or("missing scheme")?;
275        let path = parts.next().ok_or("missing path")?;
276        if parts.next().is_some() {
277            Err("too many colons")
278        } else {
279            Ok(ResourcePath(scheme.into(), path.to_string()))
280        }
281    }
282}
283
284impl serde::Serialize for ResourcePath {
285    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
286    where
287        S: serde::Serializer,
288    {
289        serializer.serialize_str(&self.to_string())
290    }
291}
292
293impl<'de> serde::Deserialize<'de> for ResourcePath {
294    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
295    where
296        D: serde::Deserializer<'de>,
297    {
298        let value = String::deserialize(deserializer)?;
299        value.parse().map_err(serde::de::Error::custom)
300    }
301}
302
303impl ResourcePath {
304    /// Creates a new resource path from a user passing system path.
305    pub fn from_user_sys(inp: &Path) -> Self {
306        let rel = if inp.is_relative() {
307            inp.to_path_buf()
308        } else {
309            let cwd = std::env::current_dir().unwrap();
310            tinymist_std::path::diff(inp, &cwd).unwrap()
311        };
312        let rel = unix_slash(&rel);
313        ResourcePath("file".into(), rel.to_string())
314    }
315    /// Creates a new resource path from a file id.
316    pub fn from_file_id(id: FileId) -> Self {
317        let package = id.package();
318        match package {
319            Some(package) => ResourcePath(
320                "file_id".into(),
321                format!("{package}{}", unix_slash(id.vpath().as_rooted_path())),
322            ),
323            None => ResourcePath(
324                "file_id".into(),
325                format!("$root{}", unix_slash(id.vpath().as_rooted_path())),
326            ),
327        }
328    }
329
330    /// Converts the resource path to a path relative to the `base` (usually the
331    /// directory storing the lockfile).
332    pub fn to_rel_path(&self, base: &Path) -> Option<PathBuf> {
333        if self.0 == "file" {
334            let path = Path::new(&self.1);
335            if path.is_absolute() {
336                Some(tinymist_std::path::diff(path, base).unwrap_or_else(|| path.to_owned()))
337            } else {
338                Some(path.to_owned())
339            }
340        } else {
341            None
342        }
343    }
344
345    /// Converts the resource path to an absolute file system path.
346    pub fn to_abs_path(&self, base: &Path) -> Option<PathBuf> {
347        if self.0 == "file" {
348            let path = Path::new(&self.1);
349            if path.is_absolute() {
350                Some(path.to_owned())
351            } else {
352                Some(base.join(path))
353            }
354        } else {
355            None
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use typst::syntax::VirtualPath;
364
365    #[test]
366    fn test_substitute_path() {
367        let root = Path::new("/root");
368        let entry =
369            EntryState::new_rooted(root.into(), Some(VirtualPath::new("/dir1/dir2/file.txt")));
370
371        assert_eq!(
372            PathPattern::new("/substitute/$dir/$name").substitute(&entry),
373            Some(PathBuf::from("/substitute/dir1/dir2/file.txt").into())
374        );
375        assert_eq!(
376            PathPattern::new("/substitute/$dir/../$name").substitute(&entry),
377            Some(PathBuf::from("/substitute/dir1/file.txt").into())
378        );
379        assert_eq!(
380            PathPattern::new("/substitute/$name").substitute(&entry),
381            Some(PathBuf::from("/substitute/file.txt").into())
382        );
383        assert_eq!(
384            PathPattern::new("/substitute/target/$dir/$name").substitute(&entry),
385            Some(PathBuf::from("/substitute/target/dir1/dir2/file.txt").into())
386        );
387    }
388}