diff --git a/Cargo.lock b/Cargo.lock index 4bff7b994..65ac805cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3962,7 +3962,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-jsonrpc", @@ -3980,7 +3980,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ast-grep-core", "ide-core", @@ -3994,7 +3994,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dirs 6.0.0", "dunce", @@ -4007,9 +4007,13 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-clipboard" +version = "0.1.15" + [[package]] name = "ide-core" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dunce", "serde", @@ -4021,7 +4025,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-run", @@ -4035,7 +4039,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4052,7 +4056,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dirs 6.0.0", "futures", @@ -4072,7 +4076,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "libc", @@ -4086,7 +4090,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-vcs", @@ -4100,7 +4104,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "parking_lot", @@ -4118,7 +4122,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.14" +version = "0.1.15" dependencies = [ "blake3", "criterion", @@ -4140,7 +4144,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.14" +version = "0.1.15" dependencies = [ "futures", "ide-core", @@ -4155,7 +4159,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.14" +version = "0.1.15" dependencies = [ "blake3", "criterion", @@ -4207,7 +4211,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.14" +version = "0.1.15" dependencies = [ "futures", "globset", @@ -4230,7 +4234,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-language", @@ -4248,7 +4252,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-run", @@ -4261,7 +4265,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.14" +version = "0.1.15" dependencies = [ "encoding_rs", "ide-core", @@ -4276,17 +4280,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.17+spec-1.1.0", "tracing", + "zip 2.4.2", ] [[package]] name = "ide-sdk" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dunce", "ide-core", "ide-language", "ide-run", "ide-vfs", + "ignore", "serde", "serde_json", "specta", @@ -4298,7 +4304,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.14" +version = "0.1.15" dependencies = [ "criterion", "globset", @@ -4318,11 +4324,12 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "parking_lot", "serde", + "serde_json", "specta", "spellbook", "tracing", @@ -4330,7 +4337,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "ide-language", @@ -4345,7 +4352,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "libc", @@ -4358,7 +4365,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.14" +version = "0.1.15" dependencies = [ "criterion", "ide-core", @@ -4370,7 +4377,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.14" +version = "0.1.15" dependencies = [ "flate2", "hex", @@ -4390,7 +4397,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dunce", "gix", @@ -4407,7 +4414,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.14" +version = "0.1.15" dependencies = [ "chardetng", "dunce", @@ -4428,7 +4435,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.14" +version = "0.1.15" dependencies = [ "ide-core", "open", @@ -4444,7 +4451,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.14" +version = "0.1.15" dependencies = [ "blake3", "dunce", @@ -5869,13 +5876,14 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dirs 6.0.0", "dunce", "ide-agent", "ide-analysis", "ide-build", + "ide-clipboard", "ide-core", "ide-coverage", "ide-dap", diff --git a/Cargo.toml b/Cargo.toml index 350444bcd..7230496cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.14" +version = "0.1.15" edition = "2024" rust-version = "1.96" license = "Apache-2.0" @@ -35,6 +35,7 @@ ide-db = { path = "crates/ide-db" } ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } ide-agent = { path = "crates/ide-agent" } +ide-clipboard = { path = "crates/ide-clipboard" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-clipboard/Cargo.toml b/crates/ide-clipboard/Cargo.toml new file mode 100644 index 000000000..4f20ecc36 --- /dev/null +++ b/crates/ide-clipboard/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ide-clipboard" +description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs new file mode 100644 index 000000000..04dd050c2 --- /dev/null +++ b/crates/ide-clipboard/src/lib.rs @@ -0,0 +1,293 @@ +//! The file list on the OS clipboard: what Explorer copies when the user +//! copies files, so the project view can paste them, and what the project view +//! writes so Explorer can paste the files it copied (IDEA does both). +//! +//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL +//! terminated list of absolute paths, wide when `fWide` is set. The data is set +//! in full rather than rendered on demand, so no window or message loop is +//! needed and the list survives after `CloseClipboard`. +//! +//! Other platforms keep file lists elsewhere (`public.file-url` on macOS, +//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and +//! [`set_files`] fails there. + +use std::path::PathBuf; + +/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`. +const HEADER: usize = 20; + +/// The absolute paths on the clipboard, empty when it holds no file list. +pub fn files() -> Vec { + imp::files() +} + +/// Whether the clipboard holds a file list, without reading it. +pub fn has_files() -> bool { + imp::has_files() +} + +/// Replaces the clipboard with `paths`. False when it is busy or holds nothing +/// to write; the clipboard is a shared resource, so callers treat this as +/// best-effort. +pub fn set_files(paths: &[PathBuf]) -> bool { + if paths.is_empty() { + return false; + } + imp::set_files(paths) +} + +/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code +/// page survive. Empty paths are skipped. +pub fn encode(paths: &[PathBuf]) -> Vec { + let mut units: Vec = Vec::new(); + for path in paths { + let path = path.to_string_lossy(); + if path.is_empty() { + continue; + } + units.extend(path.encode_utf16()); + units.push(0); + } + // The list ends with an empty string: with one path, two NULs in a row. + units.push(0); + let mut out = Vec::with_capacity(HEADER + units.len() * 2); + out.extend_from_slice(&(HEADER as u32).to_le_bytes()); // pFiles: list starts after the header + out.extend_from_slice(&[0u8; 8]); // pt: the drop point, unused + out.extend_from_slice(&0u32.to_le_bytes()); // fNC: not a file manager drop + out.extend_from_slice(&1u32.to_le_bytes()); // fWide: UTF-16 paths + for unit in units { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +/// The paths of a `DROPFILES` block, stopping at the empty string that ends the +/// list. An ANSI list (pre-Unicode writers) is decoded as UTF-8, lossily. +pub fn decode(bytes: &[u8]) -> Vec { + if bytes.len() < HEADER { + return Vec::new(); + } + let offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + let wide = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) != 0; + if offset >= bytes.len() { + return Vec::new(); + } + let body = &bytes[offset..]; + let mut out = Vec::new(); + if wide { + let mut current: Vec = Vec::new(); + for chunk in body.as_chunks::<2>().0 { + let unit = u16::from_le_bytes(*chunk); + if unit != 0 { + current.push(unit); + } else if current.is_empty() { + break; + } else { + out.push(PathBuf::from(String::from_utf16_lossy(¤t))); + current.clear(); + } + } + } else { + for part in body.split(|b| *b == 0) { + if part.is_empty() { + break; + } + out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned())); + } + } + out +} + +#[cfg(windows)] +mod imp { + use super::{HEADER, decode, encode}; + use std::ffi::c_void; + use std::path::PathBuf; + use std::ptr; + use std::time::Duration; + + /// `CF_HDROP`, the clipboard format holding a file list. + const CF_HDROP: u32 = 15; + + /// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here. + type Handle = *mut c_void; + + const GMEM_MOVEABLE: u32 = 0x0002; + /// Another process holds the clipboard only while it copies; retrying beats + /// reporting "no files" for a paste the user just made. + const OPEN_ATTEMPTS: usize = 10; + const OPEN_RETRY: Duration = Duration::from_millis(20); + + #[link(name = "user32")] + unsafe extern "system" { + fn OpenClipboard(owner: Handle) -> i32; + fn CloseClipboard() -> i32; + fn EmptyClipboard() -> i32; + fn GetClipboardData(format: u32) -> Handle; + fn SetClipboardData(format: u32, memory: Handle) -> Handle; + fn IsClipboardFormatAvailable(format: u32) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GlobalAlloc(flags: u32, bytes: usize) -> Handle; + fn GlobalLock(memory: Handle) -> *mut c_void; + fn GlobalUnlock(memory: Handle) -> i32; + fn GlobalFree(memory: Handle) -> Handle; + fn GlobalSize(memory: Handle) -> usize; + } + + /// Opens the clipboard as its owner: the list is written in full, so no + /// owning window is needed. + fn open() -> bool { + for _ in 0..OPEN_ATTEMPTS { + if unsafe { OpenClipboard(ptr::null_mut()) } != 0 { + return true; + } + std::thread::sleep(OPEN_RETRY); + } + false + } + + pub fn has_files() -> bool { + (unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0 + } + + pub fn files() -> Vec { + if !has_files() || !open() { + return Vec::new(); + } + let memory = unsafe { GetClipboardData(CF_HDROP) }; + let paths = if memory.is_null() { + Vec::new() + } else { + // SAFETY: the clipboard keeps the block alive while it is open, and + // `GlobalSize` bounds the bytes read from it. + unsafe { + let data = GlobalLock(memory); + if data.is_null() { + Vec::new() + } else { + let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)); + let paths = decode(bytes); + GlobalUnlock(memory); + paths + } + } + }; + unsafe { CloseClipboard() }; + paths + } + + pub fn set_files(paths: &[PathBuf]) -> bool { + let bytes = encode(paths); + if bytes.len() <= HEADER { + return false; + } + // SAFETY: the block is allocated, filled, handed to the clipboard and + // freed only on the paths where the clipboard did not take ownership. + unsafe { + let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len()); + if memory.is_null() { + return false; + } + let data = GlobalLock(memory); + if data.is_null() { + GlobalFree(memory); + return false; + } + ptr::copy_nonoverlapping(bytes.as_ptr(), data as *mut u8, bytes.len()); + GlobalUnlock(memory); + if !open() { + GlobalFree(memory); + return false; + } + // Replacing the list also drops the text the user copied before. + if EmptyClipboard() == 0 { + CloseClipboard(); + GlobalFree(memory); + return false; + } + let taken = !SetClipboardData(CF_HDROP, memory).is_null(); + CloseClipboard(); + if !taken { + GlobalFree(memory); + } + taken + } + } +} + +#[cfg(not(windows))] +mod imp { + use std::path::PathBuf; + + pub fn has_files() -> bool { + false + } + + pub fn files() -> Vec { + Vec::new() + } + + pub fn set_files(_paths: &[PathBuf]) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths(list: &[&str]) -> Vec { + list.iter().map(PathBuf::from).collect() + } + + #[test] + fn a_written_list_reads_back_unchanged() { + let list = paths(&["C:/work/project/a.txt", "C:/work/project/sub"]); + assert_eq!(decode(&encode(&list)), list); + } + + #[test] + fn the_header_says_where_the_paths_start_and_that_they_are_wide() { + let bytes = encode(&paths(&["C:/a"])); + assert_eq!(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), HEADER as u32); + assert_eq!(u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), 1); + // `C:/a` plus its NUL, then the empty string that ends the list. + assert_eq!(bytes.len(), HEADER + (4 + 1 + 1) * 2); + assert_eq!(&bytes[HEADER + 6..HEADER + 8], b"a\0"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + } + + #[test] + fn names_outside_the_ansi_code_page_survive() { + let list = paths(&["C:/工作/文档/测试.txt", "C:/a/"]); + assert_eq!(decode(&encode(&list)), list); + } + + #[test] + fn an_empty_list_has_no_paths() { + assert_eq!(decode(&encode(&[])), Vec::::new()); + // A lone terminator is enough for the reader to stop. + assert_eq!(decode(&[0u8; HEADER]), Vec::::new()); + } + + #[test] + fn a_truncated_or_foreign_block_reads_as_no_paths() { + assert_eq!(decode(&[]), Vec::::new()); + assert_eq!(decode(&[1, 2, 3]), Vec::::new()); + let mut far = encode(&paths(&["C:/a"])); + far[0] = 0xff; // pFiles past the end + assert_eq!(decode(&far), Vec::::new()); + } + + #[test] + fn an_ansi_list_is_still_read_before_the_terminator() { + let mut bytes = vec![0u8; HEADER]; + bytes[..4].copy_from_slice(&(HEADER as u32).to_le_bytes()); + bytes[16] = 0; // fWide: the list is single-byte + bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0"); + assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"])); + } +} diff --git a/crates/ide-core/src/inspection_settings.rs b/crates/ide-core/src/inspection_settings.rs index 7b28189b8..f4522a2e1 100644 --- a/crates/ide-core/src/inspection_settings.rs +++ b/crates/ide-core/src/inspection_settings.rs @@ -64,6 +64,10 @@ pub struct InspectionOverride { pub enabled: Option, #[specta(optional)] pub severity: Option, + /// The inspection's own options (Typo: `processCode`, `processLiterals`, + /// `processComments`); absent ones keep their defaults. + #[specta(optional)] + pub options: Option>, } /// IDEA's highlighting levels. @@ -101,5 +105,10 @@ mod tests { assert_eq!(s.ide_profiles["strict"].name, "Strict"); assert_eq!(s.ide_profiles["strict"].overrides["lsp:ruff"].severity, Some(InspectionLevel::Error)); assert!(serde_json::from_str::(r#"{"default":{"x":{"severity":"loud"}}}"#).is_err()); + let s: InspectionSettings = + serde_json::from_str(r#"{"default":{"builtin:spelling":{"options":{"processCode":false}}}}"#).unwrap(); + assert!(!s.default["builtin:spelling"].options.as_ref().unwrap()["processCode"]); + let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); } } diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 240562300..8732351d6 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -289,9 +289,18 @@ pub struct EditorSettings { pub color_scheme: String, /// Color Scheme edits: scheme id → attribute id → override. pub color_overrides: BTreeMap>, + /// Color Scheme › Language Defaults › Semantic highlighting: local variables and + /// parameters get a colour derived from their name (IDEA's rainbow identifiers). + pub semantic_highlighting: bool, + /// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth. + pub rainbow_brackets: bool, pub ligatures: bool, /// Fallback font for glyphs the main font lacks; empty = none. pub fallback_font: String, + /// Typography Settings › Main font weight (CSS 100–900), as IDEA. + pub font_weight: u16, + /// Typography Settings › Bold font weight: wherever the editor renders bold. + pub bold_font_weight: u16, pub general: EditorGeneralSettings, pub appearance: EditorAppearanceSettings, pub breadcrumbs: BreadcrumbsSettings, @@ -325,8 +334,12 @@ impl Default for EditorSettings { format_on_save: false, color_scheme: "theme-default".into(), color_overrides: BTreeMap::new(), + semantic_highlighting: false, + rainbow_brackets: false, ligatures: false, fallback_font: String::new(), + font_weight: 400, + bold_font_weight: 700, general: EditorGeneralSettings::default(), appearance: EditorAppearanceSettings::default(), breadcrumbs: BreadcrumbsSettings::default(), @@ -564,6 +577,16 @@ impl Settings { mod tests { use super::*; + #[test] + fn editor_font_weights_default_to_regular_and_bold() { + let json = serde_json::to_value(Settings::default()).unwrap(); + assert_eq!(json["editor"]["fontWeight"], 400); + assert_eq!(json["editor"]["boldFontWeight"], 700); + let s: Settings = serde_json::from_str(r#"{"editor":{"fontWeight":300,"boldFontWeight":600}}"#).unwrap(); + assert_eq!((s.editor.font_weight, s.editor.bold_font_weight), (300, 600)); + assert_eq!(s.editor.font_size, 13.0); + } + #[test] fn keymap_overrides_are_camel_case_and_default_empty() { let s: Settings = diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 1b3ce2d2d..aff17aeaa 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -36,6 +36,8 @@ pub struct AdapterSpec { pub cwd: PathBuf, /// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`). pub env: Vec<(String, String)>, + /// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface. + pub server_host: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -116,6 +118,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { install_hint: config.install_hint.clone(), cwd, env: Vec::new(), + server_host: None, } } @@ -309,7 +312,12 @@ impl AdapterLauncher for ProcessLauncher { Box::pin(async move { let stderr = Arc::new(parking_lot::Mutex::new(Vec::new())); if let Some(port) = reuse_port { - let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?; + // A remote adapter (`dlv --headless` on another machine) may answer slowly. + let (host, timeout) = match spec.server_host.as_deref() { + Some(host) => (host, CONNECT_TIMEOUT), + None => (LOOPBACK, Duration::from_secs(5)), + }; + let stream = connect_with_retries(host, port, None, timeout).await?; let (r, w) = stream.into_split(); return Ok(AdapterIo { reader: Box::new(r), @@ -366,7 +374,7 @@ impl AdapterLauncher for ProcessLauncher { if let Some(out) = child.stdout.take() { pump_lines(out, spec.adapter.clone(), stderr.clone()); } - let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await { + let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await { Ok(s) => s, Err(e) => { let _ = child.start_kill(); @@ -410,14 +418,17 @@ fn pump_lines( }); } +const LOOPBACK: &str = "127.0.0.1"; + async fn connect_with_retries( + host: &str, port: u16, mut child: Option<&mut Child>, timeout: Duration, ) -> IdeResult { let deadline = Instant::now() + timeout; loop { - match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + match tokio::net::TcpStream::connect((host, port)).await { Ok(s) => { let _ = s.set_nodelay(true); return Ok(s); @@ -432,7 +443,7 @@ async fn connect_with_retries( } if Instant::now() >= deadline { return Err(IdeError::unavailable(format!( - "could not connect to debug adapter on port {port}: {e}" + "could not connect to debug adapter at {host}:{port}: {e}" ))); } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/crates/ide-dap/src/jdwp.rs b/crates/ide-dap/src/jdwp.rs new file mode 100644 index 000000000..41deab2c2 --- /dev/null +++ b/crates/ide-dap/src/jdwp.rs @@ -0,0 +1,97 @@ +//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens, +//! the remote JVM (`-agentlib:jdwp=…,server=n,address=:`) connects to +//! it. java-debug can only attach, so the connection is relayed: once the JVM has +//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP +//! stream (handshake included) both ways. + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::ErrorKind; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +/// How long the relay waits for java-debug to attach once the JVM is connected. +const ATTACH_TIMEOUT: Duration = Duration::from_secs(60); + +/// The address to listen on for `host`: the interface it names, or every interface when +/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to). +async fn bind(host: &str, port: u16) -> IdeResult { + let host = host.trim(); + let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') }; + match TcpListener::bind((host, port)).await { + Ok(l) => Ok(l), + Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => { + TcpListener::bind(("0.0.0.0", port)) + .await + .map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}"))) + } + Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))), + } +} + +/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays +/// to it (accepts one connection). Cancelled through `cancel` while waiting. +pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult { + let listener = bind(host, port).await?; + let jvm = tokio::select! { + _ = cancel.cancelled() => return Err(IdeError::cancelled()), + accepted = listener.accept() => accepted.map_err(IdeError::from)?.0, + }; + drop(listener); + let _ = jvm.set_nodelay(true); + let relay = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = relay.local_addr()?.port(); + tokio::spawn(relay_one(relay, jvm)); + Ok(local) +} + +async fn relay_one(relay: TcpListener, mut jvm: TcpStream) { + let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await { + Ok(Ok((s, _))) => s, + _ => return, + }; + drop(relay); + let _ = debugger.set_nodelay(true); + let mut debugger = debugger; + if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await { + tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn relays_the_jdwp_handshake_both_ways() { + let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new())); + // The remote JVM (`server=n`) connects to the IDE. + let mut jvm = loop { + if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await { + break s; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let local = waiting.await.unwrap().unwrap(); + // java-debug attaches to the relay and starts the handshake. + let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap(); + debugger.write_all(b"JDWP-Handshake").await.unwrap(); + let mut buf = [0u8; 14]; + jvm.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + jvm.write_all(b"JDWP-Handshake").await.unwrap(); + debugger.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + } + + #[tokio::test] + async fn waiting_is_cancellable() { + let cancel = CancellationToken::new(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone())); + tokio::time::sleep(Duration::from_millis(50)).await; + cancel.cancel(); + let err = waiting.await.unwrap().unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + } +} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 1d76b48f9..20f8c0cbc 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -12,6 +12,7 @@ pub mod adapter; pub mod attach; mod cargo; pub mod connection; +pub mod jdwp; mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; @@ -45,7 +46,8 @@ pub struct LaunchConfig { /// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo /// and launches the produced executable; `"debugServer": ` connects to an /// adapter already listening on that local port (e.g. java-debug inside jdtls) - /// instead of starting one. + /// instead of starting one, `"debugServer": "host:port"` to one on another machine + /// (`dlv --headless`, `debugpy --listen`). pub extra_json: Option, } @@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() -> }) } +/// Where a running adapter listens: `debugServer` of the launch arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DebugServer { + /// `None` for the loopback interface (an adapter inside a local process such as jdtls). + pub host: Option, + pub port: u16, +} + /// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the -/// launch arguments and returns its port. -pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { +/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` / +/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`). +pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) }; - let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())); - match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) { - Some(p) => Ok(Some(p)), - None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))), + let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}")); + let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0); + if let Some(p) = v.as_u64() { + return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid); } + let s = v.as_str().map(str::trim).ok_or_else(invalid)?; + let (host, port) = match s.rsplit_once(':') { + Some((host, port)) => { + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + (Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port) + } + None => (None, s), + }; + let port = port.trim().parse::().ok().and_then(port_of).ok_or_else(invalid)?; + Ok(Some(DebugServer { host, port })) } /// DAP stack frame → [`StackFrame`] (1-based → 0-based). @@ -558,6 +579,9 @@ impl DebugManager { } let id = DebugSessionId::next(); let debug_server = take_debug_server(&mut arguments)?; + if let Some(server) = &debug_server { + spec.server_host = server.host.clone(); + } if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) { let cargo_args: Vec = cargo .get("args") @@ -599,8 +623,8 @@ impl DebugManager { if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() { arguments["sourceLanguages"] = json!(["rust"]); } - self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server) - .await + let port = debug_server.map(|s| s.port); + self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await } /// `startDebugging` reverse request: a child session on the same adapter. diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 39939630e..fac94e1b9 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() { #[test] fn debug_server_is_taken_from_launch_arguments() { + let local = |port| Some(DebugServer { host: None, port }); + let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port }); let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"}); - assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711)); + assert_eq!(take_debug_server(&mut args).unwrap(), local(4711)); assert!(args.get("debugServer").is_none()); assert_eq!(args["mainClass"], "a.B"); - assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005)); assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None); + // A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`. + assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345)); assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err()); assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err()); } #[test] @@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() { assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. }))); } +/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`, +/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one. +#[tokio::test] +async fn connects_to_remote_debug_server_by_host() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new()))); + let config = LaunchConfig { + name: "Remote".into(), + adapter: "delve".into(), + request: "attach".into(), + program: None, + args: vec![], + cwd: Some(std::env::temp_dir().into()), + env: BTreeMap::new(), + extra_json: Some( + json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]}) + .to_string(), + ), + }; + let id = manager.start(config, owner).await.unwrap(); + let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap(); + assert_eq!(attach["arguments"]["mode"], "remote"); + assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app"); + assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client"); + manager.stop(id).await.unwrap(); +} + fn requests(h: &Harness, command: &str) -> Vec { h.log .lock() diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 95b3ff606..83f32315d 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -40,6 +40,8 @@ pub struct QueryResult { /// More rows exist beyond the limit. pub has_more: bool, pub elapsed_ms: f64, + /// Until the server's first answer; the rest of `elapsed_ms` is fetching. + pub execution_ms: f64, } pub enum Pool { @@ -130,7 +132,9 @@ macro_rules! fetch { let mut affected: Option = None; let mut has_more = false; let mut failure = None; + let mut first: Option = None; while let Some(item) = stream.next().await { + first.get_or_insert_with(Instant::now); match item { Ok(sqlx::Either::Left(done)) => affected = Some(affected.unwrap_or(0) + done.rows_affected()), Ok(sqlx::Either::Right(row)) => { @@ -152,12 +156,13 @@ macro_rules! fetch { drop(stream); match failure { Some(e) => Err(e), - None => Ok((columns, rows, affected, has_more)), + None => Ok((columns, rows, affected, has_more, first)), } }}; } -type Fetched = (Vec, Vec>>, Option, bool); +/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer. +type Fetched = (Vec, Vec>>, Option, bool, Option); /// The server-side id of a connection (to cancel its statement from another one). macro_rules! backend_id { @@ -552,14 +557,16 @@ impl Held { } Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")), }; - let (columns, rows, affected, has_more) = fetched; + let (columns, rows, affected, has_more, first) = fetched; + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; Ok(QueryResult { statement: sql.to_owned(), affected: if columns.is_empty() { Some(affected.unwrap_or(0) as f64) } else { None }, columns, rows, has_more, - elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, + elapsed_ms, + execution_ms: first.map_or(elapsed_ms, |t| t.duration_since(started).as_secs_f64() * 1000.0), }) } } diff --git a/crates/ide-db/src/history.rs b/crates/ide-db/src/history.rs new file mode 100644 index 000000000..21213d3c2 --- /dev/null +++ b/crates/ide-db/src/history.rs @@ -0,0 +1,137 @@ +//! Query history (IDEA's Browse Query History): the statements each data +//! source ran, newest first. One JSON file in the app's data directory for all +//! projects, keyed by project root, then data source id — never committed. + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Statements kept per data source. +pub const MAX_ENTRIES: usize = 500; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbHistoryEntry")] +pub struct HistoryEntry { + pub text: String, + /// Milliseconds since the Unix epoch. + pub time: f64, +} + +/// Project root → data source id → entries, newest first. +type Contents = BTreeMap>>; + +/// Serialises read-modify-write across the projects sharing the file. +static LOCK: Mutex<()> = Mutex::new(()); + +pub struct HistoryStore { + path: PathBuf, + project: String, +} + +impl HistoryStore { + pub fn new(path: PathBuf, root: &Path) -> Self { + Self { path, project: root.to_string_lossy().into_owned() } + } + + pub fn list(&self, source: &str) -> Vec { + let _g = LOCK.lock(); + self.read().remove(&self.project).and_then(|mut p| p.remove(source)).unwrap_or_default() + } + + /// Records `text`; running a statement again moves it to the top. + pub fn add(&self, source: &str, text: &str, time: f64) -> IdeResult<()> { + let text = text.trim(); + if text.is_empty() { + return Ok(()); + } + let _g = LOCK.lock(); + let mut all = self.read(); + let entries = all.entry(self.project.clone()).or_default().entry(source.to_owned()).or_default(); + entries.retain(|e| e.text != text); + entries.insert(0, HistoryEntry { text: text.to_owned(), time }); + entries.truncate(MAX_ENTRIES); + self.write(&all) + } + + /// Removes the entries with these texts (all of them for `None`). + pub fn remove(&self, source: &str, texts: Option<&[String]>) -> IdeResult<()> { + let _g = LOCK.lock(); + let mut all = self.read(); + let Some(project) = all.get_mut(&self.project) else { return Ok(()) }; + match texts { + None => { + project.remove(source); + } + Some(texts) => { + if let Some(entries) = project.get_mut(source) { + entries.retain(|e| !texts.contains(&e.text)); + } + } + } + self.write(&all) + } + + fn read(&self) -> Contents { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!("query history: {}: {e}", self.path.display()); + Contents::new() + }), + Err(_) => Contents::new(), + } + } + + fn write(&self, all: &Contents) -> IdeResult<()> { + let err = |e: std::io::Error| IdeError::unavailable(format!("query history: {e}")); + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir).map_err(err)?; + } + let tmp = self.path.with_extension("json.tmp"); + let bytes = serde_json::to_vec(all).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(&tmp, bytes).map_err(err)?; + std::fs::rename(&tmp, &self.path).map_err(err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_newest_first_without_duplicates_per_project_and_source() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("h.json"); + let a = HistoryStore::new(file.clone(), Path::new("/p/a")); + let b = HistoryStore::new(file, Path::new("/p/b")); + a.add("s1", "select 1", 1.0).unwrap(); + a.add("s1", " select 2 ", 2.0).unwrap(); + a.add("s1", "select 1", 3.0).unwrap(); + a.add("s2", "select 3", 4.0).unwrap(); + b.add("s1", "select 4", 5.0).unwrap(); + let texts = |v: Vec| v.into_iter().map(|e| e.text).collect::>(); + assert_eq!(texts(a.list("s1")), ["select 1", "select 2"]); + assert_eq!(texts(a.list("s2")), ["select 3"]); + assert_eq!(texts(b.list("s1")), ["select 4"]); + a.remove("s1", Some(&["select 2".to_owned()])).unwrap(); + assert_eq!(texts(a.list("s1")), ["select 1"]); + a.remove("s1", None).unwrap(); + assert!(a.list("s1").is_empty()); + assert_eq!(texts(b.list("s1")), ["select 4"]); + } + + #[test] + fn caps_entries() { + let dir = tempfile::tempdir().unwrap(); + let h = HistoryStore::new(dir.path().join("h.json"), Path::new("/p")); + for i in 0..MAX_ENTRIES + 5 { + h.add("s", &format!("select {i}"), i as f64).unwrap(); + } + let list = h.list("s"); + assert_eq!(list.len(), MAX_ENTRIES); + assert_eq!(list[0].text, format!("select {}", MAX_ENTRIES + 4)); + } +} diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index b115e629e..1a7f120aa 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -3,6 +3,8 @@ //! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB. pub mod conn; +pub mod history; +pub mod plan; pub mod secrets; pub mod source; pub mod split; @@ -10,11 +12,14 @@ pub mod sql; pub mod tunnel; pub use conn::{Held, Pool, QueryResult, ResultColumn}; +pub use history::HistoryEntry; +pub use plan::{PlanNode, PlanProperty}; pub use source::{ DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode, }; pub use sql::{CellValue, DbRowEdit, SortKey}; +use history::HistoryStore; use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; use parking_lot::Mutex; use secrets::SecretStore; @@ -31,6 +36,67 @@ use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey}; pub enum DbTableKind { Table, View, + MaterializedView, +} + +impl DbTableKind { + fn from_catalog(kind: Option<&str>) -> Self { + match kind { + Some("view") => Self::View, + Some("materialized view") => Self::MaterializedView, + _ => Self::Table, + } + } +} + +/// Schema objects other than tables and columns (Go to DDL, Drop). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DbObjectKind { + Function, + Procedure, + Trigger, + Sequence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbTriggerInfo")] +pub struct TriggerInfo { + pub name: String, + /// `before`, `after`, `instead of`. + pub timing: String, + /// `insert`, `update or delete`… + pub events: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbRoutineInfo")] +pub struct RoutineInfo { + pub name: String, + /// `Function` or `Procedure`. + pub kind: DbObjectKind, + pub arguments: String, + /// Result type of a function. + pub returns: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbSequenceInfo")] +pub struct SequenceInfo { + pub name: String, + pub data_type: String, +} + +/// A schema's routines and sequences (the tree's `routines` / `sequences` folders). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbSchemaObjects")] +pub struct SchemaObjects { + pub routines: Vec, + pub sequences: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -68,6 +134,7 @@ pub struct TableDetails { pub columns: Vec, pub indexes: Vec, pub foreign_keys: Vec, + pub triggers: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -148,6 +215,7 @@ pub struct DbManager { known_hosts: PathBuf, ssh_config: Option, saved_passwords: SecretStore, + history: Arc, /// Passwords entered this session (not saved, or not yet), by credential key. session_passwords: Mutex>, /// Manual-commit data sources, each with the connection of its open @@ -251,21 +319,24 @@ async fn end_session(session: &Session) { } impl DbManager { - /// `passwords` is the app-wide file of saved passwords ([`SecretStore`]). - pub fn new(root: PathBuf, passwords: PathBuf) -> Self { + /// `passwords` / `history` are the app-wide files of saved passwords + /// ([`SecretStore`]) and query history ([`HistoryStore`]). + pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf) -> Self { let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh"); - Self::with_ssh_files(root, passwords, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) + Self::with_ssh_files(root, passwords, history, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) } /// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests). pub fn with_ssh_files( root: PathBuf, passwords: PathBuf, + history: PathBuf, known_hosts: PathBuf, ssh_config: Option, ) -> Self { Self { saved_passwords: SecretStore::new(passwords, &root), + history: Arc::new(HistoryStore::new(history, &root)), root, pools: Mutex::default(), tunnels: Mutex::default(), @@ -529,14 +600,15 @@ impl DbManager { /// The databases of the server of data source `id` (PostgreSQL), the connected one first. pub async fn databases(&self, id: &str) -> IdeResult { let id = split_target(id).0; - let system = self.source(id)?.introspection.show_system; + let introspection = self.source(id)?.introspection; + let system = introspection.show_system; let pool = self.pool(id).await?; if pool.driver() != DbDriver::Postgres { return Err(IdeError::invalid("only PostgreSQL data sources list databases")); } let first = |rows: Vec>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten()); let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default(); - let rows = pool.rows(sql::databases(system), MAX_META_ROWS).await?; + let rows = pool.rows(sql::databases(introspection.show_templates), MAX_META_ROWS).await?; let mut names: Vec = first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect(); if !names.contains(¤t) { @@ -562,9 +634,7 @@ impl DbManager { .filter_map(|r| { let mut it = r.into_iter(); let name = it.next().flatten()?; - let kind = - if it.next().flatten().as_deref() == Some("view") { DbTableKind::View } else { DbTableKind::Table }; - Some(TableInfo { name, kind }) + Some(TableInfo { name, kind: DbTableKind::from_catalog(it.next().flatten().as_deref()) }) }) .collect()) } @@ -612,7 +682,85 @@ impl DbManager { .collect(); } foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len()); - Ok(TableDetails { columns, indexes, foreign_keys }) + let triggers = pool + .rows(&sql::triggers(driver, schema, table), MAX_META_ROWS) + .await + .unwrap_or_else(|e| { + tracing::warn!("triggers: {}", e.message); + Vec::new() + }) + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + let name = it.next().flatten()?; + let (timing, events) = if driver == DbDriver::Sqlite { + sql::sqlite_trigger_event(&it.next().flatten().unwrap_or_default()) + } else { + (it.next().flatten().unwrap_or_default(), it.next().flatten().unwrap_or_default()) + }; + Some(TriggerInfo { name, timing, events }) + }) + .collect(); + Ok(TableDetails { columns, indexes, foreign_keys, triggers }) + } + + /// The routines and sequences of `schema`. + pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let mut out = SchemaObjects::default(); + if let Some(q) = sql::routines(driver, schema) { + out.routines = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + let name = it.next().flatten()?; + let kind = if it.next().flatten().as_deref() == Some("procedure") { + DbObjectKind::Procedure + } else { + DbObjectKind::Function + }; + let arguments = it.next().flatten().unwrap_or_default(); + let returns = it.next().flatten().filter(|r| !r.is_empty() && kind == DbObjectKind::Function); + Some(RoutineInfo { name, kind, arguments, returns }) + }) + .collect(); + } + if let Some(q) = sql::sequences(driver, schema) { + out.sequences = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + Some(SequenceInfo { + name: it.next().flatten()?, + data_type: it.next().flatten().unwrap_or_default(), + }) + }) + .collect(); + } + Ok(out) + } + + /// The definition of a routine (every overload), trigger or sequence. + pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult { + let pool = self.pool(id).await?; + let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name) + .ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?; + let defs: Vec = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| r.into_iter().nth(column).flatten()) + .map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))) + .collect(); + if defs.is_empty() { + return Err(IdeError::not_found(format!("{schema}.{name}"))); + } + Ok(defs.join("\n")) } /// The DDL of table or view `name`: SQLite's stored statements, MySQL's @@ -708,7 +856,7 @@ impl DbManager { _ => tables.push(CompletionTable { schema, name, - kind: if kind == "view" { DbTableKind::View } else { DbTableKind::Table }, + kind: DbTableKind::from_catalog(Some(&kind)), columns: vec![column], }), } @@ -732,12 +880,65 @@ impl DbManager { Ok(CompletionModel { default_schema, tables, foreign_keys }) } + /// Runs `statement` on `id` (in its open transaction in manual-commit mode), + /// recording it in the data source's query history. pub async fn execute( &self, id: &str, statement: &str, max_rows: usize, cancel: &CancellationToken, + ) -> IdeResult { + let r = self.run(id, statement, max_rows, cancel).await; + let ran = match &r { + Ok(_) => true, + Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied), + }; + if ran { + self.remember_statement(id, statement); + } + r + } + + fn remember_statement(&self, id: &str, statement: &str) { + let (history, source, text) = (self.history.clone(), split_target(id).0.to_owned(), statement.to_owned()); + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0.0, |d| d.as_secs_f64() * 1000.0); + tokio::task::spawn_blocking(move || { + if let Err(e) = history.add(&source, &text, time) { + tracing::warn!("{}", e.message); + } + }); + } + + /// The query history of data source `id`, newest first. + pub async fn history(&self, id: &str) -> IdeResult> { + let (history, source) = (self.history.clone(), split_target(id).0.to_owned()); + tokio::task::spawn_blocking(move || history.list(&source)).await.map_err(|e| IdeError::internal(e.to_string())) + } + + /// Forgets statements of the query history of `id` (all of them for `None`). + pub async fn remove_history(&self, id: &str, texts: Option>) -> IdeResult<()> { + let (history, source) = (self.history.clone(), split_target(id).0.to_owned()); + tokio::task::spawn_blocking(move || history.remove(&source, texts.as_deref())) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + } + + /// The execution plan of `statement` (explained, not run). + pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> { + let driver = self.pool(id).await?.driver(); + let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?; + plan::parse(driver, &result) + } + + async fn run( + &self, + id: &str, + statement: &str, + max_rows: usize, + cancel: &CancellationToken, ) -> IdeResult { let pool = self.pool(id).await?; let session = self.sessions.lock().get(id).cloned(); diff --git a/crates/ide-db/src/plan.rs b/crates/ide-db/src/plan.rs new file mode 100644 index 000000000..6f0022d69 --- /dev/null +++ b/crates/ide-db/src/plan.rs @@ -0,0 +1,268 @@ +//! Execution plans (IDEA's Explain Plan): the `EXPLAIN` statement of each +//! dialect and its output read into one flat tree of operations. + +use crate::DbDriver; +use crate::conn::QueryResult; +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbPlanProperty")] +pub struct PlanProperty { + pub name: String, + pub value: String, +} + +/// One operation of a plan; `parent` indexes the plan's node list (roots: `None`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbPlanNode")] +pub struct PlanNode { + pub parent: Option, + /// `Full Scan`, `Index Scan`, `Hash Join`… + pub operation: String, + /// The object it works on and its key details (`table: o;`). + pub params: String, + pub properties: Vec, +} + +/// The statement that explains `statement` without running it. +pub fn explain_statement(driver: DbDriver, statement: &str) -> String { + let s = statement.trim().trim_end_matches(';'); + match driver { + DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"), + DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"), + DbDriver::Mysql => format!("EXPLAIN {s}"), + } +} + +/// Reads the result of [`explain_statement`]. +pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult> { + match driver { + DbDriver::Sqlite => Ok(sqlite(&result.rows)), + DbDriver::Postgres => { + let text: String = + result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::>().join("\n"); + let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?; + let mut out = Vec::new(); + for root in json.as_array().into_iter().flatten() { + if let Some(plan) = root.get("Plan") { + postgres(plan, None, &mut out); + } + } + Ok(out) + } + DbDriver::Mysql => { + let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + Ok(mysql(&names, &result.rows)) + } + } +} + +fn prop(name: &str, value: impl Into) -> PlanProperty { + PlanProperty { name: name.to_owned(), value: value.into() } +} + +/// SQLite rows: `id, parent, notused, detail` (`SCAN o`, `SEARCH c USING INTEGER PRIMARY KEY (rowid=?)`). +fn sqlite(rows: &[Vec>]) -> Vec { + let mut ids: Vec = Vec::new(); + let mut out = Vec::new(); + for r in rows { + let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default(); + let (id, parent, detail) = (cell(0), cell(1), cell(3)); + let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32); + let (operation, params) = sqlite_operation(&detail); + out.push(PlanNode { parent, operation, params, properties: vec![prop("Detail", detail.clone())] }); + ids.push(id); + } + out +} + +fn sqlite_operation(detail: &str) -> (String, String) { + let words: Vec<&str> = detail.split_whitespace().collect(); + let object = |i: usize| words.get(i).map(|t| format!("table: {t};")).unwrap_or_default(); + let using = || detail.find(" USING ").map(|i| detail[i + 1..].to_owned()).unwrap_or_default(); + match words.first().copied() { + Some("SCAN") if detail.contains(" USING ") => ("Index Scan".into(), format!("{} {}", object(1), using())), + Some("SCAN") => ("Full Scan".into(), object(1)), + Some("SEARCH") => ("Index Scan".into(), format!("{} {}", object(1), using()).trim().to_owned()), + Some("USE") if detail.contains("ORDER BY") => ("Order By".into(), String::new()), + Some("USE") if detail.contains("GROUP BY") => ("Group By".into(), String::new()), + Some("USE") if detail.contains("DISTINCT") => ("Distinct".into(), String::new()), + _ => (detail.to_owned(), String::new()), + } +} + +/// PostgreSQL JSON plan node: `Node Type`, `Relation Name`, nested `Plans`. +fn postgres(node: &Value, parent: Option, out: &mut Vec) { + let text = |k: &str| node.get(k).and_then(Value::as_str).unwrap_or_default(); + let mut operation = text("Node Type").to_owned(); + if let Some(j) = node.get("Join Type").and_then(Value::as_str).filter(|j| *j != "Inner") { + operation = format!("{operation} ({j})"); + } + let mut params = Vec::new(); + if let Some(r) = node.get("Relation Name").and_then(Value::as_str) { + let alias = text("Alias"); + params.push(if alias.is_empty() || alias == r { + format!("table: {r};") + } else { + format!("table: {r} {alias};") + }); + } + if let Some(i) = node.get("Index Name").and_then(Value::as_str) { + params.push(format!("index: {i};")); + } + for k in ["Index Cond", "Hash Cond", "Merge Cond", "Filter", "Join Filter"] { + if let Some(c) = node.get(k).and_then(Value::as_str) { + params.push(format!("{}: {c};", k.to_lowercase())); + } + } + let properties = node + .as_object() + .into_iter() + .flatten() + .filter(|(k, _)| k.as_str() != "Plans") + .map(|(k, v)| { + let value = match v { + Value::String(s) => s.clone(), + Value::Array(a) => a + .iter() + .map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_owned)) + .collect::>() + .join(", "), + other => other.to_string(), + }; + prop(k, value) + }) + .collect(); + let me = out.len() as u32; + out.push(PlanNode { parent, operation, params: params.join(" "), properties }); + for child in node.get("Plans").and_then(Value::as_array).into_iter().flatten() { + postgres(child, Some(me), out); + } +} + +/// MySQL's tabular `EXPLAIN`: one row per table access, flat. +fn mysql(names: &[&str], rows: &[Vec>]) -> Vec { + let col = |r: &[Option], n: &str| { + names.iter().position(|c| c.eq_ignore_ascii_case(n)).and_then(|i| r.get(i).cloned().flatten()) + }; + rows.iter() + .map(|r| { + let access = col(r, "type").unwrap_or_default(); + let operation = match access.as_str() { + "ALL" => "Full Scan".to_owned(), + "index" => "Full Index Scan".to_owned(), + "range" => "Index Range Scan".to_owned(), + "ref" | "eq_ref" | "ref_or_null" => "Index Lookup".to_owned(), + "const" | "system" => "Constant Lookup".to_owned(), + "" => col(r, "Extra").unwrap_or_else(|| "Select".to_owned()), + other => other.to_owned(), + }; + let mut params = Vec::new(); + if let Some(t) = col(r, "table") { + params.push(format!("table: {t};")); + } + if let Some(k) = col(r, "key") { + params.push(format!("key: {k};")); + } + let properties = names.iter().zip(r).filter_map(|(n, v)| v.as_ref().map(|v| prop(n, v.clone()))).collect(); + PlanNode { parent: None, operation, params: params.join(" "), properties } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::ResultColumn; + + fn result(columns: &[&str], rows: Vec>>) -> QueryResult { + QueryResult { + statement: String::new(), + columns: columns.iter().map(|c| ResultColumn { name: (*c).into(), type_name: String::new() }).collect(), + rows: rows.into_iter().map(|r| r.into_iter().map(|v| v.map(str::to_owned)).collect()).collect(), + affected: None, + has_more: false, + elapsed_ms: 0.0, + execution_ms: 0.0, + } + } + + #[test] + fn explains_per_dialect() { + assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1"); + assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1"); + assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1"); + } + + #[test] + fn reads_sqlite_plans_like_idea() { + let r = result( + &["id", "parent", "notused", "detail"], + vec![ + vec![Some("3"), Some("0"), Some("0"), Some("SCAN o")], + vec![Some("5"), Some("0"), Some("0"), Some("SEARCH c USING INTEGER PRIMARY KEY (rowid=?)")], + vec![Some("9"), Some("0"), Some("0"), Some("USE TEMP B-TREE FOR ORDER BY")], + ], + ); + let plan = parse(DbDriver::Sqlite, &r).unwrap(); + let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect(); + assert_eq!( + ops, + [ + ("Full Scan", "table: o;"), + ("Index Scan", "table: c; USING INTEGER PRIMARY KEY (rowid=?)"), + ("Order By", "") + ] + ); + assert!(plan.iter().all(|n| n.parent.is_none())); + } + + #[test] + fn nests_sqlite_children_under_their_parent() { + let r = result( + &["id", "parent", "notused", "detail"], + vec![ + vec![Some("2"), Some("0"), Some("0"), Some("CO-ROUTINE sub")], + vec![Some("4"), Some("2"), Some("0"), Some("SCAN t")], + ], + ); + let plan = parse(DbDriver::Sqlite, &r).unwrap(); + assert_eq!(plan[1].parent, Some(0)); + } + + #[test] + fn reads_postgres_json_trees() { + let json = r#"[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Hash Cond": "(o.customer_id = c.id)", + "Total Cost": 12.5, "Plans": [ + {"Node Type": "Seq Scan", "Relation Name": "orders", "Alias": "o", "Filter": "(status <> 'x')"}, + {"Node Type": "Hash", "Plans": [{"Node Type": "Seq Scan", "Relation Name": "customers", "Alias": "c"}]} + ]}}]"#; + let plan = parse(DbDriver::Postgres, &result(&["QUERY PLAN"], vec![vec![Some(json)]])).unwrap(); + assert_eq!(plan.len(), 4); + assert_eq!(plan[0].operation, "Hash Join"); + assert_eq!(plan[0].params, "hash cond: (o.customer_id = c.id);"); + assert!(plan[0].properties.iter().any(|p| p.name == "Total Cost" && p.value == "12.5")); + assert_eq!((plan[1].parent, plan[1].params.as_str()), (Some(0), "table: orders o; filter: (status <> 'x');")); + assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan")); + } + + #[test] + fn reads_mysql_rows() { + let r = result( + &["id", "select_type", "table", "type", "key", "rows", "Extra"], + vec![ + vec![Some("1"), Some("SIMPLE"), Some("o"), Some("ALL"), None, Some("20"), Some("Using where")], + vec![Some("1"), Some("SIMPLE"), Some("c"), Some("eq_ref"), Some("PRIMARY"), Some("1"), None], + ], + ); + let plan = parse(DbDriver::Mysql, &r).unwrap(); + assert_eq!((plan[0].operation.as_str(), plan[0].params.as_str()), ("Full Scan", "table: o;")); + assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;")); + assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20")); + } +} diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index 1036995bf..a4e56300c 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -77,6 +77,8 @@ pub struct DbOptions { pub keep_alive_secs: Option, /// Disconnect after this long without statements (enforced by the UI, which sees the activity). pub auto_disconnect_secs: Option, + /// PostgreSQL: show only the configured database, its schemas right under the data source. + pub single_database: bool, } /// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`. @@ -87,13 +89,26 @@ pub struct DbIntrospection { pub databases: Vec, pub all_schemas: bool, pub schemas: Vec, - /// `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases. + /// `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…). pub show_system: bool, + /// PostgreSQL template databases (`template0`, `template1`). + pub show_templates: bool, + /// Object filter: the tree lists only tables, views, routines and sequences matching + /// one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all. + pub object_filter: String, } impl Default for DbIntrospection { fn default() -> Self { - Self { all_databases: true, databases: Vec::new(), all_schemas: true, schemas: Vec::new(), show_system: false } + Self { + all_databases: true, + databases: Vec::new(), + all_schemas: true, + schemas: Vec::new(), + show_system: false, + show_templates: false, + object_filter: String::new(), + } } } diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs index 17373158a..0f57823ae 100644 --- a/crates/ide-db/src/sql.rs +++ b/crates/ide-db/src/sql.rs @@ -32,10 +32,9 @@ pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String { } } -/// Databases one can connect to (PostgreSQL). -/// PostgreSQL databases one can connect to; templates only with `system`. -pub fn databases(system: bool) -> &'static str { - if system { +/// PostgreSQL databases one can connect to; template databases only with `templates`. +pub fn databases(templates: bool) -> &'static str { + if templates { "SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname" } else { "SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname" @@ -65,7 +64,7 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { } } -/// Rows: `name, kind` where kind is `table` or `view`. +/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`. pub fn tables(driver: DbDriver, schema: &str) -> String { match driver { DbDriver::Sqlite => format!( @@ -73,7 +72,13 @@ pub fn tables(driver: DbDriver, schema: &str) -> String { AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name", quote_ident(driver, schema) ), - _ => format!( + DbDriver::Postgres => format!( + "SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = {} AND c.relkind IN ('r', 'v', 'm', 'p', 'f') ORDER BY 1", + quote_literal(driver, schema) + ), + DbDriver::Mysql => format!( "SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \ FROM information_schema.tables WHERE table_schema = {} ORDER BY 1", quote_literal(driver, schema) @@ -146,7 +151,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str { WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY m.name, p.cid" } DbDriver::Postgres => { - "SELECT n.nspname, c.relname, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END, a.attname, \ + "SELECT n.nspname, c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END, a.attname, \ format_type(a.atttypid, a.atttypmod), CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, \ CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary \ AND a.attnum = ANY(i.indkey)) THEN 1 ELSE 0 END, \ @@ -375,6 +380,123 @@ pub fn postgres_ddl_indexes(oid: u32) -> String { ) } +/// Rows: `name, timing, events` of the triggers of a table (SQLite: `name, sql`). +pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String { + let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table)); + match driver { + DbDriver::Sqlite => format!( + "SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name", + quote_ident(driver, schema) + ), + DbDriver::Postgres => format!( + "SELECT t.tgname, CASE WHEN t.tgtype & 2 <> 0 THEN 'before' WHEN t.tgtype & 64 <> 0 THEN 'instead of' \ + ELSE 'after' END, concat_ws(' or ', CASE WHEN t.tgtype & 4 <> 0 THEN 'insert' END, \ + CASE WHEN t.tgtype & 16 <> 0 THEN 'update' END, CASE WHEN t.tgtype & 8 <> 0 THEN 'delete' END, \ + CASE WHEN t.tgtype & 32 <> 0 THEN 'truncate' END) \ + FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE NOT t.tgisinternal AND n.nspname = {s} AND c.relname = {t} ORDER BY 1" + ), + DbDriver::Mysql => format!( + "SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \ + WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1" + ), + } +} + +/// `before`, `insert` of SQLite's `CREATE TRIGGER name BEFORE INSERT ON …` (its catalog keeps only the text). +pub fn sqlite_trigger_event(sql: &str) -> (String, String) { + let upper = sql.to_ascii_uppercase(); + let words: Vec<&str> = upper.split_whitespace().collect(); + let timing = if words.contains(&"BEFORE") { + "before" + } else if words.windows(2).any(|w| w == ["INSTEAD", "OF"]) { + "instead of" + } else { + "after" + }; + let event = ["INSERT", "UPDATE", "DELETE"].into_iter().find(|e| words.contains(e)).unwrap_or_default(); + (timing.to_owned(), event.to_ascii_lowercase()) +} + +/// Rows: `name, kind (function/procedure), arguments, result` of a schema's routines (none in SQLite). +pub fn routines(driver: DbDriver, schema: &str) -> Option { + let s = quote_literal(driver, schema); + match driver { + DbDriver::Sqlite => None, + // Functions of extensions are left out, as in IDEA. + DbDriver::Postgres => Some(format!( + "SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \ + pg_get_function_identity_arguments(p.oid), pg_get_function_result(p.oid) \ + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = {s} AND p.prokind IN ('f', 'p') \ + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') ORDER BY 1, 3" + )), + DbDriver::Mysql => Some(format!( + "SELECT r.routine_name, lower(r.routine_type), \ + (SELECT GROUP_CONCAT(CONCAT(p.parameter_name, ' ', p.dtd_identifier) ORDER BY p.ordinal_position \ + SEPARATOR ', ') FROM information_schema.parameters p WHERE p.specific_schema = r.routine_schema \ + AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \ + FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" + )), + } +} + +/// Rows: `name, data type` of a schema's sequences (PostgreSQL). +pub fn sequences(driver: DbDriver, schema: &str) -> Option { + (driver == DbDriver::Postgres).then(|| { + format!( + "SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1", + quote_literal(driver, schema) + ) + }) +} + +/// The statement reading the definition of a routine (a row per overload), trigger +/// or sequence, and the column that holds it. +pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, name: &str) -> Option<(String, usize)> { + use crate::DbObjectKind as K; + let (s, n) = (quote_literal(driver, schema), quote_literal(driver, name)); + let target = qualified(driver, schema, name); + Some(match (driver, kind) { + (DbDriver::Sqlite, K::Trigger) => ( + format!( + "SELECT sql FROM {}.sqlite_master WHERE type = 'trigger' AND name = {n}", + quote_ident(driver, schema) + ), + 0, + ), + (DbDriver::Postgres, K::Function | K::Procedure) => ( + format!( + "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = {s} AND p.proname = {n} ORDER BY pg_get_function_identity_arguments(p.oid)" + ), + 0, + ), + (DbDriver::Postgres, K::Trigger) => ( + format!( + "SELECT pg_get_triggerdef(t.oid, true) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE NOT t.tgisinternal AND n.nspname = {s} AND t.tgname = {n}" + ), + 0, + ), + (DbDriver::Postgres, K::Sequence) => ( + format!( + "SELECT format(E'create sequence %s\\n as %s\\n increment by %s\\n minvalue %s\\n \ + maxvalue %s\\n start with %s%s', {}, data_type, increment_by, min_value, max_value, start_value, \ + CASE WHEN cycle THEN E'\\n cycle' ELSE '' END) \ + FROM pg_sequences WHERE schemaname = {s} AND sequencename = {n}", + quote_literal(driver, &target) + ), + 0, + ), + (DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2), + (DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2), + (DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2), + _ => return None, + }) +} + /// A column of [`postgres_table_ddl`]. pub struct DdlColumn { pub name: String, @@ -464,6 +586,31 @@ mod tests { assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err()); } + #[test] + fn sqlite_trigger_events() { + let ev = sqlite_trigger_event; + assert_eq!( + ev("CREATE TRIGGER t BEFORE UPDATE OF a ON x BEGIN SELECT 1; END"), + ("before".into(), "update".into()) + ); + assert_eq!(ev("create trigger t instead of insert on v begin end"), ("instead of".into(), "insert".into())); + assert_eq!(ev("CREATE TRIGGER t DELETE ON x BEGIN END"), ("after".into(), "delete".into())); + } + + #[test] + fn schema_objects_per_dialect() { + use crate::DbObjectKind as K; + assert!(routines(DbDriver::Sqlite, "main").is_none()); + assert!(sequences(DbDriver::Mysql, "db").is_none()); + assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'")); + assert_eq!( + object_ddl(DbDriver::Mysql, "db", K::Procedure, "p"), + Some(("SHOW CREATE PROCEDURE `db`.`p`".into(), 2)) + ); + assert!(object_ddl(DbDriver::Sqlite, "main", K::Function, "f").is_none()); + assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'")); + } + #[test] fn postgres_ddl() { let cols = [ diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs index 03d12dac7..315ecff54 100644 --- a/crates/ide-db/tests/sqlite.rs +++ b/crates/ide-db/tests/sqlite.rs @@ -7,7 +7,7 @@ async fn setup() -> (tempfile::TempDir, DbManager) { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); std::fs::File::create(root.join("app.db")).unwrap(); - let db = DbManager::new(root.clone(), root.join("passwords.json")); + let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json")); db.save_sources(vec![DataSource { id: "s1".into(), name: "app".into(), @@ -247,6 +247,57 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> { Ok(()) } +#[tokio::test] +async fn explains_queries_without_running_them() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?; + let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect(); + assert_eq!(ops, vec!["Index Scan", "Order By"]); + assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params); + db.explain("s1", "DELETE FROM users", &c).await?; + assert_eq!(count(&db).await, "3"); + Ok(()) +} + +#[tokio::test] +async fn records_executed_statements_in_the_query_history() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + db.execute("s1", "SELECT 1", 1, &c).await?; + assert!(db.execute("s1", "SELECT nope", 1, &c).await.is_err()); + // Recorded off the async threads. + let mut texts = Vec::new(); + for _ in 0..50 { + texts = db.history("s1").await?.into_iter().map(|e| e.text).collect::>(); + if texts.first().map(String::as_str) == Some("SELECT nope") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!(texts[..2], ["SELECT nope", "SELECT 1"]); + db.remove_history("s1", Some(vec!["SELECT 1".into()])).await?; + assert!(!db.history("s1").await?.iter().any(|e| e.text == "SELECT 1")); + db.remove_history("s1", None).await?; + assert!(db.history("s1").await?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn lists_triggers_and_their_ddl() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + db.execute("s1", "CREATE TRIGGER users_touch AFTER UPDATE ON users BEGIN SELECT 1; END", 1, &c).await?; + let d = db.table_details("s1", "main", "users").await?; + let triggers: Vec<_> = d.triggers.iter().map(|t| (t.name.as_str(), t.timing.as_str(), t.events.as_str())).collect(); + assert_eq!(triggers, vec![("users_touch", "after", "update")]); + let ddl = db.object_ddl("s1", "main", ide_db::DbObjectKind::Trigger, "users_touch").await?; + assert!(ddl.starts_with("CREATE TRIGGER users_touch"), "{ddl}"); + // SQLite has neither routines nor sequences. + assert_eq!(db.schema_objects("s1", "main").await?, ide_db::SchemaObjects::default()); + Ok(()) +} + #[tokio::test] async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeResult<()> { let (tmp, db) = setup().await; diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 7dbf5a90a..3e79608d4 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -128,7 +128,8 @@ impl ClientHandler { Value::Array( items .iter() - .map(|item| match item.get("section").and_then(Value::as_str) { + // vtsls asks for the whole configuration as section `""`. + .map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) { Some(section) if section == self.server.name => options.clone(), // Servers that take settings under `initializationOptions.settings` // (jdtls) ask for the same sections later. @@ -193,6 +194,7 @@ impl Handler for ClientHandler { _ => tracing::debug!("[{}] {text}", self.server.name), } } + "tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params), "telemetry/event" | "$/logTrace" | "$/cancelRequest" => {} other => tracing::trace!("[{}] unhandled notification {other}", self.server.name), } diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 03c30b330..9f93618fa 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -74,6 +74,9 @@ pub struct JavaLaunchInfo { pub java_home: Option, /// The `java` launcher to use (filled in by the caller from the installed JDKs). pub java_exec: Option, + /// How a launcher that builds the command line itself must shorten it (java-debug's + /// `shortenCommandLine`: `none` | `jarmanifest` | `argfile`; filled in by the caller). + pub shorten_command_line: Option, /// First compile errors of the workspace when the build had errors. pub errors: Vec, /// All compile errors (`errors` is capped). @@ -273,6 +276,7 @@ impl LspManager { module_paths: cp.module_paths, java_home: java_home.map(Into::into), java_exec: None, + shorten_command_line: None, errors, error_count, hints: Vec::new(), diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index ee171c363..bb46369d4 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -15,6 +15,7 @@ pub mod jdtls; mod launch; mod server; mod text; +mod tsserver_bridge; pub mod types; mod uri; mod virtual_doc; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 4568ca442..43d5ba0b1 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -13,7 +13,8 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Notify, watch}; -const CRASH_WINDOW: Duration = Duration::from_secs(180); +/// An instance that stayed up this long ran fine: earlier crashes no longer count. +const STABLE_UPTIME: Duration = Duration::from_secs(180); const MAX_RESTARTS: usize = 3; const STDERR_LINES: usize = 50; @@ -66,7 +67,10 @@ pub struct Server { state: Mutex, phase: watch::Sender, status: Mutex<(ServerStatus, Option)>, - crashes: Mutex>, + /// Consecutive crashes of short-lived instances. + crashes: Mutex, + /// When the current instance was launched. + started: Mutex>, stderr: Mutex>, pub progress: Mutex>, diagnostics: Mutex>>, @@ -88,7 +92,8 @@ impl Server { state: Mutex::new(State { generation: 0, conn: Conn::Idle }), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), - crashes: Mutex::new(VecDeque::new()), + crashes: Mutex::new(0), + started: Mutex::new(None), stderr: Mutex::new(VecDeque::new()), progress: Mutex::new(HashMap::new()), diagnostics: Mutex::new(HashMap::new()), @@ -127,6 +132,7 @@ impl Server { } st.generation += 1; st.conn = Conn::Starting; + *self.started.lock() = Some(Instant::now()); self.phase.send_replace(Phase::Starting); Some(st.generation) } @@ -237,21 +243,22 @@ impl Server { } } - /// Records a crash; returns the backoff before restarting, or `None` when - /// the restart budget for the window is exhausted. + /// Records a crash; returns the backoff before restarting, or `None` once + /// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`] + /// (a server that crashes on every start, however slowly, is not restarted forever). pub fn record_crash(&self) -> Option { + let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default(); let mut crashes = self.crashes.lock(); - let now = Instant::now(); - crashes.push_back(now); - while crashes.front().is_some_and(|t| now.duration_since(*t) > CRASH_WINDOW) { - crashes.pop_front(); + if uptime >= STABLE_UPTIME { + *crashes = 0; } - let n = crashes.len(); + *crashes += 1; + let n = *crashes; (n <= MAX_RESTARTS).then(|| Duration::from_millis(500) * (1 << (n - 1)) as u32) } pub fn reset_for_restart(&self) { - self.crashes.lock().clear(); + *self.crashes.lock() = 0; let mut st = self.state.lock(); if matches!(st.conn, Conn::Missing | Conn::Failed) { st.conn = Conn::Idle; @@ -300,8 +307,8 @@ impl Server { pub fn stderr_tail(&self, n: usize) -> String { let buf = self.stderr.lock(); - let skip = buf.len().saturating_sub(n); - buf.iter().skip(skip).cloned().collect::>().join("\n") + let lines: Vec<&str> = buf.iter().map(String::as_str).collect(); + crash_excerpt(&lines, n) } pub fn take_progress(&self) -> Vec<(TaskId, String)> { @@ -341,3 +348,97 @@ impl Server { self.diagnostics.lock().drain().map(|(p, _)| p).collect() } } + +/// The last `n` stderr lines, led by the exception headline: Node.js / Python / +/// JVM crashes print the error first and a stack after it, so a plain tail +/// shows frames without saying what went wrong. +pub(crate) fn crash_excerpt(lines: &[&str], n: usize) -> String { + const MAX_LINES: usize = 12; + let start = lines.len().saturating_sub(n); + let excerpt: Vec<&str> = match lines.iter().rposition(|l| is_error_headline(l)) { + Some(i) if i >= start => lines[start..].to_vec(), + Some(i) if lines.len() - i <= MAX_LINES => lines[i..].to_vec(), + Some(i) => [lines[i], "…"].into_iter().chain(lines[start..].iter().copied()).collect(), + None => lines[start..].to_vec(), + }; + excerpt.join("\n") +} + +fn is_error_headline(line: &str) -> bool { + let l = line.trim_start(); + let head = l.split(':').next().unwrap_or_default(); + let named = (head.ends_with("Error") || head.ends_with("Exception")) && !head.contains(' ') && head.len() < l.len(); + named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server() -> Server { + let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap(); + Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")) + } + + #[test] + fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() { + let s = server(); + for _ in 0..MAX_RESTARTS { + // Even crashes spread minutes apart count while each instance dies young. + *s.started.lock() = Some(Instant::now()); + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Some(Instant::now()); + assert_eq!(s.record_crash(), None); + s.reset_for_restart(); + assert!(s.record_crash().is_some()); + } + + #[test] + fn a_stable_run_resets_the_budget() { + let s = server(); + for _ in 0..MAX_RESTARTS { + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Instant::now().checked_sub(STABLE_UPTIME); + assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); + } + + #[test] + fn crash_excerpt_keeps_the_exception_line() { + // Node.js 24 crash of vue-language-server (issue #25), verbatim shape. + let node = [ + "C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40", + " projectInfoPromise = sendTsServerRequest('_vue:' + ts.server.protocol.CommandTypes.ProjectInfo, {", + " ^", + "", + "TypeError: Cannot read properties of undefined (reading 'protocol')", + " at Object.getLanguageService (C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40:86)", + " at Immediate. (C:\\tools\\node_modules\\@volar\\language-server\\lib\\features\\languageFeatures.js:744:62)", + " at process.processImmediate (node:internal/timers:504:21)", + "", + "Node.js v24.12.0", + ]; + let out = crash_excerpt(&node, 5); + assert!(out.starts_with("TypeError: Cannot read properties of undefined (reading 'protocol')"), "{out}"); + assert!(out.ends_with("Node.js v24.12.0")); + + let python = [ + "Traceback (most recent call last):", + " File \"x.py\", line 1", + "ModuleNotFoundError: No module named 'jedi'", + ]; + assert_eq!(crash_excerpt(&python, 5), python.join("\n")); + + let mut long_stack = vec!["Exception in thread \"main\" java.lang.IllegalStateException: boom"]; + long_stack.extend(std::iter::repeat_n("\tat a.b.C.d(C.java:1)", 30)); + let out = crash_excerpt(&long_stack, 5); + assert!(out.starts_with("Exception in thread \"main\""), "{out}"); + assert_eq!(out.lines().count(), 7); + + let plain = ["starting", "listening", "bye"]; + assert_eq!(crash_excerpt(&plain, 2), "listening\nbye"); + // "Error" in prose is not a headline. + assert_eq!(crash_excerpt(&["No Error here: fine", "a", "b"], 2), "a\nb"); + } +} diff --git a/crates/ide-lsp/src/tsserver_bridge.rs b/crates/ide-lsp/src/tsserver_bridge.rs new file mode 100644 index 000000000..5997fb36d --- /dev/null +++ b/crates/ide-lsp/src/tsserver_bridge.rs @@ -0,0 +1,93 @@ +//! Vue Language Server (Volar 3) runs in "hybrid mode": TypeScript work goes to +//! tsserver with `@vue/typescript-plugin`, through the client. It sends +//! `tsserver/request` `[id, command, args]` and waits for `tsserver/response` +//! `[id, body]`; the client forwards the command to vtsls, whose tsserver has the +//! plugin loaded (`typescript.tsserverRequest`). Without an answer the server +//! waits forever, so every request is answered — `null` when vtsls can't. + +use crate::LspManager; +use crate::server::Server; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +/// The server that hosts tsserver for the bridge. +pub(crate) const TSSERVER_HOST: &str = "vtsls"; +const HOST_START_WAIT: Duration = Duration::from_secs(60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// `[id, command, args]` items of a `tsserver/request` notification +/// (vscode-jsonrpc wraps the single array argument: `[[id, command, args]]`). +pub(crate) fn parse_requests(params: &Value) -> Vec<(Value, String, Value)> { + let one = |v: &Value| { + let a = v.as_array()?; + let command = a.get(1)?.as_str()?.to_owned(); + Some((a.first()?.clone(), command, a.get(2).cloned().unwrap_or(Value::Null))) + }; + match params.as_array() { + Some(items) if items.first().is_some_and(Value::is_array) => items.iter().filter_map(one).collect(), + Some(_) => one(params).into_iter().collect(), + None => Vec::new(), + } +} + +impl LspManager { + /// Answers `tsserver/request`s of `from` (instance `generation`) via [`TSSERVER_HOST`]. + pub(crate) fn forward_tsserver_requests(&self, from: Arc, generation: u64, params: Value) { + let Some(me) = self.me.upgrade() else { return }; + for (id, command, args) in parse_requests(¶ms) { + let me = me.clone(); + let from = from.clone(); + self.runtime.spawn(async move { + let body = me.tsserver_request(&from, &command, args).await.unwrap_or(Value::Null); + if let Some(run) = from.running_gen(generation) + && let Err(e) = run.peer.notify("tsserver/response", json!([[id, body]])) + { + tracing::debug!("{}: tsserver/response: {e}", from.name); + } + }); + } + } + + async fn tsserver_request(&self, from: &Server, command: &str, args: Value) -> Option { + let host = self + .servers + .lock() + .iter() + .filter(|s| s.name == TSSERVER_HOST) + .min_by_key(|s| s.root != from.root) + .cloned()?; + let run = host.wait_ready(HOST_START_WAIT).await?; + let params = json!({ + "command": "typescript.tsserverRequest", + "arguments": [command, args, { "isAsync": true, "lowPriority": true }], + }); + let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None); + match tokio::time::timeout(REQUEST_TIMEOUT, request).await { + Ok(Ok(result)) => result.get("body").cloned(), + Ok(Err(e)) => { + tracing::debug!("{TSSERVER_HOST}: {command}: {e}"); + None + } + Err(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wrapped_and_bare_requests() { + let wrapped = json!([[1, "_vue:projectInfo", { "file": "/p/App.vue" }]]); + assert_eq!( + parse_requests(&wrapped), + vec![(json!(1), "_vue:projectInfo".to_owned(), json!({ "file": "/p/App.vue" }))] + ); + let bare = json!([2, "_vue:getComponentNames", ["/p/App.vue"]]); + assert_eq!(parse_requests(&bare), vec![(json!(2), "_vue:getComponentNames".to_owned(), json!(["/p/App.vue"]))]); + assert!(parse_requests(&json!({ "id": 1 })).is_empty()); + assert!(parse_requests(&json!([[3]])).is_empty()); + } +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 42f23597a..5c42f7fae 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -227,3 +227,75 @@ async fn typescript_language_server_end_to_end() { .await; tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } + +const APP_VUE: &str = "\n\n\n"; + +fn npm_server(tools: &Path, name: &str, bin: &str, init: Option) -> LanguageServerConfig { + LanguageServerConfig { + name: name.into(), + command: tools.join("node_modules/.bin").join(bin).to_string_lossy().into_owned(), + args: vec!["--stdio".into()], + env: Default::default(), + root_markers: vec!["package.json".into()], + initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()), + install_hint: None, + fallback: false, + launch_error: None, + } +} + +/// Vue Language Server 3 in hybrid mode: its `tsserver/request`s go through the +/// client to vtsls with `@vue/typescript-plugin` (issue #25). Set +/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @vtsls/language-server`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires @vue/language-server and @vtsls/language-server (OPENIDEA_TEST_NPM_TOOLS)"] +async fn vue_hybrid_mode_end_to_end() { + let tools = std::path::PathBuf::from(std::env::var("OPENIDEA_TEST_NPM_TOOLS").expect("OPENIDEA_TEST_NPM_TOOLS")); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("package.json"), "{\"name\":\"app\",\"private\":true}").unwrap(); + std::fs::write(root.join("tsconfig.json"), "{\"compilerOptions\":{\"strict\":true},\"include\":[\"src/**/*\"]}") + .unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let file = root.join("src/App.vue"); + std::fs::write(&file, APP_VUE).unwrap(); + + let plugin = serde_json::json!({ "vtsls": { "tsserver": { "globalPlugins": [{ + "name": "@vue/typescript-plugin", + "location": tools.join("node_modules/@vue/language-server").to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }] } } }); + for with_vtsls in [false, true] { + let mut vue = lang("vue", "vue", npm_server(&tools, "vue-language-server", "vue-language-server", None)); + if with_vtsls { + vue.language_servers.push(npm_server(&tools, "vtsls", "vtsls", Some(plugin.clone()))); + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![vue])); + let doc = DocId::next(); + mgr.did_open(doc, &file, &LanguageId::new("vue"), 1, APP_VUE); + // `count` in `{{ count }}`. + let pos = Pos::new(5, 12); + if with_vtsls { + let hover = retry("hover", Duration::from_secs(90), || { + let mgr = mgr.clone(); + async move { mgr.hover(doc, pos, Default::default()).await.ok().flatten() } + }) + .await; + assert!(hover.contents.value.contains("count: number"), "{}", hover.contents.value); + } + retry("vue-language-server ready", Duration::from_secs(30), || { + let s = last_status(&events, "vue-language-server"); + async move { matches!(s, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + // Every request of vue-language-server waits for a tsserver answer (the + // project info); without vtsls it is `null` and the server falls back. + let symbols = tokio::time::timeout(Duration::from_secs(20), mgr.document_symbols(doc)).await; + assert!(matches!(symbols, Ok(Ok(_))), "vue-language-server did not answer: {symbols:?}"); + let crashed = matches!(last_status(&events, "vue-language-server"), Some((ServerStatus::Crashed, _))); + assert!(!crashed, "{:?}", last_status(&events, "vue-language-server")); + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); + } +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 8db39961d..af60d2be7 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,7 +20,9 @@ mod sandbox; mod theme; mod types; -pub use manifest::{API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest}; +pub use manifest::{ + API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl, +}; pub use sandbox::resolve_in_root; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; @@ -627,7 +629,10 @@ fn static_contributions(dir: &Path, m: &PluginManifest) -> Result, #[serde(default)] - pub themes: Vec, + pub themes: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -83,6 +83,16 @@ pub struct FileDecl { pub path: PathBuf, } +/// A contributed theme: the UI theme file plus an optional editor colour scheme +/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ThemeDecl { + pub path: PathBuf, + #[serde(default)] + pub editor_scheme: Option, +} + impl PluginManifest { pub fn parse(src: &str) -> Result { let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?; @@ -123,8 +133,9 @@ impl PluginManifest { return Err(format!("command {:?} has an empty title", c.id)); } } - for f in self.contributes.languages.iter().chain(&self.contributes.themes) { - check_relative(&f.path, "contributes path")?; + let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme)); + for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) { + check_relative(path, "contributes path")?; } Ok(()) } @@ -133,7 +144,10 @@ impl PluginManifest { pub fn files(&self) -> Vec<&Path> { let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect(); files.extend(self.contributes.languages.iter().map(|f| f.path.as_path())); - files.extend(self.contributes.themes.iter().map(|f| f.path.as_path())); + for t in &self.contributes.themes { + files.push(t.path.as_path()); + files.extend(t.editor_scheme.as_deref()); + } files } @@ -202,6 +216,7 @@ mod tests { [[contributes.themes]] path = "themes/dark.json" + editor-scheme = "themes/dark.scheme.json" "#; #[test] @@ -210,7 +225,10 @@ mod tests { assert_eq!(m.id, "hello"); assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]); assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello")); - assert_eq!(m.files(), vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json")]); + assert_eq!( + m.files(), + vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")] + ); assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead)); } @@ -242,6 +260,8 @@ mod tests { assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command")); let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\""; assert!(err(&base(traversal, "ok", "0.1")).contains("relative path")); + let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\""; + assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path")); assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version")); assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name")); } diff --git a/crates/ide-plugin/src/theme.rs b/crates/ide-plugin/src/theme.rs index 8e7f1b9dc..0d4e65e7a 100644 --- a/crates/ide-plugin/src/theme.rs +++ b/crates/ide-plugin/src/theme.rs @@ -1,4 +1,5 @@ //! Theme contributions: design-token overrides applied as CSS variables on `:root`. +//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments. use serde::{Deserialize, Serialize}; use specta::Type; @@ -24,13 +25,28 @@ pub struct ThemeDef { pub tokens: BTreeMap, } +/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme. +#[derive(Deserialize)] +struct EditorScheme { + tokens: BTreeMap, +} + impl ThemeDef { pub fn parse(src: &str) -> Result { - let theme: Self = serde_json::from_str(src).map_err(|e| format!("invalid theme JSON: {e}"))?; + let theme: Self = + serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?; theme.validate()?; Ok(theme) } + /// Merges an editor colour scheme file over the theme's tokens (the scheme wins). + pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> { + let scheme: EditorScheme = + serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?; + self.tokens.extend(scheme.tokens); + self.validate() + } + pub fn validate(&self) -> Result<(), String> { crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?; if self.name.trim().is_empty() { @@ -55,6 +71,53 @@ impl ThemeDef { } } +/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so +/// parse errors still point at the right line. +fn strip_json_comments(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut chars = src.chars().peekable(); + let (mut in_str, mut escaped) = (false, false); + while let Some(c) = chars.next() { + if in_str { + out.push(c); + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match (c, chars.peek()) { + ('/', Some('/')) => { + if chars.by_ref().any(|c| c == '\n') { + out.push('\n'); + } + } + ('/', Some('*')) => { + chars.next(); + let mut prev = ' '; + for c in chars.by_ref() { + if c == '\n' { + out.push('\n'); + } + if prev == '*' && c == '/' { + break; + } + prev = c; + } + out.push(' '); + } + _ => { + in_str = c == '"'; + out.push(c); + } + } + } + out +} + /// Values are plain CSS values (colours, lengths, font stacks). Anything that /// could break out of the declaration or fetch resources is rejected. fn is_safe_value(v: &str) -> bool { @@ -95,4 +158,34 @@ mod tests { assert!(mk("--bg", " ").is_err()); assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err()); } + + #[test] + fn allows_comments_outside_strings() { + let src = r##"{ + // line comment + "id": "c", "name": "C // not a comment", "kind": "dark", + /* block + comment */ + "tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" } + }"##; + let t = ThemeDef::parse(src).unwrap(); + assert_eq!(t.name, "C // not a comment"); + assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif"); + assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3")); + } + + #[test] + fn merges_editor_scheme_over_theme() { + let mut t = ThemeDef::parse( + r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##, + ) + .unwrap(); + t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##) + .unwrap(); + assert_eq!(t.tokens["--bg-app"], "#111"); + assert_eq!(t.tokens["--syn-keyword"], "#333"); + assert_eq!(t.tokens["--caret"], "#fff"); + assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err()); + assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err()); + } } diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index 85757421e..c56414ef4 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -242,6 +242,42 @@ fn contribution_only_plugins() { assert_eq!(f.status("language-toy"), PluginStatus::Disabled); } +#[test] +fn theme_template_installs_with_its_editor_scheme() { + let f = fixture(Limits::default()); + let info = f.host.install_from_dir(&repo().join("templates/theme-plugin")).unwrap(); + assert_eq!(info.status, PluginStatus::Static); + assert!(!info.has_code); + assert_eq!(info.themes[0].id, "harbor-night"); + assert_eq!(info.themes[0].kind, ThemeKind::Dark); + // The scheme file is copied with the package and merged over the UI tokens. + assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file()); + let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); + let tokens = &active.theme.tokens; + assert!( + tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red") + ); + assert!(tokens.len() > 150); +} + +#[test] +fn theme_with_broken_editor_scheme_fails_to_load() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("t")).unwrap(); + std::fs::write( + dir.path().join("plugin.toml"), + "id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\ + [[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n", + ) + .unwrap(); + std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap(); + std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap(); + let info = f.host.install_from_dir(dir.path()).unwrap(); + assert_eq!(info.status, PluginStatus::Failed); + assert!(info.error.unwrap_or_default().contains("a.scheme.json")); +} + #[test] fn state_persists_across_restarts() { let f = fixture(Limits::default()); diff --git a/crates/ide-run/Cargo.toml b/crates/ide-run/Cargo.toml index c64c17738..955fd355f 100644 --- a/crates/ide-run/Cargo.toml +++ b/crates/ide-run/Cargo.toml @@ -17,6 +17,7 @@ tokio.workspace = true portable-pty.workspace = true parking_lot.workspace = true tracing.workspace = true +zip = { version = "=2.4.2", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/ide-run/src/java_cmdline.rs b/crates/ide-run/src/java_cmdline.rs new file mode 100644 index 000000000..a6e3f374f --- /dev/null +++ b/crates/ide-run/src/java_cmdline.rs @@ -0,0 +1,537 @@ +//! IDEA's "Shorten command line" for `java` launches: a classpath too long for +//! the platform's command-line limit (Windows `CreateProcessW`: 32767 chars, +//! `os error 206`) moves into an `@argfile` (JDK 9+) or a pathing jar whose +//! manifest `Class-Path` lists the entries (any JDK, the only option on JDK 8). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `CreateProcessW` takes at most 32767 chars including the NUL; leave slack for quoting. +pub const WINDOWS_LIMIT: usize = 32_000; +/// Linux caps one argument at 128 KiB (`MAX_ARG_STRLEN`); macOS the whole `ARG_MAX` (1 MiB, env included). +pub const UNIX_LIMIT: usize = 120_000; + +/// How a long `java` command line is shortened (java-debug's `shortenCommandLine` values). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shortening { + None, + /// A temporary jar whose manifest `Class-Path` lists the classpath. + JarManifest, + /// A temporary `@argfile` holding `-cp` (and `--module-path`), JDK 9+. + ArgFile, +} + +impl Shortening { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::JarManifest => "jarmanifest", + Self::ArgFile => "argfile", + } + } +} + +pub fn command_line_limit(windows: bool) -> usize { + if windows { WINDOWS_LIMIT } else { UNIX_LIMIT } +} + +/// Length of the command line as the OS receives it (quotes and separators included). +pub fn command_line_length(program: &str, args: &[String]) -> usize { + std::iter::once(program).chain(args.iter().map(String::as_str)).map(|a| a.len() + 3).sum() +} + +/// Whether the longest single argument or the whole line exceeds the platform limit. +fn too_long(program: &str, args: &[String], windows: bool) -> bool { + let limit = command_line_limit(windows); + command_line_length(program, args) > limit || (!windows && args.iter().any(|a| a.len() > limit)) +} + +/// IDEA's automatic choice: nothing when the line fits; else an `@argfile` on JDK 9+ +/// when every path is ASCII (the launcher reads argfiles in the platform charset), +/// else a pathing jar (works on every JDK; manifest URLs are percent-encoded UTF-8). +pub fn choose(too_long: bool, java_major: Option, classpath: &str) -> Shortening { + if !too_long { + Shortening::None + } else if java_major.is_some_and(|v| v >= 9) && classpath.is_ascii() { + Shortening::ArgFile + } else { + Shortening::JarManifest + } +} + +/// Room left for VM options and program arguments when only the paths are known. +const OTHER_ARGS_RESERVE: usize = 4_096; + +/// The shortening a launcher that builds the command line itself (java-debug's +/// `shortenCommandLine`) should use for `java -cp [--module-path …] …`. +pub fn shortening_for(java: &Path, class_paths: &[String], module_paths: &[String], windows: bool) -> Shortening { + let sep = if windows { ";" } else { ":" }; + let (cp, mp) = (class_paths.join(sep), module_paths.join(sep)); + let len = java.as_os_str().len() + cp.len() + mp.len() + 64 + OTHER_ARGS_RESERVE; + let limit = command_line_limit(windows); + choose(len > limit || cp.len() > limit || mp.len() > limit, java_feature_version(java), &format!("{cp}{mp}")) +} + +/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file. +pub fn parse_release(release: &str) -> Option { + let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?; + let mut parts = line.trim().trim_matches('"').split(['.', '_', '-', '+']); + let first: u32 = parts.next()?.parse().ok()?; + if first == 1 { parts.next()?.parse().ok() } else { Some(first) } +} + +/// Feature version of the JDK a `java` launcher belongs to (`/bin/java`, or +/// JDK 8's `/jre/bin/java`), following symlinks; `None` when unknown. +pub fn java_feature_version(java: &Path) -> Option { + let java = std::fs::canonicalize(java).unwrap_or_else(|_| java.to_path_buf()); + java.ancestors() + .skip(2) + .take(2) + .find_map(|home| std::fs::read_to_string(home.join("release")).ok()) + .and_then(|r| parse_release(&r)) +} + +/// `java`, `java.exe`, `javaw.exe`. +pub fn is_java_launcher(program: &Path) -> bool { + let name = crate::wrap::program_name(&program.to_string_lossy()).to_ascii_lowercase(); + name == "java" || name == "javaw" +} + +/// `java` options whose value is the next argument. +const WITH_VALUE: &[&str] = &[ + "-cp", + "-classpath", + "--class-path", + "-p", + "--module-path", + "--upgrade-module-path", + "--add-modules", + "--add-opens", + "--add-exports", + "--add-reads", + "--patch-module", + "--limit-modules", + "--enable-native-access", +]; + +/// Indices of the `-cp` and `--module-path` values among the VM options (before the main class). +fn path_options(args: &[String]) -> (Option, Option) { + let (mut cp, mut mp) = (None, None); + let mut i = 0; + while i < args.len() { + let a = args[i].as_str(); + if matches!(a, "-jar" | "-m" | "--module") || !a.starts_with('-') { + break; + } + if WITH_VALUE.contains(&a) { + if i + 1 < args.len() { + match a { + "-cp" | "-classpath" | "--class-path" => cp = Some(i + 1), + "-p" | "--module-path" => mp = Some(i + 1), + _ => {} + } + } + i += 2; + } else { + i += 1; + } + } + (cp, mp) +} + +/// A shortened command line and the temporary file it refers to (delete it once the process exits). +#[derive(Debug)] +pub struct Shortened { + pub args: Vec, + pub file: PathBuf, + pub how: Shortening, +} + +/// Shortens `java [options] -cp Main …` when it is too long for the platform: +/// `None` for other programs, lines that fit, or no classpath option. +pub fn shorten( + program: &Path, + args: &[String], + cwd: &Path, + windows: bool, + temp_dir: &Path, +) -> std::io::Result> { + if !is_java_launcher(program) || !too_long(&program.to_string_lossy(), args, windows) { + return Ok(None); + } + let (cp, mp) = path_options(args); + let Some(cp) = cp else { return Ok(None) }; + let how = choose(true, java_feature_version(program), &args[cp]); + std::fs::create_dir_all(temp_dir)?; + let mut out = args.to_vec(); + let file = match how { + Shortening::ArgFile => { + let file = temp_file(temp_dir, "argfile"); + let mut moved = vec![cp - 1, cp]; + if let Some(mp) = mp { + moved.extend([mp - 1, mp]); + } + moved.sort_unstable(); + let text = argfile_text(&moved.iter().map(|&i| args[i].clone()).collect::>()); + std::fs::write(&file, text)?; + for &i in moved.iter().rev() { + out.remove(i); + } + out.insert(moved[0], format!("@{}", file.display())); + file + } + _ => { + let file = temp_file(temp_dir, "jar"); + let entries = classpath_entries(&args[cp], cwd, windows); + write_pathing_jar(&entries, &file)?; + out[cp] = file.to_string_lossy().into_owned(); + file + } + }; + Ok(Some(Shortened { args: out, file, how })) +} + +/// Where launches put their pathing jars / argfiles. +pub fn default_temp_dir() -> PathBuf { + std::env::temp_dir().join("openidea-classpath") +} + +fn temp_file(dir: &Path, ext: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + dir.join(format!("classpath-{}-{nanos}-{n}.{ext}", std::process::id())) +} + +/// Argument file lines: every argument quoted, `\` and `"` escaped (the launcher's argfile syntax). +pub fn argfile_text(args: &[String]) -> String { + let mut out = String::new(); + for a in args { + out.push('"'); + for c in a.chars() { + if c == '\\' || c == '"' { + out.push('\\'); + } + out.push(c); + } + out.push_str("\"\n"); + } + out +} + +/// Classpath entries as absolute paths: relative ones against `cwd`, `dir/*` expanded to its jars. +pub fn classpath_entries(classpath: &str, cwd: &Path, windows: bool) -> Vec { + let mut out = Vec::new(); + for e in classpath.split(if windows { ';' } else { ':' }).filter(|e| !e.is_empty()) { + let wildcard = e.strip_suffix("/*").or_else(|| e.strip_suffix("\\*")); + let path = cwd.join(wildcard.unwrap_or(e)); + if wildcard.is_some() { + let mut jars: Vec = std::fs::read_dir(&path) + .map(|rd| { + rd.flatten() + .map(|d| d.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("jar"))) + .collect() + }) + .unwrap_or_default(); + jars.sort(); + out.extend(jars); + } else { + out.push(path); + } + } + out +} + +/// `file:` URL of a classpath entry: percent-encoded UTF-8, `/` separators, a trailing `/` on directories. +pub fn file_url(path: &str, dir: bool) -> String { + let mut p = path.replace('\\', "/"); + if !p.starts_with('/') { + p.insert(0, '/'); // `C:/x` → `/C:/x` + } else if p.starts_with("//") { + p.insert_str(0, "//"); // UNC `//server/share` → `file:////server/share`, as `File.toURI` does + } + if dir && !p.ends_with('/') { + p.push('/'); + } + let mut url = String::from("file:"); + for b in p.bytes() { + if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) { + url.push(b as char); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url +} + +/// `META-INF/MANIFEST.MF` listing `entries`, lines wrapped at 72 bytes (continuations start with a space). +pub fn manifest(entries: &[PathBuf]) -> String { + let urls: Vec = entries.iter().map(|e| file_url(&e.to_string_lossy(), e.is_dir())).collect(); + let mut out = String::from("Manifest-Version: 1.0\r\nCreated-By: OpenIDEA\r\n"); + out.push_str(&wrap_header(&format!("Class-Path: {}", urls.join(" ")))); + out.push_str("\r\n"); + out +} + +/// One manifest header in lines of at most 72 bytes; `line` is ASCII (URLs are percent-encoded). +fn wrap_header(line: &str) -> String { + let mut out = String::new(); + let mut rest = line; + let mut width = 72; + while !rest.is_empty() { + let (head, tail) = rest.split_at(width.min(rest.len())); + if width == 71 { + out.push(' '); + } + out.push_str(head); + out.push_str("\r\n"); + rest = tail; + width = 71; + } + out +} + +/// A jar holding only a manifest whose `Class-Path` is `entries` (IDEA's "JAR manifest"). +pub fn write_pathing_jar(entries: &[PathBuf], out: &Path) -> std::io::Result<()> { + let mut zip = zip::ZipWriter::new(std::fs::File::create(out)?); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("META-INF/MANIFEST.MF", opts).map_err(std::io::Error::other)?; + zip.write_all(manifest(entries).as_bytes())?; + zip.finish().map_err(std::io::Error::other)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(a: &[&str]) -> Vec { + a.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn chooses_like_idea() { + assert_eq!(choose(false, Some(8), "a.jar"), Shortening::None); + assert_eq!(choose(true, Some(8), "a.jar"), Shortening::JarManifest); + assert_eq!(choose(true, None, "a.jar"), Shortening::JarManifest); + assert_eq!(choose(true, Some(9), "a.jar"), Shortening::ArgFile); + assert_eq!(choose(true, Some(21), "a.jar"), Shortening::ArgFile); + assert_eq!(choose(true, Some(21), "C:\\用户\\a.jar"), Shortening::JarManifest); + } + + #[test] + fn debugger_launches_get_the_same_choice() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk11"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"11.0.18\"").unwrap(); + let java = jdk.join("bin/java"); + let few = vec!["/r/a.jar".to_owned(); 10]; + let many = vec![r"C:\Users\dev\.m2\repository\org\x\lib\1.0\lib-1.0.jar".to_owned(); 600]; + assert_eq!(shortening_for(&java, &few, &[], true), Shortening::None); + assert_eq!(shortening_for(&java, &many, &[], true), Shortening::ArgFile); + assert_eq!(shortening_for(&java, &many, &[], false), Shortening::None); + assert_eq!(shortening_for(Path::new("java"), &many, &[], true), Shortening::JarManifest); + } + + #[test] + fn release_versions() { + assert_eq!(parse_release("JAVA_VERSION=\"1.8.0_372\"\n"), Some(8)); + assert_eq!(parse_release("IMPLEMENTOR=x\nJAVA_VERSION=\"21.0.3\""), Some(21)); + assert_eq!(parse_release("JAVA_VERSION=\"25\""), Some(25)); + assert_eq!(parse_release("nothing"), None); + } + + #[test] + fn feature_version_from_the_jdk_layout() { + let dir = tempfile::tempdir().unwrap(); + let jdk8 = dir.path().join("jdk8"); + std::fs::create_dir_all(jdk8.join("jre/bin")).unwrap(); + std::fs::write(jdk8.join("release"), "JAVA_VERSION=\"1.8.0_372\"").unwrap(); + assert_eq!(java_feature_version(&jdk8.join("jre/bin/java")), Some(8)); + let jdk21 = dir.path().join("jdk21"); + std::fs::create_dir_all(jdk21.join("bin")).unwrap(); + std::fs::write(jdk21.join("release"), "JAVA_VERSION=\"21.0.3\"").unwrap(); + assert_eq!(java_feature_version(&jdk21.join("bin/java")), Some(21)); + assert_eq!(java_feature_version(Path::new("java")), None); + } + + #[test] + fn launchers() { + for p in ["java", "/usr/bin/java", r"C:\jdk\bin\java.exe", r"C:\jdk\bin\javaw.exe", "JAVA.EXE"] { + assert!(is_java_launcher(Path::new(p)), "{p}"); + } + for p in ["javac", "kotlin", "/usr/bin/mvn", "java-agent"] { + assert!(!is_java_launcher(Path::new(p)), "{p}"); + } + } + + #[test] + fn finds_classpath_among_vm_options_only() { + let a = strings(&["-Xmx1g", "--add-opens", "a/b=ALL", "-cp", "x.jar", "Main", "-cp", "y"]); + assert_eq!(path_options(&a), (Some(4), None)); + let a = strings(&["-p", "mods", "-classpath", "x.jar", "Main"]); + assert_eq!(path_options(&a), (Some(3), Some(1))); + assert_eq!(path_options(&strings(&["-jar", "app.jar", "-cp", "x"])), (None, None)); + assert_eq!(path_options(&strings(&["Main", "-cp", "x"])), (None, None)); + } + + #[test] + fn urls_are_encoded_and_directories_end_with_a_slash() { + assert_eq!(file_url("/p/a b/x.jar", false), "file:/p/a%20b/x.jar"); + assert_eq!(file_url("/p/classes", true), "file:/p/classes/"); + assert_eq!(file_url("/p/classes/", true), "file:/p/classes/"); + assert_eq!(file_url(r"C:\Users\me\.m2\a+b#c.jar", false), "file:/C:/Users/me/.m2/a%2Bb%23c.jar"); + assert_eq!(file_url(r"\\srv\share\x.jar", false), "file:////srv/share/x.jar"); + assert_eq!(file_url("/p/用户/x.jar", false), "file:/p/%E7%94%A8%E6%88%B7/x.jar"); + assert_eq!(file_url("/p/100%/x.jar", false), "file:/p/100%25/x.jar"); + } + + #[test] + fn manifest_lines_are_at_most_72_bytes() { + let entries: Vec = (0..300) + .map(|i| PathBuf::from(format!("/home/u/.m2/repository/org/x/lib-{i}/1.0/lib {i}-1.0.jar"))) + .collect(); + let m = manifest(&entries); + assert!(m.starts_with("Manifest-Version: 1.0\r\n")); + assert!(m.ends_with("\r\n\r\n")); + for line in m.split("\r\n") { + assert!(line.len() <= 72, "{line:?}"); + } + // Unwrapping restores the header exactly. + let joined = m.replace("\r\n ", ""); + let joined = joined.lines().find_map(|l| l.strip_prefix("Class-Path: ")).unwrap(); + let urls: Vec<&str> = joined.split(' ').collect(); + assert_eq!(urls.len(), 300); + assert_eq!(urls[7], "file:/home/u/.m2/repository/org/x/lib-7/1.0/lib%207-1.0.jar"); + } + + #[test] + fn argfile_quotes_and_escapes() { + assert_eq!( + argfile_text(&strings(&["-cp", r#"C:\a b\x.jar;C:\q"y.jar"#])), + "\"-cp\"\n\"C:\\\\a b\\\\x.jar;C:\\\\q\\\"y.jar\"\n" + ); + } + + #[test] + fn short_lines_and_other_programs_are_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let args = strings(&["-cp", "a.jar", "Main"]); + assert!(shorten(Path::new("java"), &args, dir.path(), true, dir.path()).unwrap().is_none()); + let long = strings(&["-cp", &"x".repeat(40_000), "Main"]); + assert!(shorten(Path::new("node"), &long, dir.path(), true, dir.path()).unwrap().is_none()); + // Fits the Unix limit; too long for Windows. + assert!(shorten(Path::new("java"), &long, dir.path(), false, dir.path()).unwrap().is_none()); + } + + /// The reporter's case: JDK 8, ~270 jars, past 32767 chars on Windows. + #[test] + fn jdk8_gets_a_pathing_jar() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk8"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"1.8.0_372\"").unwrap(); + let java = jdk.join("bin/java.exe"); + let cp: Vec = (0..270) + .map(|i| format!(r"C:\Users\developer01\.m2\repository\org\springframework\boot\spring-boot-starter-lib-{i}\2.7.18\spring-boot-starter-lib-{i}-2.7.18.jar")) + .collect(); + let cp = format!(r"C:\work\demo\target\classes;{}", cp.join(";")); + assert!(cp.len() > 32_767); + let args = + strings(&["-Dspring.profiles.active=dev", "-cp", &cp, "com.example.DemoApplication", "--port", "8080"]); + let tmp = dir.path().join("tmp"); + let s = shorten(&java, &args, dir.path(), true, &tmp).unwrap().unwrap(); + assert_eq!(s.how, Shortening::JarManifest); + assert_eq!(s.args[0], "-Dspring.profiles.active=dev"); + assert_eq!(s.args[1], "-cp"); + assert_eq!(s.args[2], s.file.to_string_lossy()); + assert_eq!(&s.args[3..], ["com.example.DemoApplication", "--port", "8080"]); + assert!(command_line_length(&java.to_string_lossy(), &s.args) < 1_000); + let mut jar = zip::ZipArchive::new(std::fs::File::open(&s.file).unwrap()).unwrap(); + let mut text = String::new(); + std::io::Read::read_to_string(&mut jar.by_name("META-INF/MANIFEST.MF").unwrap(), &mut text).unwrap(); + let text = text.replace("\r\n ", ""); + let urls: Vec<&str> = text.lines().find_map(|l| l.strip_prefix("Class-Path: ")).unwrap().split(' ').collect(); + assert_eq!(urls.len(), 271); + assert!(urls.iter().all(|u| u.starts_with("file:/") && u.ends_with(".jar") || u.ends_with("classes"))); + } + + #[test] + fn jdk9_plus_gets_an_argfile_with_the_module_path() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk17"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"17.0.6\"").unwrap(); + let cp = vec!["/r/lib.jar"; 3_000].join(":"); + let args = strings(&["-Xmx1g", "--module-path", "/r/mods", "-cp", &cp, "a.Main", "x"]); + let s = shorten(&jdk.join("bin/java"), &args, dir.path(), true, dir.path()).unwrap().unwrap(); + assert_eq!(s.how, Shortening::ArgFile); + assert_eq!(s.args, ["-Xmx1g".to_owned(), format!("@{}", s.file.display()), "a.Main".into(), "x".into()]); + let text = std::fs::read_to_string(&s.file).unwrap(); + assert_eq!(text, format!("\"--module-path\"\n\"/r/mods\"\n\"-cp\"\n\"{cp}\"\n")); + } + + #[test] + fn wildcards_and_relative_entries() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("lib")).unwrap(); + for f in ["b.jar", "a.JAR", "notes.txt"] { + std::fs::write(dir.path().join("lib").join(f), "").unwrap(); + } + // An absolute entry stays as is; built from the temp dir so it is absolute on every platform. + let abs = dir.path().join("abs").join("x.jar"); + let sep = if cfg!(windows) { ";" } else { ":" }; + let cp = ["classes", "lib/*", &abs.to_string_lossy()].join(sep); + let e = classpath_entries(&cp, dir.path(), cfg!(windows)); + assert_eq!(e, [dir.path().join("classes"), dir.path().join("lib/a.JAR"), dir.path().join("lib/b.jar"), abs]); + } + + /// A pathing jar really loads classes (and resources from directories) with this machine's JDKs. + #[test] + fn pathing_jar_runs_with_a_local_jdk() { + let Some(home) = std::env::var_os("JAVA_HOME").map(PathBuf::from).or_else(|| { + let java = crate::which::find_executable("java", &std::env::var_os("PATH").unwrap_or_default())?; + std::fs::canonicalize(java).ok()?.parent()?.parent().map(Path::to_path_buf) + }) else { + return; + }; + let bin = |t: &str| home.join("bin").join(if cfg!(windows) { format!("{t}.exe") } else { t.to_owned() }); + if !bin("javac").is_file() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src dir"); + let classes = dir.path().join("out classes"); + std::fs::create_dir_all(src.join("p")).unwrap(); + std::fs::create_dir_all(&classes).unwrap(); + std::fs::write( + src.join("p/Main.java"), + "package p; public class Main { public static void main(String[] a) { System.out.println(\"hello \" + a[0]); } }", + ) + .unwrap(); + let ok = std::process::Command::new(bin("javac")) + .args(["--release", "8", "-d"]) + .arg(&classes) + .arg(src.join("p/Main.java")) + .status() + .is_ok_and(|s| s.success()) + || std::process::Command::new(bin("javac")) + .arg("-d") + .arg(&classes) + .arg(src.join("p/Main.java")) + .status() + .is_ok_and(|s| s.success()); + assert!(ok, "javac failed"); + let mut entries: Vec = (0..400).map(|i| dir.path().join(format!("missing {i}.jar"))).collect(); + entries.push(classes.clone()); + let jar = dir.path().join("cp.jar"); + write_pathing_jar(&entries, &jar).unwrap(); + let out = + std::process::Command::new(bin("java")).arg("-cp").arg(&jar).args(["p.Main", "world"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello world"); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 0a717609d..58e6b12cc 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod detect; pub mod envfile; +pub mod java_cmdline; pub mod junit; mod process; mod utf8; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 4874ede65..40b03834f 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -144,6 +144,44 @@ pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation { Invocation { program, args: config.args.clone(), raw_args: false, display } } +/// "Shorten command line" for a `java` launch too long for the platform (see +/// [`crate::java_cmdline`]): rewrites `inv` and returns the temporary file to delete on exit. +fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResult { + if inv.raw_args || !crate::java_cmdline::is_java_launcher(&inv.program) { + return Ok(TempFile(None)); + } + let java = if inv.program.components().count() == 1 { + crate::which::find_executable(&inv.program.to_string_lossy(), &crate::wrap::search_path(config)) + .unwrap_or_else(|| inv.program.clone()) + } else { + inv.program.clone() + }; + let dir = crate::java_cmdline::default_temp_dir(); + let shortened = crate::java_cmdline::shorten(&java, &inv.args, cwd, cfg!(windows), &dir) + .map_err(|e| IdeError::internal(format!("cannot shorten the command line: {e}")))?; + let Some(s) = shortened else { return Ok(TempFile(None)) }; + tracing::debug!(how = s.how.as_str(), file = %s.file.display(), "java command line shortened"); + let mut display = quote(config.command.trim()); + for a in &s.args { + display.push(' '); + display.push_str("e(a)); + } + inv.args = s.args; + inv.display = display; + Ok(TempFile(Some(s.file))) +} + +/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started). +struct TempFile(Option); + +impl Drop for TempFile { + fn drop(&mut self) { + if let Some(f) = self.0.take() { + let _ = std::fs::remove_file(f); + } + } +} + /// `command` as one line for the platform shell: `/bin/sh -c`, or `cmd /D /S /C "…"` /// (`/S` strips exactly the outer quotes, so the line's own quoting survives). fn shell_invocation(command: &str, windows: bool, comspec: Option) -> Invocation { @@ -232,7 +270,7 @@ impl ProcessManager { if !cwd.is_dir() { return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display()))); } - let inv = invocation(config, &cwd); + let mut inv = invocation(config, &cwd); let extras = &config.extras; if extras.emulate_terminal && extras.stdin_file.is_some() { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); @@ -248,8 +286,9 @@ impl ProcessManager { cwd: cwd.clone(), env: env.clone(), }); + let temp = shorten_java(&mut inv, config, &cwd)?; if extras.emulate_terminal { - return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output); + return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); } let mut cmd = Command::new(&inv.program); @@ -336,6 +375,7 @@ impl ProcessManager { .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { let status = child.wait(); + drop(temp); let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status))); }) .map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?; @@ -356,6 +396,7 @@ impl ProcessManager { options: &StartOptions, sink: SharedSink, on_output: OutputFn, + temp: TempFile, ) -> IdeResult { let pair = native_pty_system() .openpty(pty_size(DEFAULT_PTY.0, DEFAULT_PTY.1)) @@ -413,6 +454,7 @@ impl ProcessManager { .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { let status = child.wait(); + drop(temp); let _ = tx.send(Msg::Exited(Exit::Pty(status))); }) .map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?; diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 946edfc10..e60ffb000 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -379,3 +379,38 @@ fn stop_command_runs_first_with_the_run_environment() { assert!(h.root.path().join("stopped").exists(), "stop command did not run"); assert!(text(&chunks, system).contains("touch \"$OPENIDEA_MARKER\"")); } + +/// A `java` command line past the platform limit starts through a pathing jar +/// (JDK 8) or an `@argfile` (JDK 9+), which is deleted once the JVM exits. +#[test] +fn long_java_classpath_is_shortened() { + let Some(home) = std::env::var_os("JAVA_HOME").map(PathBuf::from) else { return }; + let (java, javac) = (home.join("bin/java"), home.join("bin/javac")); + if !javac.is_file() { + return; + } + let h = harness(); + let classes = h.root.path().join("out classes"); + std::fs::create_dir_all(classes.join("p")).unwrap(); + let src = h.root.path().join("Main.java"); + std::fs::write(&src, "package p; public class Main { public static void main(String[] a) { System.out.println(\"hello \" + a[0]); } }").unwrap(); + let ok = std::process::Command::new(&javac).arg("-d").arg(&classes).arg(&src).status().unwrap().success(); + assert!(ok, "javac failed"); + let mut cp: Vec = (0..3_000).map(|i| format!("/no/such/repository/lib-{i}/1.0/lib-{i}-1.0.jar")).collect(); + cp.push(classes.to_string_lossy().into_owned()); + let cp = cp.join(":"); + assert!(cp.len() > ide_run::java_cmdline::UNIX_LIMIT); + let config = cfg("t:java", &java.to_string_lossy(), &["-cp", &cp, "p.Main", "world"]); + let (id, chunks) = start(&h, &config); + assert_eq!(wait_exit(&h, id, Duration::from_secs(30)), Some(0), "{}", text(&chunks, stderr)); + assert_eq!(text(&chunks, stdout).trim(), "hello world"); + let echo = text(&chunks, system); + let first = echo.lines().next().unwrap(); + assert!(first.len() < 1_000, "{first}"); + let file = first + .split(' ') + .map(|a| a.trim_matches('\'').trim_start_matches('@')) + .find(|a| a.contains("openidea-classpath")) + .unwrap_or_else(|| panic!("no pathing jar / argfile in {first}")); + assert!(!Path::new(file).exists(), "{file} not deleted"); +} diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index 028f233f8..b83b58065 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] dunce.workspace = true +ignore.workspace = true ide-core.workspace = true ide-language.workspace = true ide-run.workspace = true diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 490defe04..93e0ce644 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -2,7 +2,8 @@ //! language servers, run configurations and terminals. use crate::discover::{project_venv_python, workspace_tsdk}; -use crate::exec::path_with; +use crate::exec::{path_with, search_path}; +use crate::vue; use crate::{bin_dir, exe, venv_of}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; @@ -258,6 +259,17 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root _ => {} } } + let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p)); + let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy); + if name == "vtsls" + && let Some(vue) = vue::server_package(&vue_command, &search_path()) + { + let plugin = vue::tsserver_plugin(&vue); + merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } })); + } + if name == vue::SERVER { + configure_vue_typescript(cfg, root, server_root, s); + } // Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js. if let Some(dir) = node_dir(root, s) { @@ -270,6 +282,26 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } +/// vue-language-server loads TypeScript as a library (`--tsdk`, else its own +/// `require('typescript')`): the selected version, else its own, else the +/// project's — whichever still has the language-service API. +fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { + let selected = tsdk(server_root, s).or_else(|| tsdk(root, s)).filter(|l| vue::has_ts_api(l)); + let package = vue::server_package(&cfg.command, &search_path()); + let own = package.as_deref().and_then(vue::own_typescript); + let workspace = || workspace_tsdk(server_root).or_else(|| workspace_tsdk(root)).filter(|l| vue::has_ts_api(l)); + let lib = match selected { + Some(lib) => Some(lib), + None if own.is_some() => None, + None => workspace(), + }; + match lib { + Some(lib) => cfg.args.push(format!("--tsdk={}", lib.to_string_lossy())), + None if own.is_none() && package.is_some() => cfg.launch_error = Some(vue::missing_typescript_message()), + None => {} + } +} + fn is_python_command(cmd: &str) -> bool { let name = Path::new(cmd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = name.strip_suffix(".exe").unwrap_or(&name); diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index 903682d31..439971472 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -394,27 +394,33 @@ fn release_value(release: &str, key: &str) -> Option { } fn jdk() -> Vec { + crate::java::rescan_jdks().iter().map(jdk_sdk).collect() +} + +/// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`). +pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk { let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok()); - crate::java::rescan_jdks() - .iter() - .map(|j| { - let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default(); - let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string()); - let name = match release_value(&release, "IMPLEMENTOR") { - Some(vendor) => format!("{vendor} {version}"), - None => format!("JDK {version}"), - }; - let source = if java_home.as_ref() == Some(&j.home) { "JAVA_HOME" } else { "system" }; - Sdk { - path: j.home.clone().into(), - name, - version: Some(version), - source: source.to_owned(), - home: Some(j.home.clone().into()), - extra: None, - } - }) - .collect() + let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default(); + let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string()); + let name = match release_value(&release, "IMPLEMENTOR") { + Some(vendor) => format!("{vendor} {version}"), + None => format!("JDK {version}"), + }; + let source = if java_home.as_ref() == Some(&j.home) { + "JAVA_HOME" + } else if crate::java::is_downloaded(&j.home) { + "downloaded" + } else { + "system" + }; + Sdk { + path: j.home.clone().into(), + name, + version: Some(version), + source: source.to_owned(), + home: Some(j.home.clone().into()), + extra: None, + } } // ------------------------------------------------------------------ maven diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index c021612c9..163427cb1 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -49,9 +49,26 @@ fn children(dir: &Path) -> Vec { const WINDOWS_VENDORS: &[&str] = &["Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Amazon Corretto", "BellSoft", "Semeru", "OpenJDK"]; +/// Folders the IDE downloads JDKs into ([`add_jdk_root`]). +static EXTRA_ROOTS: Mutex> = Mutex::new(Vec::new()); + +/// Scans the JDKs in `dir` (one per subfolder) too, e.g. where Download JDK puts them. +pub fn add_jdk_root(dir: &Path) { + let mut roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); + if !roots.iter().any(|r| r == dir) { + roots.push(dir.to_path_buf()); + } +} + +/// Whether `home` lies in a folder registered with [`add_jdk_root`]. +pub fn is_downloaded(home: &Path) -> bool { + let roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); + roots.iter().any(|r| home.starts_with(r) || dunce::canonicalize(r).is_ok_and(|r| home.starts_with(r))) +} + /// JDK homes in the usual install locations (macOS `JavaVirtualMachines`, /// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor -/// directories), `JAVA_HOME` first, then the `java` on `PATH`. +/// directories, the IDE's downloads), `JAVA_HOME` first, then the `java` on `PATH`. fn scan() -> Vec { let mut homes: Vec = Vec::new(); if let Some(h) = std::env::var_os("JAVA_HOME") { @@ -74,6 +91,7 @@ fn scan() -> Vec { if let Some(h) = &home { roots.extend([h.join("Library/Java/JavaVirtualMachines"), h.join(".jdks"), h.join(".sdkman/candidates/java")]); } + roots.extend(EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()).iter().cloned()); if cfg!(windows) { let bases = ["ProgramFiles", "ProgramW6432", "LOCALAPPDATA"].into_iter().filter_map(|v| { let dir = PathBuf::from(std::env::var_os(v)?); @@ -346,32 +364,51 @@ pub fn configure_jdtls( /// Where the JDK that runs jdtls is chosen, named as the settings dialog shows it. const SERVER_JDK_SETTING: &str = "Settings › Languages & Frameworks › Java › Language Server › JDK"; -fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { +/// `found JDK 8, 17` / `no JDK was found`. +fn found_jdks(jdks: &[Jdk]) -> String { let mut found: Vec = jdks.iter().map(|j| j.major).collect(); found.sort_unstable(); found.dedup(); - let found = if found.is_empty() { + if found.is_empty() { "no JDK was found".to_owned() } else { let list: Vec = found.iter().map(u32::to_string).collect(); format!("found JDK {}", list.join(", ")) - }; + } +} + +// The UI offers "Download JDK N" for messages saying "needs JDK N or newer". +fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { format!( - "jdtls needs JDK {min_java} or newer to run ({found}). Pick one in {SERVER_JDK_SETTING} (it restarts \ - automatically), or install a newer JDK and restart the server; the project can still target an older \ - Java version." + "jdtls needs JDK {min_java} or newer to run ({}). Download JDK {min_java}, or pick an installed one in \ + {SERVER_JDK_SETTING} (it restarts automatically); the project can still target an older Java version.", + found_jdks(jdks) ) } fn server_jdk_too_old_message(jdk: &Jdk, min_java: u32) -> String { format!( - "The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Choose another \ - one in {SERVER_JDK_SETTING}, or set it to Auto.", + "The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Download JDK \ + {min_java}, choose another one in {SERVER_JDK_SETTING}, or set it to Auto.", jdk.home.display(), jdk.major ) } +/// Why a module targeting Java `release` can't be built or run with the installed +/// JDKs (none is that new), or `None` when one is (or the release is unknown). +pub fn missing_jdk_message(jdks: &[Jdk], release: Option) -> Option { + let release = release?; + if jdks.iter().any(|j| j.major >= release) { + return None; + } + Some(format!( + "This project targets Java {release}: it needs JDK {release} or newer ({}). Download JDK {release}, or \ + choose a Project JDK in Settings › Languages & Frameworks › Java.", + found_jdks(jdks) + )) +} + /// The `java` launcher of a JDK home. pub fn java_executable(home: &Path) -> PathBuf { home.join("bin").join(crate::exe("java")) @@ -595,6 +632,62 @@ mod tests { configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, None, None, 25, &[], None); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}"); + assert!(err.contains("Download JDK 25"), "offers the download: {err}"); + } + + #[test] + fn issue_49_jdtls_needs_25_with_8_17_21_installed() { + let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; + let installed = [ + 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); + let err = cfg.launch_error.unwrap(); + assert!(err.starts_with("jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21)"), "{err}"); + assert!(err.contains("Download JDK 25"), "{err}"); + + // Once a JDK 25 has been downloaded, Auto runs jdtls on it. + 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"], Path::new("/tools/jdks/temurin-25").to_string_lossy()); + } + + #[test] + fn missing_jdk_for_the_project_release() { + let jdks = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; + assert_eq!(missing_jdk_message(&jdks, Some(17)), None); + assert_eq!(missing_jdk_message(&jdks, Some(11)), None); + assert_eq!(missing_jdk_message(&jdks, None), None); + let msg = missing_jdk_message(&jdks, Some(21)).unwrap(); + assert!(msg.contains("needs JDK 21 or newer (found JDK 8, 17)") && msg.contains("Download JDK 21"), "{msg}"); + assert!(missing_jdk_message(&[], Some(8)).unwrap().contains("no JDK was found")); + } + + #[test] + fn downloaded_jdks_are_found() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("jdks/temurin-25.0.1"); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(home.join("bin").join(crate::exe("java")), "").unwrap(); + // macOS bundles: /Contents/Home + let bundle = tmp.path().join("jdks/temurin-21.0.5/Contents/Home"); + std::fs::create_dir_all(bundle.join("bin")).unwrap(); + std::fs::write(bundle.join("release"), "JAVA_VERSION=\"21.0.5\"\n").unwrap(); + std::fs::write(bundle.join("bin").join(crate::exe("java")), "").unwrap(); + add_jdk_root(&tmp.path().join("jdks")); + let found = rescan_jdks(); + let home = dunce::canonicalize(&home).unwrap(); + let bundle = dunce::canonicalize(&bundle).unwrap(); + assert!(found.iter().any(|j| j.home == home && j.major == 25), "{found:?}"); + assert!(found.iter().any(|j| j.home == bundle && j.major == 21), "{found:?}"); + assert!(is_downloaded(&home) && !is_downloaded(Path::new("/elsewhere"))); } #[test] diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 1068f19e5..8660e9d52 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -13,12 +13,13 @@ pub mod libraries; pub mod locate; pub mod lombok; pub mod python; +mod vue; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::{Path, PathBuf}; -pub use discover::detect; +pub use discover::{detect, jdk_sdk}; pub use exec::search_path; /// What [`detect`] looks for. diff --git a/crates/ide-sdk/src/locate.rs b/crates/ide-sdk/src/locate.rs index d01005fcc..22717b59c 100644 --- a/crates/ide-sdk/src/locate.rs +++ b/crates/ide-sdk/src/locate.rs @@ -85,7 +85,11 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: .iter() .map(|name| { let path = valid_name(name) - .then(|| near.and_then(|n| local_bin(n, name)).or_else(|| on_path(name, search))) + .then(|| { + near.and_then(|n| local_bin(n, name)) + .or_else(|| on_path(name, search)) + .or_else(|| jdk_tool(name, &crate::java::installed_jdks())) + }) .flatten(); LocatedTool { name: name.clone(), path: path.map(Into::into), version: None } }) @@ -106,6 +110,16 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: }) } +/// `java` / `javac` / `jshell` of the newest installed JDK when none is on the +/// search path (JDKs installed without `PATH` changes, or downloaded by the IDE). +fn jdk_tool(name: &str, jdks: &[crate::java::Jdk]) -> Option { + if !["java", "javac", "jshell"].contains(&name) { + return None; + } + let jdk = jdks.iter().max_by_key(|j| j.major)?; + Some(jdk.home.join("bin").join(crate::exe(name))).filter(|p| p.is_file()) +} + /// Directory for binaries compiled from single source files (`rustc`, `clang`). pub fn compile_dir() -> PathBuf { std::env::temp_dir().join("openidea-run") @@ -124,6 +138,26 @@ mod tests { assert_eq!(parse_tool_version("no version here"), None); } + #[test] + fn java_falls_back_to_the_newest_installed_jdk() { + let tmp = tempfile::tempdir().unwrap(); + let jdk = |major: u32| { + let home = tmp.path().join(format!("jdk{major}")); + std::fs::create_dir_all(home.join("bin")).unwrap(); + for t in ["java", "javac"] { + std::fs::write(home.join("bin").join(crate::exe(t)), "").unwrap(); + } + crate::java::Jdk { major, home } + }; + let jdks = [jdk(17), jdk(25), jdk(21)]; + let newest = tmp.path().join("jdk25/bin"); + assert_eq!(jdk_tool("java", &jdks), Some(newest.join(crate::exe("java")))); + assert_eq!(jdk_tool("javac", &jdks), Some(newest.join(crate::exe("javac")))); + assert_eq!(jdk_tool("jshell", &jdks), None, "not in this JDK"); + assert_eq!(jdk_tool("node", &jdks), None); + assert_eq!(jdk_tool("java", &[]), None); + } + #[test] fn rejects_paths_and_shell_syntax() { assert!(valid_name("ts-node")); diff --git a/crates/ide-sdk/src/lombok.rs b/crates/ide-sdk/src/lombok.rs index e9f7ca1af..f8958bc40 100644 --- a/crates/ide-sdk/src/lombok.rs +++ b/crates/ide-sdk/src/lombok.rs @@ -64,7 +64,8 @@ pub fn agent_in_args<'a>(args: impl IntoIterator) -> Option bool { - const FILES: [&str; 4] = ["pom.xml", "build.gradle", "build.gradle.kts", "libs.versions.toml"]; - const SKIP: [&str; 7] = ["target", "build", "node_modules", "out", "bin", "src", ".gradle"]; - fn walk(dir: &Path, depth: usize) -> bool { +/// Whether the project at `root` uses Lombok. It often arrives through inheritance the +/// project's own build files don't show (a parent POM up the tree or only in the local +/// repository `maven_repo`, an enclosing Gradle build, a convention plugin), so besides +/// those this follows Maven `` chains, checks an enclosing Gradle build and, last, +/// looks for Java sources importing Lombok, a signal independent of the build. +pub fn uses_lombok(root: &Path, maven_repo: Option<&Path>) -> bool { + let mut poms = Vec::new(); + build_files_mention_lombok(root, &mut poms) + || root.ancestors().skip(1).any(|d| d.join("lombok.config").is_file()) + || parents_mention_lombok(&poms, maven_repo) + || enclosing_gradle_build_mentions_lombok(root) + || sources_import_lombok(root) +} + +const BUILD_FILES: [&str; 4] = ["pom.xml", "build.gradle", "build.gradle.kts", "libs.versions.toml"]; +const OUTPUT_DIRS: [&str; 6] = ["target", "build", "node_modules", "out", "bin", ".gradle"]; + +fn mentions_lombok(file: &Path) -> bool { + std::fs::read_to_string(file).is_ok_and(|t| t.contains("lombok")) +} + +/// Build files under `root` (a few levels, for multi-module builds) mentioning Lombok; +/// the POMs seen go to `poms`. +fn build_files_mention_lombok(root: &Path, poms: &mut Vec) -> bool { + fn walk(dir: &Path, depth: usize, poms: &mut Vec) -> bool { if dir.join("lombok.config").is_file() { return true; } @@ -117,19 +139,133 @@ pub fn uses_lombok(root: &Path) -> bool { let name = name.to_string_lossy(); let Ok(ty) = e.file_type() else { continue }; if ty.is_dir() { - if depth > 0 && !name.starts_with('.') && !SKIP.contains(&name.as_ref()) { + if depth > 0 && !name.starts_with('.') && name != "src" && !OUTPUT_DIRS.contains(&name.as_ref()) { subdirs.push(e.path()); } - } else if FILES.contains(&name.as_ref()) - && std::fs::read_to_string(e.path()).is_ok_and(|t| t.contains("lombok")) - { - return true; + } else if BUILD_FILES.contains(&name.as_ref()) { + if mentions_lombok(&e.path()) { + return true; + } + if name == "pom.xml" { + poms.push(e.path()); + } } } - subdirs.iter().any(|d| walk(d, depth - 1)) + subdirs.iter().any(|d| walk(d, depth - 1, poms)) } // `gradle/libs.versions.toml` sits one level down, modules usually no deeper than three. - walk(root, 3) + walk(root, 3, poms) +} + +/// Maven inherits dependencies along ``: whether an ancestor POM of `poms` +/// mentions Lombok. +fn parents_mention_lombok(poms: &[PathBuf], maven_repo: Option<&Path>) -> bool { + const MAX_HOPS: usize = 8; + let mut seen = std::collections::HashSet::new(); + for pom in poms { + let Ok(mut text) = std::fs::read_to_string(pom) else { continue }; + let mut current = pom.clone(); + for _ in 0..MAX_HOPS { + let Some(parent) = parent_pom(¤t, &text, maven_repo) else { break }; + if !seen.insert(parent.clone()) { + break; + } + let Ok(parent_text) = std::fs::read_to_string(&parent) else { break }; + if parent_text.contains("lombok") { + return true; + } + (current, text) = (parent, parent_text); + } + } + false +} + +/// The parent of `pom` (content `text`) as Maven resolves it: the POM at `` +/// (default `../pom.xml`, none when empty) if it is that parent, else +/// `///-.pom` in the local repository. +fn parent_pom(pom: &Path, text: &str, maven_repo: Option<&Path>) -> Option { + let text = strip_xml_comments(text); + let (block, _) = split_parent(&text)?; + let coord = |tag| crate::java::xml_value(block, tag).filter(|v| !v.is_empty()); + let (group, artifact) = (coord("groupId")?, coord("artifactId")?); + let relative = if block.contains("") || block.contains("") { + String::new() + } else { + crate::java::xml_value(block, "relativePath").unwrap_or_else(|| "../pom.xml".to_owned()) + }; + if !relative.is_empty() { + let mut local = pom.parent()?.join(relative.replace('\\', "/")); + if local.is_dir() { + local = local.join("pom.xml"); + } + let is_parent = std::fs::read_to_string(&local) + .is_ok_and(|t| own_artifact_id(&strip_xml_comments(&t)).as_deref() == Some(artifact.as_str())); + if is_parent { + return Some(dunce::canonicalize(&local).unwrap_or(local)); + } + } + let version = coord("version").filter(|v| !v.contains("${"))?; + let dir = group.split('.').fold(maven_repo?.to_path_buf(), |d, part| d.join(part)); + let file = dir.join(&artifact).join(&version).join(format!("{artifact}-{version}.pom")); + file.is_file().then_some(file) +} + +/// A POM's `…` element and the text without it. +fn split_parent(text: &str) -> Option<(&str, String)> { + let start = text.find("")?; + let end = start + text[start..].find("")? + "".len(); + Some((&text[start..end], format!("{}{}", &text[..start], &text[end..]))) +} + +/// A POM's own ``, not its parent's. +fn own_artifact_id(text: &str) -> Option { + match split_parent(text) { + Some((_, rest)) => crate::java::xml_value(&rest, "artifactId"), + None => crate::java::xml_value(text, "artifactId"), + } +} + +/// A Gradle subproject opened on its own: the enclosing build (the nearest ancestor with +/// `settings.gradle[.kts]`) may apply Lombok from its root build file or version catalog. +fn enclosing_gradle_build_mentions_lombok(root: &Path) -> bool { + const MAX_LEVELS: usize = 4; + let settings = |d: &Path| d.join("settings.gradle").is_file() || d.join("settings.gradle.kts").is_file(); + root.ancestors().skip(1).take(MAX_LEVELS).find(|d| settings(d)).is_some_and(|build| { + ["build.gradle", "build.gradle.kts", "gradle/libs.versions.toml"] + .iter() + .any(|f| mentions_lombok(&build.join(f))) + }) +} + +/// Java sources under `root` importing Lombok. Bounded (the heads of the first files), +/// honouring ignore files and skipping build output. +fn sources_import_lombok(root: &Path) -> bool { + use std::io::Read; + const MAX_ENTRIES: usize = 50_000; + const MAX_FILES: usize = 2000; + // Imports precede the first type, after at most a license header. + const HEAD: u64 = 8 * 1024; + let mut walker = ignore::WalkBuilder::new(root); + walker.require_git(false).follow_links(false).filter_entry(|e| { + e.depth() == 0 + || !e.file_type().is_some_and(|t| t.is_dir()) + || !OUTPUT_DIRS.contains(&e.file_name().to_string_lossy().as_ref()) + }); + let imports_lombok = |path: &Path| { + let mut head = Vec::new(); + let read = std::fs::File::open(path).and_then(|f| f.take(HEAD).read_to_end(&mut head)); + let head = String::from_utf8_lossy(&head); + read.is_ok() + && (head.contains("@lombok.") + || head.lines().any(|l| l.trim_start().starts_with("import ") && l.contains(" lombok."))) + }; + walker + .build() + .take(MAX_ENTRIES) + .flatten() + .filter(|e| e.file_type().is_some_and(|t| t.is_file()) && e.path().extension().is_some_and(|x| x == "java")) + .take(MAX_FILES) + .any(|e| imports_lombok(e.path())) } /// `` of `~/.m2/settings.xml`, else `~/.m2/repository`. @@ -203,18 +339,164 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); fs::write(root.join("pom.xml"), "api").unwrap(); - assert!(!uses_lombok(root)); + assert!(!uses_lombok(root, None)); fs::create_dir_all(root.join("api/src/main")).unwrap(); fs::write(root.join("api/src/main/pom.xml"), "lombok").unwrap(); - assert!(!uses_lombok(root), "sources are not scanned"); + assert!(!uses_lombok(root, None), "build files under src are not scanned"); fs::write(root.join("api/pom.xml"), "lombok").unwrap(); - assert!(uses_lombok(root)); + assert!(uses_lombok(root, None)); let gradle = tempfile::tempdir().unwrap(); fs::create_dir_all(gradle.path().join("gradle")).unwrap(); fs::write(gradle.path().join("gradle/libs.versions.toml"), "lombok = \"org.projectlombok:lombok:1.18.30\"") .unwrap(); - assert!(uses_lombok(gradle.path())); + assert!(uses_lombok(gradle.path(), None)); + } + + const LOMBOK_DEP: &str = "org.projectlomboklombok"; + + fn pom(own: &str, parent: &str) -> String { + format!("4.0.0{parent}{own}") + } + + fn parent(artifact: &str, extra: &str) -> String { + format!( + "com.acme{artifact}1.0{extra}" + ) + } + + #[test] + fn parent_pom_only_in_the_local_repository() { + // #33: the module's parent was `mvn install`ed, its directory isn't on disk. + let tmp = tempfile::tempdir().unwrap(); + let (module, repo) = (tmp.path().join("module-a"), tmp.path().join("m2")); + fs::create_dir_all(&module).unwrap(); + fs::write(module.join("pom.xml"), pom("module-a", &parent("acme-parent", ""))).unwrap(); + assert!(!uses_lombok(&module, Some(&repo))); + let dir = repo.join("com/acme/acme-parent/1.0"); + fs::create_dir_all(&dir).unwrap(); + // The dependency comes from the grandparent. + fs::write(dir.join("acme-parent-1.0.pom"), pom("acme-parent", &parent("acme-root", ""))).unwrap(); + assert!(!uses_lombok(&module, Some(&repo))); + let root = repo.join("com/acme/acme-root/1.0"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("acme-root-1.0.pom"), + format!("acme-root{LOMBOK_DEP}"), + ) + .unwrap(); + assert!(uses_lombok(&module, Some(&repo))); + assert!(!uses_lombok(&module, None), "no repository, no parent"); + let agent = lombok_agent(&module, None, None, Some(repo.clone())); + assert_ne!(agent, LombokAgent::Unused, "missing, or a jar from the Gradle cache"); + } + + #[test] + fn parent_pom_via_relative_path_outside_the_root() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let module = project.join("modules/api"); + fs::create_dir_all(&module).unwrap(); + fs::create_dir_all(tmp.path().join("platform")).unwrap(); + fs::write( + module.join("pom.xml"), + pom("api", &parent("platform", "../../../platform")), + ) + .unwrap(); + fs::write( + tmp.path().join("platform/pom.xml"), + format!("platform{LOMBOK_DEP}"), + ) + .unwrap(); + assert!(uses_lombok(&module, None)); + assert!(uses_lombok(&project, None), "modules' parents are followed too"); + + // The default `../pom.xml`, but it is another project's POM: not the parent. + let other = tempfile::tempdir().unwrap(); + let child = other.path().join("child"); + fs::create_dir_all(&child).unwrap(); + fs::write(child.join("pom.xml"), pom("child", &parent("acme-parent", ""))).unwrap(); + fs::write( + other.path().join("pom.xml"), + format!("unrelated{LOMBOK_DEP}"), + ) + .unwrap(); + assert!(!uses_lombok(&child, None)); + fs::write( + other.path().join("pom.xml"), + format!("acme-parent{LOMBOK_DEP}"), + ) + .unwrap(); + assert!(uses_lombok(&child, None)); + // An empty relativePath means: from the repository only. + fs::write(child.join("pom.xml"), pom("child", &parent("acme-parent", ""))).unwrap(); + assert!(!uses_lombok(&child, None)); + } + + #[test] + fn parent_cycles_terminate() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a"); + fs::create_dir_all(&a).unwrap(); + fs::write(a.join("pom.xml"), pom("a", &parent("root", ""))).unwrap(); + fs::write(tmp.path().join("pom.xml"), pom("root", &parent("root", "pom.xml"))) + .unwrap(); + assert!(!uses_lombok(&a, None)); + } + + #[test] + fn enclosing_gradle_build() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("services/billing"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("build.gradle.kts"), "plugins { java }").unwrap(); + fs::write(tmp.path().join("settings.gradle.kts"), "include(\"services:billing\")").unwrap(); + assert!(!uses_lombok(&sub, None)); + fs::write( + tmp.path().join("build.gradle.kts"), + "subprojects { dependencies { \"compileOnly\"(\"org.projectlombok:lombok:1.18.36\") } }", + ) + .unwrap(); + assert!(uses_lombok(&sub, None)); + } + + #[test] + fn sources_importing_lombok() { + // E.g. a Gradle convention plugin in buildSrc/src or an included build adds it. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pkg = root.join("app/src/main/java/com/acme"); + fs::create_dir_all(&pkg).unwrap(); + fs::write(root.join("build.gradle"), "plugins { id 'acme.java-conventions' }").unwrap(); + fs::write(pkg.join("Plain.java"), "package com.acme;\n\nimport java.util.List;\n\nclass Plain {}\n").unwrap(); + assert!(!uses_lombok(root, None)); + // Build output and ignored files don't count. + fs::create_dir_all(root.join("app/build/generated")).unwrap(); + fs::write(root.join("app/build/generated/G.java"), "import lombok.Data;\n").unwrap(); + fs::write(root.join(".ignore"), "vendor/\n").unwrap(); + fs::create_dir_all(root.join("vendor")).unwrap(); + fs::write(root.join("vendor/V.java"), "import lombok.Value;\n").unwrap(); + assert!(!uses_lombok(root, None)); + fs::write( + pkg.join("User.java"), + "/* License */\npackage com.acme;\n\nimport lombok.Data;\n\n@Data\nclass User {}\n", + ) + .unwrap(); + assert!(uses_lombok(root, None)); + fs::write(pkg.join("User.java"), "package com.acme;\n\n@lombok.Getter\nclass User {}\n").unwrap(); + assert!(uses_lombok(root, None)); + fs::write(pkg.join("User.java"), "package com.acme;\nimport static lombok.AccessLevel.PRIVATE;\n").unwrap(); + assert!(uses_lombok(root, None)); + } + + #[test] + fn the_installed_agent_applies_without_detection() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("m2"); + assert_eq!(lombok_agent(tmp.path(), None, None, Some(repo.clone())), LombokAgent::Unused); + let managed = lombok_agent(tmp.path(), None, Some("/managed/lombok.jar".into()), Some(repo)); + assert_eq!(managed.jar(), Some(Path::new("/managed/lombok.jar"))); + assert_eq!(managed.problem(), None); } #[test] diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 86088a935..5bcc70e49 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -461,3 +461,55 @@ fn no_ecosystem_libraries_without_manifests() { std::fs::create_dir_all(tmp.path().join("node_modules/react")).unwrap(); assert!(crate::libraries::project_libraries(tmp.path(), &Settings::default(), &[]).is_empty()); } + +/// Tools dir with vue-language-server and the given TypeScript files, plus a +/// project; returns (tools, project). +fn vue_setup(tmp: &Path, tools_ts: &str, project_ts: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let tools = tmp.join("tools/node_modules"); + let project = tmp.join("app"); + for (p, content) in [ + (tools.join(".bin/vue-language-server"), ""), + (tools.join("@vue/language-server/package.json"), "{}"), + (tools.join("typescript/lib").join(tools_ts), ""), + (project.join("node_modules/typescript/lib").join(project_ts), ""), + (project.join("node_modules/typescript/lib/tsserver.js"), ""), + ] { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, content).unwrap(); + } + (tools, project) +} + +#[test] +fn vue_language_server_gets_a_typescript_with_the_language_service_api() { + // Issue #25: `typescript@latest` (7.x) in the tools dir has no `ts.server`. + let s = Settings::default(); + let cases = [ + // (tools TypeScript, project TypeScript, expected --tsdk = project lib, launch error) + ("typescript.js", "typescript.js", false, false), // own TypeScript 5/6 works + ("tsc.js", "typescript.js", true, false), // own TS 7: the project's + ("tsc.js", "tsc.js", false, true), // nothing usable: clear error + ]; + for (tools_ts, project_ts, uses_project, error) in cases { + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), tools_ts, project_ts); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let tsdk = cfg.args.iter().find_map(|a| a.strip_prefix("--tsdk=")).map(std::path::PathBuf::from); + let project_lib = project.join("node_modules/typescript/lib"); + assert_eq!(tsdk, uses_project.then_some(project_lib), "{tools_ts} / {project_ts}"); + assert_eq!(cfg.launch_error.is_some(), error, "{tools_ts} / {project_ts}"); + } + + // A selected version wins over the server's own. + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), "typescript.js", "typescript.js"); + let mut s = Settings::default(); + s.languages.typescript.version = "workspace".into(); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let lib = project.join("node_modules/typescript/lib"); + assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); +} diff --git a/crates/ide-sdk/src/vue.rs b/crates/ide-sdk/src/vue.rs new file mode 100644 index 000000000..6809485b7 --- /dev/null +++ b/crates/ide-sdk/src/vue.rs @@ -0,0 +1,121 @@ +//! Vue Language Server (Volar 3): the TypeScript it loads as a library, and the +//! `@vue/typescript-plugin` that vtsls' tsserver hosts for it (hybrid mode). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(crate) const SERVER: &str = "vue-language-server"; +const PACKAGE: &str = "@vue/language-server"; +const PLUGIN: &str = "@vue/typescript-plugin"; + +/// The TypeScript `lib` directory has the language-service API (`typescript.js`). +/// TypeScript 7, the native port, ships only `tsc`. +pub(crate) fn has_ts_api(lib: &Path) -> bool { + lib.join("typescript.js").is_file() +} + +/// `command` as given (a path) or found on `path`, npm shims included. +fn find_command(command: &str, path: &OsStr) -> Option { + let p = Path::new(command); + if p.is_absolute() { + return p.is_file().then(|| p.to_path_buf()); + } + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; + std::env::split_paths(path) + .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) + .find(|c| c.is_file()) +} + +/// The npm package an executable comes from: next to a `node_modules/.bin` shim +/// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a +/// Unix global `/bin` or a symlink into the package. +fn package_dir(bin: &Path, package: &str) -> Option { + let dir = bin.parent()?; + let mut candidates = vec![dir.join("node_modules").join(package)]; + if dir.file_name() == Some(OsStr::new(".bin")) + && let Some(node_modules) = dir.parent() + { + candidates.push(node_modules.join(package)); + } + if let Ok(real) = dunce::canonicalize(bin) { + candidates.extend(real.ancestors().filter(|a| a.ends_with(package)).map(Path::to_path_buf)); + } + if let Some(prefix) = dir.parent() { + candidates.push(prefix.join("lib").join("node_modules").join(package)); + } + candidates.into_iter().find(|c| c.join("package.json").is_file()) +} + +/// `@vue/language-server`'s directory for the `vue-language-server` command. +pub(crate) fn server_package(command: &str, path: &OsStr) -> Option { + package_dir(&find_command(command, path)?, PACKAGE) +} + +/// The TypeScript `require('typescript')` resolves to from `package`, when usable. +pub(crate) fn own_typescript(package: &Path) -> Option { + package.ancestors().map(|a| a.join("node_modules").join("typescript").join("lib")).find(|l| has_ts_api(l)) +} + +/// vtsls `globalPlugins` entry loading the Vue plugin shipped with `package`. +pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value { + serde_json::json!({ + "name": PLUGIN, + "location": package.to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }) +} + +pub(crate) fn missing_typescript_message() -> String { + "Vue Language Server needs TypeScript 5 or 6 (TypeScript 7 has no language service API). \ + Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@\"<7\" \ + to the project, or pick a TypeScript version in Settings › Languages & Frameworks › TypeScript." + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, "{}").unwrap(); + } + + #[test] + fn finds_the_package_of_every_npm_install_layout() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + // (executable, package.json) per layout. + let layouts = [ + // The IDE's tools dir / a project: node_modules/.bin shim. + ("tools/node_modules/.bin/vue-language-server", "tools/node_modules/@vue/language-server/package.json"), + // Windows global: %APPDATA%\npm\vue-language-server(.cmd) + %APPDATA%\npm\node_modules. + ("npm/vue-language-server", "npm/node_modules/@vue/language-server/package.json"), + // Unix global prefix: /bin + /lib/node_modules. + ("prefix/bin/vue-language-server", "prefix/lib/node_modules/@vue/language-server/package.json"), + ]; + for (bin, pkg) in layouts { + touch(&t.join(bin)); + touch(&t.join(pkg)); + let found = server_package(&t.join(bin).to_string_lossy(), OsStr::new("")); + assert_eq!(found, Some(t.join(pkg).parent().unwrap().to_path_buf()), "{bin}"); + } + let path = std::env::join_paths([t.join("tools/node_modules/.bin")]).unwrap(); + assert_eq!(server_package(SERVER, &path), Some(t.join("tools/node_modules/@vue/language-server"))); + assert_eq!(server_package("not-installed", &path), None); + } + + #[test] + fn own_typescript_skips_typescript_7() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("node_modules/@vue/language-server"); + touch(&pkg.join("package.json")); + // TypeScript 7 ships only `tsc`. + touch(&tmp.path().join("node_modules/typescript/lib/tsc.js")); + assert_eq!(own_typescript(&pkg), None); + touch(&tmp.path().join("node_modules/typescript/lib/typescript.js")); + assert_eq!(own_typescript(&pkg), Some(tmp.path().join("node_modules/typescript/lib"))); + } +} diff --git a/crates/ide-spell/Cargo.toml b/crates/ide-spell/Cargo.toml index 119493098..5fe6a9d01 100644 --- a/crates/ide-spell/Cargo.toml +++ b/crates/ide-spell/Cargo.toml @@ -13,3 +13,6 @@ tracing.workspace = true spellbook = "0.4" serde.workspace = true specta.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index 0a92977c9..b67a860cd 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -461,3 +461,45 @@ workspaces xml yaml yml +# Python (IDEA's Python plugin bundles a similar dictionary) +asyncio +awaitable +classmethod +contextmanager +dataclass +dataclasses +dtype +dtypes +dunder +elif +kwarg +ndarray +nonlocal +numpy +pydantic +pyproject +pytest +setuptools +staticmethod +venv +virtualenv +# Services and infrastructure +checkpointing +dockerfile +embeddings +healthcheck +hotfix +kubectl +kubernetes +llm +llms +mysql +postgres +postgresql +rollout +subnet +upsert +webhook +webhooks +websocket +websockets diff --git a/crates/ide-spell/src/lib.rs b/crates/ide-spell/src/lib.rs index f8cb34e08..0ac12038b 100644 --- a/crates/ide-spell/src/lib.rs +++ b/crates/ide-spell/src/lib.rs @@ -2,6 +2,8 @@ //! comments, string literals and declared names (regions come from the //! syntax layer), checked against the bundled en_US Hunspell dictionary, a //! list of programming terms and the user's accepted words and dictionaries. +//! Regular agent nouns of known words (`checkpointer`) pass everywhere; in +//! identifiers so do closed compounds of known words (`codebase`). mod words; @@ -35,12 +37,62 @@ pub struct Region { pub kind: RegionKind, } +/// What a typo is part of: correcting one in `Code` (a declared name) must +/// rename the symbol; the others may replace the word in place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum TypoContext { + Code, + Literal, + Comment, + Prose, +} + +impl From for TypoContext { + fn from(kind: RegionKind) -> Self { + match kind { + RegionKind::Name => Self::Code, + RegionKind::String => Self::Literal, + RegionKind::Comment => Self::Comment, + RegionKind::Prose => Self::Prose, + } + } +} + /// A misspelt word. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct Typo { /// UTF-16 range of the word. pub range: Range, pub word: String, + pub context: TypoContext, +} + +/// The Typo inspection's options (IDEA's "Process code / literals / comments"); +/// plain text and Markdown are always checked. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SpellScope { + pub code: bool, + pub literals: bool, + pub comments: bool, +} + +impl Default for SpellScope { + fn default() -> Self { + Self { code: true, literals: true, comments: true } + } +} + +impl SpellScope { + pub fn includes(&self, kind: RegionKind) -> bool { + match kind { + RegionKind::Name => self.code, + RegionKind::String => self.literals, + RegionKind::Comment => self.comments, + RegionKind::Prose => true, + } + } } /// At most this many typos are reported per text. @@ -55,6 +107,50 @@ fn english() -> &'static spellbook::Dictionary { }) } +/// Lowercase entries of the dictionary as listed (no affixes applied, no proper nouns). +fn lemmas() -> &'static HashSet<&'static str> { + static LEMMAS: OnceLock> = OnceLock::new(); + LEMMAS.get_or_init(|| { + include_str!("dict/en_US.dic") + .lines() + .skip(1) + .map(|l| l.split('/').next().unwrap_or(l).trim()) + .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase())) + .collect() + }) +} + +/// Common misspellings made of two words; never accepted as compounds. +const NOT_COMPOUNDS: &[&str] = &["eachother", "everytime", "publically", "aswell", "infact", "atleast", "alot"]; + +/// Shortest dictionary word in a derivation or compound (programming terms: 3). +const MIN_PART: usize = 4; + +/// Whether English doubles the final consonant before `-er` (`program` → +/// `programmer`): the stem ends consonant, single vowel, consonant (not w/x/y). +fn doubles_final_consonant(stem: &str) -> bool { + let vowel = |c: u8| b"aeiou".contains(&c); + match stem.as_bytes() { + [.., a, b, c] => !vowel(*a) && vowel(*b) && !vowel(*c) && !b"wxy".contains(c), + _ => false, + } +} + +/// Whether dropping one letter or swapping two adjacent ones gives a dictionary +/// word: then a compound (`writ`+`ting`) is more likely a typo of +/// it (`writing`), as Hunspell's CHECKCOMPOUNDREP reasons. +fn near_word(lower: &str) -> bool { + let b = lower.as_bytes(); + let dict = english(); + let check = |v: &[u8]| std::str::from_utf8(v).is_ok_and(|w| dict.check(w)); + (0..b.len()).any(|i| check(&[&b[..i], &b[i + 1..]].concat())) + || (0..b.len().saturating_sub(1)).any(|i| { + let mut v = b.to_vec(); + v.swap(i, i + 1); + v != b && check(&v) + }) +} + fn software_terms() -> &'static HashSet<&'static str> { static TERMS: OnceLock> = OnceLock::new(); TERMS.get_or_init(|| { @@ -118,23 +214,71 @@ impl Speller { } } - /// Whether `word` is spelt correctly. Case is forgiven the way identifiers - /// need it: `url` passes for `URL`, `paris` for `Paris`. + /// Whether `word` (of prose, a comment or a string) is spelt correctly. + /// Case is forgiven the way identifiers need it: `url` passes for `URL`, + /// `paris` for `Paris`. Regular agent nouns of known words pass too + /// (`checkpointer`, `serializers`). pub fn is_correct(&self, word: &str) -> bool { let lower = word.to_lowercase(); - if self.accepted.contains(&lower) - || software_terms().contains(lower.as_str()) - || self.custom.iter().any(|d| d.contains(&lower)) - { + self.listed(word, &lower) || self.agent_noun(&lower) + } + + /// [`Self::is_correct`] for a part of an identifier, which may also be two + /// known words run together (`codebase`, `urlpath`), as identifiers often are. + pub fn is_correct_identifier(&self, word: &str) -> bool { + self.is_correct(word) || self.compound(&word.to_lowercase()) + } + + fn user_word(&self, lower: &str) -> bool { + self.accepted.contains(lower) + || software_terms().contains(lower) + || self.custom.iter().any(|d| d.contains(lower)) + } + + /// In a dictionary, in any case style. + fn listed(&self, word: &str, lower: &str) -> bool { + if self.user_word(lower) { return true; } let dict = english(); - if dict.check(word) || dict.check(&lower) { - return true; + dict.check(word) || dict.check(lower) || dict.check(&capitalize(lower)) || dict.check(&lower.to_uppercase()) + } + + /// `er(s)` / `r(s)` of a listed stem: `checkpointer` + /// (checkpoint), `serializers` (serialize), `queriers` (query). Not when + /// English doubles the final consonant (`programer`) or the `-or` noun is + /// the word (`processer`, `generater`). + fn agent_noun(&self, lower: &str) -> bool { + if !lower.bytes().all(|c| c.is_ascii_lowercase()) { + return false; } - let mut chars = lower.chars(); - let capitalized: String = chars.next().into_iter().flat_map(char::to_uppercase).chain(chars).collect(); - dict.check(&capitalized) || dict.check(&lower.to_uppercase()) + let singular = lower.strip_suffix('s').unwrap_or(lower); + let Some(before_r) = singular.strip_suffix('r') else { return false }; + let Some(before_er) = before_r.strip_suffix('e') else { return false }; + // (stem, root the `-or` noun would be built on) + let mut stems = vec![(before_r.to_string(), before_er)]; + if let Some(y) = before_er.strip_suffix('i') { + stems.push((format!("{y}y"), before_er)); + } else if !before_er.ends_with('e') && !doubles_final_consonant(before_er) { + stems.push((before_er.to_string(), before_er)); + } + stems.into_iter().any(|(stem, root)| { + stem.len() >= MIN_PART + && (lemmas().contains(stem.as_str()) || self.user_word(&stem)) + && !english().check(&format!("{root}or")) + }) + } + + /// Two known words run together, each at least [`MIN_PART`] letters (or a + /// 3-letter programming term: `urlpath`). Proper nouns don't count. + fn compound(&self, lower: &str) -> bool { + if lower.len() < 6 || !lower.bytes().all(|c| c.is_ascii_lowercase()) || NOT_COMPOUNDS.contains(&lower) { + return false; + } + let part = |p: &str| { + (p.len() >= 3 && self.user_word(p)) || (p.len() >= MIN_PART && (english().check(p) || self.agent_noun(p))) + }; + (3..=lower.len() - 3).any(|i| part(&lower[..i]) && part(&lower[i..])) && !near_word(lower) } /// Replacements for `word`, best first, in the word's case style. @@ -155,19 +299,22 @@ impl Speller { /// `cancelled` fires. pub fn check(&self, text: &str, regions: &[Region], cancelled: &dyn Fn() -> bool) -> Option> { let mut spans = Vec::new(); - let mut verdicts: HashMap<&str, bool> = HashMap::new(); - let mut bad: Vec = Vec::new(); + let mut verdicts: HashMap<(&str, bool), bool> = HashMap::new(); + let mut bad: Vec<(WordSpan, RegionKind)> = Vec::new(); for (i, r) in regions.iter().enumerate() { if i % 64 == 0 && cancelled() { return None; } spans.clear(); words(text, r.start, r.end, r.kind, &mut spans); + let name = r.kind == RegionKind::Name; for w in &spans { let word = &text[w.start..w.end]; - let ok = *verdicts.entry(word).or_insert_with(|| self.is_correct(word)); + let ok = *verdicts + .entry((word, name)) + .or_insert_with(|| if name { self.is_correct_identifier(word) } else { self.is_correct(word) }); if !ok { - bad.push(*w); + bad.push((*w, r.kind)); } } if bad.len() >= MAX_TYPOS { @@ -175,14 +322,15 @@ impl Speller { break; } } - bad.sort_by_key(|w| w.start); - bad.dedup(); + bad.sort_by_key(|(w, _)| w.start); + bad.dedup_by_key(|(w, _)| *w); let mut index = Utf16Walker::new(text); Some( bad.into_iter() - .map(|w| Typo { + .map(|(w, kind)| Typo { range: Range::new(index.pos(w.start), index.pos(w.end)), word: text[w.start..w.end].to_string(), + context: kind.into(), }) .collect(), ) @@ -291,6 +439,118 @@ mod tests { assert!(!s.is_correct("lenght")); } + #[test] + fn agent_nouns_of_known_words() { + let s = Speller::default(); + for ok in ["checkpointer", "Checkpointers", "serializer", "queriers", "tokenizer", "serializers", "pointer"] { + assert!(s.is_correct(ok), "{ok}"); + } + // Real misspellings of the same shape stay flagged. + for bad in ["programer", "processer", "generater", "writter", "mesager", "begginer"] { + assert!(!s.is_correct(bad), "{bad}"); + } + } + + #[test] + fn compounds_only_in_identifiers() { + let s = Speller::default(); + for ok in ["filepath", "dataframe", "username", "codebase", "pathname", "urlpath", "timestampfile"] { + assert!(s.is_correct_identifier(ok), "{ok}"); + } + assert!(!s.is_correct("dataframe")); + for bad in ["eachother", "everytime", "publically", "mesage", "lenght", "recieve", "comming", "withold"] { + assert!(!s.is_correct_identifier(bad), "{bad}"); + } + } + + /// Common English misspellings must survive the derivation and compound rules. + #[test] + fn common_misspellings_stay_flagged() { + let s = Speller::default(); + let typos = [ + "accomodate", + "acheive", + "accross", + "adress", + "arguement", + "becuase", + "beleive", + "begining", + "calender", + "comming", + "commited", + "concious", + "definately", + "dependancy", + "enviroment", + "existance", + "explaination", + "fucntion", + "funtion", + "goverment", + "heigth", + "independant", + "intial", + "lenght", + "maintainance", + "mesage", + "neccessary", + "occured", + "occurence", + "paramter", + "persistant", + "posession", + "programer", + "recieve", + "refrence", + "relevent", + "retreive", + "seperate", + "seperator", + "succesful", + "tommorow", + "truely", + "untill", + "wich", + "widht", + "wierd", + "writting", + ]; + let accepted: Vec<_> = typos + .iter() + .filter(|t| s.is_correct(t) || s.is_correct_identifier(t)) + .filter(|t| **t != "calender") + .collect(); + assert!(accepted.is_empty(), "accepted: {accepted:?}"); + } + + #[test] + fn issue_42_python_example() { + let s = Speller::default(); + let text = "# Initialize the checkpointer connection pool.\ncheckpointer_pool = None\nmemory_checkpointer = None\nmesage = 'hello'\n"; + let regions = vec![ + Region { start: 0, end: 46, kind: RegionKind::Comment }, + Region { start: 47, end: 64, kind: RegionKind::Name }, + Region { start: 72, end: 91, kind: RegionKind::Name }, + Region { start: 99, end: 105, kind: RegionKind::Name }, + ]; + assert_eq!(&text[99..105], "mesage"); + let typos = s.check(text, ®ions, &|| false).unwrap(); + let found: Vec<_> = typos.iter().map(|t| (t.word.as_str(), t.context)).collect(); + assert_eq!(found, [("mesage", TypoContext::Code)]); + } + + #[test] + fn scope_filters_region_kinds() { + let comments_only = SpellScope { code: false, literals: false, comments: true }; + assert!(comments_only.includes(RegionKind::Comment)); + assert!(comments_only.includes(RegionKind::Prose)); + assert!(!comments_only.includes(RegionKind::Name)); + assert!(!comments_only.includes(RegionKind::String)); + let parsed: SpellScope = serde_json::from_str(r#"{"literals":false}"#).unwrap(); + assert_eq!(parsed, SpellScope { code: true, literals: false, comments: true }); + } + #[test] fn accepted_words_and_custom_dictionaries() { let dir = std::env::temp_dir().join(format!("ide-spell-{}", std::process::id())); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs new file mode 100644 index 000000000..d93212f6d --- /dev/null +++ b/crates/ide-tools/src/jdk.rs @@ -0,0 +1,477 @@ +//! JDK downloads (IDEA's Download JDK): Eclipse Temurin builds from the +//! Adoptium API, unpacked into `/jdks/-` (or a chosen +//! location) where JDK discovery finds them. Parsing is pure so it is testable +//! without network. + +use crate::platform::{Arch, Os, Platform}; +use crate::process::{Emit, Http}; +use crate::sources::Download; +use crate::{InstallEvent, InstallGuard, Tools, archive, clear_quarantine, fetch_verified}; +use ide_core::error::ErrorKind; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; + +const ADOPTIUM_API: &str = "https://api.adoptium.net/v3"; + +/// A JDK distribution the IDE can download. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JdkVendor { + pub id: String, + pub name: String, +} + +pub const TEMURIN: &str = "temurin"; + +pub fn vendors() -> Vec { + vec![JdkVendor { id: TEMURIN.into(), name: "Eclipse Temurin".into() }] +} + +/// Java versions a vendor publishes. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JdkReleases { + /// Feature versions, newest first. + pub versions: Vec, + /// The long-term-support ones among them. + pub lts: Vec, + /// The version offered first (the newest LTS). + pub recommended: u32, +} + +/// A JDK the IDE downloaded. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DownloadedJdk { + pub home: ide_core::IpcPath, + /// Full version, e.g. `25.0.1`. + pub version: String, + pub major: u32, +} + +/// Where downloaded JDKs go by default. +pub fn jdks_dir(app_data: &Path) -> PathBuf { + crate::tools_dir(app_data).join("jdks") +} + +fn proto(msg: impl Into) -> IdeError { + IdeError::new(ErrorKind::Protocol, msg) +} + +fn vendor_or_err(vendor: &str) -> IdeResult<()> { + if vendor == TEMURIN { Ok(()) } else { Err(IdeError::invalid(format!("unknown JDK vendor `{vendor}`"))) } +} + +pub fn adoptium_releases_url() -> String { + format!("{ADOPTIUM_API}/info/available_releases") +} + +#[derive(Deserialize)] +struct AvailableReleases { + available_releases: Vec, + #[serde(default)] + available_lts_releases: Vec, + #[serde(default)] + most_recent_lts: Option, +} + +pub fn parse_releases(json: &str) -> IdeResult { + let r: AvailableReleases = + serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; + let mut versions = r.available_releases; + versions.sort_unstable_by(|a, b| b.cmp(a)); + versions.dedup(); + let recommended = r + .most_recent_lts + .or_else(|| r.available_lts_releases.iter().copied().max()) + .or_else(|| versions.first().copied()) + .ok_or_else(|| proto("Adoptium lists no Java versions"))?; + Ok(JdkReleases { versions, lts: r.available_lts_releases, recommended }) +} + +fn adoptium_os(os: Os) -> &'static str { + match os { + Os::Mac => "mac", + Os::Linux => "linux", + Os::Windows => "windows", + } +} + +fn adoptium_arch(arch: Arch) -> &'static str { + match arch { + Arch::X64 => "x64", + Arch::Arm64 => "aarch64", + } +} + +/// The latest Temurin JDK build of Java `feature` for `platform`. +pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String { + format!( + "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type=jdk&os={}&vendor=eclipse", + adoptium_arch(platform.arch), + adoptium_os(platform.os), + ) +} + +#[derive(Deserialize)] +struct Asset { + binary: Binary, + version: AssetVersion, +} + +#[derive(Deserialize)] +struct Binary { + #[serde(default)] + image_type: Option, + #[serde(default)] + heap_size: Option, + #[serde(default)] + os: Option, + package: Package, +} + +#[derive(Deserialize)] +struct Package { + name: String, + link: String, + #[serde(default)] + checksum: Option, + #[serde(default)] + size: Option, +} + +#[derive(Deserialize)] +struct AssetVersion { + major: u32, + #[serde(default)] + minor: u32, + #[serde(default)] + security: u32, + #[serde(default)] + openjdk_version: Option, +} + +impl AssetVersion { + /// `25.0.1` (`openjdk_version` without its build; `1.8.0_472` style for Java 8). + fn display(&self) -> String { + let plain = || format!("{}.{}.{}", self.major, self.minor, self.security); + match &self.openjdk_version { + Some(v) => { + let v = v.split(['+', '-']).next().unwrap_or(v); + if v.is_empty() { plain() } else { v.to_owned() } + } + None => plain(), + } + } +} + +/// 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> { + let assets: Vec = + 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.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) + }); + Ok(found.map(|a| { + let sha256 = a.binary.package.checksum.map(|c| c.trim().to_ascii_lowercase()).filter(|c| c.len() == 64); + ( + Download { + version: a.version.display(), + name: a.binary.package.name, + url: a.binary.package.link, + size: a.binary.package.size, + sha256, + checksum_url: None, + }, + a.version.major, + ) + })) +} + +/// Folder name of a downloaded JDK, as IDEA names them: `temurin-25.0.1`. +pub fn jdk_dir_name(vendor: &str, version: &str) -> String { + let safe: String = + version.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_') { c } else { '-' }).collect(); + format!("{vendor}-{safe}") +} + +fn is_jdk_home(dir: &Path, platform: Platform) -> bool { + dir.join("release").is_file() && dir.join("bin").join(platform.exe("java")).is_file() +} + +/// The JDK home inside an unpacked JDK: `dir` itself, `dir/Contents/Home` +/// (a macOS bundle), or either of those below one top-level folder +/// (`jdk-25.0.1+8/`, `jdk-25.0.1+8/Contents/Home`). +pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option { + let candidates = |d: &Path| [d.to_path_buf(), d.join("Contents").join("Home")]; + if let Some(home) = candidates(dir).into_iter().find(|h| is_jdk_home(h, platform)) { + return Some(home); + } + let mut subdirs: Vec = std::fs::read_dir(dir) + .ok()? + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.path()) + .collect(); + subdirs.sort(); + subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform)) +} + +/// 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 { + match home.strip_prefix(pkg).ok().and_then(|rel| rel.components().next()) { + Some(first) if first.as_os_str() != "Contents" => pkg.join(first), + _ => pkg.to_path_buf(), + } +} + +impl Tools { + fn http(&self) -> IdeResult { + Http::new(&self.system_path) + } + + /// Java versions `vendor` publishes. + pub fn jdk_releases(&self, vendor: &str, cancel: &CancellationToken) -> IdeResult { + vendor_or_err(vendor)?; + parse_releases(&self.http()?.get_text(&adoptium_releases_url(), cancel)?) + } + + /// Downloads the latest `vendor` JDK of Java `feature` into + /// `/-` (`location` defaults to the tools' `jdks` + /// folder), verifying its SHA-256. A JDK already there is reused. + pub fn download_jdk( + &self, + vendor: &str, + feature: u32, + location: Option<&Path>, + cancel: &CancellationToken, + emit: Emit, + ) -> IdeResult { + vendor_or_err(vendor)?; + let _guard = InstallGuard::acquire(&format!("jdk-{vendor}-{feature}"))?; + 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)?; + if picked.is_none() && self.platform.os == Os::Mac && self.platform.arch == Arch::Arm64 { + // Java 8 has no Apple Silicon build: the Intel one runs under Rosetta. + let intel = Platform { os: Os::Mac, arch: Arch::X64 }; + picked = adoptium_pick(&http.get_text(&adoptium_assets_url(feature, intel), cancel)?, intel)?; + } + let (dl, major) = picked.ok_or_else(|| { + IdeError::unavailable(format!( + "Eclipse Temurin {feature} has no build for {:?} {:?}", + self.platform.os, self.platform.arch + )) + })?; + 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) = find_jdk_home(&target, self.platform) { + emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) }); + return Ok(done(home)); + } + emit(InstallEvent::Line { text: format!("Downloading {} ({})", dl.name, dl.url) }); + std::fs::create_dir_all(&location)?; + // Next to the target, so moving it into place never crosses file systems. + let staging = location.join(format!(".staging-{}", jdk_dir_name(vendor, &dl.version))); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging)?; + let result = (|| { + let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?; + emit(InstallEvent::Line { text: "Extracting…".into() }); + let pkg = staging.join("pkg"); + archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java")?; + let home = find_jdk_home(&pkg, self.platform) + .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?; + if target.exists() { + std::fs::remove_dir_all(&target)?; + } + std::fs::rename(unpacked_root(&pkg, &home), &target)?; + clear_quarantine(&target); + 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); + let home = result?; + emit(InstallEvent::Line { text: format!("Eclipse Temurin {} installed in {}", dl.version, home.display()) }); + Ok(done(home)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC_ARM: Platform = Platform { os: Os::Mac, arch: Arch::Arm64 }; + const LINUX_X64: Platform = Platform { os: Os::Linux, arch: Arch::X64 }; + const WIN_X64: Platform = Platform { os: Os::Windows, arch: Arch::X64 }; + + #[test] + fn releases() { + let json = r#"{"available_lts_releases":[8,11,17,21,25],"available_releases":[8,11,16,17,21,24,25,26], + "most_recent_feature_release":26,"most_recent_lts":25,"tip_version":27}"#; + let r = parse_releases(json).unwrap(); + assert_eq!(r.versions, [26, 25, 24, 21, 17, 16, 11, 8]); + assert_eq!(r.lts, [8, 11, 17, 21, 25]); + assert_eq!(r.recommended, 25); + assert!(parse_releases("{}").is_err()); + } + + #[test] + fn asset_urls() { + assert_eq!( + adoptium_assets_url(25, MAC_ARM), + "https://api.adoptium.net/v3/assets/latest/25/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse" + ); + assert_eq!( + adoptium_assets_url(21, WIN_X64), + "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse" + ); + assert!(adoptium_assets_url(17, LINUX_X64).contains("os=linux")); + } + + fn asset(name: &str, os: &str, heap: &str, checksum: &str) -> String { + format!( + r#"{{"binary":{{"architecture":"x64","heap_size":"{heap}","image_type":"jdk","os":"{os}", + "installer":{{"name":"x.msi","link":"https://e/x.msi","checksum":"00"}}, + "package":{{"name":"{name}","link":"https://e/{name}","checksum":"{checksum}","size":1234}}}}, + "release_name":"jdk-25.0.1+8","vendor":"eclipse", + "version":{{"major":25,"minor":0,"security":1,"build":8,"openjdk_version":"25.0.1+8-LTS"}}}}"# + ) + } + + #[test] + fn picks_the_platform_archive() { + let sum = "36DC14D797AAFF6D89AAB302CD053FDBA6207DFDCF772052FA7B023A94B0E857"; + let json = format!( + "[{},{},{}]", + asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "large", sum), + asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "normal", sum), + asset("OpenJDK25U-jdk_x64_linux_hotspot_25.0.1_8.tar.gz", "linux", "normal", sum), + ); + let (dl, major) = adoptium_pick(&json, WIN_X64).unwrap().unwrap(); + assert_eq!(dl.name, "OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip"); + assert_eq!(dl.url, "https://e/OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip"); + assert_eq!(dl.sha256.as_deref(), Some(sum.to_ascii_lowercase().as_str())); + assert_eq!((dl.version.as_str(), major, dl.size), ("25.0.1", 25, Some(1234))); + let (dl, _) = adoptium_pick(&json, LINUX_X64).unwrap().unwrap(); + assert!(dl.name.ends_with(".tar.gz")); + // No build for the platform (Java 8 on Apple Silicon): an empty list. + assert_eq!(adoptium_pick("[]", MAC_ARM).unwrap(), None); + assert_eq!(adoptium_pick(&json, MAC_ARM).unwrap(), None); + assert!(adoptium_pick("{\"errorMessage\":\"x\"}", MAC_ARM).is_err()); + } + + #[test] + fn java_8_versions_and_dir_names() { + let v = AssetVersion { major: 8, minor: 0, security: 472, openjdk_version: Some("1.8.0_472-b08".into()) }; + assert_eq!(v.display(), "1.8.0_472"); + let v = AssetVersion { major: 21, minor: 0, security: 9, openjdk_version: None }; + assert_eq!(v.display(), "21.0.9"); + assert_eq!(jdk_dir_name(TEMURIN, "25.0.1"), "temurin-25.0.1"); + assert_eq!(jdk_dir_name(TEMURIN, "1.8.0_472"), "temurin-1.8.0_472"); + } + + fn fake_jdk(home: &Path, platform: Platform) { + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(home.join("bin").join(platform.exe("java")), "").unwrap(); + } + + #[test] + fn finds_the_home_of_each_archive_layout() { + let tmp = tempfile::tempdir().unwrap(); + // macOS: jdk-25.0.1+8/Contents/Home + let mac = tmp.path().join("mac"); + fake_jdk(&mac.join("jdk-25.0.1+8/Contents/Home"), MAC_ARM); + std::fs::create_dir_all(mac.join("jdk-25.0.1+8/Contents/MacOS")).unwrap(); + let home = find_jdk_home(&mac, MAC_ARM).unwrap(); + assert_eq!(home, mac.join("jdk-25.0.1+8/Contents/Home")); + assert_eq!(unpacked_root(&mac, &home), mac.join("jdk-25.0.1+8")); + // Once moved into place, the bundle itself. + assert_eq!(find_jdk_home(&mac.join("jdk-25.0.1+8"), MAC_ARM).unwrap(), mac.join("jdk-25.0.1+8/Contents/Home")); + // Linux tar.gz / Windows zip: jdk-25.0.1+8/bin/java[.exe] + for platform in [LINUX_X64, WIN_X64] { + let dir = tmp.path().join(format!("{:?}", platform.os)); + fake_jdk(&dir.join("jdk-25.0.1+8"), platform); + let home = find_jdk_home(&dir, platform).unwrap(); + assert_eq!(home, dir.join("jdk-25.0.1+8")); + assert_eq!(unpacked_root(&dir, &home), dir.join("jdk-25.0.1+8")); + } + // Without a top-level folder, everything unpacked is the JDK. + let flat = tmp.path().join("flat"); + fake_jdk(&flat, LINUX_X64); + assert_eq!(find_jdk_home(&flat, LINUX_X64).unwrap(), flat); + assert_eq!(unpacked_root(&flat, &flat), flat); + let bundle = tmp.path().join("bundle"); + fake_jdk(&bundle.join("Contents/Home"), MAC_ARM); + assert_eq!(unpacked_root(&bundle, &bundle.join("Contents/Home")), bundle); + // A Windows layout isn't a JDK for Linux (no `bin/java`), nor is a folder without `release`. + assert_eq!(find_jdk_home(&tmp.path().join("Windows"), LINUX_X64), None); + std::fs::create_dir_all(tmp.path().join("norelease/bin")).unwrap(); + std::fs::write(tmp.path().join("norelease/bin/java"), "").unwrap(); + assert_eq!(find_jdk_home(&tmp.path().join("norelease"), LINUX_X64), None); + } + + #[test] + fn unknown_vendors_are_refused() { + let tools = Tools::with_paths(PathBuf::from("/nonexistent"), Default::default(), LINUX_X64); + let err = tools.download_jdk("acme", 25, None, &CancellationToken::new(), &|_| {}).unwrap_err(); + assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}"); + } + + /// Downloads a real JDK from Adoptium: `OPENIDEA_LIVE_JDK=25 cargo nextest run -p ide-tools live_download`; + /// `OPENIDEA_LIVE_JDK_OS=windows|linux` fetches and unpacks another platform's archive (not run). + #[test] + fn live_download() { + let Some(feature) = std::env::var("OPENIDEA_LIVE_JDK").ok().and_then(|v| v.parse::().ok()) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let other = match std::env::var("OPENIDEA_LIVE_JDK_OS").as_deref() { + Ok("windows") => Some(WIN_X64), + Ok("linux") => Some(LINUX_X64), + _ => None, + }; + if let Some(platform) = other { + let tools = Tools::with_paths(crate::tools_dir(tmp.path()), crate::default_search_path(), platform); + let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); + let home = jdk.home.into_path_buf(); + eprintln!("{}", home.display()); + assert!(home.join("bin").join(platform.exe("java")).is_file()); + assert_eq!(home.parent(), Some(jdks_dir(tmp.path()).as_path()), "no Contents/Home outside macOS"); + return; + } + let tools = Tools::new(tmp.path()); + let releases = tools.jdk_releases(TEMURIN, &CancellationToken::new()).unwrap(); + assert!(releases.versions.contains(&feature), "{releases:?}"); + let emit = |e: InstallEvent| { + if let InstallEvent::Line { text } = e { + eprintln!("{text}"); + } + }; + let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &emit).unwrap(); + let home = jdk.home.clone().into_path_buf(); + assert!(home.starts_with(jdks_dir(tmp.path())), "{}", home.display()); + let out = std::process::Command::new(home.join("bin").join(Platform::current().exe("java"))) + .arg("-version") + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stderr); + eprintln!("{text}"); + assert!(out.status.success() && text.contains("Temurin"), "{text}"); + // Downloading again reuses it. + let again = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(again.home, jdk.home); + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 1b1ec4a57..3e00a6cd6 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -10,6 +10,7 @@ //! [`tools_bin_dirs`] on the process `PATH` at startup. pub mod archive; +pub mod jdk; pub mod platform; mod process; pub mod registry; @@ -90,6 +91,14 @@ struct Manifest { const MANIFEST: &str = ".openidea-tool.json"; +/// `typescript@<7` → `typescript`, `@vue/language-server` unchanged. +fn npm_name(spec: &str) -> &str { + match spec[1..].find('@') { + Some(i) => &spec[..i + 1], + None => spec, + } +} + pub fn tools_dir(app_data: &Path) -> PathBuf { app_data.join("tools") } @@ -349,6 +358,17 @@ impl Tools { json.get("version")?.as_str().map(String::from) } + /// Whether an installed npm tool other than `tool` also installs `package`. + fn npm_dependency_in_use(&self, tool: &str, package: &str) -> bool { + TOOLS.iter().any(|t| match t.method { + Method::Npm { packages } if t.name != tool => { + packages.iter().skip(1).any(|p| npm_name(p) == package) + && self.npm_package_version(npm_name(packages[0])).is_some() + } + _ => false, + }) + } + fn query_version(&self, spec: &ToolSpec, found: &Path) -> Option { let (cmd, args) = spec.version?; let sibling = found.parent().and_then(|d| self.exe_in(d, cmd)); @@ -518,7 +538,7 @@ impl Tools { let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); if let Some(path) = managed { let version = match spec.method { - Method::Npm { packages } => self.npm_package_version(packages[0]), + Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), _ => self.manifest(spec.name).and_then(|m| m.version), } .or_else(|| self.query_version(spec, &path)); @@ -644,7 +664,7 @@ impl Tools { let (manager, pnpm) = self.npm_manager()?; self.ensure_package_json()?; let mut c = self.command(manager); - let specs = packages.iter().map(|p| format!("{p}@latest")); + let specs = packages.iter().map(|p| if npm_name(p) == *p { format!("{p}@latest") } else { p.to_string() }); if pnpm { c.arg("add").arg("--dir").arg(&self.root).args(specs); } else { @@ -929,33 +949,7 @@ impl Tools { cancel: &CancellationToken, emit: Emit, ) -> IdeResult<()> { - let archive = staging.join(&dl.name); - http.download(&dl.url, &archive, dl.size, cancel, emit)?; - let expected = match (&dl.sha256, &dl.checksum_url) { - (Some(h), _) => Some(h.clone()), - (None, Some(url)) => match http.get_text(url, cancel) { - Ok(text) => sources::parse_checksum(&text, &dl.name), - Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), - Err(_) => None, - }, - (None, None) => None, - }; - match expected { - Some(expected) => { - let actual = sources::sha256_file(&archive)?; - if actual != expected { - return Err(IdeError::new( - ErrorKind::Io, - format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name), - )); - } - emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() }); - } - None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }), - } - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } + let archive = fetch_verified(http, dl, staging, cancel, emit)?; emit(InstallEvent::Line { text: "Extracting…".into() }); let pkg = staging.join("pkg"); let kind = archive::ArchiveKind::from_name(&dl.name); @@ -999,11 +993,8 @@ impl Tools { archive::link_executable(&bin, main, &target)?; } } - #[cfg(target_os = "macos")] - { - // Downloads made by the IDE itself are not quarantined, but archives may carry the flag. - let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(&final_dir).output(); - } + // Downloads made by the IDE itself are not quarantined, but archives may carry the flag. + clear_quarantine(&final_dir); self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) }) } @@ -1038,12 +1029,19 @@ impl Tools { } Method::Npm { packages } => { if self.root.join("node_modules").exists() { + // Shared dependencies (`typescript`) stay while another installed tool needs them. + let packages: Vec<&str> = packages + .iter() + .enumerate() + .filter(|(i, p)| *i == 0 || !self.npm_dependency_in_use(spec.name, npm_name(p))) + .map(|(_, p)| npm_name(p)) + .collect(); let (manager, pnpm) = self.npm_manager()?; let mut c = self.command(manager); if pnpm { - c.arg("remove").arg("--dir").arg(&self.root).args(packages); + c.arg("remove").arg("--dir").arg(&self.root).args(&packages); } else { - c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(packages); + c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(&packages); } c.current_dir(&self.root); run_streamed(c, &cancel, &quiet)?; @@ -1065,5 +1063,54 @@ impl Tools { } } +/// Downloads `dl` into `staging` and checks its SHA-256 when one is published; +/// returns the downloaded file. +fn fetch_verified( + http: &Http, + dl: &sources::Download, + staging: &Path, + cancel: &CancellationToken, + emit: Emit, +) -> IdeResult { + let archive = staging.join(&dl.name); + http.download(&dl.url, &archive, dl.size, cancel, emit)?; + let expected = match (&dl.sha256, &dl.checksum_url) { + (Some(h), _) => Some(h.clone()), + (None, Some(url)) => match http.get_text(url, cancel) { + Ok(text) => sources::parse_checksum(&text, &dl.name), + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(_) => None, + }, + (None, None) => None, + }; + match expected { + Some(expected) => { + let actual = sources::sha256_file(&archive)?; + if actual != expected { + return Err(IdeError::new( + ErrorKind::Io, + format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name), + )); + } + emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() }); + } + None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }), + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + Ok(archive) +} + +/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked. +fn clear_quarantine(dir: &Path) { + #[cfg(target_os = "macos")] + { + let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(dir).output(); + } + #[cfg(not(target_os = "macos"))] + let _ = dir; +} + #[cfg(test)] mod tests; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index bd25e806f..7ca612b8b 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -112,6 +112,10 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] Some((cmd, &["--version"])) } +/// The TypeScript these npm servers load as a library (`tsserver.js`, `ts.server`): +/// TypeScript 7 (the native port) ships neither, and `@latest` resolves to it. +pub const TYPESCRIPT: &str = "typescript@<7"; + pub static TOOLS: &[ToolSpec] = &[ ToolSpec { name: "rust-analyzer", @@ -138,7 +142,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "typescript-language-server", kind: LanguageServer, commands: &["typescript-language-server"], - method: Method::Npm { packages: &["typescript-language-server", "typescript"] }, + method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] }, version: ver("typescript-language-server"), homepage: "https://github.com/typescript-language-server/typescript-language-server", needs: &[], @@ -228,7 +232,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Svelte Language Server", kind: LanguageServer, commands: &["svelteserver"], - method: Method::Npm { packages: &["svelte-language-server"] }, + method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/sveltejs/language-tools", needs: &[], @@ -238,7 +242,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Vue Language Server", kind: LanguageServer, commands: &["vue-language-server"], - method: Method::Npm { packages: &["@vue/language-server"] }, + method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/vuejs/language-tools", needs: &[], diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 2edf2507e..b6b9b8a67 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -272,6 +272,54 @@ echo "added 1 package""#, assert!(env.root.join("package.json").exists()); } +/// Servers that load TypeScript as a library get one with its JS API: `@latest` +/// is TypeScript 7 (native, no `ts.server`), which crashed vue-language-server (#25). +/// Uninstalling one keeps the TypeScript another installed server still uses. +#[cfg(unix)] +#[test] +fn npm_typescript_is_pinned_and_shared() { + let env = Env::new(); + let log = env.root.parent().unwrap().join("npm-args.txt"); + env.script( + &env.sys, + "npm", + &format!( + r#"echo "$@" >> '{log}' +prefix=""; for a in "$@"; do [ "$prev" = "--prefix" ] && prefix="$a"; prev="$a"; done +[ "$1" = "install" ] || exit 0 +/bin/mkdir -p "$prefix/node_modules/.bin" +for a in "$@"; do case "$a" in + typescript-language-server@latest) n=typescript-language-server;; + @vue/language-server@latest) n=vue-language-server; d=@vue/language-server;; + *) continue;; +esac +d="${{d:-$n}}"; /bin/mkdir -p "$prefix/node_modules/$d" +echo '{{"version":"1.0.0"}}' > "$prefix/node_modules/$d/package.json" +printf '#!/bin/sh\necho 1.0.0\n' > "$prefix/node_modules/.bin/$n"; /bin/chmod +x "$prefix/node_modules/.bin/$n"; d="" +done"#, + log = log.display() + ), + ); + let tools = env.tools(); + for name in ["vue-language-server", "typescript-language-server"] { + assert_eq!( + tools.install(name, &langs(), &CancellationToken::new(), &|_| {}).unwrap().status, + ToolStatus::Installed + ); + } + let installs = fs::read_to_string(&log).unwrap(); + for line in installs.lines() { + assert!(line.contains("typescript@<7") && !line.contains("typescript@latest"), "{line}"); + } + assert!(installs.contains("@vue/language-server@latest"), "{installs}"); + + fs::write(&log, "").unwrap(); + tools.uninstall("vue-language-server", &langs()).unwrap(); + let uninstall = fs::read_to_string(&log).unwrap(); + assert!(uninstall.contains("uninstall") && uninstall.contains("@vue/language-server"), "{uninstall}"); + assert!(!uninstall.contains("typescript"), "typescript-language-server still needs it: {uninstall}"); +} + #[test] fn installed_bundle_is_detected_and_resolvable() { let env = Env::new(); diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 5d0c9e782..b2125fd29 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -1,7 +1,7 @@ //! Commit history (topological walk) and per-commit changed files via gitoxide. use crate::repo::{gx, resolve, to_string}; -use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo}; +use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo, RevisionChange}; use gix::ObjectId; use gix::bstr::ByteSlice; use ide_core::{IdeError, IdeResult}; @@ -423,11 +423,24 @@ fn path_entry( pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult> { let repo = r.gix()?; - let id = resolve(&repo, rev)?; + Ok(first_parent_changes(&repo, rev)?.files) +} + +struct CommitChanges { + id: ObjectId, + parent: Option, + files: Vec, +} + +/// Files changed by `rev` relative to its first parent (renames detected), sorted by path. +fn first_parent_changes(repo: &gix::Repository, rev: &str) -> IdeResult { + let id = resolve(repo, rev)?; let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; + let id = commit.id; + let parent = commit.parent_ids().next().map(|p| p.detach()); let new_tree = commit.tree().map_err(gx)?; - let old_tree = match commit.parent_ids().next() { - Some(p) => Some(repo.find_commit(p.detach()).map_err(gx)?.tree().map_err(gx)?), + let old_tree = match parent { + Some(p) => Some(repo.find_commit(p).map_err(gx)?.tree().map_err(gx)?), None => None, }; let mut opts = gix::diff::Options::default(); @@ -456,5 +469,67 @@ pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult> { out.push(file); } out.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(CommitChanges { id, parent, files: out }) +} + +/// One file's change accumulated over several commits. +struct Merged { + /// Commit before the first change and the file's path there; `None` when it was added. + before: Option<(String, String)>, + /// Commit of the last change; `None` when it was deleted. + after: Option, + path: String, +} + +/// Combined changes of `commits` (oldest first), merged like IDEA merges a multi-commit Git +/// Log selection: per file, the state before the first selected commit that touched it ↔ +/// after the last one, following renames. Files added and then deleted drop out. +pub(crate) fn commits_changes(r: &Repo, commits: &[String]) -> IdeResult> { + let repo = r.gix()?; + let mut merged: Vec = Vec::new(); + let mut seen = HashSet::new(); + for rev in commits { + let c = first_parent_changes(&repo, rev)?; + if !seen.insert(c.id) { + continue; + } + let id = c.id.to_string(); + for f in c.files { + let from = match f.kind { + ChangeKind::Renamed => f.old_path.as_deref().unwrap_or(&f.path), + _ => &f.path, + }; + // A live entry at the path this change starts from, else a deleted one being re-added. + let existing = merged + .iter() + .position(|m| m.after.is_some() && m.path == from) + .or_else(|| merged.iter().position(|m| m.after.is_none() && m.path == f.path)); + let i = existing.unwrap_or_else(|| { + let before = match (f.kind, c.parent) { + (ChangeKind::Added, _) | (_, None) => None, + (_, Some(p)) => Some((p.to_string(), from.to_owned())), + }; + merged.push(Merged { before, after: None, path: f.path.clone() }); + merged.len() - 1 + }); + let entry = &mut merged[i]; + entry.after = (f.kind != ChangeKind::Deleted).then(|| id.clone()); + entry.path = f.path; + } + } + let mut out: Vec = merged + .into_iter() + .filter_map(|m| { + let (kind, path, old_path) = match (&m.before, &m.after) { + (None, None) => return None, + (None, Some(_)) => (ChangeKind::Added, m.path, None), + (Some((_, old)), None) => (ChangeKind::Deleted, old.clone(), None), + (Some((_, old)), Some(_)) if *old != m.path => (ChangeKind::Renamed, m.path, Some(old.clone())), + (Some(_), Some(_)) => (ChangeKind::Modified, m.path, None), + }; + Some(RevisionChange { path, kind, old_path, before: m.before.map(|(rev, _)| rev), after: m.after }) + }) + .collect(); + out.sort_by(|a, b| a.path.cmp(&b.path)); Ok(out) } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 067648f38..950debbf6 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -205,6 +205,21 @@ pub struct ChangedFile { pub old_path: Option, } +/// A file changed across several commits: its content at `before` ↔ at `after`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RevisionChange { + /// Repository-relative path after the change (before it, for a deletion). + pub path: String, + pub kind: ChangeKind, + /// Path at `before`, for a rename. + pub old_path: Option, + /// Commit holding the old content; `None` when the file was added. + pub before: Option, + /// Commit holding the new content; `None` when the file was deleted. + pub after: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Stash { @@ -546,6 +561,11 @@ impl Repo { pub fn commit_files(&self, commit: &str) -> IdeResult> { history::commit_files(self, commit) } + /// Combined changes of several commits (oldest first): per file, before the first commit + /// that touched it ↔ after the last one (IDEA's multi-commit Git Log selection). + pub fn commits_changes(&self, commits: &[String]) -> IdeResult> { + history::commits_changes(self, commits) + } /// Local and remote branches; the current branch first, then most recent first. pub fn branches(&self) -> IdeResult> { refs::branches(self) diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 40f6a95bc..64f8a64ec 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -543,6 +543,67 @@ fn commit_files_kinds() { assert!(repo.commit_files("HEAD").unwrap().len() == 4); } +/// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔ +/// after the last one, contiguous or not. +#[test] +fn commits_changes_merge_selected_commits() { + let fx = Fixture::new(); + fx.write("a.txt", "a0\n"); + fx.write("b.txt", "b0\n"); + fx.write("gone.txt", "g\n"); + fx.write("move.txt", "a fairly long line of content\nanother line\nand a third\n"); + let c0 = fx.commit_all("c0"); + fx.write("a.txt", "a1\n"); + fx.write("tmp.txt", "t\n"); + let c1 = fx.commit_all("c1"); + fx.write("b.txt", "b2\n"); + fx.git(&["mv", "move.txt", "moved.txt"]); + let c2 = fx.commit_all("c2"); + fx.write("a.txt", "a3\n"); + std::fs::remove_file(fx.path("tmp.txt")).unwrap(); + std::fs::remove_file(fx.path("gone.txt")).unwrap(); + std::fs::create_dir(fx.path("dir")).unwrap(); + fx.git(&["mv", "moved.txt", "dir/final.txt"]); + let c3 = fx.commit_all("c3"); + let repo = fx.repo(); + let changes = |ids: &[&String]| { + let ids: Vec = ids.iter().map(|s| (*s).clone()).collect(); + // Sorted by path. + repo.commits_changes(&ids) + .unwrap() + .into_iter() + .map(|c| (c.path, c.kind, c.old_path, c.before, c.after)) + .collect::>() + }; + let s = |x: &String| Some(x.clone()); + + // Contiguous c1..c3: tmp.txt added then deleted drops out; the rename chain collapses. + assert_eq!( + changes(&[&c1, &c2, &c3]), + vec![ + ("a.txt".into(), ChangeKind::Modified, None, s(&c0), s(&c3)), + ("b.txt".into(), ChangeKind::Modified, None, s(&c1), s(&c2)), + ("dir/final.txt".into(), ChangeKind::Renamed, Some("move.txt".into()), s(&c1), s(&c3)), + ("gone.txt".into(), ChangeKind::Deleted, None, s(&c2), None), + ] + ); + // Non-contiguous c1 + c3: each file from before its first selected change. + assert_eq!( + changes(&[&c1, &c3]), + vec![ + ("a.txt".into(), ChangeKind::Modified, None, s(&c0), s(&c3)), + ("dir/final.txt".into(), ChangeKind::Renamed, Some("moved.txt".into()), s(&c2), s(&c3)), + ("gone.txt".into(), ChangeKind::Deleted, None, s(&c2), None), + ] + ); + // The root commit has no parent: everything is added; a duplicate id counts once. + let root = changes(&[&c0, &c0]); + assert_eq!(root.len(), 4); + assert!(root.iter().all(|c| c.1 == ChangeKind::Added && c.3.is_none() && c.4 == s(&c0))); + // A single commit matches its own changed files. + assert_eq!(changes(&[&c2]).len(), repo.commit_files(&c2).unwrap().len()); +} + #[test] fn file_at_revisions_and_index() { let fx = Fixture::new(); diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md new file mode 100644 index 000000000..ea7caa480 --- /dev/null +++ b/docs/plugins/themes.md @@ -0,0 +1,567 @@ +# Theme plugins + +OpenIDEA ships three built-in themes (Islands Light, Islands Dark, High +Contrast). Everything else comes from **theme plugins**, the same model as +IntelliJ IDEA, where most themes are plugins rather than part of the IDE. + +A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an +optional `.icls` editor scheme) it has no code: no WebAssembly, nothing to +compile, no permissions. + +``` +my-theme/ +├─ plugin.toml # manifest +└─ themes/ + ├─ my-theme.theme.json # UI colours (IntelliJ: .theme.json) + └─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls) +``` + +The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin). +It contains *Harbor Night*, a complete example dark theme that sets every +themeable token. The rest of this page covers the file format, the token +reference, porting themes from IntelliJ IDEA and VS Code, and testing. + +## The manifest + +```toml +id = "theme-harbor-night" # [a-z0-9][a-z0-9_-]*, unique among plugins +name = "Harbor Night Theme" +version = "1.0.0" +description = "..." +authors = ["You "] +openidea-api = "0.1" + +[[contributes.themes]] # one entry per theme; a plugin may ship several +path = "themes/harbor-night.theme.json" +editor-scheme = "themes/harbor-night.scheme.json" # optional +``` + +Paths are relative to the package directory and may not leave it. The other +manifest fields are described in [`plugins/README.md`](../../plugins/README.md). + +## The theme file + +```jsonc +{ + "id": "harbor-night", // [a-z0-9][a-z0-9._-]*, unique within the plugin + "name": "Harbor Night", // shown in Settings › Plugins + "kind": "dark", // "dark" or "light": the built-in base + "tokens": { + "--bg-app": "#14171c", + "--accent": "#2b9e93" + } +} +``` + +- **`tokens`** are the CSS variables of [`src/styles/tokens.css`](../../src/styles/tokens.css), + the IDE's single source of colours. Components never use colour literals, so + every surface, border, text colour, syntax colour and status colour can be + themed through them. The [reference](#token-reference) lists them all. +- **`kind`** picks the built-in base. While the theme is active the IDE runs on + Islands Dark or Islands Light, and every token the theme leaves out keeps + that base's value. Icons also use the light or dark set that matches the + kind. A partial theme still looks consistent, but listing every token keeps + it stable when the IDE's own palette changes. +- Values are plain CSS values: hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`, + `hsl()`, `color-mix()`, `var(--other-token)`, or for the few non-colour + tokens a keyword (`italic`, `normal`, `none`) or shadow list. `url()`, + `image()`, `;`, braces, `<`, `>` and `\` are rejected. +- `//` and `/* */` comments are allowed, as in VS Code theme files. +- Tokens that tokens.css derives from others (for example `--bg-editor` is + `var(--bg-island)`, and the coverage and flame graph colours are mixed from + the status colours) follow automatically. You may still set them. +- Metrics (`--fs-*`, `--h-*`, `--radius-*`) and fonts (`--font-ui`, + `--font-mono`) can be overridden too, but a theme normally leaves them alone + so that compact mode and the user's font settings keep working. + +### The editor colour scheme file + +```jsonc +{ + "name": "Harbor Night", + "tokens": { "--syn-keyword": "#e0a46e", "--caret": "#5ce0d2" } +} +``` + +Its tokens are merged over the theme's, and the scheme wins where both set a +token. By convention it holds the *syntax*, *editor* and *ANSI palette* groups +of the reference, the parts IntelliJ keeps in an `.icls` file. The split only +organises the files: a theme can also put everything in its theme file. + +### How a theme is applied + +The selected theme is injected as one stylesheet on `:root` that outranks the +built-in theme blocks, and `data-theme` is switched to the theme's `kind`. +Three things still layer on top of it: + +- an accent colour picked in *Settings › Appearance* (other than the default), +- an explicit scheme or attribute overrides in *Settings › Editor › Color Scheme*, +- the appearance options that change only metrics (compact mode). + +The selection is remembered. When the plugin is disabled or uninstalled the +built-in theme returns. + +## Building, packaging, installing + +There is no build step. The package *is* the folder. + +1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. +2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). +3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the + *Install Plugin from Disk…* action) and pick the package folder. The + manifest and the files it references are copied to + `/plugins//`. +4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next + to the theme. *Use Default Theme* switches back. + +To iterate without reinstalling, put (or symlink) the package in +`/.openidea/plugins//` of an open project. It loads as a *dev* +plugin, and *Reload* (or the *Reload Plugins* action) picks up your edits. + +To share a theme, publish the folder (a Git repository or a zip). Users +install it from disk. + +## Porting a theme + +Only port themes whose licence allows redistribution, keep their licence file, +and credit the original authors in `authors` and `description`. + +### From IntelliJ IDEA (automatic) + +`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`, +and optionally its `.icls` editor scheme, into a ready-to-install plugin. It +has no dependencies beyond Node 22. + +```sh +pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author" +# or: node scripts/theme/import-intellij-theme.ts … +``` + +It maps the keys in the tables below, resolves named colours from the theme's +`colors` section and `*` wildcards, and takes `kind` from `"dark"` (or the +`.icls` `parent_scheme`). Tokens without an IntelliJ counterpart keep the +built-in values for that kind. Open the result next to the template and fill +in what matters to you. Themes that ship only a `.theme.json` with an +`editorScheme` path need that `.icls` passed as the second argument. Themes that +ship no `.icls` at all keep the built-in syntax colours. + +#### `.theme.json` → UI theme + +| OpenIDEA token | IntelliJ `ui` key (first present wins) | +|---|---| +| `--bg-app` | `Panel.background` | +| `--bg-frame` | `MainWindow.background`, `MainToolbar.background` | +| `--bg-island`, `--bg-elev` | `ToolWindow.background` | +| `--bg-popup` | `Popup.background`, `PopupMenu.background` | +| `--bg-hover` | `List.hoverBackground`, `Tree.hoverBackground` | +| `--bg-selected`, `--bg-tree-selected` | `List.selectionBackground`, `Tree.selectionBackground` | +| `--bg-tree-selected-inactive` | `Tree.selectionInactiveBackground` | +| `--bg-input`, `--bg-field` | `TextField.background` | +| `--border` | `Borders.color` | +| `--border-strong`, `--border-window` | `Component.borderColor`, `Borders.ContrastBorderColor` | +| `--fg`, `--fg-emphasis` | `Label.foreground` | +| `--fg-secondary`, `--fg-muted` | `Label.infoForeground`, `Component.infoForeground` | +| `--fg-subtle`, `--fg-faint` | `Label.disabledForeground` | +| `--accent` | `Button.default.startBackground`, `Component.focusColor` | +| `--fg-on-accent`, `--fg-button-primary` | `Button.default.foreground` | +| `--link`, `--link-hover` | `Link.activeForeground`, `Link.hoverForeground` | +| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` | +| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` | +| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` | +| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` | +| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` | +| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` | +| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` | +| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` | +| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … | + +The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts). + +#### `.icls` → editor scheme + +| OpenIDEA token | `.icls` option | +|---|---| +| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) | +| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` | +| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` | +| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` | +| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` | +| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` | +| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` | +| `--syn-comment` (+ `--syn-comment-style`), `--syn-doc-comment` | `DEFAULT_LINE_COMMENT` (+ `FONT_TYPE`), `DEFAULT_DOC_COMMENT` | +| `--syn-function`, `--syn-function-call` | `DEFAULT_FUNCTION_DECLARATION`, `DEFAULT_FUNCTION_CALL` | +| `--syn-type`, `--syn-constant`, `--syn-property` | `DEFAULT_CLASS_NAME`, `DEFAULT_CONSTANT`, `DEFAULT_INSTANCE_FIELD` | +| `--syn-variable`, `--syn-param`, `--syn-builtin` | `DEFAULT_LOCAL_VARIABLE`, `DEFAULT_PARAMETER`, `DEFAULT_PREDEFINED_SYMBOL` | +| `--syn-annotation`, `--syn-decorator` | `DEFAULT_METADATA` | +| `--syn-punct`, `--syn-escape` | `DEFAULT_OPERATION_SIGN`, `DEFAULT_VALID_STRING_ESCAPE` | +| `--syn-tag`, `--syn-attribute`, `--syn-todo`, `--syn-invalid` | `DEFAULT_TAG`, `DEFAULT_ATTRIBUTE`, `TODO_DEFAULT_ATTRIBUTES`, `BAD_CHARACTER` | +| `--syn-rainbow-1` … `--syn-rainbow-5` (Semantic highlighting) | `RAINBOW_COLOR1` … `RAINBOW_COLOR5` | +| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) | +| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` | +| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` | +| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` | +| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) | + +The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file. + +### From VS Code (by hand) + +A VS Code theme (`themes/*.json` in the extension) has `type` (`dark` / `light` +/ `hc`), `colors` (workbench) and `tokenColors` (TextMate scopes). Set `kind` +from `type`, then map: + +| OpenIDEA token | VS Code `colors` key | +|---|---| +| `--bg-frame` | `titleBar.activeBackground`, `activityBar.background` | +| `--bg-island`, `--bg-app` | `editor.background`, `sideBar.background` | +| `--bg-elev` | `sideBarSectionHeader.background`, `editorGroupHeader.tabsBackground` | +| `--bg-popup`, `--border-popup` | `editorWidget.background`, `editorWidget.border` | +| `--bg-hover` | `list.hoverBackground` | +| `--bg-selected`, `--bg-tree-selected` | `list.activeSelectionBackground` | +| `--bg-tree-selected-inactive` | `list.inactiveSelectionBackground` | +| `--border` | `panel.border`, `sideBar.border` | +| `--fg` | `foreground`, `sideBar.foreground` | +| `--fg-muted`, `--fg-subtle` | `descriptionForeground`, `disabledForeground` | +| `--accent`, `--fg-on-accent` | `button.background`, `button.foreground` | +| `--focus-ring-soft` | `focusBorder` | +| `--link` | `textLink.foreground` | +| `--bg-input`, `--bg-field` | `input.background` | +| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` | +| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` | +| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` | +| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` | +| `--fg-editor` | `editor.foreground` | +| `--caret` | `editorCursor.foreground` | +| `--bg-current-line` | `editor.lineHighlightBackground` | +| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` | +| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` | +| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` | +| `--fg-whitespace` | `editorWhitespace.foreground` | +| `--bg-text-search-result`, `--bg-text-search-result-current` | `editor.findMatchHighlightBackground`, `editor.findMatchBackground` | +| `--bg-matched-brace` | `editorBracketMatch.background` | +| `--syn-bracket-1` … `--syn-bracket-3` (Rainbow brackets) | `editorBracketHighlight.foreground1` … `foreground3` | +| `--bg-identifier-under-caret`, `-write` | `editor.wordHighlightBackground`, `editor.wordHighlightStrongBackground` | +| `--wave-error`, `--wave-warning` | `editorError.foreground`, `editorWarning.foreground` | +| `--gutter-modified`, `--gutter-added`, `--gutter-deleted` | `editorGutter.modifiedBackground`, `.addedBackground`, `.deletedBackground` | +| `--fg-inlay`, `--bg-inlay` | `editorInlayHint.foreground`, `editorInlayHint.background` | +| `--ansi-*` | `terminal.ansiBlack` … `terminal.ansiBrightWhite` | + +`tokenColors` → syntax tokens, by TextMate scope: + +| OpenIDEA token | Scope | +|---|---| +| `--syn-keyword` | `keyword`, `storage.type`, `storage.modifier` | +| `--syn-string` | `string` | +| `--syn-escape` | `constant.character.escape` | +| `--syn-number` | `constant.numeric` | +| `--syn-constant` | `constant.language`, `variable.other.constant` | +| `--syn-comment`, `--syn-doc-comment` | `comment`, `comment.block.documentation` | +| `--syn-function`, `--syn-function-call` | `entity.name.function`, `meta.function-call` | +| `--syn-type` | `entity.name.type`, `entity.name.class`, `support.class` | +| `--syn-variable`, `--syn-param` | `variable`, `variable.parameter` | +| `--syn-property` | `variable.other.property`, `variable.other.member` | +| `--syn-builtin`, `--syn-predefined` | `support.function`, `support.variable` | +| `--syn-self` | `variable.language.self`, `variable.language.this` | +| `--syn-annotation`, `--syn-decorator` | `meta.decorator`, `storage.type.annotation` | +| `--syn-punct` | `punctuation`, `keyword.operator` | +| `--syn-tag`, `--syn-attribute` | `entity.name.tag`, `entity.other.attribute-name` | +| `--syn-regexp` | `string.regexp` | +| `--syn-markup-heading`, `--syn-markup-code`, `--syn-link` | `markup.heading`, `markup.inline.raw`, `markup.underline.link` | +| `--syn-invalid` | `invalid` | + +A `fontStyle: "italic"` on comments maps to `"--syn-comment-style": "italic"`. + +## Testing a theme + +- Install it from disk (or as a dev plugin), apply it, and walk through: the + Project tool window with a selection, the editor with a few languages, Search + Everywhere, a popup menu, a tooltip, Settings, the terminal (`ls --color`), + a diff, and the VCS colours in the Commit tool window. +- *Settings › Plugins* shows a load error (with the file and line) if the JSON + or a value is invalid. *Reload* re-reads a dev plugin. +- Text should keep at least WCAG AA contrast (4.5:1) on its surfaces. The + built-in themes are checked by `src/features/settings/__tests__/contrast.test.ts`. +- Repository checks (for changes to this repo): `pnpm vitest run scripts/theme` + validates the template against tokens.css and keeps this reference current, + and `cargo test -p ide-plugin` loads the template through the plugin host. + +## Token reference + +Generated from `src/styles/tokens.css` (the variables of its Islands Light +block, which redefines every colour token of Islands Dark). `pnpm gen:theme-docs` +regenerates it. The `scripts/theme` tests fail when it, or the template, misses +a token. + + + +203 themeable tokens: 113 in the UI theme file, 90 in the editor colour scheme file. + +### Surfaces (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-app` | `#191a1c` | `#ffffff` | +| `--bg-elev` | `#212326` | `#f7f8f9` | +| `--bg-popup` | `#26282c` | `#ffffff` | +| `--bg-hover` | `#2e2f30` | `#ededed` | +| `--bg-selected` | `#2a4371` | `#d0dffe` | +| `--bg-selected-strong` | `#2e4d89` | `#bdd3ff` | +| `--bg-current-line` | `#1f2024` | `#f5f8fe` | +| `--bg-debug-line` | `#2a5091` | `#d4e2ff` | +| `--bg-backdrop` | `#26282c` | `#e9eaee` | +| `--bg-input` | `#191a1c` | `#ffffff` | + +### Lines (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--border` | `#26282c` | `#e9eaee` | +| `--border-popup` | `#33353b` | `#d1d3d9` | +| `--border-strong` | `#40434a` | `#d1d3d9` | +| `--border-window` | `#40434a` | `#d1d3d9` | +| `--control-off` | `#5f6269` | `#c3c5cb` | + +### Text (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--fg-strong` | `#dfe1e5` | `#000000` | +| `--fg-emphasis` | `#d1d3d9` | `#000000` | +| `--fg` | `#d1d3d9` | `#000000` | +| `--fg-secondary` | `#9fa2a8` | `#5f6269` | +| `--fg-muted` | `#8c8f95` | `#6c707e` | +| `--fg-subtle` | `#73767c` | `#73767c` | +| `--fg-faint` | `#5f6269` | `#9fa2a8` | +| `--fg-gutter` | `#4b5059` | `#aeb3c2` | +| `--fg-icon` | `#ced0d6` | `#6c707e` | + +### Accent (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--accent` | `#3871e1` | `#3871e1` | +| `--link` | `#71a1fe` | `#2f5eb9` | +| `--link-hover` | `#92b7ff` | `#2e4d89` | + +### Charts (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--chart-series` | `#e07a7c` | `#d9686a` | + +### Status (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--danger` | `#f57e84` | `#c54e58` | +| `--warning` | `#d59637` | `#a56906` | +| `--success` | `#6db083` | `#338555` | +| `--db-connected` | `#2fe02f` | `#00ff00` | +| `--db-connected-ring` | `#1d6b1d` | `#5b995b` | +| `--info` | `#548af7` | `#3871e1` | + +### Vcs (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--vcs-modified` | `#70aeff` | `#0033b3` | +| `--vcs-added` | `#73bd79` | `#067d17` | +| `--vcs-deleted` | `#6f737a` | `#6c707e` | +| `--vcs-untracked` | `#e88f89` | `#b23247` | +| `--vcs-ignored` | `#d69a6b` | `#8c4f00` | +| `--vcs-conflict` | `#de6a66` | `#de1b2e` | +| `--gutter-modified` | `#375fad` | `#88adf7` | +| `--gutter-added` | `#549159` | `#7fc784` | +| `--gutter-deleted` | `#868a91` | `#767a8a` | + +### Syntax (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--syn-keyword` | `#cf8e6d` | `#0033b3` | +| `--syn-string` | `#6aab73` | `#067d17` | +| `--syn-type` | `#bcbec4` | `#000000` | +| `--syn-function` | `#56a8f5` | `#00627a` | +| `--syn-function-call` | `#bcbec4` | `#080808` | +| `--syn-comment` | `#7a7e85` | `#8c8c8c` | +| `--syn-comment-style` | `normal` | `italic` | +| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` | +| `--syn-docstring` | `#5f826b` | `#067d17` | +| `--syn-annotation` | `#b3ae60` | `#9e880d` | +| `--syn-decorator` | `#b3ae60` | `#9e880d` | +| `--syn-number` | `#2aacb8` | `#1750eb` | +| `--syn-punct` | `#bcbec4` | `#080808` | +| `--syn-variable` | `#bcbec4` | `#080808` | +| `--syn-param` | `#bcbec4` | `#080808` | +| `--syn-property` | `#c77dbb` | `#871094` | +| `--syn-constant` | `#c77dbb` | `#871094` | +| `--syn-builtin` | `#8888c6` | `#000080` | +| `--syn-self` | `#94558d` | `#94558d` | +| `--syn-keyword-arg` | `#aa4926` | `#660099` | +| `--syn-predefined` | `#b200b2` | `#b200b2` | +| `--syn-escape` | `#cf8e6d` | `#0037a6` | +| `--syn-regexp` | `#42c3d4` | `#264eff` | +| `--syn-tag` | `#d5b778` | `#0033b3` | +| `--syn-attribute` | `#bcbec4` | `#174ad4` | +| `--syn-todo` | `#8bb33d` | `#008dde` | +| `--syn-link` | `#548af7` | `#006dcc` | +| `--syn-markup-heading` | `#cf8e6d` | `#871094` | +| `--syn-markup-code` | `#6aab73` | `#067d17` | +| `--syn-invalid` | `#f75464` | `#f50000` | +| `--syn-inline-value` | `#868a91` | `#8c8c8c` | +| `--syn-rainbow-1` | `#6fafbd` | `#9b3b6a` | +| `--syn-rainbow-2` | `#d79b6e` | `#114d77` | +| `--syn-rainbow-3` | `#9fbf6b` | `#9c6a2c` | +| `--syn-rainbow-4` | `#c98bc6` | `#005910` | +| `--syn-rainbow-5` | `#8e9cf0` | `#bc5150` | +| `--syn-bracket-1` | `#e8c25b` | `#0431fa` | +| `--syn-bracket-2` | `#d78bd6` | `#319331` | +| `--syn-bracket-3` | `#4ea7f0` | `#7b3814` | + +### Editor (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--fg-editor` | `#bcbec4` | `#080808` | +| `--caret` | `#ced0d6` | `#000000` | +| `--fg-line-number` | `#4b5059` | `#aeb3c2` | +| `--fg-line-number-current` | `#a1a3ab` | `#767a8a` | +| `--border-gutter` | `#26282c` | `#ebecf0` | +| `--border-indent-guide` | `#323438` | `#ebecf0` | +| `--border-indent-guide-active` | `#4e5157` | `#aeb3c2` | +| `--border-right-margin` | `#323438` | `#ebecf0` | +| `--bg-editor-selection` | `#214283` | `#a6d2ff` | +| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` | +| `--bg-text-search-result` | `#114957` | `#fcd47e` | +| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` | +| `--bg-matched-brace` | `#43454a` | `#93d9d9` | +| `--matched-brace-weight` | `var(--fw-code-bold)` | `inherit` | +| `--bg-matched-tag` | `#3f4045` | `#c9ecec` | +| `--bg-fold-placeholder` | `#393b40` | `#e9f5e6` | +| `--fg-fold-placeholder` | `#868991` | `#414d41` | +| `--fg-fold-marker` | `#6f737a` | `#8b8e94` | +| `--fg-whitespace` | `#6f737a` | `#b5b7bd` | +| `--fg-unused` | `#6f737a` | `#8c8c8c` | +| `--bg-identifier-under-caret` | `#373b39` | `#edebfc` | +| `--bg-identifier-under-caret-write` | `#402f33` | `#fce8f4` | +| `--fg-inlay` | `#868a91` | `#73767c` | +| `--bg-inlay` | `#393b40` | `#00000012` | +| `--wave-error` | `#fa6675` | `#f50000` | +| `--wave-warning` | `#f2c55c` | `#f2bf57` | +| `--editor-error-decoration` | `underline wavy #fa6675` | `underline wavy #f50000` | +| `--editor-warning-decoration` | `underline wavy #f2c55c` | `none` | +| `--editor-warning-background` | `transparent` | `#fcf4d4` | +| `--bg-breakpoint-line` | `#40252b` | `#faeae6` | +| `--fg-completion-match` | `#538af9` | `#2f5eb9` | +| `--bg-editor-inline` | `#212326` | `#f7f8f9` | +| `--bg-editor-banner` | `#233558` | `#f7f8ff` | +| `--fg-weak-warning` | `#6f737a` | `#a8adbd` | +| `--fg-bulb` | `#f2c55c` | `#ffaf0f` | + +### ANSI palette (editor scheme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--ansi-black` | `#000000` | `#000000` | +| `--ansi-red` | `#f0524f` | `#e5372a` | +| `--ansi-green` | `#5c962c` | `#367a29` | +| `--ansi-yellow` | `#a68a0d` | `#ad8d2b` | +| `--ansi-blue` | `#3993d4` | `#3866d1` | +| `--ansi-magenta` | `#a771bf` | `#a33cae` | +| `--ansi-cyan` | `#00a3a3` | `#3c8b8d` | +| `--ansi-white` | `#808080` | `#8b8e94` | +| `--ansi-bright-black` | `#595959` | `#73767c` | +| `--ansi-bright-red` | `#ff4050` | `#f05046` | +| `--ansi-bright-green` | `#4fc414` | `#2e9b3f` | +| `--ansi-bright-yellow` | `#e5bf00` | `#b58e00` | +| `--ansi-bright-blue` | `#1fb0ff` | `#3d82f0` | +| `--ansi-bright-magenta` | `#ed7eed` | `#c04bcf` | +| `--ansi-bright-cyan` | `#00e5e5` | `#1b9ea3` | +| `--ansi-bright-white` | `#ffffff` | `#5f6269` | + +### Shadows and window controls (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--shadow-popup` | `0 6px 20px rgba(0, 0, 0, 0.45)` | `0 6px 20px rgba(0, 0, 0, 0.12)` | +| `--shadow-dialog` | `0 0 0 0.5px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.35), 0 24px 72px rgba(0, 0, 0, 0.45)` | `0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 10px 24px rgba(0, 0, 0, 0.1), 0 24px 72px rgba(0, 0, 0, 0.2)` | +| `--traffic-close` | `#ff5f57` | `#ff5f57` | +| `--traffic-close-border` | `#e0443e` | `#e0443e` | +| `--traffic-close-pressed` | `#bf4942` | `#bf4942` | +| `--traffic-glyph` | `#4d0000` | `#4d0000` | +| `--traffic-off` | `#4b4c4f` | `#dddddd` | +| `--traffic-off-border` | `#404144` | `#c8c8c8` | + +### Additive (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-tooltip` | `#33353b` | `#ffffff` | +| `--fg-tooltip` | `#d1d3d9` | `#000000` | +| `--border-tooltip` | `#33353b` | `#d1d3d9` | +| `--bg-control` | `#26282c` | `#ffffff` | +| `--bg-control-hover` | `#33353b` | `#f7f8f9` | +| `--bg-active` | `#40434a` | `#dddfe4` | +| `--bg-scrim` | `rgba(0, 0, 0, 0.45)` | `rgba(0, 0, 0, 0.16)` | +| `--fg-on-accent` | `#ffffff` | `#ffffff` | +| `--shadow-tooltip` | `0 4px 12px rgba(0, 0, 0, 0.4)` | `0 4px 12px rgba(0, 0, 0, 0.12)` | +| `--shadow-balloon` | `0 8px 24px rgba(0, 0, 0, 0.45)` | `0 8px 24px rgba(0, 0, 0, 0.14)` | + +### Islands contract (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--bg-frame` | `#26282c` | `#e9eaee` | +| `--bg-island` | `#191a1c` | `#ffffff` | +| `--bg-stripe-hover` | `#ffffff17` | `#00000012` | +| `--bg-stripe-selected` | `#ffffff29` | `#00000020` | +| `--fg-stripe-selected-active` | `#ffffff` | `#ffffff` | +| `--fg-stripe-label` | `#9fa2a8` | `#5f6269` | +| `--bg-tab-selected` | `#233558` | `#e3ebfe` | +| `--border-tab-selected` | `#2e4d89` | `#a7c5ff` | +| `--bg-tab-selected-inactive` | `#26282c` | `#e9eaee` | +| `--border-tab-selected-inactive` | `#40434a` | `#d1d3d9` | +| `--bg-tab-hover` | `#ffffff17` | `#00000012` | +| `--border-editor-tabs` | `#26282c` | `#e9eaee` | +| `--bg-tree-selected` | `#2a4371` | `#d0dffe` | +| `--bg-tree-selected-inactive` | `#33353b` | `#e9eaee` | +| `--bg-tree-hover` | `#ffffff10` | `#00000008` | +| `--bg-toolbar-hover` | `#ffffff17` | `#00000012` | +| `--bg-toolbar-pressed` | `#ffffff29` | `#00000020` | +| `--bg-toolbar-selected` | `#2e4d89` | `#d0dffe` | +| `--bg-toolbar-selected-hover` | `#2f5eb9` | `#bdd3ff` | +| `--bg-button-primary-hover` | `#2f5eb9` | `#2f5eb9` | +| `--fg-button-primary` | `#ffffff` | `#ffffff` | +| `--bg-button` | `transparent` | `#ffffff` | +| `--border-button` | `#40434a` | `#d1d3d9` | +| `--bg-field` | `#191a1c` | `#ffffff` | +| `--bg-checkbox` | `#191a1c` | `#ffffff` | +| `--border-checkbox` | `#5f6269` | `#a8adbd` | +| `--bg-toggle-on` | `#338555` | `#4e9d6c` | +| `--fg-toggle-on` | `#ffffff` | `#ffffff` | +| `--bg-toggle-off` | `#191a1c` | `#f7f8f9` | +| `--fg-toggle-off` | `#73767c` | `#73767c` | +| `--border-toggle` | `#5f6269` | `#c3c5cb` | +| `--bg-toggle-knob` | `#5f6269` | `#c3c5cb` | +| `--focus-ring-soft` | `#3d6185` | `#97c3f3` | +| `--scrollbar-thumb` | `#ffffff33` | `#00000033` | +| `--scrollbar-thumb-hover` | `#ffffff59` | `#00000080` | +| `--bg-search-match` | `#ba9752` | `#fee6b1` | +| `--bg-search-match-current` | `#f2c55c` | `#fcd47e` | +| `--fg-search-match` | `#191a1c` | `#000000` | +| `--bg-dialog` | `#191a1c` | `#f7f8f9` | +| `--bg-popup-inline` | `#26282c` | `#f7f8f9` | +| `--bg-popup-advertiser` | `#26282c` | `#f7f8f9` | +| `--bg-feedback-info` | `#233558` | `#f7f8ff` | +| `--border-feedback-info` | `#2e4d89` | `#bdd3ff` | +| `--bg-feedback-error` | `#56272b` | `#fff6f5` | +| `--border-feedback-error` | `#80383e` | `#ffc4c5` | +| `--bg-feedback-warning` | `#44321d` | `#fff6e9` | +| `--border-feedback-warning` | `#694820` | `#f4cd9a` | +| `--bg-feedback-success` | `#203b2a` | `#f5faf3` | +| `--border-feedback-success` | `#29583c` | `#bbdbc2` | +| `--bg-counter` | `#73767c` | `#8b8e94` | +| `--fg-counter` | `#000000` | `#ffffff` | +| `--bg-progress-track` | `#40434a` | `#dddfe4` | + + diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 278bcee55..88fd1aeac 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -4,8 +4,16 @@ test("⌘, opens Settings with the page tree; Escape closes", async ({ workbench await page.keyboard.press("Meta+,"); const dialog = page.getByRole("dialog", { name: "Settings" }); await expect(dialog).toBeVisible(); - for (const name of ["Appearance & Behavior", "Keymap", "Editor", "Plugins", "Version Control"]) { - await expect(dialog.getByRole("treeitem", { name, exact: true })).toBeVisible(); + const item = (name: string) => dialog.getByRole("treeitem", { name, exact: true }); + for (const name of ["Appearance & Behavior", "Keymap", "Editor"]) { + await expect(item(name)).toBeVisible(); + } + // The tree is virtualized: collapse Editor (expanded by default) so the later groups render. + await item("Editor").click(); + await page.keyboard.press("ArrowLeft"); + await expect(item("Editor")).not.toHaveAttribute("aria-expanded", "true"); + for (const name of ["Plugins", "Version Control"]) { + await expect(item(name)).toBeVisible(); } await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); diff --git a/languages/javascript.toml b/languages/javascript.toml index 9eca84ee5..d16c1ad57 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -22,7 +22,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index ddca8d529..540722ee3 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescript.toml b/languages/typescript.toml index 60432e162..c8dc5779d 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ab3c27c8c..8c5a439d9 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/vue.toml b/languages/vue.toml index aab4e5e0a..1176b0ecb 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -10,4 +10,14 @@ name = "vue-language-server" command = "vue-language-server" args = ["--stdio"] root-markers = ["package.json", "vite.config.ts", "vite.config.js", "nuxt.config.ts", "tsconfig.json"] -install-hint = "npm i -g @vue/language-server (TypeScript support also needs @vue/typescript-plugin in vtsls)" +install-hint = "npm i -g @vue/language-server typescript@\"<7\"" + +# Hybrid mode: TypeScript in `.vue` files comes from tsserver with +# `@vue/typescript-plugin` (configured by the settings hook); vue-language-server +# reaches it through `tsserver/request`, which the client forwards to vtsls. +[[language-servers]] +name = "vtsls" +command = "vtsls" +args = ["--stdio"] +root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] +install-hint = "npm i -g @vtsls/language-server" diff --git a/package.json b/package.json index a2cbd3141..72fad620c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.14", + "version": "0.1.15", "license": "Apache-2.0", "type": "module", "scripts": { @@ -14,8 +14,10 @@ "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", - "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit && tsc -p scripts --noEmit", "gen:bindings": "cargo test -p openidea --test export_bindings", + "gen:theme-docs": "node scripts/theme/gen-docs.ts", + "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "tauri dev --config src-tauri/tauri.dev.conf.json", "e2e": "playwright test", "perf": "node perf/run.ts", @@ -35,6 +37,7 @@ "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@lezer/highlight": "^1.2.5", + "@replit/codemirror-vim": "6.4.0", "@tanstack/react-virtual": "^3.14.21", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.3", diff --git a/plugins/README.md b/plugins/README.md index d09cb2468..101f802a9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -179,8 +179,14 @@ never affected. Tokens are the CSS variables of `src/styles/tokens.css` (`--bg-*`, `--fg-*`, `--border*`, `--accent`, `--syn-*`, `--vcs-*`, `--gutter-*`, …). Unlisted tokens -keep the built-in values. Values must be plain CSS values (no `url()`, `;`, -braces). Pick a theme in Settings › Plugins › *Apply*. +keep the values of the built-in base named by `kind`. Values must be plain CSS +values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A +manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor +colours, merged over the theme). Pick a theme in Settings › Plugins › *Apply*. + +Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide, +token reference and IntelliJ / VS Code porting tables are in +[`docs/plugins/themes.md`](../docs/plugins/themes.md). ## 4. Languages diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cedddec2d..06ab3552a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@lezer/highlight': specifier: ^1.2.5 version: 1.2.5 + '@replit/codemirror-vim': + specifier: 6.4.0 + version: 6.4.0(@codemirror/commands@6.11.1)(@codemirror/language@6.13.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.6)(@codemirror/view@6.43.14) '@tanstack/react-virtual': specifier: ^3.14.21 version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -571,6 +574,18 @@ packages: engines: {node: '>=20'} hasBin: true + '@replit/codemirror-vim-core@0.1.0': + resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==} + + '@replit/codemirror-vim@6.4.0': + resolution: {integrity: sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A==} + peerDependencies: + '@codemirror/commands': 6.x.x + '@codemirror/language': 6.x.x + '@codemirror/search': 6.x.x + '@codemirror/state': 6.x.x + '@codemirror/view': 6.x.x + '@rolldown/binding-android-arm-eabi@1.2.14': resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -619,12 +634,14 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.14': resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.14': resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} @@ -645,6 +662,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.14': resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} @@ -1302,18 +1320,21 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} @@ -2382,6 +2403,17 @@ snapshots: dependencies: playwright: 1.64.1 + '@replit/codemirror-vim-core@0.1.0': {} + + '@replit/codemirror-vim@6.4.0(@codemirror/commands@6.11.1)(@codemirror/language@6.13.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.6)(@codemirror/view@6.43.14)': + dependencies: + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.13.1 + '@codemirror/search': 6.7.2 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@replit/codemirror-vim-core': 0.1.0 + '@rolldown/binding-android-arm-eabi@1.2.14': optional: true diff --git a/scripts/theme/gen-docs.ts b/scripts/theme/gen-docs.ts new file mode 100644 index 000000000..46269309f --- /dev/null +++ b/scripts/theme/gen-docs.ts @@ -0,0 +1,14 @@ +/** + * Regenerates the token reference in docs/plugins/themes.md from + * src/styles/tokens.css (`pnpm gen:theme-docs`). scripts/theme/theme.test.ts + * fails when the checked-in reference is stale. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { referenceMarkdown, themeTokenGroups, withReference } from "./tokens.ts"; + +const DOC = "docs/plugins/themes.md"; +const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8")); +const doc = readFileSync(DOC, "utf8"); +const next = withReference(doc, referenceMarkdown(groups)); +if (next !== doc) writeFileSync(DOC, next); +console.log(next === doc ? `${DOC} is up to date` : `updated ${DOC}`); diff --git a/scripts/theme/import-intellij-theme.ts b/scripts/theme/import-intellij-theme.ts new file mode 100644 index 000000000..4fdd0cf18 --- /dev/null +++ b/scripts/theme/import-intellij-theme.ts @@ -0,0 +1,52 @@ +/** + * Converts an IntelliJ theme into an OpenIDEA theme plugin package. + * + * pnpm theme:import-intellij [file.icls] --out [--id ] [--author ] + * + * Writes /plugin.toml and /themes/.theme.json (+ .scheme.json), + * ready for Settings › Plugins › Install Plugin from Disk…. Only port themes + * whose licence allows it, and credit the original author. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { parseArgs } from "node:util"; +import { convertIntellijTheme, pluginManifest } from "./intellij.ts"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { out: { type: "string" }, id: { type: "string" }, author: { type: "string" } }, +}); +const [themePath, iclsPath] = positionals; +if (!themePath || !values.out) { + console.error( + "usage: pnpm theme:import-intellij [file.icls] --out [--id ] [--author ]", + ); + process.exit(2); +} + +const converted = convertIntellijTheme( + readFileSync(themePath, "utf8"), + iclsPath ? readFileSync(iclsPath, "utf8") : undefined, + values.id, +); +const { theme, scheme } = converted; +const source = [basename(themePath), iclsPath && basename(iclsPath)].filter(Boolean).join(" + "); +const header = (what: string) => `// ${theme.name}: ${what}, converted from ${source}.\n`; + +mkdirSync(join(values.out, "themes"), { recursive: true }); +writeFileSync(join(values.out, "plugin.toml"), pluginManifest(converted, values.author ?? "Unknown", source)); +writeFileSync( + join(values.out, "themes", `${theme.id}.theme.json`), + header("UI theme") + JSON.stringify(theme, null, 2) + "\n", +); +if (scheme) { + writeFileSync( + join(values.out, "themes", `${theme.id}.scheme.json`), + header("editor colour scheme") + JSON.stringify(scheme, null, 2) + "\n", + ); +} +console.log( + `${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` + + (scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") + + ` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see docs/plugins/themes.md to fill them in.`, +); diff --git a/scripts/theme/intellij.ts b/scripts/theme/intellij.ts new file mode 100644 index 000000000..1fb828b95 --- /dev/null +++ b/scripts/theme/intellij.ts @@ -0,0 +1,323 @@ +/** + * Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme + * `.icls`) into an OpenIDEA theme + editor scheme. The mapping covers the + * main keys only; anything unmapped keeps the built-in value of the base + * (`kind`). Pure functions, no dependencies: see import-intellij-theme.ts + * for the command line. + */ +import { stripJsonComments } from "./tokens.ts"; + +/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */ +export const UI_KEYS: Record = { + "--bg-app": ["Panel.background"], + "--bg-frame": ["MainWindow.background", "MainToolbar.background", "Panel.background"], + "--bg-island": ["ToolWindow.background", "Panel.background"], + "--bg-elev": ["ToolWindow.background", "SidePanel.background"], + "--bg-popup": ["Popup.background", "PopupMenu.background"], + "--bg-backdrop": ["Popup.background"], + "--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"], + "--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"], + "--bg-selected-strong": ["List.selectionBackground"], + "--bg-tree-selected": ["Tree.selectionBackground", "List.selectionBackground"], + "--bg-tree-selected-inactive": ["Tree.selectionInactiveBackground", "List.selectionInactiveBackground"], + "--bg-input": ["TextField.background"], + "--bg-field": ["TextField.background"], + "--bg-dialog": ["Dialog.background", "Panel.background"], + "--border": ["Borders.color"], + "--border-popup": ["Popup.borderColor", "Popup.Border.color"], + "--border-strong": ["Component.borderColor", "Borders.ContrastBorderColor"], + "--border-window": ["Borders.ContrastBorderColor", "Component.borderColor"], + "--fg": ["Label.foreground"], + "--fg-emphasis": ["Label.foreground"], + "--fg-strong": ["Label.selectedForeground", "Label.foreground"], + "--fg-secondary": ["Label.infoForeground", "Component.infoForeground"], + "--fg-muted": ["Label.infoForeground", "Component.infoForeground"], + "--fg-subtle": ["Label.disabledForeground"], + "--fg-faint": ["Label.disabledForeground"], + "--accent": ["Button.default.startBackground", "Button.default.background", "Component.focusColor"], + "--fg-on-accent": ["Button.default.foreground"], + "--fg-button-primary": ["Button.default.foreground"], + "--focus-ring-soft": ["Component.focusColor"], + "--link": ["Link.activeForeground", "Hyperlink.linkColor"], + "--link-hover": ["Link.hoverForeground", "Link.activeForeground"], + "--danger": ["Component.errorFocusColor"], + "--warning": ["Component.warningFocusColor"], + "--bg-tooltip": ["ToolTip.background"], + "--fg-tooltip": ["ToolTip.foreground"], + "--border-tooltip": ["ToolTip.borderColor"], + "--bg-button": ["Button.startBackground", "Button.background"], + "--border-button": ["Button.startBorderColor", "Button.borderColor"], + "--bg-checkbox": ["Checkbox.Background.Default"], + "--border-checkbox": ["Checkbox.Border.Default"], + "--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"], + "--border-tab-selected": ["EditorTabs.underlineColor"], + "--bg-tab-hover": ["EditorTabs.hoverBackground"], + "--border-editor-tabs": ["EditorTabs.borderColor"], + "--bg-toolbar-hover": ["ActionButton.hoverBackground"], + "--bg-toolbar-pressed": ["ActionButton.pressedBackground"], + "--bg-stripe-hover": ["ToolWindow.Button.hoverBackground"], + "--bg-stripe-selected": ["ToolWindow.Button.selectedBackground"], + "--scrollbar-thumb": ["ScrollBar.Mac.thumbColor", "ScrollBar.thumbColor"], + "--scrollbar-thumb-hover": ["ScrollBar.Mac.hoverThumbColor", "ScrollBar.hoverThumbColor"], + "--bg-progress-track": ["ProgressBar.trackColor"], + "--bg-counter": ["Counter.background"], + "--fg-counter": ["Counter.foreground"], + "--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"], + "--bg-toggle-on": ["ToggleButton.onBackground"], + "--fg-toggle-on": ["ToggleButton.onForeground"], + "--bg-toggle-off": ["ToggleButton.offBackground"], + "--fg-toggle-off": ["ToggleButton.offForeground"], + "--border-toggle": ["ToggleButton.borderColor"], + "--bg-toggle-knob": ["ToggleButton.buttonColor"], + "--bg-feedback-info": ["Banner.infoBackground"], + "--border-feedback-info": ["Banner.infoBorderColor"], + "--bg-feedback-error": ["Banner.errorBackground"], + "--border-feedback-error": ["Banner.errorBorderColor"], + "--bg-feedback-warning": ["Banner.warningBackground"], + "--border-feedback-warning": ["Banner.warningBorderColor"], + "--bg-feedback-success": ["Banner.successBackground"], + "--border-feedback-success": ["Banner.successBorderColor"], +}; + +/** `.icls` `` options → tokens. */ +export const ICLS_COLORS: Record = { + CARET_COLOR: ["--caret"], + CARET_ROW_COLOR: ["--bg-current-line"], + SELECTION_BACKGROUND: ["--bg-editor-selection"], + LINE_NUMBERS_COLOR: ["--fg-line-number"], + LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"], + GUTTER_BACKGROUND: ["--bg-editor-gutter"], + INDENT_GUIDE: ["--border-indent-guide"], + SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"], + RIGHT_MARGIN_COLOR: ["--border-right-margin"], + WHITESPACES: ["--fg-whitespace"], + METHOD_SEPARATORS_COLOR: ["--border-method-separator"], + FILESTATUS_MODIFIED: ["--vcs-modified"], + FILESTATUS_ADDED: ["--vcs-added"], + FILESTATUS_DELETED: ["--vcs-deleted"], + FILESTATUS_UNKNOWN: ["--vcs-untracked"], + FILESTATUS_IDEA_FILESTATUS_IGNORED: ["--vcs-ignored"], + FILESTATUS_IDEA_FILESTATUS_MERGED_WITH_CONFLICTS: ["--vcs-conflict"], + MODIFIED_LINES_COLOR: ["--gutter-modified"], + ADDED_LINES_COLOR: ["--gutter-added"], + DELETED_LINES_COLOR: ["--gutter-deleted"], +}; + +type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR"; + +/** `.icls` `` options → [part, token] pairs. */ +export const ICLS_ATTRIBUTES: Record> = { + TEXT: [ + ["FOREGROUND", "--fg-editor"], + ["BACKGROUND", "--bg-editor"], + ], + DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]], + DEFAULT_STRING: [["FOREGROUND", "--syn-string"]], + DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]], + DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]], + DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]], + DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]], + DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]], + DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]], + DEFAULT_CONSTANT: [["FOREGROUND", "--syn-constant"]], + DEFAULT_INSTANCE_FIELD: [["FOREGROUND", "--syn-property"]], + DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]], + DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]], + DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]], + DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]], + DEFAULT_METADATA: [ + ["FOREGROUND", "--syn-annotation"], + ["FOREGROUND", "--syn-decorator"], + ], + DEFAULT_OPERATION_SIGN: [["FOREGROUND", "--syn-punct"]], + DEFAULT_VALID_STRING_ESCAPE: [["FOREGROUND", "--syn-escape"]], + DEFAULT_TAG: [["FOREGROUND", "--syn-tag"]], + DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]], + TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]], + BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]], + RAINBOW_COLOR1: [["FOREGROUND", "--syn-rainbow-1"]], + RAINBOW_COLOR2: [["FOREGROUND", "--syn-rainbow-2"]], + RAINBOW_COLOR3: [["FOREGROUND", "--syn-rainbow-3"]], + RAINBOW_COLOR4: [["FOREGROUND", "--syn-rainbow-4"]], + RAINBOW_COLOR5: [["FOREGROUND", "--syn-rainbow-5"]], + ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]], + WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]], + NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]], + MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]], + SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]], + TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]], + IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]], + WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]], + FOLDED_TEXT_ATTRIBUTES: [ + ["FOREGROUND", "--fg-fold-placeholder"], + ["BACKGROUND", "--bg-fold-placeholder"], + ], + INLINE_PARAMETER_HINT: [ + ["FOREGROUND", "--fg-inlay"], + ["BACKGROUND", "--bg-inlay"], + ], + BREAKPOINT_ATTRIBUTES: [["BACKGROUND", "--bg-breakpoint-line"]], + DEBUGGER_INLINED_VALUES: [["FOREGROUND", "--syn-inline-value"]], + "PY.SELF_PARAMETER": [["FOREGROUND", "--syn-self"]], + "PY.KEYWORD_ARGUMENT": [["FOREGROUND", "--syn-keyword-arg"]], + "PY.DOC_COMMENT": [["FOREGROUND", "--syn-docstring"]], + "PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]], + MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]], + MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]], + MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]], + CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]], + CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]], + CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]], + CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]], + CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]], + CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]], + CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]], + CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]], + CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]], + CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]], + CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]], + CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]], + CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]], + CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]], + CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]], + CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]], +}; + +export interface ConvertedTheme { + theme: { id: string; name: string; kind: "dark" | "light"; tokens: Record }; + scheme: { name: string; tokens: Record } | null; +} + +type Json = string | number | boolean | null | Json[] | { [k: string]: Json }; + +/** `"Button": {"default": {"startBackground": x}}` → `Button.default.startBackground`; `"*": {...}` → `*.prop`. */ +function flatten(obj: { [k: string]: Json }, prefix = "", out = new Map()): Map { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.set(key, v); + else if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); + } + return out; +} + +const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +/** IntelliJ colour (`#rrggbb`, `#rrggbbaa`, or a named colour from `colors`) → CSS hex. */ +function resolveColor(value: string, named: Map, depth = 0): string | null { + const m = HEX.exec(value.trim()); + if (m) return `#${m[1]!.toLowerCase()}`; + const ref = named.get(value); + return ref !== undefined && depth < 8 ? resolveColor(ref, named, depth + 1) : null; +} + +/** `.icls` hex: RGB without `#`, leading zeros dropped (`"0"` is black, `"ff"` is #0000ff). */ +function iclsColor(value: string): string | null { + const v = value.trim().toLowerCase(); + if (!/^[0-9a-f]{1,8}$/.test(v)) return null; + return `#${v.length <= 6 ? v.padStart(6, "0") : v.padStart(8, "0")}`; +} + +const attr = (tag: string, name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1]; + +/** Parses the colours and attributes of an `.icls` file into tokens. */ +export function convertIcls(xml: string): { name: string; dark: boolean | null; tokens: Record } { + const tokens: Record = {}; + const set = (names: string[], color: string | null) => { + if (color) for (const n of names) tokens[n] ??= color; + }; + const schemeTag = /]*>/.exec(xml)?.[0] ?? ""; + const name = attr(schemeTag, "name") ?? "Imported"; + const parent = attr(schemeTag, "parent_scheme") ?? ""; + const dark = parent ? /darcula|dark/i.test(parent) : null; + + const colors = /([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? ""; + for (const m of colors.matchAll(/]*\/>/g)) { + const key = attr(m[0], "name"); + const value = attr(m[0], "value"); + if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value)); + } + + const attributes = /([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? ""; + for (const m of attributes.matchAll(/