diff --git a/crates/tinymist-package/src/registry.rs b/crates/tinymist-package/src/registry.rs index a6af67e43..bd36b7b6d 100644 --- a/crates/tinymist-package/src/registry.rs +++ b/crates/tinymist-package/src/registry.rs @@ -9,7 +9,7 @@ use serde::Deserialize; use tinymist_std::time::UtcDateTime; pub use typst::diag::PackageError; pub use typst::syntax::package::PackageSpec; -use typst::syntax::package::{PackageInfo, TemplateInfo}; +use typst::syntax::package::{PackageInfo, TemplateInfo, VersionlessPackageSpec}; mod dummy; pub use dummy::*; @@ -30,6 +30,27 @@ pub use http::*; /// The default Typst registry. pub const DEFAULT_REGISTRY: &str = "https://packages.typst.org"; +/// The namespace for Typst registry. +pub const PREVIEW_NS: &str = "preview"; + +/// An extension trait for package specifications. +pub trait PackageSpecExt { + /// Returns true if the package spec is in the preview namespace. + fn is_preview(&self) -> bool; +} + +impl PackageSpecExt for PackageSpec { + fn is_preview(&self) -> bool { + self.namespace == PREVIEW_NS + } +} + +impl PackageSpecExt for VersionlessPackageSpec { + fn is_preview(&self) -> bool { + self.namespace == PREVIEW_NS + } +} + /// A trait for package registries. pub trait PackageRegistry { /// A function to be called when the registry is reset. @@ -85,6 +106,11 @@ impl PackageIndexEntry { version: self.package.version, } } + + /// Check if this entry matches a versionless package specification. + pub fn matches_versionless(&self, spec: &VersionlessPackageSpec) -> bool { + self.namespace == spec.namespace && self.package.name == spec.name + } } fn deserialize_timestamp<'de, D>(deserializer: D) -> Result, D::Error> diff --git a/crates/tinymist-package/src/registry/http.rs b/crates/tinymist-package/src/registry/http.rs index a3205e814..cb9a692f1 100644 --- a/crates/tinymist-package/src/registry/http.rs +++ b/crates/tinymist-package/src/registry/http.rs @@ -10,7 +10,7 @@ use tinymist_std::ImmutPath; use typst::diag::{PackageResult, StrResult, eco_format}; use typst::syntax::package::{PackageVersion, VersionlessPackageSpec}; -use crate::registry::PackageIndexEntry; +use crate::registry::{PREVIEW_NS, PackageIndexEntry, PackageSpecExt}; use super::{ DEFAULT_REGISTRY, DummyNotifier, Notifier, PackageError, PackageRegistry, PackageSpec, @@ -179,7 +179,7 @@ impl PackageStorage { } // Download from network if it doesn't exist yet. - if spec.namespace == "preview" { + if spec.is_preview() { self.download_package(spec, &dir)?; if dir.exists() { return Ok(dir.into()); @@ -195,7 +195,7 @@ impl PackageStorage { &self, spec: &VersionlessPackageSpec, ) -> StrResult { - if spec.namespace == "preview" { + if spec.is_preview() { // For `@preview`, download the package index and find the latest // version. self.download_index() @@ -249,7 +249,7 @@ impl PackageStorage { } }; for entry in &mut entries { - entry.namespace = "preview".into(); + entry.namespace = PREVIEW_NS.into(); } entries @@ -263,7 +263,7 @@ impl PackageStorage { /// # Panics /// Panics if the package spec namespace isn't `preview`. pub fn download_package(&self, spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { - assert_eq!(spec.namespace, "preview"); + assert!(spec.is_preview(), "only preview packages can be downloaded"); let url = format!( "{DEFAULT_REGISTRY}/preview/{}-{}.tar.gz", diff --git a/crates/tinymist-query/src/docs/package.rs b/crates/tinymist-query/src/docs/package.rs index 1988f268a..670dbb3b7 100644 --- a/crates/tinymist-query/src/docs/package.rs +++ b/crates/tinymist-query/src/docs/package.rs @@ -431,7 +431,7 @@ struct ConvertResult { #[cfg(test)] mod tests { - use tinymist_world::package::{PackageRegistry, PackageSpec}; + use tinymist_world::package::{PackageRegistry, PackageSpec, registry::PREVIEW_NS}; use super::{PackageInfo, package_docs, package_docs_md, package_docs_typ}; use crate::tests::*; @@ -471,7 +471,7 @@ mod tests { #[test] fn tidy() { test(PackageSpec { - namespace: "preview".into(), + namespace: PREVIEW_NS.into(), name: "tidy".into(), version: "0.3.0".parse().unwrap(), }); @@ -480,7 +480,7 @@ mod tests { #[test] fn touying() { test(PackageSpec { - namespace: "preview".into(), + namespace: PREVIEW_NS.into(), name: "touying".into(), version: "0.6.0".parse().unwrap(), }); @@ -489,7 +489,7 @@ mod tests { #[test] fn fletcher() { test(PackageSpec { - namespace: "preview".into(), + namespace: PREVIEW_NS.into(), name: "fletcher".into(), version: "0.5.8".parse().unwrap(), }); @@ -498,7 +498,7 @@ mod tests { #[test] fn cetz() { test(PackageSpec { - namespace: "preview".into(), + namespace: PREVIEW_NS.into(), name: "cetz".into(), version: "0.2.2".parse().unwrap(), }); diff --git a/crates/tinymist-query/src/fixtures/hover/package_import.typ b/crates/tinymist-query/src/fixtures/hover/package_import.typ new file mode 100644 index 000000000..1e1b0b5ab --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/package_import.typ @@ -0,0 +1 @@ +#import /* position after */ "@preview/shiroa:0.2.3" diff --git a/crates/tinymist-query/src/fixtures/hover/package_import_no_upgrade.typ b/crates/tinymist-query/src/fixtures/hover/package_import_no_upgrade.typ new file mode 100644 index 000000000..a6af92b1c --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/package_import_no_upgrade.typ @@ -0,0 +1 @@ +#import /* position after */ "@preview/example:0.1.1" diff --git a/crates/tinymist-query/src/fixtures/hover/package_import_upgrade.typ b/crates/tinymist-query/src/fixtures/hover/package_import_upgrade.typ new file mode 100644 index 000000000..d8e63b071 --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/package_import_upgrade.typ @@ -0,0 +1 @@ +#import /* position after */ "@preview/example:0.1.0" diff --git a/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import.typ.snap b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import.typ.snap new file mode 100644 index 000000000..49de92819 --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import.typ.snap @@ -0,0 +1,11 @@ +--- +source: crates/tinymist-query/src/hover.rs +expression: content +input_file: crates/tinymist-query/src/fixtures/hover/package_import.typ +--- +Range: 0:29:0:52 + +🌌 [Universe](https://typst.app/universe/package/shiroa) + +**Package:** `@preview/shiroa:0.2.3` +â€ŧī¸ **Version 0.2.3 not found** diff --git a/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_no_upgrade.typ.snap b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_no_upgrade.typ.snap new file mode 100644 index 000000000..5923c2dc0 --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_no_upgrade.typ.snap @@ -0,0 +1,17 @@ +--- +source: crates/tinymist-query/src/hover.rs +expression: content +input_file: crates/tinymist-query/src/fixtures/hover/package_import_no_upgrade.typ +--- +Range: 0:29:0:53 + +🌌 [Universe](https://typst.app/universe/package/example) + +**Package:** `@preview/example:0.1.1` +✅ **Up to date** (latest version) + +**Description:** example package (mock). + +**Available Versions** (click to replace): +- **0.1.1** / +- [0.1.0](command:tinymist.replaceText?%7B%22range%22%3A%7B%22end%22%3A%7B%22character%22%3A53%2C%22line%22%3A0%7D%2C%22start%22%3A%7B%22character%22%3A29%2C%22line%22%3A0%7D%7D%2C%22replace%22%3A%22%5C%22%40preview%2Fexample%3A0%2E1%2E0%5C%22%22%7D) / diff --git a/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_upgrade.typ.snap b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_upgrade.typ.snap new file mode 100644 index 000000000..0c429c6d5 --- /dev/null +++ b/crates/tinymist-query/src/fixtures/hover/snaps/test@package_import_upgrade.typ.snap @@ -0,0 +1,17 @@ +--- +source: crates/tinymist-query/src/hover.rs +expression: content +input_file: crates/tinymist-query/src/fixtures/hover/package_import_upgrade.typ +--- +Range: 0:29:0:53 + +🌌 [Universe](https://typst.app/universe/package/example) + +**Package:** `@preview/example:0.1.0` +âš ī¸ **Newer version available:** 0.1.1 + +**Description:** example package (mock). + +**Available Versions** (click to replace): +- [0.1.1](command:tinymist.replaceText?%7B%22range%22%3A%7B%22end%22%3A%7B%22character%22%3A53%2C%22line%22%3A0%7D%2C%22start%22%3A%7B%22character%22%3A29%2C%22line%22%3A0%7D%7D%2C%22replace%22%3A%22%5C%22%40preview%2Fexample%3A0%2E1%2E1%5C%22%22%7D) / +- **0.1.0** / diff --git a/crates/tinymist-query/src/hover.rs b/crates/tinymist-query/src/hover.rs index 396f09765..736e822ae 100644 --- a/crates/tinymist-query/src/hover.rs +++ b/crates/tinymist-query/src/hover.rs @@ -1,6 +1,9 @@ use core::fmt::{self, Write}; +use std::cmp::Reverse; +use std::str::FromStr; use tinymist_std::typst::TypstDocument; +use tinymist_world::package::PackageSpec; use typst::foundations::repr::separated_list; use typst_shim::syntax::LinkedNodeExt; @@ -100,6 +103,7 @@ impl HoverWorker<'_> { let source = self.source.clone(); let leaf = LinkedNode::new(source.root()).leaf_at_compat(self.cursor)?; + self.package_import(&leaf); self.definition(&leaf) .or_else(|| self.star(&leaf)) .or_else(|| self.link(&leaf)) @@ -214,6 +218,192 @@ impl HoverWorker<'_> { Some(()) } + fn package_import(&mut self, node: &LinkedNode) -> Option<()> { + // Check if we're in a string literal that's part of an import + if !matches!(node.kind(), SyntaxKind::Str) { + return None; + } + + // Navigate up to find the ModuleImport node + let import_node = node.parent()?.cast::()?; + + // Check if this is a package import + if let ast::Expr::Str(str_node) = import_node.source() + && let import_str = str_node.get() + && import_str.starts_with("@") + && let Ok(package_spec) = PackageSpec::from_str(&import_str) + { + self.def + .push(self.get_package_hover_info(&package_spec, node)); + return Some(()); + } + + None + } + + /// Get package information for hover content + fn get_package_hover_info( + &self, + package_spec: &PackageSpec, + import_str_node: &LinkedNode, + ) -> String { + #[cfg(feature = "local-registry")] + use tinymist_world::package::registry::PackageSpecExt; + + let versionless_spec = package_spec.versionless(); + + // Get all matching packages + let w = self.ctx.world().clone(); + let mut packages = w + .packages() + .iter() + .filter(|it| it.matches_versionless(&versionless_spec)) + .collect_vec(); + // local_packages to references and add them to the packages + #[cfg(feature = "local-registry")] + let local_packages = self.ctx.local_packages(); + #[cfg(feature = "local-registry")] + if !package_spec.is_preview() { + packages.extend( + local_packages + .iter() + .filter(|it| it.matches_versionless(&versionless_spec)), + ); + } + + // Sort by version descending + packages.sort_by_key(|entry| Reverse(entry.package.version)); + + let current_entry = packages + .iter() + .find(|entry| entry.package.version == package_spec.version); + + let mut info = String::new(); + { + // Add links + let mut links_line = Vec::new(); + + if package_spec.is_preview() { + let package_name = &package_spec.name; + + // Universe page + let universe_url = format!("https://typst.app/universe/package/{package_name}"); + links_line.push(format!("🌌 [Universe]({universe_url})")); + } + + if let Some(current_entry) = current_entry { + // Repository URL + if let Some(ref repo) = current_entry.package.repository { + links_line.push(format!("🔗 [Repository]({repo})")); + } + + // Homepage URL + if let Some(ref homepage) = current_entry.package.homepage { + links_line.push(format!("🏠 [Homepage]({homepage})")); + } + } + + if !links_line.is_empty() { + info.push_str(&links_line.iter().join(" | ")); + info.push_str("\n\n"); + } + } + + // Package header + if package_spec.namespace == "local" { + info.push_str("â„šī¸ This is a local package\n\n"); + } + + info.push_str(&format!("**Package:** `{package_spec}`\n")); + // Check version information and show status + if current_entry.is_none() { + info.push_str(&format!( + "â€ŧī¸ **Version {} not found**\n\n", + package_spec.version + )); + } else if let Some(latest) = packages.first() { + let latest_version = &latest.package.version; + if *latest_version != package_spec.version { + info.push_str(&format!( + "âš ī¸ **Newer version available: {latest_version}**\n" + )); + } else { + info.push_str("✅ **Up to date** (latest version)\n"); + } + } + info.push('\n'); + + let date_format = tinymist_std::time::yyyy_mm_dd(); + + // Add manifest information if available + if let Some(current_entry) = current_entry { + let pkg_info = ¤t_entry.package; + + if !pkg_info.authors.is_empty() { + info.push_str(&format!("**Authors:** {}\n\n", pkg_info.authors.join(", "))); + } + + if let Some(description) = pkg_info.description.as_ref() { + info.push_str(&format!("**Description:** {description}\n\n")); + } + + if let Some(license) = &pkg_info.license { + info.push_str(&format!("**License:** {license}\n\n")); + } + + if let Some(updated_at) = ¤t_entry.updated_at { + info.push_str(&format!( + "**Updated:** {}\n\n", + updated_at + .format(&date_format) + .unwrap_or_else(|_| "unknown".to_string()) + )); + } + } + + // Show version history for preview packages + if !packages.is_empty() { + info.push_str("**Available Versions** (click to replace):\n"); + for entry in &packages { + let version = &entry.package.version; + let release_date = entry + .updated_at + .and_then(|time| time.format(&date_format).ok()) + .unwrap_or_default(); + if *version == package_spec.version { + // Current version + info.push_str(&format!("- **{version}** / {release_date}\n")); + continue; + } + // Other versions + let lsp_range = self.ctx.to_lsp_range(import_str_node.range(), &self.source); + let args = serde_json::json!({ + "range": lsp_range, + "replace": format!( + "\"@{}/{}:{}\"", + package_spec.namespace, package_spec.name, version + ) + }); + let json_str = match serde_json::to_string(&args) { + Ok(s) => s, + Err(e) => { + log::error!("Failed to serialize arguments for replaceText command: {e}"); + continue; + } + }; + let encoded = percent_encoding::utf8_percent_encode( + &json_str, + percent_encoding::NON_ALPHANUMERIC, + ); + let version_url = format!("command:tinymist.replaceText?{encoded}"); + info.push_str(&format!("- [{version}]({version_url}) / {release_date}\n")); + } + info.push('\n'); + } + + info + } + fn link(&mut self, mut node: &LinkedNode) -> Option<()> { while !matches!(node.kind(), SyntaxKind::FuncCall) { node = node.parent()?; diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 50d09e4cf..6320d668d 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -232,8 +232,9 @@ async function languageActivate(context: IContext) { // We would like to define it at the server side, but it is not possible for now. // https://github.com/microsoft/language-server-protocol/issues/1117 commands.registerCommand("tinymist.triggerSuggestAndParameterHints", triggerSuggestAndParameterHints), + + commands.registerTextEditorCommand("tinymist.replaceText", commandReplaceText), ); - // context.subscriptions.push const provider = new SymbolViewProvider(context.context); context.subscriptions.push( vscode.window.registerWebviewViewProvider(SymbolViewProvider.Name, provider), @@ -639,3 +640,17 @@ function triggerSuggestAndParameterHints() { vscode.commands.executeCommand("editor.action.triggerSuggest"); vscode.commands.executeCommand("editor.action.triggerParameterHints"); } + +async function commandReplaceText( + editor: TextEditor, + edit: vscode.TextEditorEdit, + args?: { range: vscode.Range; replace: string }, +): Promise { + if (editor && args) { + const range = new vscode.Range( + new vscode.Position(args.range.start.line, args.range.start.character), + new vscode.Position(args.range.end.line, args.range.end.character), + ); + edit.replace(range, args.replace); + } +} diff --git a/editors/vscode/src/lsp.ts b/editors/vscode/src/lsp.ts index 487d81f5f..c7f7533ec 100644 --- a/editors/vscode/src/lsp.ts +++ b/editors/vscode/src/lsp.ts @@ -201,7 +201,7 @@ export class LanguageState { }; const trustedCommands = { - enabledCommands: ["tinymist.openInternal", "tinymist.openExternal"], + enabledCommands: ["tinymist.openInternal", "tinymist.openExternal", "tinymist.replaceText"], }; const hoverStorage = extensionState.features.renderDocs && LanguageState.HoverTmpStorage