From ff1bfeea9d02eaa014c49c52c9b9fb11d65c2223 Mon Sep 17 00:00:00 2001 From: GuoYu Date: Fri, 13 Nov 2026 13:29:43 +0800 Subject: [PATCH] feat(mcp): check_files for fresh diagnostics, exact read_file ranges, search results with the matched line and context (#3061) - check_files syncs each file from disk, tells its language servers it was saved (so on-save checks such as rust-analyzer's cargo check run), waits until every server answered for the current text and went idle, and reports per file whether the problems are complete (checked / incomplete / serverUnavailable / noLanguageServer / untrusted / failed) with each server's status and reason. - read_file with offset or limit returns exactly that range plus where to read on; the default read of a whole file keeps its head and tail. - search_text / search_regex / search_symbol results carry the matched line (long lines cut around the match) and, with `context`, the lines before and after it. - Merged main: check_files uses the run tools' uncapped timeout. Co-authored-by: guoyucode <29471644+guoyucode@users.noreply.github.com> --- crates/ide-host/src/mcp/check.rs | 176 ++++++++++++++++++++++++++++ crates/ide-host/src/mcp/editors.rs | 2 +- crates/ide-host/src/mcp/files.rs | 43 +++++-- crates/ide-host/src/mcp/mod.rs | 2 + crates/ide-host/src/mcp/run/mod.rs | 2 +- crates/ide-host/src/mcp/search.rs | 133 ++++++++++++++++++++- crates/ide-host/tests/mcp.rs | 29 +++++ crates/ide-lsp/src/inspect.rs | 116 ++++++++++++++++++ crates/ide-lsp/src/lib.rs | 2 +- crates/ide-lsp/src/server.rs | 18 +++ crates/ide-lsp/tests/mock_server.rs | 51 ++++++++ docs/en/guide/mcp.mdx | 3 +- docs/zh/guide/mcp.mdx | 3 +- 13 files changed, 564 insertions(+), 16 deletions(-) create mode 100644 crates/ide-host/src/mcp/check.rs diff --git a/crates/ide-host/src/mcp/check.rs b/crates/ide-host/src/mcp/check.rs new file mode 100644 index 000000000..831e16a55 --- /dev/null +++ b/crates/ide-host/src/mcp/check.rs @@ -0,0 +1,176 @@ +//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a +//! file could not be checked (no server for it, not installed, crashed, misconfigured…). + +use super::editors::problem; +use crate::mcp::run::timeout; +use crate::mcp::tool::{Call, Output, Param, ParamType, Tool}; +use ide_core::{IdeError, IdeResult, Severity}; +use ide_lsp::DocumentCheck; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const DEFAULT_TIMEOUT_MS: usize = 30_000; +const MAX_FILES: usize = 20; + +pub fn check_files() -> Tool { + Tool::new( + "check_files", + &format!( + "Checks files with the language servers now and returns their problems: call it after changing files \ +(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \ +run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \ +text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \ +Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering. + +Per file, `status` says what the problems are worth: +- checked: every server answered; `problems` is complete (empty means no problems). +- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`. +- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \ +misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \ +fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler). +- noLanguageServer: no language server is configured for this kind of file. +- untrusted: language servers don't run in an untrusted project. +- failed: the file could not be opened (`error`). +`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \ +the file on disk. Lines and UTF-16 columns are 1-based." + ), + |call| async move { + let raw = call.str_list("filePaths")?; + if raw.is_empty() { + return Err(IdeError::invalid("filePaths must name at least one file")); + } + if raw.len() > MAX_FILES { + return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time"))); + } + let errors_only = call.flag("errorsOnly", true)?; + let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?; + let mut paths = Vec::new(); + for p in raw { + paths.push(call.resolve_readable(&p).await?); + } + let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only)); + let files = futures::future::join_all(checks).await; + Output::json(json!({ "files": files })) + }, + ) + .param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root")) + .param(Param::optional( + "errorsOnly", + ParamType::Boolean, + "Whether to return only errors (default) or warnings and hints too", + )) + .param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most")) +} + +async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value { + let mut out = json!({ "filePath": call.display(&path) }); + if !call.project().is_trusted() { + out["status"] = "untrusted".into(); + return out; + } + match check(call, &path, limit).await { + Ok((check, dirty, text)) => { + let servers: Vec = check + .servers + .iter() + .map(|s| { + let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked }); + if let Some(m) = &s.message { + v["message"] = m.clone().into(); + } + if s.incomplete { + v["incomplete"] = true.into(); + } + if let Some(e) = &s.error { + v["error"] = e.clone().into(); + } + v + }) + .collect(); + out["status"] = file_status(&check).into(); + out["problems"] = check + .diagnostics + .into_iter() + .filter(|d| !errors_only || d.severity == Severity::Error) + .map(|d| problem(d, &text)) + .collect::>() + .into(); + out["servers"] = servers.into(); + if dirty { + out["unsavedInEditor"] = true.into(); + } + } + Err(e) => { + out["status"] = "failed".into(); + out["error"] = e.message.into(); + } + } + out +} + +fn file_status(check: &DocumentCheck) -> &'static str { + if check.servers.is_empty() { + "noLanguageServer" + } else if !check.servers.iter().any(|s| s.checked) { + "serverUnavailable" + } else if check.servers.iter().all(|s| s.checked && !s.incomplete) { + "checked" + } else { + "incomplete" + } +} + +/// The check of `path`, whether it has unsaved changes in the IDE, and its text. +async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> { + let project = call.project().clone(); + let docs = project.documents.clone(); + let p = path.to_owned(); + let at = Instant::now(); + // Synced now: the file watcher's reload of a change made outside the IDE may not have run. + let (doc, changed, dirty) = call + .host() + .blocking(move || { + let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v))); + docs.on_disk_changed(std::slice::from_ref(&p)); + let doc = docs.open(&p)?.id; + let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v)); + Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false))) + }) + .await?; + let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await; + let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default(); + let docs = project.documents.clone(); + let _ = call + .host() + .blocking(move || { + docs.close(doc); + Ok(()) + }) + .await; + Ok((result?, dirty, text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::event::ServerStatus; + use ide_lsp::CheckedServer; + + fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer { + CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None } + } + + #[test] + fn statuses_say_whether_the_problems_are_complete() { + let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers }); + assert_eq!(check(vec![]), "noLanguageServer"); + assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable"); + assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked"); + assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete"); + assert_eq!( + check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]), + "incomplete" + ); + } +} diff --git a/crates/ide-host/src/mcp/editors.rs b/crates/ide-host/src/mcp/editors.rs index 92405c6d0..fcb165766 100644 --- a/crates/ide-host/src/mcp/editors.rs +++ b/crates/ide-host/src/mcp/editors.rs @@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult { }) } -fn problem(d: Diagnostic, text: &str) -> Value { +pub(super) fn problem(d: Diagnostic, text: &str) -> Value { json!({ "severity": d.severity, "description": d.message, "lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(), diff --git a/crates/ide-host/src/mcp/files.rs b/crates/ide-host/src/mcp/files.rs index ee4bf6fd2..f23548d0d 100644 --- a/crates/ide-host/src/mcp/files.rs +++ b/crates/ide-host/src/mcp/files.rs @@ -30,9 +30,14 @@ fn read_file() -> Tool { "Reads a file in the project directory or from any project dependency or other project source root. Can read text sources inside jar/zip archives; compiled class files are not decompiled. Returns numbered lines (1-indexed) as text. -By default, reads up to 2000 lines starting from the beginning of the file. +By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last +lines with a `…N lines truncated…` marker between them. +With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file), +followed by `…N more lines; read on with offset=M…` when the file goes on. The maximum accepted limit is 5000 lines.", |call| async move { + // An explicit range is read as asked; the default read of a whole file keeps its head and tail. + let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some(); let offset = positive(&call, "offset", 1)?; let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?; if limit > MAX_READ_LIMIT { @@ -50,7 +55,7 @@ The maximum accepted limit is 5000 lines.", ))); } let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?; - Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?)) + Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?)) }, ) .param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION)) @@ -70,10 +75,12 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult { } } -/// Lines `start..` of `text` as `L: `; when more than `max_lines` remain, the first -/// and the last halves of the budget with a `…N lines truncated…` marker between them. +/// Lines `start..` of `text` as `L: `, at most `max_lines` of them. A `window` is +/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than +/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…` +/// marker between them. /// Lines are the editor's: a trailing line break starts an empty last line. -fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult { +fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult { let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); let count = lines.len(); if start > count { @@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult if available <= max_lines { return Ok(range(start, count)); } + if window { + let end = start + max_lines - 1; + return Ok(format!( + "{}\n\u{2026}{} more lines; read on with offset={}\u{2026}", + range(start, end), + count - end, + end + 1 + )); + } let head = max_lines / 2; let tail = max_lines - head; let head_end = start + head - 1; @@ -245,7 +261,7 @@ mod tests { use super::*; fn slice(text: &str, start: usize, max: usize) -> String { - render_slice(text, start, max).unwrap() + render_slice(text, start, max, false).unwrap() } #[test] @@ -254,18 +270,27 @@ mod tests { assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: "); assert_eq!(slice("", 1, 10), "L1: "); assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c"); - assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length")); - assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length")); + assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length")); + assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length")); } #[test] - fn keeps_head_and_tail_of_long_slices() { + fn keeps_head_and_tail_of_a_long_default_read() { let text = (1..=11).map(|n| format!("line {n}")).collect::>().join("\n"); assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11"); assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11"); assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11"); } + #[test] + fn reads_an_explicit_range_exactly_and_says_where_to_read_on() { + let text = (1..=11).map(|n| format!("line {n}")).collect::>().join("\n"); + let window = |start, max| render_slice(&text, start, max, true).unwrap(); + assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…"); + assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…"); + assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker"); + } + #[test] fn cuts_long_lines_at_utf16_units() { let long = "x".repeat(2100); diff --git a/crates/ide-host/src/mcp/mod.rs b/crates/ide-host/src/mcp/mod.rs index 08d37348e..5b65777f9 100644 --- a/crates/ide-host/src/mcp/mod.rs +++ b/crates/ide-host/src/mcp/mod.rs @@ -1,4 +1,5 @@ //! Built-in IDE tools. Transport and discovery live in `ide-mcp`. +mod check; mod edit; mod editors; mod requests; @@ -31,6 +32,7 @@ fn tools() -> Vec { let mut tools = files::tools(); tools.extend(search::tools()); tools.extend(editors::tools()); + tools.push(check::check_files()); tools.push(symbols::get_symbol_info()); tools.push(symbols::find_references()); tools.extend(edit::tools()); diff --git a/crates/ide-host/src/mcp/run/mod.rs b/crates/ide-host/src/mcp/run/mod.rs index 1c1b49cf7..5d7e2b080 100644 --- a/crates/ide-host/src/mcp/run/mod.rs +++ b/crates/ide-host/src/mcp/run/mod.rs @@ -25,7 +25,7 @@ const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000; /// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long /// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains /// (`withTimeoutOrNull`), zero or a negative value times out at once. -fn timeout(call: &Call, default: usize) -> IdeResult { +pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult { Ok(match call.opt_i64("timeout")? { None => Duration::from_millis(default as u64), Some(n) if n <= 0 => Duration::ZERO, diff --git a/crates/ide-host/src/mcp/search.rs b/crates/ide-host/src/mcp/search.rs index d4d572f31..4fa490ec7 100644 --- a/crates/ide-host/src/mcp/search.rs +++ b/crates/ide-host/src/mcp/search.rs @@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500; const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \ excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored."; const LIMIT_DESCRIPTION: &str = "Maximum number of results to return"; +const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \ +N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \ +each side. At most 50. Default: the matched line only."; +/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units +/// on each side of it; a longer context line to its start. +const MAX_LINE_TEXT: usize = 300; +const SNIPPET_AROUND: usize = 100; +const MAX_CONTEXT_LINES: u32 = 50; pub fn tools() -> Vec { vec![search_text(), search_regex(), search_symbol(), search_file()] @@ -49,6 +57,8 @@ fn search_text() -> Tool { "Searches for a text substring within project files. Use this tool for fast text search with match coordinates. Results include match coordinates when available (1-based line/column, end exclusive). +Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked +with …), and with `context` the lines around it. Paths are glob patterns relative to the project root. Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].", @@ -57,6 +67,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].", .param(Param::required("q", ParamType::String, "Text to search for")) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) + .param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION)) .output(result_schema()) } @@ -66,6 +77,8 @@ fn search_regex() -> Tool { "Searches for regex matches within project files. Use this tool when you need regex search with match coordinates. Results include match coordinates when available (1-based line/column, end exclusive). +Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked +with …), and with `context` the lines around it. The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case. Paths are glob patterns relative to the project root. @@ -75,6 +88,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].", .param(Param::required("q", ParamType::String, "Regex pattern to search for")) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) + .param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION)) .output(result_schema()) } @@ -84,6 +98,7 @@ fn search_symbol() -> Tool { "Searches for symbols (classes, methods, fields). Use this tool for semantic lookup by identifier fragments. Results include match coordinates when available (1-based line/column, end exclusive). +Project results have their line (`lineText`), and with `context` the lines around it. Paths are glob patterns relative to the project root. By default this searches project symbols only. @@ -99,6 +114,7 @@ If you don't find a suitable result, try again with include_external=true to sea with include_external=true.", )) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) + .param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION)) .output(result_schema()) } @@ -145,6 +161,13 @@ struct SearchItem { name: Option, #[serde(skip_serializing_if = "Option::is_none")] container: Option, + /// The text of `start_line` (cut when long). + #[serde(skip_serializing_if = "Option::is_none")] + line_text: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + context_before: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + context_after: Vec, } impl SearchItem { @@ -182,7 +205,9 @@ fn result_schema() -> Value { "type": "object", "properties": { "filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int, - "name": string, "container": string, + "name": string, "container": string, "lineText": string, + "contextBefore": { "type": "array", "items": string }, + "contextAfter": { "type": "array", "items": string }, }, "required": ["filePath"], } }, @@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult { Ok(q) } +// --- matched lines ------------------------------------------------------------- + +/// The `context` argument as (lines before, lines after). +fn context(call: &Call) -> IdeResult<(u32, u32)> { + let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)"); + let (sign, n) = match call.args.get("context") { + None | Some(Value::Null) => return Ok((0, 0)), + Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?), + Some(Value::String(s)) => { + let s = s.trim(); + match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) { + Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?), + None => (None, s.parse().map_err(|_| invalid())?), + } + } + Some(_) => return Err(invalid()), + }; + let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32; + Ok(match sign { + Some('+') => (0, n), + Some(_) => (n, 0), + None => (n, n), + }) +} + +/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`] +/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut. +fn snippet(line: &str, start: usize, end: usize) -> String { + let units: usize = line.chars().map(char::len_utf16).sum(); + if units <= MAX_LINE_TEXT { + return line.to_owned(); + } + let from = start.saturating_sub(SNIPPET_AROUND); + let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units); + let (mut out, mut at) = (String::new(), 0); + for c in line.chars() { + if at >= from && at < to { + out.push(c); + } + at += c.len_utf16(); + } + let before = if from > 0 { "\u{2026}" } else { "" }; + let after = if to < units { "\u{2026}" } else { "" }; + format!("{before}{out}{after}") +} + +/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units. +fn context_line(line: &str) -> String { + snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND)) +} + +/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left +/// as they are); each file is read once, with unsaved editor text. +async fn fill_lines( + call: &Call, + items: &mut [SearchItem], + absolute: impl Fn(&SearchItem) -> Option, + (before, after): (u32, u32), +) { + let host = call.host(); + let mut current: Option<(PathBuf, Option>)> = None; + for item in items { + let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue }; + if current.as_ref().is_none_or(|(p, _)| p != &path) { + let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok(); + let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect()); + current = Some((path, lines)); + } + let Some((_, Some(lines))) = ¤t else { continue }; + let index = line as usize - 1; + let Some(text) = lines.get(index) else { continue }; + let start = item.start_column.unwrap_or(1) as usize - 1; + let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start }; + item.line_text = Some(snippet(text, start, end)); + let first = index.saturating_sub(before as usize); + item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect(); + let last = (index + after as usize).min(lines.len() - 1); + item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect(); + } +} + // --- path globs ---------------------------------------------------------------- /// The `paths` argument: project-relative globs, `!` excludes. @@ -416,6 +522,7 @@ struct TextHit { async fn text_search(call: Call, is_regex: bool) -> IdeResult { let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?; let limit = limit(&call, DEFAULT_LIMIT)?; + let around = context(&call)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; // Globs Find in Files cannot take are applied afterwards, so the engine must not // stop at `limit` matches the filter may drop. @@ -494,7 +601,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult { let more = hits.len() > limit || summary.limit_hit || timed_out; hits.truncate(limit); extend_spanning(&call, &query, &mut hits, spanning).await; - let items = hits + let mut items: Vec = hits .into_iter() .map(|h| SearchItem { file_path: h.file_path, @@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult { ..Default::default() }) .collect(); + let absolute: std::collections::HashMap<&str, &Path> = + files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect(); + fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await; let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON); Output::json(SearchResult { items, more, partial_result_reason }) } @@ -726,6 +836,7 @@ async fn symbol_search(call: Call) -> IdeResult { let started = Instant::now(); let q = query(&call, "Search query is empty")?; let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?; + let around = context(&call)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; let external = call.flag("include_external", false)?; let host = call.host(); @@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult { } let more = items.len() > limit || timed_out; items.truncate(limit); + // Project files (relative paths, or absolute ones of content roots); library URIs are left out. + let root = call.root().to_path_buf(); + let absolute = |item: &SearchItem| { + let path = Path::new(&item.file_path); + (!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) }) + }; + fill_lines(&call, &mut items, absolute, around).await; let indexing = indexing_before || project.index.status().indexing; let partial_result_reason = if indexing { Some(INDEXING_PARTIAL_RESULT_REASON) @@ -869,6 +987,17 @@ mod tests { assert_eq!(byte_at("ab", 5), None); } + #[test] + fn long_lines_are_cut_around_the_match() { + assert_eq!(snippet("short line", 0, 5), "short line"); + let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400)); + let cut = snippet(&line, 400, 406); + assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100))); + let start = snippet(&line, 0, 0); + assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101); + assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1); + } + #[test] fn path_scopes() { let (_dir, root) = root(); diff --git a/crates/ide-host/tests/mcp.rs b/crates/ide-host/tests/mcp.rs index 4c79bdadd..9b878bb3f 100644 --- a/crates/ide-host/tests/mcp.rs +++ b/crates/ide-host/tests/mcp.rs @@ -112,6 +112,16 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() { assert_eq!(found["more"], true); let all = h.json("search_text", json!({"q":"fresh","limit":20})).await; assert_eq!(all["items"][0]["filePath"], "src/a.txt"); + // The matched line comes with the result (unsaved text included), and lines around it on request. + assert_eq!(all["items"][0]["lineText"], "fresh needle text"); + assert!(all["items"][0].get("contextAfter").is_none()); + let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await; + assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"])); + let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await; + assert_eq!(before["items"][0]["lineText"], "needle needle"); + assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"])); + let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await; + assert!(bad.contains("context must be"), "{bad}"); let all = h.json("search_text", json!({"q":"needle","limit":20})).await; assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored"))); let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await; @@ -454,6 +464,25 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() { assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked"); } +#[tokio::test(flavor = "multi_thread")] +async fn check_files_says_why_a_file_could_not_be_checked() { + let h = Harness::new(); + let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await; + assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}"); + h.trust().await; + let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await; + assert_eq!(files["files"][0]["filePath"], "src/a.txt"); + assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}"); + assert_eq!(files["files"][0]["problems"], json!([])); + assert_eq!(files["files"][1]["status"], "failed", "{files}"); + assert!(files["files"][1]["error"].is_string()); + // No cap of the tool's own: a very long timeout is taken as given, without overflowing. + let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await; + assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}"); + let none = h.error("check_files", json!({"filePaths": []})).await; + assert!(none.contains("at least one file"), "{none}"); +} + /// Answers `run_command`'s window requests the way the Run tool window does: starts the /// agent tab's configuration (shell from the default) and says whether it started. Returns /// the configurations it started, for reruns. diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index a58d049d1..90c02d810 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -22,6 +22,9 @@ use std::time::{Duration, Instant}; /// How long Inspect Code waits for a server that is still starting (jdtls, /// rust-analyzer on a large workspace). const INSPECT_START_WAIT: Duration = Duration::from_secs(60); +/// Quiet period after a publish before a checked file counts as done (follow-up publishes: +/// syntax first, semantics later). +const CHECK_SETTLE: Duration = Duration::from_millis(500); #[derive(Clone, Copy, Debug)] pub struct InspectOptions { @@ -48,6 +51,29 @@ pub trait InspectHost: Send + Sync + 'static { fn close(&self, doc: DocId); } +/// One language server's part in a [`DocumentCheck`]. +#[derive(Clone, Debug)] +pub struct CheckedServer { + pub name: String, + /// Its state when the check ended: not `Ready`/`Busy` means it could not check. + pub status: ide_core::event::ServerStatus, + /// Why it isn't running (not installed, misconfigured, crashed), as the status bar says. + pub message: Option, + /// Its diagnostics are in the result. + pub checked: bool, + /// It was still analysing (or had not published for this change) when the wait ended. + pub incomplete: bool, + /// Asking it failed. + pub error: Option, +} + +/// Fresh diagnostics of a document, with every language server that serves it. +#[derive(Clone, Debug, Default)] +pub struct DocumentCheck { + pub diagnostics: Vec, + pub servers: Vec, +} + /// What became of one file. #[derive(Clone, Debug)] pub enum FileInspection { @@ -198,6 +224,96 @@ impl LspManager { } } + /// Fresh diagnostics of the open document `doc`, whose text the caller just synced + /// (`changed`: at that instant, so diagnostics published before it are stale). Tells the + /// servers the document was saved, since checks that run on save (rust-analyzer's + /// `cargo check`) don't run for a change made outside the IDE, then waits for each server + /// to be ready, to answer for this text and to finish the background work it started, up + /// to `timeout`. Every server serving the document is reported, so a caller can tell "no + /// problems" from "nothing could check". + pub async fn check_document( + &self, + doc: DocId, + changed: Option, + timeout: Duration, + cancel: &CancellationToken, + ) -> IdeResult { + let snap = self.snapshot(doc)?; + let deadline = tokio::time::Instant::now() + timeout; + let left = || deadline.saturating_duration_since(tokio::time::Instant::now()); + let ready = tokio::select! { + r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + // Once the servers have the document: a save sent while one starts would not reach it. + self.did_save(doc, snap.text.as_str()); + let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async { + let mut report = CheckedServer { + name: server.name.clone(), + status: server.status(), + message: server.status_message(), + checked: false, + incomplete: false, + error: None, + }; + let Some(run) = run else { return Some((report, Vec::new())) }; + let t = Target { server: server.clone(), run }; + if !t.caps().restriction.diagnostics() { + return None; + } + let pull = t.caps().has("diagnosticProvider"); + let mut since = changed; + let raw = loop { + // A pull asks for this text; a push server is waited on to publish for it. + let raw = if pull { + server.wait_idle(left(), cancel).await; + self.pull_diagnostics(&t, &snap.uri, cancel, left()).await + } else { + let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE }; + wait_for_publish(server, &snap.path, since, opts, cancel).await + }; + // The save may have started work (a `cargo check`) that publishes when it ends. + if raw.is_err() || pull || !server.is_busy() || left().is_zero() { + break raw; + } + if !server.wait_idle(left(), cancel).await { + break raw.map(|_| server.diagnostics_for(&snap.path)); + } + // Idle: its last publish is final once quiet. + since = None; + }; + report.status = server.status(); + match raw { + Ok(items) => { + report.checked = true; + let fresh = + pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c)); + report.incomplete = !fresh || server.is_busy(); + let conv = self.conv(t.enc()); + let items = items + .into_iter() + .map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name)) + .collect::>(); + Some((report, items)) + } + Err(e) => { + report.error = Some(e.message); + Some((report, Vec::new())) + } + } + }); + let results = tokio::select! { + r = futures::future::join_all(per_server) => r, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + let mut out = DocumentCheck::default(); + for (report, items) in results.into_iter().flatten() { + out.servers.push(report); + out.diagnostics.extend(items); + } + Ok(out) + } + async fn pull_diagnostics( &self, t: &Target, diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 95a3557ff..c90a665e0 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -40,7 +40,7 @@ mod vue_navigation; mod vue_refs; pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES}; -pub use inspect::{FileInspection, InspectHost, InspectOptions}; +pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions}; pub use jdtls::jvm_inspect::JdtJvmInspector; pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable}; pub use launch::{LaunchError, Launcher, ProcessLauncher}; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index fe3cc2f1f..90500e6c8 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -658,6 +658,24 @@ impl Server { } } + /// Waits until no `$/progress` task is active, background work included (jdtls' builds, + /// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or + /// cancellation. + pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let notified = self.phase_notify.notified(); + if !self.is_busy() || self.running().is_none() { + return !self.is_busy(); + } + tokio::select! { + () = notified => {} + () = tokio::time::sleep_until(deadline) => return !self.is_busy(), + () = cancel.cancelled() => return false, + } + } + } + // ---- transiently opened files ---- /// Registers a holder of the transient file `path` on instance `generation`; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index a8bff65b1..7b6bdb7c2 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2178,6 +2178,57 @@ async fn missing_server_reports_once() { assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none()); } +#[tokio::test(flavor = "multi_thread")] +async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + let (doc, path) = h.open("a.mk", "bad\n"); + h.wait_status("mock", ServerStatus::Ready).await; + // The server answers the save with a publish, as an on-save check (`cargo check`) does. + let (server, uri) = (s.clone(), uri_of(&path)); + let publisher = tokio::spawn(async move { + eventually(|| !server.notified("textDocument/didSave").is_empty()).await; + let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}}, + "severity": 1, "message": "on save" }); + server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap(); + }); + let cancel = CancellationToken::new(); + let changed = Some(std::time::Instant::now()); + let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap(); + publisher.await.unwrap(); + assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::>(), ["on save"]); + assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers); + + // A change the server never answered: what it has is reported as not fresh. + let changed = Some(std::time::Instant::now()); + let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap(); + assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers); + assert_eq!(stale.diagnostics.len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checking_a_document_reports_servers_that_cannot_check() { + let cancel = CancellationToken::new(); + let h = harness(&[("ghost", json!({}))]); + h.launcher.state("ghost").missing.store(true, Ordering::SeqCst); + let (doc, _) = h.open("a.mk", "x"); + h.wait_status("ghost", ServerStatus::Missing).await; + let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap(); + assert!(check.diagnostics.is_empty()); + let s = &check.servers[0]; + assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false)); + assert_eq!(s.message.as_deref(), Some("install ghost")); + + let h = harness(&[("mock", incremental_caps())]); + h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into()))); + let (doc, _) = h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Misconfigured).await; + let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap(); + assert_eq!(check.servers[0].status, ServerStatus::Misconfigured); + assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers); + assert!(!check.servers[0].checked); +} + #[tokio::test(flavor = "multi_thread")] async fn launch_error_fails_without_starting_the_server() { let h = harness(&[("mock", incremental_caps())]); diff --git a/docs/en/guide/mcp.mdx b/docs/en/guide/mcp.mdx index 96c5b373c..c2780ba49 100644 --- a/docs/en/guide/mcp.mdx +++ b/docs/en/guide/mcp.mdx @@ -85,7 +85,7 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i | Search | `search_text`, `search_regex`, `search_symbol`, `search_file` | | Editors | `get_all_open_file_paths`, `get_current_file`, `open_file_in_editor` | | Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` | -| Problems | `get_file_problems`, `get_project_problems` | +| Problems | `get_file_problems`, `get_project_problems`, `check_files` | | Run and build | `get_run_configurations`, `execute_run_configuration`, `build_project` | | Commands | `run_command`, `wait_for_run`, `send_run_input`, `stop_run` | | Command state | `get_run_status`, `read_run_output`, `list_runs` | @@ -96,6 +96,7 @@ Notes on what they do: - `read_file` also reads sources of dependencies and files inside jar and zip archives. - `search_symbol` and `find_references` use the IDE's index and the language servers; `get_symbol_info` returns what Quick Documentation shows. - `get_file_problems` and `get_project_problems` return what the Problems tool window shows, after your inspection profile is applied, rather than a fresh analysis. +- `check_files` is the fresh analysis, for after an edit made by any tool: it syncs each file from disk, tells its language servers it was saved (so on-save checks such as `cargo check` run) and waits until they answered and finished. Each file gets a `status` that says whether the problems are complete, or why it could not be checked: no language server for it, or one that is not installed, misconfigured or crashed. - `rename_refactoring` renames through the language server, so references that a text search would miss are updated too. It can preview without writing. - `apply_patch` accepts the Codex `apply_patch` format and unified diffs. Paths must stay inside the project. - `git_diff` compares files on disk, so unsaved editor text is not included. diff --git a/docs/zh/guide/mcp.mdx b/docs/zh/guide/mcp.mdx index a9671dd65..ae50aef21 100644 --- a/docs/zh/guide/mcp.mdx +++ b/docs/zh/guide/mcp.mdx @@ -84,7 +84,7 @@ stdio 配置大致如下(路径以你机器上的为准): | 搜索 | `search_text`、`search_regex`、`search_symbol`、`search_file` | | 编辑器 | `get_all_open_file_paths`、`get_current_file`、`open_file_in_editor` | | 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` | -| 问题 | `get_file_problems`、`get_project_problems` | +| 问题 | `get_file_problems`、`get_project_problems`、`check_files` | | 运行与构建 | `get_run_configurations`、`execute_run_configuration`、`build_project` | | 命令 | `run_command`、`wait_for_run`、`send_run_input`、`stop_run` | | 命令状态 | `get_run_status`、`read_run_output`、`list_runs` | @@ -95,6 +95,7 @@ stdio 配置大致如下(路径以你机器上的为准): - `read_file` 还能读取依赖的源代码,以及 jar 和 zip 压缩包中的文件。 - `search_symbol` 和 `find_references` 使用 IDE 的索引和语言服务器;`get_symbol_info` 返回的内容与快速文档相同。 - `get_file_problems` 和 `get_project_problems` 返回的是问题工具窗口当前显示的内容(已应用你的检查配置文件),而不是重新分析的结果。 +- `check_files` 才是重新分析,适合在任何工具修改文件之后调用:它从磁盘同步每个文件,通知语言服务器文件已保存(这样 `cargo check` 等保存时检查也会运行),并等到服务器答复且分析完成。每个文件都有一个 `status`,说明问题列表是否完整,或者为什么无法检查:没有对应的语言服务器,或者服务器未安装、配置错误、已崩溃。 - `rename_refactoring` 通过语言服务器重命名,文本搜索找不到的引用也会一并更新;也可以只预览不写入。 - `apply_patch` 接受 Codex 的 `apply_patch` 格式和 unified diff,路径必须在项目内。 - `git_diff` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。