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>
This commit is contained in:
GuoYu
2026-11-13 13:29:43 +08:00
committed by GitHub
co-authored by guoyucode
parent 14ee1a97f2
commit ff1bfeea9d
13 changed files with 564 additions and 16 deletions
+176
View File
@@ -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<Value> = 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::<Vec<_>>()
.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"
);
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
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(),
+34 -9
View File
@@ -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<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; 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<n>: <line>`, 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<String> {
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
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<String>
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::<Vec<_>>().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::<Vec<_>>().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);
+2
View File
@@ -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<tool::Tool> {
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());
+1 -1
View File
@@ -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<Duration> {
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
+131 -2
View File
@@ -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<Tool> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
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<String> {
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<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = 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))) = &current 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<Output> {
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<Output> {
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<SearchItem> = 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<Output> {
..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<Output> {
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<Output> {
}
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();
+29
View File
@@ -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.
+116
View File
@@ -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<String>,
/// 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<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// 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<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
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::<Vec<_>>();
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,
+1 -1
View File
@@ -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};
+18
View File
@@ -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`;
+51
View File
@@ -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::<Vec<_>>(), ["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())]);
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。