1use std::borrow::Cow;
4use std::path::PathBuf;
5
6use ecow::eco_format;
7#[cfg(feature = "local-registry")]
8use ecow::{EcoVec, eco_vec};
9use 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PackageInfo {
25 pub path: PathBuf,
27 pub namespace: EcoString,
29 pub name: EcoString,
31 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
47pub 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
67pub 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
106pub 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
118pub 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
137pub 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#[cfg(feature = "local-registry")]
150pub enum PackageFilter {
151 For(EcoString),
153 ExceptFor(EcoString),
155 All,
157}
158
159#[cfg(feature = "local-registry")]
160pub 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 self.canonicalize()
174 .map(|meta| meta.is_dir())
175 .unwrap_or(false)
176 }
177 }
178
179 let registry = &world.registry;
180
181 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 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 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}