mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(cargo): dependency version inspections and updates (#684)
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
This commit is contained in:
Generated
+1
@@ -4684,6 +4684,7 @@ dependencies = [
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -60,6 +60,7 @@ pub struct Host {
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
|
||||
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(),
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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<u32>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
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<Vec<String>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Mutex<Option<Cached>>>;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Cached {
|
||||
fetched: u64,
|
||||
versions: Vec<String>,
|
||||
#[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<HashMap<String, Slot>>,
|
||||
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<Vec<String>> {
|
||||
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::<Cached>(&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;
|
||||
@@ -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<Vec<String>> {
|
||||
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"]);
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<Vec<String>> {
|
||||
#[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<ManifestDependency> {
|
||||
let Ok(doc) = text.parse::<Document<String>>() 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<ManifestDependency>) {
|
||||
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<ManifestDependency>) {
|
||||
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<DependencyUpdate> {
|
||||
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;
|
||||
@@ -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::<Vec<_>>(),
|
||||
["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());
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<String>>;
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub update: Option<DependencyUpdate>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u32>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await
|
||||
}
|
||||
@@ -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<tauri::Wry> {
|
||||
nav::fs_resolve_reference,
|
||||
nav::path_completions,
|
||||
nav::web_unresolved_references,
|
||||
dependencies::dependency_versions,
|
||||
spell::spell_check,
|
||||
spell::spell_suggest,
|
||||
search::search_start,
|
||||
|
||||
@@ -327,6 +327,7 @@ export const commands = {
|
||||
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
|
||||
/** Paths in an HTML document or stylesheet that name no existing file. */
|
||||
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
|
||||
dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError<DependencyVersions[], IdeError>(__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,
|
||||
|
||||
+13
-1
@@ -21,6 +21,7 @@ import {
|
||||
Annotation,
|
||||
Compartment,
|
||||
Facet,
|
||||
StateEffect,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
type RangeSet,
|
||||
@@ -332,14 +333,25 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
|
||||
return state.languageDataAt<T>(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<void>;
|
||||
}
|
||||
|
||||
/** A background provider has new context actions for the current document. */
|
||||
export const intentionsChanged = StateEffect.define<null>();
|
||||
|
||||
/** 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[]>();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<CodeAction[]> {
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
|
||||
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
|
||||
export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise<void> };
|
||||
|
||||
/** Enabled quick fixes of the problem at `from..to`, including local inspections. */
|
||||
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<EditorQuickFix[]> {
|
||||
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<CodeAction, Promise<CodeAction>>,
|
||||
): 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<CodeAction, Promise<CodeAction>>();
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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<unknown>>(
|
||||
"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" });
|
||||
});
|
||||
});
|
||||
@@ -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<DependencyVersions[]>();
|
||||
const versionsField = StateField.define<DependencyVersions[]>({
|
||||
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<string, number>();
|
||||
|
||||
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<typeof setTimeout> | 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<CompletionResult | null> => {
|
||||
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) : []),
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 桥接命令未确认类库更新",
|
||||
|
||||
@@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
|
||||
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
|
||||
const UNTRANSLATED = new Set<string>([
|
||||
// Product names
|
||||
"Cargo",
|
||||
"CMake",
|
||||
"Prettier",
|
||||
"IntelliJ IDEA",
|
||||
|
||||
Reference in New Issue
Block a user