Compare commits

...
Author SHA1 Message Date
l0ng-ai 0d485e67d5 fix(host): don't block the event loop while a closing project shuts down
Closing a project window waited on the event-loop thread for the project's
language servers, kernels and debug sessions to shut down on the async
runtime. When the runtime workers were busy answering IPC or querying
windows, they waited for the event loop in turn and the app froze.

Window destruction and the close_project command now detach the project at
once and let its workers shut down in the background. Opening a project
waits for those shutdowns first, and exit waits for them with a bound.

Closes #1414
2026-11-05 09:56:10 +08:00
l0ng-ai f16ec29f4a fix(dev): rewrite src/bindings.ts only when the bindings change (#1404)
Debug launches (also second instances that only hand over a path) exported the bindings and rewrote an unchanged src/bindings.ts, which made Vite reload the running app's windows. The bindings now go to a temporary file first and the source file is replaced only when it differs.
2026-11-05 09:05:42 +08:00
ntbang0901 02d30b1609 fix(editor): keep the caret and view when a file changes in distant places (#1329)
Document::diff only trimmed the common prefix and suffix, so two distant
edits on disk became one change spanning everything between them. The
caret and scroll position inside that span were mapped to its start,
which made the editor jump to the first edit after an agent, formatter
or git operation rewrote the file.

Diff the changed middle line by line (Myers via similar, 50 ms deadline
with a coarser fallback) and emit one change per edited region, trimmed
to its differing characters. The frontend DocSync path produces the same
per-region changes, so external reloads, local history restores and
formatting keep the caret and view in place.

Closes #1326

Co-authored-by: ntbang0901 <[email protected]>
2026-11-05 09:04:48 +08:00
l0ng-ai 01e09565b7 feat(run): recognize TestNG classes annotated with @Test (#1403)
TestNG's class-level @Test makes the class's public void methods tests; the scanner now recognizes them (excluding @Before*/@After*/@DataProvider/@Factory). Test results locate TestNG failures past org.testng frames and parse TestNG's comparison message. Refs #1368
2026-11-05 08:59:49 +08:00
l0ng-ai 1f70b134de feat(vcs): show the selected commit's details in the Push dialog (#1402)
Selecting one outgoing commit shows its message, revision, author and time above its files; a Group by Directory toggle switches the files between tree and flat list and is remembered. Fixes #1373
2026-11-05 08:57:19 +08:00
l0ng-ai 11fd64f399 feat(java): add the "Serializable class without 'serialVersionUID'" inspection (#1401)
Settings › Editor › Inspections › Java gets the inspection, off by default. When on, jdtls's compiler reports the missing field and offers the add default/generated ID quick fixes. Built-in inspections can now be off by default, the backend resolves the profile in effect, the compiler-defaults file is named after the variant so jdtls restarts with it, and saving inspection settings reconfigures language servers. Fixes #1199
2026-11-05 08:50:59 +08:00
l0ng-ai 8a884b6362 test(project): smoke-test opening each language's fixture (#1398)
Opens each fixtures/ project through the ops the app's commands call and checks the source and test roots from its build file, the index, Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files come and go, and the Maven / Gradle model. Ignored tests run a Maven and a Gradle sync and check virtualenv and JDK detection; the Language smoke java, kotlin and python jobs run them.
2026-11-05 08:49:08 +08:00
l0ng-ai 481d44930f fix(themes): add --syn-global to the Harbor Night scheme and the token reference (#1400)
#1393 added the --syn-global token to tokens.css but not to the Harbor Night scheme (the theme template) or the generated token reference in docs/plugins/themes.md, so the theme tests failed. The scheme gives it the --syn-variable colour, as tokens.css does.
2026-11-05 08:48:39 +08:00
l0ng-ai f5895d0336 test(debug): smoke-test debugging each language's fixture (#1357)
Debugs each language's fixture the way the editor does, against openidea-server and the debugger the IDE installs: a breakpoint on the entry point's marker line, Debug from the file's context, then the paused frame, its variables, Evaluate Expression, Step Over and Resume. Runs per language in the Language smoke workflow. The build model is loaded first, as the Build Tools feature does, so a Gradle Application is debugged through Gradle; Step Over allows a repeated stop on a line (Python 3.12 reports a conditional expression's line twice).
2026-11-05 08:32:11 +08:00
l0ng-ai c3c1d88d10 fix(vcs): let Commit and Shelf tree names use the whole row width (#1396)
Tree names in the Commit and Shelf windows were capped at 75% of the row, so long folder names were truncated with empty space left over. Names now take the whole row and the grey detail after them is truncated first. Fixes #1367
2026-11-05 08:25:51 +08:00
43 changed files with 1327 additions and 147 deletions

No files matched your search

+15 -1
View File
@@ -2,8 +2,12 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
on:
push:
branches: [main]
@@ -17,6 +21,7 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
@@ -104,7 +109,16 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+3
View File
@@ -71,6 +71,9 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
Generated
+1
View File
@@ -4634,6 +4634,7 @@ dependencies = [
"proptest",
"ropey",
"serde",
"similar",
"specta",
]
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -111,4 +128,20 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
+106 -48
View File
@@ -82,8 +82,13 @@ pub struct Host {
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
}
/// How long exit waits for project shutdown before giving up.
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
@@ -126,6 +131,7 @@ impl Host {
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
};
host.apply_proxy();
host
@@ -179,46 +185,69 @@ impl Host {
}
}
/// Closes `window`'s project and waits until its language servers, kernels and
/// debug sessions have shut down. Never call this on the UI event-loop thread:
/// the shutdown runs on the async runtime, whose workers may themselves be
/// waiting for the event loop (IPC responses, window queries). Use
/// [`Self::close_project_detached`] there.
pub fn close_project(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
self.wait_blocking(shutdown);
}
}
/// Like [`Self::close_project`], but returns once the project is detached from
/// the window; its workers finish shutting down in the background, and
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
pub fn close_project_detached(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
let mut closing = self.closing.lock();
closing.retain(|handle| !handle.is_finished());
closing.push(shutdown);
}
}
/// Detaches the project from `window` and stops what it started, synchronously,
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let project = self.projects.write().remove(window)?;
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and its language
// servers shut down.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
Some(self.spawn_project_shutdown(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
))
}
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
/// Project/window destruction must finish owned kernel cleanup, not enqueue
/// work which can be lost as the desktop runtime exits: the returned thread is
/// joined by the closer or, for a detached close, before exit.
fn spawn_project_shutdown(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
}
@@ -226,25 +255,37 @@ impl Host {
service.stop_admission();
}
let runtime = self.runtime.clone();
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
})
}
/// Waits for detached closes, so a project opened next never races the
/// language servers of one still shutting down (e.g. over a shared workspace).
pub(crate) fn wait_detached_closes(&self) {
let pending: Vec<_> = self.closing.lock().drain(..).collect();
for shutdown in pending {
self.wait_blocking(shutdown);
}
}
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
let wait = move || {
if let Err(error) = shutdown.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
@@ -256,15 +297,32 @@ impl Host {
}
}
/// Called before the desktop runtime exits, including update/restart exits.
/// Called before the desktop runtime exits, including update/restart exits, on
/// the event-loop thread: waits for detached closes and the open projects'
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
/// stuck on the event loop cannot keep the process from exiting.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
self.shutdown_project_workers_blocking(
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
pending.push(self.spawn_project_shutdown(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
if std::time::Instant::now() >= deadline {
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for handle in pending {
if let Err(error) = handle.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
}
}
/// Called before desktop exit, which may bypass document destructors.
+6 -1
View File
@@ -175,7 +175,12 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+3
View File
@@ -88,6 +88,7 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
@@ -209,6 +210,7 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
@@ -566,6 +568,7 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
let effective = settings.effective();
+51
View File
@@ -5,6 +5,7 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
@@ -52,3 +53,53 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
host.terminals.kill_key("other");
});
}
/// The desktop closes a project on the event-loop thread while runtime workers may be
/// blocked waiting for that thread (IPC responses, window queries): closing must not
/// wait for workers that need the runtime, and exit still waits for them once free.
#[test]
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// Occupy the only runtime worker until "the event loop" answers.
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
runtime.spawn(async move {
started_tx.send(()).unwrap();
let _ = release_rx.recv();
});
started_rx.recv().unwrap();
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
let closer = host.clone();
std::thread::spawn(move || {
closer.close_project_detached("main");
let _ = closed_tx.send(());
});
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
release_tx.send(()).unwrap();
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
assert!(host.project("main").is_err(), "the window no longer has the project");
host.open_project("main", &root).expect("the window opens a project once the close has finished");
let started = Instant::now();
host.shutdown_notebooks_on_exit();
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
}
+285
View File
@@ -0,0 +1,285 @@
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
//! the source and test roots detected from its build file, the index (files and symbols),
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
//! come and go, and the Maven / Gradle model.
//!
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
//! java, kotlin and python jobs):
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
use ide_host::{Host, HostConfig, ops};
use ide_index::SymbolFilter;
use ide_workspace::roots::RootKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Fixture {
dir: &'static str,
/// Detected roots: (project-relative path, kind).
roots: &'static [(&'static str, RootKind)],
/// Search Everywhere › Files: (query, file found).
file: (&'static str, &'static str),
/// Go to Class: (name, file declaring it).
class: (&'static str, &'static str),
/// Go to Symbol: (name, file declaring it).
symbol: (&'static str, &'static str),
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
added: (&'static str, &'static str),
}
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
async fn open(fixture: &str) -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
Opened { host, root, _tmp: tmp }
}
/// Polls `check` until it holds; fails with its last answer.
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
let deadline = Instant::now() + timeout;
loop {
match check() {
Ok(()) => return,
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
impl Opened {
fn files(&self, query: &str) -> Vec<String> {
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
found.into_iter().map(|f| f.rel_path).collect()
}
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
}
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
let found = self.symbols(name, filter);
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
}
}
async fn smoke(f: Fixture) {
let p = open(f.dir).await;
until("indexing", Duration::from_secs(30), || {
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
})
.await;
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
for (path, kind) in f.roots {
assert!(
roots.iter().any(|r| r.path == *path && r.kind == *kind),
"{}: no {kind:?} root {path:?} in {roots:?}",
f.dir
);
}
let files = p.files(f.file.0);
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
}
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
}
// The index follows the file system while the project is open.
let (added, text) = f.added;
std::fs::write(p.root.join(added), text).unwrap();
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
.await;
std::fs::remove_file(p.root.join(added)).unwrap();
until("a deleted file's class", Duration::from_secs(20), || {
let found = p.symbols("FreshWidget", SymbolFilter::Types);
if found.is_empty() { Ok(()) } else { Err(found) }
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rust_app() {
smoke(Fixture {
dir: "rust-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("pars", "src/parser.rs"),
class: ("Inventory", "src/lib.rs"),
symbol: ("low_stock", "src/lib.rs"),
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ts_app() {
smoke(Fixture {
dir: "ts-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("cart", "src/cart.ts"),
class: ("Cart", "src/cart.ts"),
symbol: ("formatMoney", "src/money.ts"),
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn python_app() {
smoke(Fixture {
dir: "python-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("models", "src/tasks/models.py"),
class: ("TaskList", "src/tasks/models.py"),
symbol: ("build_demo", "src/tasks/cli.py"),
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn go_app() {
smoke(Fixture {
dir: "go-app",
roots: &[("", RootKind::Source)],
file: ("store", "internal/store/store.go"),
class: ("Store", "internal/store/store.go"),
symbol: ("writeJSON", "cmd/server/main.go"),
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn java_app() {
smoke(Fixture {
dir: "java-app",
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
added: (
"src/main/java/com/example/orders/FreshWidget.java",
"package com.example.orders;\n\npublic class FreshWidget {}\n",
),
})
.await;
let p = open("java-app").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let maven = model.maven.expect("a Maven model");
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn kotlin_gradle() {
smoke(Fixture {
dir: "kotlin-gradle",
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
})
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let gradle = model.gradle.expect("a Gradle model");
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
}
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
let p = open(fixture).await;
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
let model =
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
model
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Maven and a JDK"]
async fn maven_sync() {
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
assert_eq!(maven.sync_error, None);
assert!(maven.synced_at.is_some());
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
// The resolved tree, not only the declaration.
assert!(!junit.children.is_empty(), "{junit:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
assert_eq!(gradle.sync_error, None);
assert!(gradle.synced_at.is_some());
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
// The build's own tasks: the application and Kotlin plugins' too.
for task in ["run", "compileKotlin", "test"] {
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs python3"]
async fn python_venv() {
let p = open("python-app").await;
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
assert!(made.unwrap().success());
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs JDK 21"]
async fn jdk() {
let p = open("java-app").await;
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
}
+71 -8
View File
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
if o.enabled != Some(true) {
return None;
}
Some(match o.severity {
Some(InspectionLevel::Error) => "error",
None | Some(InspectionLevel::Warning) => "warning",
Some(_) => "info",
})
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
/// Each variant has its own file, so changing an inspection restarts the server with it.
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let (name, content) = match serial {
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
Some(level) => (
format!("compiler-defaults-serial-{level}.prefs"),
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
),
};
let file = data_dir.join("lsp/jdtls").join(name);
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
}
Ok(())
};
@@ -1095,13 +1121,50 @@ mod java_compiler_tests {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn serial_version_inspection_sets_the_compiler_option() {
let tmp = tempfile::tempdir().unwrap();
let url = |inspections: &InspectionSettings| {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
configure_java_compiler(&mut cfg, tmp.path(), inspections);
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
};
let set = |o: ide_core::settings::InspectionOverride| {
let mut s = InspectionSettings::default();
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
s
};
let off = url(&InspectionSettings::default());
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
assert_ne!(on, off, "a new file, so the server restarts with it");
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
let error = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
let weak = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::WeakWarning),
..Default::default()
}));
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
let severity_only = url(&set(ide_core::settings::InspectionOverride {
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert_eq!(severity_only, off, "off by default");
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1112,7 +1175,7 @@ mod java_compiler_tests {
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1122,7 +1185,7 @@ mod java_compiler_tests {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path());
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
-1
View File
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+1
View File
@@ -12,6 +12,7 @@ ide-core.workspace = true
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
ropey = { version = "1.6", default-features = false, features = ["simd"] }
serde.workspace = true
similar = { version = "2.7", default-features = false }
specta.workspace = true
[dev-dependencies]
+4
View File
@@ -73,6 +73,10 @@ fn bench(c: &mut Criterion) {
BatchSize::SmallInput,
)
});
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
let every_line = text.replace("compute", "calculate");
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
c.bench_function("text/offset_to_pos_round_trip", |b| {
let mut rng = Lcg(3);
let len = base.len_utf16();
+124 -31
View File
@@ -14,6 +14,7 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
use serde::{Deserialize, Serialize};
use specta::Type;
use std::borrow::Cow;
use std::time::{Duration, Instant};
pub use ropey;
@@ -66,6 +67,36 @@ impl LineEnding {
}
}
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
const DIFF_BUDGET: Duration = Duration::from_millis(50);
type LineRange = std::ops::Range<usize>;
/// Byte lengths of the common prefix and suffix; they never overlap.
fn common_ends(a: &str, b: &str) -> (usize, usize) {
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
};
let prefix = shared(&mut a.chars(), &mut b.chars());
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
(prefix, suffix)
}
/// Byte offset of each line, then the total length.
fn line_starts(lines: &[&str]) -> Vec<usize> {
let mut at = 0;
let mut starts = vec![0];
starts.extend(lines.iter().map(|line| {
at += line.len();
at
}));
starts
}
fn utf16_len(text: &str) -> u32 {
text.chars().map(|c| c.len_utf16() as u32).sum()
}
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
if !text.contains('\r') {
@@ -334,42 +365,63 @@ impl Document {
cs
}
/// The minimal single-change (common prefix/suffix) change set turning the
/// current text into `text` (after line-ending normalization). Empty when equal.
/// The change set turning the current text into `text` (after line-ending
/// normalization), one change per changed region. Empty when equal. A single change
/// spanning two distant edits would drag the editor's caret and view to the first one.
pub fn diff(&self, text: &str) -> ChangeSet {
self.diff_until(text, Instant::now() + DIFF_BUDGET)
}
/// Past `deadline` the regions get coarser, never wrong.
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
let new = normalize_line_endings(text);
let old_chars = self.rope.len_chars();
let old = self.rope.to_string();
let (prefix, suffix) = common_ends(&old, &new);
let old_rest = &old[prefix..old.len() - suffix];
let new_rest = &new[prefix..new.len() - suffix];
let mut prefix_chars = 0usize;
let mut prefix_bytes = 0usize;
for (a, b) in self.rope.chars().zip(new.chars()) {
if a != b {
break;
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
let old_starts = line_starts(&old_lines);
let new_starts = line_starts(&new_lines);
let mut changes = Vec::new();
let mut utf16_at = utf16_len(&old[..prefix]);
let mut old_at = 0;
let mut region: Option<(LineRange, LineRange)> = None;
let ops =
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
let mut flush = |region: Option<(LineRange, LineRange)>| {
let Some((old_range, new_range)) = region else { return };
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
let (head, tail) = common_ends(region_old, region_new);
let start = old_starts[old_range.start] + head;
let end = old_starts[old_range.end] - tail;
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
let to = from + utf16_len(&old_rest[start..end]);
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
utf16_at = to;
old_at = end;
};
// Positions come from the lengths: `similar` can report a stale start for the empty
// side of an op (an insert after an equal run still names the run's first old line).
let (mut old_line, mut new_line) = (0, 0);
for op in ops {
let old_end = old_line + op.old_range().len();
let new_end = new_line + op.new_range().len();
if op.tag() == similar::DiffTag::Equal {
flush(region.take());
} else {
region = Some(match region.take() {
Some((o, n)) => (o.start..old_end, n.start..new_end),
None => (old_line..old_end, new_line..new_end),
});
}
prefix_chars += 1;
prefix_bytes += b.len_utf8();
(old_line, new_line) = (old_end, new_end);
}
let new_rest = &new[prefix_bytes..];
let new_rest_chars = new_rest.chars().count();
if prefix_chars == old_chars && new_rest_chars == 0 {
return ChangeSet::default();
}
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
let mut suffix_chars = 0usize;
let mut suffix_bytes = 0usize;
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
if a != b {
break;
}
suffix_chars += 1;
suffix_bytes += b.len_utf8();
}
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
ChangeSet { changes: vec![TextChange { from, to, insert }] }
flush(region);
ChangeSet { changes }
}
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
@@ -606,4 +658,45 @@ mod tests {
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
assert_eq!(doc.text(), "😀x😀");
}
fn numbered_lines(n: usize) -> String {
(0..n).map(|i| format!("line {i}\n")).collect()
}
#[test]
fn diff_keeps_distant_edits_apart() {
let old = numbered_lines(400);
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
let set = Document::new(&old).diff(&new);
assert_eq!(
set.changes,
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
);
}
#[test]
fn diff_handles_added_and_removed_lines() {
let old = "a\nb\nc\nd\ne\nf\n";
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
}
#[test]
fn diff_counts_utf16_past_astral_text() {
let old = "😀 one\nsame\n😀 two\n";
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
}
#[test]
fn diff_past_its_deadline_is_coarser_but_still_correct() {
let old = numbered_lines(5000);
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
let doc = Document::new(&old);
let set = doc.diff_until(&new, std::time::Instant::now());
let mut replayed = Document::new(&old);
replayed.apply(0, &set).unwrap();
assert_eq!(replayed.text(), new);
}
}
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
+1 -1
View File
@@ -176,7 +176,7 @@ proptest! {
let set = doc.replace_all(&b);
prop_assert_eq!(doc.text(), b.clone());
prop_assert_eq!(naive_apply(&a, &set), b.clone());
prop_assert!(set.changes.len() <= 1);
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
prop_assert_eq!(set.changes.is_empty(), a == b);
}
+22
View File
@@ -344,6 +344,28 @@ fn backend_edits_emit_patches() {
assert_eq!(fs::read_to_string(&path).unwrap(), "ONE\r\n2\r\nTHREE\r\n");
}
#[test]
fn external_change_in_two_places_patches_each() {
let f = fixture();
let path = f.root.join("two.txt");
let old: String = (0..400).map(|i| format!("line {i}\n")).collect();
fs::write(&path, &old).unwrap();
let id = f.docs.open(&path).unwrap().id;
std::thread::sleep(std::time::Duration::from_millis(10));
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
fs::write(&path, &new).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.docs.snapshot(id).unwrap().text, new);
let events = f.events.lock();
let changes = events.iter().find_map(|e| match e {
IdeEvent::DocPatched { changes, .. } => Some(changes),
_ => None,
});
assert_eq!(changes.map(Vec::len), Some(2));
}
#[test]
fn external_changes() {
let f = fixture();
+2 -1
View File
@@ -318,7 +318,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -420,6 +420,7 @@ a token.
| `--syn-number` | `#2aacb8` | `#1750eb` |
| `--syn-punct` | `#bcbec4` | `#080808` |
| `--syn-variable` | `#bcbec4` | `#080808` |
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
+4 -3
View File
@@ -20,9 +20,10 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests): when you change a
fixture, run both for that language.
language servers, `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
them for that language.
## Setup
+2
View File
@@ -62,6 +62,8 @@
"text/open_1mb": 2000,
"text/apply_keystroke_1mb": 20,
"text/apply_100_changes_1mb": 1000,
"text/diff_two_edits_1mb": 10000,
"text/diff_every_line_1mb": 100000,
"text/offset_to_pos_round_trip": 5,
"text/pos_to_byte": 5,
"text/text_for_save_crlf_1mb": 1000,
@@ -18,6 +18,7 @@
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-global": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
+251
View File
@@ -0,0 +1,251 @@
/**
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { get } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { Channel } from "@tauri-apps/api/core";
import { commands, type InstallEvent } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { openProject, useProject } from "@/platform/project";
import { activate as activateBuildTools } from "@/features/build-tools";
import { useBuild } from "@/features/build-tools/store";
import { activate as activateDebugger } from "@/features/debugger";
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
import { activate as activateRun } from "@/features/run";
import { resetRuns, useRun } from "@/features/run/store";
import { activateWorkbench } from "@/workbench/activate";
import { useNotifications } from "@/workbench/notificationStore";
import { connectDesktop } from "./desktop";
import { repoRoot, Server } from "./server";
interface Case {
fixture: string;
/** Runs in the project copy first (installs its dependencies). */
setup?: (root: string) => void;
/** The IDE's tool that debugs it. */
debugger: string;
/** The entry point, debugged from its context. */
file: string;
/** A variable of the paused frame. */
variable: string;
/** An expression evaluated there (Evaluate Expression), and its value. */
evaluate: [string, RegExp];
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
request?: string;
}
const CASES: Record<string, Case> = {
typescript: {
fixture: "ts-app",
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
debugger: "js-debug",
file: "src/index.ts",
variable: "cart",
evaluate: ["cart.items.length", /^2$/],
},
python: {
fixture: "python-app",
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
debugger: "debugpy",
file: "src/tasks/cli.py",
variable: "tasks",
evaluate: ["len(tasks.pending())", /^3$/],
},
rust: {
fixture: "rust-app",
debugger: "codelldb",
file: "src/main.rs",
variable: "inventory",
evaluate: ["inventory", /\S/],
},
go: {
fixture: "go-app",
debugger: "delve",
file: "cmd/server/main.go",
variable: "r",
evaluate: ["r.Method", /^"GET"$/],
request: "http://127.0.0.1:8080/notes?q=openidea",
},
java: {
fixture: "java-app",
debugger: "java-debug",
file: "src/main/java/com/example/orders/App.java",
variable: "book",
evaluate: ["book.size()", /^3$/],
},
kotlin: {
fixture: "kotlin-gradle",
debugger: "kotlin-debug-adapter",
file: "src/main/kotlin/com/example/library/App.kt",
variable: "library",
evaluate: ["library", /Library/],
},
};
let deactivate: (() => void) | null = null;
let server: Server | null = null;
let disconnect: (() => void) | null = null;
let base = "";
/** Backend events received, for failure messages. */
const events: string[] = [];
beforeAll(() => {
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
deactivate = () => disposers.reverse().forEach((d) => d());
});
afterAll(() => deactivate?.());
afterEach(() => {
resetRuns();
disconnect?.();
server?.close();
if (base) rmSync(base, { recursive: true, force: true });
});
async function open(c: Case): Promise<string> {
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
const root = join(base, c.fixture);
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
c.setup?.(root);
server = await Server.start(join(base, "home"));
disconnect = connectDesktop(server);
await openProject(root);
const trusted = await commands.projectTrustSet(root, true, false);
if (trusted.status !== "ok") throw new Error(trusted.error.message);
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
await until(
"the build model",
() => {
const error = useBuild.getState().error;
if (error) throw new Error(`the build model did not load: ${error}`);
return useBuildModel.getState().root === root && useBuildModel.getState().model;
},
5 * 60_000,
);
return root;
}
async function install(tool: string) {
const tools = await call(commands.toolsList());
if (tools.find((t) => t.name === tool)?.status === "installed") return;
const channel = new Channel<InstallEvent>();
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
expect(info.status, `${tool} installs`).toBe("installed");
}
/** What the Debug and Run tool windows show, for failure messages. */
function shown(): string {
const { sessions, console } = useDebug.getState();
const debug = Object.values(sessions).map(
(s) =>
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
(console[s.id] ?? [])
.slice(-40)
.map((l) => ` ${l.text}`)
.join(""),
);
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
}
/** Waits for `value` to be truthy; fails with what the tool windows show. */
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
const end = Date.now() + timeoutMs;
for (;;) {
const v = value();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
await new Promise((r) => setTimeout(r, 200));
}
}
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
async function requestWhenListening(url: string) {
for (let i = 0; i < 600; i++) {
const connected = await new Promise<boolean>((resolve) => {
const req = get(url, (res) => {
res.resume();
resolve(true);
});
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
req.on("error", () => resolve(false));
});
if (connected) return;
await new Promise((r) => setTimeout(r, 500));
}
}
const paused = () => {
const s = activeSession();
return s?.status === "paused" && s.frames.length > 0 ? s : null;
};
/** Paused, failing as soon as every session has ended instead. */
const pausedOrEnded = () => {
const sessions = Object.values(useDebug.getState().sessions);
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
return paused();
};
async function smoke(language: string) {
const c = CASES[language]!;
const root = await open(c);
await install(c.debugger);
const path = join(root, c.file);
const content = readFileSync(path, "utf8");
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
const line = marker + 1;
addBreakpoint(path, line);
await executeAction("run.debugContext", { path, content });
if (c.request) void requestWhenListening(c.request);
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
const top = s.frames[0]!;
expect(top.path).toBe(path);
expect(top.line).toBe(line);
const scopes = await call(commands.debugScopes(s.id, top.id));
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
expect(locals.map((v) => v.name)).toContain(c.variable);
const value = await evaluate(c.evaluate[0], "repl", s);
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
// (and its breakpoint) again.
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
const stops = useDebug.getState().stopSeq;
await executeAction("debug.stepOver");
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
}
const after = paused()!;
expect(after.stopReason, shown()).toBe("step");
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
await executeAction("debug.resume");
if (c.request) {
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
await executeAction("debug.stop");
}
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
}
for (const language of Object.keys(CASES)) {
test(language, () => smoke(language));
}
+11 -7
View File
@@ -3,23 +3,25 @@
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
*/
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, as in the app: the frontend registers its event listeners once
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
// One runtime per page, there from the start as in the app: the frontend registers its event
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
// across the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
let installed = false;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
function install() {
if (installed) return;
installed = true;
{
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
@@ -38,6 +40,9 @@ function install() {
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
@@ -50,7 +55,6 @@ function install() {
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
install();
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
+3
View File
@@ -3,6 +3,7 @@
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { dirname, join } from "node:path";
const PROTOCOL_VERSION = 2;
@@ -35,6 +36,8 @@ export class Server {
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
child.stderr.on("data", (chunk: Buffer) => {
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
this.stderr.push(chunk.toString());
if (this.stderr.length > 200) this.stderr.shift();
});
+11 -2
View File
@@ -28,7 +28,7 @@ mod windows;
use parking_lot::Mutex;
use state::AppState;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::{Emitter, Manager};
use windows::WindowsState;
@@ -40,12 +40,21 @@ pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
}
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
// Rewritten only when it changes: every debug launch exports, also a second instance that just
// hands its path over, and a rewrite makes Vite reload the running app's windows.
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
builder
.export(
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
&fresh,
)
.expect("failed to export TypeScript bindings");
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
let _ = std::fs::remove_file(&fresh);
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
std::fs::write(target, text).expect("failed to write TypeScript bindings");
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
+3 -1
View File
@@ -154,9 +154,11 @@ impl AppState {
self.host.open_light(window, file)
}
/// Called on the event-loop thread (window destruction, the `close_project`
/// command), so it must not wait for the project's workers to shut down.
pub fn close_project(&self, window: &str) {
self.remote_dev.disconnect(window);
self.host.close_project(window);
self.host.close_project_detached(window);
}
}
+61 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { ChangeSet, Text } from "@codemirror/state";
import type { TextChange } from "@/platform/ipc";
import { DocSync, toTextChanges } from "./DocSync";
import { minimalChange } from "./diff";
import { minimalChanges } from "./diff";
function apply(text: string, changes: TextChange[]) {
let out = "";
@@ -14,6 +14,10 @@ function apply(text: string, changes: TextChange[]) {
return out + text.slice(pos);
}
function numberedLines(count: number) {
return Array.from({ length: count }, (_, i) => `line ${i}\n`).join("");
}
class OutOfSync extends Error {
kind = "outOfSync";
}
@@ -226,6 +230,46 @@ describe("DocSync", () => {
expect(sync.idle).toBe(true);
});
it("a hard resync touches only the regions that differ", async () => {
const initial = numberedLines(40);
const { server, view, type, frame, host } = setup(initial);
server.failNext = { kind: "permissionDenied", message: "read-only" };
type(initial.indexOf("line 2\n"), initial.indexOf("line 2\n"), "#");
type(view.text.toString().indexOf("line 37\n"), view.text.toString().indexOf("line 37\n"), "#");
frame();
await server.processAll();
await flushMicrotasks();
expect(view.text.toString()).toBe(initial);
const touched: number[] = [];
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
expect(touched).toHaveLength(2);
});
it("a snapshot resync patches each region a lost event changed", async () => {
vi.useFakeTimers();
try {
const initial = numberedLines(40);
const { server, view, type, frame, host } = setup(initial);
type(initial.length, initial.length, "tail\n");
frame();
const lost = [
{ from: initial.indexOf("line 2\n"), to: initial.indexOf("line 2\n"), insert: "#" },
{ from: initial.indexOf("line 37\n"), to: initial.indexOf("line 37\n"), insert: "#" },
];
server.patch(lost);
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
await server.processAll();
await flushMicrotasks();
expect(view.text.toString()).toBe(server.text);
const touched: number[] = [];
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
expect(touched).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
it("whenIdle resolves after everything is acked", async () => {
const { server, sync, type } = setup("");
type(0, 0, "a");
@@ -279,10 +323,22 @@ describe("DocSync", () => {
});
});
describe("minimalChange", () => {
describe("minimalChanges", () => {
it("trims common prefix and suffix", () => {
expect(minimalChange("hello world", "hello brave world")).toEqual({ from: 6, to: 6, insert: "brave " });
expect(minimalChange("abc", "abc")).toBeNull();
expect(minimalChange("aaa", "a")).toEqual({ from: 1, to: 3, insert: "" });
expect(minimalChanges("hello world", "hello brave world")).toEqual([{ from: 6, to: 6, insert: "brave " }]);
expect(minimalChanges("abc", "abc")).toEqual([]);
expect(apply("aaa", minimalChanges("aaa", "a"))).toBe("a");
});
it("keeps distant edits apart", () => {
const before = numberedLines(40);
const after = before.replace("line 2\n", "line two\n").replace("line 37\n", "line thirty-seven\n");
const changes = minimalChanges(before, after);
expect(changes).toHaveLength(2);
expect(apply(before, changes)).toBe(after);
});
it("rebuilds text across astral characters", () => {
expect(apply("a😀b\n😁c", minimalChanges("a😀b\n😁c", "a😁b\n😀c"))).toBe("a😁b\n😀c");
});
});
+6 -6
View File
@@ -26,7 +26,7 @@
*/
import { ChangeSet, Text } from "@codemirror/state";
import type { TextChange } from "@/platform/ipc";
import { minimalChange } from "./diff";
import { minimalChanges } from "./diff";
export interface SyncTransport {
edit(baseVersion: number, changes: TextChange[]): Promise<number>;
@@ -258,9 +258,9 @@ export class DocSync {
}
if (gen !== this.generation || this.disposed) return;
if (snap.version > this.serverVersion) {
const change = minimalChange(this.confirmed.toString(), snap.text);
const changes = minimalChanges(this.confirmed.toString(), snap.text);
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes: change ? [change] : [] });
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes });
this.drainRemote();
}
this.resend();
@@ -329,9 +329,9 @@ export class DocSync {
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
this.confirmed = Text.of(snap.text.split("\n"));
this.serverVersion = snap.version;
const change = minimalChange(local, snap.text);
if (change) {
const cs = ChangeSet.of([change], local.length);
const changes = minimalChanges(local, snap.text);
if (changes.length) {
const cs = ChangeSet.of(changes, local.length);
this.localText = this.confirmed;
this.host.applyRemote(cs);
}
+6 -15
View File
@@ -1,20 +1,11 @@
import { diff } from "@codemirror/merge";
import type { TextChange } from "@/platform/ipc";
/**
* The single replacement turning `a` into `b` (common prefix / suffix
* trimmed), or null when equal. Linear time; used for resyncs, where the
* backend's own patches are already minimal.
* The changes turning `a` into `b`, one per changed region; empty when equal. Used for resyncs:
* a single change spanning two distant edits would drag the caret and view to the first one.
* The timeout keeps a huge file from stalling the UI; past it the regions only get coarser.
*/
export function minimalChange(a: string, b: string): TextChange | null {
if (a === b) return null;
const max = Math.min(a.length, b.length);
let start = 0;
while (start < max && a.charCodeAt(start) === b.charCodeAt(start)) start++;
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a.charCodeAt(endA - 1) === b.charCodeAt(endB - 1)) {
endA--;
endB--;
}
return { from: start, to: endA, insert: b.slice(start, endB) };
export function minimalChanges(a: string, b: string): TextChange[] {
return diff(a, b, { timeout: 50 }).map((c) => ({ from: c.fromA, to: c.toA, insert: b.slice(c.fromB, c.toB) }));
}
+25
View File
@@ -94,6 +94,31 @@ describe("scanJvmTargets (Java)", () => {
[16, "test", "a.b.OrderTest$Fixture", "inner", false],
]);
});
it("treats the public void methods of a TestNG class-level @Test as tests", () => {
const src = [
"package a.b;", // 0
"import org.testng.annotations.*;",
'@Test(groups = "unit")',
"public class PriceTest {", // 3
" @BeforeMethod public void setUp() {}",
" public void rounds() {}", // 5
' @Test(dataProvider = "rows")',
" public void parses(String row) {}", // 7
" @DataProvider public Object[][] rows() { return null; }",
' @DataProvider(name = "more") public void notATest() {}',
" void packagePrivate() {}",
" private void util() {}",
" public static void shared() {}",
" public int value() { return 1; }",
"}",
].join("\n");
expect(pick(src, "src/test/java/a/b/PriceTest.java")).toEqual([
[3, "suite", "a.b.PriceTest", null, false],
[5, "test", "a.b.PriceTest", "rounds", false],
[7, "test", "a.b.PriceTest", "parses", false],
]);
});
});
describe("scanJvmTargets (Kotlin)", () => {
+12 -1
View File
@@ -89,6 +89,8 @@ function tokenize(code: string): Token[] {
}
const TEST_ANNOTATIONS = new Set(["Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"]);
/** TestNG methods that set up or feed tests; not tests themselves under a class-level `@Test`. */
const TESTNG_NOT_TESTS = /^(Before|After)\w*$|^(DataProvider|Factory)$/;
const TYPE_KEYWORDS = new Set(["class", "interface", "enum", "record", "object"]);
const NOT_TYPES = new Set(["new", "return", "throw", "else", "case", "assert", "yield", "await", "this", "super"]);
const KOTLIN_DECL = /^(fun|val|var|class|object|interface|enum|data|sealed|abstract|open|private|public|internal|@)$/;
@@ -246,7 +248,16 @@ function scan(src: string, relPath: string, kotlin: boolean) {
const modifiers: string[] = [];
for (let k = i - 1; k >= 0 && !MEMBER_END.has(tokens[k]!.text); k--) modifiers.push(tokens[k]!.text);
const owner = top();
if (annotations.some((a) => TEST_ANNOTATIONS.has(a)) && owner) {
// TestNG `@Test` on a class (JUnit's is method-only): its public `void` methods are tests.
const classLevelTest =
!kotlin &&
owner?.kind === "class" &&
owner.annotations.includes("Test") &&
prev === "void" &&
modifiers.includes("public") &&
!modifiers.includes("static") &&
!annotations.some((a) => TESTNG_NOT_TESTS.test(a));
if ((annotations.some((a) => TEST_ANNOTATIONS.has(a)) || classLevelTest) && owner) {
const className = qualify(binaryName(frames));
out.push({ line: tokens[i]!.line, kind: "test", className, method: name });
for (let f = frames.length; f > 0; f--) {
@@ -10,6 +10,13 @@ describe("parseComparison", () => {
expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' });
});
it("TestNG", () => {
expect(parseComparison(["java.lang.AssertionError: total expected [3] but found [2]"])).toEqual({
expected: "3",
actual: "2",
});
});
it("kotlin.test", () => {
expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({
expected: "1",
+2
View File
@@ -21,6 +21,8 @@ export function parseComparison(output: readonly string[]): Comparison | null {
// JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: <x> but was: <y>`.
if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text)))
return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) };
// TestNG `Assert.assertEquals`: `expected [x] but found [y]`.
if ((m = /expected \[([\s\S]*?)\] but found \[([\s\S]*?)\]/.exec(text))) return { expected: m[1]!, actual: m[2]! };
// kotlin.test: `Expected <x>, actual <y>.`
if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! };
// Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected).
+1 -1
View File
@@ -21,7 +21,7 @@ const SUREFIRE_CLASS_RE =
const SUREFIRE_TOTAL_RE = /^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)\s*$/;
// `at org.junit…`, also behind a class loader or module (`at app//org.junit…`, `at java.base@21/java.lang…`).
const LIBRARY_FRAME_RE =
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|org\.testng|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/** The source position of a stack frame in the project's code, null for library frames. */
function frameLocation(line: string): TestLocation | null {
+18
View File
@@ -471,6 +471,24 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
expect(r.get("com.acme.UserTest")?.own).toBe("passed");
expect(r.summary).toMatchObject({ passed: 2, failed: 1 });
});
it("locates a TestNG failure in the test, past TestNG's own frames", () => {
const r = results(
jvmParser(),
`[INFO] Running com.acme.PriceTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.2 s <<< FAILURE! -- in com.acme.PriceTest
[ERROR] com.acme.PriceTest.rounds -- Time elapsed: 0.01 s <<< FAILURE!
java.lang.AssertionError: expected [3] but found [2]
at org.testng.Assert.fail(Assert.java:111)
at org.testng.Assert.assertEquals(Assert.java:1080)
at com.acme.PriceTest.rounds(PriceTest.java:12)
`,
);
expect(leaf(r, "com.acme.PriceTest", "rounds")?.location).toMatchObject({
path: "com/acme/PriceTest.java",
line: 12,
});
});
});
describe("parser selection", () => {
@@ -15,7 +15,7 @@ import {
type InspectionNode,
} from "../pages/editor/inspectionsModel";
import { splitPageTarget } from "../model/staging";
import { validateInspections } from "@/platform/inspections";
import { JAVA_SERIAL_VERSION_INSPECTION, validateInspections } from "@/platform/inspections";
import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling";
import { INSPECTIONS_PAGE } from "../pages/editor/inspections";
import { EDITOR_PAGES } from "../pages/editor";
@@ -69,6 +69,7 @@ describe("inspections tree", () => {
expect(tree.map((g) => g.name)).toEqual([
"Cargo",
"General",
"Java",
"JavaScript",
"Other",
"Proofreading",
@@ -109,6 +110,19 @@ describe("inspections tree", () => {
expect(checkState(o, python)).toBe("on");
});
it("keeps an inspection that is off by default off until turned on", () => {
const serial = find(tree, `Java/${JAVA_SERIAL_VERSION_INSPECTION}`)!;
expect(checkState({}, serial)).toBe("off");
let o = setEnabled({}, serial, true);
expect(o).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
expect(checkState(o, serial)).toBe("on");
o = setEnabled(o, serial, false);
expect(o).toEqual({});
// Turning a whole group on or off only records what differs from each default.
const java = find(tree, "group:Java")!;
expect(setEnabled({}, java, true)).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
});
it("keeps severity and enabled overrides independent", () => {
let o = setLevel({}, "lsp:ruff", "error");
o = setEnabled(o, find(tree, "Python/lsp:ruff")!, false);
@@ -8,6 +8,7 @@ import {
INSPECTION_LEVELS,
codeInspectionId,
customProfileId,
enabledByDefault,
newProfileKey,
overridesOf,
producerInspectionId,
@@ -159,7 +160,7 @@ export function inspectionIds(node: InspectionNode): string[] {
return [...(node.id ? [node.id] : []), ...node.children.flatMap(inspectionIds)];
}
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled !== false;
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
export type CheckState = "on" | "off" | "mixed";
@@ -181,9 +182,12 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
return next;
}
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
return inspectionIds(node).reduce(
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
o,
);
}
/** `undefined` restores the default ("As reported"). */
+6 -2
View File
@@ -62,9 +62,9 @@
.icon {
flex: none;
}
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
.label {
flex: 0 0 auto;
max-width: 75%;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -74,6 +74,7 @@
font-weight: 600;
}
.detail {
flex: 0 10000 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -190,6 +191,9 @@
align-items: center;
gap: 8px;
}
.stashRow > .detail {
flex-shrink: 1;
}
.stashMessage {
flex: 1;
min-width: 0;
+59 -6
View File
@@ -1,19 +1,29 @@
/**
* Push dialog: the files changed by the outgoing commits (the selected commit's, or all of
* them), with the selected file's diff beside the tree. The diff stays inside the dialog,
* which is modal.
* which is modal. A single commit also shows its message, author and time.
*/
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { Button, Spinner, T } from "@/ui";
import { Button, IconButton, Spinner, T } from "@/ui";
import { ComparisonTree } from "./ComparisonTree";
import { buildFileTree } from "./fileTree";
import { toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, fileCount } from "./model";
import { flatFileNodes, toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, dirName, fileCount } from "./model";
import styles from "./common.module.css";
const GROUP_KEY = "openidea.vcs.push.groupByDirectory";
function readGroupByDirectory(): boolean {
try {
return localStorage.getItem(GROUP_KEY) !== "false";
} catch {
return true;
}
}
/** Identifies what `PushChanges` shows; render it with this `key` so a new selection starts afresh. */
export const pushChangesKey = (repo: string | null, commits: readonly Commit[]) =>
`${repo ?? ""}:${commits.map((c) => c.id).join(" ")}`;
@@ -34,6 +44,7 @@ export function PushChanges({
const [changes, setChanges] = useState<LogChange[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [current, setCurrent] = useState<LogChange | null>(null);
const [byDirectory, setByDirectory] = useState(readGroupByDirectory);
useEffect(() => {
if (!commits.length) return;
@@ -50,7 +61,18 @@ export function PushChanges({
};
}, [repo, commits]);
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
const tree = useMemo(
() => (!changes ? [] : byDirectory ? buildFileTree(changes, (f) => f.path) : flatFileNodes(changes, (f) => f.path)),
[changes, byDirectory],
);
const toggleGrouping = () => {
setByDirectory(!byDirectory);
try {
localStorage.setItem(GROUP_KEY, String(!byDirectory));
} catch {
/* kept for this dialog only */
}
};
const count = commits.length;
if (!count) return null;
return (
@@ -65,7 +87,16 @@ export function PushChanges({
<T>Show All Commits</T>
</Button>
)}
<IconButton
icon="folder"
size="small"
title={tx("Group by Directory")}
pressed={byDirectory}
onClick={toggleGrouping}
className={styles.pushGrouping}
/>
</div>
{count === 1 && <PushCommitDetails commit={commits[0]!} />}
{error ? (
<div className={styles.error}>{error}</div>
) : !changes ? (
@@ -80,9 +111,31 @@ export function PushChanges({
diffFor={(c) => c.diff}
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
ariaLabel="Changed files"
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
renderDetails={(n) =>
n.file ? (
byDirectory ? null : (
<span className={styles.fileDir}>{dirName(n.file.path)}</span>
)
) : (
<span className={styles.fileDir}>{n.count}</span>
)
}
/>
)}
</div>
);
}
/** The selected commit's full message, revision, author and time. */
function PushCommitDetails({ commit }: { commit: Commit }) {
const date = commit.timeMs ? new Date(commit.timeMs).toLocaleString() : null;
return (
<section className={styles.pushCommitDetails} aria-label={tx("Commit details")}>
<div className={styles.pushCommitMessage}>{commit.message.trimEnd() || commit.summary}</div>
<div className={styles.fileDir}>
{commit.shortId} {commit.author} &lt;{commit.email}&gt;
{date ? " " + tx`on ${date}` : ""}
</div>
</section>
);
}
@@ -12,7 +12,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
import { PushChanges } from "../PushChanges";
const commit = (id: string, parent: string): Commit =>
({ id, shortId: id.slice(0, 7), parents: [parent], summary: id, author: "me", timeMs: 0 }) as unknown as Commit;
({
id,
shortId: id.slice(0, 7),
parents: [parent],
summary: id,
message: id,
author: "me",
email: "[email protected]",
timeMs: 0,
}) as unknown as Commit;
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
const older = commit("aaaaaaaaaa", "9999999999");
@@ -41,4 +50,36 @@ describe("Push dialog changes", () => {
fireEvent.click(screen.getByRole("button", { name: "Show All Commits" }));
expect(showAll).toHaveBeenCalled();
});
it("shows the selected commit's message and author, and lists files flat on request", async () => {
localStorage.clear();
files.mockResolvedValue({
status: "ok",
data: [{ path: "src/orders/service.py", oldPath: null, kind: "added" }],
});
const one = { ...older, message: "Add order service\n\nIntroduces the service.\n", timeMs: 1_700_000_000_000 };
render(<PushChanges repo="/r" commits={[one]} />);
const details = screen.getByRole("region", { name: "Commit details" });
expect(details.textContent).toContain("Add order service\n\nIntroduces the service.");
expect(details.textContent).toContain("aaaaaaa me <[email protected]> on ");
expect(await screen.findByText("src/orders")).toBeTruthy();
expect(screen.getAllByRole("treeitem")).toHaveLength(2);
const grouping = screen.getByRole("button", { name: "Group by Directory" });
expect(grouping.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(grouping);
expect(grouping.getAttribute("aria-pressed")).toBe("false");
// Flat: one row, the file, with its directory beside the name.
const rows = screen.getAllByRole("treeitem");
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).toContain("service.py");
expect(rows[0]!.textContent).toContain("src/orders");
expect(localStorage.getItem("openidea.vcs.push.groupByDirectory")).toBe("false");
});
it("shows no commit details for several commits", async () => {
combined.mockResolvedValue({ status: "ok", data: [] });
render(<PushChanges repo="/r" commits={[newer, older]} />);
expect(await screen.findByText(/0 files in 2 commits/)).toBeTruthy();
expect(screen.queryByRole("region", { name: "Commit details" })).toBeNull();
});
});
+21
View File
@@ -143,6 +143,27 @@
padding: 0 14px 6px;
color: var(--fg-subtle);
}
.pushGrouping {
margin-left: auto;
}
.pushCommitDetails {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 96px;
margin: 0 14px 8px;
padding: 6px 8px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-small);
-webkit-user-select: text;
user-select: text;
}
.pushCommitMessage {
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.todoList {
margin: 8px 0 0;
padding-left: 18px;
+21 -1
View File
@@ -216,9 +216,14 @@ export interface BuiltinInspection {
description: string;
/** Highlighting level when the profile sets none (else as reported). */
defaultLevel?: InspectionLevel;
/** `false`: off until a profile turns it on. */
defaultEnabled?: boolean;
options?: readonly InspectionOptionSpec[];
}
/** Turned on, the Java language server's compiler reports it (`ide-sdk` `SERIAL_VERSION_INSPECTION`). */
export const JAVA_SERIAL_VERSION_INSPECTION = "java:missing-serial-version";
/** Producer key of the Typo inspection's diagnostics. */
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
@@ -283,6 +288,16 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
group: "General",
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
},
{
id: JAVA_SERIAL_VERSION_INSPECTION,
producer: "jdt-serial-version",
name: "Serializable class without 'serialVersionUID'",
group: "Java",
description:
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.",
defaultLevel: "warning",
defaultEnabled: false,
},
{
id: "builtin:sql",
producer: "sql-inspections",
@@ -310,6 +325,11 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b]));
/** Whether inspection `id` is on when a profile doesn't say. */
export function enabledByDefault(id: string): boolean {
return BUILTIN_BY_ID.get(id)?.defaultEnabled ?? true;
}
/** Option `key` of inspection `id` under `profile` (its default when unset). */
export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean {
const value = profile[id]?.options?.[key];
@@ -363,7 +383,7 @@ export function applyProfile<D extends Diagnostic>(
): readonly D[] {
const base = producerInspectionId(producer);
const own = profile[base];
if (own?.enabled === false) return [];
if (!(own?.enabled ?? enabledByDefault(base))) return [];
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
let changed = false;
const out: D[] = [];
+3
View File
@@ -32,6 +32,8 @@
"Not found": "未找到",
"Not found on PATH": "未在 PATH 中找到",
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
"Serializable class without 'serialVersionUID'": "没有 'serialVersionUID' 的可序列化类",
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.": "报告实现了 Serializable 但没有声明 serialVersionUID 字段的类。在类名上按 Alt+Enter 可添加默认 ID 或生成的 ID。此检查由 Java 语言服务器执行,更改后语言服务器会重启。",
"Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。",
"Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。",
"Run `codex login` in a terminal.": "在终端中运行 `codex login`。",
@@ -475,6 +477,7 @@
"Comment": "评论",
"Comment:": "注释:",
"Commit": "提交",
"Commit details": "提交详细信息",
"Commit Anyway": "仍然提交",
"Commit Changelist…": "提交更改列表…",
"Commit Checks": "提交检查",