diff --git a/crates/ide-core/src/task.rs b/crates/ide-core/src/task.rs index b43c4b9eb..fe59216f9 100644 --- a/crates/ide-core/src/task.rs +++ b/crates/ide-core/src/task.rs @@ -44,6 +44,39 @@ pub fn cancel(id: TaskId) -> bool { } } +/// A progress title a tool reported, made readable: a leading absolute executable path +/// (rust-analyzer reports `/…/bin/cargo check`) shows as its program name (`cargo check`). +pub fn display_title(title: &str) -> String { + let is_sep = |c: char| c == '/' || c == '\\'; + let b = title.as_bytes(); + let absolute = title.starts_with(is_sep) + || (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && is_sep(b[2] as char)); + if !absolute { + return title.to_owned(); + } + // The path runs on through spaces (`Application Support/…`) while the next word still + // continues it; the first word without a separator, an option or a new path ends it. + let mut end = title.find(' ').unwrap_or(title.len()); + while end < title.len() { + let rest = &title[end + 1..]; + let word = rest.split(' ').next().unwrap_or_default(); + if word.is_empty() || !word.contains(is_sep) || word.starts_with(is_sep) || word.starts_with('-') { + break; + } + end += 1 + word.len(); + } + let (path, rest) = title.split_at(end); + let name = path.rsplit(is_sep).next().unwrap_or(path); + let name = [".exe", ".cmd", ".bat"] + .iter() + .find_map(|ext| { + let cut = name.len().checked_sub(ext.len())?; + (name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(ext)).then(|| &name[..cut]) + }) + .unwrap_or(name); + if name.is_empty() { title.to_owned() } else { format!("{name}{rest}") } +} + #[cfg(test)] mod tests { use super::*; @@ -71,4 +104,28 @@ mod tests { assert!(!cancel(other), "an ended task is not cancelled"); assert_eq!(hits.load(Ordering::SeqCst), 1); } + + #[test] + fn display_title_shows_a_leading_executable_path_as_its_name() { + for (title, shown) in [ + ("/Users/thomas/Library/Application Support/mbx/bin/cargo check", "cargo check"), + ("/home/me/.cargo/bin/cargo clippy (#2)", "cargo clippy (#2)"), + ( + "/opt/my tools/bin/cargo check --manifest-path /x/Cargo.toml", + "cargo check --manifest-path /x/Cargo.toml", + ), + (r"C:\Users\me\.cargo\bin\cargo.exe check", "cargo check"), + (r"C:\Program Files\Rust\bin\cargo.EXE check", "cargo check"), + (r"\\server\share\tools\x.cmd build", "x build"), + ("/usr/bin/make", "make"), + ("cargo check", "cargo check"), + ("Indexing", "Indexing"), + ("Building compile-time-deps", "Building compile-time-deps"), + ("Loading /src/app (2/3)", "Loading /src/app (2/3)"), + ("/", "/"), + ("", ""), + ] { + assert_eq!(display_title(title), shown, "{title:?}"); + } + } } diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 38361fec3..8ac55b927 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -405,7 +405,7 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, mut bo let mut progress = s.progress.lock(); let id = if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) }; - (id, str_("title").unwrap_or_else(|| s.name.clone())) + (id, str_("title").map(|t| ide_core::task::display_title(&t)).unwrap_or_else(|| s.name.clone())) }; let Some(id) = id else { return }; s.owner.sink.emit(IdeEvent::Task { diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 4e0237986..11ab9696f 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -59,7 +59,7 @@ impl ClientHandler { .get("title") .and_then(Value::as_str) .filter(|t| !t.is_empty()) - .map(str::to_owned) + .map(ide_core::task::display_title) .unwrap_or_else(|| self.server.name.clone()); let task = TaskId::next(); let cancel = (cancellable == Some(true)).then(|| self.cancel_handle(task, ¶ms["token"])); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index e7f110401..5a600ec83 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -788,6 +788,30 @@ async fn progress_tasks_and_busy_status() { assert!(tasks[2].3); } +#[tokio::test(flavor = "multi_thread")] +async fn progress_titled_with_an_executable_path_shows_the_program_name() { + let h = harness(&[("mock", incremental_caps())]); + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let peer = h.launcher.state("mock").peer(); + let title = "/Users/thomas/Library/Application Support/mbx/bin/cargo check"; + peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "begin", "title": title } })).unwrap(); + peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "report", "message": "tauri_plugin (lib)" } })) + .unwrap(); + peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "end" } })).unwrap(); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. }))).await; + let titles: Vec = h + .events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Task { title, .. } => Some(title.clone()), + _ => None, + }) + .collect(); + assert_eq!(titles, ["cargo check"; 3]); +} + #[tokio::test(flavor = "multi_thread")] async fn cancelling_a_cancellable_progress_task_asks_the_server_to_stop() { let h = harness(&[("mock", incremental_caps())]);