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