tinymist_query/
package.rs

1//! Package management tools.
2
3use std::borrow::Cow;
4use std::path::PathBuf;
5
6use ecow::eco_format;
7#[cfg(feature = "local-registry")]
8use ecow::{EcoVec, eco_vec};
9// use reflexo_typst::typst::prelude::*;
10use serde::{Deserialize, Serialize};
11use tinymist_world::package::registry::PackageIndexEntry;
12use tinymist_world::package::{PackageSpec, PackageSpecExt};
13use typst::World;
14use typst::diag::{EcoString, StrResult};
15use typst::syntax::package::PackageManifest;
16use typst::syntax::{FileId, LinkedNode, RootedPath, SyntaxKind, VirtualPath, VirtualRoot, ast};
17use typst_shim::syntax::resolve_path_from_id;
18
19use crate::LocalContext;
20use crate::analysis::SharedContext;
21
22/// Information about a package.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PackageInfo {
25    /// The path to the package if any.
26    pub path: PathBuf,
27    /// The namespace the package lives in.
28    pub namespace: EcoString,
29    /// The name of the package within its namespace.
30    pub name: EcoString,
31    /// The package's version.
32    pub version: String,
33}
34
35impl From<PackageIndexEntry> for PackageInfo {
36    fn from(entry: PackageIndexEntry) -> Self {
37        let spec = entry.spec();
38        Self {
39            path: entry.path.unwrap_or_default(),
40            namespace: spec.namespace,
41            name: spec.name,
42            version: spec.version.to_string(),
43        }
44    }
45}
46
47/// Parses a package import from a string literal node in an import statement.
48/// Returns the PackageSpec if it's a valid package import.
49pub fn parse_package_import(node: &LinkedNode) -> Option<PackageSpec> {
50    if !matches!(node.kind(), SyntaxKind::Str) {
51        return None;
52    }
53
54    let import_node = node.parent()?.cast::<ast::ModuleImport>()?;
55
56    let ast::Expr::Str(str_node) = import_node.source() else {
57        return None;
58    };
59    let import_str = str_node.get();
60    if import_str.starts_with('@') {
61        import_str.parse().ok()
62    } else {
63        None
64    }
65}
66
67/// Finds the package entry for a given package spec, and also the latest
68/// version entry.
69pub fn find_package_and_latest<'a>(
70    ctx: &'a SharedContext,
71    package_spec: &PackageSpec,
72) -> (
73    Option<Cow<'a, PackageIndexEntry>>,
74    Option<Cow<'a, PackageIndexEntry>>,
75) {
76    let versionless_spec = package_spec.versionless();
77
78    if package_spec.is_preview() {
79        let packages = ctx.world().packages();
80
81        let current = packages.iter().find(|it| it.matches(package_spec));
82        let latest = packages
83            .iter()
84            .filter(|it| it.matches_versionless(&versionless_spec))
85            .max_by_key(|entry| entry.package.version);
86
87        (current.map(Cow::Borrowed), latest.map(Cow::Borrowed))
88    } else if cfg!(feature = "local-registry") {
89        let local_packages = ctx.non_preview_packages();
90
91        let current = local_packages.iter().find(|it| it.matches(package_spec));
92        let latest = local_packages
93            .iter()
94            .filter(|it| it.matches_versionless(&versionless_spec))
95            .max_by_key(|entry| entry.package.version);
96
97        (
98            current.map(|p| Cow::Owned(p.clone())),
99            latest.map(|p| Cow::Owned(p.clone())),
100        )
101    } else {
102        (None, None)
103    }
104}
105
106/// Parses the manifest of the package located at `package_path`.
107pub fn get_manifest_id(spec: &PackageInfo) -> StrResult<FileId> {
108    Ok(FileId::new(RootedPath::new(
109        VirtualRoot::Package(PackageSpec {
110            namespace: spec.namespace.clone(),
111            name: spec.name.clone(),
112            version: spec.version.parse()?,
113        }),
114        VirtualPath::new("typst.toml").expect("valid manifest path"),
115    )))
116}
117
118/// Parses the manifest of the package located at `package_path`.
119pub fn get_manifest(world: &dyn World, toml_id: FileId) -> StrResult<PackageManifest> {
120    let toml_data = world
121        .file(toml_id)
122        .map_err(|err| eco_format!("failed to read package manifest ({})", err))?;
123
124    let string = std::str::from_utf8(&toml_data)
125        .map_err(|err| eco_format!("package manifest is not valid UTF-8 ({})", err))?;
126
127    toml::from_str(string)
128        .map_err(|err| eco_format!("package manifest is malformed ({})", err.message()))
129}
130
131pub(crate) fn package_entrypoint_id(manifest_id: FileId, entrypoint: &str) -> FileId {
132    resolve_path_from_id(manifest_id, entrypoint)
133        .expect("valid package entrypoint")
134        .intern()
135}
136
137/// Check Package.
138pub fn check_package(ctx: &mut LocalContext, spec: &PackageInfo) -> StrResult<()> {
139    let toml_id = get_manifest_id(spec)?;
140    let manifest = ctx.get_manifest(toml_id)?;
141
142    let entry_point = package_entrypoint_id(toml_id, &manifest.package.entrypoint);
143
144    ctx.shared_().preload_package(entry_point);
145    Ok(())
146}
147
148/// A filter for packages.
149#[cfg(feature = "local-registry")]
150pub enum PackageFilter {
151    /// Filter for packages that match the given namespace.
152    For(EcoString),
153    /// Filter for packages that do not match the given namespace.
154    ExceptFor(EcoString),
155    /// Filter that matches all packages.
156    All,
157}
158
159#[cfg(feature = "local-registry")]
160/// Get the packages in namespaces and their descriptions.
161pub fn list_package(
162    world: &tinymist_project::LspWorld,
163    filter: PackageFilter,
164) -> EcoVec<PackageIndexEntry> {
165    trait IsDirFollowLinks {
166        fn is_dir_follow_links(&self) -> bool;
167    }
168
169    impl IsDirFollowLinks for PathBuf {
170        fn is_dir_follow_links(&self) -> bool {
171            // Although `canonicalize` is heavy, we must use it because `symlink_metadata`
172            // is not reliable.
173            self.canonicalize()
174                .map(|meta| meta.is_dir())
175                .unwrap_or(false)
176        }
177    }
178
179    let registry = &world.registry;
180
181    // search packages locally. We only search in the data
182    // directory and not the cache directory, because the latter is not
183    // intended for storage of local packages.
184    let mut packages = eco_vec![];
185
186    let paths = registry.paths();
187    log::info!("searching for packages in paths {paths:?}");
188
189    let mut search_in_dir = |local_path: PathBuf, ns: EcoString| {
190        if !local_path.exists() || !local_path.is_dir_follow_links() {
191            return;
192        }
193        // namespace/package_name/version
194        // 2. package_name
195        let Some(package_names) = once_log(std::fs::read_dir(local_path), "read local package")
196        else {
197            return;
198        };
199        for package in package_names {
200            let Some(package) = once_log(package, "read package name") else {
201                continue;
202            };
203            let package_name = EcoString::from(package.file_name().to_string_lossy());
204            if package_name.starts_with('.') {
205                continue;
206            }
207
208            let package_path = package.path();
209            if !package_path.is_dir_follow_links() {
210                continue;
211            }
212            // 3. version
213            let Some(versions) = once_log(std::fs::read_dir(package_path), "read package versions")
214            else {
215                continue;
216            };
217            for version in versions {
218                let Some(version_entry) = once_log(version, "read package version") else {
219                    continue;
220                };
221                if version_entry.file_name().to_string_lossy().starts_with('.') {
222                    continue;
223                }
224                let package_version_path = version_entry.path();
225                if !package_version_path.is_dir_follow_links() {
226                    continue;
227                }
228                let Some(version) = once_log(
229                    version_entry.file_name().to_string_lossy().parse(),
230                    "parse package version",
231                ) else {
232                    continue;
233                };
234                let spec = PackageSpec {
235                    namespace: ns.clone(),
236                    name: package_name.clone(),
237                    version,
238                };
239                let manifest_id = typst::syntax::FileId::new(typst::syntax::RootedPath::new(
240                    typst::syntax::VirtualRoot::Package(spec.clone()),
241                    typst::syntax::VirtualPath::new("typst.toml").expect("valid manifest path"),
242                ));
243                let Some(manifest) =
244                    once_log(get_manifest(world, manifest_id), "read package manifest")
245                else {
246                    continue;
247                };
248                packages.push(PackageIndexEntry {
249                    namespace: ns.clone(),
250                    package: manifest.package,
251                    template: manifest.template,
252                    updated_at: None,
253                    path: Some(package_version_path),
254                });
255            }
256        }
257    };
258
259    for dir in paths {
260        let matching_ns = match &filter {
261            PackageFilter::For(ns) => {
262                let local_path = dir.join(ns.as_str());
263                search_in_dir(local_path, ns.clone());
264
265                continue;
266            }
267            PackageFilter::ExceptFor(ns) => Some(ns),
268            PackageFilter::All => None,
269        };
270
271        let Some(namespaces) = once_log(std::fs::read_dir(dir), "read package directory") else {
272            continue;
273        };
274        for dir in namespaces {
275            let Some(dir) = once_log(dir, "read ns directory") else {
276                continue;
277            };
278            let ns = dir.file_name();
279            let ns = ns.to_string_lossy();
280            if let Some(matching_ns) = &matching_ns
281                && matching_ns.as_str() == ns.as_ref()
282            {
283                continue;
284            }
285            let local_path = dir.path();
286            search_in_dir(local_path, ns.into());
287        }
288    }
289
290    packages
291}
292
293#[cfg(feature = "local-registry")]
294fn once_log<T, E: std::fmt::Display>(result: Result<T, E>, site: &'static str) -> Option<T> {
295    use std::collections::HashSet;
296    use std::sync::OnceLock;
297
298    use parking_lot::Mutex;
299
300    let err = match result {
301        Ok(value) => return Some(value),
302        Err(err) => err,
303    };
304
305    static ONCE: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
306    let mut once = ONCE.get_or_init(Default::default).lock();
307    if once.insert(site) {
308        log::error!("failed to perform {site}: {err}");
309    }
310
311    None
312}
313
314#[cfg(test)]
315mod tests {
316    use std::str::FromStr;
317
318    use typst::syntax::package::PackageSpec;
319
320    use super::*;
321
322    fn manifest_id() -> FileId {
323        FileId::new(RootedPath::new(
324            VirtualRoot::Package(
325                PackageSpec::from_str("@preview/example:0.1.0").expect("valid package spec"),
326            ),
327            VirtualPath::new("typst.toml").expect("valid manifest path"),
328        ))
329    }
330
331    #[test]
332    fn package_entrypoint_id_resolves_relative_to_manifest_parent() {
333        let manifest_id = manifest_id();
334        let entrypoint = package_entrypoint_id(manifest_id, "src/lib.typ");
335
336        assert_eq!(entrypoint.root(), manifest_id.root());
337        assert_eq!(entrypoint.vpath().get_with_slash(), "/src/lib.typ");
338    }
339
340    #[test]
341    fn package_entrypoint_id_resolves_absolute_path_in_package_root() {
342        let manifest_id = manifest_id();
343        let entrypoint = package_entrypoint_id(manifest_id, "/lib.typ");
344
345        assert_eq!(entrypoint.root(), manifest_id.root());
346        assert_eq!(entrypoint.vpath().get_with_slash(), "/lib.typ");
347    }
348}