tinymist_project/lock/
system.rs

1use std::cmp::Ordering;
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::{path::Path, sync::Arc};
4
5use ecow::{EcoVec, eco_vec};
6use tinymist_std::error::prelude::*;
7use tinymist_std::path::unix_slash;
8use tinymist_std::{ImmutPath, bail};
9use tinymist_task::CtxPath;
10use typst::World;
11use typst::diag::EcoString;
12
13use crate::model::{ApplyProjectTask, Id, ProjectInput, ProjectRoute, ResourcePath};
14use crate::{LOCK_FILENAME, LOCK_VERSION, LockFile, LockFileCompat, ProjectPathMaterial};
15
16impl LockFile {
17    /// Gets the input by the id.
18    pub fn get_document(&self, id: &Id) -> Option<&ProjectInput> {
19        self.document.iter().find(|i| &i.id == id)
20    }
21
22    /// Gets the task by the id.
23    pub fn get_task(&self, id: &Id) -> Option<&ApplyProjectTask> {
24        self.task.iter().find(|i| &i.id == id)
25    }
26
27    /// Replaces the input by the id.
28    pub fn replace_document(&mut self, mut input: ProjectInput) {
29        input.lock_dir = None;
30        let input = input;
31        let id = input.id.clone();
32        let index = self.document.iter().position(|i| i.id == id);
33        if let Some(index) = index {
34            self.document[index] = input;
35        } else {
36            self.document.push(input);
37        }
38    }
39
40    /// Replaces the task by the id.
41    pub fn replace_task(&mut self, mut task: ApplyProjectTask) {
42        if let Some(pat) = task.task.as_export_mut().and_then(|t| t.output.as_mut()) {
43            let rel = pat.clone().relative_to(self.lock_dir.as_ref().unwrap());
44            *pat = rel;
45        }
46
47        let task = task;
48
49        let id = task.id().clone();
50        let index = self.task.iter().position(|i| *i.id() == id);
51        if let Some(index) = index {
52            self.task[index] = task;
53        } else {
54            self.task.push(task);
55        }
56    }
57
58    /// Replaces the route by the id.
59    pub fn replace_route(&mut self, route: ProjectRoute) {
60        let id = route.id.clone();
61
62        self.route.retain(|i| i.id != id);
63        self.route.push(route);
64    }
65
66    /// Sorts the document, task, and route.
67    pub fn sort(&mut self) {
68        self.document.sort_by(|a, b| a.id.cmp(&b.id));
69        self.task
70            .sort_by(|a, b| a.doc_id().cmp(b.doc_id()).then_with(|| a.id().cmp(b.id())));
71        // the route's order is important, so we don't sort them.
72    }
73
74    /// Serializes the lock file.
75    pub fn serialize_resolve(&self) -> String {
76        let content = toml::Table::try_from(self).unwrap();
77
78        let mut out = String::new();
79
80        // At the start of the file we notify the reader that the file is generated.
81        // Specifically Phabricator ignores files containing "@generated", so we use
82        // that.
83        let marker_line = "# This file is automatically @generated by tinymist.";
84        let extra_line = "# It is not intended for manual editing.";
85
86        out.push_str(marker_line);
87        out.push('\n');
88        out.push_str(extra_line);
89        out.push('\n');
90
91        out.push_str(&format!("version = {LOCK_VERSION:?}\n"));
92
93        let document = content.get("document");
94        if let Some(document) = document {
95            for document in document.as_array().unwrap() {
96                out.push('\n');
97                out.push_str("[[document]]\n");
98                emit_document(document, &mut out);
99            }
100        }
101
102        let route = content.get("route");
103        if let Some(route) = route {
104            for route in route.as_array().unwrap() {
105                out.push('\n');
106                out.push_str("[[route]]\n");
107                emit_route(route, &mut out);
108            }
109        }
110
111        let task = content.get("task");
112        if let Some(task) = task {
113            for task in task.as_array().unwrap() {
114                out.push('\n');
115                out.push_str("[[task]]\n");
116                emit_output(task, &mut out);
117            }
118        }
119
120        return out;
121
122        fn emit_document(input: &toml::Value, out: &mut String) {
123            let table = input.as_table().unwrap();
124            out.push_str(&table.to_string());
125        }
126
127        fn emit_output(output: &toml::Value, out: &mut String) {
128            let mut table = output.clone();
129            let table = table.as_table_mut().unwrap();
130            // replace transform with task.transforms
131            if let Some(transform) = table.remove("transform") {
132                let mut task_table = toml::Table::new();
133                task_table.insert("transform".to_string(), transform);
134
135                table.insert("task".to_string(), task_table.into());
136            }
137
138            out.push_str(&table.to_string());
139        }
140
141        fn emit_route(route: &toml::Value, out: &mut String) {
142            let table = route.as_table().unwrap();
143            out.push_str(&table.to_string());
144        }
145    }
146
147    /// Updates the lock file.
148    pub fn update(cwd: &Path, f: impl FnOnce(&mut Self) -> Result<()>) -> Result<()> {
149        let fs = tinymist_std::fs::flock::Filesystem::new(cwd.to_owned());
150
151        let mut lock_file = fs
152            .open_rw_exclusive_create(LOCK_FILENAME, "project commands")
153            .context("tinymist.lock")?;
154
155        let mut data = vec![];
156        lock_file.read_to_end(&mut data).context("read lock")?;
157
158        let old_data =
159            std::str::from_utf8(&data).context("tinymist.lock file is not valid utf-8")?;
160
161        let mut state = if old_data.trim().is_empty() {
162            LockFile {
163                // todo: reduce cost
164                lock_dir: Some(ImmutPath::from(cwd)),
165                document: vec![],
166                task: vec![],
167                route: eco_vec![],
168            }
169        } else {
170            let old_state = toml::from_str::<LockFileCompat>(old_data)
171                .context_ut("tinymist.lock file is not a valid TOML file")?;
172
173            let version = old_state.version()?;
174            match Version(version).partial_cmp(&Version(LOCK_VERSION)) {
175                Some(Ordering::Equal | Ordering::Less) => {}
176                Some(Ordering::Greater) => {
177                    bail!(
178                        "trying to update lock file having a future version, current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
179                    );
180                }
181                None => {
182                    bail!(
183                        "cannot compare version, are version strings in right format? current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
184                    );
185                }
186            }
187
188            let mut lf = old_state.migrate()?;
189            lf.lock_dir = Some(ImmutPath::from(cwd));
190            lf
191        };
192
193        f(&mut state)?;
194
195        // todo: for read only operations, we don't have to compare it.
196        state.sort();
197        let new_data = state.serialize_resolve();
198
199        // If the lock file contents haven't changed so don't rewrite it. This is
200        // helpful on read-only filesystems.
201        if old_data == new_data {
202            return Ok(());
203        }
204
205        // todo: even if cargo, they don't update the lock file atomically. This
206        // indicates that we may get data corruption if the process is killed
207        // while writing the lock file. This is sensible because `Cargo.lock` is
208        // only a "resolved result" of the `Cargo.toml`. Thus, we should inform
209        // users that don't only persist configuration in the lock file.
210        lock_file.file().set_len(0).context(LOCK_FILENAME)?;
211        lock_file.seek(SeekFrom::Start(0)).context(LOCK_FILENAME)?;
212        lock_file
213            .write_all(new_data.as_bytes())
214            .context(LOCK_FILENAME)?;
215
216        Ok(())
217    }
218
219    /// Reads the lock file.
220    pub fn read(dir: &Path) -> Result<Self> {
221        let fs = tinymist_std::fs::flock::Filesystem::new(dir.to_owned());
222
223        let mut lock_file = fs
224            .open_ro_shared(LOCK_FILENAME, "project commands")
225            .context(LOCK_FILENAME)?;
226
227        let mut data = vec![];
228        lock_file.read_to_end(&mut data).context(LOCK_FILENAME)?;
229
230        let data = std::str::from_utf8(&data).context("tinymist.lock file is not valid utf-8")?;
231
232        let state = toml::from_str::<LockFileCompat>(data)
233            .context_ut("tinymist.lock file is not a valid TOML file")?;
234
235        let mut lf = state.migrate()?;
236        lf.lock_dir = Some(dir.into());
237        Ok(lf)
238    }
239}
240
241/// Make a new project lock updater.
242pub fn update_lock(root: ImmutPath) -> LockFileUpdate {
243    LockFileUpdate {
244        root,
245        updates: vec![],
246    }
247}
248
249enum LockUpdate {
250    Input(ProjectInput),
251    Task(ApplyProjectTask),
252    Material(ProjectPathMaterial),
253    Route(ProjectRoute),
254}
255
256/// A lock file update.
257pub struct LockFileUpdate {
258    root: Arc<Path>,
259    updates: Vec<LockUpdate>,
260}
261
262impl LockFileUpdate {
263    /// Compiles the lock file.
264    #[cfg(feature = "lsp")]
265    pub fn compiled(&mut self, world: &crate::LspWorld, ctx: CtxPath) -> Option<Id> {
266        let id = Id::from_world(world, ctx)?;
267
268        let root = ResourcePath::from_user_sys(Path::new("."), ctx);
269        let main =
270            ResourcePath::from_user_sys(world.path_for_id(world.main()).ok()?.as_path(), ctx);
271
272        let font_resolver = &world.font_resolver;
273        let font_paths = font_resolver
274            .font_paths()
275            .iter()
276            .map(|p| ResourcePath::from_user_sys(p, ctx))
277            .collect::<Vec<_>>();
278
279        // let system_font = font_resolver.system_font();
280
281        let registry = &world.registry;
282        let package_path = registry
283            .package_path()
284            .map(|p| ResourcePath::from_user_sys(p, ctx));
285        let package_cache_path = registry
286            .package_cache_path()
287            .map(|p| ResourcePath::from_user_sys(p, ctx));
288
289        // todo: freeze the package paths
290        let _ = package_cache_path;
291        let _ = package_path;
292
293        // todo: freeze the sys.inputs
294
295        let input = ProjectInput {
296            id: id.clone(),
297            lock_dir: Some(ctx.1.to_path_buf()),
298            root: Some(root),
299            main,
300            inputs: vec![],
301            font_paths,
302            system_fonts: true, // !args.font.ignore_system_fonts,
303            package_path: None,
304            package_cache_path: None,
305        };
306
307        self.updates.push(LockUpdate::Input(input));
308
309        Some(id)
310    }
311
312    /// Adds a task to the lock file.
313    pub fn task(&mut self, task: ApplyProjectTask) {
314        self.updates.push(LockUpdate::Task(task));
315    }
316
317    /// Adds a material to the lock file.
318    pub fn update_materials(&mut self, doc_id: Id, files: EcoVec<ImmutPath>) {
319        self.updates
320            .push(LockUpdate::Material(ProjectPathMaterial::from_deps(
321                doc_id, files,
322            )));
323    }
324
325    /// Adds a route to the lock file.
326    pub fn route(&mut self, doc_id: Id, priority: u32) {
327        self.updates.push(LockUpdate::Route(ProjectRoute {
328            id: doc_id,
329            priority,
330        }));
331    }
332
333    /// Commits the lock file.
334    pub fn commit(self) {
335        crate::LockFile::update(&self.root, |l| {
336            let root: EcoString = unix_slash(&self.root).into();
337            let root_hash = tinymist_std::hash::hash128(&root);
338            for update in self.updates {
339                match update {
340                    LockUpdate::Input(input) => {
341                        l.replace_document(input);
342                    }
343                    LockUpdate::Task(task) => {
344                        l.replace_task(task);
345                    }
346                    LockUpdate::Material(mut mat) => {
347                        let root: EcoString = unix_slash(&self.root).into();
348                        mat.root = root.clone();
349                        let cache_dir = dirs::cache_dir();
350                        if let Some(cache_dir) = cache_dir {
351                            let id = tinymist_std::hash::hash128(&mat.id);
352                            let root_lo = root_hash & 0xfff;
353                            let root_hi = root_hash >> 12;
354                            let id_lo = id & 0xfff;
355                            let id_hi = id >> 12;
356
357                            let hash_str =
358                                format!("{root_lo:03x}/{root_hi:013x}/{id_lo:03x}/{id_hi:013x}");
359
360                            let cache_dir = cache_dir.join("tinymist/projects").join(hash_str);
361                            let _ = std::fs::create_dir_all(&cache_dir);
362
363                            let data = serde_json::to_string(&mat).unwrap();
364                            let path = cache_dir.join("path-material.json");
365                            tinymist_std::fs::paths::write_atomic(path, data)
366                                .log_error("ProjectCompiler: write material error");
367
368                            // todo: clean up old cache
369                        }
370                        // l.replace_material(mat);
371                    }
372                    LockUpdate::Route(route) => {
373                        l.replace_route(route);
374                    }
375                }
376            }
377
378            Ok(())
379        })
380        .log_error("ProjectCompiler: lock file error");
381    }
382}
383
384/// A version string conforming to the [semver] standard.
385///
386/// [semver]: https://semver.org
387struct Version<'a>(&'a str);
388
389impl PartialEq for Version<'_> {
390    fn eq(&self, other: &Self) -> bool {
391        semver::Version::parse(self.0)
392            .ok()
393            .and_then(|a| semver::Version::parse(other.0).ok().map(|b| a == b))
394            .unwrap_or(false)
395    }
396}
397
398impl PartialOrd for Version<'_> {
399    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
400        let lhs = semver::Version::parse(self.0).ok()?;
401        let rhs = semver::Version::parse(other.0).ok()?;
402        Some(lhs.cmp(&rhs))
403    }
404}