返回 CodeWhale
lsp.rs
根目录 / crates / tui / src / tools / lsp.rs
1 //! Model-facing LSP code-intelligence tool.
2 //!
3 //! Extends the existing [`crate::lsp::LspManager`] lifecycle — never spawns a
4 //! competing server pool. Operations: diagnostics, read_lints, symbols,
5 //! definition, references.
6
7 use async_trait::async_trait;
8 use serde::Serialize;
9 use serde_json::{Value, json};
10 use std::path::{Path, PathBuf};
11
12 use crate::lsp::LintReadStatus;
13
14 use super::spec::{
15 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
16 optional_str, required_str,
17 };
18
19 /// Model-callable LSP intelligence surface.
20 pub struct LspTool;
21
22 #[async_trait]
23 impl ToolSpec for LspTool {
24 fn name(&self) -> &'static str {
25 "lsp"
26 }
27
28 fn description(&self) -> &'static str {
29 "Query the configured session LSP for diagnostics, symbols, definitions, \
30 references, or read_lints: 1-16 newline-separated files with \
31 success/error/timeout; warnings follow include_warnings."
32 }
33
34 fn input_schema(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "operation": {
39 "type": "string",
40 "enum": ["diagnostics", "read_lints", "symbols", "definition", "references"],
41 "description": "Operation. read_lints reports per-file status, counts/count_complete, and truncation."
42 },
43 "path": {
44 "type": "string",
45 "description": "Source path. For read_lints: 1-16 newline-separated workspace-relative files."
46 },
47 "line": {
48 "type": "integer",
49 "minimum": 1,
50 "description": "1-based line."
51 },
52 "character": {
53 "type": "integer",
54 "minimum": 1,
55 "default": 1,
56 "description": "1-based column (default 1)."
57 },
58 "query": {
59 "type": "string",
60 "description": "Workspace symbol query."
61 }
62 },
63 "required": ["operation", "path"]
64 })
65 }
66
67 fn capabilities(&self) -> Vec<ToolCapability> {
68 vec![ToolCapability::ReadOnly]
69 }
70
71 fn approval_requirement(&self) -> ApprovalRequirement {
72 ApprovalRequirement::Auto
73 }
74
75 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
76 let operation = required_str(&input, "operation")?;
77 let path_raw = required_str(&input, "path")?;
78 let line = input.get("line").and_then(|v| v.as_u64()).map(|n| n as u32);
79 let character = input
80 .get("character")
81 .and_then(|v| v.as_u64())
82 .map(|n| n as u32);
83 let query = optional_str(&input, "query")?;
84
85 if operation == "read_lints" {
86 let paths = path_raw
87 .split('\n')
88 .map(str::trim)
89 .filter(|path| !path.is_empty())
90 .map(ToOwned::to_owned)
91 .collect::<Vec<_>>();
92 return execute_read_lints(json!({"paths": paths}), context).await;
93 }
94
95 let manager = context.lsp_manager.as_ref().ok_or_else(|| {
96 ToolError::execution_failed(
97 "LSP manager is not attached to this tool context (LSP unavailable for this session)",
98 )
99 })?;
100
101 let path = context.resolve_path(path_raw)?;
102 let payload = manager
103 .intelligence(operation, &path, line, character, query)
104 .await
105 .map_err(ToolError::execution_failed)?;
106
107 Ok(ToolResult::success(
108 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()),
109 ))
110 }
111 }
112
113 const MAX_LINT_PATHS: usize = 16;
114 const MAX_LINT_DIAGNOSTICS: usize = 100;
115 const MAX_LINT_MESSAGE_CHARS: usize = 512;
116 const MAX_LINT_OUTPUT_BYTES: usize = 12_000;
117
118 #[derive(Serialize)]
119 struct ReadLintsDiagnostic {
120 line: u32,
121 column: u32,
122 severity: String,
123 message: String,
124 #[serde(skip_serializing_if = "is_false")]
125 message_truncated: bool,
126 }
127
128 #[derive(Serialize)]
129 struct ReadLintsFile {
130 #[serde(flatten)]
131 freshness: crate::lsp::DiagnosticFreshness,
132 file: String,
133 status: &'static str,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 error: Option<String>,
136 #[serde(skip_serializing_if = "is_false")]
137 error_truncated: bool,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 timeout_ms: Option<u64>,
140 diagnostics: Vec<ReadLintsDiagnostic>,
141 diagnostic_count: usize,
142 total_diagnostic_count: Option<usize>,
143 count_complete: bool,
144 truncated: bool,
145 }
146
147 #[derive(Serialize)]
148 struct ReadLintsOutput {
149 files: Vec<ReadLintsFile>,
150 file_count: usize,
151 total_file_count: usize,
152 diagnostic_count: usize,
153 total_diagnostic_count: Option<usize>,
154 count_complete: bool,
155 truncated: bool,
156 }
157
158 impl ReadLintsOutput {
159 fn refresh_returned_metadata(&mut self) {
160 for file in &mut self.files {
161 file.diagnostic_count = file.diagnostics.len();
162 file.truncated |= file
163 .total_diagnostic_count
164 .is_some_and(|total| file.diagnostic_count < total);
165 }
166 self.file_count = self.files.len();
167 self.diagnostic_count = self.files.iter().map(|file| file.diagnostic_count).sum();
168 self.truncated =
169 self.file_count < self.total_file_count || self.files.iter().any(|file| file.truncated);
170 }
171 }
172
173 fn is_false(value: &bool) -> bool {
174 !*value
175 }
176
177 fn bounded_text(value: &str, max_chars: usize) -> (String, bool) {
178 let mut chars = value.chars();
179 let bounded = chars.by_ref().take(max_chars).collect();
180 (bounded, chars.next().is_some())
181 }
182
183 /// Read bounded diagnostics for several existing files without requiring a
184 /// preceding edit. The model-facing entry point is the `lsp` operation above;
185 /// keeping this as a helper avoids adding a second catalog tool name.
186 async fn execute_read_lints(input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
187 let raw_paths = input
188 .get("paths")
189 .and_then(Value::as_array)
190 .ok_or_else(|| ToolError::invalid_input("paths must be a non-empty array"))?;
191 if raw_paths.is_empty() || raw_paths.len() > MAX_LINT_PATHS {
192 return Err(ToolError::invalid_input(format!(
193 "paths must contain between 1 and {MAX_LINT_PATHS} files"
194 )));
195 }
196
197 let paths = raw_paths
198 .iter()
199 .map(|value| {
200 let raw = value
201 .as_str()
202 .ok_or_else(|| ToolError::invalid_input("each paths entry must be a string"))?;
203 resolve_lint_path(&context.workspace, raw)
204 })
205 .collect::<Result<Vec<_>, _>>()?;
206
207 let manager = context.lsp_manager.as_ref().ok_or_else(|| {
208 ToolError::execution_failed(
209 "LSP manager is not attached to this tool context; enable LSP for this session",
210 )
211 })?;
212 let results = manager
213 .diagnostics_for_paths(&paths)
214 .await
215 .map_err(ToolError::execution_failed)?;
216
217 let total_file_count = results.len();
218 let total_diagnostic_count = results.iter().try_fold(0usize, |count, result| {
219 result
220 .total_diagnostic_count
221 .map(|total| count.saturating_add(total))
222 });
223 let count_complete = total_diagnostic_count.is_some();
224 let mut remaining_diagnostics = MAX_LINT_DIAGNOSTICS;
225 let mut files = Vec::with_capacity(results.len());
226 for result in results {
227 let file_total_diagnostic_count = result.total_diagnostic_count;
228 let file_count_complete = file_total_diagnostic_count.is_some();
229 let (status, error, error_truncated, timeout_ms) = match result.status {
230 LintReadStatus::Success => ("success", None, false, None),
231 LintReadStatus::Error(error) => {
232 let (error, truncated) = bounded_text(&error, MAX_LINT_MESSAGE_CHARS);
233 ("error", Some(error), truncated, None)
234 }
235 LintReadStatus::Timeout { wait_ms } => (
236 "timeout",
237 Some(format!("LSP diagnostics timed out after {wait_ms} ms")),
238 false,
239 Some(wait_ms),
240 ),
241 };
242 let available_count = result.items.len();
243 let take_count = available_count.min(remaining_diagnostics);
244 remaining_diagnostics -= take_count;
245 let mut message_was_truncated = false;
246 let diagnostics = result
247 .items
248 .into_iter()
249 .take(take_count)
250 .map(|diagnostic| {
251 let (message, message_truncated) =
252 bounded_text(&diagnostic.message, MAX_LINT_MESSAGE_CHARS);
253 message_was_truncated |= message_truncated;
254 ReadLintsDiagnostic {
255 line: diagnostic.line,
256 column: diagnostic.column,
257 severity: format!("{:?}", diagnostic.severity).to_ascii_lowercase(),
258 message,
259 message_truncated,
260 }
261 })
262 .collect::<Vec<_>>();
263 files.push(ReadLintsFile {
264 freshness: result.freshness,
265 file: result.file.display().to_string(),
266 status,
267 error,
268 error_truncated,
269 timeout_ms,
270 diagnostic_count: diagnostics.len(),
271 total_diagnostic_count: file_total_diagnostic_count,
272 count_complete: file_count_complete,
273 truncated: result.truncated
274 || take_count < available_count
275 || message_was_truncated
276 || error_truncated,
277 diagnostics,
278 });
279 }
280
281 let mut output = ReadLintsOutput {
282 files,
283 file_count: total_file_count,
284 total_file_count,
285 diagnostic_count: 0,
286 total_diagnostic_count,
287 count_complete,
288 truncated: false,
289 };
290 output.refresh_returned_metadata();
291 loop {
292 let output_len = serde_json::to_vec(&output)
293 .map_err(|error| ToolError::execution_failed(error.to_string()))?
294 .len();
295 if output_len <= MAX_LINT_OUTPUT_BYTES {
296 break;
297 }
298 if let Some(file) = output
299 .files
300 .iter_mut()
301 .rev()
302 .find(|file| !file.diagnostics.is_empty())
303 {
304 file.diagnostics.pop();
305 file.truncated = true;
306 } else if output.files.pop().is_none() {
307 return Err(ToolError::execution_failed(
308 "read_lints metadata exceeded its output bound",
309 ));
310 }
311 output.refresh_returned_metadata();
312 }
313
314 ToolResult::json(&output).map_err(|error| ToolError::execution_failed(error.to_string()))
315 }
316
317 fn resolve_lint_path(workspace: &Path, raw: &str) -> Result<PathBuf, ToolError> {
318 let raw = raw.trim();
319 let candidate = Path::new(raw);
320 if raw.is_empty() || candidate.is_absolute() {
321 return Err(ToolError::permission_denied(
322 "read_lints paths must be non-empty workspace-relative files",
323 ));
324 }
325 if candidate
326 .components()
327 .any(|component| matches!(component, std::path::Component::ParentDir))
328 {
329 return Err(ToolError::permission_denied(
330 "read_lints paths cannot contain '..' traversal",
331 ));
332 }
333 let workspace = workspace.canonicalize().map_err(|error| {
334 ToolError::execution_failed(format!("failed to resolve workspace: {error}"))
335 })?;
336 let path = workspace.join(candidate).canonicalize().map_err(|error| {
337 ToolError::execution_failed(format!("failed to read_lints path {raw}: {error}"))
338 })?;
339 if !path.starts_with(&workspace) {
340 return Err(ToolError::permission_denied(
341 "read_lints path resolves outside the workspace",
342 ));
343 }
344 if !path.is_file() {
345 return Err(ToolError::invalid_input(format!(
346 "read_lints path is not a file: {raw}"
347 )));
348 }
349 Ok(path)
350 }
351
352 #[cfg(test)]
353 mod tests {
354 use super::*;
355 use crate::lsp::{Diagnostic, Language, LspConfig, LspManager, Severity};
356 use crate::tools::spec::ToolContext;
357 use async_trait::async_trait;
358 use std::path::Path;
359 use std::sync::Arc;
360 use std::sync::atomic::{AtomicUsize, Ordering};
361 use std::time::Duration;
362 use tempfile::tempdir;
363
364 struct CountingTransport {
365 calls: AtomicUsize,
366 request_calls: AtomicUsize,
367 }
368
369 #[async_trait]
370 impl crate::lsp::LspTransport for CountingTransport {
371 async fn diagnostics_for(
372 &self,
373 _path: &Path,
374 _text: &str,
375 _wait: Duration,
376 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
377 self.calls.fetch_add(1, Ordering::Relaxed);
378 Ok(vec![Diagnostic {
379 line: 1,
380 column: 1,
381 severity: Severity::Error,
382 message: "boom".into(),
383 }]
384 .into())
385 }
386
387 async fn request(
388 &self,
389 method: &str,
390 _params: Value,
391 _wait: Duration,
392 ) -> anyhow::Result<Value> {
393 self.request_calls.fetch_add(1, Ordering::Relaxed);
394 Ok(json!({ "method": method, "locations": [] }))
395 }
396
397 async fn shutdown(&self) {}
398 }
399
400 struct EmptyTransport;
401
402 #[async_trait]
403 impl crate::lsp::LspTransport for EmptyTransport {
404 async fn diagnostics_for(
405 &self,
406 _path: &Path,
407 _text: &str,
408 _wait: Duration,
409 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
410 Ok(Vec::new().into())
411 }
412
413 async fn request(
414 &self,
415 _method: &str,
416 _params: Value,
417 _wait: Duration,
418 ) -> anyhow::Result<Value> {
419 Ok(json!({}))
420 }
421
422 async fn shutdown(&self) {}
423 }
424
425 struct FixedTransport {
426 items: Vec<Diagnostic>,
427 }
428
429 #[async_trait]
430 impl crate::lsp::LspTransport for FixedTransport {
431 async fn diagnostics_for(
432 &self,
433 _path: &Path,
434 _text: &str,
435 _wait: Duration,
436 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
437 Ok(self.items.clone().into())
438 }
439
440 async fn shutdown(&self) {}
441 }
442
443 struct ErrorTransport;
444
445 #[async_trait]
446 impl crate::lsp::LspTransport for ErrorTransport {
447 async fn diagnostics_for(
448 &self,
449 _path: &Path,
450 _text: &str,
451 _wait: Duration,
452 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
453 anyhow::bail!("server exploded")
454 }
455
456 async fn shutdown(&self) {}
457 }
458
459 struct TimeoutTransport;
460
461 #[async_trait]
462 impl crate::lsp::LspTransport for TimeoutTransport {
463 async fn diagnostics_for(
464 &self,
465 _path: &Path,
466 _text: &str,
467 _wait: Duration,
468 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
469 std::future::pending().await
470 }
471
472 async fn shutdown(&self) {}
473 }
474
475 #[test]
476 fn schema_documents_read_lints_contract_query_and_character_default() {
477 assert!(LspTool.description().contains("1-16 newline-separated"));
478 assert!(LspTool.description().contains("success/error/timeout"));
479 assert!(LspTool.description().contains("include_warnings"));
480 let schema = LspTool.input_schema();
481 let properties = &schema["properties"];
482 assert!(
483 properties["operation"]["description"]
484 .as_str()
485 .unwrap()
486 .contains("counts/count_complete")
487 );
488 assert!(
489 properties["path"]["description"]
490 .as_str()
491 .unwrap()
492 .contains("1-16 newline-separated")
493 );
494 assert_eq!(properties["line"]["description"], "1-based line.");
495 assert_eq!(properties["character"]["default"], 1);
496 assert_eq!(
497 properties["character"]["description"],
498 "1-based column (default 1)."
499 );
500 assert_eq!(
501 properties["query"]["description"],
502 "Workspace symbol query."
503 );
504 }
505
506 #[tokio::test]
507 async fn intelligence_paths_cannot_escape_before_transport_dispatch() {
508 let root = tempdir().unwrap();
509 let workspace = root.path().join("workspace");
510 std::fs::create_dir(&workspace).unwrap();
511 let outside = root.path().join("secret.rs");
512 std::fs::write(&outside, "fn secret() {}\n").unwrap();
513 std::fs::write(workspace.join("lib.rs"), "fn local() {}\n").unwrap();
514 let mgr = Arc::new(LspManager::new(LspConfig::default(), workspace.clone()));
515 let transport = Arc::new(CountingTransport {
516 calls: AtomicUsize::new(0),
517 request_calls: AtomicUsize::new(0),
518 });
519 mgr.install_test_transport(Language::Rust, transport.clone())
520 .await;
521 let ctx = ToolContext::new(&workspace).with_lsp_manager(mgr);
522 let denied = vec![outside.display().to_string(), "../secret.rs".into()];
523 #[cfg(unix)]
524 let denied = {
525 std::os::unix::fs::symlink(&outside, workspace.join("escape.rs")).unwrap();
526 let mut denied = denied;
527 denied.push("escape.rs".into());
528 denied
529 };
530 for operation in ["diagnostics", "symbols", "definition", "references"] {
531 for path in &denied {
532 let result = LspTool
533 .execute(
534 json!({"operation": operation, "path": path, "line": 1}),
535 &ctx,
536 )
537 .await;
538 assert!(result.is_err(), "{operation}: {path}");
539 }
540 }
541 assert_eq!(transport.calls.load(Ordering::Relaxed), 0);
542 assert_eq!(transport.request_calls.load(Ordering::Relaxed), 0);
543 for path in [
544 "lib.rs".to_string(),
545 workspace.join("lib.rs").display().to_string(),
546 ] {
547 assert!(
548 LspTool
549 .execute(
550 json!({"operation": "definition", "path": path, "line": 1}),
551 &ctx
552 )
553 .await
554 .unwrap()
555 .success
556 );
557 }
558 assert_eq!(transport.request_calls.load(Ordering::Relaxed), 2);
559 }
560
561 #[tokio::test]
562 async fn tool_reuses_single_manager_transport_for_definition() {
563 let dir = tempdir().unwrap();
564 let path = dir.path().join("lib.rs");
565 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
566
567 let mgr = Arc::new(LspManager::new(
568 LspConfig::default(),
569 dir.path().to_path_buf(),
570 ));
571 let transport = Arc::new(CountingTransport {
572 calls: AtomicUsize::new(0),
573 request_calls: AtomicUsize::new(0),
574 });
575 mgr.install_test_transport(Language::Rust, transport.clone())
576 .await;
577
578 let mut ctx = ToolContext::new(dir.path());
579 ctx = ctx.with_lsp_manager(mgr);
580
581 let tool = LspTool;
582 for _ in 0..2 {
583 let result = tool
584 .execute(
585 json!({
586 "operation": "definition",
587 "path": "lib.rs",
588 "line": 1,
589 "character": 4
590 }),
591 &ctx,
592 )
593 .await
594 .expect("definition succeeds");
595 assert!(result.success, "{}", result.content);
596 assert!(result.content.contains("definition"));
597 }
598 assert_eq!(
599 transport.request_calls.load(Ordering::Relaxed),
600 2,
601 "two definition calls"
602 );
603 }
604
605 #[tokio::test]
606 async fn diagnostics_operation_returns_items() {
607 let dir = tempdir().unwrap();
608 let path = dir.path().join("lib.rs");
609 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
610
611 let mgr = Arc::new(LspManager::new(
612 LspConfig::default(),
613 dir.path().to_path_buf(),
614 ));
615 let transport = Arc::new(CountingTransport {
616 calls: AtomicUsize::new(0),
617 request_calls: AtomicUsize::new(0),
618 });
619 mgr.install_test_transport(Language::Rust, transport.clone())
620 .await;
621
622 let mut ctx = ToolContext::new(dir.path());
623 ctx = ctx.with_lsp_manager(mgr);
624
625 let result = LspTool
626 .execute(
627 json!({ "operation": "diagnostics", "path": "lib.rs" }),
628 &ctx,
629 )
630 .await
631 .expect("diagnostics");
632 assert!(result.success);
633 assert!(result.content.contains("boom"));
634 assert_eq!(transport.calls.load(Ordering::Relaxed), 1);
635 }
636
637 #[tokio::test]
638 async fn read_lints_returns_structured_diagnostics_for_multiple_files() {
639 let dir = tempdir().unwrap();
640 let first = dir.path().join("lib.rs");
641 let second = dir.path().join("main.rs");
642 tokio::fs::write(&first, b"fn lib() {}\n").await.unwrap();
643 tokio::fs::write(&second, b"fn main() {}\n").await.unwrap();
644
645 let mgr = Arc::new(LspManager::new(
646 LspConfig::default(),
647 dir.path().to_path_buf(),
648 ));
649 mgr.install_test_transport(
650 Language::Rust,
651 Arc::new(CountingTransport {
652 calls: AtomicUsize::new(0),
653 request_calls: AtomicUsize::new(0),
654 }),
655 )
656 .await;
657 let mut ctx = ToolContext::new(dir.path());
658 ctx = ctx.with_lsp_manager(mgr);
659
660 let result = LspTool
661 .execute(
662 json!({
663 "operation": "read_lints",
664 "path": "lib.rs\nmain.rs"
665 }),
666 &ctx,
667 )
668 .await
669 .expect("read_lints");
670 let payload: Value = serde_json::from_str(&result.content).unwrap();
671 assert_eq!(payload["files"].as_array().unwrap().len(), 2);
672 assert_eq!(payload["file_count"], 2);
673 assert_eq!(payload["total_file_count"], 2);
674 assert_eq!(payload["diagnostic_count"], 2);
675 assert_eq!(payload["total_diagnostic_count"], 2);
676 assert_eq!(payload["count_complete"], true);
677 assert_eq!(payload["truncated"], false);
678 assert_eq!(payload["files"][0]["status"], "success");
679 assert_eq!(payload["files"][0]["diagnostic_count"], 1);
680 assert_eq!(payload["files"][0]["total_diagnostic_count"], 1);
681 assert_eq!(payload["files"][0]["count_complete"], true);
682 assert_eq!(payload["files"][0]["diagnostics"][0]["line"], 1);
683 assert_eq!(payload["files"][0]["diagnostics"][0]["severity"], "error");
684 assert_eq!(payload["files"][0]["diagnostics"][0]["message"], "boom");
685 }
686
687 #[tokio::test]
688 async fn read_lints_preserves_files_with_empty_diagnostics() {
689 let dir = tempdir().unwrap();
690 let path = dir.path().join("lib.rs");
691 tokio::fs::write(&path, b"fn main() {}\n").await.unwrap();
692
693 let mgr = Arc::new(LspManager::new(
694 LspConfig::default(),
695 dir.path().to_path_buf(),
696 ));
697 mgr.install_test_transport(Language::Rust, Arc::new(EmptyTransport))
698 .await;
699 let mut ctx = ToolContext::new(dir.path());
700 ctx = ctx.with_lsp_manager(mgr);
701
702 let result = LspTool
703 .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx)
704 .await
705 .expect("empty diagnostics are a successful read");
706 let payload: Value = serde_json::from_str(&result.content).unwrap();
707 assert_eq!(payload["diagnostic_count"], 0);
708 assert_eq!(payload["total_diagnostic_count"], 0);
709 assert_eq!(payload["count_complete"], true);
710 assert_eq!(payload["truncated"], false);
711 assert_eq!(payload["files"][0]["status"], "success");
712 assert_eq!(payload["files"][0]["diagnostic_count"], 0);
713 assert_eq!(payload["files"][0]["total_diagnostic_count"], 0);
714 assert_eq!(payload["files"][0]["count_complete"], true);
715 assert_eq!(payload["files"][0]["truncated"], false);
716 assert_eq!(payload["files"][0]["diagnostics"], json!([]));
717 }
718
719 #[tokio::test]
720 async fn read_lints_reports_file_read_error() {
721 let dir = tempdir().unwrap();
722 let path = dir.path().join("invalid.rs");
723 tokio::fs::write(&path, [0xff]).await.unwrap();
724
725 let mgr = Arc::new(LspManager::new(
726 LspConfig::default(),
727 dir.path().to_path_buf(),
728 ));
729 let mut ctx = ToolContext::new(dir.path());
730 ctx = ctx.with_lsp_manager(mgr);
731
732 let result = LspTool
733 .execute(
734 json!({"operation": "read_lints", "path": "invalid.rs"}),
735 &ctx,
736 )
737 .await
738 .expect("read failure is a per-file result");
739 let payload: Value = serde_json::from_str(&result.content).unwrap();
740 assert_eq!(payload["files"][0]["status"], "error");
741 assert!(
742 payload["files"][0]["error"]
743 .as_str()
744 .unwrap()
745 .contains("failed to read file")
746 );
747 assert_eq!(payload["files"][0]["total_diagnostic_count"], Value::Null);
748 assert_eq!(payload["files"][0]["count_complete"], false);
749 assert_eq!(payload["total_diagnostic_count"], Value::Null);
750 assert_eq!(payload["count_complete"], false);
751 assert_eq!(payload["files"][0]["diagnostics"], json!([]));
752 }
753
754 #[tokio::test]
755 async fn read_lints_distinguishes_server_error_and_timeout() {
756 let dir = tempdir().unwrap();
757 tokio::fs::write(dir.path().join("error.rs"), b"fn main() {}\n")
758 .await
759 .unwrap();
760 tokio::fs::write(dir.path().join("timeout.py"), b"pass\n")
761 .await
762 .unwrap();
763
764 let mgr = Arc::new(LspManager::new(
765 LspConfig {
766 poll_after_edit_ms: 5,
767 ..LspConfig::default()
768 },
769 dir.path().to_path_buf(),
770 ));
771 mgr.install_test_transport(Language::Rust, Arc::new(ErrorTransport))
772 .await;
773 mgr.install_test_transport(Language::Python, Arc::new(TimeoutTransport))
774 .await;
775 let mut ctx = ToolContext::new(dir.path());
776 ctx = ctx.with_lsp_manager(mgr);
777
778 let result = LspTool
779 .execute(
780 json!({
781 "operation": "read_lints",
782 "path": "error.rs\ntimeout.py"
783 }),
784 &ctx,
785 )
786 .await
787 .expect("per-file failures remain structured results");
788 let payload: Value = serde_json::from_str(&result.content).unwrap();
789 assert_eq!(payload["files"][0]["status"], "error");
790 assert!(
791 payload["files"][0]["error"]
792 .as_str()
793 .unwrap()
794 .contains("server exploded")
795 );
796 assert_eq!(payload["files"][1]["status"], "timeout");
797 assert_eq!(payload["files"][1]["timeout_ms"], 5);
798 assert!(
799 payload["files"][1]["error"]
800 .as_str()
801 .unwrap()
802 .contains("timed out")
803 );
804 assert_eq!(payload["files"][0]["total_diagnostic_count"], Value::Null);
805 assert_eq!(payload["files"][1]["total_diagnostic_count"], Value::Null);
806 assert_eq!(payload["files"][0]["count_complete"], false);
807 assert_eq!(payload["files"][1]["count_complete"], false);
808 assert_eq!(payload["total_diagnostic_count"], Value::Null);
809 assert_eq!(payload["count_complete"], false);
810 }
811
812 #[tokio::test]
813 async fn read_lints_excludes_warnings_when_include_warnings_is_false() {
814 let dir = tempdir().unwrap();
815 let path = dir.path().join("lib.rs");
816 tokio::fs::write(&path, b"fn main() {}\n").await.unwrap();
817 let items = vec![
818 Diagnostic {
819 line: 4,
820 column: 1,
821 severity: Severity::Hint,
822 message: "hint".into(),
823 },
824 Diagnostic {
825 line: 2,
826 column: 1,
827 severity: Severity::Warning,
828 message: "warning".into(),
829 },
830 Diagnostic {
831 line: 3,
832 column: 1,
833 severity: Severity::Information,
834 message: "information".into(),
835 },
836 Diagnostic {
837 line: 1,
838 column: 1,
839 severity: Severity::Error,
840 message: "error".into(),
841 },
842 ];
843 let mgr = Arc::new(LspManager::new(
844 LspConfig::default(),
845 dir.path().to_path_buf(),
846 ));
847 mgr.install_test_transport(
848 Language::Rust,
849 Arc::new(FixedTransport {
850 items: items.clone(),
851 }),
852 )
853 .await;
854 let mut ctx = ToolContext::new(dir.path());
855 ctx = ctx.with_lsp_manager(mgr.clone());
856
857 let result = LspTool
858 .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx)
859 .await
860 .expect("read_lints");
861 let payload: Value = serde_json::from_str(&result.content).unwrap();
862 let severities = payload["files"][0]["diagnostics"]
863 .as_array()
864 .unwrap()
865 .iter()
866 .map(|item| item["severity"].as_str().unwrap())
867 .collect::<Vec<_>>();
868 assert_eq!(severities, vec!["error"]);
869 assert_eq!(payload["files"][0]["total_diagnostic_count"], 1);
870 assert_eq!(payload["files"][0]["count_complete"], true);
871 }
872
873 #[tokio::test]
874 async fn read_lints_includes_warnings_when_configured() {
875 let dir = tempdir().unwrap();
876 tokio::fs::write(dir.path().join("lib.rs"), b"fn main() {}\n")
877 .await
878 .unwrap();
879 let items = vec![
880 Diagnostic {
881 line: 3,
882 column: 1,
883 severity: Severity::Information,
884 message: "information".into(),
885 },
886 Diagnostic {
887 line: 2,
888 column: 1,
889 severity: Severity::Warning,
890 message: "warning".into(),
891 },
892 Diagnostic {
893 line: 1,
894 column: 1,
895 severity: Severity::Error,
896 message: "error".into(),
897 },
898 ];
899 let mgr = Arc::new(LspManager::new(
900 LspConfig {
901 include_warnings: true,
902 ..LspConfig::default()
903 },
904 dir.path().to_path_buf(),
905 ));
906 mgr.install_test_transport(Language::Rust, Arc::new(FixedTransport { items }))
907 .await;
908 let mut ctx = ToolContext::new(dir.path());
909 ctx = ctx.with_lsp_manager(mgr);
910
911 let result = LspTool
912 .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx)
913 .await
914 .expect("read_lints");
915 let payload: Value = serde_json::from_str(&result.content).unwrap();
916 let severities = payload["files"][0]["diagnostics"]
917 .as_array()
918 .unwrap()
919 .iter()
920 .map(|item| item["severity"].as_str().unwrap())
921 .collect::<Vec<_>>();
922 assert_eq!(severities, vec!["error", "warning"]);
923 assert_eq!(payload["files"][0]["total_diagnostic_count"], 2);
924 assert_eq!(payload["files"][0]["count_complete"], true);
925 }
926
927 #[tokio::test]
928 async fn read_lints_propagates_underlying_truncation_and_total_count() {
929 let dir = tempdir().unwrap();
930 tokio::fs::write(dir.path().join("lib.rs"), b"fn main() {}\n")
931 .await
932 .unwrap();
933 let items = (0..4)
934 .map(|index| Diagnostic {
935 line: index + 1,
936 column: 1,
937 severity: Severity::Error,
938 message: format!("error {index}"),
939 })
940 .collect();
941 let mgr = Arc::new(LspManager::new(
942 LspConfig {
943 max_diagnostics_per_file: 2,
944 ..LspConfig::default()
945 },
946 dir.path().to_path_buf(),
947 ));
948 mgr.install_test_transport(Language::Rust, Arc::new(FixedTransport { items }))
949 .await;
950 let mut ctx = ToolContext::new(dir.path());
951 ctx = ctx.with_lsp_manager(mgr);
952
953 let result = LspTool
954 .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx)
955 .await
956 .expect("read_lints");
957 let payload: Value = serde_json::from_str(&result.content).unwrap();
958 assert_eq!(payload["diagnostic_count"], 2);
959 assert_eq!(payload["total_diagnostic_count"], 4);
960 assert_eq!(payload["truncated"], true);
961 assert_eq!(payload["files"][0]["diagnostic_count"], 2);
962 assert_eq!(payload["files"][0]["total_diagnostic_count"], 4);
963 assert_eq!(payload["files"][0]["truncated"], true);
964 }
965
966 #[tokio::test]
967 async fn read_lints_outer_bounds_keep_returned_counts_truthful() {
968 let dir = tempdir().unwrap();
969 tokio::fs::write(dir.path().join("lib.rs"), b"fn main() {}\n")
970 .await
971 .unwrap();
972 let items = (0..105)
973 .map(|index| Diagnostic {
974 line: index + 1,
975 column: 1,
976 severity: Severity::Error,
977 message: "x".repeat(MAX_LINT_MESSAGE_CHARS + 10),
978 })
979 .collect();
980 let mgr = Arc::new(LspManager::new(
981 LspConfig {
982 max_diagnostics_per_file: 200,
983 ..LspConfig::default()
984 },
985 dir.path().to_path_buf(),
986 ));
987 mgr.install_test_transport(Language::Rust, Arc::new(FixedTransport { items }))
988 .await;
989 let mut ctx = ToolContext::new(dir.path());
990 ctx = ctx.with_lsp_manager(mgr);
991
992 let result = LspTool
993 .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx)
994 .await
995 .expect("read_lints");
996 assert!(result.content.len() <= MAX_LINT_OUTPUT_BYTES);
997 let payload: Value = serde_json::from_str(&result.content).unwrap();
998 let returned = payload["files"]
999 .as_array()
1000 .unwrap()
1001 .iter()
1002 .map(|file| file["diagnostics"].as_array().unwrap().len())
1003 .sum::<usize>();
1004 assert_eq!(payload["diagnostic_count"], returned);
1005 assert_eq!(payload["files"][0]["diagnostic_count"], returned);
1006 assert_eq!(payload["total_diagnostic_count"], 105);
1007 assert_eq!(payload["files"][0]["total_diagnostic_count"], 105);
1008 assert!(returned < MAX_LINT_DIAGNOSTICS);
1009 assert_eq!(payload["truncated"], true);
1010 assert_eq!(payload["files"][0]["truncated"], true);
1011 assert_eq!(
1012 payload["files"][0]["diagnostics"][0]["message_truncated"],
1013 true
1014 );
1015 }
1016
1017 #[tokio::test]
1018 async fn read_lints_caps_short_diagnostics_at_one_hundred_globally() {
1019 let dir = tempdir().unwrap();
1020 tokio::fs::write(dir.path().join("lib.rs"), b"fn lib() {}\n")
1021 .await
1022 .unwrap();
1023 tokio::fs::write(dir.path().join("main.rs"), b"fn main() {}\n")
1024 .await
1025 .unwrap();
1026 let items = (0..60)
1027 .map(|index| Diagnostic {
1028 line: index + 1,
1029 column: 1,
1030 severity: Severity::Error,
1031 message: "e".to_string(),
1032 })
1033 .collect();
1034 let mgr = Arc::new(LspManager::new(
1035 LspConfig {
1036 max_diagnostics_per_file: 200,
1037 ..LspConfig::default()
1038 },
1039 dir.path().to_path_buf(),
1040 ));
1041 mgr.install_test_transport(Language::Rust, Arc::new(FixedTransport { items }))
1042 .await;
1043 let mut ctx = ToolContext::new(dir.path());
1044 ctx = ctx.with_lsp_manager(mgr);
1045
1046 let result = LspTool
1047 .execute(
1048 json!({"operation": "read_lints", "path": "lib.rs\nmain.rs"}),
1049 &ctx,
1050 )
1051 .await
1052 .expect("read_lints");
1053 let payload: Value = serde_json::from_str(&result.content).unwrap();
1054
1055 assert_eq!(payload["diagnostic_count"], MAX_LINT_DIAGNOSTICS);
1056 assert_eq!(payload["total_diagnostic_count"], 120);
1057 assert_eq!(payload["count_complete"], true);
1058 assert_eq!(payload["files"][0]["diagnostic_count"], 60);
1059 assert_eq!(payload["files"][1]["diagnostic_count"], 40);
1060 assert_eq!(payload["files"][1]["total_diagnostic_count"], 60);
1061 assert_eq!(payload["files"][1]["truncated"], true);
1062 assert_eq!(payload["truncated"], true);
1063 assert!(result.content.len() <= MAX_LINT_OUTPUT_BYTES);
1064 }
1065
1066 #[tokio::test]
1067 async fn disabled_lsp_hard_blocks_tool() {
1068 let dir = tempdir().unwrap();
1069 let path = dir.path().join("lib.rs");
1070 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1071 let mgr = Arc::new(LspManager::new(
1072 LspConfig {
1073 enabled: false,
1074 ..LspConfig::default()
1075 },
1076 dir.path().to_path_buf(),
1077 ));
1078 let mut ctx = ToolContext::new(dir.path());
1079 ctx = ctx.with_lsp_manager(mgr);
1080 let err = LspTool
1081 .execute(
1082 json!({ "operation": "diagnostics", "path": "lib.rs" }),
1083 &ctx,
1084 )
1085 .await
1086 .expect_err("disabled must fail");
1087 assert!(
1088 err.to_string().contains("disabled"),
1089 "unexpected error: {err}"
1090 );
1091
1092 let path_error = LspTool
1093 .execute(
1094 json!({"operation": "read_lints", "path": "../outside.rs"}),
1095 &ctx,
1096 )
1097 .await
1098 .expect_err("path traversal must fail closed");
1099 assert!(path_error.to_string().contains("cannot contain"));
1100 }
1101 }
1102
1102 lines RUST