From b5dbd3c02012b3db022d0b8212e66eda09d30129 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:30:28 +0800 Subject: [PATCH] feat(cargo): dependency version inspections and updates (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml had no way to tell that a dependency had a newer release. Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent. Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app. Fixes #479 --- Cargo.lock | 1 + crates/ide-host/src/lib.rs | 4 + crates/ide-host/src/ops/dependencies.rs | 123 ++++++++++ crates/ide-host/src/ops/mod.rs | 2 + crates/ide-workspace/Cargo.toml | 1 + .../ide-workspace/src/dependencies/cache.rs | 102 +++++++++ .../src/dependencies/cache/tests.rs | 97 ++++++++ .../ide-workspace/src/dependencies/cargo.rs | 150 +++++++++++++ .../src/dependencies/cargo/tests.rs | 143 ++++++++++++ crates/ide-workspace/src/dependencies/mod.rs | 41 ++++ crates/ide-workspace/src/lib.rs | 1 + src-tauri/src/commands/dependencies.rs | 17 ++ src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 20 ++ src/editor/api.ts | 14 +- src/editor/lsp/diagnostics.ts | 7 +- src/editor/lsp/hover.ts | 2 +- src/editor/lsp/intentions.test.ts | 23 ++ src/editor/lsp/intentions.ts | 94 ++++++-- src/editor/lsp/popupStore.ts | 2 + .../dependencies/dependencies.test.ts | 210 ++++++++++++++++++ src/features/dependencies/index.ts | 208 +++++++++++++++++ src/features/index.ts | 2 + src/platform/inspections.ts | 11 + src/platform/locales/text/zh.json | 4 + src/platform/locales/textCatalog.test.ts | 1 + 26 files changed, 1257 insertions(+), 25 deletions(-) create mode 100644 crates/ide-host/src/ops/dependencies.rs create mode 100644 crates/ide-workspace/src/dependencies/cache.rs create mode 100644 crates/ide-workspace/src/dependencies/cache/tests.rs create mode 100644 crates/ide-workspace/src/dependencies/cargo.rs create mode 100644 crates/ide-workspace/src/dependencies/cargo/tests.rs create mode 100644 crates/ide-workspace/src/dependencies/mod.rs create mode 100644 src-tauri/src/commands/dependencies.rs create mode 100644 src/features/dependencies/dependencies.test.ts create mode 100644 src/features/dependencies/index.ts diff --git a/Cargo.lock b/Cargo.lock index 5c25ebe5..ee5c7b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4684,6 +4684,7 @@ dependencies = [ "ide-text", "ide-vfs", "parking_lot", + "semver", "serde", "serde_json", "specta", diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 7acd168d..66e21c9b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -60,6 +60,7 @@ pub struct Host { pub languages: Arc, syntax: Arc>>, pub global_settings: SettingsStore, + pub dependency_versions: Arc, pub terminals: TerminalManager, pub processes: ProcessManager, /// Debug sessions (adapters run on this machine, next to the project). @@ -104,6 +105,9 @@ impl Host { ssh, environment: environment::Environment::new(project_environment, local_data_dir.clone()), global_settings, + dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new( + data_dir.join("cache/dependency-versions"), + )), // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), processes: ProcessManager::new(), diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs new file mode 100644 index 00000000..96dfad9b --- /dev/null +++ b/crates/ide-host/src/ops/dependencies.rs @@ -0,0 +1,123 @@ +//! Repository version lookups for manifest inspections and completion. +use crate::{Host, rpc::Dispatcher}; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; + +struct CratesIo; +impl DependencyVersionSource for CratesIo { + fn id(&self) -> &'static str { + "crates-io" + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?; + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited( + &format!("https://index.crates.io/{path}"), + Some(16 * 1024 * 1024), + cancel, + )?; + cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index")) + } +} + +/// `offset` limits a completion request to its version string; inspections omit it. +pub async fn dependency_versions( + host: &Host, + window: &str, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let cache = host.dependency_versions.clone(); + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for dependency in cargo::dependencies(&snap.text) { + if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) { + continue; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?; + let update = cargo::update(&dependency.requirement, &versions); + out.push(DependencyVersions { dependency, versions, update }); + } + Ok(out) + }) + .await; + host.end_request(request); + result +} + +pub(crate) fn register(d: &mut Dispatcher) { + d.add("dependency_versions", |c| async move { + dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use std::sync::Arc; + + struct Source; + impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "crates-io" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + Ok(vec!["1.10.0".into(), "0.2.1".into()]) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn host_uses_synced_document_cache_and_cursor_filter_without_network() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n"; + let path = root.join("Cargo.toml"); + std::fs::write(&path, text).unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + let info = project.documents.open(&path).unwrap(); + for package in ["tokio", "serde"] { + host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap(); + } + let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10"); + assert!(all[1].update.is_none()); + let offset = all[1].dependency.from; + let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap(); + assert_eq!(at_cursor.len(), 1); + assert_eq!(at_cursor[0].dependency.package, "serde"); + assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty()); + host.cancel_request(RequestId(413)); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err()); + let other = root.join("other.toml"); + std::fs::write(&other, text).unwrap(); + let info = project.documents.open(&other).unwrap(); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty()); + host.close_project("main"); + } +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 228d02fc..93a0b28f 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,6 +8,7 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod env; @@ -69,6 +70,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { analysis::register(d); syntax::register(d); spell::register(d); + dependencies::register(d); history::register(d); coverage::register(d); build::register(d); diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index cfb75f56..9036108e 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true toml_edit.workspace = true +semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs new file mode 100644 index 00000000..7be33fe7 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -0,0 +1,102 @@ +use super::DependencyVersionSource; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(3600); +const FAILURE_TTL: Duration = Duration::from_secs(30); +const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024; +type Slot = Arc>>; + +#[derive(Clone, Serialize, Deserialize)] +struct Cached { + fetched: u64, + versions: Vec, + #[serde(skip)] + failed: bool, +} + +/// Same-package lookups share one lock and fetch. Different packages don't block +/// each other; waiting callers can cancel independently of the active fetch. +pub struct VersionCache { + directory: PathBuf, + entries: Mutex>, + ttl: Duration, +} + +impl VersionCache { + pub fn new(directory: PathBuf) -> Self { + Self { directory, entries: Mutex::default(), ttl: TTL } + } + + pub fn versions( + &self, + source: &dyn DependencyVersionSource, + package: &str, + cancel: &CancellationToken, + ) -> IdeResult> { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + if !safe(source.id()) || !safe(package) || package.len() > 64 { + return Ok(Vec::new()); + } + let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + let slot = self.entries.lock().entry(key.clone()).or_default().clone(); + let mut cached = loop { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) { + break guard; + } + }; + let checked_at = now(); + let fresh = |c: &Cached| { + c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs() + }; + if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) { + return Ok(entry.versions.clone()); + } + let path = self.directory.join(format!("{key}.json")); + let disk = std::fs::metadata(&path) + .ok() + .filter(|m| m.len() <= MAX_DISK_BYTES) + .and_then(|_| std::fs::read(&path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(fresh); + if let Some(entry) = disk { + let versions = entry.versions.clone(); + *cached = Some(entry); + return Ok(versions); + } + let result = source.fetch(package, cancel); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let failed = result.is_err(); + let versions = result.unwrap_or_default(); + let entry = Cached { fetched: now(), versions: versions.clone(), failed }; + if !failed + && let Ok(bytes) = serde_json::to_vec(&entry) + && std::fs::create_dir_all(&self.directory).is_ok() + { + let temporary = path.with_extension("tmp"); + if std::fs::write(&temporary, bytes).is_ok() { + let _ = std::fs::rename(&temporary, &path); + let _ = std::fs::remove_file(&temporary); + } + } + *cached = Some(entry); + Ok(versions) + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cache/tests.rs b/crates/ide-workspace/src/dependencies/cache/tests.rs new file mode 100644 index 00000000..c8426510 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Source { + calls: AtomicUsize, + fail: bool, +} +impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "test" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(20)); + if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) } + } +} +fn source(fail: bool) -> Source { + Source { calls: AtomicUsize::new(0), fail } +} + +#[test] +fn memory_disk_ttl_corruption_and_failed_cache_writes() { + let dir = tempfile::tempdir().unwrap(); + let s = source(false); + let cancel = CancellationToken::new(); + let cache = VersionCache::new(dir.path().into()); + for _ in 0..2 { + assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart"); + let mut expired = VersionCache::new(dir.path().into()); + expired.ttl = Duration::ZERO; + expired.versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); + std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap(); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 3); + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, "x").unwrap(); + assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); +} + +#[test] +fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"])); + } + }); + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + let s = source(true); + for _ in 0..2 { + assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty()); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted"); + let slot = cache.entries.lock().get("test-offline").unwrap().clone(); + slot.lock().as_mut().unwrap().fetched -= 31; + cache.versions(&s, "offline", &CancellationToken::new()).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn cancellation_and_invalid_cache_keys_do_not_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let token = CancellationToken::new(); + token.cancel(); + assert!(cache.versions(&s, "foo", &token).is_err()); + assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty()); + assert_eq!(s.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn cancelled_waiter_does_not_cancel_the_shared_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let slot = Arc::new(Mutex::new(None)); + cache.entries.lock().insert("test-foo".into(), slot.clone()); + let guard = slot.lock(); + let token = CancellationToken::new(); + std::thread::scope(|scope| { + let waiter = scope.spawn(|| cache.versions(&s, "foo", &token)); + token.cancel(); + assert!(waiter.join().unwrap().is_err()); + }); + drop(guard); + assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]); +} diff --git a/crates/ide-workspace/src/dependencies/cargo.rs b/crates/ide-workspace/src/dependencies/cargo.rs new file mode 100644 index 00000000..a63e701e --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo.rs @@ -0,0 +1,150 @@ +use super::{DependencyUpdate, ManifestDependency}; +use semver::{Op, Version, VersionReq}; +use serde::Deserialize; +use toml_edit::{Document, Item, TableLike}; + +/// Cargo's sparse-index path, including the special cases for short names. +pub fn index_path(name: &str) -> Option { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')) + || !name.as_bytes()[0].is_ascii_alphabetic() + { + return None; + } + let name = name.to_ascii_lowercase(); + Some(match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + }) +} + +pub fn parse_index(text: &str) -> Option> { + #[derive(Deserialize)] + struct Entry { + vers: String, + #[serde(default)] + yanked: bool, + } + let mut versions = Vec::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let entry: Entry = serde_json::from_str(line).ok()?; + if !entry.yanked { + versions.push(Version::parse(&entry.vers).ok()?); + } + } + versions.sort_unstable_by(|a, b| b.cmp(a)); + versions.dedup(); + Some(versions.into_iter().map(|v| v.to_string()).collect()) +} + +/// A complete TOML parser supplies spans, so strings/comments that resemble tables +/// never become dependencies. Broken documents are silent until they parse again. +pub fn dependencies(text: &str) -> Vec { + let Ok(doc) = text.parse::>() else { return Vec::new() }; + let mut out = Vec::new(); + sections(doc.as_table(), text, &mut out); + if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like) + && let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like) + { + collect(deps, text, &mut out); + } + if let Some(targets) = doc.get("target").and_then(Item::as_table_like) { + for (_, target) in targets.iter() { + if let Some(target) = target.as_table_like() { + sections(target, text, &mut out); + } + } + } + out.sort_unstable_by_key(|d| d.from); + out +} + +fn sections(table: &dyn TableLike, text: &str, out: &mut Vec) { + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(deps) = table.get(section).and_then(Item::as_table_like) { + collect(deps, text, out); + } + } +} + +fn collect(table: &dyn TableLike, text: &str, out: &mut Vec) { + for (name, item) in table.iter() { + let (package, value) = if let Some(dep) = item.as_table_like() { + if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k)) + || dep.get("workspace").and_then(Item::as_bool) == Some(true) + { + continue; + } + let Some(version) = dep.get("version") else { continue }; + (dep.get("package").and_then(Item::as_str).unwrap_or(name), version) + } else { + (name, item) + }; + if index_path(package).is_none() { + continue; + } + let Some(requirement) = value.as_str() else { continue }; + let Some(span) = value.span() else { continue }; + let Some(raw) = text.get(span.clone()) else { continue }; + // Spans cover the encoded TOML string. Replace its entire contents, + // including escapes, while retaining the original quote delimiters. + let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 }; + out.push(ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from: text[..span.start + delimiter].encode_utf16().count() as u32, + to: text[..span.end - delimiter].encode_utf16().count() as u32, + }); + } +} + +pub fn update(requirement: &str, versions: &[String]) -> Option { + let req = VersionReq::parse(requirement).ok()?; + let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty()); + let latest = + versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?; + if req.matches(&latest) { + return None; + } + // An unavailable future version isn't an invitation to downgrade. + if req.comparators.iter().any(|c| { + (c.op == Op::Greater + && c.patch.is_none() + && (latest.major < c.major + || (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor)))) + || !matches!(c.op, Op::Less | Op::LessEq) + && latest + <= Version { + major: c.major, + minor: c.minor.unwrap_or(0), + patch: c.patch.unwrap_or(0), + pre: c.pre.clone(), + build: Default::default(), + } + }) { + return None; + } + let trimmed = requirement.trim(); + let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start(); + let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())); + let mut replacement = latest.to_string(); + if simple && latest.pre.is_empty() { + let precision = digits.split('.').count(); + let version = match precision { + 1 => latest.major.to_string(), + 2 => format!("{}.{}", latest.major, latest.minor), + _ => latest.to_string(), + }; + let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]); + if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) { + replacement = candidate; + } + } + Some(DependencyUpdate { latest: latest.to_string(), replacement }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cargo/tests.rs b/crates/ide-workspace/src/dependencies/cargo/tests.rs new file mode 100644 index 00000000..7be9927d --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo/tests.rs @@ -0,0 +1,143 @@ +use super::*; + +#[test] +fn sparse_paths_and_input_validation() { + for (name, path) in + [("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")] + { + assert_eq!(index_path(name).as_deref(), Some(path)); + } + for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] { + assert!(index_path(name).is_none(), "{name}"); + } +} + +#[test] +fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() { + let text = r#"{"vers":"1.9.0","yanked":false} +{"vers":"2.0.0-beta.1","yanked":false} +{"vers":"9.0.0","yanked":true} +{"vers":"1.10.0","yanked":false} +{"vers":"1.10.0"}"#; + assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]); + assert!(parse_index("{broken").is_none()); + assert!(parse_index(r#"{"vers":"bogus"}"#).is_none()); +} + +#[test] +fn cargo_requirements_updates_and_precision() { + for (req, latest, expected) in [ + ("1", "1.9.0", None), + ("1.0.100", "1.0.228", None), + ("^1.2", "1.9.0", None), + ("0.7", "0.7.9", None), + ("0.0.1", "0.0.2", Some("0.0.2")), + ("1", "2.3.4", Some("2")), + ("1.2", "2.3.4", Some("2.3")), + ("1.2.3", "2.3.0", Some("2.3.0")), + ("1.2.3", "1.3.0", None), + ("0.2", "1.3.0", Some("1.3")), + ("=1.2.3", "1.3.0", Some("=1.3.0")), + ("~1.2", "1.3.0", Some("~1.3")), + ("^0.2", "0.3.1", Some("^0.3")), + ("1.*", "1.9.0", None), + ("*", "2.3.4", None), + (">=1, <2", "2.3.4", Some("2.3.4")), + (">=1, <2", "2.0.0", Some("2.0.0")), + ("<1", "1.0.0", Some("1.0.0")), + (">=1, <3", "2.3.4", None), + (">=9", "2.3.4", None), + (">1", "1.9.0", None), + (">1.2", "1.2.9", None), + (">1.2.3", "1.2.3", None), + ("=9.1.2", "2.3.4", None), + ("bad", "2.3.4", None), + ("1.0", "2.0.0-beta.1", None), + ("1.0.0-beta.1", "1.0.0-beta.2", None), + ("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")), + ("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")), + ] { + let actual = update(req, &[latest.to_owned()]); + assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}"); + if let Some(u) = actual { + assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap())); + } + } + let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()]; + assert_eq!(update("1", &versions).unwrap().latest, "2.0.0"); +} + +#[test] +fn locates_all_cargo_sections_and_only_replaces_version_contents() { + let text = r#"# 中文 😀 +[package] +name = "fixture" +version = "1.0.0" +[dependencies] +serde = "1.0.100" # stays +rand = '0.7' +tokio = { version = "0.2", features = ["full"] } +renamed = { package = "real-crate", version = "1.2" } +local = { path = "../local", version = "1" } +git = { git = "https://example.com/repo", version = "1" } +inherited = { workspace = true } +other = { registry = "private", version = "1" } +[dev-dependencies] +check = "1" +[build-dependencies.build] +version = "1.2.3" +[target.'cfg(unix)'.dependencies] +native = "0.1" +[target.'cfg(windows)'.dev-dependencies] +win = { version = "1" } +[target.'cfg(unix)'.build-dependencies] +build-native = "2" +[workspace.dependencies] +shared = { version = "1", features = ["std"] } +[package.metadata.dependencies] +not-a-dependency = "1" +"#; + let deps = dependencies(text); + assert_eq!( + deps.iter().map(|d| d.package.as_str()).collect::>(), + ["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"] + ); + let utf16: Vec<_> = text.encode_utf16().collect(); + for dep in &deps { + assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement); + } + let tokio = &deps[2]; + let mut edited = utf16.clone(); + edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\"")); + let rand = &deps[1]; + let mut edited = utf16; + edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'")); +} + +#[test] +fn invalid_toml_and_decoy_tables_are_silent() { + assert!(dependencies("[dependencies]\na = \"").is_empty()); + assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty()); + assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty()); + let deps = dependencies("[dependencies]\na = \"\"\n"); + assert_eq!(deps.len(), 1, "empty version strings still offer completion"); + assert_eq!(deps[0].from, deps[0].to); +} + +#[test] +fn replaces_encoded_and_multiline_strings_without_damaging_quotes() { + for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] { + let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n"); + let dep = dependencies(&text).pop().unwrap(); + assert_eq!(dep.requirement, "0.2"); + let mut encoded: Vec<_> = text.encode_utf16().collect(); + encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16()); + let edited = String::from_utf16(&encoded).unwrap(); + assert!(edited.contains("features = [\"full\"]")); + assert_eq!(dependencies(&edited)[0].requirement, "1.10"); + assert_eq!(edited.matches('"').count(), text.matches('"').count()); + assert_eq!(edited.matches('\'').count(), text.matches('\'').count()); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs new file mode 100644 index 00000000..eef3a69a --- /dev/null +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -0,0 +1,41 @@ +//! Manifest dependencies and repository versions, independent of editor and transport. +mod cache; +pub mod cargo; + +pub use cache::VersionCache; +use ide_core::{CancellationToken, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// A repository adapter; Maven/npm can use the same cache and editor protocol. +pub trait DependencyVersionSource: Send + Sync { + /// A filesystem-safe, stable repository identifier. + fn id(&self) -> &'static str; + /// Non-yanked versions in descending semantic order. + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyUpdate { + pub latest: String, + pub replacement: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ManifestDependency { + pub package: String, + pub requirement: String, + /// UTF-16 offsets of the string's contents (quotes excluded). + pub from: u32, + pub to: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyVersions { + pub dependency: ManifestDependency, + pub versions: Vec, + pub update: Option, +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 1ab611e6..38ba30ca 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -1,6 +1,7 @@ //! The project model that ties documents, settings and history together. pub mod caches; +pub mod dependencies; pub mod devcontainer; pub mod documents; pub mod editorconfig; diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs new file mode 100644 index 00000000..ae5e9bbb --- /dev/null +++ b/src-tauri/src/commands/dependencies.rs @@ -0,0 +1,17 @@ +//! Typed IPC adapters for manifest dependency versions. +use crate::state::AppState; +use ide_core::{DocId, IdeResult, RequestId}; +use ide_workspace::dependencies::DependencyVersions; +use tauri::{State, Window}; + +#[tauri::command] +#[specta::specta] +pub async fn dependency_versions( + window: Window, + state: State<'_, AppState>, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c71ce840..2d37e23e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod docker; @@ -190,6 +191,7 @@ pub fn builder() -> tauri_specta::Builder { nav::fs_resolve_reference, nav::path_completions, nav::web_unresolved_references, + dependencies::dependency_versions, spell::spell_check, spell::spell_suggest, search::search_start, diff --git a/src/bindings.ts b/src/bindings.ts index 645f8668..e2a005bb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -327,6 +327,7 @@ export const commands = { pathCompletions: (from: IpcPath, prefix: string) => typedError(__TAURI_INVOKE("path_completions", { from, prefix })), /** Paths in an HTML document or stylesheet that name no existing file. */ webUnresolvedReferences: (doc: DocId) => typedError(__TAURI_INVOKE("web_unresolved_references", { doc })), + dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError(__TAURI_INVOKE("dependency_versions", { doc, offset, request })), /** * Misspelt words in the comments, string literals and declared names of * `doc` that `scope` includes (all of its text for prose). Cancel with @@ -2468,6 +2469,17 @@ export type DeletedBranch = { unmerged: boolean, }; +export type DependencyUpdate = { + latest: string, + replacement: string, +}; + +export type DependencyVersions = { + dependency: ManifestDependency, + versions: string[], + update: DependencyUpdate | null, +}; + /** Where a local file is deployed. */ export type DeployedPath = { server: string, @@ -4289,6 +4301,14 @@ export type MainToolbarSettings = { hidden?: string[], }; +export type ManifestDependency = { + package: string, + requirement: string, + /** UTF-16 offsets of the string's contents (quotes excluded). */ + from: number, + to: number, +}; + export type Markup = { /** `markdown` or `plaintext`. */ kind: string, diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f2ab9a0..9f0251cb 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -21,6 +21,7 @@ import { Annotation, Compartment, Facet, + StateEffect, type EditorState, type Extension, type RangeSet, @@ -332,14 +333,25 @@ export function contributedAt(state: EditorState, key: string, pos: number): return state.languageDataAt(key, pos)[0]; } -/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */ +/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */ export interface ContributedIntention { id: string; label: string; icon?: string; + quickFix?: boolean; run: () => void | Promise; } +/** A background provider has new context actions for the current document. */ +export const intentionsChanged = StateEffect.define(); + +/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */ +export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>(); + +export function contributedQuickFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + return view.state.facet(quickFixProviders).flatMap((provider) => provider(view, from, to)); +} + /** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */ export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>(); diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index f4cc7850..65691292 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -12,7 +12,7 @@ import type { Diagnostic, Severity } from "@/bindings"; import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { publishLocalDiagnostics } from "@/platform/localDiagnostics"; -import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api"; import { rangeToOffsets } from "./positions"; import { lspDoc } from "./lsp"; import { showHint } from "./hint"; @@ -199,7 +199,10 @@ const diagnosticsSync = ViewPlugin.fromClass( if (!path || !this.view.dom.isConnected) return; const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path)); const tr = setDiagnostics(this.view.state, lint); - this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] }); + this.view.dispatch({ + ...tr, + effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint)), intentionsChanged.of(null)], + }); }); } destroy() { diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index e6614dc2..4da03f15 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -98,7 +98,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle fix.className = "cm-lsp-link-button"; const keys = shortcutLabel("code.showIntentions"); fix.textContent = keys ? `More actions… ${keys}` : "More actions…"; - if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to); + if (d.severity !== "hint") offerQuickFix(view, meta, fix, from, to); fix.addEventListener("mousedown", (e) => e.preventDefault()); fix.addEventListener("click", () => { view.dispatch({ selection: EditorSelection.cursor(from) }); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index bb913d15..a76d9b4a 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { setDiagnostics } from "@codemirror/lint"; import type { CodeAction } from "@/bindings"; const resolved: CodeAction = { @@ -35,6 +36,7 @@ const { intentionPreview, lightsBulb, listedCodeActions, + listedIntentions, withInspectionControls, } = await import("./intentions"); const { setFileDiagnostics } = await import("./diagnostics"); @@ -90,6 +92,27 @@ describe("inspection controls in ⌥↵ (issue #42)", () => { setFileDiagnostics("/p/two.py", "ruff", []); expect(withInspectionControls(view, [])).toEqual([]); }); + + it.each(["info", "warning", "error"] as const)( + "uses the effective %s severity for local and LSP fixes", + (severity) => { + const path = `/p/severity-${severity}.py`; + setFileDiagnostics(path, "ruff", [{ ...unused, severity }]); + const view = editorAt(path); + view.dispatch(setDiagnostics(view.state, [{ from: 7, to: 9, severity, message: unused.message }])); + const items = listedIntentions(view, [ + { id: "refactor", label: "Extract method", icon: "bulb", run: vi.fn() }, + { id: "local", label: "Local fix", quickFix: true, run: vi.fn() }, + { id: "lsp", label: "LSP fix", quickFix: true, emphasis: true, run: vi.fn() }, + ]); + expect(items.map((i) => i.id)).toEqual(["lsp", "local", "refactor"]); + expect(items.slice(0, 2).map((i) => i.icon)).toEqual(Array(2).fill(severity === "error" ? "bulb-fix" : "bulb")); + const controls = withInspectionControls(view, items); + expect(controls.slice(0, 2).every((i) => i.submenu?.length === 2)).toBe(true); + expect(controls[2]!.submenu).toBeUndefined(); + setFileDiagnostics(path, "ruff", []); + }, + ); }); describe("intention preview", () => { diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 07cb7d6d..7dd58413 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -9,7 +9,13 @@ import { forEachDiagnostic } from "@codemirror/lint"; import { indentUnit } from "@codemirror/language"; import { diff } from "@codemirror/merge"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; -import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api"; +import { + contributedIntentions, + contributedQuickFixes, + intentionProviders, + intentionsChanged, + whenSynced, +} from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; @@ -32,6 +38,27 @@ const codeActionBackoff = featureBackoff(); const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix.")); +/** The red bulb is reserved for errors; other inspections use the ordinary bulb. */ +export function quickFixIcon(view: EditorView): "bulb" | "bulb-fix" { + const sel = view.state.selection.main; + const line = view.state.doc.lineAt(sel.head); + let error = false; + forEachDiagnostic(view.state, (d, from, to) => { + if (d.severity === "error" && from <= line.to && to >= line.from) error = true; + }); + return error ? "bulb-fix" : "bulb"; +} + +const isFixItem = (item: ListPopupItem) => item.quickFix ?? item.icon === "bulb-fix"; + +/** Local and language-server fixes share icons and the same stable ordering. */ +export function listedIntentions(view: EditorView, items: ListPopupItem[]): ListPopupItem[] { + const rank = (item: ListPopupItem) => (item.disabled ? 4 : 0) + (isFixItem(item) ? 0 : 2) + (item.emphasis ? 0 : 1); + return items + .map((item) => (isFixItem(item) ? { ...item, quickFix: true, icon: quickFixIcon(view) } : item)) + .sort((a, b) => rank(a) - rank(b)); +} + /** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */ export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] { const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1); @@ -104,15 +131,23 @@ async function fetchIntentions(view: EditorView): Promise { return actions; } -/** Enabled quick fixes of the problem at `from..to`, preferred first. */ -export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { +export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise }; + +/** Enabled quick fixes of the problem at `from..to`, including local inspections. */ +export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { + const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run })); const doc = docOf(view); - if (!doc) return []; - await whenSynced(view); - const actions = await lspRequest(() => - commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), - ); - return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason)); + if (!doc) return local; + try { + await whenSynced(view); + const actions = await lspRequest(() => + commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), + ); + return [...local, ...sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason))]; + } catch (e) { + if (local.length) return local; + throw e; + } } /** @@ -144,7 +179,8 @@ export async function applyQuickFix(view: EditorView) { showHint(view, "No quick fixes available"); } -export async function applyCodeAction(view: EditorView, action: CodeAction) { +export async function applyCodeAction(view: EditorView, action: EditorQuickFix) { + if ("run" in action) return action.run(); const doc = docOf(view); if (!doc) return; if (action.disabledReason) { @@ -276,9 +312,10 @@ function actionItems( resolved?: Map>, ): ListPopupItem[] { return listedCodeActions(actions).map((a, i) => ({ - id: `${i}`, + id: `lsp:${i}`, label: a.title, - icon: isQuickFix(a) ? "bulb-fix" : "bulb", + icon: isQuickFix(a) ? quickFixIcon(view) : "bulb", + quickFix: isQuickFix(a), emphasis: a.isPreferred, disabled: a.disabledReason, // The edit the preview already resolved is applied as is. @@ -344,7 +381,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) (i) => i.id !== SPELLING_INSPECTION, ); if (!inspections.length) return items; - const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu); + const fixes = items.filter((i) => isFixItem(i) && !i.submenu); if (inspections.length === 1 && fixes.length) { const submenu = intentionControls(inspections[0]!); return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i)); @@ -364,7 +401,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { - // Typo fixes and feature-contributed actions come first and need no language server. + // Local inspections need no language server. const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)]; let actions: CodeAction[] = []; let failure: { error: unknown } | null = null; @@ -376,7 +413,9 @@ export async function showIntentions(view: EditorView) { } const sorted = listedCodeActions(actions); const resolved = new Map>(); - const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]); + const serverItems = actionItems(view, sorted, resolved); + const previews = new Map(serverItems.map((item, i) => [item.id, sorted[i]!])); + const items = withInspectionControls(view, listedIntentions(view, [...local, ...serverItems])); if (!items.length) { if (failure) throw failure.error; showHint(view, "No intentions available"); @@ -388,7 +427,7 @@ export async function showIntentions(view: EditorView) { title: "Intentions", items, preview: (item, signal) => { - const action = sorted[items.indexOf(item) - local.length]; + const action = previews.get(item.id); return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null); }, }); @@ -500,7 +539,13 @@ const bulbPlugin = ViewPlugin.fromClass( this.lookup(); } update(u: ViewUpdate) { - if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup(); + if ( + u.docChanged || + u.selectionSet || + (u.focusChanged && u.view.hasFocus) || + u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged))) + ) + this.lookup(); else if (u.focusChanged) { this.lookup.cancel(); // A blur dispatched from inside the update is not allowed. @@ -516,16 +561,16 @@ const bulbPlugin = ViewPlugin.fromClass( if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return; const state = this.view.state; const typoFix = spellingIntentions(this.view).length > 0; - const local = typoFix || contributedIntentions(this.view).length > 0; + const selection = state.selection.main; + const ownFix = contributedQuickFixes(this.view, selection.from, selection.to).length > 0; + const local = typoFix || ownFix || contributedIntentions(this.view).length > 0; const backoff = codeActionBackoff.active(doc.doc); - if (backoff && !local) return; let actions: CodeAction[] = []; if (!backoff) { try { actions = await fetchIntentions(this.view); } catch (e) { if (isUnavailable(e)) codeActionBackoff.mark(doc.doc); - if (!local) return; } } if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) @@ -533,7 +578,14 @@ const bulbPlugin = ViewPlugin.fromClass( const line = state.doc.lineAt(state.selection.main.head).number; const any = actions.some(lightsBulb) || local; this.view.dispatch({ - effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null), + effects: setBulb.of( + any + ? { + line, + quickFix: (typoFix || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix", + } + : null, + ), }); } destroy() { diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index e5276d6c..a855ccd3 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -18,6 +18,8 @@ export interface ListPopupItem { /** Second line / muted text after the label (code preview). */ preview?: string; icon?: string; + /** Inspection fix, independent of the icon chosen for its severity. */ + quickFix?: boolean; /** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */ symbol?: string; emphasis?: boolean; diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts new file mode 100644 index 00000000..e3322c59 --- /dev/null +++ b/src/features/dependencies/dependencies.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { CompletionContext } from "@codemirror/autocomplete"; +import type { DependencyVersions } from "@/bindings"; + +const dependencyVersions = vi.fn(); +const cancelRequest = vi.fn(async () => {}); +const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions }, + }; +}); +const { activate, versionCompletions } = await import("./index"); +const { editorContributions, contributedIntentions } = await import("@/editor/api"); +const { diagnosticsFor } = await import("@/editor/lsp/diagnostics"); +const { lspDoc } = await import("@/editor/lsp/lsp"); +const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions"); +const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { useProject } = await import("@/platform/project"); +const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections"); +const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n'; +const from = text.indexOf('"0.2"') + 1; +const item: DependencyVersions = { + dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 }, + versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"], + update: { latest: "1.10.0", replacement: "1.10" }, +}; +let deactivate: () => void; +const views: EditorView[] = []; +beforeEach(() => { + vi.useFakeTimers(); + dependencyVersions.mockResolvedValue({ status: "ok", data: [item] }); + useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); + deactivate = activate(); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + deactivate(); + closePopup(); + useProject.setState({ project: null, settings: null }); + vi.clearAllMocks(); + vi.useRealTimers(); +}); +function editor(path = "/p/Cargo.toml", readOnly = false) { + const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; + const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: text, + selection: { anchor: from }, + extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], + }), + }); + views.push(view); + return view; +} +async function settle() { + await vi.advanceTimersByTimeAsync(550); +} + +describe("Cargo dependency versions", () => { + it("publishes a weak warning and fixes only the version through context actions", async () => { + const view = editor(); + await settle(); + expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number)); + expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([ + { + severity: "info", + message: "A newer version is available: 1.10.0", + range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } }, + }, + ]); + const [fix] = contributedIntentions(view); + expect(fix?.label).toBe("Update to 1.10"); + await fix!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"')); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("provides direct and hover quick fixes even without a language server", async () => { + const view = editor(); + await settle(); + const [fix] = await quickFixesAt(view, from, from + 3); + expect(fix?.title).toBe("Update to 1.10"); + await applyCodeAction(view, fix!); + expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]'); + }); + it.each(["0.10", "^0.10", "=0.10.3"])( + "shows the exact replacement %s with weak-warning inspection controls", + async (replacement) => { + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [{ ...item, update: { latest: "0.10.3", replacement } }], + }); + const view = editor(); + await settle(); + await showIntentions(view); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("list"); + if (popup?.kind !== "list") throw new Error("Missing intentions"); + expect(popup.title).toBe("Intentions"); + expect(popup.items).toHaveLength(1); + expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true }); + expect(popup.items[0]!.submenu).toHaveLength(2); + await popup.items[0]!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`)); + }, + ); + it("keeps diagnostics when one of two split editors closes", async () => { + editor(); + const split = editor(); + await settle(); + split.destroy(); + views.pop(); + expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1); + }); + it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => { + const view = editor(); + await settle(); + const [fix] = contributedIntentions(view); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + expect(contributedIntentions(view)).toEqual([]); + await fix!.run(); + expect(view.state.doc.toString()).toBe("# edit\n" + text); + }); + it("discards delayed responses after an edit, project switch or editor close", async () => { + let resolve!: (value: unknown) => void; + dependencyVersions.mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + await settle(); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + resolve({ status: "ok", data: [item] }); + await vi.advanceTimersByTimeAsync(0); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + await settle(); + useProject.setState({ project: { root: "/other" } as never }); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(cancelRequest).toHaveBeenCalled(); + const other = editor("/other/Cargo.toml"); + await settle(); + other.destroy(); + views.pop(); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]); + }); + it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => { + dependencyVersions.mockRejectedValue(new Error("offline")); + editor(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] }); + editor("/p/sub/Cargo.toml"); + await settle(); + expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]); + dependencyVersions.mockClear(); + editor("/p/other.toml"); + editor("/p/readonly/Cargo.toml", true); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + }); + it("honours the inspection profile and cleans up on close", async () => { + const view = editor(); + await settle(); + useProject.setState({ + settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never, + }); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(contributedIntentions(view)).toEqual([]); + view.destroy(); + views.pop(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("completes only at a parsed dependency version, preserving descending source order", async () => { + const view = editor(); + const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + const result = await source(new CompletionContext(view.state, from + 3, true, view)); + expect(result).toMatchObject({ + from, + to: from + 3, + filter: false, + options: item.versions.map((label) => ({ label })), + }); + expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number)); + dependencyVersions.mockResolvedValue({ status: "ok", data: [] }); + expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull(); + }); + it("replaces the whole version and retains a simple requirement operator", () => { + const state = EditorState.create({ doc: "^1.2" }); + const context = new CompletionContext(state, 4, true); + const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context); + expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts new file mode 100644 index 00000000..3a47f969 --- /dev/null +++ b/src/features/dependencies/index.ts @@ -0,0 +1,208 @@ +import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { ViewPlugin, type EditorView } from "@codemirror/view"; +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { + intentionProviders, + intentionsChanged, + quickFixProviders, + registerEditorContribution, + whenSynced, + type ContributedIntention, + type EditorDocContext, +} from "@/editor/api"; +import { setFileDiagnostics } from "@/editor/lsp/diagnostics"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc"; +import { + DEPENDENCY_INSPECTION, + DEPENDENCY_PRODUCER, + inspectionProfile, + subscribeInspectionProfile, +} from "@/platform/inspections"; +import { useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; + +const setVersions = StateEffect.define(); +const versionsField = StateField.define({ + create: () => [], + update(value, tr) { + if (tr.docChanged) return []; + for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value; + return value; + }, +}); + +const viewers = new Map(); + +const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false; + +export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + if (!enabled() || view.state.readOnly) return []; + const text = view.state.doc; + return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => { + if (!update || from > dependency.to || to < dependency.from) return []; + const { replacement } = update; + return [ + { + id: `dependency.update:${dependency.from}`, + label: tx`Update to ${replacement}`, + quickFix: true, + run: () => { + if (view.state.doc !== text) return; + view.dispatch({ + changes: { from: dependency.from, to: dependency.to, insert: replacement }, + userEvent: "input.dependency", + }); + }, + }, + ]; + }); +} + +export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult { + const { dependency, versions } = item; + const typed = ctx.state.sliceDoc(dependency.from, ctx.pos); + const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? ""; + const filter = typed.slice(prefix.length); + return { + from: dependency.from, + to: dependency.to, + filter: false, + options: versions + .filter((v) => ctx.explicit || v.startsWith(filter)) + .map((version) => ({ + label: version, + type: "constant", + detail: dependency.package, + apply: prefix + version, + })), + }; +} + +function extension(doc: EditorDocContext): Extension { + const plugin = ViewPlugin.define((view) => { + if (!isTauri()) return {}; + viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1); + let timer: ReturnType | undefined; + let request: RequestId | null = null; + let sequence = 0; + let disposed = false; + const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []); + const cancel = () => { + sequence++; + if (request !== null) void commands.cancelRequest(request); + request = null; + }; + const run = async () => { + if (disposed || !enabled()) return; + const mine = sequence; + const text = view.state.doc; + const root = useProject.getState().project?.root; + const id = nextRequestId(); + request = id; + try { + await whenSynced(view); + if (mine !== sequence || disposed) return; + const items = await call(commands.dependencyVersions(doc.doc, null, id)); + if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root) + return; + view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] }); + setFileDiagnostics( + doc.path, + DEPENDENCY_PRODUCER, + items.flatMap(({ dependency, update }) => { + if (!update) return []; + const { latest } = update; + return [ + { + range: offsetsToRange(text, dependency.from, dependency.to), + severity: "info", + message: tx`A newer version is available: ${latest}`, + source: "Cargo", + code: null, + }, + ]; + }), + ); + } catch { + // Offline registries and cancelled checks never interrupt editing. + } finally { + if (request === id) request = null; + } + }; + const schedule = () => { + cancel(); + clear(); + clearTimeout(timer); + timer = setTimeout(() => void run(), 500); + }; + schedule(); + const offProfile = subscribeInspectionProfile(() => { + schedule(); + view.dispatch({ effects: intentionsChanged.of(null) }); + }); + const offProject = useProject.subscribe((state, previous) => { + if (state.project?.root !== previous.project?.root) { + disposed = true; + cancel(); + clearTimeout(timer); + clear(); + } + }); + return { + update: (u) => { + if (u.docChanged) schedule(); + }, + destroy: () => { + disposed = true; + cancel(); + clearTimeout(timer); + offProfile(); + offProject(); + const remaining = (viewers.get(doc.path) ?? 1) - 1; + if (remaining) viewers.set(doc.path, remaining); + else { + viewers.delete(doc.path); + clear(); + } + }, + }; + }); + const completion = async (ctx: CompletionContext): Promise => { + if (!isTauri()) return null; + const id = nextRequestId(); + const text = ctx.state.doc; + const root = useProject.getState().project?.root; + ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + try { + // The completion context is immutable; only use it while its document is current. + const view = ctx.view; + if (!view) return null; + await whenSynced(view); + if (ctx.aborted || view.state.doc !== text) return null; + const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id)); + if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null; + const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to); + return item ? versionCompletions(item, ctx) : null; + } catch { + return null; + } + }; + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + EditorState.languageData.of(() => [{ openideaCompletion: completion }]), + ]; +} + +export function activate(): () => void { + return registerEditorContribution({ + id: "dependency-versions", + extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + }); +} diff --git a/src/features/index.ts b/src/features/index.ts index 2678ade5..6286ef08 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp"; import { activate as welcome } from "./welcome"; import { activate as projectTree } from "./project-tree"; import { activate as search } from "./search"; +import { activate as dependencies } from "./dependencies"; import { activate as codeInsight } from "./code-insight"; import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs"; import { activate as terminal } from "./terminal"; @@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [ ["project-tree", projectTree], ["search", search], ["code-insight", codeInsight], + ["dependencies", dependencies], ["vcs", vcs], ["terminal", terminal], ["run", run], diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index 88fcfb51..8a0ed22e 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -222,6 +222,9 @@ export interface BuiltinInspection { export const SPELLING_PRODUCER = "spelling"; export const SPELLING_INSPECTION = "builtin:spelling"; +export const DEPENDENCY_PRODUCER = "dependency-versions"; +export const DEPENDENCY_INSPECTION = "builtin:dependency-versions"; + /** IDEA's Typo options: which parts of source files are checked (plain text always is). */ export const SPELLING_OPTIONS = { code: "processCode", @@ -231,6 +234,14 @@ export const SPELLING_OPTIONS = { /** Checks the IDE runs itself (not a language server). */ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ + { + id: DEPENDENCY_INSPECTION, + producer: DEPENDENCY_PRODUCER, + name: "Newer dependency version available", + group: "Cargo", + description: "Reports newer crate versions that Cargo.toml requirements do not already allow.", + defaultLevel: "weakWarning", + }, { id: "builtin:file-references", producer: "file-references", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 839f1066..b92b2166 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Newer dependency version available": "有更新的依赖版本可用", + "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", + "Update to {latest}": "更新至 {latest}", + "A newer version is available: {latest}": "有新版本可用:{latest}", "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 3a418ebf..2079d23a 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ // Product names + "Cargo", "CMake", "Prettier", "IntelliJ IDEA",