This commit is contained in:
QuadnucYard 2025-12-17 20:33:39 -06:00 committed by GitHub
commit 5f35e8b1cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 296 additions and 14 deletions

View file

@ -4,4 +4,4 @@ pub mod pack;
pub use pack::*;
pub mod registry;
pub use registry::{PackageError, PackageRegistry, PackageSpec};
pub use registry::{PackageError, PackageRegistry, PackageSpec, PackageSpecExt};

View file

@ -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<Option<UtcDateTime>, D::Error>

View file

@ -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<PackageVersion> {
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",

View file

@ -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(),
});

View file

@ -0,0 +1 @@
#import /* position after */ "@preview/example:0.1.1"

View file

@ -0,0 +1 @@
#import /* position after */ "@preview/shiroa:0.2.3"

View file

@ -0,0 +1 @@
#import /* position after */ "@preview/example:0.1.0"

View file

@ -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 (2)** (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) /

View file

@ -0,0 +1,11 @@
---
source: crates/tinymist-query/src/hover.rs
expression: content
input_file: crates/tinymist-query/src/fixtures/hover/package_import_not_found.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**

View file

@ -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 (2)** (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** /

View file

@ -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, PackageSpecExt};
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,195 @@ 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::<ast::ModuleImport>()?;
// 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 {
let versionless_spec = package_spec.versionless();
// Get all matching packages
let w = self.ctx.world().clone();
let mut packages = vec![];
if package_spec.is_preview() {
packages.extend(
w.packages()
.iter()
.filter(|it| it.matches_versionless(&versionless_spec)),
);
}
// Add non-preview packages
#[cfg(feature = "local-registry")]
let local_packages = self.ctx.non_preview_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.is_preview() {
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 = &current_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) = &current_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(&format!(
"**Available Versions ({})** (click to replace):\n",
packages.len()
));
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()?;

View file

@ -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<void> {
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);
}
}

View file

@ -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