Compare commits

..
183 changed files with 431 additions and 8048 deletions

No files matched your search

Generated
-4
View File
@@ -4241,7 +4241,6 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
@@ -4251,7 +4250,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
@@ -4685,7 +4683,6 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -6210,7 +6207,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+2 -30
View File
@@ -285,14 +285,6 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
}
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
/// which Maven 4 (model 4.1.0) uses instead.
fn module_names(e: &Element) -> Vec<String> {
let mut names = texts(e, "modules", "module");
names.extend(texts(e, "subprojects", "subproject"));
names
}
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
@@ -316,7 +308,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: module_names(project),
modules: texts(project, "modules", "module"),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -337,7 +329,7 @@ fn read_raw(project: &Element) -> RawPom {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
modules: module_names(p),
modules: texts(p, "modules", "module"),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -1284,26 +1276,6 @@ mod tests {
</profiles>
</project>"#;
#[test]
fn maven_4_subprojects_are_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<subprojects><subproject>core</subproject></subprojects>
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
</project>"#,
);
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
let model = load_with_repo(root, None);
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -19,4 +19,4 @@ tokio-util.workspace = true
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
-6
View File
@@ -82,12 +82,6 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
-25
View File
@@ -1,25 +0,0 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
-1
View File
@@ -11,7 +11,6 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
+38 -133
View File
@@ -245,7 +245,10 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
@@ -256,53 +259,45 @@ pub fn user_path() -> Option<OsString> {
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let value = read_registry_string(key, &wide("Path"));
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
/// against the current environment. `None` when the value is missing or unreadable.
///
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
/// that grows when expanded is re-read at the size the failed call reports. And the
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
/// the first NUL, not just stripped of trailing ones.
#[cfg(windows)]
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
let mut kind = 0u32;
let mut len = 0u32;
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
let status =
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let buf = loop {
let mut buf = vec![0u16; len as usize / 2 + 1];
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
let status = unsafe {
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
};
if status == ERROR_SUCCESS {
break buf;
}
if status != ERROR_MORE_DATA || len == 0 {
return None;
}
};
let text = String::from_utf16_lossy(&buf);
let end = text.find('\0').unwrap_or(text.len());
Some(text[..end].to_owned())
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
@@ -339,96 +334,6 @@ mod tests {
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
-80
View File
@@ -207,58 +207,6 @@ impl ProxySettings {
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
/// System properties that make a JVM follow this configuration. A JVM reads none of
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
/// HTTP client) goes direct without these, and a repository its proxy answers in
/// milliseconds can then hang it instead.
pub fn jvm_args(&self) -> Vec<String> {
match self.mode {
// macOS and Windows JVMs read the system proxy with this one.
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
ProxyMode::None => Vec::new(),
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
ProxyMode::Manual => Vec::new(),
}
}
fn manual_jvm_args(&self) -> Vec<String> {
let (host, port) = (self.host.trim(), self.port);
match self.kind {
ProxyKind::Http => {
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
let mut args = vec![
format!("-Dhttp.proxyHost={host}"),
format!("-Dhttp.proxyPort={port}"),
format!("-Dhttps.proxyHost={host}"),
format!("-Dhttps.proxyPort={port}"),
];
let hosts = self.non_proxy_hosts();
if !hosts.is_empty() {
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
}
args
}
// SOCKS proxies every TCP connection, so there is one property pair.
ProxyKind::Socks => vec![
format!("-DsocksProxyHost={host}"),
format!("-DsocksProxyPort={port}"),
"-Djava.net.useSystemProxies=false".into(),
],
}
}
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
fn non_proxy_hosts(&self) -> Vec<String> {
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
for entry in self.no_proxy_entries() {
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
hosts.push(entry);
}
}
hosts
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
@@ -344,34 +292,6 @@ mod tests {
assert!(p.child_env().is_empty());
}
#[test]
fn jvm_args_carry_the_proxy_as_system_properties() {
// A JVM reads none of `child_env`: a Java language server needs these to not go
// direct while curl, git and Node follow the environment.
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
assert_eq!(
manual("proxy.corp", 3128).jvm_args(),
[
"-Dhttp.proxyHost=proxy.corp",
"-Dhttp.proxyPort=3128",
"-Dhttps.proxyHost=proxy.corp",
"-Dhttps.proxyPort=3128",
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
]
);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
let p = ProxySettings {
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
..manual("proxy.corp", 3128)
};
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
// A manual proxy without a host connects directly, and so does its JVM.
assert!(manual(" ", 80).jvm_args().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
-56
View File
@@ -58,43 +58,6 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -191,7 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -803,24 +765,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
-3
View File
@@ -41,8 +41,6 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -73,7 +71,6 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
-7
View File
@@ -51,11 +51,4 @@ uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
-1
View File
@@ -55,7 +55,6 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
-474
View File
@@ -1,474 +0,0 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+12 -40
View File
@@ -7,16 +7,6 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
@@ -54,19 +44,16 @@ pub async fn format(
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
@@ -82,13 +69,13 @@ pub async fn format(
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
});
}
edits.map_err(|e| e.with_tool(tool))
edits.map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
@@ -117,19 +104,4 @@ mod tests {
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
+3 -24
View File
@@ -3,7 +3,6 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -34,10 +33,7 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -53,14 +49,10 @@ pub struct HostConfig {
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
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).
@@ -84,17 +76,8 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
@@ -103,11 +86,8 @@ impl Host {
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
environment: environment::Environment::new(project_environment, 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(),
@@ -116,7 +96,6 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
RegistryPaths::new(&host.config_dir, &host.data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
+14 -21
View File
@@ -133,29 +133,22 @@ pub async fn build_sync(
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
&& let Some(maven) = &model.maven
{
bridge.configure_maven(offline, bridge_profiles);
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436).
if let Some(bridge) = &project.kotlin_bridge {
bridge.configure_maven(offline, bridge_profiles);
}
let lsp = project.lsp.clone();
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
}
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
-124
View File
@@ -1,124 +0,0 @@
//! 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"),
local_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");
}
}
-1
View File
@@ -30,7 +30,6 @@ impl Fixture {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
+1 -1
View File
@@ -61,7 +61,7 @@ async fn resolve_launch(
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
-2
View File
@@ -8,7 +8,6 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -70,7 +69,6 @@ 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);
+4 -5
View File
@@ -50,11 +50,10 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
-20
View File
@@ -5,23 +5,6 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
|| config.extras.target.as_ref().is_some_and(|s| !s.is_empty())
{
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let project = host.project(window)?;
let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?;
if debug {
let root = host.project(window)?.root.clone();
let target = ide_run::dotnet::target_path(&root, &config).await?;
config.extras.params.insert("targetPath".into(), target);
}
Ok(config)
}
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -223,9 +206,6 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await
});
d.add("run_application_build", |c| async move {
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
});
-46
View File
@@ -3,21 +3,6 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
mod images;
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
pub async fn store_image(host: &Host, id: TerminalId, data: Vec<u8>, mime: String) -> IdeResult<String> {
if data.len() > MAX_IMAGE_BYTES {
return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)"));
}
if !host.terminals.contains(id) {
return Err(ide_core::IdeError::invalid("terminal session not found"));
}
let data_dir = host.data_dir.clone();
host.blocking(move || images::store(&data_dir, &data, &mime)).await
}
pub fn spawn(
host: &Host,
window: &str,
@@ -62,34 +47,3 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
host.terminals.is_busy(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use ide_core::{NullSink, error::ErrorKind};
use std::sync::Arc;
#[tokio::test]
async fn image_size_limit_is_checked_before_storage() {
let root = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let id = TerminalId::next();
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
assert_eq!(error.message, "terminal session not found");
assert!(!host.data_dir.join("terminal-images").exists());
}
}
-220
View File
@@ -1,220 +0,0 @@
use ide_core::{IdeError, IdeResult};
use std::path::Path;
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
use std::fs::File;
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::time::{Duration, SystemTime};
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const DIRECTORY: &str = "terminal-images";
fn extension(mime: &str) -> IdeResult<&str> {
match mime {
"image/png" => Ok("png"),
"image/jpeg" => Ok("jpg"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/bmp" => Ok("bmp"),
"image/svg+xml" => Ok("svg"),
"image/tiff" => Ok("tiff"),
"image/x-icon" => Ok("ico"),
_ => Err(IdeError::invalid("unsupported image type")),
}
}
fn open_directory(data_dir: &Path) -> io::Result<File> {
if !data_dir.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
// The configured data directory and its ancestors are the host's trust boundary.
let parent =
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
Err(e) => return Err(e.into()),
}
let dir: File = fs::openat(
&parent,
DIRECTORY,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into();
let meta = dir.metadata()?;
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal image directory is not private or owned by the current user",
));
}
Ok(dir)
}
fn owned_name(name: &str) -> bool {
let name = name.strip_prefix(".partial-").unwrap_or(name);
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
id.len() == 32
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
}
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
for entry in Dir::read_from(dir)? {
let entry = entry?;
let name = entry.file_name();
if !name.to_str().is_ok_and(owned_name) {
continue;
}
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
let fd = match fs::openat(
dir,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => fd,
Err(_) => continue,
};
let file = File::from(fd);
let meta = file.metadata()?;
if meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::unlinkat(dir, name, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let dir = open_directory(data_dir)?;
cleanup(&dir, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let temporary = format!(".partial-{name}");
let fd = fs::openat(
&dir,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(io::Error::from)?;
let mut file = File::from(fd);
if let Err(error) = write(&mut file, data) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(error.into());
}
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(io::Error::from(error).into());
}
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn stores_bytes_with_private_modes_and_unpredictable_names() {
let root = tempfile::tempdir().unwrap();
let first = store(root.path(), b"image bytes", "image/png").unwrap();
let second = store(root.path(), b"other", "image/jpeg").unwrap();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn expiry_only_removes_owned_regular_files_at_boundary() {
let root = tempfile::tempdir().unwrap();
let dir = open_directory(root.path()).unwrap();
let path = root.path().join(DIRECTORY);
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.png",
"image-00000000000000000000000000000003.png",
"unrelated.png",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000004.png");
symlink(path.join(names[0]), &link).unwrap();
cleanup(&dir, now).unwrap();
assert!(!path.join(names[0]).exists());
for name in &names[1..] {
assert!(path.join(name).exists());
}
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn rejects_symlink_and_insecure_directory() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join(DIRECTORY);
symlink(outside.path(), &target).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
fs::remove_file(&target).unwrap();
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
}
}
#[cfg(unix)]
pub(super) use unix::store;
#[cfg(not(unix))]
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
+4 -4
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.local_data_dir.clone();
let data = host.data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (host.global_settings.effective(), Default::default()),
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = Tools::new(&host.local_data_dir);
let tools = Tools::new(&host.data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
/// Where Download JDK puts JDKs unless told otherwise.
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
@@ -130,7 +130,7 @@ pub async fn jdk_download(
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.local_data_dir);
let tools = Tools::new(&host.data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+11 -11
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.local_data_dir.join("index").join(&project_key),
&self.data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
@@ -127,7 +127,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.local_data_dir.clone();
let data_dir = self.data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
@@ -214,7 +214,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.local_data_dir.clone();
let data_dir = self.data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
@@ -255,8 +255,8 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
self.data_dir.join("cache").join(&project_key).join("build"),
&self.data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
@@ -385,7 +385,7 @@ impl Host {
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
@@ -404,9 +404,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
cache_dir: self.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
@@ -509,7 +509,7 @@ impl Host {
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
@@ -526,9 +526,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
-3
View File
@@ -231,9 +231,6 @@ impl Dispatcher {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_store_image", |c| async move {
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
-2
View File
@@ -222,7 +222,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_symbols"
| "mark_recent"
@@ -244,7 +243,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "remote_list"
| "remote_open"
| "remote_of"
-39
View File
@@ -1,39 +0,0 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
-1
View File
@@ -9,7 +9,6 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -20,7 +20,6 @@ fn host(base: &Path) -> Host {
Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -10,7 +10,6 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -79,7 +79,6 @@ fn server_environment_from_login_shell_and_devcontainer() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
-3
View File
@@ -56,7 +56,6 @@ async fn serves_projects_files_documents_search_and_runs() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
@@ -262,7 +261,6 @@ async fn local_deployment_runs_on_the_project_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
@@ -330,7 +328,6 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
-1
View File
@@ -6,7 +6,6 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -24,7 +24,6 @@ fn terminal_activation_uses_the_resolved_default_shell() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-47
View File
@@ -1,47 +0,0 @@
#![cfg(unix)]
use ide_core::{NullSink, TerminalId};
use ide_host::{Host, HostConfig, ops};
use ide_terminal::TerminalOptions;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn storage_requires_a_registered_terminal() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: data_dir.clone(),
local_data_dir: data_dir.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
assert!(!data_dir.join("terminal-images").exists());
let terminal = host
.terminals
.spawn(
TerminalOptions {
cwd: root.path().into(),
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
Arc::new(NullSink),
Box::new(|_| {}),
)
.unwrap();
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
host.terminals.kill(terminal.id).unwrap();
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
}
-61
View File
@@ -11,7 +11,6 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -56,8 +55,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"db_console_path",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
@@ -77,15 +74,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_git_version",
"docker_compose_op",
"db_execute",
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
"deploy_download",
"deploy_delete_remote",
"deploy_sync",
"unknown_command",
] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
@@ -120,55 +108,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
host.shutdown().await;
}
#[tokio::test]
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
use ide_core::remote_settings::{SshAuth, SshConfiguration};
use ide_ssh::{FsConnection, SshFiles, SshManager};
let tmp = tempfile::tempdir().unwrap();
let server_root = tmp.path().join("server");
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
tmp.path().join("secrets.json"),
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
));
ssh.set_configs(vec![SshConfiguration {
id: "selected-host".into(),
host: "127.0.0.1".into(),
port: Some(port),
user: "dev".into(),
auth: SshAuth::Password,
..Default::default()
}]);
ssh.set_secret("selected-host", Some("secret".into()), false);
assert!(ssh.session("selected-host").await.is_err());
ssh.trust_host("selected-host").await.unwrap();
let mut host = host(&tmp.path().join("app"), false);
let state = Arc::get_mut(&mut host).unwrap();
state.ssh = ssh;
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
assert!(host.project("welcome").is_err());
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
let entries = host.remote.list(&connection, "/projects").await.unwrap();
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
host.authorize_command("welcome", "deploy_test_connection").unwrap();
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
assert!(host.project("welcome").is_err());
host.shutdown().await;
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
@@ -12,7 +12,6 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -28,7 +28,6 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -31,7 +31,6 @@ async fn patches_read_and_write_the_requested_repository() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -22,7 +22,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1 -28
View File
@@ -8,7 +8,6 @@ use std::sync::Arc;
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("bat", include_str!("../../../languages/bat.toml")),
("c", include_str!("../../../languages/c.toml")),
("clojure", include_str!("../../../languages/clojure.toml")),
@@ -16,7 +15,6 @@ const BUILTIN: &[(&str, &str)] = &[
("cpp", include_str!("../../../languages/cpp.toml")),
("csharp", include_str!("../../../languages/csharp.toml")),
("css", include_str!("../../../languages/css.toml")),
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
@@ -375,7 +373,6 @@ mod tests {
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
/// (the UI resolves `ui-mode` against these).
const UI_MODES: &[&str] = &[
"AsciiDoc",
"Batch",
"C",
"C++",
@@ -535,12 +532,6 @@ mod tests {
let reg = reg();
assert_eq!(detect(&reg, "src/main.rs"), "rust");
assert_eq!(detect(&reg, "a/b.TS"), "typescript");
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
assert_eq!(detect(&reg, path), "asciidoc");
}
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -576,7 +567,7 @@ mod tests {
};
// After the TypeScript fallback (an available server before it would hide
// it); before the HTML server (hover, rename and symbols take the first).
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -586,24 +577,6 @@ mod tests {
}
}
#[test]
fn oxlint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
}
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
-126
View File
@@ -1,126 +0,0 @@
//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks.
use crate::{LspManager, Server};
use ide_core::IdeEvent;
use std::path::Path;
pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.";
fn is_apple_clangd(path: &Path) -> bool {
if path == Path::new("/usr/bin/clangd") {
return true;
}
if !path.ends_with("usr/bin/clangd") {
return false;
}
path.starts_with("/Library/Developer/CommandLineTools")
|| path.ancestors().any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "Toolchains")
&& ancestor.parent().is_some_and(|developer| {
developer.ends_with("Contents/Developer")
&& developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| {
let name = name.to_string_lossy();
name.starts_with("Xcode") && name.ends_with(".app")
})
})
})
}
fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool {
server_root.join(".clang-tidy").is_file()
|| file
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|dir| dir.starts_with(project_root))
.any(|dir| dir.join(".clang-tidy").is_file())
}
impl LspManager {
pub(crate) async fn check_clang_tidy(&self, server: &Server) {
if server.name != "clangd"
|| !cfg!(target_os = "macos")
|| self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into()))
{
return;
}
let binary = server.resolved_binary.read().clone();
let Some(binary) = binary else { return };
let files: Vec<_> = self
.docs
.lock()
.values()
.filter(|doc| {
matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp")
&& doc.servers.iter().any(|attached| attached.server.id == server.id)
})
.map(|doc| doc.path.clone())
.collect();
let root = server.root.clone();
let Some(me) = self.me.upgrade() else { return };
let _ = tokio::task::spawn_blocking(move || {
if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) {
return;
}
if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire)
&& files.iter().any(|file| has_config(&root, &me.project_root_for(file), file))
&& me.first_message("clangd", MESSAGE)
{
me.sink.emit(IdeEvent::LanguageServerWarning {
name: "clangd".into(),
message: MESSAGE.into(),
install_tool: "clangd".into(),
});
}
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_paths() {
for path in [
"/usr/bin/clangd",
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Library/Developer/CommandLineTools/usr/bin/clangd",
] {
assert!(is_apple_clangd(Path::new(path)), "{path}");
}
for path in [
"/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd",
"/opt/homebrew/opt/llvm/bin/clangd",
"/usr/local/bin/clangd",
"/usr/bin/clangd-custom",
"/Library/Developer/CommandLineTools-custom/usr/bin/clangd",
"/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd",
] {
assert!(!is_apple_clangd(Path::new(path)), "{path}");
}
}
#[test]
fn config_in_server_root_or_file_ancestors_stays_inside_project() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project");
let server = project.join("build");
let source = project.join("src/nested");
let file = source.join("main.cpp");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&server).unwrap();
std::fs::write(dir.path().join(".clang-tidy"), "").unwrap();
assert!(!has_config(&server, &project, &file));
for root in [&server, &source, &project.join("src"), &project] {
let config = root.join(".clang-tidy");
std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap();
assert!(has_config(&server, &project, &file), "{}", root.display());
std::fs::remove_file(config).unwrap();
}
std::fs::create_dir(source.join(".clang-tidy")).unwrap();
assert!(!has_config(&server, &project, &file));
}
}
+1 -26
View File
@@ -43,18 +43,6 @@ pub trait Launcher: Send + Sync + 'static {
self.launch(config, root, handler, on_stderr)
}
/// Starts a server and reports its executable when the launcher runs a local process.
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None))
}
/// Whether `config` could start without a `Missing` error.
fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool {
true
@@ -84,17 +72,6 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer)
}
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b));
@@ -110,12 +87,10 @@ impl Launcher for ProcessLauncher {
handler,
on_stderr,
cancel,
)
.map(|peer| (peer, None));
);
}
let binary = binary.ok_or(LaunchError::Missing)?;
Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr)
.map(|peer| (peer, Some(binary.clone())))
.map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))
}
+2 -13
View File
@@ -8,7 +8,6 @@
mod angular;
mod caps;
mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
@@ -473,16 +472,13 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok((peer, binary)) => {
*server.resolved_binary.write() = binary;
peer
}
Ok(peer) => peer,
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
@@ -591,7 +587,6 @@ impl LspManager {
}
}
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
self.spawn_supervisor(server, generation, peer);
@@ -815,12 +810,6 @@ impl LspManager {
}
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
}
}
-2
View File
@@ -79,7 +79,6 @@ pub struct Server {
pub config: LanguageServerConfig,
/// `config` with the settings hook applied, as the current instance was launched.
effective: Mutex<Option<LanguageServerConfig>>,
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
@@ -115,7 +114,6 @@ impl Server {
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
start_cancel: Mutex::new(CancellationToken::new()),
-78
View File
@@ -21,7 +21,6 @@ use std::time::Duration;
struct MockState {
caps: Mutex<Value>,
missing: std::sync::atomic::AtomicBool,
binary: Mutex<Option<PathBuf>>,
launches: AtomicUsize,
notifications: Mutex<Vec<(String, Value)>>,
requests: Mutex<Vec<(String, Value)>>,
@@ -149,18 +148,6 @@ impl MockLauncher {
}
impl Launcher for MockLauncher {
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &ide_core::CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel)
.map(|peer| (peer, self.state(&config.name).binary.lock().clone()))
}
fn launch(
&self,
config: &LanguageServerConfig,
@@ -2849,68 +2836,3 @@ async fn formatting_provider_selection_tracks_dynamic_registration() {
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() {
for language in ["c", "cpp", "objective-c", "objective-cpp"] {
let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("main.src", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("clangd", ServerStatus::Ready).await;
h.open("other.src", "int *p = 0;");
h.mgr.shutdown_all().await;
let events = h.events.lock();
let warnings: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)),
_ => None,
})
.collect();
assert_eq!(warnings.len(), 1, "{language}");
assert_eq!(warnings[0].0, "clangd");
assert!(warnings[0].1.contains(".clang-tidy"));
assert_eq!(warnings[0].2, "clangd");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_warning_requires_apple_binary_and_a_config() {
for (binary, config) in [
("/usr/bin/clangd", false),
("/opt/homebrew/opt/llvm/bin/clangd", true),
("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true),
] {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary));
if config {
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
}
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
h.mgr.shutdown_all().await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })));
std::fs::create_dir(h.root.join("nested")).unwrap();
std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("nested/other.cpp", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
+1 -11
View File
@@ -4,7 +4,6 @@
//! Detection never fails: unreadable or malformed files simply contribute nothing.
pub(crate) mod cmake;
mod dotnet;
use crate::RunConfig;
use serde_json::Value as Json;
@@ -17,14 +16,6 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
}
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
detect_with_profiles(root, excluded_poms, None)
}
pub(crate) fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
@@ -33,8 +24,7 @@ pub(crate) fn detect_with_profiles(
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, profiles, &mut out);
dotnet::detect(root, &mut out);
cmake::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+5 -172
View File
@@ -2,7 +2,6 @@
use super::{ConfigExt, config};
use crate::{BeforeLaunchTask, RunConfig};
use ide_core::settings::CMakeProfile;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
@@ -20,16 +19,10 @@ fn before(config: &mut RunConfig, id: &str) {
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
}
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
if !root.join("CMakeLists.txt").is_file() {
return;
}
if let Some(profiles) = profiles {
for (index, profile) in profiles.iter().enumerate() {
detect_profile(root, profile, index, profiles.len() > 1, out);
}
return;
}
let mut builds = Vec::new();
if let Ok(entries) = fs::read_dir(root) {
builds.extend(entries.flatten().filter_map(|e| {
@@ -86,69 +79,11 @@ pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut V
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, None, out);
targets(root, &build, &dir, &prefix, out);
}
}
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
let dir = if profile.build_dir.trim().is_empty() {
format!("cmake-build-{}", profile.build_type.to_lowercase())
} else {
profile.build_dir.clone()
};
let build = root.join(&dir);
let prefix = format!("cmake:profile:{index}");
let configure_id = format!("{prefix}:configure");
let build_id = format!("{prefix}:build");
let start = out.len();
let mut configure =
config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]);
if !profile.generator.trim().is_empty() {
configure.args.extend(["-G".into(), profile.generator.clone()]);
}
configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type));
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
configure.args.extend_from_slice(&profile.cmake_args);
configure.extras.params.insert("buildDir".into(), dir.clone());
out.push(configure);
let mut all = config(
&build_id,
format!("CMake Build ({dir})"),
"cmake",
"cmake",
&["--build", &dir, "--config", &profile.build_type],
);
all.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut all, &configure_id);
}
out.push(all);
let mut test = config(
format!("{prefix}:test"),
format!("CTest ({dir})"),
"cmake",
"ctest",
&["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"],
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, Some(profile), out);
for config in &mut out[start..] {
config.env = profile.env.clone();
if multiple {
config.name = format!("{} [{}]", config.name, profile.name);
}
}
}
fn targets(
root: &Path,
build: &Path,
dir: &str,
prefix: &str,
profile: Option<&CMakeProfile>,
out: &mut Vec<RunConfig>,
) {
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
let reply = build.join(".cmake/api/v1/reply");
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
entries
@@ -178,9 +113,6 @@ fn targets(
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
for configuration in configurations {
let name = configuration["name"].as_str().unwrap_or("");
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
continue;
}
let Some(targets) = configuration["targets"].as_array() else { continue };
for target in targets {
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
@@ -198,17 +130,8 @@ fn targets(
if !name.is_empty() {
args.extend(["--config", name]);
}
let mut build_config =
let build_config =
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
if let Some(profile) = profile {
if name.is_empty() {
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
}
build_config.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut build_config, &format!("{prefix}:configure"));
}
}
out.push(build_config);
let binary = build.join(artifact);
let mut run =
@@ -242,99 +165,9 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
&& config.args.first().is_some_and(|a| a == "-S")
&& let Some(dir) = config.extras.params.get("buildDir")
{
let build = cwd.join(dir);
let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str());
if let Some(generator) = generator
&& let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt"))
&& let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL="))
&& cached != generator
{
fs::remove_file(build.join("CMakeCache.txt"))?;
let files = build.join("CMakeFiles");
if files.is_dir() {
fs::remove_dir_all(files)?;
}
}
let query = build.join(".cmake/api/v1/query/client-openidea");
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
fs::create_dir_all(&query)?;
fs::write(query.join("codemodel-v2"), "")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profiles_select_file_api_configuration_and_apply_target_options() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let reply = root.path().join("output/.cmake/api/v1/reply");
fs::create_dir_all(&reply).unwrap();
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
write(
"index-1.json",
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
);
write(
"model.json",
serde_json::json!({"paths":{"source":root.path()},"configurations":[
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
]}),
);
for name in ["debug", "release"] {
write(
&format!("{name}.json"),
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
);
}
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
build_dir: "output".into(),
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
env: [("FOO".into(), "bar".into())].into(),
..Default::default()
};
let mut configs = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
let run = configs.iter().find(|c| c.kind == "native").unwrap();
assert!(run.command.ends_with("release/app"));
assert_eq!(run.env, profile.env);
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
assert_eq!(
build.args,
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
);
assert_eq!(build.env, profile.env);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
let mut configured = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
for id in ["cmake:profile:0:build", build.id.as_str()] {
let build = configured.iter().find(|c| c.id == id).unwrap();
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
}
}
#[test]
fn generator_change_clears_only_cmake_state_at_launch() {
let root = tempfile::tempdir().unwrap();
let build = root.path().join("cmake-build-debug");
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
fs::write(build.join("keep.txt"), "artifact").unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let mut configs = Vec::new();
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
assert!(build.join("CMakeCache.txt").exists());
prepare(&configs[0], root.path()).unwrap();
assert!(!build.join("CMakeCache.txt").exists());
assert!(!build.join("CMakeFiles").exists());
assert!(build.join("keep.txt").exists());
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Runnable .NET projects and launch profiles, without a solution model.
use crate::{
RunConfig,
dotnet::{parse_project, profiles, solution_projects},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
let mut dirs = vec![root.to_path_buf()];
let mut projects = BTreeSet::new();
while let Some(dir) = dirs.pop() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
if !name.starts_with('.')
&& !matches!(
name.as_str(),
"bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages"
)
{
dirs.push(path);
}
} else if entry.file_type().is_ok_and(|t| t.is_file()) {
match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() {
"csproj" => {
projects.insert(path);
}
"sln" | "slnx" => {
if let Ok(src) = fs::read_to_string(&path) {
for project in solution_projects(
&src,
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")),
) {
projects.insert(dir.join(project));
}
}
}
_ => {}
}
}
}
}
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into());
let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect();
for path in projects {
let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue };
let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let name = path.file_stem().unwrap_or_default().to_string_lossy();
let profiles = profiles(&path);
let mut params = BTreeMap::from([
("project".into(), rel.clone()),
("framework".into(), project.frameworks.first().cloned().unwrap_or_default()),
("frameworks".into(), project.frameworks.join(";")),
("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()),
("launchProfile".into(), String::new()),
("programArgs".into(), String::new()),
]);
if project.executable && !project.test {
out.push(detected(&rel, &name, "", params.clone(), false));
for profile in profiles.keys() {
params.insert("launchProfile".into(), profile.clone());
out.push(detected(&rel, &name, profile, params.clone(), false));
}
}
if project.test {
params.insert("launchProfile".into(), String::new());
out.push(detected(&rel, &name, "", params, true));
}
}
}
fn detected(project: &str, name: &str, profile: &str, params: BTreeMap<String, String>, test: bool) -> RunConfig {
let op = if test { "test" } else { "run" };
let mut config = super::config(
format!("dotnet:{project}:{op}:{profile}"),
if test {
format!("dotnet test ({name})")
} else if profile.is_empty() {
format!("{name} (.NET Project)")
} else {
format!("{name}: {profile}")
},
if test { "dotnet-test" } else { "dotnet" },
"dotnet",
&[],
);
config.args = command_args(&params, test);
config.is_test = test;
config.debug_adapter = (!test).then(|| "netcoredbg".into());
config.extras.params = params;
if !test {
config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() });
}
config
}
fn command_args(params: &BTreeMap<String, String>, test: bool) -> Vec<String> {
let mut args = if test {
vec!["test".into(), params.get("project").cloned().unwrap_or_default()]
} else {
vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()]
};
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.extend(["--framework".into(), framework.clone()]);
}
if !test {
if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) {
args.extend(["--launch-profile".into(), profile.clone()]);
} else {
args.push("--no-launch-profile".into());
}
}
args
}
-257
View File
@@ -1,257 +0,0 @@
//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs.
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use quick_xml::{Reader, XmlVersion, events::Event};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, PartialEq)]
pub struct Project {
pub executable: bool,
pub frameworks: Vec<String>,
pub test: bool,
}
#[derive(Default)]
struct Element {
name: String,
attrs: BTreeMap<String, String>,
text: String,
conditional: bool,
}
fn elements(xml: &str) -> Option<Vec<Element>> {
let mut reader = Reader::from_str(xml);
let mut out: Vec<Element> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
loop {
let event = reader.read_event().ok()?;
let empty = matches!(&event, Event::Empty(_));
match event {
Event::Start(tag) | Event::Empty(tag) => {
let attrs: BTreeMap<_, _> = tag
.attributes()
.map(|a| {
let a = a.ok()?;
Some((
a.key.as_ref().to_owned(),
a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(),
))
})
.collect::<Option<_>>()?;
let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional);
out.push(Element {
name: tag.local_name().as_ref().to_owned(),
attrs,
conditional,
..Default::default()
});
// Empty elements have no corresponding End event.
if !empty {
stack.push(out.len() - 1);
}
}
Event::End(_) => {
stack.pop()?;
}
Event::Text(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0));
}
}
Event::CData(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(text.as_ref());
}
}
Event::GeneralRef(reference) => {
let name = reference.as_ref();
let escaped = format!("&{name};");
if let Some(i) = stack.last() {
out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?);
}
}
Event::Eof => return stack.is_empty().then_some(out),
_ => {}
}
}
}
pub fn parse_project(xml: &str) -> Option<Project> {
let tags = elements(xml)?;
if tags.first()?.name != "Project" {
return None;
}
let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim());
let web = tags.iter().any(|t| {
t.attrs
.get("Sdk")
.or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten())
.is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web")))
});
let executable = match property("OutputType") {
Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"),
None => web,
};
let frameworks = property("TargetFramework")
.filter(|v| !v.is_empty())
.or_else(|| property("TargetFrameworks"))
.unwrap_or_default()
.split(';')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect();
let test = tags.iter().any(|t| {
t.name == "PackageReference"
&& t.attrs
.get("Include")
.or_else(|| t.attrs.get("Update"))
.is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk"))
});
Some(Project { executable, frameworks, test })
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", default)]
pub struct Profile {
pub command_name: String,
pub command_line_args: String,
pub working_directory: String,
pub application_url: String,
pub environment_variables: BTreeMap<String, String>,
}
pub fn parse_profiles(json: &str) -> BTreeMap<String, Profile> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(&ide_core::jsonc::strip(json)) else {
return BTreeMap::new();
};
json["profiles"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(name, value)| {
let profile: Profile = serde_json::from_value(value.clone()).ok()?;
(profile.command_name == "Project").then(|| (name.clone(), profile))
})
.collect()
}
pub fn solution_projects(src: &str, xml: bool) -> Vec<PathBuf> {
let paths: Vec<String> = if xml {
elements(src)
.unwrap_or_default()
.into_iter()
.filter(|e| e.name == "Project")
.filter_map(|mut e| e.attrs.remove("Path"))
.collect()
} else {
src.lines()
.filter_map(|line| {
let line = line.trim();
if !line.starts_with("Project(") {
return None;
}
Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned())
})
.collect()
};
paths
.into_iter()
.filter(|p| p.to_ascii_lowercase().ends_with(".csproj"))
.map(|p| PathBuf::from(p.replace('\\', "/")))
.collect()
}
pub(crate) fn profiles(project: &Path) -> BTreeMap<String, Profile> {
project
.parent()
.and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok())
.map(|s| parse_profiles(&s))
.unwrap_or_default()
}
/// Resolves the selected profile afresh so changing project/profile never reuses stale values.
pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult<RunConfig> {
let params = &config.extras.params;
let project = params
.get("project")
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let project = dunce::canonicalize(root.join(project))?;
let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?;
let profile_name = params.get("launchProfile").cloned().unwrap_or_default();
let profile = if profile_name.is_empty() {
Profile::default()
} else {
profiles(&project).remove(&profile_name).ok_or_else(|| {
IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile"))
})?
};
let mut env = profile.environment_variables;
if !profile.application_url.is_empty() {
env.insert("ASPNETCORE_URLS".into(), profile.application_url);
}
if !profile_name.is_empty() {
env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name);
}
env.extend(config.env);
config.env = env;
config.cwd = Some(
config
.cwd
.map(|p| root.join(&*p))
.unwrap_or_else(|| {
if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) }
})
.into(),
);
config.extras.params.insert("project".into(), project.to_string_lossy().into_owned());
config.extras.params.insert("profileArgs".into(), profile.command_line_args);
Ok(config)
}
/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties.
pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult<String> {
let params = &config.extras.params;
let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let mut args = vec![
"msbuild".to_owned(),
root.join(project).to_string_lossy().into_owned(),
"-nologo".into(),
"-getProperty:TargetPath".into(),
"-p:Configuration=Debug".into(),
];
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.push(format!("-p:TargetFramework={framework}"));
}
let mut command = std::process::Command::new(&config.command);
ide_core::process::host_env(&mut command);
command.args(args).current_dir(root);
if !config.extras.include_system_env {
command.env_clear();
} else {
command.envs(ide_core::process::shell_env());
}
command.envs(crate::resolved_env(root, config)?);
let mut command = tokio::process::Command::from(command);
command.kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??;
if !output.status.success() {
return Err(IdeError::invalid(format!(
".NET output resolution failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)));
}
let target = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if target.is_empty() || !Path::new(&target).is_file() {
return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging"));
}
Ok(target)
}
-10
View File
@@ -2,7 +2,6 @@
mod config;
mod detect;
pub mod dotnet;
pub mod envfile;
pub mod go;
pub mod gradle_jvm;
@@ -202,15 +201,6 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
detect::detect_excluding(root, excluded_poms)
}
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
pub fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
detect::detect_with_profiles(root, excluded_poms, profiles)
}
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
config::load(root)
-69
View File
@@ -652,72 +652,3 @@ fn go_default_build_output_is_ignored_by_version_control() {
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
}
#[test]
fn cmake_profiles_control_commands_names_and_environment() {
use ide_core::settings::CMakeProfile;
let dir = project(&[("CMakeLists.txt", "project(app)")]);
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
generator: generator.into(),
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
build_dir: "output dir".into(),
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
env: [("MY_ENV".into(), "yes".into())].into(),
};
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
let configure = find(&configs, "cmake:profile:0:configure");
let mut expected = vec!["-S", ".", "-B", "output dir"];
if !generator.is_empty() {
expected.extend(["-G", generator]);
}
expected.extend([
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DFEATURE=ON",
"-DMESSAGE=two words",
"-DQUOTE=\"hello world\"",
"",
]);
assert_eq!(configure.args, expected);
assert_eq!(configure.name, "CMake Configure (output dir)");
let build = find(&configs, "cmake:profile:0:build");
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
assert_eq!(build.extras.before_launch[0].target, configure.id);
assert!(configs.iter().all(|c| c.env == profile.env));
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
}
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
}
#[test]
fn cmake_profile_build_configures_only_without_a_cache() {
use ide_core::settings::CMakeProfile;
let root = project(&[("CMakeLists.txt", "project(app)")]);
for dir in ["", "custom output"] {
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
for cached in [false, true] {
if cached {
fs::create_dir_all(&build_dir).unwrap();
// Generator changes are handled by an explicit Configure, not every Build.
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
}
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
let build = find(&configs, "cmake:profile:0:build");
if cached {
assert!(build.extras.before_launch.is_empty());
} else {
assert_eq!(build.extras.before_launch.len(), 1);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
}
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
}
}
}
-138
View File
@@ -1,138 +0,0 @@
use ide_run::{
detect,
dotnet::{parse_profiles, parse_project, resolve, solution_projects},
};
use std::fs;
use std::path::PathBuf;
const CONSOLE: &str = r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net10.0; net9.0</TargetFrameworks></PropertyGroup></Project>"#;
const PROFILES: &str = r#"{
// Only Project profiles can be run with dotnet.
"profiles": {
"http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}},
"https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}},
"IIS Express": {"commandName":"IISExpress"},
"external": {"commandName":"Executable"},
"broken": {"commandName":"Project", "environmentVariables":7}
}
}"#;
fn write(root: &std::path::Path, file: &str, src: &str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, src).unwrap();
}
#[test]
fn project_properties_and_sdk_defaults() {
let p = parse_project(CONSOLE).unwrap();
assert!(p.executable && !p.test);
assert_eq!(p.frameworks, ["net10.0", "net9.0"]);
for (sdk, output, executable) in [
("Microsoft.NET.Sdk", "", false),
("Microsoft.NET.Sdk", "<OutputType>WinExe</OutputType>", true),
("Microsoft.NET.Sdk.Web", "", true),
("Microsoft.NET.Sdk.Web", "<OutputType>Library</OutputType>", false),
] {
let xml = format!(
r#"<Project Sdk="{sdk}"><PropertyGroup>{output}<TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"#
);
let p = parse_project(&xml).unwrap();
assert_eq!(p.executable, executable, "{xml}");
assert_eq!(p.frameworks, ["net10.0"]);
}
let p = parse_project(r#"<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><Sdk Name="Microsoft.NET.Sdk.Web"/><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><PropertyGroup Condition="'$(Configuration)'=='Release'"><OutputType>Library</OutputType></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0" /></ItemGroup></Project>"#).unwrap();
assert!(p.executable && p.test);
assert!(parse_project("<Project><PropertyGroup>").is_none());
assert!(parse_project("garbage").is_none());
assert!(
parse_project(
r#"<Project><ItemGroup><PackageReference Update="microsoft.net.test.sdk" /></ItemGroup></Project>"#
)
.unwrap()
.test
);
assert!(
!parse_project(r#"<Project><!-- <PackageReference Include="Microsoft.NET.Test.Sdk"/> --></Project>"#)
.unwrap()
.test
);
}
#[test]
fn profiles_filter_and_preserve_launch_properties() {
let p = parse_profiles(PROFILES);
assert_eq!(p.keys().map(String::as_str).collect::<Vec<_>>(), ["http", "https"]);
assert_eq!(p["http"].application_url, "http://localhost:5165");
assert_eq!(p["http"].environment_variables["DEMO"], "profile");
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
assert_eq!(p["http"].working_directory, "work");
assert!(parse_profiles("invalid").is_empty());
}
#[test]
fn solution_paths_support_windows_separators_folders_and_entities() {
let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\"";
assert_eq!(
solution_projects(sln, false),
[PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")]
);
let slnx = r#"<Solution><Folder Name="/src/"><Project Path="src\A&amp;B\App.csproj" /></Folder><Project Path="other.vbproj" /></Solution>"#;
assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]);
assert!(solution_projects("<Solution><Project", true).is_empty());
}
#[test]
fn detection_deduplicates_projects_and_generates_commands() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
write(root, "src/App/App.csproj", CONSOLE);
write(root, "src/App/Properties/launchSettings.json", PROFILES);
write(
root,
"demo.slnx",
r#"<Solution><Project Path="src/App/App.csproj"/><Project Path="../external/Missing.csproj"/></Solution>"#,
);
write(
root,
"src/Test/Test.csproj",
r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk"/></ItemGroup></Project>"#,
);
write(root, "bin/Ignored.csproj", CONSOLE);
write(root, "obj/Ignored.csproj", CONSOLE);
write(root, "node_modules/Ignored.csproj", CONSOLE);
write(root, "Lib.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\" />");
let configs = detect(root);
assert_eq!(configs.len(), 4);
let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap();
assert_eq!(
app.args,
["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"]
);
assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg"));
assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild");
let test = configs.iter().find(|c| c.is_test).unwrap();
assert_eq!(test.kind, "dotnet-test");
assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]);
assert!(test.debug_adapter.is_none());
let mut edited = app.clone();
edited.env.insert("DEMO".into(), "override".into());
let resolved = resolve(root, edited.clone()).unwrap();
assert_eq!(resolved.env["DEMO"], "override");
assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165");
assert!(resolved.cwd.unwrap().ends_with("src/App/work"));
edited.extras.params.insert("launchProfile".into(), "https".into());
let resolved = resolve(root, edited).unwrap();
assert!(!resolved.env.contains_key("ASPNETCORE_URLS"));
assert!(resolved.cwd.unwrap().ends_with("src/App"));
}
#[test]
fn solution_references_can_reach_projects_outside_the_open_directory() {
let temp = tempfile::tempdir().unwrap();
write(temp.path(), "external/App.csproj", CONSOLE);
write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\"");
let configs = detect(&temp.path().join("open"));
assert_eq!(configs.len(), 1);
assert!(configs[0].args[2].ends_with("external/App.csproj"));
}
-3
View File
@@ -88,9 +88,6 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result<Option<crate::java::Jdk>, String> {
match jdk_setting(root, &s.languages.java.jdtls_jdk) {
None => Ok(None),
Some(Ok(jdk)) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA)))
}
Some(Ok(jdk)) => Ok(Some(jdk)),
Some(Err(p)) => Err(format!(
"The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).",
+31 -207
View File
@@ -16,13 +16,6 @@ pub struct Jdk {
pub home: PathBuf,
}
pub(crate) fn server_jdk_incomplete_message(jdk: &Jdk, min_java: u32) -> String {
format!(
"The language server JDK ({}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {min_java} or newer. Download JDK {min_java}, choose a full JDK in {SERVER_JDK_SETTING}, or set it to Auto.",
jdk.home.display()
)
}
/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file.
pub fn parse_release(release: &str) -> Option<u32> {
let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?;
@@ -206,7 +199,7 @@ pub const JDTLS_MIN_JAVA: u32 = 21;
/// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins).
pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
jdks.iter().find(|j| j.major >= min_java && ide_core::java_runtime::supports_java_bundles(&j.home))
jdks.iter().find(|j| j.major >= min_java)
}
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
@@ -320,36 +313,8 @@ fn text_key(text: &str) -> String {
format!("{h:016x}")
}
/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it
/// carries: a value of theirs wins, and a second call adds nothing.
fn set_jvm_property(args: &mut Vec<String>, arg: &str) {
let key = arg.split('=').next().unwrap_or(arg);
let taken = args.iter().enumerate().any(|(i, a)| {
let value =
a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str()));
value.is_some_and(|a| a.split('=').next() == Some(key))
});
if !taken {
args.push(format!("--jvm-arg={arg}"));
}
}
fn add_jdtls_proxy_args(args: &mut Vec<String>, proxy: &ide_core::proxy_settings::ProxySettings) {
for arg in proxy.jvm_args() {
set_jvm_property(args, &arg);
}
}
const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot";
/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools
/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is
/// not a valid ZIP file", the project using it gets a build path error, and every project
/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven
/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told
/// to behave the same.
const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true";
/// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data
/// directory, one per server root, and no Eclipse metadata is written into
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
@@ -402,14 +367,6 @@ pub fn configure_jdtls(
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
}
crate::jdtls::add_default_vm_args(&mut cfg.args);
// jdtls resolves Maven dependencies through its own HTTP client, and a JVM reads none
// of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system
// properties. One the user set in their own JVM arguments wins.
add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy());
// ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25
// refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21
// accepts the same file.
set_jvm_property(&mut cfg.args, ZIP64_LENIENCY);
let managed = language_server_runtimes_dir(data_dir);
let managed = dunce::canonicalize(&managed).unwrap_or(managed);
let project_jdks: Vec<_> = project_jdk
@@ -428,9 +385,6 @@ pub fn configure_jdtls(
if !cfg.env.contains_key("JAVA_HOME") {
let runtime = match server_jdk {
Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)),
Some(jdk) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(server_jdk_incomplete_message(jdk, min_java))
}
Some(jdk) => Ok(jdk),
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
};
@@ -440,10 +394,6 @@ pub fn configure_jdtls(
}
Err(e) => cfg.launch_error = Some(e),
}
} else if let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
&& !ide_core::java_runtime::supports_java_bundles(&jdk.home)
{
cfg.launch_error = Some(server_jdk_incomplete_message(&jdk, min_java));
}
let mut runtimes = runtimes(&project_jdks);
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
@@ -587,19 +537,6 @@ fn parse_java_version(v: &str) -> Option<u32> {
mod tests {
use super::*;
fn test_jdk(root: &Path, major: u32, name: &str) -> Jdk {
let home = root.join(name.trim_start_matches('/'));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(
home.join("release"),
format!("JAVA_VERSION=\"{major}\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n"),
)
.unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
Jdk { major, home }
}
#[test]
fn unmanaged_source_models_have_separate_jdtls_workspaces() {
let data = tempfile::tempdir().unwrap();
@@ -626,13 +563,8 @@ mod tests {
let project_root = data.path().join("tools/jdks");
let project_home = project_root.join("temurin-21");
std::fs::create_dir_all(project_home.join("bin")).unwrap();
std::fs::write(
project_home.join("release"),
"JAVA_VERSION=\"21.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\n").unwrap();
std::fs::write(java_executable(&project_home), "").unwrap();
std::fs::write(project_home.join("bin").join(crate::exe("javac")), "").unwrap();
let project = jdk_at(&project_home).unwrap();
let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::<Vec<_>>();
let installed = discover();
@@ -640,13 +572,8 @@ mod tests {
assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none());
let home = language_server_runtimes_dir(data.path()).join("temurin-25");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
let candidates = runtime_candidates(&installed, data.path());
assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25);
assert_eq!(jdtls_runtime(&candidates, 21), Some(&project));
@@ -665,47 +592,11 @@ mod tests {
);
}
#[test]
fn jdtls_rejects_jre_for_auto_explicit_and_managed_runtimes() {
let tmp = tempfile::tempdir().unwrap();
let jre = test_jdk(tmp.path(), 25, "jre");
std::fs::write(jre.home.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base java.compiler\"\n").unwrap();
let jdk = test_jdk(tmp.path(), 25, "jdk");
let installed = [jre.clone(), jdk.clone()];
assert_eq!(jdtls_runtime(&installed, 25), Some(&jdk));
assert!(jdtls_runtime(std::slice::from_ref(&jre), 25).is_none());
let managed = language_server_runtimes_dir(tmp.path()).join("temurin-25");
std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
std::fs::rename(&jre.home, &managed).unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_none());
let jre = jdk_at(&managed).unwrap();
for env_override in [false, true] {
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
if env_override {
cfg.env.insert("JAVA_HOME".into(), jre.home.to_string_lossy().into_owned());
}
configure_jdtls(&mut cfg, tmp.path(), tmp.path(), &installed[1..], None, Some(&jre), 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(
err.contains("full JDK with jdk.compiler, jdk.jdi and jdk.unsupported")
&& err.contains("Download JDK 25")
&& err.contains(SERVER_JDK_SETTING),
"{err}"
);
}
std::fs::write(
managed.join("release"),
"JAVA_VERSION=\"25\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_some());
}
#[test]
fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() {
let fixtures = tempfile::tempdir().unwrap();
let installed = [test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b")];
let server = test_jdk(fixtures.path(), 25, "/custom-server-runtime");
let installed =
[Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }];
let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") };
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None);
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
@@ -715,7 +606,7 @@ mod tests {
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
}])
);
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime");
}
#[test]
@@ -728,16 +619,15 @@ mod tests {
#[test]
fn jdtls_workspace_and_runtimes() {
let fixtures = tempfile::tempdir().unwrap();
if cfg!(windows) {
return; // Unix paths and shells
}
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
let jdks = [
test_jdk(fixtures.path(), 25, "/jdk25"),
test_jdk(fixtures.path(), 21, "/jdk21a"),
test_jdk(fixtures.path(), 21, "/jdk21b"),
test_jdk(fixtures.path(), 8, "/jdk8"),
Jdk { major: 25, home: PathBuf::from("/jdk25") },
Jdk { major: 21, home: PathBuf::from("/jdk21a") },
Jdk { major: 21, home: PathBuf::from("/jdk21b") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(
@@ -757,22 +647,13 @@ mod tests {
assert_eq!(cfg.args[n], "-data");
assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]);
assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
assert!(
cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"),
"jars JDK 25 rejects must still load: {:?}",
cfg.args
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25", "default": true }));
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
assert_eq!(
cfg.env["JAVA_HOME"],
fixtures.path().join("jdk25").to_string_lossy(),
"first JDK new enough to run jdtls"
);
assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls");
assert_eq!(cfg.launch_error, None);
// Deterministic (reconfigure compares configs) and idempotent.
@@ -791,7 +672,7 @@ mod tests {
assert_eq!(cfg, before);
// The project JDK is the default runtime, and runs the server when new enough.
let project = test_jdk(fixtures.path(), 21, "/jdk21b");
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
configure_jdtls(
&mut cfg,
@@ -806,8 +687,8 @@ mod tests {
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(
@@ -824,54 +705,6 @@ mod tests {
assert!(other.args.is_empty() && other.initialization_options.is_none());
}
#[test]
fn jdtls_proxy_properties_respect_exact_user_overrides() {
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
let proxy =
ProxySettings { mode: ProxyMode::Manual, host: "ide.proxy".into(), port: 3128, ..Default::default() };
for (user_arg, overridden) in [
("--jvm-arg=-Dhttp.proxyHost=user.proxy", true),
("--jvm-arg=-Dhttp.proxyHost", true),
("--jvm-arg=-Dhttp.proxyHostSuffix=unrelated", false),
("--jvm-arg=-Dother=-Dhttp.proxyHost", false),
("/workspace/-Dhttp.proxyHost", false),
] {
let mut forms = vec![vec![user_arg.to_owned()]];
if let Some(value) = user_arg.strip_prefix("--jvm-arg=") {
forms.push(vec!["--jvm-arg".into(), value.into()]);
}
for mut args in forms {
let user_args = args.clone();
add_jdtls_proxy_args(&mut args, &proxy);
assert_eq!(args[..user_args.len()], user_args);
assert_eq!(args.contains(&"--jvm-arg=-Dhttp.proxyHost=ide.proxy".into()), !overridden, "{user_arg}");
assert!(args.contains(&"--jvm-arg=-Dhttps.proxyHost=ide.proxy".into()));
let before = args.clone();
add_jdtls_proxy_args(&mut args, &proxy);
assert_eq!(args, before, "idempotent: {user_arg}");
}
}
}
#[test]
fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() {
// aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the
// JVM that runs jdtls that is a build path error in every project using it.
let mut args = Vec::new();
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]);
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args.len(), 1, "added once");
for user in [
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(),
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(),
] {
let mut own = vec![user.clone()];
set_jvm_property(&mut own, ZIP64_LENIENCY);
assert_eq!(own, [user], "the user decides");
}
}
#[test]
fn jdtls_lombok_agent() {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
@@ -914,9 +747,8 @@ mod tests {
#[test]
fn jdtls_needs_a_new_enough_jdk_to_run() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let old = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8")];
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert!(!cfg.env.contains_key("JAVA_HOME"));
@@ -935,7 +767,7 @@ mod tests {
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
let mixed = [old[0].clone(), test_jdk(fixtures.path(), 21, "/jdk21")];
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21);
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
@@ -948,12 +780,11 @@ mod tests {
#[test]
fn issue_49_jdtls_needs_25_with_8_17_21_installed() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let installed = [
test_jdk(fixtures.path(), 21, "/jdk21"),
test_jdk(fixtures.path(), 17, "/jdk17"),
test_jdk(fixtures.path(), 8, "/jdk8"),
Jdk { major: 21, home: PathBuf::from("/jdk21") },
Jdk { major: 17, home: PathBuf::from("/jdk17") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
@@ -962,11 +793,12 @@ mod tests {
assert!(err.contains("Download JDK 25"), "{err}");
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
let downloaded =
[installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat();
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
assert_eq!(cfg.launch_error, None);
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy());
}
#[test]
@@ -1003,9 +835,8 @@ mod tests {
#[test]
fn jdtls_runs_on_the_chosen_server_jdk() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let jdk = |major: u32, home: &str| test_jdk(fixtures.path(), major, home);
let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) };
let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")];
let project = jdk(17, "/jdk17");
let run = |server: Option<&Jdk>, min_java: u32| {
@@ -1026,25 +857,18 @@ mod tests {
// Project on JDK 17, server on the chosen 21: each keeps its own JDK.
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
assert_eq!(
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None));
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(
rt[0],
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
"still compiles on 17"
);
assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17");
// Not set: the first installed JDK new enough, as before.
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25");
// A chosen JDK outside the scanned locations.
let own = jdk(25, "/opt/own-jdk");
let cfg = run(Some(&own), 25);
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk");
// Too old for this jdtls: the user's choice is not overridden, it is explained.
let cfg = run(Some(&jdk(21, "/jdk21")), 25);
+1 -19
View File
@@ -249,10 +249,6 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
}),
};
let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return };
if !ide_core::java_runtime::supports_java_bundles(&jdk.home) {
cfg.launch_error = Some(crate::java::server_jdk_incomplete_message(&jdk, crate::java::jdtls_min_java(cfg)));
return;
}
cfg.args = java_args(&install, jdk.major, &cfg.args);
cfg.command = java.to_string_lossy().into_owned();
}
@@ -469,12 +465,7 @@ mod tests {
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let jdk = dir.path().join("jdk-25");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(
jdk.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(jdk.join("bin/javac"), "").unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
@@ -495,15 +486,6 @@ mod tests {
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
let jre = dir.path().join("jre-25");
std::fs::create_dir_all(jre.join("bin")).unwrap();
std::fs::write(jre.join("bin/java"), "").unwrap();
std::fs::write(jre.join("release"), "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\n").unwrap();
let mut overridden = cfg("jdtls");
overridden.args.push(format!("--java-executable={}", jre.join("bin/java").display()));
launch_directly(&mut overridden);
assert!(overridden.launch_error.unwrap().contains("jdk.jdi"));
// Someone's own wrapper, or no usable JDK: the command is kept.
std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
+1 -12
View File
@@ -166,12 +166,7 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
std::fs::write(
dir.join("release"),
"JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap();
std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
}
#[test]
@@ -312,12 +307,6 @@ fn jdtls_jdk_setting() {
assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None));
s.languages.java.jdtls_jdk = "jdk17/bin".into();
assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17));
std::fs::write(
tmp.path().join("jdk17/release"),
"JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK"));
// A chosen path that is not a JDK is an error, not a silent fallback.
s.languages.java.jdtls_jdk = "missing".into();
assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home"));
-4
View File
@@ -178,10 +178,6 @@ impl TerminalManager {
let Some(pid) = session.info.pid else { return Ok(false) };
Ok(foreground_busy(&session, pid))
}
/// Whether a terminal session is still registered.
pub fn contains(&self, id: TerminalId) -> bool {
self.sessions.lock().contains_key(&id)
}
pub fn list(&self) -> Vec<TerminalInfo> {
let mut out: Vec<TerminalInfo> = self.sessions.lock().values().map(|s| s.info.clone()).collect();
+1 -3
View File
@@ -21,8 +21,7 @@ pub enum ArchiveKind {
impl ArchiveKind {
pub fn from_name(name: &str) -> Self {
let n = name.to_ascii_lowercase();
// JetBrains' macOS .sit distributions are zip archives.
if n.ends_with(".zip") || n.ends_with(".vsix") || n.ends_with(".sit") {
if n.ends_with(".zip") || n.ends_with(".vsix") {
Self::Zip
} else if n.ends_with(".tar.gz") || n.ends_with(".tgz") {
Self::TarGz
@@ -394,7 +393,6 @@ mod tests {
fn kinds() {
assert_eq!(ArchiveKind::from_name("a.vsix"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("a.ZIP"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("kotlin-server-263.7084.0-aarch64.sit"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("a.tar.gz"), ArchiveKind::TarGz);
assert_eq!(ArchiveKind::from_name("a.tgz"), ArchiveKind::TarGz);
assert_eq!(ArchiveKind::from_name("zls.tar.xz"), ArchiveKind::TarXz);
+38 -96
View File
@@ -13,30 +13,20 @@ pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
}
pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
let name = display_name(tool);
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
let bin = json["bin"]
.as_str()
.or_else(|| json["bin"][tool].as_str())
.ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?;
.or_else(|| json["bin"]["prettier"].as_str())
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
let path = package.join(bin);
if !path.is_file() {
return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display())));
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
}
Ok(path)
}
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
tool == "prettier" && config.range
}
impl Tools {
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
@@ -44,26 +34,22 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
if tool != "prettier" {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
let manifest = package.join("package.json");
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
(package_bin(custom, tool)?, true)
(package_bin(custom)?, true)
} else if let Some(custom) = custom {
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
} else if let Some(manifest) = find_up(file, &manifest) {
(package_bin(manifest.parent().unwrap(), tool)?, true)
} else if self.root.join(&manifest).is_file() {
(package_bin(&self.root.join(&package), tool)?, true)
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
(package_bin(manifest.parent().unwrap())?, true)
} else if self.root.join("node_modules/prettier/package.json").is_file() {
(package_bin(&self.root.join("node_modules/prettier"))?, true)
} else {
(self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false)
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
};
if node {
let name = display_name(tool);
let node =
self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?;
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
let mut cmd = self.command(node);
cmd.arg(path);
Ok(cmd)
@@ -73,40 +59,34 @@ impl Tools {
}
}
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
args.extend(
[
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.map(OsString::from),
);
}
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
let mut args: Vec<OsString> = [
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.into_iter()
.map(Into::into)
.collect();
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if supports_range(tool, config)
if config.range
&& let Some((start, end)) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
@@ -120,8 +100,8 @@ pub fn arguments(
args
}
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
@@ -138,7 +118,7 @@ mod tests {
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
let failure = run("prettier", custom, "text", &Default::default()).unwrap_err();
let failure = run(custom, "text", &Default::default()).unwrap_err();
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -147,7 +127,7 @@ mod tests {
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
let mut command = Command::new("sh");
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
let failure = run("prettier", command, "", &Default::default()).unwrap_err();
let failure = run(command, "", &Default::default()).unwrap_err();
assert_eq!(failure.message, "error\n".repeat(1000));
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -181,8 +161,7 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -192,41 +171,4 @@ mod tests {
assert!(!args.contains(&"--write".into()));
}
}
#[test]
fn oxfmt_runs_the_nearest_package_without_prettier_flags() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap();
std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap();
std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap();
std::fs::write(root.join(".prettierignore"), "dist").unwrap();
let file = root.join("packages/web/src/app.ts");
let path = std::env::var_os("PATH").unwrap_or_default();
let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current());
let command = tools.formatter_command("oxfmt", &file, None).unwrap();
let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned();
assert_eq!(program, "node");
let script: Vec<_> = command.get_args().collect();
// Compare components: `package_bin` joins the manifest's `bin` entry
// (`bin/oxfmt`) onto a platform path, so separators differ on Windows.
assert_eq!(
Path::new(&script[0]).components().collect::<Vec<_>>(),
root.join("node_modules/oxfmt/bin/oxfmt").components().collect::<Vec<_>>()
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore]
);
assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}");
assert!(!supports_range("oxfmt", &config));
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
}
+49 -57
View File
@@ -108,7 +108,26 @@ fn adoptium_arch(arch: Arch) -> &'static str {
/// The latest Temurin JDK build of Java `feature` for `platform`.
pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String {
let image = "jdk";
assets_url(feature, platform, ImageType::Jdk)
}
#[derive(Clone, Copy)]
enum ImageType {
Jdk,
Jre,
}
impl ImageType {
fn name(self) -> &'static str {
match self {
Self::Jdk => "jdk",
Self::Jre => "jre",
}
}
}
fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String {
let image = image.name();
format!(
"{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse",
adoptium_arch(platform.arch),
@@ -171,12 +190,16 @@ impl AssetVersion {
/// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium
/// `assets/latest` response; `None` when there is no build for the platform.
pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult<Option<(Download, u32)>> {
pick_image(json, platform, ImageType::Jdk)
}
fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult<Option<(Download, u32)>> {
let assets: Vec<Asset> =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" };
let os = adoptium_os(platform.os);
let found = assets.into_iter().find(|a| {
a.binary.image_type.as_deref().is_none_or(|t| t == "jdk")
a.binary.image_type.as_deref().is_none_or(|t| t == image.name())
&& a.binary.heap_size.as_deref().is_none_or(|h| h == "normal")
&& a.binary.os.as_deref().is_none_or(|o| o == os)
&& a.binary.package.name.ends_with(ext)
@@ -226,10 +249,6 @@ pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option<PathBuf> {
subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform))
}
fn usable_java_home(dir: &Path, platform: Platform, runtime: bool) -> Option<PathBuf> {
find_jdk_home(dir, platform).filter(|home| !runtime || ide_core::java_runtime::supports_java_bundles(home))
}
/// The directory to move into place: the archive's single top-level folder
/// holding the JDK, else everything that was unpacked.
fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf {
@@ -264,8 +283,8 @@ impl Tools {
self.download_java(vendor, feature, location, false, cancel, emit)
}
/// Downloads a full JDK for a language server and its bundles (java-debug
/// needs `jdk.jdi`). Kept outside project JDK discovery roots.
/// Downloads a runtime for a language server, preferring a JRE. Only a
/// platform without a JRE build falls back to a full JDK.
pub fn download_java_runtime(
&self,
feature: u32,
@@ -291,8 +310,19 @@ impl Tools {
let http = self.http()?;
let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks"));
emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") });
let mut picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
let mut picked = if runtime {
pick_image(
&http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?,
self.platform,
ImageType::Jre,
)?
} else {
None
};
if picked.is_none() {
picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
}
if picked.is_none() && matches!(self.platform.os, Os::Mac | Os::Windows) && self.platform.arch == Arch::Arm64 {
// Older JDKs may need the OS-provided x64 emulation.
let intel = Platform { os: self.platform.os, arch: Arch::X64 };
@@ -306,7 +336,7 @@ impl Tools {
})?;
let target = location.join(jdk_dir_name(vendor, &dl.version));
let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major };
if let Some(home) = usable_java_home(&target, self.platform, runtime) {
if let Some(home) = find_jdk_home(&target, self.platform) {
emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) });
return Ok(done(home));
}
@@ -321,7 +351,7 @@ impl Tools {
emit(InstallEvent::Line { text: "Extracting…".into() });
let pkg = staging.join("pkg");
archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java", cancel)?;
let home = usable_java_home(&pkg, self.platform, runtime)
let home = find_jdk_home(&pkg, self.platform)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?;
// Last point to stop: from here the JDK replaces what is at `target`.
if cancel.is_cancelled() {
@@ -332,7 +362,7 @@ impl Tools {
}
std::fs::rename(unpacked_root(&pkg, &home), &target)?;
clear_quarantine(&target);
usable_java_home(&target, self.platform, runtime)
find_jdk_home(&target, self.platform)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display())))
})();
let _ = std::fs::remove_dir_all(&staging);
@@ -407,56 +437,18 @@ mod tests {
}
#[test]
fn runtime_assets_require_jdk_images() {
fn runtime_assets_use_jre_without_changing_jdk_downloads() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre"));
assert!(adoptium_assets_url(25, platform).contains("image_type=jdk"));
let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" };
let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", "");
let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\"");
assert!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some());
assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none());
}
}
#[test]
fn stale_runtime_is_replaced_even_at_the_same_version() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(jdk_dir_name(TEMURIN, "25.0.1"));
let home = if platform.os == Os::Mac { target.join("Contents/Home") } else { target.clone() };
fake_jdk(&home, platform);
let javac = home.join("bin").join(if cfg!(windows) { "javac.exe" } else { "javac" });
std::fs::write(&javac, "").unwrap();
for modules in [None, Some("java.base java.compiler jdk.jdix"), Some("java.base jdk.jdi.agent")] {
let line = modules.map(|m| format!("MODULES=\"{m}\"\n")).unwrap_or_default();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"25.0.1\"\n{line}")).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "{modules:?}");
assert_eq!(usable_java_home(&target, platform, false), Some(home.clone()));
}
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::create_dir_all(home.join("jmods")).unwrap();
for module in ["jdk.compiler", "jdk.jdi", "jdk.unsupported"] {
std::fs::write(home.join("jmods").join(format!("{module}.jmod")), "").unwrap();
}
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(
home.join("release"),
"IMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "Temurin JRE release can list absent modules");
std::fs::write(home.join("release"), "MODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n")
.unwrap();
std::fs::remove_file(javac).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "custom runtime without compiler");
std::fs::write(home.join("release"), "MODULES=\"java.base\"\n").unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "release describes the runtime image");
assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("offline", platform, ImageType::Jre).is_err());
}
}
+10 -85
View File
@@ -134,28 +134,6 @@ pub static TOOLS: &[ToolSpec] = &[
needs: NODE,
homepage: "https://prettier.io",
},
ToolSpec {
name: "oxfmt",
display: "oxfmt",
kind: Formatter,
commands: &["oxfmt"],
method: Method::Npm { packages: &["oxfmt"] },
fallback: None,
version: ver("oxfmt"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/formatter",
},
ToolSpec {
name: "oxlint",
display: "oxlint",
kind: LanguageServer,
commands: &["oxlint"],
method: Method::Npm { packages: &["oxlint"] },
fallback: None,
version: ver("oxlint"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/linter",
},
ToolSpec {
name: "rust-analyzer",
display: "rust-analyzer",
@@ -636,25 +614,15 @@ pub static TOOLS: &[ToolSpec] = &[
method: Method::Release {
source: ReleaseSource::GitHub { repo: "Kotlin/kotlin-lsp" },
assets: &[
// Current releases (kotlin-server-*): the CLI distribution is a
// .sit on macOS, .tar.gz on Linux and .win.zip on Windows. x64 ships as the
// un-suffixed archive, aarch64 as `-aarch64`; the un-suffixed one
// comes first in the release notes, which is what the x64 pattern
// matches first. Without a matching rule the installer used to fall
// through to a loose `kotlin-*.zip` pattern and POSIX picked the
// Windows archive (#723).
rule(Mac, Arm64, "kotlin-server-*-aarch64.sit"),
rule(Mac, X64, "kotlin-server-*.sit"),
rule(Linux, Arm64, "kotlin-server-*-aarch64.tar.gz"),
rule(Linux, X64, "kotlin-server-*.tar.gz"),
rule(Windows, X64, "kotlin-server-*.win.zip"),
rule(Windows, Arm64, "kotlin-server-*-aarch64.win.zip"),
// Old releases named the archives kotlin-lsp-*.
rule(Mac, Arm64, "kotlin-lsp-*-mac-aarch64.zip"),
rule(Mac, X64, "kotlin-lsp-*-mac-x64.zip"),
rule(Linux, X64, "kotlin-lsp-*-linux-x64.zip"),
rule(Linux, Arm64, "kotlin-lsp-*-linux-aarch64.zip"),
rule(Windows, X64, "kotlin-lsp-*-win-x64.zip"),
// Older releases ship one platform-independent archive.
any_arch(Mac, "kotlin-*.zip"),
any_arch(Linux, "kotlin-*.zip"),
any_arch(Windows, "kotlin-*.zip"),
],
locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"],
},
@@ -987,8 +955,12 @@ mod tests {
known.insert((d.adapter.clone(), d.command.clone()));
}
}
// The host selects the formatter of each file (Prettier or oxfmt).
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
for l in &langs {
if let Some(f) = &l.formatter {
known.insert((f.tool.clone(), f.tool.clone()));
}
}
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
assert!(
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
"{} / {} is not referenced by languages/*.toml",
@@ -1016,51 +988,4 @@ mod tests {
}
}
}
/// The v263 kotlin-server-* release notes: the CLI distribution archives plus
/// the VS Code extension and checksums; each platform must pick its native
/// distribution and the aarch64 archive must never be picked on x64.
#[test]
fn kotlin_lsp_picks_the_native_archive_for_each_platform() {
use crate::platform::{Platform, select_asset};
let names = [
"kotlin-server-0.0.27-mac-amd64.vsix",
"kotlin-server-0.0.27-mac-amd64.vsix.sha256",
"kotlin-server-0.0.27-mac-aarch64.vsix",
"kotlin-server-0.0.27-mac-aarch64.vsix.sha256",
"kotlin-server-0.0.27-linux-amd64.vsix",
"kotlin-server-0.0.27-linux-amd64.vsix.sha256",
"kotlin-server-0.0.27-linux-aarch64.vsix",
"kotlin-server-0.0.27-linux-aarch64.vsix.sha256",
"kotlin-server-0.0.27-win-amd64.vsix",
"kotlin-server-0.0.27-win-amd64.vsix.sha256",
"kotlin-server-0.0.27-win-aarch64.vsix",
"kotlin-server-0.0.27-win-aarch64.vsix.sha256",
"kotlin-server-263.7084.0.sit",
"kotlin-server-263.7084.0.sit.sha256",
"kotlin-server-263.7084.0-aarch64.sit",
"kotlin-server-263.7084.0-aarch64.sit.sha256",
"kotlin-server-263.7084.0.tar.gz",
"kotlin-server-263.7084.0.tar.gz.sha256",
"kotlin-server-263.7084.0-aarch64.tar.gz",
"kotlin-server-263.7084.0-aarch64.tar.gz.sha256",
"kotlin-server-263.7084.0.win.zip",
"kotlin-server-263.7084.0.win.zip.sha256",
"kotlin-server-263.7084.0-aarch64.win.zip",
"kotlin-server-263.7084.0-aarch64.win.zip.sha256",
];
let rules = find("kotlin-lsp").unwrap().asset_rules();
let mac_arm = Platform { os: Mac, arch: Arm64 };
assert_eq!(select_asset(&names, rules, mac_arm), Some("kotlin-server-263.7084.0-aarch64.sit"));
let mac_x64 = Platform { os: Mac, arch: X64 };
assert_eq!(select_asset(&names, rules, mac_x64), Some("kotlin-server-263.7084.0.sit"));
let linux_x64 = Platform { os: Linux, arch: X64 };
assert_eq!(select_asset(&names, rules, linux_x64), Some("kotlin-server-263.7084.0.tar.gz"));
let linux_arm = Platform { os: Linux, arch: Arm64 };
assert_eq!(select_asset(&names, rules, linux_arm), Some("kotlin-server-263.7084.0-aarch64.tar.gz"));
let win_x64 = Platform { os: Windows, arch: X64 };
assert_eq!(select_asset(&names, rules, win_x64), Some("kotlin-server-263.7084.0.win.zip"));
let win_arm = Platform { os: Windows, arch: Arm64 };
assert_eq!(select_asset(&names, rules, win_arm), Some("kotlin-server-263.7084.0-aarch64.win.zip"));
}
}
+2 -2
View File
@@ -19,8 +19,8 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
cmd.args(formatter::arguments(&config, path, 4, true, range));
formatter::run(cmd, text, &CancellationToken::new())
}
#[test]
+9 -22
View File
@@ -89,28 +89,15 @@ pub(crate) fn blame(
return Ok(uncommitted_lines(line_count));
}
// Restricted blame reads committed blobs only. Feeding working-tree bytes to
// Git's --contents path would run configured clean filters, so it blames the
// revision plain. Git learned to combine `--contents` with a revision in 2.41;
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
// Git's --contents path would run configured clean filters.
let out = if crate::execution_allowed() {
run(
r.root(),
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
Some(&contents),
)?
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), plain_args, None)?, false)
run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)?
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -119,7 +106,7 @@ pub(crate) fn blame(
line.summary = String::new();
}
}
if !contents_blame {
if !crate::execution_allowed() {
let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default();
for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() {
let start = (hunk.old_start as usize).min(lines.len());
-1
View File
@@ -15,7 +15,6 @@ 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
+9 -47
View File
@@ -23,26 +23,16 @@ pub struct InvalidateCachesOptions {
const MARKER: &str = "invalidate-caches.json";
/// Data-directory folders removed for `options`.
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![local_data_dir.join("index")];
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![data_dir.join("index")];
if options.local_history {
dirs.push(data_dir.join("history"));
}
if options.language_servers {
dirs.push(local_data_dir.join("lsp"));
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(local_data_dir.join("cache"));
}
// Also invalidate pre-migration caches so a later start cannot revive them.
if data_dir != local_data_dir {
dirs.push(data_dir.join("index"));
if options.language_servers {
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
}
dirs.push(data_dir.join("cache"));
}
dirs
}
@@ -67,11 +57,11 @@ pub fn cancel(data_dir: &Path) {
/// Carries out a scheduled invalidation (call at start-up, before projects
/// open). Returns the folders removed.
pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
let Some(options) = pending(data_dir) else { return Vec::new() };
cancel(data_dir);
let mut removed = Vec::new();
for dir in cache_dirs(data_dir, local_data_dir, &options) {
for dir in cache_dirs(data_dir, &options) {
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -85,34 +75,6 @@ pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
mod tests {
use super::*;
#[test]
fn split_roots_invalidate_local_caches_and_roaming_history() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true };
assert_eq!(
cache_dirs(&data, &local, &options),
vec![
local.join("index"),
data.join("history"),
local.join("lsp"),
local.join("cache"),
data.join("index"),
data.join("lsp"),
data.join("cache")
]
);
for path in cache_dirs(&data, &local, &options) {
fs::create_dir_all(path).unwrap();
}
fs::create_dir_all(local.join("tools")).unwrap();
schedule(&data, &options).unwrap();
assert_eq!(apply_pending(&data, &local).len(), 7);
assert!(local.join("tools").exists());
assert_eq!(pending(&data), None);
}
#[test]
fn scheduled_invalidation_runs_once() {
let tmp = tempfile::tempdir().unwrap();
@@ -120,16 +82,16 @@ mod tests {
for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] {
fs::create_dir_all(data.join(d)).unwrap();
}
assert!(apply_pending(data, data).is_empty());
assert!(apply_pending(data).is_empty());
let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true };
schedule(data, &opts).unwrap();
assert_eq!(pending(data), Some(opts));
let removed = apply_pending(data, data);
let removed = apply_pending(data);
assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]);
assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists());
assert_eq!(pending(data), None);
assert!(apply_pending(data, data).is_empty());
assert!(apply_pending(data).is_empty());
schedule(data, &InvalidateCachesOptions::default()).unwrap();
cancel(data);
@@ -1,102 +0,0 @@
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;
-97
View File
@@ -1,97 +0,0 @@
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"]);
}
@@ -1,150 +0,0 @@
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;
@@ -1,143 +0,0 @@
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());
}
}
@@ -1,41 +0,0 @@
//! 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
View File
@@ -1,7 +1,6 @@
//! 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;
-69
View File
@@ -2,29 +2,6 @@
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
/// Platform adapters supply the system's tab preference and active project state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabbingPreference {
Always,
InFullScreen,
Manually,
}
pub fn should_group_new_project(
preference: TabbingPreference,
fullscreen: bool,
active_project: bool,
light_edit: bool,
) -> bool {
active_project
&& !light_edit
&& match preference {
TabbingPreference::Always => true,
TabbingPreference::InFullScreen => fullscreen,
TabbingPreference::Manually => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowGroup {
pub members: Vec<String>,
@@ -62,13 +39,6 @@ impl WindowGroups {
Ok(())
}
/// Adds a window to the anchor's group without merging unrelated groups.
pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> {
let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]);
members.push(member.to_owned());
self.merge(members, anchor)
}
pub fn select(&mut self, caller: &str, target: &str) -> IdeResult<WindowGroup> {
let group = self
.groups
@@ -124,45 +94,6 @@ mod tests {
values.iter().map(|s| (*s).into()).collect()
}
#[test]
fn new_project_grouping_follows_system_preference_and_excludes_light_edit() {
for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] {
for fullscreen in [false, true] {
for active_project in [false, true] {
for light_edit in [false, true] {
let expected = matches!(
(preference, fullscreen, active_project, light_edit),
(TabbingPreference::Always, _, true, false)
| (TabbingPreference::InFullScreen, true, true, false)
);
assert_eq!(
should_group_new_project(preference, fullscreen, active_project, light_edit),
expected,
"{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}"
);
}
}
}
}
}
#[test]
fn joining_new_windows_preserves_the_active_group_and_other_groups() {
let mut groups = WindowGroups::default();
groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap();
groups.join("a", "b").unwrap();
assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"]));
let before = groups.select("a", "b").unwrap();
assert_eq!(before.selected, "a");
groups.join("b", "c").unwrap();
let before = groups.select("b", "c").unwrap();
assert_eq!(before.selected, "b");
assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"]));
assert_eq!(groups.group("c").unwrap().selected, "c");
assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"]));
assert_eq!(groups.group("other-a").unwrap().selected, "other-b");
}
#[test]
fn selecting_and_detaching_preserve_the_other_sessions() {
let mut groups = WindowGroups::default();
-6
View File
@@ -1,6 +0,0 @@
id = "asciidoc"
name = "AsciiDoc"
extensions = ["adoc", "asciidoc"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "AsciiDoc"
-13
View File
@@ -1,13 +0,0 @@
id = "d"
name = "D"
extensions = ["d", "di"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "D"
[[language-servers]]
name = "serve-d"
command = "serve-d"
root-markers = ["dub.json", "dub.sdl", "dub.selections.json"]
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`"
-8
View File
@@ -27,14 +27,6 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -26,14 +26,6 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -13,11 +13,3 @@ command = "svelteserver"
args = ["--stdio"]
root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"]
install-hint = "npm i -g svelte-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
-8
View File
@@ -37,14 +37,6 @@ root-markers = ["angular.json", "node_modules/@angular/core"]
require-root-marker = true
install-hint = "npm i -g @angular/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -26,14 +26,6 @@ args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -22,11 +22,3 @@ command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+1 -1
View File
@@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
[apps.openidea.markers]
project_opened = 'opened .* in window'
-8
View File
@@ -78,11 +78,3 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
-17
View File
@@ -1,17 +0,0 @@
//! 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
}
-4
View File
@@ -16,7 +16,6 @@ pub mod clipboard;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod docker;
@@ -191,7 +190,6 @@ 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,
@@ -298,7 +296,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_changelists_save,
vcs::vcs_write_patch,
term::term_spawn,
term::term_store_image,
term::term_write,
term::term_resize,
term::term_kill,
@@ -306,7 +303,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_default_shell,
term::term_available_shells,
run::run_configs,
run::run_dotnet_resolve,
run::run_save_configs,
run::run_templates,
run::run_save_templates,
-11
View File
@@ -8,17 +8,6 @@ use ide_run::{OutputChunk, RunConfig, RunInfo};
use tauri::ipc::Channel;
use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub async fn run_dotnet_resolve(
window: Window,
state: State<'_, AppState>,
config: RunConfig,
debug: bool,
) -> IdeResult<RunConfig> {
ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await
}
#[tauri::command]
#[specta::specta]
pub async fn run_application_build(
-11
View File
@@ -24,17 +24,6 @@ pub async fn term_spawn(
)
}
#[tauri::command]
#[specta::specta]
pub async fn term_store_image(
state: State<'_, AppState>,
id: TerminalId,
data: Vec<u8>,
mime: String,
) -> IdeResult<String> {
ide_host::ops::term::store_image(&state.host, id, data, mime).await
}
#[tauri::command]
#[specta::specta]
pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> {
+2 -12
View File
@@ -11,8 +11,6 @@ mod events;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
#[cfg(target_os = "macos")]
mod macos;
mod menu;
mod remote_dev;
@@ -21,8 +19,6 @@ mod theme_frame;
#[cfg(target_os = "macos")]
mod titlebar;
mod window_groups;
#[cfg(any(windows, test))]
mod window_work_area;
mod windows;
use parking_lot::Mutex;
@@ -57,7 +53,7 @@ pub fn run() {
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
if let Some(data) = dirs::data_local_dir() {
if let Some(data) = dirs::data_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
@@ -74,9 +70,6 @@ pub fn run() {
// Single instance first: a second launch focuses this one and opens its path argument.
// Debug builds have a separate identifier per worktree.
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
// Install before window-state restores maximized geometry during window creation.
#[cfg(windows)]
let app = app.plugin(window_work_area::init());
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
#[cfg(target_os = "linux")]
let app = app.plugin(linux_window_state::plugin());
@@ -174,10 +167,7 @@ pub fn run() {
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")]
{
titlebar::forget(window);
macos::forget_fullscreen(window.label());
}
titlebar::forget(window);
window_groups::destroyed(window.app_handle(), window.label());
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) {
let _ = settings.destroy();
-98
View File
@@ -1,98 +0,0 @@
//! macOS window tab preferences, applied to application-drawn project tabs.
use crate::windows::{Launch, WindowsState};
use ide_core::{IdeError, IdeResult};
use ide_workspace::window_groups::{TabbingPreference, should_group_new_project};
use objc2::{MainThreadMarker, rc::Retained, runtime::ProtocolObject};
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use tauri::{AppHandle, Manager, WebviewWindow};
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
fn read(mtm: MainThreadMarker) -> TabbingPreference {
match NSWindow::userTabbingPreference(mtm) {
NSWindowUserTabbingPreference::Always => TabbingPreference::Always,
NSWindowUserTabbingPreference::InFullScreen => TabbingPreference::InFullScreen,
_ => TabbingPreference::Manually,
}
}
if let Some(mtm) = MainThreadMarker::new() {
return Ok(read(mtm));
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
app.run_on_main_thread(move || {
let _ = tx.send(read(MainThreadMarker::new().expect("AppKit main thread")));
})
.map_err(|e| IdeError::internal(e.to_string()))?;
rx.recv().map_err(|e| IdeError::internal(e.to_string()))
}
/// Capture before building the new window, which takes focus itself.
pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Option<WebviewWindow>> {
let windows = app.webview_windows();
let active = windows
.values()
.find(|w| w.is_focused().unwrap_or(false))
.cloned()
.or_else(|| app.state::<WindowsState>().last_focused().and_then(|label| windows.get(&label).cloned()));
let active = active.and_then(|w| {
let label = crate::windows::settings_parent(w.label()).unwrap_or(w.label());
windows.get(label).cloned()
});
let active = active.filter(|w| {
let host = app.state::<crate::state::AppState>();
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
});
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
let light_edit = launch.project.is_none() && launch.remote.is_none();
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
.then_some(active)
.flatten())
}
struct FullscreenObserver(Retained<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for FullscreenObserver {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
}
}
thread_local! {
static FULLSCREEN_TRANSFERS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
}
pub fn forget_fullscreen(label: &str) {
FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label));
}
/// AppKit cannot enter another window's fullscreen Space until the old one has exited.
pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
let source = from.label().to_owned();
let to = to.clone();
from.with_webview(move |webview| unsafe {
let native = &*webview.ns_window().cast::<NSWindow>();
forget_fullscreen(&source);
let label = source.clone();
let block = block2::RcBlock::new(move |_| {
forget_fullscreen(&label);
let app = to.app_handle();
let selected = app.state::<WindowsState>().groups.lock().group(to.label()).map(|g| g.selected.clone());
if let Some(selected) = selected {
// Exiting fullscreen can show the old window again; reapply group visibility.
let _ = crate::window_groups::select(app, &selected, &selected);
if selected == to.label() {
let _ = to.set_fullscreen(true);
}
}
});
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(objc2_app_kit::NSWindowDidExitFullScreenNotification),
Some(native),
None,
&block,
);
FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer)));
native.toggleFullScreen(None);
})
}
+3 -8
View File
@@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::pa
if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Some(p);
}
let bundled = state.local_data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
if bundled.is_file() {
return Some(bundled);
}
@@ -205,7 +205,7 @@ async fn download_server(
arch: &str,
progress: Arc<Progress>,
) -> IdeResult<std::path::PathBuf> {
let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
tokio::task::spawn_blocking(move || {
ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e {
ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"),
@@ -259,12 +259,7 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
(or set OPENIDEA_SERVER_BINARY)",
e.message,
state
.local_data_dir
.join("server")
.join(format!("{os}-{arch}"))
.join("openidea-server")
.display()
state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display()
))
});
}
+1 -8
View File
@@ -64,28 +64,21 @@ impl AppState {
}
let config_dir = app.path().app_config_dir()?;
let data_dir = app.path().app_data_dir()?;
let local_data_dir = app.path().app_local_data_dir()?;
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&data_dir)?;
if let Err(error) = std::fs::create_dir_all(&local_data_dir) {
tracing::warn!(?local_data_dir, %error, "cannot create local data directory");
}
tracing::info!(?data_dir, ?local_data_dir, "application data directories");
// Saved git HTTPS credentials (askpass answers them silently).
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
// File › Invalidate Caches… requested at the last run.
let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir);
let removed = ide_workspace::caches::apply_pending(&data_dir);
if !removed.is_empty() {
tracing::info!("invalidated caches: {removed:?}");
}
ide_host::data_dirs::migrate(&data_dir, &local_data_dir);
let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None });
let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?);
let sink_app = app.clone();
let host = Arc::new(Host::new(ide_host::HostConfig {
config_dir: config_dir.clone(),
data_dir: data_dir.clone(),
local_data_dir,
languages: languages.clone(),
syntax,
sink_for: Box::new(move |window| {
+3 -15
View File
@@ -51,13 +51,8 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
let fullscreen = from.is_fullscreen().map_err(err)?;
if fullscreen {
#[cfg(target_os = "macos")]
crate::macos::transfer_fullscreen(from, to).map_err(err)?;
#[cfg(not(target_os = "macos"))]
{
from.set_fullscreen(false).map_err(err)?;
to.set_fullscreen(true).map_err(err)?;
}
from.set_fullscreen(false).map_err(err)?;
to.set_fullscreen(true).map_err(err)?;
return Ok(());
}
to.set_fullscreen(false).map_err(err)?;
@@ -114,13 +109,6 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
result
}
#[cfg(target_os = "macos")]
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
let state = app.state::<WindowsState>();
state.groups.lock().join(anchor, new_window)?;
select(app, anchor, new_window)
}
pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
let members = windows::project_window_labels(app)
.into_iter()
@@ -247,7 +235,7 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
} else if path.is_dir() {
let window = match windows::find_project_window(app, path) {
Some(w) => w,
None => windows::create_restored_window(app, path.clone())?,
None => windows::create_window(app, Some(path.clone()))?,
};
Some(window.label().to_owned())
} else {
-208
View File
@@ -1,208 +0,0 @@
//! Native maximize bounds for every Windows frameless window, including state restoration.
#[cfg(windows)]
pub use native::init;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Rect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
/// All coordinates are physical pixels in the selected monitor's coordinate space.
/// Auto-hide needs one exposed pixel, but an already reserved edge needs no extra inset.
fn bounds(monitor: Rect, work: Rect, auto_hide: [bool; 4], fullscreen: bool) -> Rect {
if fullscreen {
return monitor;
}
let [left, top, right, bottom] = auto_hide;
Rect {
left: work.left + i32::from(left && work.left == monitor.left),
top: work.top + i32::from(top && work.top == monitor.top),
right: work.right - i32::from(right && work.right == monitor.right),
bottom: work.bottom - i32::from(bottom && work.bottom == monitor.bottom),
}
}
fn maximize_geometry(monitor: Rect, area: Rect) -> (i32, i32, i32, i32) {
(area.left - monitor.left, area.top - monitor.top, area.right - area.left, area.bottom - area.top)
}
#[cfg(windows)]
mod native {
use super::{Rect, bounds, maximize_geometry};
use windows_sys::Win32::{
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
Graphics::Gdi::{
GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow,
},
UI::{
Shell::{
ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETAUTOHIDEBAREX, APPBARDATA, DefSubclassProc,
RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
},
WindowsAndMessaging::{
GWL_STYLE, GetWindowLongPtrW, IsZoomed, MINMAXINFO, NCCALCSIZE_PARAMS, WM_GETMINMAXINFO, WM_NCCALCSIZE,
WM_NCDESTROY, WS_CAPTION,
},
},
};
const SUBCLASS: usize = 0x410;
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("window-work-area")
.on_window_ready(|window| {
if window.is_decorated().unwrap_or(true) {
return;
}
let install = || -> tauri::Result<()> {
let hwnd = window.hwnd()?.0 as usize;
window.run_on_main_thread(move || {
// Subclasses must be installed and removed on the owning GUI thread.
if unsafe { SetWindowSubclass(hwnd as HWND, Some(window_proc), SUBCLASS, 0) } == 0 {
tracing::warn!(
"cannot install window work area handler: {}",
std::io::Error::last_os_error()
);
}
})
};
if let Err(error) = install() {
tracing::warn!("cannot install window work area handler: {error}");
}
})
.build()
}
fn rect(rect: RECT) -> Rect {
Rect { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
}
fn native_rect(rect: Rect) -> RECT {
RECT { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
}
unsafe fn monitor_bounds(monitor: HMONITOR, fullscreen: bool) -> Option<(Rect, Rect)> {
let mut info = MONITORINFO { cbSize: size_of::<MONITORINFO>() as u32, ..Default::default() };
if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 {
return None;
}
let mut auto_hide = [false; 4];
if !fullscreen {
for (index, edge) in [ABE_LEFT, ABE_TOP, ABE_RIGHT, ABE_BOTTOM].into_iter().enumerate() {
let mut data = APPBARDATA {
cbSize: size_of::<APPBARDATA>() as u32,
uEdge: edge,
rc: info.rcMonitor,
..Default::default()
};
auto_hide[index] = unsafe { SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &mut data) } != 0;
}
}
let monitor = rect(info.rcMonitor);
Some((monitor, bounds(monitor, rect(info.rcWork), auto_hide, fullscreen)))
}
unsafe extern "system" fn window_proc(
hwnd: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
_id: usize,
_data: usize,
) -> LRESULT {
// Tao keeps WS_CAPTION on frameless windows and removes it for native fullscreen.
// Read native styles here: a Tauri state query from its window procedure can deadlock.
let fullscreen = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION == 0;
match message {
WM_GETMINMAXINFO if !fullscreen => {
// Keep Tao's min/max tracking constraints, then set the outer maximize bounds.
let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) };
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
if let Some((monitor, area)) = unsafe { monitor_bounds(monitor, false) } {
let (x, y, width, height) = maximize_geometry(monitor, area);
let info = unsafe { &mut *(lparam as *mut MINMAXINFO) };
info.ptMaxPosition.x = x;
info.ptMaxPosition.y = y;
info.ptMaxSize.x = width;
info.ptMaxSize.y = height;
}
result
}
WM_NCCALCSIZE if wparam != 0 && (fullscreen || unsafe { IsZoomed(hwnd) } != 0) => {
let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) };
// Use the proposed rectangle: a move/snap/DPI change can target another monitor.
let monitor = unsafe { MonitorFromRect(&params.rgrc[0], MONITOR_DEFAULTTONEAREST) };
if let Some((_, area)) = unsafe { monitor_bounds(monitor, fullscreen) } {
// Replace Tao's client-only clamp and global auto-hide query. Fullscreen
// must retain the entire display, even when entered from a maximized window.
params.rgrc[0] = native_rect(area);
0
} else {
unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }
}
}
WM_NCDESTROY => {
unsafe { RemoveWindowSubclass(hwnd, Some(window_proc), SUBCLASS) };
unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }
}
_ => unsafe { DefSubclassProc(hwnd, message, wparam, lparam) },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r(left: i32, top: i32, right: i32, bottom: i32) -> Rect {
Rect { left, top, right, bottom }
}
#[test]
fn taskbars_on_every_edge_and_secondary_displays() {
for (monitor, work, expected) in [
// Reported case: maximized window must leave the bottom taskbar visible.
(r(0, 0, 1920, 1080), r(0, 0, 1920, 1040), (0, 0, 1920, 1040)),
(r(0, 0, 1920, 1080), r(48, 0, 1920, 1080), (48, 0, 1872, 1080)),
(r(0, 0, 1920, 1080), r(0, 40, 1920, 1080), (0, 40, 1920, 1040)),
(r(0, 0, 1920, 1080), r(0, 0, 1872, 1080), (0, 0, 1872, 1080)),
// Negative origin, top taskbar, 150% display beside a 100% display.
(r(-2560, -1440, 0, 0), r(-2560, -1380, 0, 0), (0, 60, 2560, 1380)),
// 250% display: physical bounds must not be scaled with the previous monitor's DPI.
(r(1920, 0, 5760, 2160), r(1920, 0, 5760, 2060), (0, 0, 3840, 2060)),
] {
let area = bounds(monitor, work, [false; 4], false);
assert_eq!(area, work);
assert_eq!(maximize_geometry(monitor, area), expected);
}
}
#[test]
fn auto_hide_leaves_a_physical_pixel_only_on_its_own_monitor_edge() {
let monitor = r(-1920, 0, 0, 1080);
for (edges, expected) in [
([true, false, false, false], r(-1919, 0, 0, 1080)),
([false, true, false, false], r(-1920, 1, 0, 1080)),
([false, false, true, false], r(-1920, 0, -1, 1080)),
([false, false, false, true], r(-1920, 0, 0, 1079)),
([false; 4], monitor),
([true; 4], r(-1919, 1, -1, 1079)),
] {
assert_eq!(bounds(monitor, monitor, edges, false), expected);
}
let reserved = r(-1920, 0, 0, 1040);
assert_eq!(bounds(monitor, reserved, [false, false, false, true], false), reserved);
}
#[test]
fn fullscreen_covers_taskbars_including_when_entered_from_maximized() {
let monitor = r(1920, -100, 5760, 2060);
let work = r(1980, -100, 5760, 1960);
assert_eq!(bounds(monitor, work, [true; 4], true), monitor);
assert_eq!(bounds(monitor, work, [false; 4], false), work);
}
}
+8 -42
View File
@@ -357,35 +357,18 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult<WebviewWindow> {
/// Creates a new project window; `project` is opened by its frontend on load.
pub fn create_window(app: &AppHandle, project: Option<PathBuf>) -> IdeResult<WebviewWindow> {
create_window_with(app, project, Vec::new(), false)
create_window_with(app, project, Vec::new())
}
/// [`create_window`] that also opens `files` (without `project`: in LightEdit).
fn create_window_with(
app: &AppHandle,
project: Option<PathBuf>,
files: Vec<PathBuf>,
restoring: bool,
) -> IdeResult<WebviewWindow> {
fn create_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) -> IdeResult<WebviewWindow> {
let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name);
let title = title.map(|n| n.to_string_lossy().into_owned());
let files = files.into_iter().map(Into::into).collect();
create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }, restoring)
create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() })
}
/// Restoring a saved group preserves its membership and selected tab.
pub fn create_restored_window(app: &AppHandle, project: PathBuf) -> IdeResult<WebviewWindow> {
create_window_with(app, Some(project), Vec::new(), true)
}
fn create_window_for(
app: &AppHandle,
title: Option<String>,
launch: Launch,
_restoring: bool,
) -> IdeResult<WebviewWindow> {
#[cfg(target_os = "macos")]
let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? };
fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) -> IdeResult<WebviewWindow> {
let windows = app.webview_windows();
let label = next_label(windows.keys().map(String::as_str));
let mut config = window_config(app)?;
@@ -405,11 +388,6 @@ fn create_window_for(
}
app.state::<WindowsState>().set_launch(&label, launch);
let window = build_project_window(app, config)?;
#[cfg(target_os = "macos")]
if let Some(target) = tab_target {
crate::window_groups::join(app, target.label(), window.label())?;
return Ok(window);
}
focus(&window);
Ok(window)
}
@@ -423,7 +401,7 @@ pub fn spawn_window(app: &AppHandle, project: Option<PathBuf>) {
fn spawn_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = create_window_with(&app, project, files, false) {
if let Err(e) = create_window_with(&app, project, files) {
tracing::warn!("cannot open window: {}", e.message);
}
});
@@ -462,7 +440,7 @@ pub fn open_project_window(
if !new_window {
return Ok(OpenOutcome::Current);
}
let window = create_window_with(app, Some(root), files, false)?;
let window = create_window_with(app, Some(root), files)?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
@@ -472,7 +450,7 @@ pub fn open_project_window(
/// detach an editor tab out of its window.
pub fn open_file_in_new_window(app: &AppHandle, project: Option<PathBuf>, file: PathBuf) -> IdeResult<OpenOutcome> {
tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project);
let window = create_window_with(app, project, vec![file], false)?;
let window = create_window_with(app, project, vec![file])?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
@@ -500,7 +478,7 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win
}
let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned);
let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() };
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() }, false)?;
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() })?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
@@ -863,18 +841,9 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri:
/// scale and its position is given in physical pixels.
#[cfg(not(target_os = "macos"))]
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
// Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state.
// A restored fullscreen window already covers its display and must not be fitted to the work area.
if window.is_fullscreen()? {
return Ok(());
}
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
return Ok(());
};
let maximized = window.is_maximized()?;
if maximized {
window.unmaximize()?;
}
let scale = monitor.scale_factor();
let area = monitor.work_area();
let origin = area.position.to_logical::<f64>(scale);
@@ -903,9 +872,6 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind
)))?;
window.set_size(tauri::LogicalSize::new(width, height))?;
}
if maximized {
window.maximize()?;
}
Ok(())
}
-52
View File
@@ -327,7 +327,6 @@ 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
@@ -551,7 +550,6 @@ export const commands = {
* to start the shell takes tens of milliseconds, a frozen UI if run there.
*/
termSpawn: (options: TerminalOptions, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("term_spawn", { options, onOutput })),
termStoreImage: (id: TerminalId, data: number[], mime: string) => typedError<string, IdeError>(__TAURI_INVOKE("term_store_image", { id, data, mime })),
termWrite: (id: TerminalId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("term_write", { id, data })),
termResize: (id: TerminalId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("term_resize", { id, cols, rows })),
termKill: (id: TerminalId) => typedError<null, IdeError>(__TAURI_INVOKE("term_kill", { id })),
@@ -566,7 +564,6 @@ export const commands = {
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })),
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
/** Configuration templates: per-type defaults applied to new configurations. */
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
@@ -1642,30 +1639,6 @@ export type ByteRange = {
total: number,
};
export type CMakeProfile = {
name?: string,
buildType?: string,
generator?: string,
cmakeArgs?: string[],
/** Empty uses cmake-build-<lowercase build type>. */
buildDir?: string,
buildArgs?: string[],
env?: { [key in string]: string },
};
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize;
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings_Deserialize = {
profiles?: CMakeProfile[] | null,
};
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings_Serialize = {
profiles?: CMakeProfile[] | null,
};
export type CallHierarchy = {
items: HierarchyItem[],
incomplete: ReferencesIncomplete | null,
@@ -2471,17 +2444,6 @@ 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,
@@ -3350,8 +3312,6 @@ export type GitOperation =
{ op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] };
export type GitSettings = {
/** Show blame after the current editor line. */
inlineBlame?: boolean,
/** Path to the git executable; empty = auto-detect. */
executable?: string,
/** Default of Update Project: `merge` | `rebase`. */
@@ -3621,8 +3581,6 @@ cancellable: boolean } |
{ type: "indexState"; indexing: boolean; files: number; symbols: number } |
/** Git status / branch information should be refreshed. */
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } |
/** A running server lacks a capability; the UI offers the managed tool as a replacement. */
{ type: "languageServerWarning"; name: string; message: string; installTool: string } |
/**
* A stopped language server left the project's list (disabled in settings, or a
* fallback that a preferred server took over from again).
@@ -4307,14 +4265,6 @@ 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,
@@ -5685,7 +5635,6 @@ export type Settings_Deserialize = {
deployment?: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
};
export type Settings_Serialize = {
@@ -5741,7 +5690,6 @@ export type Settings_Serialize = {
deployment: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
};
export type Severity = "error" | "warning" | "info" | "hint";
+1 -16
View File
@@ -21,7 +21,6 @@ import {
Annotation,
Compartment,
Facet,
StateEffect,
type EditorState,
type Extension,
type RangeSet,
@@ -32,9 +31,6 @@ import type { LanguageDescription } from "@codemirror/language";
import { create } from "zustand";
import { maybeEditorService } from "@/workbench/contributions";
/** Documents above this size (UTF-16 units) skip per-line decorations. */
export const HEAVY_LIMIT = 8_000_000;
export interface EditorDocContext {
doc: number;
path: string;
@@ -336,25 +332,14 @@ 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 (⌥↵). */
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
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[]>();
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more