Merge origin/main into feat/agents

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai committed 2026-10-27 14:44:01 +08:00
commit 4548cf05ea
286 files changed
+16464 -993

No files matched your search

Generated
+37 -29
View File
@@ -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",
+2 -1
View File
@@ -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"] }
+8
View File
@@ -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]
+293
View File
@@ -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<PathBuf> {
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<u8> {
let mut units: Vec<u16> = 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<PathBuf> {
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<u16> = 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(&current)));
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<PathBuf> {
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<PathBuf> {
Vec::new()
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(list: &[&str]) -> Vec<PathBuf> {
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::<PathBuf>::new());
// A lone terminator is enough for the reader to stop.
assert_eq!(decode(&[0u8; HEADER]), Vec::<PathBuf>::new());
}
#[test]
fn a_truncated_or_foreign_block_reads_as_no_paths() {
assert_eq!(decode(&[]), Vec::<PathBuf>::new());
assert_eq!(decode(&[1, 2, 3]), Vec::<PathBuf>::new());
let mut far = encode(&paths(&["C:/a"]));
far[0] = 0xff; // pFiles past the end
assert_eq!(decode(&far), Vec::<PathBuf>::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"]));
}
}
@@ -64,6 +64,10 @@ pub struct InspectionOverride {
pub enabled: Option<bool>,
#[specta(optional)]
pub severity: Option<InspectionLevel>,
/// The inspection's own options (Typo: `processCode`, `processLiterals`,
/// `processComments`); absent ones keep their defaults.
#[specta(optional)]
pub options: Option<BTreeMap<String, bool>>,
}
/// 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::<InspectionSettings>(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);
}
}
+23
View File
@@ -289,9 +289,18 @@ pub struct EditorSettings {
pub color_scheme: String,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// 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 =
+15 -4
View File
@@ -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<String>,
}
/// 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<R: AsyncRead + Unpin + Send + 'static>(
});
}
const LOOPBACK: &str = "127.0.0.1";
async fn connect_with_retries(
host: &str,
port: u16,
mut child: Option<&mut Child>,
timeout: Duration,
) -> IdeResult<tokio::net::TcpStream> {
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;
+97
View File
@@ -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=<ide-host>:<port>`) 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<TcpListener> {
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<u16> {
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);
}
}
+33 -9
View File
@@ -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": <port>` 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<String>,
}
@@ -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<String>,
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<Option<u16>> {
/// 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<Option<DebugServer>> {
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::<u64>().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<String> = 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.
+46 -2
View File
@@ -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<Value> {
h.log
.lock()
+11 -4
View File
@@ -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<u64> = None;
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = 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<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool);
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
/// 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),
})
}
}
+137
View File
@@ -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<String, BTreeMap<String, Vec<HistoryEntry>>>;
/// 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<HistoryEntry> {
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<HistoryEntry>| v.into_iter().map(|e| e.text).collect::<Vec<_>>();
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));
}
}
+211 -10
View File
@@ -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<String>,
}
#[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<RoutineInfo>,
pub sequences: Vec<SequenceInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -68,6 +134,7 @@ pub struct TableDetails {
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKey>,
pub triggers: Vec<TriggerInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -148,6 +215,7 @@ pub struct DbManager {
known_hosts: PathBuf,
ssh_config: Option<PathBuf>,
saved_passwords: SecretStore,
history: Arc<HistoryStore>,
/// Passwords entered this session (not saved, or not yet), by credential key.
session_passwords: Mutex<HashMap<String, String>>,
/// 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<PathBuf>,
) -> 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<Databases> {
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<Vec<Option<String>>>| 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<String> =
first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
if !names.contains(&current) {
@@ -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<SchemaObjects> {
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<String> {
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<String> = 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<QueryResult> {
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<Vec<HistoryEntry>> {
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<Vec<String>>) -> 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<Vec<PlanNode>> {
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<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
+268
View File
@@ -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<u32>,
/// `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<PlanProperty>,
}
/// 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<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Postgres => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().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<String>) -> 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<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = 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<u32>, out: &mut Vec<PlanNode>) {
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::<Vec<_>>()
.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<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], 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<Vec<Option<&str>>>) -> 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"));
}
}
+17 -2
View File
@@ -77,6 +77,8 @@ pub struct DbOptions {
pub keep_alive_secs: Option<u32>,
/// Disconnect after this long without statements (enforced by the UI, which sees the activity).
pub auto_disconnect_secs: Option<u32>,
/// 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<String>,
pub all_schemas: bool,
pub schemas: Vec<String>,
/// `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(),
}
}
}
+154 -7
View File
@@ -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<String> {
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<String> {
(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 = [
+52 -1
View File
@@ -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::<Vec<_>>();
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;
+3 -1
View File
@@ -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),
}
+4
View File
@@ -74,6 +74,9 @@ pub struct JavaLaunchInfo {
pub java_home: Option<ide_core::IpcPath>,
/// The `java` launcher to use (filled in by the caller from the installed JDKs).
pub java_exec: Option<String>,
/// 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<String>,
/// First compile errors of the workspace when the build had errors.
pub errors: Vec<JavaCompileError>,
/// 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(),
+1
View File
@@ -15,6 +15,7 @@ pub mod jdtls;
mod launch;
mod server;
mod text;
mod tsserver_bridge;
pub mod types;
mod uri;
mod virtual_doc;
+114 -13
View File
@@ -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<State>,
phase: watch::Sender<Phase>,
status: Mutex<(ServerStatus, Option<String>)>,
crashes: Mutex<VecDeque<Instant>>,
/// Consecutive crashes of short-lived instances.
crashes: Mutex<usize>,
/// When the current instance was launched.
started: Mutex<Option<Instant>>,
stderr: Mutex<VecDeque<String>>,
pub progress: Mutex<HashMap<String, Progress>>,
diagnostics: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
@@ -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<Duration> {
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::<Vec<_>>().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.<anonymous> (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");
}
}
+93
View File
@@ -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<Server>, generation: u64, params: Value) {
let Some(me) = self.me.upgrade() else { return };
for (id, command, args) in parse_requests(&params) {
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<Value> {
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());
}
}
+72
View File
@@ -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 = "<script setup lang=\"ts\">\nconst count: number = 1\n</script>\n\n<template>\n <div>{{ count }}</div>\n</template>\n";
fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Value>) -> 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();
}
}
+7 -2
View File
@@ -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<Registrations,
regs.add_language(cfg);
}
for f in &m.contributes.themes {
let theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
if let Some(scheme) = &f.editor_scheme {
theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
}
regs.add_theme(theme);
}
Ok(regs)
+25 -5
View File
@@ -64,7 +64,7 @@ pub struct Contributions {
#[serde(default)]
pub languages: Vec<FileDecl>,
#[serde(default)]
pub themes: Vec<FileDecl>,
pub themes: Vec<ThemeDecl>,
}
#[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<PathBuf>,
}
impl PluginManifest {
pub fn parse(src: &str) -> Result<Self, String> {
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"));
}
+94 -1
View File
@@ -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<String, String>,
}
/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme.
#[derive(Deserialize)]
struct EditorScheme {
tokens: BTreeMap<String, String>,
}
impl ThemeDef {
pub fn parse(src: &str) -> Result<Self, String> {
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());
}
}
+36
View File
@@ -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());
+1
View File
@@ -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"
+537
View File
@@ -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<u32>, 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 <class_paths> [--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<u32> {
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 (`<home>/bin/java`, or
/// JDK 8's `<home>/jre/bin/java`), following symlinks; `None` when unknown.
pub fn java_feature_version(java: &Path) -> Option<u32> {
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<usize>, Option<usize>) {
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<String>,
pub file: PathBuf,
pub how: Shortening,
}
/// Shortens `java [options] -cp <classpath> 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<Option<Shortened>> {
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::<Vec<_>>());
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<PathBuf> {
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<PathBuf> = 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<String> = 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<String> {
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<PathBuf> = (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<String> = (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<PathBuf> = (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");
}
}
+1
View File
@@ -3,6 +3,7 @@
mod config;
mod detect;
pub mod envfile;
pub mod java_cmdline;
pub mod junit;
mod process;
mod utf8;
+44 -2
View File
@@ -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<TempFile> {
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(&quote(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<PathBuf>);
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<String>) -> 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<RunInfo> {
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}")))?;
+35
View File
@@ -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<String> = (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");
}
+1
View File
@@ -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
+33 -1
View File
@@ -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);
+26 -20
View File
@@ -394,27 +394,33 @@ fn release_value(release: &str, key: &str) -> Option<String> {
}
fn jdk() -> Vec<Sdk> {
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
+102 -9
View File
@@ -49,9 +49,26 @@ fn children(dir: &Path) -> Vec<PathBuf> {
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<Vec<PathBuf>> = 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<Jdk> {
let mut homes: Vec<PathBuf> = Vec::new();
if let Some(h) = std::env::var_os("JAVA_HOME") {
@@ -74,6 +91,7 @@ fn scan() -> Vec<Jdk> {
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<u32> = 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<String> = 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<u32>) -> Option<String> {
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: <dir>/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]
+2 -1
View File
@@ -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.
+35 -1
View File
@@ -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<PathBuf> {
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"));
+303 -21
View File
@@ -64,7 +64,8 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
}
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
/// options) wins, then `managed` (the IDE-installed jar), then the build caches
/// options) wins, then `managed` (the IDE-installed jar, always attached: it is inert
/// without Lombok on the classpath), then, for a project using Lombok, the build caches
/// (`maven_repo`, the configured Maven local repository, else the default one).
pub fn lombok_agent(
root: &Path,
@@ -80,15 +81,16 @@ pub fn lombok_agent(
.map(str::to_owned);
return LombokAgent::Found { jar, version };
}
if !uses_lombok(root) {
return LombokAgent::Unused;
}
if let Some(jar) = managed {
return LombokAgent::Found { jar, version: None };
}
let home = home_dir();
let maven_repo = maven_repo.or_else(|| maven_repository(home.as_deref()));
if !uses_lombok(root, maven_repo.as_deref()) {
return LombokAgent::Unused;
}
let mut dirs = Vec::new();
if let Some(repo) = maven_repo.or_else(|| maven_repository(home.as_deref())) {
if let Some(repo) = maven_repo {
dirs.push(repo.join("org").join("projectlombok").join("lombok"));
}
if let Some(gradle) =
@@ -102,11 +104,31 @@ pub fn lombok_agent(
}
}
/// Build files under `root` (a few levels, for multi-module builds) mentioning Lombok.
pub fn uses_lombok(root: &Path) -> 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 `<parent>` 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<PathBuf>) -> bool {
fn walk(dir: &Path, depth: usize, poms: &mut Vec<PathBuf>) -> 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 `<parent>`: 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(&current, &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 `<relativePath>`
/// (default `../pom.xml`, none when empty) if it is that parent, else
/// `<groupId>/<artifactId>/<version>/<artifactId>-<version>.pom` in the local repository.
fn parent_pom(pom: &Path, text: &str, maven_repo: Option<&Path>) -> Option<PathBuf> {
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("<relativePath/>") || block.contains("<relativePath />") {
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 `<parent>…</parent>` element and the text without it.
fn split_parent(text: &str) -> Option<(&str, String)> {
let start = text.find("<parent>")?;
let end = start + text[start..].find("</parent>")? + "</parent>".len();
Some((&text[start..end], format!("{}{}", &text[..start], &text[end..])))
}
/// A POM's own `<artifactId>`, not its parent's.
fn own_artifact_id(text: &str) -> Option<String> {
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()))
}
/// `<localRepository>` 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"), "<project><modules><module>api</module></modules></project>").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"), "<artifactId>lombok</artifactId>").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"), "<artifactId>lombok</artifactId>").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 = "<dependencies><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency></dependencies>";
fn pom(own: &str, parent: &str) -> String {
format!("<project><modelVersion>4.0.0</modelVersion>{parent}<artifactId>{own}</artifactId></project>")
}
fn parent(artifact: &str, extra: &str) -> String {
format!(
"<parent><groupId>com.acme</groupId><artifactId>{artifact}</artifactId><version>1.0</version>{extra}</parent>"
)
}
#[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!("<project><artifactId>acme-root</artifactId>{LOMBOK_DEP}</project>"),
)
.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", "<relativePath>../../../platform</relativePath>")),
)
.unwrap();
fs::write(
tmp.path().join("platform/pom.xml"),
format!("<project><artifactId>platform</artifactId>{LOMBOK_DEP}</project>"),
)
.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!("<project><artifactId>unrelated</artifactId>{LOMBOK_DEP}</project>"),
)
.unwrap();
assert!(!uses_lombok(&child, None));
fs::write(
other.path().join("pom.xml"),
format!("<project><artifactId>acme-parent</artifactId>{LOMBOK_DEP}</project>"),
)
.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", "<relativePath/>"))).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", "<relativePath>pom.xml</relativePath>")))
.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]
+52
View File
@@ -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())]);
}
+121
View File
@@ -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<PathBuf> {
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 `<prefix>/bin` or a symlink into the package.
fn package_dir(bin: &Path, package: &str) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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: <prefix>/bin + <prefix>/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")));
}
}
+3
View File
@@ -13,3 +13,6 @@ tracing.workspace = true
spellbook = "0.4"
serde.workspace = true
specta.workspace = true
[dev-dependencies]
serde_json.workspace = true
+42
View File
@@ -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
+278 -18
View File
@@ -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<RegionKind> 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<HashSet<&'static str>> = 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<HashSet<&'static str>> = 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())
}
/// `<stem>er(s)` / `<stem>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<Vec<Typo>> {
let mut spans = Vec::new();
let mut verdicts: HashMap<&str, bool> = HashMap::new();
let mut bad: Vec<WordSpan> = 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, &regions, &|| 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()));
+477
View File
@@ -0,0 +1,477 @@
//! JDK downloads (IDEA's Download JDK): Eclipse Temurin builds from the
//! Adoptium API, unpacked into `<tools>/jdks/<vendor>-<version>` (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<JdkVendor> {
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<u32>,
/// The long-term-support ones among them.
pub lts: Vec<u32>,
/// 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<String>) -> 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<u32>,
#[serde(default)]
available_lts_releases: Vec<u32>,
#[serde(default)]
most_recent_lts: Option<u32>,
}
pub fn parse_releases(json: &str) -> IdeResult<JdkReleases> {
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<String>,
#[serde(default)]
heap_size: Option<String>,
#[serde(default)]
os: Option<String>,
package: Package,
}
#[derive(Deserialize)]
struct Package {
name: String,
link: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
size: Option<u64>,
}
#[derive(Deserialize)]
struct AssetVersion {
major: u32,
#[serde(default)]
minor: u32,
#[serde(default)]
security: u32,
#[serde(default)]
openjdk_version: Option<String>,
}
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<Option<(Download, u32)>> {
let assets: Vec<Asset> =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" };
let os = adoptium_os(platform.os);
let found = assets.into_iter().find(|a| {
a.binary.image_type.as_deref().is_none_or(|t| t == "jdk")
&& a.binary.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<PathBuf> {
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<PathBuf> = 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> {
Http::new(&self.system_path)
}
/// Java versions `vendor` publishes.
pub fn jdk_releases(&self, vendor: &str, cancel: &CancellationToken) -> IdeResult<JdkReleases> {
vendor_or_err(vendor)?;
parse_releases(&self.http()?.get_text(&adoptium_releases_url(), cancel)?)
}
/// Downloads the latest `vendor` JDK of Java `feature` into
/// `<location>/<vendor>-<version>` (`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<DownloadedJdk> {
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::<u32>().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);
}
}
+83 -36
View File
@@ -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<String> {
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<PathBuf> {
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;
+7 -3
View File
@@ -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: &[],
+48
View File
@@ -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();
+79 -4
View File
@@ -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<Vec<ChangedFile>> {
let repo = r.gix()?;
let id = resolve(&repo, rev)?;
Ok(first_parent_changes(&repo, rev)?.files)
}
struct CommitChanges {
id: ObjectId,
parent: Option<ObjectId>,
files: Vec<ChangedFile>,
}
/// Files changed by `rev` relative to its first parent (renames detected), sorted by path.
fn first_parent_changes(repo: &gix::Repository, rev: &str) -> IdeResult<CommitChanges> {
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<Vec<ChangedFile>> {
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<String>,
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<Vec<RevisionChange>> {
let repo = r.gix()?;
let mut merged: Vec<Merged> = 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<RevisionChange> = 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)
}
+20
View File
@@ -205,6 +205,21 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// 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<String>,
/// Commit holding the old content; `None` when the file was added.
pub before: Option<String>,
/// Commit holding the new content; `None` when the file was deleted.
pub after: Option<String>,
}
#[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<Vec<ChangedFile>> {
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<Vec<RevisionChange>> {
history::commits_changes(self, commits)
}
/// Local and remote branches; the current branch first, then most recent first.
pub fn branches(&self) -> IdeResult<Vec<Branch>> {
refs::branches(self)
+61
View File
@@ -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<String> = 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::<Vec<_>>()
};
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();
+567
View File
@@ -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 <you@example.com>"]
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
`<app-data>/plugins/<id>/`.
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
`<project>/.openidea/plugins/<name>/` 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.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
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` |
<!-- END GENERATED TOKEN REFERENCE -->
+10 -2
View File
@@ -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();
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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"
+5 -2
View File
@@ -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",
+8 -2
View File
@@ -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
+32
View File
@@ -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/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])
'@tanstack/react-virtual':
specifier: ^3.14.21
version: 3.14.21([email protected]([email protected]))([email protected])
@@ -571,6 +574,18 @@ packages:
engines: {node: '>=20'}
hasBin: true
'@replit/[email protected]':
resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==}
'@replit/[email protected]':
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/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==}
@@ -1302,18 +1320,21 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
@@ -2382,6 +2403,17 @@ snapshots:
dependencies:
playwright: 1.64.1
'@replit/[email protected]': {}
'@replit/[email protected](@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])':
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/[email protected]':
optional: true
+14
View File
@@ -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}`);
+52
View File
@@ -0,0 +1,52 @@
/**
* Converts an IntelliJ theme into an OpenIDEA theme plugin package.
*
* pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]
*
* Writes <dir>/plugin.toml and <dir>/themes/<id>.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.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]",
);
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.`,
);
+323
View File
@@ -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<string, string[]> = {
"--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` `<colors>` options → tokens. */
export const ICLS_COLORS: Record<string, string[]> = {
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` `<attributes>` options → [part, token] pairs. */
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
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<string, string> };
scheme: { name: string; tokens: Record<string, string> } | 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<string, string>()): Map<string, string> {
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<string, string>, 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<string, string> } {
const tokens: Record<string, string> = {};
const set = (names: string[], color: string | null) => {
if (color) for (const n of names) tokens[n] ??= color;
};
const schemeTag = /<scheme\b[^>]*>/.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 = /<colors>([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? "";
for (const m of colors.matchAll(/<option\b[^>]*\/>/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 = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
for (const m of attributes.matchAll(/<option name="([^"]+)"[^>]*>\s*<value>([\s\S]*?)<\/value>\s*<\/option>/g)) {
const map = ICLS_ATTRIBUTES[m[1]!];
if (!map) continue;
const parts = new Map<string, string>();
for (const o of m[2]!.matchAll(/<option\b[^>]*\/>/g)) {
const k = attr(o[0], "name");
const v = attr(o[0], "value");
if (k && v !== undefined) parts.set(k, v);
}
for (const [part, token] of map) set([token], iclsColor(parts.get(part) ?? ""));
// FONT_TYPE: 1 bold, 2 italic, 3 bold italic.
if (m[1] === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
tokens["--syn-comment-style"] = Number(parts.get("FONT_TYPE")) & 2 ? "italic" : "normal";
}
}
if (tokens["--wave-error"]) tokens["--editor-error-decoration"] = `underline wavy ${tokens["--wave-error"]}`;
if (tokens["--bg-editor"]) tokens["--bg-editor-gutter"] ??= tokens["--bg-editor"];
return { name, dark, tokens };
}
export function slug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "imported"
);
}
/** Converts an IntelliJ `.theme.json` (and optionally its `.icls`) into OpenIDEA theme files. */
export function convertIntellijTheme(themeJson: string, icls?: string, id?: string): ConvertedTheme {
const src = JSON.parse(stripJsonComments(themeJson)) as { [k: string]: Json };
const name = typeof src.name === "string" ? src.name : "Imported Theme";
const named = new Map<string, string>();
if (src.colors && typeof src.colors === "object" && !Array.isArray(src.colors)) {
for (const [k, v] of Object.entries(src.colors)) if (typeof v === "string") named.set(k, v);
}
const ui =
src.ui && typeof src.ui === "object" && !Array.isArray(src.ui) ? flatten(src.ui) : new Map<string, string>();
const lookup = (key: string): string | null => {
const v = ui.get(key) ?? ui.get(`*.${key.slice(key.lastIndexOf(".") + 1)}`);
return v === undefined ? null : resolveColor(v, named);
};
const tokens: Record<string, string> = {};
for (const [token, keys] of Object.entries(UI_KEYS)) {
for (const key of keys) {
const color = lookup(key);
if (color) {
tokens[token] = color;
break;
}
}
}
const scheme = icls ? convertIcls(icls) : null;
const dark = typeof src.dark === "boolean" ? src.dark : (scheme?.dark ?? true);
// The editor surface sits on an island in OpenIDEA: keep it on the editor background.
if (scheme?.tokens["--bg-editor"] && !tokens["--bg-island"]) tokens["--bg-island"] = scheme.tokens["--bg-editor"];
return {
theme: { id: id ?? slug(name), name, kind: dark ? "dark" : "light", tokens },
scheme: scheme ? { name: scheme.name, tokens: scheme.tokens } : null,
};
}
/** `plugin.toml` for a converted theme. */
export function pluginManifest(c: ConvertedTheme, author: string, sourceName: string): string {
const q = (s: string) => JSON.stringify(s);
return [
`# Converted from ${sourceName} by scripts/theme/import-intellij-theme.ts.`,
`id = ${q(`theme-${c.theme.id}`)}`,
`name = ${q(`${c.theme.name} Theme`)}`,
`version = "1.0.0"`,
`description = ${q(`${c.theme.name}, converted from an IntelliJ theme.`)}`,
`authors = [${q(author)}]`,
`openidea-api = "0.1"`,
"",
"[[contributes.themes]]",
`path = ${q(`themes/${c.theme.id}.theme.json`)}`,
...(c.scheme ? [`editor-scheme = ${q(`themes/${c.theme.id}.scheme.json`)}`] : []),
"",
].join("\n");
}
+182
View File
@@ -0,0 +1,182 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
convertIcls,
convertIntellijTheme,
ICLS_ATTRIBUTES,
ICLS_COLORS,
pluginManifest,
UI_KEYS,
} from "./intellij.ts";
import { referenceMarkdown, stripJsonComments, themeTokenGroups, tokenSets, withReference } from "./tokens.ts";
const read = (p: string) => readFileSync(p, "utf8");
const css = read("src/styles/tokens.css");
const groups = themeTokenGroups(css);
const sets = tokenSets(groups);
const TEMPLATE = "templates/theme-plugin";
const themeSrc = read(`${TEMPLATE}/themes/harbor-night.theme.json`);
const schemeSrc = read(`${TEMPLATE}/themes/harbor-night.scheme.json`);
const theme = JSON.parse(stripJsonComments(themeSrc)) as { id: string; kind: string; tokens: Record<string, string> };
const scheme = JSON.parse(stripJsonComments(schemeSrc)) as { tokens: Record<string, string> };
const sorted = (xs: Iterable<string>) => [...xs].sort();
describe("theme token reference", () => {
it("reads every variable of the Islands Light block, grouped", () => {
const lightBlock = /:root\[data-theme="light"\] \{([\s\S]*?)\n\}/.exec(css)![1]!;
const declared = new Set([...lightBlock.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
expect(sorted([...sets.ui, ...sets.scheme])).toEqual(sorted(declared));
expect(groups.map((g) => g.title)).toEqual(
expect.arrayContaining(["surfaces", "syntax", "editor", "ANSI palette"]),
);
expect(sets.scheme.every((t) => !sets.ui.includes(t))).toBe(true);
});
it("requires a section comment for every chunk of the light block", () => {
const bad = ':root { --a: #000; }\n:root[data-theme="light"] {\n /* ok */\n --a: #fff;\n\n --b: #fff;\n}';
expect(() => themeTokenGroups(bad)).toThrow(/--b .*section comment/);
});
it("is current in docs/plugins/themes.md (run pnpm gen:theme-docs)", () => {
const doc = read("docs/plugins/themes.md");
expect(withReference(doc, referenceMarkdown(groups))).toBe(doc);
});
});
describe("theme plugin template", () => {
it("sets every UI token in the theme file and every editor token in the scheme file", () => {
// A failure here means tokens.css gained (or lost) a themeable token:
// add it to templates/theme-plugin/themes/*.json, then run pnpm gen:theme-docs.
expect(sorted(Object.keys(theme.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(scheme.tokens))).toEqual(sorted(sets.scheme));
});
it("groups tokens under the section comments of tokens.css", () => {
for (const g of groups) {
const src = g.scheme ? schemeSrc : themeSrc;
const heading = `// ${g.title.charAt(0).toUpperCase()}${g.title.slice(1)}`;
expect(src, heading).toContain(heading);
const at = src.indexOf(heading);
for (const t of g.tokens) expect(src.indexOf(`"${t.name}"`), t.name).toBeGreaterThan(at);
}
});
it("uses only values the plugin host accepts", () => {
for (const [k, v] of Object.entries({ ...theme.tokens, ...scheme.tokens })) {
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
}
expect(theme.kind).toBe("dark");
});
it("is referenced by its manifest", () => {
const manifest = read(`${TEMPLATE}/plugin.toml`);
expect(manifest).toContain('path = "themes/harbor-night.theme.json"');
expect(manifest).toContain('editor-scheme = "themes/harbor-night.scheme.json"');
expect(manifest).not.toMatch(/^wasm\s*=/m);
});
});
describe("IntelliJ theme import", () => {
const themeJson = `{
// comment
"name": "Deep Sea",
"dark": true,
"author": "Someone",
"colors": { "base": "#101820", "accent": "#33AAFF" },
"ui": {
"*": { "background": "base", "foreground": "#c0c8d0", "selectionBackground": "#203040" },
"Button": { "default": { "startBackground": "accent" } },
"Popup.background": "#181f28",
"Label.infoForeground": "#80888F80",
"Tree": { "rowHeight": 24 }
}
}`;
const icls = `<scheme name="Deep Sea" version="142" parent_scheme="Darcula">
<colors>
<option name="CARET_COLOR" value="ffcc00" />
<option name="LINE_NUMBERS_COLOR" value="0" />
<option name="UNKNOWN_KEY" value="123456" />
</colors>
<attributes>
<option name="DEFAULT_KEYWORD">
<value>
<option name="FOREGROUND" value="cc7832" />
<option name="FONT_TYPE" value="1" />
</value>
</option>
<option name="DEFAULT_LINE_COMMENT">
<value>
<option name="FOREGROUND" value="808080" />
<option name="FONT_TYPE" value="2" />
</value>
</option>
<option name="TEXT">
<value>
<option name="FOREGROUND" value="a9b7c6" />
<option name="BACKGROUND" value="2b2b2b" />
</value>
</option>
<option name="ERRORS_ATTRIBUTES">
<value>
<option name="EFFECT_COLOR" value="ff0000" />
<option name="EFFECT_TYPE" value="2" />
</value>
</option>
<option name="DEFAULT_CONSTANT" baseAttributes="DEFAULT_IDENTIFIER" />
</attributes>
</scheme>`;
it("maps ui keys, named colours and wildcards", () => {
const { theme: t, scheme: s } = convertIntellijTheme(themeJson);
expect(t).toMatchObject({ id: "deep-sea", name: "Deep Sea", kind: "dark" });
expect(t.tokens["--bg-app"]).toBe("#101820"); // *.background → named colour
expect(t.tokens["--accent"]).toBe("#33aaff");
expect(t.tokens["--bg-popup"]).toBe("#181f28");
expect(t.tokens["--bg-selected"]).toBe("#203040");
expect(t.tokens["--fg-muted"]).toBe("#80888f80");
expect(t.tokens["--fg"]).toBe("#c0c8d0");
expect(s).toBeNull();
});
it("only targets tokens that exist in tokens.css", () => {
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
// Read with a fallback by the editor (no tokens.css default), still themeable.
declared.add("--border-method-separator");
const targets = [
...Object.keys(UI_KEYS),
...Object.values(ICLS_COLORS).flat(),
...Object.values(ICLS_ATTRIBUTES).flatMap((parts) => parts.map(([, token]) => token)),
];
expect(targets.filter((t) => !declared.has(t))).toEqual([]);
});
it("maps .icls colours and attributes", () => {
const s = convertIcls(icls);
expect(s.name).toBe("Deep Sea");
expect(s.dark).toBe(true);
expect(s.tokens).toMatchObject({
"--caret": "#ffcc00",
"--fg-line-number": "#000000",
"--syn-keyword": "#cc7832",
"--syn-comment": "#808080",
"--syn-comment-style": "italic",
"--fg-editor": "#a9b7c6",
"--bg-editor": "#2b2b2b",
"--bg-editor-gutter": "#2b2b2b",
"--wave-error": "#ff0000",
"--editor-error-decoration": "underline wavy #ff0000",
});
expect(s.tokens["--syn-constant"]).toBeUndefined();
});
it("produces an installable package", () => {
const c = convertIntellijTheme(themeJson.replace('"dark": true,', ""), icls.replace("Darcula", "Default"), "sea");
expect(c.theme.kind).toBe("light");
expect(c.theme.id).toBe("sea");
expect(c.theme.tokens["--bg-island"]).toBe("#101820"); // ToolWindow ← *.background wins over the editor
const toml = pluginManifest(c, "Someone", "DeepSea.theme.json");
expect(toml).toContain('id = "theme-sea"');
expect(toml).toContain('editor-scheme = "themes/sea.scheme.json"');
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* The themeable design tokens, read from `src/styles/tokens.css`: every
* variable of the `:root[data-theme="light"]` block (it redefines each colour
* token of the dark `:root`), grouped by the block's section comments.
* Sections named in `SCHEME_GROUPS` belong in a theme's editor colour scheme
* file, the rest in its UI theme file. Pure functions: no Node APIs.
*/
export interface ThemeToken {
name: string;
/** Islands Dark value (`:root`). */
dark: string;
/** Islands Light value. */
light: string;
}
export interface TokenGroup {
/** Section comment of tokens.css, without its parenthetical. */
title: string;
/** True when the group belongs in the editor colour scheme file. */
scheme: boolean;
tokens: ThemeToken[];
}
/** tokens.css sections that IntelliJ keeps in an editor scheme (`.icls`). */
export const SCHEME_GROUPS = ["syntax", "editor", "ANSI palette"];
const LIGHT_SELECTOR = ':root[data-theme="light"]';
/** Raw bodies (comments kept) of top-level rules whose selector list is exactly `selector`. */
function ruleBodies(css: string, selector: string): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
let head = "";
for (let i = 0; i < css.length; i++) {
if (css.startsWith("/*", i)) {
const end = css.indexOf("*/", i + 2);
i = end < 0 ? css.length : end + 1;
continue;
}
const ch = css[i];
if (ch === "{") {
if (depth === 0) {
head = css
.slice(start, i)
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim();
start = i + 1;
}
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
if (head === selector) out.push(css.slice(start, i));
start = i + 1;
}
}
}
return out;
}
const DECL = /^\s*(--[a-z0-9-]+)\s*:\s*(.+?);\s*$/;
function declarations(body: string): Map<string, string> {
const out = new Map<string, string>();
for (const line of body.replace(/\/\*[\s\S]*?\*\//g, "").split("\n")) {
const m = DECL.exec(line);
if (m) out.set(m[1]!, m[2]!);
}
return out;
}
/**
* Themeable tokens in tokens.css order. Throws when a blank-line separated
* chunk of the light block has no section comment, so new tokens always land
* in a named group.
*/
export function themeTokenGroups(css: string): TokenGroup[] {
const [lightBody] = ruleBodies(css, LIGHT_SELECTOR);
if (lightBody === undefined) throw new Error(`tokens.css has no ${LIGHT_SELECTOR} block`);
const dark = new Map<string, string>();
for (const body of ruleBodies(css, ":root")) for (const [k, v] of declarations(body)) dark.set(k, v);
const groups: TokenGroup[] = [];
const placed = new Map<string, TokenGroup>();
let current: TokenGroup | null = null;
let chunkHasComment = false;
const lines = lightBody.replace(/\/\*\s*\n[\s\S]*?\*\//g, "").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
chunkHasComment = false;
current = null;
continue;
}
const comment = /^\/\*\s*(.*?)\s*\*\/$/.exec(trimmed);
if (comment) {
const title = comment[1]!
.replace(/\s*\(.*$/, "")
.replace(/\s+as\s.*$/, "")
.trim();
current = { title, scheme: SCHEME_GROUPS.some((g) => title.startsWith(g)), tokens: [] };
groups.push(current);
chunkHasComment = true;
continue;
}
const m = DECL.exec(line);
if (!m) continue;
if (!current || !chunkHasComment) {
throw new Error(`tokens.css: ${m[1]} in ${LIGHT_SELECTOR} needs a section comment (/* group */) above its chunk`);
}
const name = m[1]!;
// A token listed twice stays in the later group (the redefinition wins in CSS too).
const prev = placed.get(name);
if (prev) prev.tokens = prev.tokens.filter((t) => t.name !== name);
current.tokens.push({ name, light: m[2]!, dark: dark.get(name) ?? m[2]! });
placed.set(name, current);
}
return groups.filter((g) => g.tokens.length > 0);
}
/** Every themeable token name, split into UI theme and editor scheme sets. */
export function tokenSets(groups: readonly TokenGroup[]): { ui: string[]; scheme: string[] } {
const ui: string[] = [];
const scheme: string[] = [];
for (const g of groups) (g.scheme ? scheme : ui).push(...g.tokens.map((t) => t.name));
return { ui, scheme };
}
const cell = (v: string) => "`" + v.replace(/\|/g, "\\|") + "`";
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Markdown token reference (one table per group) for docs/plugins/themes.md. */
export function referenceMarkdown(groups: readonly TokenGroup[]): string {
const { ui, scheme } = tokenSets(groups);
const parts = [
`${ui.length + scheme.length} themeable tokens: ${ui.length} in the UI theme file, ${scheme.length} in the editor colour scheme file.`,
];
for (const g of groups) {
parts.push(
`### ${capitalize(g.title)} (${g.scheme ? "editor scheme" : "UI theme"})`,
[
"| Token | Islands Dark | Islands Light |",
"|---|---|---|",
...g.tokens.map((t) => `| ${cell(t.name)} | ${cell(t.dark)} | ${cell(t.light)} |`),
].join("\n"),
);
}
return parts.join("\n\n");
}
export const REFERENCE_BEGIN = "<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->";
export const REFERENCE_END = "<!-- END GENERATED TOKEN REFERENCE -->";
/** `doc` with the generated reference section replaced. */
export function withReference(doc: string, reference: string): string {
const begin = doc.indexOf(REFERENCE_BEGIN);
const end = doc.indexOf(REFERENCE_END);
if (begin < 0 || end < begin) throw new Error("token reference markers not found");
return `${doc.slice(0, begin + REFERENCE_BEGIN.length)}\n\n${reference}\n\n${doc.slice(end)}`;
}
/** JSON with `//` and `/* *\/` comments removed outside strings (as the plugin host accepts). */
export function stripJsonComments(src: string): string {
let out = "";
let inStr = false;
let escaped = false;
for (let i = 0; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
out += c;
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inStr = false;
continue;
}
if (c === "/" && src[i + 1] === "/") {
const nl = src.indexOf("\n", i);
if (nl < 0) break;
i = nl - 1;
} else if (c === "/" && src[i + 1] === "*") {
const end = src.indexOf("*/", i + 2);
out += src.slice(i, end < 0 ? src.length : end).replace(/[^\n]/g, "") + " ";
i = end < 0 ? src.length : end + 1;
} else {
inStr = c === '"';
out += c;
}
}
return out;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
+1
View File
@@ -34,6 +34,7 @@ dirs.workspace = true
ide-core.workspace = true
dunce.workspace = true
ide-clipboard.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
ide-workspace.workspace = true
+22
View File
@@ -42,6 +42,28 @@ pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel
if let Some(g) = &mut model.gradle {
g.env = java_env(jdk.as_ref(), g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
Some(_) => None,
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
};
let explain = |error: &mut Option<String>, hint: Option<String>| {
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
*e = format!("{hint}\n{e}");
}
};
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = missing(g.java_version);
explain(&mut g.sync_error, hint);
}
model
}
+32
View File
@@ -0,0 +1,32 @@
//! The OS clipboard's file list, so the project view and Explorer exchange files.
use crate::commands::doc::blocking;
use ide_core::IdeResult;
use std::path::{Path, PathBuf};
/// Absolute paths on the clipboard, empty when it holds no file list.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_file_list() -> IdeResult<Vec<String>> {
blocking(|| Ok(ide_clipboard::files().iter().map(|p| slash(p)).collect())).await
}
/// Whether the clipboard holds files: cheap, for the Paste item's enabled state.
#[tauri::command]
#[specta::specta]
pub fn clipboard_has_files() -> bool {
ide_clipboard::has_files()
}
/// Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
/// false when the clipboard stayed busy; the caller keeps working either way.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_set_file_list(paths: Vec<PathBuf>) -> IdeResult<bool> {
blocking(move || Ok(ide_clipboard::set_files(&paths))).await
}
/// Paths cross the IPC boundary `/`-separated, as every other backend path does.
fn slash(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
+70 -1
View File
@@ -1,7 +1,10 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_db::split::SqlStatement;
use ide_db::{CompletionModel, DataSource, DbRowEdit, QueryResult, SortKey, TableDetails, TableInfo, TablePage};
use ide_db::{
CompletionModel, DataSource, DbObjectKind, DbRowEdit, HistoryEntry, PlanNode, QueryResult, SchemaObjects, SortKey,
TableDetails, TableInfo, TablePage,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{State, Window};
@@ -175,6 +178,72 @@ pub async fn db_ddl(
db.ddl(&id, &schema, &table).await
}
/// The routines and sequences of `schema` (the tree's `routines` / `sequences`).
#[tauri::command]
#[specta::specta]
pub async fn db_schema_objects(
window: Window,
state: State<'_, AppState>,
id: String,
schema: String,
) -> IdeResult<SchemaObjects> {
let db = state.project(window.label())?.db.clone();
db.schema_objects(&id, &schema).await
}
/// The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation).
#[tauri::command]
#[specta::specta]
pub async fn db_object_ddl(
window: Window,
state: State<'_, AppState>,
id: String,
schema: String,
kind: DbObjectKind,
name: String,
) -> IdeResult<String> {
let db = state.project(window.label())?.db.clone();
db.object_ddl(&id, &schema, kind, &name).await
}
/// Explain Plan: the execution plan of `statement` on `id`, without running it.
#[tauri::command]
#[specta::specta]
pub async fn db_explain(
window: Window,
state: State<'_, AppState>,
request: RequestId,
id: String,
statement: String,
) -> IdeResult<Vec<PlanNode>> {
let db = state.project(window.label())?.db.clone();
let cancel = state.begin_request(request);
let result = db.explain(&id, &statement, &cancel).await;
state.end_request(request);
result
}
/// The statements data source `id` ran, newest first (Browse Query History).
#[tauri::command]
#[specta::specta]
pub async fn db_history(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<Vec<HistoryEntry>> {
let db = state.project(window.label())?.db.clone();
db.history(&id).await
}
/// Removes statements from the query history of `id` (`None`: all of them).
#[tauri::command]
#[specta::specta]
pub async fn db_remove_history(
window: Window,
state: State<'_, AppState>,
id: String,
texts: Option<Vec<String>>,
) -> IdeResult<()> {
let db = state.project(window.label())?.db.clone();
db.remove_history(&id, texts).await
}
/// Writes exported result data (CSV, JSON, SQL…) to `path`.
#[tauri::command]
#[specta::specta]
+16
View File
@@ -264,6 +264,22 @@ pub async fn debug_attachable_processes() -> IdeResult<Vec<attach::AttachablePro
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
#[tauri::command]
#[specta::specta]
pub async fn debug_listen_for_jvm(
state: State<'_, AppState>,
request: ide_core::RequestId,
host: String,
port: u16,
) -> IdeResult<u16> {
let token = state.begin_request(request);
let result = jdwp::listen_for_jvm(&host, port, token).await;
state.end_request(request);
result
}
/// HotSwap (Reload Changed Classes) in a java-debug session.
#[tauri::command]
#[specta::specta]
+13 -7
View File
@@ -48,17 +48,23 @@ pub async fn java_resolve_launch(
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let vm_args = &settings.languages.java.jdtls_vm_args;
let lombok = ide_sdk::lombok::lombok_agent(
&project.root,
ide_sdk::lombok::agent_in_args(vm_args.split_whitespace()),
ide_tools::managed_bundle(&state.data_dir, "lombok"),
ide_sdk::apply::maven_local_repository(&project.root, &settings),
);
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&state.data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok =
tauri::async_runtime::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
Ok(info)
+15
View File
@@ -12,6 +12,7 @@ pub mod app;
pub mod build;
pub mod bytes;
pub mod caches;
pub mod clipboard;
pub mod coverage;
pub mod db;
pub mod debug;
@@ -84,6 +85,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
fs::fs_delete,
fs::fs_copy,
fs::fs_read_text,
clipboard::clipboard_file_list,
clipboard::clipboard_has_files,
clipboard::clipboard_set_file_list,
doc::doc_open,
doc::doc_open_virtual,
doc::doc_close,
@@ -168,6 +172,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_blame,
vcs::vcs_log,
vcs::vcs_commit_files,
vcs::vcs_commits_changes,
vcs::vcs_branches,
vcs::vcs_stashes,
vcs::vcs_stage,
@@ -263,6 +268,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_list,
tools::tools_install,
tools::tools_uninstall,
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_project_libraries,
@@ -301,6 +310,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_set_data_breakpoints,
debug::debug_data_breakpoint_info,
debug::debug_attachable_processes,
debug::debug_listen_for_jvm,
debug::debug_redefine_classes,
coverage::coverage_prepare,
coverage::coverage_collect,
@@ -370,6 +380,11 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
db::db_tables,
db::db_table_details,
db::db_ddl,
db::db_schema_objects,
db::db_object_ddl,
db::db_explain,
db::db_history,
db::db_remove_history,
db::db_completion,
db::db_export_file,
db::db_set_manual_commit,
+5 -2
View File
@@ -5,7 +5,7 @@ use crate::state::{AppState, Project};
use ide_core::settings::SpellingSettings;
use ide_core::{DocId, IdeError, IdeResult, RequestId};
use ide_language::SpellingKind;
use ide_spell::{Region, RegionKind, Speller, Typo};
use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo};
use std::path::{Path, PathBuf};
use tauri::{State, Window};
@@ -33,13 +33,15 @@ fn speller(project: &Project) -> Speller {
}
/// Misspelt words in the comments, string literals and declared names of
/// `doc` (all of its text for prose). Cancel with `cancel_request(request)`.
/// `doc` that `scope` includes (all of its text for prose). Cancel with
/// `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn spell_check(
window: Window,
state: State<'_, AppState>,
doc: DocId,
scope: SpellScope,
request: RequestId,
) -> IdeResult<Vec<Typo>> {
let project = state.project(window.label())?;
@@ -65,6 +67,7 @@ pub async fn spell_check(
SpellingKind::Name => RegionKind::Name,
},
})
.filter(|r| scope.includes(r.kind))
.collect()
};
if cancel.is_cancelled() {
+79 -1
View File
@@ -1,8 +1,11 @@
//! Language server / debug adapter installer (`ide-tools`).
//! Language server / debug adapter installer and JDK downloads (`ide-tools`).
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State, Window};
@@ -81,6 +84,81 @@ pub async fn tools_uninstall(
Ok(info)
}
/// JDK distributions Download JDK offers.
#[tauri::command]
#[specta::specta]
pub fn jdk_download_vendors() -> Vec<JdkVendor> {
ide_tools::jdk::vendors()
}
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn jdk_download_releases(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = tools(&app, &state, &window)?;
let cancel = state.begin_request(request);
let result =
tauri::async_runtime::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
state.end_request(request);
result?
}
/// Where Download JDK puts JDKs unless told otherwise.
#[tauri::command]
#[specta::specta]
pub fn jdk_download_location(app: AppHandle) -> IdeResult<ide_core::IpcPath> {
let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?;
Ok(ide_tools::jdk::jdks_dir(&data).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
/// and language servers waiting for a JDK (jdtls) start on it.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn jdk_download(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
vendor: String,
version: u32,
location: Option<PathBuf>,
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = tools(&app, &state, &window)?;
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = state.begin_request(request);
let result = tauri::async_runtime::spawn_blocking(move || {
let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| {
let _ = on_event.send(e);
})?;
let home = jdk.home.into_path_buf();
// A chosen location is scanned from now on too (the default one is from startup).
if let Some(location) = &location {
ide_sdk::java::add_jdk_root(location);
}
ide_sdk::java::rescan_jdks();
let jdk = ide_sdk::java::jdk_at(&home)
.ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?;
IdeResult::Ok(ide_sdk::jdk_sdk(&jdk))
})
.await
.map_err(join_err);
state.end_request(request);
let sdk = result??;
reconfigure_servers(&state);
Ok(sdk)
}
fn reconfigure_servers(state: &AppState) {
for project in state.projects() {
let lsp = project.lsp.clone();
+13
View File
@@ -121,6 +121,19 @@ pub async fn vcs_commit_files(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
#[tauri::command]
#[specta::specta]
pub async fn vcs_commits_changes(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
+1
View File
@@ -41,6 +41,7 @@ pub fn run() {
if let Some(data) = dirs::data_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
}
if let Some(refresh) = refresh_shell_path {
refresh();
+3
View File
@@ -630,6 +630,8 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("db.showToolWindow", "Database"),
act("db.newDataSource", "New Data Source…"),
act("db.openConsole", "Open Query Console"),
SEP,
act("vim.toggle", "Vim"),
],
));
@@ -1432,6 +1434,7 @@ mod tests {
"code.quickDefinition",
"code.quickTypeDefinition",
"localHistory.recentChanges",
"vim.toggle",
"editor.resetFontSize",
"diff.compareWithClipboard",
"view.quickSwitchScheme",
+5 -1
View File
@@ -399,7 +399,11 @@ impl AppState {
mark("recent");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let db = Arc::new(ide_db::DbManager::new(root.clone(), self.data_dir.join("db-passwords.json")));
let db = Arc::new(ide_db::DbManager::new(
root.clone(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
));
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
+13
View File
@@ -240,6 +240,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| b.build())
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
apply_zoom(app, &window);
// "Sync with OS" needs the OS mode, known once the window exists.
if let Some(a) = appearance.filter(|a| a.sync_with_os || a.theme == "system")
&& window.theme().is_ok_and(|t| t == Theme::Dark)
@@ -249,6 +250,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
Ok(window)
}
/// Sets the saved IDE zoom before the page loads; applied later by the frontend,
/// it would visibly scale up the splash.
fn apply_zoom(app: &AppHandle, window: &WebviewWindow) {
let Some(zoom) = app.try_state::<AppState>().map(|s| s.global_settings.effective().appearance.zoom) else {
return;
};
if zoom != 100 {
let _ = window.set_zoom(f64::from(zoom) / 100.0);
}
}
/// The first window (`create: false` in tauri.conf.json so it can take the theme's colour).
pub fn create_main_window(app: &AppHandle) -> IdeResult<WebviewWindow> {
build_project_window(app, window_config(app)?)
@@ -414,6 +426,7 @@ pub fn open_settings_window(
.and_then(|b| b.parent(&parent))
.and_then(|b| b.build())
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
apply_zoom(app, &window);
// window-state restores the remembered size; the position always follows the parent.
if let (Ok(pos), Ok(size), Ok(own), Ok(scale)) =
(parent.outer_position(), parent.outer_size(), window.outer_size(), parent.scale_factor())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.14",
"version": "0.1.15",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+188 -5
View File
@@ -101,6 +101,15 @@ export const commands = {
* definition). Prefers the unsaved buffer when the file is open.
*/
fsReadText: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("fs_read_text", { path })),
/** Absolute paths on the clipboard, empty when it holds no file list. */
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
/**
* Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
* false when the clipboard stayed busy; the caller keeps working either way.
*/
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
/**
* Opens a document. Async: reading and decoding a large file must not block
* the main thread (synchronous commands run on it).
@@ -234,9 +243,10 @@ export const commands = {
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
/**
* Misspelt words in the comments, string literals and declared names of
* `doc` (all of its text for prose). Cancel with `cancel_request(request)`.
* `doc` that `scope` includes (all of its text for prose). Cancel with
* `cancel_request(request)`.
*/
spellCheck: (doc: DocId, request: RequestId) => typedError<Typo[], IdeError>(__TAURI_INVOKE("spell_check", { doc, request })),
spellCheck: (doc: DocId, scope: SpellScope, request: RequestId) => typedError<Typo[], IdeError>(__TAURI_INVOKE("spell_check", { doc, scope, request })),
/** Replacements for a misspelt `word`, best first (Typo › Change to…). */
spellSuggest: (word: string) => typedError<string[], IdeError>(__TAURI_INVOKE("spell_suggest", { word })),
/**
@@ -329,6 +339,8 @@ export const commands = {
vcsBlame: (path: string) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
/** Combined changes of several commits, `commits` oldest first. */
vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError<RevisionChange[], IdeError>(__TAURI_INVOKE("vcs_commits_changes", { repo, commits })),
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
@@ -506,6 +518,19 @@ export const commands = {
toolsInstall: (name: string, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_install", { name, request, onEvent })),
/** Removes a tool the IDE installed. */
toolsUninstall: (name: string) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_uninstall", { name })),
/** JDK distributions Download JDK offers. */
jdkDownloadVendors: () => __TAURI_INVOKE<JdkVendor[]>("jdk_download_vendors"),
/** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */
jdkDownloadReleases: (vendor: string, request: RequestId) => typedError<JdkReleases, IdeError>(__TAURI_INVOKE("jdk_download_releases", { vendor, request })),
/** Where Download JDK puts JDKs unless told otherwise. */
jdkDownloadLocation: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("jdk_download_location")),
/**
* Downloads the latest `vendor` JDK of Java `version` into `location` (default:
* [`jdk_download_location`]), streaming progress through `on_event`; cancellable
* with `cancel_request(request)`. The JDK is then detected like an installed one,
* and language servers waiting for a JDK (jdtls) start on it.
*/
jdkDownload: (vendor: string, version: number, location: string | null, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<Sdk, IdeError>(__TAURI_INVOKE("jdk_download", { vendor, version, location, request, onEvent })),
/** Installed interpreters / toolchains of `kind`; project-local ones first. */
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
@@ -603,6 +628,11 @@ export const commands = {
debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError<DataBreakpointInfo, IdeError>(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })),
/** Attach to Process: the user's processes a debugger can attach to. */
debugAttachableProcesses: () => typedError<AttachableProcess[], IdeError>(__TAURI_INVOKE("debug_attachable_processes")),
/**
* Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
* JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
*/
debugListenForJvm: (request: RequestId, host: string, port: number) => typedError<number, IdeError>(__TAURI_INVOKE("debug_listen_for_jvm", { request, host, port })),
/** HotSwap (Reload Changed Classes) in a java-debug session. */
debugRedefineClasses: (session: DebugSessionId) => typedError<HotSwapResult, IdeError>(__TAURI_INVOKE("debug_redefine_classes", { session })),
/**
@@ -759,6 +789,16 @@ export const commands = {
dbTableDetails: (id: string, schema: string, table: string) => typedError<DbTableDetails, IdeError>(__TAURI_INVOKE("db_table_details", { id, schema, table })),
/** The DDL of table or view `table` (Go to DDL, Generate DDL). */
dbDdl: (id: string, schema: string, table: string) => typedError<string, IdeError>(__TAURI_INVOKE("db_ddl", { id, schema, table })),
/** The routines and sequences of `schema` (the tree's `routines` / `sequences`). */
dbSchemaObjects: (id: string, schema: string) => typedError<DbSchemaObjects, IdeError>(__TAURI_INVOKE("db_schema_objects", { id, schema })),
/** The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation). */
dbObjectDdl: (id: string, schema: string, kind: DbObjectKind, name: string) => typedError<string, IdeError>(__TAURI_INVOKE("db_object_ddl", { id, schema, kind, name })),
/** Explain Plan: the execution plan of `statement` on `id`, without running it. */
dbExplain: (request: RequestId, id: string, statement: string) => typedError<DbPlanNode[], IdeError>(__TAURI_INVOKE("db_explain", { request, id, statement })),
/** The statements data source `id` ran, newest first (Browse Query History). */
dbHistory: (id: string) => typedError<DbHistoryEntry[], IdeError>(__TAURI_INVOKE("db_history", { id })),
/** Removes statements from the query history of `id` (`None`: all of them). */
dbRemoveHistory: (id: string, texts: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("db_remove_history", { id, texts })),
/** Tables, columns and foreign keys for SQL completion. */
dbCompletion: (id: string) => typedError<DbCompletion, IdeError>(__TAURI_INVOKE("db_completion", { id })),
/** Writes exported result data (CSV, JSON, SQL…) to `path`. */
@@ -1710,6 +1750,12 @@ export type DbForeignKey = {
refColumns: string[],
};
export type DbHistoryEntry = {
text: string,
/** Milliseconds since the Unix epoch. */
time: number | null,
};
export type DbIndexInfo = {
name: string,
unique: boolean,
@@ -1722,10 +1768,20 @@ export type DbIntrospection = {
databases?: string[],
allSchemas?: boolean,
schemas?: string[],
/** `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases. */
/** `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…). */
showSystem?: boolean,
/** PostgreSQL template databases (`template0`, `template1`). */
showTemplates?: boolean,
/**
* Object filter: the tree lists only tables, views, routines and sequences matching
* one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all.
*/
objectFilter?: string,
};
/** Schema objects other than tables and columns (Go to DDL, Drop). */
export type DbObjectKind = "function" | "procedure" | "trigger" | "sequence";
/** Options tab: session settings applied to every connection. */
export type DbOptions = {
transaction?: DbTxMode,
@@ -1739,6 +1795,23 @@ export type DbOptions = {
keepAliveSecs?: number | null,
/** Disconnect after this long without statements (enforced by the UI, which sees the activity). */
autoDisconnectSecs?: number | null,
/** PostgreSQL: show only the configured database, its schemas right under the data source. */
singleDatabase?: boolean,
};
/** One operation of a plan; `parent` indexes the plan's node list (roots: `None`). */
export type DbPlanNode = {
parent: number | null,
/** `Full Scan`, `Index Scan`, `Hash Join`… */
operation: string,
/** The object it works on and its key details (`table: o;`). */
params: string,
properties: DbPlanProperty[],
};
export type DbPlanProperty = {
name: string,
value: string,
};
/**
@@ -1761,6 +1834,8 @@ export type DbQueryResult = {
/** More rows exist beyond the limit. */
hasMore: boolean,
elapsedMs: number | null,
/** Until the server's first answer; the rest of `elapsed_ms` is fetching. */
executionMs: number | null,
};
export type DbResultColumn = {
@@ -1768,12 +1843,32 @@ export type DbResultColumn = {
typeName: string,
};
export type DbRoutineInfo = {
name: string,
/** `Function` or `Procedure`. */
kind: DbObjectKind,
arguments: string,
/** Result type of a function. */
returns: string | null,
};
/**
* A pending change of the table editor. `key` identifies the row: its primary
* key columns with their original values.
*/
export type DbRowEdit = { kind: "update"; key: DbCellValue[]; values: DbCellValue[] } | { kind: "insert"; values: DbCellValue[] } | { kind: "delete"; key: DbCellValue[] };
/** A schema's routines and sequences (the tree's `routines` / `sequences` folders). */
export type DbSchemaObjects = {
routines: DbRoutineInfo[],
sequences: DbSequenceInfo[],
};
export type DbSequenceInfo = {
name: string,
dataType: string,
};
export type DbSortKey = {
column: string,
descending: boolean,
@@ -1816,6 +1911,7 @@ export type DbTableDetails = {
columns: DbColumnInfo[],
indexes: DbIndexInfo[],
foreignKeys: DbForeignKey[],
triggers: DbTriggerInfo[],
};
export type DbTableInfo = {
@@ -1823,7 +1919,7 @@ export type DbTableInfo = {
kind: DbTableKind,
};
export type DbTableKind = "table" | "view";
export type DbTableKind = "table" | "view" | "materializedView";
export type DbTablePage = {
result: DbQueryResult,
@@ -1831,6 +1927,14 @@ export type DbTablePage = {
offset: number | null,
};
export type DbTriggerInfo = {
name: string,
/** `before`, `after`, `instead of`. */
timing: string,
/** `insert`, `update or delete`… */
events: string,
};
/** Transaction control of the data source's consoles. */
export type DbTxMode = "auto" | "manual";
@@ -2194,9 +2298,20 @@ export type EditorSettings_Deserialize = {
colorScheme?: string,
/** Color Scheme edits: scheme id → attribute id → override. */
colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } },
/**
* Color Scheme › Language Defaults › Semantic highlighting: local variables and
* parameters get a colour derived from their name (IDEA's rainbow identifiers).
*/
semanticHighlighting?: boolean,
/** Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth. */
rainbowBrackets?: boolean,
ligatures?: boolean,
/** Fallback font for glyphs the main font lacks; empty = none. */
fallbackFont?: string,
/** Typography Settings › Main font weight (CSS 100–900), as IDEA. */
fontWeight?: number,
/** Typography Settings › Bold font weight: wherever the editor renders bold. */
boldFontWeight?: number,
general?: EditorGeneralSettings,
appearance?: EditorAppearanceSettings,
breadcrumbs?: BreadcrumbsSettings,
@@ -2230,9 +2345,20 @@ export type EditorSettings_Serialize = {
colorScheme: string,
/** Color Scheme edits: scheme id → attribute id → override. */
colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } },
/**
* Color Scheme › Language Defaults › Semantic highlighting: local variables and
* parameters get a colour derived from their name (IDEA's rainbow identifiers).
*/
semanticHighlighting: boolean,
/** Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth. */
rainbowBrackets: boolean,
ligatures: boolean,
/** Fallback font for glyphs the main font lacks; empty = none. */
fallbackFont: string,
/** Typography Settings › Main font weight (CSS 100–900), as IDEA. */
fontWeight: number,
/** Typography Settings › Bold font weight: wherever the editor renders bold. */
boldFontWeight: number,
general: EditorGeneralSettings,
appearance: EditorAppearanceSettings,
breadcrumbs: BreadcrumbsSettings,
@@ -2929,6 +3055,11 @@ export type InspectionLevel = "error" | "warning" | "weakWarning" | "serverProbl
export type InspectionOverride = {
enabled?: boolean | null,
severity?: InspectionLevel | null,
/**
* The inspection's own options (Typo: `processCode`, `processLiterals`,
* `processComments`); absent ones keep their defaults.
*/
options?: { [key in string]: boolean } | null,
};
export type InspectionSettings = {
@@ -3011,6 +3142,11 @@ export type JavaLaunchInfo = {
javaHome: IpcPath | null,
/** The `java` launcher to use (filled in by the caller from the installed JDKs). */
javaExec: string | null,
/**
* How a launcher that builds the command line itself must shorten it (java-debug's
* `shortenCommandLine`: `none` | `jarmanifest` | `argfile`; filled in by the caller).
*/
shortenCommandLine: string | null,
/** First compile errors of the workspace when the build had errors. */
errors: JavaCompileError[],
/** All compile errors (`errors` is capped). */
@@ -3040,6 +3176,22 @@ export type JavaSettings = {
maven?: MavenSettings,
};
/** Java versions a vendor publishes. */
export type JdkReleases = {
/** Feature versions, newest first. */
versions: number[],
/** The long-term-support ones among them. */
lts: number[],
/** The version offered first (the newest LTS). */
recommended: number,
};
/** A JDK distribution the IDE can download. */
export type JdkVendor = {
id: string,
name: string,
};
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize;
@@ -3145,7 +3297,8 @@ export type LaunchConfig = {
* A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
* and launches the produced executable; `"debugServer": <port>` 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`).
*/
extraJson: string | null,
};
@@ -4087,6 +4240,19 @@ export type ResetMode = "soft" | "mixed" | "hard" | "keep";
export type ReviewEvent = "approve" | "requestChanges" | "comment";
/** A file changed across several commits: its content at `before` ↔ at `after`. */
export type RevisionChange = {
/** Repository-relative path after the change (before it, for a deletion). */
path: string,
kind: ChangeKind,
/** Path at `before`, for a rename. */
oldPath: string | null,
/** Commit holding the old content; `None` when the file was added. */
before: string | null,
/** Commit holding the new content; `None` when the file was deleted. */
after: string | null,
};
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
@@ -4524,6 +4690,16 @@ export type SourceFolder = {
detectedFrom: string | null,
};
/**
* The Typo inspection's options (IDEA's "Process code / literals / comments");
* plain text and Markdown are always checked.
*/
export type SpellScope = {
code?: boolean,
literals?: boolean,
comments?: boolean,
};
/** Editor › Natural Languages › Spelling. */
export type SpellingSettings = {
/** Application-level dictionary: accepted words (IDE settings). */
@@ -4923,8 +5099,15 @@ export type Typo = {
/** UTF-16 range of the word. */
range: Range,
word: string,
context: TypoContext,
};
/**
* 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.
*/
export type TypoContext = "code" | "literal" | "comment" | "prose";
/** What [`FileMoves::undo`] did. */
export type UndoOutcome = {
/** Moves reverted (each `from` is the original location again). */
+50 -2
View File
@@ -17,8 +17,8 @@
* - Right-hand overview marks: `scrollbarMarks`; save hooks:
* `registerSaveParticipant`; Back/Forward history: `getNavigationHistory`.
*/
import { Annotation, Facet, type EditorState, type Extension, type RangeSet, type Transaction } from "@codemirror/state";
import { GutterMarker, type EditorView } from "@codemirror/view";
import { Annotation, Compartment, Facet, type EditorState, type Extension, type RangeSet, type Transaction } from "@codemirror/state";
import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view";
import type { LanguageDescription } from "@codemirror/language";
import { create } from "zustand";
import { maybeEditorService } from "@/workbench/contributions";
@@ -45,6 +45,11 @@ export interface EditorContribution {
order?: number;
/** Return extensions for one editor instance (called on creation). */
extensions: (ctx: EditorDocContext) => Extension | Extension[];
/**
* Also applies to editable editors outside editor/core (the diff viewer's
* editable side, the merge result), which get `doc: -1`. Default false.
*/
embedded?: boolean;
}
export interface TextEditorHandle {
@@ -85,6 +90,49 @@ export function editorContributions(): EditorContribution[] {
return [...useEditorRegistry.getState().contributions.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
}
const embeddedCompartment = new Compartment();
const embeddedViews = new Map<EditorView, EditorDocContext>();
function embeddedContributions(ctx: EditorDocContext): Extension[] {
const out: Extension[] = [];
for (const c of editorContributions()) {
if (!c.embedded) continue;
try {
out.push(c.extensions(ctx));
} catch (e) {
console.error(`editor contribution ${c.id} failed`, e);
}
}
return out;
}
/**
* Contributions flagged `embedded` for an editable editor that editor/core
* does not own (diff viewer, merge result). Reconfigured live when
* contributions change.
*/
export function embeddedEditorExtensions(path: string): Extension {
const ctx: EditorDocContext = { doc: -1, path, language: "", uiMode: null, readOnly: false };
return [
embeddedCompartment.of(embeddedContributions(ctx)),
ViewPlugin.define((view) => {
embeddedViews.set(view, ctx);
return { destroy: () => void embeddedViews.delete(view) };
}),
];
}
const embeddedOf = (m: Map<string, EditorContribution>) => [...m.values()].filter((c) => c.embedded);
useEditorRegistry.subscribe((s, prev) => {
if (s.contributions === prev.contributions) return;
const now = embeddedOf(s.contributions);
const before = embeddedOf(prev.contributions);
if (now.length === before.length && now.every((c, i) => c === before[i])) return;
for (const [view, ctx] of embeddedViews)
view.dispatch({ effects: embeddedCompartment.reconfigure(embeddedContributions(ctx)) });
});
export function activeEditor(): TextEditorHandle | null {
return useEditorRegistry.getState().active;
}
+33
View File
@@ -40,6 +40,9 @@ import { useDocMeta, useOutline } from "./model/document";
import { fontZoom, setFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { call, commands } from "@/platform/ipc";
import { editorPrefs } from "@/platform/editorSettings";
import { notifications } from "@/workbench/contributions";
import { toggleReadOnly } from "./fileAttributes";
import { openNavBarPopup } from "./view/NavBarPopup";
import {
@@ -298,6 +301,26 @@ function addNextOccurrence(view: EditorView) {
if (nextOccurrence(view) === "notFound") void executeAction("editor.showHint", { text: "Not found, press again to search from the top" });
}
/** Settings › Editor › Color Scheme switches that are also toggle actions. */
type SchemeSwitch = "semanticHighlighting" | "rainbowBrackets";
export async function toggleSchemeSwitch(key: SchemeSwitch, on = !editorPrefs()[key]): Promise<void> {
try {
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(on))) });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot change the setting", body: e instanceof Error ? e.message : String(e) });
}
}
const schemeSwitch = (id: string, title: string, key: SchemeSwitch, keywords: string[]): Action => ({
id,
title,
category: "Editor",
keywords,
toggled: () => editorPrefs()[key],
run: () => toggleSchemeSwitch(key),
});
// ---------------------------------------------------------------------------------
export function registerEditorActions(): () => void {
@@ -316,6 +339,16 @@ export function registerEditorActions(): () => void {
return pane?.active ? { paneId: pane.id, tabId: pane.active } : null;
};
return registerActions([
schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [
"rainbow variables",
"rainbow identifiers",
"color scheme",
]),
schemeSwitch("editor.toggleRainbowBrackets", "Rainbow Brackets", "rainbowBrackets", [
"bracket pair colorization",
"rainbow parentheses",
"color scheme",
]),
text("code.commentLine", "Comment with Line Comment", (v) => commentLine(v)),
text("code.commentBlock", "Comment with Block Comment", (v) => toggleBlockComment(v)),
text("code.duplicateLine", "Duplicate Line or Selection", (v) => duplicateLineOrSelection(v)),
+2 -2
View File
@@ -46,10 +46,10 @@ export const openideaHighlightStyle = HighlightStyle.define([
token([t.punctuation, t.bracket, t.separator, t.operator, t.derefOperator], "punct"),
token(t.invalid, "invalid"),
// Markup (Markdown, docs)
token(t.heading, "markup-heading", { bold: "600" }),
token(t.heading, "markup-heading", { bold: "var(--fw-code-bold, 700)" }),
token(t.processingInstruction, "keyword"),
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strong, fontWeight: "600" },
{ tag: t.strong, fontWeight: "var(--fw-code-bold, 700)" },
{ tag: t.strikethrough, textDecoration: "line-through" },
// Markdown, as IntelliJ: link destination like a static method, rules like markers.
token(t.url, "function", { italic: "italic" }),
+3 -1
View File
@@ -280,12 +280,14 @@ export function richTextHtml(view: EditorView, from: number, to: number): string
const styleOf = new Map<string, string>();
const probe = document.createElement("span");
view.contentDOM.appendChild(probe);
// Bold = heavier than the editor's main weight (Typography Settings).
const regular = Number(getComputedStyle(view.contentDOM).fontWeight) || 400;
const resolve = (cls: string) => {
let s = styleOf.get(cls);
if (s === undefined) {
probe.className = cls;
const cs = getComputedStyle(probe);
s = `color:${cs.color};${cs.fontStyle !== "normal" ? `font-style:${cs.fontStyle};` : ""}${Number(cs.fontWeight) >= 600 ? "font-weight:bold;" : ""}`;
s = `color:${cs.color};${cs.fontStyle !== "normal" ? `font-style:${cs.fontStyle};` : ""}${Number(cs.fontWeight) > regular ? "font-weight:bold;" : ""}`;
styleOf.set(cls, s);
}
return s;
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { ensureSyntaxTree } from "@codemirror/language";
import { languages } from "@codemirror/language-data";
import { BracketScanner, bracketClass } from "./rainbowBrackets";
import { heavyExtension } from "./setup";
import type { EditorSettings } from "@/platform/ipc";
const cStyle = EditorState.languageData.of(() => [
{ commentTokens: { line: "//", block: { open: "/*", close: "*/" } } },
]);
/** `char@depth` of every coloured bracket (`!` for unmatched). */
function marks(doc: string, ext: Extension = [], from = 0, to = doc.length, scanner = new BracketScanner()) {
const state = EditorState.create({ doc, extensions: ext });
ensureSyntaxTree(state, doc.length, 5000);
return scanner.marks(state, from, to).map((m) => `${doc[m.from]}${m.depth < 0 ? "!" : m.depth}`);
}
const javascript = () => languages.find((l) => l.name === "JavaScript")!.load();
describe("rainbow brackets", () => {
it("colours brackets by nesting depth, cycling through the palette", () => {
expect(marks("f(a[b{c}d]e)")).toEqual(["(0", "[1", "{2", "}2", "]1", ")0"]);
expect([0, 1, 2, 3, 4].map(bracketClass)).toEqual([
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-3",
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
]);
});
it("marks unmatched and mismatched brackets", () => {
expect(marks("a)b")).toEqual([")!"]);
expect(marks("(]")).toEqual(["(!", "]!"]);
expect(marks("x(y")).toEqual(["(!"]);
expect(marks("((a)")).toEqual(["(!", "(1", ")1"]);
expect(bracketClass(-1)).toBe("cm-rainbow-bracket-unmatched");
});
it("skips strings and comments lexically when there is no syntax tree", () => {
const doc = "f(\"(\", '[', `{\n)`) // (\n/* ] */ g()";
expect(marks(doc, cStyle)).toEqual(["(0", ")0", "(0", ")0"]);
// Primes and lifetimes are not quotes.
expect(marks("f x' = (x', y)", cStyle)).toEqual(["(0", ")0"]);
});
it("skips strings, comments and regexps with the syntax tree", async () => {
const doc = 'f("(", /[(]/, `{`) // )\n/* ] */ g([1, {a: 2}])';
expect(marks(doc, await javascript())).toEqual(["(0", ")0", "(0", "[1", "{2", "}2", "]1", ")0"]);
});
it("knows the depth at a viewport far into the document and after edits", () => {
const body = "x(y)\n".repeat(5000);
const doc = `{\n${body}[z]\n}`;
const scanner = new BracketScanner();
const at = doc.indexOf("[z]");
expect(marks(doc, cStyle, at, doc.length, scanner)).toEqual(["[1", "]1", "}0"]);
// Checkpoints make the rescan start near the viewport; an edit before it invalidates them.
let state = EditorState.create({ doc, extensions: cStyle });
const tr = state.update({ changes: { from: 100, insert: "(" } });
scanner.invalidate(100);
state = tr.state;
const from = at + 1;
expect(
scanner
.marks(state, from, state.doc.length)
.map((m) => `${state.doc.sliceString(m.from, m.from + 1)}${m.depth < 0 ? "!" : m.depth}`),
).toEqual(["[2", "]2", "}!"]);
});
it("is installed by the Rainbow brackets setting", () => {
const render = (rainbowBrackets: boolean) => {
const view = new EditorView({
state: EditorState.create({
doc: "f(a[b])",
extensions: [cStyle, heavyExtension(7, { rainbowBrackets } as unknown as EditorSettings)],
}),
parent: document.body,
});
const classes = [...view.contentDOM.querySelectorAll("[class*=cm-rainbow-bracket]")].map((e) => e.className);
view.destroy();
return classes;
};
expect(render(false)).toEqual([]);
expect(render(true)).toEqual([
"cm-rainbow-bracket-1",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-2",
"cm-rainbow-bracket-1",
]);
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* Rainbow brackets (VS Code's bracket pair colorization): `()`, `[]`, `{}`
* coloured by nesting depth (`--syn-bracket-<n>`, cycling), unmatched ones in
* the error colour. Strings and comments are skipped: with the syntax tree
* when the language has one, else lexically with the language's comment
* tokens and quotes.
*
* Only the viewport is decorated. The nesting state at its start comes from
* checkpoints saved every few KB while scanning, so scrolling and typing
* rescan from the nearest checkpoint before the change, not from the top.
*/
import { Prec, type EditorState, type Extension, type Text } from "@codemirror/state";
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
type Tree = ReturnType<typeof syntaxTree>;
type NodeType = Tree["type"];
/** Number of `--syn-bracket-<n>` colours the depth cycles through. */
export const BRACKET_COLORS = 3;
const CHUNK = 4096;
/** How far past the viewport to look for the closers of visible openers. */
const LOOKAHEAD = 64_000;
/** Longest comment / quote token the lexical scanner needs to see past a chunk end. */
const TOKEN_SLACK = 8;
/** Closer char code of each opener char code: `(` `[` `{`. */
const CLOSER: Record<number, number> = { 40: 41, 91: 93, 123: 125 };
/** An open bracket; the stack is a persistent list so checkpoints can share it. */
interface Opener {
closer: number;
pos: number;
depth: number;
prev: Opener | null;
}
/** Lexical mode: code, line comment, block comment, string (quote in `quote`). */
type Mode = 0 | 1 | 2 | 3;
interface ScanState {
pos: number;
stack: Opener | null;
mode: Mode;
quote: string;
}
export interface BracketMark {
from: number;
/** 0-based depth, or -1 for an unmatched bracket. */
depth: number;
}
interface CommentTokens {
line?: string;
block?: { open: string; close: string };
}
const SKIP_NODE = /string|comment|char|rune|regexp|textblock|docstring|heredoc/i;
const skipCache = new WeakMap<NodeType, boolean>();
function skipsNode(type: NodeType): boolean {
let v = skipCache.get(type);
if (v === undefined) skipCache.set(type, (v = !type.isTop && SKIP_NODE.test(type.name)));
return v;
}
/**
* Bracket scanner of one editor: keeps the checkpoints between rebuilds and
* drops the ones after an edit (`invalidate`).
*/
export class BracketScanner {
private checkpoints: ScanState[] = [];
private tree: Tree | null = null;
/** Forget checkpoints after `pos` (first changed position of the old document). */
invalidate(pos: number) {
const keep = pos - TOKEN_SLACK;
let n = this.checkpoints.length;
while (n > 0 && this.checkpoints[n - 1]!.pos > keep) n--;
this.checkpoints.length = n;
}
/** Brackets in `[from, to)` with their depth; unmatched ones get depth -1. */
marks(state: EditorState, from: number, to: number): BracketMark[] {
const tree = treeOf(state);
if (tree !== this.tree) {
// A grown tree may reinterpret what was past its old end.
if (this.tree && tree) this.invalidate(Math.min(this.tree.length, tree.length));
else this.checkpoints.length = 0;
this.tree = tree;
}
const doc = state.doc;
to = Math.min(to, doc.length);
const limit = tree ? Math.min(doc.length, tree.length) : doc.length;
const comments = tree ? {} : (state.languageDataAt<CommentTokens>("commentTokens", 0)[0] ?? {});
const out: BracketMark[] = [];
const s = this.start(from);
const visit = (pos: number, depth: number) => {
if (pos >= from && pos < to) out.push({ from: pos, depth });
};
this.scan(doc, tree, comments, s, Math.min(to, limit), visit);
// Visible openers whose closer never comes are unmatched; give up (leave them coloured) past the lookahead.
let open = s.stack;
let shallowest = Infinity;
for (let o = open; o && o.pos >= from; o = o.prev) shallowest = o.depth;
if (shallowest !== Infinity) {
const end = Math.min(limit, to + LOOKAHEAD);
this.scan(doc, tree, comments, s, end, () => {}, shallowest);
open = s.stack;
if (s.pos >= doc.length && (open?.depth ?? -1) >= shallowest) {
const unmatched = new Set<number>();
for (let o = open; o && o.depth >= shallowest; o = o.prev) unmatched.add(o.pos);
for (const m of out) if (unmatched.has(m.from)) m.depth = -1;
}
}
return out;
}
private start(from: number): ScanState {
const cps = this.checkpoints;
let lo = 0;
let hi = cps.length - 1;
let best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (cps[mid]!.pos <= from) {
best = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return best < 0 ? { pos: 0, stack: null, mode: 0, quote: "" } : { ...cps[best]! };
}
/**
* Advances `s` to `end`, calling `visit` for every bracket. With `untilBelow`,
* stops once the stack is shallower than that depth (lookahead).
*/
private scan(
doc: Text,
tree: Tree | null,
comments: CommentTokens,
s: ScanState,
end: number,
visit: (pos: number, depth: number) => void,
untilBelow = -1,
) {
const cps = this.checkpoints;
const lineFirst = comments.line ? comments.line.charCodeAt(0) : -1;
const blockFirst = comments.block ? comments.block.open.charCodeAt(0) : -1;
while (s.pos < end) {
const chunkEnd = Math.min(end, (Math.floor(s.pos / CHUNK) + 1) * CHUNK);
const base = s.pos;
const text = doc.sliceString(base, Math.min(doc.length, chunkEnd + TOKEN_SLACK));
const stop = chunkEnd - base;
const skips: number[] = [];
if (tree)
tree.iterate({
from: base,
to: chunkEnd,
enter: (n) => {
if (!skipsNode(n.type)) return;
skips.push(n.from - base, n.to - base);
return false;
},
});
let k = 0;
let i = 0;
while (i < stop) {
if (tree) {
while (k < skips.length && skips[k + 1]! <= i) k += 2;
if (k < skips.length && skips[k]! <= i) {
i = skips[k + 1]!;
continue;
}
} else if (s.mode !== 0) {
i = lexSkip(text, i, stop, s, comments);
continue;
}
const ch = text.charCodeAt(i);
if (!tree && (ch === 34 || ch === 39 || ch === 96 || ch === lineFirst || ch === blockFirst)) {
const next = lexEnter(text, i, s, comments);
if (next !== i) {
i = next;
continue;
}
}
if (ch === 40 || ch === 91 || ch === 123) {
const depth = s.stack ? s.stack.depth + 1 : 0;
visit(base + i, depth);
s.stack = { closer: CLOSER[ch]!, pos: base + i, depth, prev: s.stack };
} else if (ch === 41 || ch === 93 || ch === 125) {
if (s.stack && s.stack.closer === ch) {
visit(base + i, s.stack.depth);
s.stack = s.stack.prev;
if ((s.stack?.depth ?? -1) < untilBelow) {
s.pos = base + i + 1;
return;
}
} else visit(base + i, -1);
}
i++;
}
s.pos = base + i;
const last = cps[cps.length - 1];
if (s.pos >= (last ? last.pos : 0) + CHUNK && s.pos <= doc.length) cps.push({ ...s });
}
}
}
/** The syntax tree when the editor has a language, else null (lexical scanning). */
function treeOf(state: EditorState): Tree | null {
const tree = syntaxTree(state);
return tree.length || state.doc.length === 0 ? tree : null;
}
/** In code at `i`: enters a comment / string and returns the index after its opening token, else `i`. */
function lexEnter(text: string, i: number, s: ScanState, c: CommentTokens): number {
const ch = text[i]!;
if (c.line && text.startsWith(c.line, i)) {
s.mode = 1;
return i + c.line.length;
}
if (c.block && text.startsWith(c.block.open, i)) {
s.mode = 2;
return i + c.block.open.length;
}
if (ch === '"' || ch === "`" || (ch === "'" && quoteStartsString(text, i))) {
s.mode = 3;
s.quote = ch;
return i + 1;
}
return i;
}
/** `'` starts a string unless it ends a word (Haskell's `x'`) or has no partner on the line (Rust lifetimes). */
function quoteStartsString(text: string, i: number): boolean {
if (i > 0 && /[\w]/.test(text[i - 1]!)) return false;
const nl = text.indexOf("\n", i + 1);
const close = text.indexOf("'", i + 1);
return close >= 0 && (nl < 0 || close < nl);
}
/** Inside a comment / string at `i`: the index after its end (back in code), else `stop` (still inside). */
function lexSkip(text: string, i: number, stop: number, s: ScanState, c: CommentTokens): number {
if (s.mode === 1 || s.mode === 2) {
const close = s.mode === 1 ? "\n" : (c.block?.close ?? "\n");
const at = text.indexOf(close, i);
if (at < 0 || at >= stop) return stop;
s.mode = 0;
return at + close.length;
}
for (; i < stop; i++) {
const ch = text[i];
if (ch === "\\") i++;
else if (ch === s.quote || (ch === "\n" && s.quote !== "`")) {
s.mode = 0;
return i + 1;
}
}
return i;
}
/** Class of a bracket at `depth` (-1: unmatched); the palette repeats every `BRACKET_COLORS` levels. */
export function bracketClass(depth: number): string {
return depth < 0 ? "cm-rainbow-bracket-unmatched" : `cm-rainbow-bracket-${(depth % BRACKET_COLORS) + 1}`;
}
const markCache = new Map<string, Decoration>();
function markFor(depth: number): Decoration {
const cls = bracketClass(depth);
let m = markCache.get(cls);
if (!m) markCache.set(cls, (m = Decoration.mark({ class: cls })));
return m;
}
function build(view: EditorView, scanner: BracketScanner): DecorationSet {
const { from, to } = view.viewport;
return Decoration.set(
scanner.marks(view.state, from, to).map((m) => markFor(m.depth).range(m.from, m.from + 1)),
true,
);
}
const plugin = ViewPlugin.fromClass(
class {
scanner = new BracketScanner();
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view, this.scanner);
}
update(u: ViewUpdate) {
if (u.docChanged) {
let first = Infinity;
u.changes.iterChangedRanges((fromA) => (first = Math.min(first, fromA)));
this.scanner.invalidate(first);
}
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state))
this.decorations = build(u.view, this.scanner);
}
},
{ decorations: (v) => v.decorations },
);
/** Rainbow brackets for one editor (Settings › Editor › Color Scheme › Rainbow brackets). */
export function rainbowBrackets(): Extension {
// Above the syntax highlighter so the bracket colour is the innermost span.
return Prec.high(plugin);
}
+5 -3
View File
@@ -26,6 +26,7 @@ import { highlightSelectionMatches } from "@codemirror/search";
import type { EditorConfigProps } from "@/bindings";
import type { EditorSettings } from "@/platform/ipc";
import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings";
import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts";
import { editorContributions, type EditorDocContext } from "@/editor/api";
import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree";
import { openideaHighlightStyle } from "./highlight";
@@ -35,6 +36,7 @@ import { changeLane, overviewLane } from "./lanes";
import { foldingExtension } from "./folding";
import { syntaxHighlightField } from "./syntaxHighlight";
import { indentGuides } from "./indentGuides";
import { rainbowBrackets } from "./rainbowBrackets";
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
import { findExtension } from "./find";
import { codeLensExtension } from "./codeLens";
@@ -160,9 +162,8 @@ export function fontExtension(settings: EditorSettings | undefined): Extension {
const prefs = prefsOf(settings);
const family = settings?.fontFamily;
const fallback = prefs.fallbackFont.trim();
if ((family && family !== "JetBrains Mono") || fallback) {
const fam = (family || "JetBrains Mono").replace(/"/g, "");
vars.push(`--openidea-editor-font: "${fam}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`);
if ((family && family !== BUNDLED_MONO_FAMILY) || fallback) {
vars.push(`--openidea-editor-font: ${monoFontFamily(family, fallback, "var(--font-mono)")}`);
}
if (settings?.fontSize) vars.push(`--openidea-editor-font-size: ${settings.fontSize}px`, `--openidea-editor-base-size: ${settings.fontSize}px`);
if (settings?.lineHeight) vars.push(`--openidea-editor-line-height: ${settings.lineHeight}px`, `--openidea-editor-base-line: ${settings.lineHeight}px`);
@@ -204,6 +205,7 @@ export function heavyExtension(docLength: number, settings?: EditorSettings): Ex
return [
prefs.codeEditing.highlightBraces ? bracketMatching({ renderMatch }) : [],
prefs.appearance.indentGuides ? indentGuides : [],
prefs.rainbowBrackets ? rainbowBrackets() : [],
prefs.codeEditing.highlightSelection ? highlightSelectionMatches({ minSelectionLength: 2, maxMatches: 500 }) : [],
];
}
+17 -1
View File
@@ -6,6 +6,7 @@
* ones are read through `--editor-*` locals with fallbacks to the base tokens.
*/
import { EditorView } from "@codemirror/view";
import { BRACKET_COLORS } from "./rainbowBrackets";
export const openideaTheme = EditorView.theme({
"&": {
@@ -29,6 +30,7 @@ export const openideaTheme = EditorView.theme({
// The workbench body uses the arrow cursor; the text area gets the I-beam (also past the last line).
cursor: "text",
fontFamily: "var(--openidea-editor-font, var(--font-mono))",
fontWeight: "var(--fw-code, 400)",
fontSize: "var(--openidea-editor-font-size, var(--fs-code))",
lineHeight: "var(--openidea-editor-line-height, var(--lh-code))",
fontVariantLigatures: "none",
@@ -71,7 +73,7 @@ export const openideaTheme = EditorView.theme({
".cm-matchingBracket, &.cm-focused .cm-matchingBracket": {
backgroundColor: "var(--editor-brace)",
color: "inherit",
fontWeight: "var(--matched-brace-weight, 700)",
fontWeight: "var(--matched-brace-weight, var(--fw-code-bold, 700))",
borderRadius: "1px",
},
".cm-matchingTag, &.cm-focused .cm-matchingTag": {
@@ -157,6 +159,19 @@ export const openideaTheme = EditorView.theme({
backgroundPosition: "6px 0",
},
// Rainbow brackets (cm/rainbowBrackets.ts); `*` too: the syntax highlighter's span sits inside and would win.
...Object.fromEntries(
Array.from({ length: BRACKET_COLORS }, (_, i) => [
`.cm-rainbow-bracket-${i + 1}, .cm-rainbow-bracket-${i + 1} *`,
{
color: `var(--syn-bracket-${i + 1})`,
fontWeight: `var(--syn-bracket-${i + 1}-bold, inherit)`,
fontStyle: `var(--syn-bracket-${i + 1}-italic, inherit)`,
},
]),
),
".cm-rainbow-bracket-unmatched, .cm-rainbow-bracket-unmatched *": { color: "var(--syn-invalid)" },
// Whitespace rendering
".cm-highlightSpace": {
backgroundImage: "radial-gradient(circle at 50% 55%, var(--fg-whitespace, var(--fg-faint)) 11%, transparent 12%)",
@@ -241,6 +256,7 @@ export const openideaTheme = EditorView.theme({
borderBottom: "1px solid var(--editor-gutter-border)",
boxShadow: "0 3px 6px -2px color-mix(in srgb, var(--fg-strong) 12%, transparent)",
fontFamily: "var(--openidea-editor-font, var(--font-mono))",
fontWeight: "var(--fw-code, 400)",
fontSize: "var(--openidea-editor-font-size, var(--fs-code))",
lineHeight: "var(--openidea-editor-line-height, var(--lh-code))",
overflow: "hidden",
+13 -3
View File
@@ -1,6 +1,10 @@
/** Applies Settings › Editor › Color Scheme (scheme + attribute overrides) as one stylesheet. */
/**
* Applies Settings › Editor › Color Scheme (scheme + attribute overrides) as one
* stylesheet, and Editor › Font › Typography Settings as the root's
* `--fw-code` / `--fw-code-bold`.
*/
import { useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import { editorPrefs, fontWeightVars } from "@/platform/editorSettings";
import { schemeCss } from "@/platform/colorScheme";
export function installColorScheme(): () => void {
@@ -8,10 +12,14 @@ export function installColorScheme(): () => void {
const el = document.createElement("style");
el.id = "openidea-editor-color-scheme";
document.head.appendChild(el);
const root = document.documentElement;
const apply = () => {
const settings = useProject.getState().settings;
const css = schemeCss(settings?.editor?.colorScheme, editorPrefs(settings).colorOverrides);
const prefs = editorPrefs(settings);
const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides);
if (el.textContent !== css) el.textContent = css;
for (const [name, value] of Object.entries(fontWeightVars(prefs.fontWeight, prefs.boldFontWeight)))
if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value);
};
apply();
const unsubscribe = useProject.subscribe((s, prev) => {
@@ -23,5 +31,7 @@ export function installColorScheme(): () => void {
unsubscribe();
observer?.disconnect();
el.remove();
root.style.removeProperty("--fw-code");
root.style.removeProperty("--fw-code-bold");
};
}
+23
View File
@@ -65,6 +65,7 @@
margin: 0;
padding: 6px 0;
font-family: var(--openidea-editor-font, var(--font-mono));
font-weight: var(--fw-code);
font-size: var(--fs-small);
color: var(--fg-editor);
}
@@ -129,6 +130,28 @@
.spacer {
flex: 1;
}
.subArrow {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-right: -4px;
padding: 0;
border: none;
border-radius: var(--radius-small);
background: none;
color: var(--fg-muted);
}
.subArrow:hover {
background-color: var(--bg-toolbar-hover);
}
.subMenu {
padding: 6px 0;
min-width: 200px;
max-width: 420px;
}
.detail {
flex: 0 1 auto;
min-width: 0;
+33
View File
@@ -48,6 +48,39 @@ describe("list popup", () => {
expect(useLspPopup.getState().popup).toBeNull();
});
it("opens a row's → menu with the right arrow and runs its items", () => {
const disable = vi.fn();
const fix = vi.fn();
openPopup({
kind: "list",
anchor: { x: 0, y: 0 },
title: "Intentions",
items: [
{
id: "fix",
label: "Typo: Change to…",
run: fix,
submenu: [
{ label: "Edit inspection profile setting", run: vi.fn() },
{ label: "Disable inspection", run: disable },
],
},
],
});
render(<LspPopups />);
expect(screen.getByRole("button", { name: "Typo: Change to… options" })).toBeTruthy();
fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowRight" });
const menu = screen.getByRole("menu", { name: "Typo: Change to…" });
expect([...menu.querySelectorAll('[role="menuitem"]')].map((b) => b.textContent)).toEqual([
"Edit inspection profile setting",
"Disable inspection",
]);
fireEvent.click(screen.getByRole("menuitem", { name: "Disable inspection" }));
expect(disable).toHaveBeenCalledOnce();
expect(fix).not.toHaveBeenCalled();
expect(useLspPopup.getState().popup).toBeNull();
});
it("reports the selected row and cleans up on close", () => {
const onActive = [vi.fn(), vi.fn()];
const onClose = vi.fn();
+60 -6
View File
@@ -9,11 +9,13 @@ import {
IconButton,
Input,
List,
MenuList,
Popup,
Select,
SymbolBadge,
fileIconName,
fuzzyMatch,
selectableText,
T,
} from "@/ui";
import { basename, relativePath, useProject } from "@/platform/project";
@@ -116,9 +118,25 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
void item.run();
};
// IDEA's right-arrow menu of a row (inspection options of a fix).
const bodyRef = useRef<HTMLDivElement>(null);
const [sub, setSub] = useState<{ item: ListPopupItem; anchor: Element } | null>(null);
const openSub = (item: ListPopupItem, row?: Element | null) => {
if (!item.submenu?.length || item.disabled || !bodyRef.current) return;
const anchor = row ?? bodyRef.current.querySelector('[role="option"][aria-selected="true"]') ?? bodyRef.current;
setSub({ item, anchor });
};
const closeSub = () => {
setSub(null);
bodyRef.current?.querySelector<HTMLElement>('[role="listbox"]')?.focus();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (popup.numbered && !query && /^[1-9]$/.test(e.key)) {
if (e.key === "ArrowRight" && activeItem?.submenu?.length) {
e.preventDefault();
openSub(activeItem);
} else if (popup.numbered && !query && /^[1-9]$/.test(e.key)) {
const item = popup.items[Number(e.key) - 1];
e.preventDefault();
if (item) activate(item);
@@ -178,7 +196,7 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
/>
)}
</div>
<div onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
<div ref={bodyRef} onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
<List
items={filtered}
getKey={(r) => r.item.id}
@@ -206,6 +224,21 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
<span className={styles.spacer} />
{item.detail && <span className={styles.detail}>{item.detail}</span>}
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
{item.submenu?.length ? (
<button
type="button"
tabIndex={-1}
className={styles.subArrow}
aria-label={`${item.label} options`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
openSub(item, e.currentTarget.closest('[role="option"]'));
}}
>
<Icon name="chevron-right" size={14} />
</button>
) : null}
</div>
);
}}
@@ -213,6 +246,20 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
</div>
</div>
{popup.preview && <ListPreviewPanel preview={shownPreview} />}
{sub?.item.submenu && (
<Popup
open
anchor={sub.anchor}
placement="right-start"
offset={2}
role="presentation"
autoFocus={false}
onClose={closeSub}
className={styles.subMenu}
>
<MenuList items={sub.item.submenu} aria-label={sub.item.label} onDone={closePopup} onCloseLevel={closeSub} />
</Popup>
)}
</Popup>
);
}
@@ -329,7 +376,7 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
{entry && (
<DomContent
key={`${history.index}:${history.entries.length}`}
className={styles.docBody}
className={clsx(styles.docBody, selectableText)}
build={() => renderMarkup(entry.markup, entry.language, docLinkHandler(host))}
/>
)}
@@ -352,7 +399,13 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
/>
<span className={styles.spacer} />
{entry?.source && (
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={() => void editDocSource(host)} />
<IconButton
icon="edit"
size="small"
title="Edit Source"
shortcut="F4"
onClick={() => void editDocSource(host)}
/>
)}
{host === "popup" && (
<IconButton
@@ -373,7 +426,8 @@ function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
const scope = useRef<HTMLDivElement>(null);
const opened = useRef(focusSeq);
useEffect(() => {
if (focusSeq !== opened.current) scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
if (focusSeq !== opened.current)
scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
}, [focusSeq]);
return (
<Popup
@@ -463,7 +517,7 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
<div className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent
key={popup.index}
className={styles.defBody}
className={clsx(styles.defBody, selectableText)}
build={() => renderCode(target.text, target.language, "cm-lsp-code")}
/>
</div>
-4
View File
@@ -239,10 +239,6 @@ describe("lookup commit characters", () => {
});
describe("explicit completion", () => {
// jsdom has no layout; CodeMirror measures text when the tooltip is positioned.
Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
function editor(labels: string[]) {
const source: CompletionSource = (ctx) => {
const word = ctx.matchBefore(/\w*/);
+68 -19
View File
@@ -25,13 +25,22 @@ import {
} from "@codemirror/autocomplete";
import { EditorSelection, Prec, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { call, commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import {
call,
commands,
isTauri,
nextRequestId,
type CompletionItem,
type Markup,
type RequestId,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { lspSnippetToCm, snippetVariables } from "./snippet";
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, posToOffset, textEditsToChanges } from "./positions";
import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { iconNode } from "./iconMarkup";
import { triggerSignatureHelp } from "./signature";
import { serverBackoff } from "./availability";
@@ -101,7 +110,7 @@ function buildInfo(c: LspCompletion, delay: number): Promise<CompletionInfo> {
const hasDoc = !isEmptyMarkup(item.documentation);
if (!hasDoc && !item.detail) return null;
const dom = document.createElement("div");
dom.className = "cm-lsp-completion-doc";
dom.className = `cm-lsp-completion-doc ${selectableText}`;
if (item.detail) dom.appendChild(renderCode(item.detail, c.req.language, "cm-lsp-code cm-lsp-doc-signature"));
if (hasDoc) dom.appendChild(renderMarkup(item.documentation!, c.req.language));
return dom;
@@ -160,7 +169,8 @@ export function applyLspCompletion(
const item = completion.resolved ?? completion.lsp;
const state = view.state;
const delta = to - req.pos;
const mapOld = (offset: number) => Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
const mapOld = (offset: number) =>
Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
let start = from;
let end = to;
@@ -190,7 +200,9 @@ export function applyLspCompletion(
}
snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end);
} else {
const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) : null;
const parens = editorPrefs().completion.insertParentheses
? callParens(item, state.doc.lineAt(start), start, end)
: null;
insertedParens = parens;
const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText + (parens ? "()" : "") }]);
const caret = changes.mapPos(end, 1) - (parens === "inside" ? 1 : 0);
@@ -203,7 +215,8 @@ export function applyLspCompletion(
});
}
if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start);
if (!completion.resolved && !item.additionalEdits.length && item.data != null)
void applyLateEdits(view, completion, start);
if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0);
runCommand(view, item);
}
@@ -240,7 +253,9 @@ export function callParens(
async function applyLateEdits(view: EditorView, c: LspCompletion, insertedAt: number) {
const item = await resolveItem(c.req.doc, c.lsp);
if (!item.additionalEdits.length || !view.dom.isConnected) return;
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter((e) => e.to <= Math.min(insertedAt, c.req.pos));
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter(
(e) => e.to <= Math.min(insertedAt, c.req.pos),
);
if (!edits.length) return;
const limit = Math.max(...edits.map((e) => e.to));
if (view.state.doc.sliceString(0, limit) !== c.req.text.sliceString(0, limit)) return;
@@ -259,7 +274,10 @@ function runCommand(view: EditorView, item: CompletionItem) {
else if (!CLIENT_COMMANDS.has(cmd) && !cmd.startsWith("editor.")) {
const doc = docOf(view);
// Server commands (auto-import, postfix…); resulting edits come back as workspace/applyEdit.
if (doc) void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => reportLspError(view, e, false));
if (doc)
void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) =>
reportLspError(view, e, false),
);
}
}
@@ -278,7 +296,10 @@ function cancelInflight() {
/** Language-data completion sources (keywords etc.), used when no server answers. */
async function fallback(ctx: CompletionContext): Promise<CompletionResult | null> {
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>("autocomplete", ctx.pos);
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>(
"autocomplete",
ctx.pos,
);
for (const s of sources) {
const source = typeof s === "function" ? s : completeFromList(s);
const r = await source(ctx);
@@ -317,7 +338,9 @@ export function mergeResults(results: readonly CompletionResult[]): CompletionRe
const [first] = results;
if (!first || results.length === 1) return first ?? null;
const same = results.filter((r) => r.from === first.from);
const validFor = same.every((r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source)
const validFor = same.every(
(r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source,
)
? first.validFor
: undefined;
return { from: first.from, to: first.to, options: same.flatMap((r) => r.options), validFor };
@@ -360,7 +383,9 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc };
const prefs = editorPrefs().completion;
const typed = word?.text ?? "";
const items = prefs.matchCase ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items;
const items = prefs.matchCase
? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity))
: list.items;
return {
from: word ? word.from : ctx.pos,
options: items.map((item) => makeCompletion(item, req)),
@@ -396,7 +421,10 @@ export function sortAlphabetically(result: CompletionResult, typed: string): Com
update: validFor
? (_cur, from, to, ctx) => {
const text = ctx.state.sliceDoc(from, to);
const ok = typeof validFor === "function" ? validFor(text, from, to, ctx.state) : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
const ok =
typeof validFor === "function"
? validFor(text, from, to, ctx.state)
: new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
return ok ? sortAlphabetically({ ...result, from }, text) : null;
}
: undefined,
@@ -455,7 +483,9 @@ function gated(source: CompletionSource): CompletionSource {
if (only) return source === contributedSource ? only(ctx) : null;
if (!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state))) return null;
if (!editorPrefs().completion.sortAlphabetically) return source(ctx);
return Promise.resolve(source(ctx)).then((r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)));
return Promise.resolve(source(ctx)).then(
(r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)),
);
};
}
@@ -478,7 +508,12 @@ const explicitPending = new WeakSet<EditorView>();
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]);
/** Whether typing `ch` finishes the open lookup with its selected item. */
export function commitsLookup(ch: string, focused: boolean, insertOnSpecialKeys: boolean, itemChars: readonly string[] = []): boolean {
export function commitsLookup(
ch: string,
focused: boolean,
insertOnSpecialKeys: boolean,
itemChars: readonly string[] = [],
): boolean {
if (!focused && !insertOnSpecialKeys) return false;
return COMMIT_CHARS.includes(ch) || itemChars.includes(ch);
}
@@ -495,11 +530,15 @@ const lookupKeys = Prec.highest(
const selected = selectedCompletion(view.state);
if (!selected) return false;
const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []);
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false;
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars))
return false;
if (!acceptCompletion(view)) return false;
// A call snippet or inserted parentheses already opened the call: the typed one is consumed.
const head = view.state.selection.main.head;
if (e.key === "(" && (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")) {
if (
e.key === "(" &&
(view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")
) {
e.preventDefault();
return true;
}
@@ -526,7 +565,8 @@ const lookupTracker = ViewPlugin.fromClass(
resetLookupDocs();
if (explicitPending.has(view)) {
explicitPending.delete(view);
if (prev === "pending" && !u.docChanged) queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
if (prev === "pending" && !u.docChanged)
queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
}
} else if (status === "active" && explicitPending.has(view)) {
explicitPending.delete(view);
@@ -535,7 +575,12 @@ const lookupTracker = ViewPlugin.fromClass(
const doc = u.state.doc;
setTimeout(() => {
const now = currentCompletions(view.state);
if (view.state.doc === doc && completionStatus(view.state) === "active" && now.length === 1 && now[0] === options[0])
if (
view.state.doc === doc &&
completionStatus(view.state) === "active" &&
now.length === 1 &&
now[0] === options[0]
)
acceptCompletion(view);
}, AUTO_INSERT_DELAY);
}
@@ -583,7 +628,9 @@ function typeColumn(c: Completion): Node | null {
async function toggleSortByName(view: EditorView) {
const on = !editorPrefs().completion.sortAlphabetically;
if (isTauri()) {
const settings = await call(commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)));
const settings = await call(
commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)),
);
useProject.setState({ settings });
}
if (!view.dom.isConnected) return;
@@ -647,7 +694,9 @@ export function completionExtension() {
optionClass: (c) => {
// IDEA shows keywords in bold.
const keyword = (isLsp(c) ? c.lsp.kind : c.type) === "keyword" ? " cm-lsp-keyword-option" : "";
return (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword;
return (
(isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword
);
},
tooltipClass: () => "cm-lsp-completion",
}),
+29 -1
View File
@@ -10,6 +10,7 @@ import { EditorView, ViewPlugin } from "@codemirror/view";
import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint";
import type { Diagnostic, Severity } from "@/bindings";
import { onIdeEvent } from "@/platform/ipc";
import { publishLocalDiagnostics } from "@/platform/localDiagnostics";
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { rangeToOffsets } from "./positions";
import { lspDoc } from "./lsp";
@@ -17,10 +18,12 @@ import { showHint } from "./hint";
import { editorPrefs } from "@/platform/editorSettings";
import {
applyProfile,
diagnosticInspection,
inspectionProfile,
isTypoStyled,
noteDiagnosticCodes,
subscribeInspectionProfile,
type InspectionRef,
} from "@/platform/inspections";
type Listener = () => void;
@@ -28,7 +31,15 @@ type Listener = () => void;
const byPath = new Map<string, Map<string, Diagnostic[]>>();
const listeners = new Map<string, Set<Listener>>();
/** Diagnostics a UI-side inspection computed for `path` (shown in the editor and Problems). */
export function setFileDiagnostics(path: string, source: string, items: Diagnostic[]) {
const had = byPath.get(path)?.has(source) ?? false;
storeDiagnostics(path, source, items);
// `server` names the producer for views that see the diagnostic alone (inspection controls).
if (items.length || had) publishLocalDiagnostics(path, source, items.map((d) => (d.server ? d : { ...d, server: source })));
}
function storeDiagnostics(path: string, source: string, items: Diagnostic[]) {
let sources = byPath.get(path);
if (!items.length) {
if (!sources?.delete(source)) return;
@@ -49,6 +60,23 @@ export function diagnosticsFor(path: string): Diagnostic[] {
return [...sources].flatMap(([source, items]) => applyProfile(profile, source, items));
}
/** Inspections reporting a problem at `pos` of `path` (profile applied), most severe first. */
export function inspectionsAt(path: string, doc: Text, pos: number): InspectionRef[] {
const sources = byPath.get(path);
if (!sources) return [];
const profile = inspectionProfile();
const found: { ref: InspectionRef; rank: number }[] = [];
for (const [producer, items] of sources) {
for (const d of applyProfile(profile, producer, items)) {
const { from, to } = rangeToOffsets(doc, d.range);
if (pos < from || pos > to) continue;
const ref = diagnosticInspection(producer, d);
if (ref && !found.some((f) => f.ref.id === ref.id)) found.push({ ref, rank: SEVERITY_RANK[d.severity] });
}
}
return found.sort((a, b) => a.rank - b.rank).map((f) => f.ref);
}
export function subscribeDiagnostics(path: string, l: Listener): () => void {
let set = listeners.get(path);
if (!set) listeners.set(path, (set = new Set()));
@@ -60,7 +88,7 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void {
}
export function installDiagnosticsEvents(): () => void {
const off = onIdeEvent("diagnostics", (e) => setFileDiagnostics(e.path, e.source, e.items));
const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items));
// Another inspection profile (or edits to it) re-filters every open file.
const offProfile = subscribeInspectionProfile(() => listeners.forEach((set) => set.forEach((l) => l())));
return () => {
+67 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import type { CodeAction } from "@/bindings";
import { semanticRanges } from "./semantic";
import { RAINBOW_COLORS, rainbowIndex, semanticRanges } from "./semantic";
import { lineErrorsAwayFromCaret, sortCodeActions } from "./intentions";
import { splitSignature } from "./signature";
import { dedupeLocations, usagesStatusText } from "./navigation";
@@ -26,6 +26,72 @@ describe("semanticRanges", () => {
{ from: 34, to: 37, className: "cm-sem-constant" },
]);
});
it("applies the Python-specific static style without changing other languages", () => {
const input = { tokenTypes: ["property"], tokenModifiers: ["static"], data: [0, 0, 5, 0, 1] };
expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe("cm-sem-property cm-sem-static cm-sem-python-static");
expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-property cm-sem-static");
});
});
describe("semantic highlighting (rainbow identifiers)", () => {
// fn f(total: u32, self) { let count = total; static S; obj.field; count }
const doc = Text.of(["fn f(total, self) { let count = total; S; obj.field; count + total }"]);
const tokens = {
tokenTypes: ["variable", "parameter", "property", "selfParameter"],
tokenModifiers: ["static", "declaration"],
data: [
[0, 5, 5, 1, 0b10], // total (parameter declaration)
[0, 12, 4, 3, 0], // self
[0, 24, 5, 0, 0b10], // count
[0, 32, 5, 1, 0], // total
[0, 39, 1, 0, 0b01], // S (static)
[0, 42, 3, 0, 0], // obj
[0, 46, 5, 2, 0], // field
[0, 53, 5, 0, 0], // count
[0, 61, 5, 1, 0], // total
].flat(),
};
const rainbow = (r: { className: string }) => /cm-sem-rainbow-(\d)/.exec(r.className)?.[1] ?? null;
it("hashes names to a stable palette index", () => {
expect(rainbowIndex("count")).toBe(rainbowIndex("count"));
const all = ["a", "b", "count", "total", "index", "value", "name", "item", "result", "i", "j", "k"].map(rainbowIndex);
for (const n of all) expect(n >= 1 && n <= RAINBOW_COLORS).toBe(true);
expect(new Set(all).size).toBeGreaterThan(2);
});
it("colours locals and parameters by name, only when enabled", () => {
expect(semanticRanges(doc, tokens).some((r) => rainbow(r))).toBe(false);
const ranges = semanticRanges(doc, tokens, true);
const byText = new Map<string, Set<string | null>>();
for (const r of ranges) {
const name = doc.sliceString(r.from, r.to);
byText.set(name, (byText.get(name) ?? new Set()).add(rainbow(r)));
}
expect(byText.get("total")).toEqual(new Set([String(rainbowIndex("total"))]));
expect(byText.get("count")).toEqual(new Set([String(rainbowIndex("count"))]));
expect(byText.get("obj")).toEqual(new Set([String(rainbowIndex("obj"))]));
// Parameters keep their own class too; self, statics and fields get no rainbow colour.
expect(ranges.find((r) => r.from === 5)!.className).toBe(`cm-sem-parameter cm-sem-declaration cm-sem-rainbow-${rainbowIndex("total")}`);
expect(byText.get("self")).toEqual(new Set([null]));
expect(byText.get("S")).toEqual(new Set([null]));
expect(byText.get("field")).toEqual(new Set([null]));
});
it("leaves module-level variables plain when the server marks locals", () => {
const ts = Text.of(["let top = 1; function f(p) { let x = top + p; }"]);
const r = semanticRanges(
ts,
{
tokenTypes: ["variable", "parameter"],
tokenModifiers: ["declaration", "local"],
data: [0, 4, 3, 0, 0b01, 0, 24, 1, 1, 0b11, 0, 33, 1, 0, 0b11, 0, 37, 3, 0, 0, 0, 43, 1, 1, 0b10],
},
true,
);
expect(r.filter((x) => x.className.includes("rainbow")).map((x) => ts.sliceString(x.from, x.to))).toEqual(["p", "x", "p"]);
});
});
describe("sortCodeActions", () => {
+113 -45
View File
@@ -4,12 +4,23 @@
* history, `docs.ts`) and code.quickDefinition (⌥Space, definition source popup).
*/
import { EditorSelection } from "@codemirror/state";
import { hoverTooltip, type EditorView } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc";
import { closeHoverTooltips, hasHoverTooltips, hoverTooltip, ViewPlugin, type EditorView } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
import {
commands,
isTauri,
nextRequestId,
type Hover,
type Location,
type Pos,
type Range,
type RequestId,
} from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics";
import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
@@ -22,6 +33,7 @@ import { basename } from "@/platform/project";
import { iconNode } from "./iconMarkup";
import { editorPrefs } from "@/platform/editorSettings";
import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api";
import { navigationModifierHeld, onNavigationModifier, pointerTarget } from "@/platform/navigationModifier";
let hoverRequest: RequestId | null = null;
@@ -51,7 +63,13 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
for (const { diagnostic: d, from } of diags) {
const row = document.createElement("div");
row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`;
row.appendChild(iconNode(d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", 16, "cm-lsp-diag-icon"));
row.appendChild(
iconNode(
d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
16,
"cm-lsp-diag-icon",
),
);
const msg = document.createElement("div");
msg.className = "cm-lsp-diag-message";
msg.textContent = d.message;
@@ -82,43 +100,78 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
return box;
}
/** Hover: problems always; quick documentation per Settings › Editor › Code Editing. */
export function hoverExtension() {
return hoverTooltip(
async (view, pos) => {
const diags = diagnosticsAt(view, pos);
let hover: Hover | null = null;
if (editorPrefs().codeEditing.quickDocOnHover)
try {
hover = await requestHover(view, pos);
} catch (e) {
reportLspError(view, e, false);
}
if (!diags.length && !hover) return null;
let from = pos,
to = pos;
if (hover?.range) ({ from, to } = rangeToOffsets(view.state.doc, hover.range));
else if (diags.length) ({ from, to } = diags[0]!);
else {
const word = view.state.wordAt(pos);
if (word) ({ from, to } = word);
}
const lang = docOf(view)?.uiMode ?? null;
return {
pos: from,
end: to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hover";
if (diags.length) dom.appendChild(renderDiagnostics(view, diags));
if (hover) dom.appendChild(renderMarkup(hover.contents, lang));
return { dom };
},
};
/**
* Whether the plain hover (problems + quick documentation) stays out: while
* ⌘/Ctrl is held the ⌘-hover link and its brief info (`navigation.ts`) own the
* pointer, as in IDEA; and over an inlay hint, which has its own tooltip.
*/
export function hoverYields(): boolean {
return navigationModifierHeld() || !!pointerTarget()?.closest?.(".cm-lsp-inlay");
}
/** Problems always; quick documentation per Settings › Editor › Code Editing. */
export async function hoverSource(view: EditorView, pos: number) {
if (hoverYields()) return null;
const diags = diagnosticsAt(view, pos);
let hover: Hover | null = null;
if (editorPrefs().codeEditing.quickDocOnHover)
try {
hover = await requestHover(view, pos);
} catch (e) {
reportLspError(view, e, false);
}
// ⌘/Ctrl pressed while the server answered.
if (hoverYields()) return null;
if (!diags.length && !hover) return null;
let from = pos,
to = pos;
if (hover?.range) ({ from, to } = rangeToOffsets(view.state.doc, hover.range));
else if (diags.length) ({ from, to } = diags[0]!);
else {
const word = view.state.wordAt(pos);
if (word) ({ from, to } = word);
}
const lang = docOf(view)?.uiMode ?? null;
return {
pos: from,
end: to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = `cm-lsp-hover ${selectableText}`;
if (diags.length) dom.appendChild(renderDiagnostics(view, diags));
if (hover) dom.appendChild(renderMarkup(hover.contents, lang));
return { dom };
},
{ hoverTime: Math.max(100, editorPrefs().codeEditing.quickDocDelay) },
);
};
}
/**
* Pressing ⌘/Ctrl closes the open hover popups (documentation, problems,
* debugger values) so the ⌘-hover info replaces them instead of stacking;
* a popup the pointer is in stays (selecting text in it and copying).
*/
const hoverYieldsToNavigation = ViewPlugin.fromClass(
class {
private off: () => void;
constructor(view: EditorView) {
this.off = onNavigationModifier((held) => {
if (!held || !hasHoverTooltips(view.state)) return;
if (pointerTarget()?.closest?.(".cm-tooltip")) return;
view.dispatch({ effects: closeHoverTooltips });
});
}
destroy() {
this.off();
}
},
);
export function hoverExtension(): Extension {
return [
hoverTooltip(hoverSource, { hoverTime: Math.max(100, editorPrefs().codeEditing.quickDocDelay) }),
hoverYieldsToNavigation,
];
}
/** The documentation popup has the keyboard (F1 pressed twice, or clicked into). */
@@ -141,7 +194,13 @@ function showDoc(view: EditorView, entry: DocEntry, at: number) {
showInDocWindow(entry, view.themeClasses);
return;
}
openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "doc",
anchor: caretAnchor(view, at),
entries: [entry],
index: 0,
themeClasses: view.themeClasses,
});
}
/**
@@ -210,7 +269,10 @@ export function definitionSpan(
async function declarationRange(loc: Location): Promise<Range | null> {
try {
return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null);
return await withDocument(
loc.path,
async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null,
);
} catch {
return null;
}
@@ -249,14 +311,20 @@ export async function quickDefinition(view: EditorView, kind: "definition" | "ty
showHint(view, kind === "typeDefinition" ? "No type definition found" : "No definition found");
return;
}
const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter(
(t): t is DefinitionTarget => !!t,
);
const targets = (
await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))
).filter((t): t is DefinitionTarget => !!t);
if (!targets.length) {
showHint(view, "Cannot read the definition's file");
return;
}
openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "definition",
anchor: caretAnchor(view, pos),
targets,
index: 0,
themeClasses: view.themeClasses,
});
} catch (e) {
reportLspError(view, e, true);
}
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more