mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Merge origin/main into feat/agents
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
commit
4548cf05ea
286 files changed
+16464
-993
No files matched your search
Generated
+37
-29
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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]
|
||||
@@ -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(¤t)));
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for part in body.split(|b| *b == 0) {
|
||||
if part.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
|
||||
const GMEM_MOVEABLE: u32 = 0x0002;
|
||||
/// Another process holds the clipboard only while it copies; retrying beats
|
||||
/// reporting "no files" for a paste the user just made.
|
||||
const OPEN_ATTEMPTS: usize = 10;
|
||||
const OPEN_RETRY: Duration = Duration::from_millis(20);
|
||||
|
||||
#[link(name = "user32")]
|
||||
unsafe extern "system" {
|
||||
fn OpenClipboard(owner: Handle) -> i32;
|
||||
fn CloseClipboard() -> i32;
|
||||
fn EmptyClipboard() -> i32;
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GlobalAlloc(flags: u32, bytes: usize) -> Handle;
|
||||
fn GlobalLock(memory: Handle) -> *mut c_void;
|
||||
fn GlobalUnlock(memory: Handle) -> i32;
|
||||
fn GlobalFree(memory: Handle) -> Handle;
|
||||
fn GlobalSize(memory: Handle) -> usize;
|
||||
}
|
||||
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<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);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¤t) {
|
||||
@@ -562,9 +634,7 @@ impl DbManager {
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
let name = it.next().flatten()?;
|
||||
let kind =
|
||||
if it.next().flatten().as_deref() == Some("view") { DbTableKind::View } else { DbTableKind::Table };
|
||||
Some(TableInfo { name, kind })
|
||||
Some(TableInfo { name, kind: DbTableKind::from_catalog(it.next().flatten().as_deref()) })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -612,7 +682,85 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
Ok(TableDetails { columns, indexes, foreign_keys })
|
||||
let triggers = pool
|
||||
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("triggers: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
let name = it.next().flatten()?;
|
||||
let (timing, events) = if driver == DbDriver::Sqlite {
|
||||
sql::sqlite_trigger_event(&it.next().flatten().unwrap_or_default())
|
||||
} else {
|
||||
(it.next().flatten().unwrap_or_default(), it.next().flatten().unwrap_or_default())
|
||||
};
|
||||
Some(TriggerInfo { name, timing, events })
|
||||
})
|
||||
.collect();
|
||||
Ok(TableDetails { columns, indexes, foreign_keys, triggers })
|
||||
}
|
||||
|
||||
/// The routines and sequences of `schema`.
|
||||
pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult<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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms) {
|
||||
let me = me.clone();
|
||||
let from = from.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let body = me.tsserver_request(&from, &command, args).await.unwrap_or(Value::Null);
|
||||
if let Some(run) = from.running_gen(generation)
|
||||
&& let Err(e) = run.peer.notify("tsserver/response", json!([[id, body]]))
|
||||
{
|
||||
tracing::debug!("{}: tsserver/response: {e}", from.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn tsserver_request(&self, from: &Server, command: &str, args: Value) -> Option<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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod envfile;
|
||||
pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
mod process;
|
||||
mod utf8;
|
||||
|
||||
@@ -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("e(a));
|
||||
}
|
||||
inv.args = s.args;
|
||||
inv.display = display;
|
||||
Ok(TempFile(Some(s.file)))
|
||||
}
|
||||
|
||||
/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started).
|
||||
struct TempFile(Option<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}")))?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(¤t, &text, maven_repo) else { break };
|
||||
if !seen.insert(parent.clone()) {
|
||||
break;
|
||||
}
|
||||
let Ok(parent_text) = std::fs::read_to_string(&parent) else { break };
|
||||
if parent_text.contains("lombok") {
|
||||
return true;
|
||||
}
|
||||
(current, text) = (parent, parent_text);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The parent of `pom` (content `text`) as Maven resolves it: the POM at `<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]
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,6 @@ tracing.workspace = true
|
||||
spellbook = "0.4"
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -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
@@ -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, ®ions, &|| false).unwrap();
|
||||
let found: Vec<_> = typos.iter().map(|t| (t.word.as_str(), t.context)).collect();
|
||||
assert_eq!(found, [("mesage", TypoContext::Code)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_filters_region_kinds() {
|
||||
let comments_only = SpellScope { code: false, literals: false, comments: true };
|
||||
assert!(comments_only.includes(RegionKind::Comment));
|
||||
assert!(comments_only.includes(RegionKind::Prose));
|
||||
assert!(!comments_only.includes(RegionKind::Name));
|
||||
assert!(!comments_only.includes(RegionKind::String));
|
||||
let parsed: SpellScope = serde_json::from_str(r#"{"literals":false}"#).unwrap();
|
||||
assert_eq!(parsed, SpellScope { code: true, literals: false, comments: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_words_and_custom_dictionaries() {
|
||||
let dir = std::env::temp_dir().join(format!("ide-spell-{}", std::process::id()));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+32
@@ -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
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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('\\', "/")
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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*/);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user