返回 CodeWhale
mod.rs
根目录 / crates / tui / src / lsp / mod.rs
1 //! LSP integration: post-edit diagnostics injection (#136).
2 //!
3 //! After the agent performs a successful file edit (`edit_file`,
4 //! `apply_patch`, or `write_file`) the engine asks the [`LspManager`] for
5 //! diagnostics on that file. The manager spawns the appropriate LSP server
6 //! lazily on first use, sends `didOpen`/`didChange`, waits up to a bounded
7 //! timeout for `publishDiagnostics`, normalizes the result, and returns it
8 //! to the engine.
9 //!
10 //! Failure modes are non-blocking by design: a missing LSP binary, a
11 //! crashed server, or a timeout all degrade to "no diagnostics this turn"
12 //! rather than stalling the agent. We log a one-time warning per language
13 //! when the binary is missing.
14 //!
15 //! # Wiring
16 //!
17 //! ```text
18 //! Engine ── after successful edit ──▶ LspManager.diagnostics_for(path, seq)
19 //! │
20 //! ▼
21 //! per-language LspClient
22 //! │
23 //! ▼
24 //! LspTransport (stdio)
25 //! ```
26 //!
27 //! # Configuration
28 //!
29 //! The `[lsp]` table in `~/.deepseek/config.toml` controls behavior:
30 //! `enabled`, `poll_after_edit_ms`, `max_diagnostics_per_file`, `include_warnings`,
31 //! an optional `servers` override, and a `custom` table for registering LSP
32 //! servers for file extensions not covered by the built-in registry (e.g. Ruby,
33 //! PHP, C#). See [`LspConfig`] for defaults and `config.example.toml` for
34 //! documentation.
35
36 use std::collections::{HashMap, HashSet};
37 use std::path::{Path, PathBuf};
38 use std::sync::Arc;
39 use std::time::Duration;
40
41 use serde::Deserialize;
42 use tokio::sync::Mutex as AsyncMutex;
43 use tokio::time::timeout;
44
45 pub mod client;
46 pub mod diagnostics;
47 pub mod registry;
48
49 pub use client::{LspTransport, StdioLspTransport};
50 pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
51 pub use registry::Language;
52
53 /// User-defined LSP server for one file extension.
54 ///
55 /// Registered via `[lsp.custom.<ext>]` in the config. The extension key is the
56 /// file suffix (without the leading dot), e.g. `"php"`, `"rb"`, `"cs"`.
57 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
58 pub struct CustomLspDef {
59 /// LSP `languageId` value used in `textDocument/didOpen`.
60 pub language_id: String,
61 /// Executable to spawn.
62 pub command: String,
63 /// Arguments passed to the executable.
64 #[serde(default)]
65 pub args: Vec<String>,
66 }
67
68 /// `[lsp]` config schema. Mirrors the TOML keys documented in
69 /// `config.example.toml`. Unknown keys are ignored.
70 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
71 #[serde(default)]
72 pub struct LspConfig {
73 /// Master switch. When `false`, the manager skips every operation and
74 /// returns an empty diagnostics list.
75 pub enabled: bool,
76 /// Maximum time in milliseconds to wait for the LSP server to publish
77 /// diagnostics after a `didOpen`/`didChange`. Default 5000 ms.
78 pub poll_after_edit_ms: u64,
79 /// Maximum diagnostics to keep per file. Excess items are dropped after
80 /// sorting by severity. Default 20.
81 pub max_diagnostics_per_file: usize,
82 /// When `true`, warnings (severity 2) are kept in the output. When
83 /// `false` (default), only errors (severity 1) are surfaced.
84 pub include_warnings: bool,
85 /// Optional override for the `Language -> (cmd, args)` table. Keys use
86 /// [`Language::as_key`] (e.g. `"rust"`).
87 pub servers: HashMap<String, Vec<String>>,
88 /// User-defined LSP servers for file extensions not in the built-in
89 /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
90 #[serde(default)]
91 pub custom: HashMap<String, CustomLspDef>,
92 }
93
94 impl Default for LspConfig {
95 fn default() -> Self {
96 Self {
97 enabled: true,
98 poll_after_edit_ms: 5_000,
99 max_diagnostics_per_file: 20,
100 include_warnings: false,
101 servers: HashMap::new(),
102 custom: HashMap::new(),
103 }
104 }
105 }
106
107 impl LspConfig {
108 /// Resolve `(command, args)` for `lang`. User-supplied overrides take
109 /// precedence over the built-in registry.
110 pub(crate) fn resolve_command(&self, lang: Language) -> Option<(String, Vec<String>)> {
111 if let Some(parts) = self.servers.get(lang.as_key())
112 && let Some((first, rest)) = parts.split_first()
113 {
114 return Some((first.clone(), rest.to_vec()));
115 }
116 let (cmd, args) = registry::server_for(lang)?;
117 Some((
118 cmd.to_string(),
119 args.iter().map(|a| (*a).to_string()).collect(),
120 ))
121 }
122 }
123
124 type TransportSlot = Arc<AsyncMutex<Option<Arc<dyn LspTransport>>>>;
125
126 /// The LspManager holds a lazily populated map of `Language -> Transport`.
127 /// One transport is reused across files of the same language for the
128 /// session's lifetime.
129 pub struct LspManager {
130 config: LspConfig,
131 workspace: PathBuf,
132 /// One startup slot per language. Cold callers share a handshake without
133 /// holding the map lock or blocking unrelated language servers.
134 transports: AsyncMutex<HashMap<Language, TransportSlot>>,
135 /// Per-language "we already warned the user that the binary is missing"
136 /// guard so we do not spam the audit log on every edit.
137 missing_warned: AsyncMutex<HashSet<Language>>,
138 /// Test seam: when set, `diagnostics_for` uses these instead of spawning
139 /// real LSP processes. Keyed by language.
140 test_transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
141 /// Per-extension transports for user-defined custom language servers.
142 custom_transports: AsyncMutex<HashMap<String, TransportSlot>>,
143 /// Per-extension "we already warned" guard for custom servers.
144 custom_missing_warned: AsyncMutex<HashSet<String>>,
145 }
146
147 /// Per-file outcome for the model-facing `read_lints` operation. Unlike the
148 /// best-effort post-edit hook, this preserves the distinction between an
149 /// honest empty result, a server/read error, and a timeout.
150 #[derive(Debug)]
151 pub(crate) struct LintReadResult {
152 pub(crate) file: PathBuf,
153 pub(crate) status: LintReadStatus,
154 pub(crate) items: Vec<Diagnostic>,
155 pub(crate) freshness: DiagnosticFreshness,
156 /// Number of diagnostics after severity selection but before the
157 /// configured per-file cap was applied. Unknown when the request did not
158 /// complete successfully.
159 pub(crate) total_diagnostic_count: Option<usize>,
160 pub(crate) truncated: bool,
161 }
162
163 #[derive(Debug)]
164 pub(crate) enum LintReadStatus {
165 Success,
166 Error(String),
167 Timeout { wait_ms: u64 },
168 }
169
170 #[derive(Debug, Default, serde::Serialize)]
171 pub(crate) struct DiagnosticFreshness {
172 pub(crate) source_revision: Option<String>,
173 pub(crate) document_version: Option<i64>,
174 pub(crate) diagnostic_version: Option<i64>,
175 /// Only an exact publication version match proves the text was checked.
176 pub(crate) freshness: Option<&'static str>,
177 }
178
179 struct DiagnosticPollSuccess {
180 block: DiagnosticBlock,
181 freshness: DiagnosticFreshness,
182 total_diagnostic_count: usize,
183 truncated: bool,
184 }
185
186 enum DiagnosticPollOutcome {
187 Success(DiagnosticPollSuccess),
188 Error(String),
189 Timeout,
190 }
191
192 impl LspManager {
193 /// Build a new manager. Does not spawn any LSP servers — that is lazy.
194 #[must_use]
195 pub fn new(config: LspConfig, workspace: PathBuf) -> Self {
196 Self {
197 config,
198 workspace,
199 transports: AsyncMutex::new(HashMap::new()),
200 missing_warned: AsyncMutex::new(HashSet::new()),
201 test_transports: AsyncMutex::new(HashMap::new()),
202 custom_transports: AsyncMutex::new(HashMap::new()),
203 custom_missing_warned: AsyncMutex::new(HashSet::new()),
204 }
205 }
206
207 /// Read-only access to the resolved config. Used by the engine to skip
208 /// the post-edit hook entirely when `enabled = false`.
209 #[must_use]
210 pub fn config(&self) -> &LspConfig {
211 &self.config
212 }
213
214 async fn read_workspace_text(&self, file: &Path) -> std::io::Result<String> {
215 let root = self.workspace.clone();
216 let requested = file.to_path_buf();
217 tokio::task::spawn_blocking(move || {
218 use std::io::Read;
219 // The caller may already have resolved the configured workspace
220 // alias (macOS /var -> /private/var, or a workspace-root symlink).
221 // Resolve only that authorized root, never the requested file:
222 // internal links must still be rejected by the confined opener.
223 let canonical_root = root.canonicalize()?;
224 let relative = requested
225 .strip_prefix(&root)
226 .or_else(|_| requested.strip_prefix(&canonical_root))
227 .map_err(|_| {
228 std::io::Error::new(
229 std::io::ErrorKind::PermissionDenied,
230 "LSP file is outside the workspace",
231 )
232 })?;
233 // Match the existing workspace file serving ceiling. Decode only
234 // bounded UTF-8 bytes from the same no-follow workspace opener.
235 const MAX_DOCUMENT_BYTES: u64 = 16 * 1024 * 1024;
236 let file = crate::fleet::files::WorkspaceFile::open(&canonical_root, relative, false)?;
237 let mut bytes = Vec::new();
238 file.open_file()?
239 .take(MAX_DOCUMENT_BYTES + 1)
240 .read_to_end(&mut bytes)?;
241 if bytes.len() as u64 > MAX_DOCUMENT_BYTES {
242 return Err(std::io::Error::new(
243 std::io::ErrorKind::InvalidData,
244 "LSP file exceeds the document limit",
245 ));
246 }
247 String::from_utf8(bytes)
248 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
249 })
250 .await
251 .map_err(std::io::Error::other)?
252 }
253
254 /// Inject a fake transport for a language. Used by tests so we never
255 /// fork a real LSP server in CI.
256 #[cfg(test)]
257 pub async fn install_test_transport(&self, lang: Language, transport: Arc<dyn LspTransport>) {
258 self.test_transports.lock().await.insert(lang, transport);
259 }
260
261 /// Poll the LSP server for diagnostics on `file`. Returns the rendered
262 /// [`DiagnosticBlock`] (already truncated to the configured per-file
263 /// max) or `None` when the manager is disabled / has no server / the
264 /// poll times out.
265 ///
266 /// The `_edit_seq` argument is currently a no-op; it exists in the
267 /// signature so the engine can correlate diagnostics back to a specific
268 /// edit when we add request batching in v0.7.x.
269 pub async fn diagnostics_for(&self, file: &Path, _edit_seq: u64) -> Option<DiagnosticBlock> {
270 if !self.config.enabled {
271 return None;
272 }
273
274 let lang = registry::detect_language(file);
275 if lang == Language::Other {
276 // Custom extension fallback: check user-defined LSP servers
277 // for file extensions not covered by the built-in registry.
278 if let Some(custom) = self.config.custom_for_extension(file) {
279 return self.diagnostics_for_custom(file, custom).await;
280 }
281 return None;
282 }
283
284 let text = match self.read_workspace_text(file).await {
285 Ok(text) => text,
286 Err(err) => {
287 tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
288 return None;
289 }
290 };
291
292 let transport = match self.transport_for(lang).await {
293 Some(t) => t,
294 None => return None,
295 };
296
297 self.poll_diagnostics(file, &text, transport).await
298 }
299
300 /// Shared diagnostics polling for the best-effort post-edit hook. This
301 /// keeps the legacy configured severity filter and `None` failure shape.
302 async fn poll_diagnostics(
303 &self,
304 file: &Path,
305 text: &str,
306 transport: Arc<dyn LspTransport>,
307 ) -> Option<DiagnosticBlock> {
308 match self.poll_diagnostics_outcome(file, text, transport).await {
309 DiagnosticPollOutcome::Success(result) if !result.block.items.is_empty() => {
310 Some(result.block)
311 }
312 DiagnosticPollOutcome::Success(_)
313 | DiagnosticPollOutcome::Error(_)
314 | DiagnosticPollOutcome::Timeout => None,
315 }
316 }
317
318 /// Send didOpen/didChange, wait, filter, sort, and truncate while retaining
319 /// enough state for callers that must distinguish empty, error, and timeout.
320 async fn poll_diagnostics_outcome(
321 &self,
322 file: &Path,
323 text: &str,
324 transport: Arc<dyn LspTransport>,
325 ) -> DiagnosticPollOutcome {
326 let wait = Duration::from_millis(self.config.poll_after_edit_ms);
327 // The outer bound owns timeout reporting, including time waiting for
328 // another operation on the same transport. An explicit empty
329 // publishDiagnostics payload is a successful publication.
330 let inner_wait = wait.saturating_add(Duration::from_millis(100));
331 let raw = match timeout(wait, transport.diagnostics_for(file, text, inner_wait)).await {
332 Ok(Ok(items)) => items,
333 Ok(Err(err)) => {
334 tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed");
335 return if err.to_string().contains("timed out") {
336 DiagnosticPollOutcome::Timeout
337 } else {
338 DiagnosticPollOutcome::Error(err.to_string())
339 };
340 }
341 Err(_) => {
342 tracing::debug!(file = %file.display(), "lsp: diagnostics timed out");
343 return DiagnosticPollOutcome::Timeout;
344 }
345 };
346
347 let freshness = DiagnosticFreshness {
348 source_revision: Some(crate::hashing::sha256_hex(text.as_bytes())),
349 document_version: raw.document_version,
350 diagnostic_version: raw.diagnostic_version,
351 freshness: Some(raw.freshness()),
352 };
353 // Filter, sort, and truncate.
354 let include_warnings = self.config.include_warnings;
355 let mut items: Vec<Diagnostic> = raw
356 .items
357 .into_iter()
358 .filter(|d| match d.severity {
359 Severity::Error => true,
360 Severity::Warning => include_warnings,
361 _ => false,
362 })
363 .collect();
364 items.sort_by_key(|d| match d.severity {
365 Severity::Error => 0u8,
366 Severity::Warning => 1u8,
367 Severity::Information => 2u8,
368 Severity::Hint => 3u8,
369 });
370 let total_diagnostic_count = items.len();
371 let truncated = total_diagnostic_count > self.config.max_diagnostics_per_file;
372 let mut block = DiagnosticBlock {
373 file: relative_to_workspace(&self.workspace, file),
374 items,
375 };
376 block.truncate(self.config.max_diagnostics_per_file);
377 DiagnosticPollOutcome::Success(DiagnosticPollSuccess {
378 block,
379 freshness,
380 total_diagnostic_count,
381 truncated,
382 })
383 }
384
385 /// Diagnostics path for a user-defined custom language server.
386 async fn diagnostics_for_custom(
387 &self,
388 file: &Path,
389 custom: &CustomLspDef,
390 ) -> Option<DiagnosticBlock> {
391 let ext = file.extension()?.to_str()?.to_ascii_lowercase();
392 let text = match self.read_workspace_text(file).await {
393 Ok(t) => t,
394 Err(err) => {
395 tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
396 return None;
397 }
398 };
399 let transport = match self.transport_for_custom(&ext, custom).await {
400 Some(t) => t,
401 None => return None,
402 };
403 self.poll_diagnostics(file, &text, transport).await
404 }
405
406 /// Lazy-spawn a custom LSP server for an extension.
407 async fn transport_for_custom(
408 &self,
409 ext: &str,
410 def: &CustomLspDef,
411 ) -> Option<Arc<dyn LspTransport>> {
412 let slot = self
413 .custom_transports
414 .lock()
415 .await
416 .entry(ext.to_owned())
417 .or_default()
418 .clone();
419 match self
420 .cached_transport(&slot, &def.command, &def.args, &def.language_id)
421 .await
422 {
423 Ok(transport) => Some(transport),
424 Err(err) => {
425 let key = ext.to_string();
426 let mut warned = self.custom_missing_warned.lock().await;
427 if warned.insert(key) {
428 tracing::warn!(
429 extension = %ext,
430 command = %def.command,
431 error = %err,
432 "lsp: custom server unavailable; diagnostics disabled for this extension"
433 );
434 }
435 None
436 }
437 }
438 }
439
440 /// Resolve (and lazily spawn) the transport for `lang`. Tests can
441 /// short-circuit this via `install_test_transport` (cfg-test only).
442 async fn transport_for(&self, lang: Language) -> Option<Arc<dyn LspTransport>> {
443 if let Some(t) = self.test_transports.lock().await.get(&lang) {
444 return Some(t.clone());
445 }
446
447 let (cmd, args) = self.config.resolve_command(lang)?;
448 let slot = self
449 .transports
450 .lock()
451 .await
452 .entry(lang)
453 .or_default()
454 .clone();
455 match self
456 .cached_transport(&slot, &cmd, &args, lang.language_id())
457 .await
458 {
459 Ok(transport) => Some(transport),
460 Err(err) => {
461 self.warn_missing_once(lang, &cmd, &err).await;
462 None
463 }
464 }
465 }
466
467 async fn cached_transport(
468 &self,
469 slot: &TransportSlot,
470 command: &str,
471 args: &[String],
472 language_id: &str,
473 ) -> anyhow::Result<Arc<dyn LspTransport>> {
474 let mut cached = slot.lock().await;
475 if let Some(transport) = cached.as_ref().filter(|transport| transport.is_alive()) {
476 return Ok(transport.clone());
477 }
478 // Only a later caller retries a dead process; never replay a failed
479 // operation or spawn a background restart loop.
480 if let Some(dead) = cached.take() {
481 dead.shutdown().await;
482 }
483 let transport: Arc<dyn LspTransport> = Arc::new(
484 StdioLspTransport::spawn(command, args, language_id, self.workspace.clone()).await?,
485 );
486 *cached = Some(transport.clone());
487 Ok(transport)
488 }
489
490 async fn warn_missing_once(&self, lang: Language, cmd: &str, err: &anyhow::Error) {
491 let mut warned = self.missing_warned.lock().await;
492 if warned.insert(lang) {
493 tracing::warn!(
494 language = %lang.as_key(),
495 command = %cmd,
496 error = %err,
497 "lsp: server unavailable; diagnostics disabled for this language"
498 );
499 }
500 }
501
502 /// Resolve a transport for `file` without creating a second server
503 /// lifecycle. Reuses the same lazy map as post-edit diagnostics.
504 async fn transport_for_path(&self, file: &Path) -> Option<Arc<dyn LspTransport>> {
505 if !self.config.enabled {
506 return None;
507 }
508 let lang = registry::detect_language(file);
509 if lang != Language::Other {
510 return self.transport_for(lang).await;
511 }
512 if let Some(custom) = self.config.custom_for_extension(file) {
513 let ext = file.extension()?.to_str()?.to_ascii_lowercase();
514 return self.transport_for_custom(&ext, custom).await;
515 }
516 None
517 }
518
519 /// Model-facing intelligence query. Shares the existing transport pool.
520 /// `operation` is one of: `diagnostics`, `symbols`, `definition`, `references`.
521 pub async fn intelligence(
522 &self,
523 operation: &str,
524 file: &Path,
525 line: Option<u32>,
526 character: Option<u32>,
527 query: Option<&str>,
528 ) -> Result<serde_json::Value, String> {
529 self.intelligence_at_revision(operation, file, line, character, query, None)
530 .await
531 }
532
533 /// Optional source revision for native navigation; raw tool results remain
534 /// available. A target revision proves bytes read here, not server analysis.
535 pub async fn intelligence_at_revision(
536 &self,
537 operation: &str,
538 file: &Path,
539 line: Option<u32>,
540 character: Option<u32>,
541 query: Option<&str>,
542 expected_revision: Option<&str>,
543 ) -> Result<serde_json::Value, String> {
544 if !self.config.enabled {
545 return Err("LSP is disabled ([lsp] enabled = false)".to_string());
546 }
547 let wait = Duration::from_millis(self.config.poll_after_edit_ms);
548 match operation {
549 "diagnostics" => {
550 let result = self
551 .diagnostics_for_paths(&[file.to_path_buf()])
552 .await?
553 .into_iter()
554 .next()
555 .ok_or("LSP diagnostics returned no outcome")?;
556 match result.status {
557 LintReadStatus::Error(error) => Err(error),
558 LintReadStatus::Timeout { wait_ms } => {
559 Err(format!("LSP diagnostics timed out after {wait_ms} ms"))
560 }
561 LintReadStatus::Success => Ok(serde_json::json!({
562 "file": result.file.display().to_string(),
563 "items": result.items.iter().map(|d| serde_json::json!({
564 "line": d.line, "column": d.column,
565 "severity": format!("{:?}", d.severity).to_ascii_lowercase(),
566 "message": d.message,
567 })).collect::<Vec<_>>(),
568 "source_revision": result.freshness.source_revision,
569 "document_version": result.freshness.document_version,
570 "diagnostic_version": result.freshness.diagnostic_version,
571 "freshness": result.freshness.freshness,
572 "total_diagnostic_count": result.total_diagnostic_count,
573 "truncated": result.truncated,
574 })),
575 }
576 }
577 "symbols" | "definition" | "references" => {
578 let text = self
579 .read_workspace_text(file)
580 .await
581 .map_err(|err| format!("read {}: {err}", file.display()))?;
582 let source_revision = crate::hashing::sha256_hex(text.as_bytes());
583 if expected_revision.is_some_and(|expected| expected != source_revision) {
584 return Err("stale_document".into());
585 }
586 let root = tokio::fs::canonicalize(&self.workspace)
587 .await
588 .map_err(|_| "workspace unavailable")?;
589 let source_path = file
590 .strip_prefix(&self.workspace)
591 .or_else(|_| file.strip_prefix(&root))
592 .ok()
593 .and_then(semantic_relative_path)
594 .ok_or("source path is not workspace-relative UTF-8")?;
595 let uri = client::uri_from_path(file);
596 let (method, params) = if operation == "symbols" {
597 if let Some(q) = query.filter(|s| !s.trim().is_empty()) {
598 if q.len() > 1024 {
599 return Err("symbol query is too long".into());
600 }
601 ("workspace/symbol", serde_json::json!({"query":q}))
602 } else {
603 (
604 "textDocument/documentSymbol",
605 serde_json::json!({"textDocument":{"uri":uri}}),
606 )
607 }
608 } else {
609 let position = SemanticPosition {
610 line: line
611 .and_then(|value| value.checked_sub(1))
612 .ok_or("line must be 1-based")?,
613 character: character
614 .unwrap_or(1)
615 .checked_sub(1)
616 .ok_or("character must be 1-based")?,
617 };
618 if !position.valid_in(&text) {
619 return Err(
620 "position is outside the document or splits a UTF-16 character".into(),
621 );
622 }
623 let method = if operation == "definition" {
624 "textDocument/definition"
625 } else {
626 "textDocument/references"
627 };
628 let mut params =
629 serde_json::json!({"textDocument":{"uri":uri}, "position":position});
630 if operation == "references" {
631 params["context"] = serde_json::json!({"includeDeclaration":true});
632 }
633 (method, params)
634 };
635 let transport = self
636 .transport_for_path(file)
637 .await
638 .ok_or_else(|| format!("no LSP server for {}", file.display()))?;
639 let reply = transport
640 .request_for_document(file, &text, method, params, wait)
641 .await
642 .map_err(|err| err.to_string())?;
643 let (locations, truncated, omitted) =
644 self.semantic_locations(&reply.result, file, &text).await;
645 // Re-read after the request and normalization; a changed or
646 // replaced source cannot publish a successful navigation result.
647 let current = self
648 .read_workspace_text(file)
649 .await
650 .map_err(|_| "stale_document")?;
651 if crate::hashing::sha256_hex(current.as_bytes()) != source_revision {
652 return Err("stale_document".into());
653 }
654 Ok(serde_json::json!({
655 "operation": operation,
656 "file": source_path,
657 "semantic_contract_version": 1,
658 "position_encoding": "utf-16",
659 "source_revision": source_revision,
660 "document_version": reply.document_version,
661 "freshness": if reply.document_version.is_some() { "verified" } else { "unverified" },
662 "locations": locations, "truncated": truncated, "omitted": omitted,
663 "result": truncate_intelligence_result(reply.result),
664 }))
665 }
666 other => Err(format!(
667 "unknown LSP operation '{other}'; use diagnostics, symbols, definition, or references"
668 )),
669 }
670 }
671
672 async fn semantic_locations(
673 &self,
674 raw: &serde_json::Value,
675 source: &Path,
676 source_text: &str,
677 ) -> (Vec<SemanticLocation>, bool, usize) {
678 const MAX_LOCATIONS: usize = 40;
679 const MAX_NODES: usize = 256;
680 const MAX_TARGET_BYTES: usize = 16 * 1024 * 1024;
681 let root = match tokio::fs::canonicalize(&self.workspace).await {
682 Ok(root) => root,
683 Err(_) => return (vec![], false, 1),
684 };
685 let mut pending = vec![(raw, 0usize)];
686 let mut locations = Vec::new();
687 let mut visited = 0;
688 let mut target_bytes: usize = 0;
689 let mut truncated = false;
690 let mut omitted = 0;
691 while let Some((value, depth)) = pending.pop() {
692 visited += 1;
693 if visited > MAX_NODES || locations.len() >= MAX_LOCATIONS {
694 truncated = true;
695 break;
696 }
697 if value.is_null() {
698 continue;
699 }
700 if let Some(items) = value.as_array() {
701 let remaining = MAX_NODES.saturating_sub(visited + pending.len());
702 truncated |= items.len() > remaining;
703 pending.extend(items.iter().take(remaining).rev().map(|item| (item, depth)));
704 continue;
705 }
706 if let Some(children) = value.get("children").and_then(serde_json::Value::as_array) {
707 if depth >= 16 {
708 truncated |= !children.is_empty();
709 } else {
710 let remaining = MAX_NODES.saturating_sub(visited + pending.len());
711 truncated |= children.len() > remaining;
712 pending.extend(
713 children
714 .iter()
715 .take(remaining)
716 .rev()
717 .map(|item| (item, depth + 1)),
718 );
719 }
720 }
721 let location = value.get("location").unwrap_or(value);
722 let uri = location.get("uri").or_else(|| value.get("targetUri"));
723 let target = match uri {
724 Some(uri) => uri.as_str().and_then(client::path_from_uri),
725 None if value
726 .get("name")
727 .and_then(serde_json::Value::as_str)
728 .is_some()
729 && value.get("selectionRange").is_some() =>
730 {
731 Some(source.to_path_buf())
732 }
733 _ => None,
734 };
735 let range = value
736 .get("targetSelectionRange")
737 .or_else(|| value.get("selectionRange"))
738 .or_else(|| location.get("range"))
739 .and_then(|range| serde_json::from_value::<SemanticRange>(range.clone()).ok());
740 let (Some(target), Some(range)) = (target, range) else {
741 omitted += 1;
742 continue;
743 };
744 let relative = match target.strip_prefix(&self.workspace).or_else(|_| target.strip_prefix(&root)) {
745 Ok(relative) if !relative.as_os_str().is_empty()
746 && relative.components().all(|component| matches!(component, std::path::Component::Normal(part) if !crate::snapshot::is_git_metadata_name(part))) => relative,
747 _ => { omitted += 1; continue; }
748 };
749 let Some(path) = semantic_relative_path(relative) else {
750 omitted += 1;
751 continue;
752 };
753 let text = if target == source {
754 source_text.to_owned()
755 } else {
756 match self.read_workspace_text(&target).await {
757 Ok(text) => text,
758 Err(_) => {
759 omitted += 1;
760 continue;
761 }
762 }
763 };
764 target_bytes = target_bytes.saturating_add(text.len());
765 if target_bytes > MAX_TARGET_BYTES {
766 truncated = true;
767 break;
768 }
769 if range.start > range.end || !range.start.valid_in(&text) || !range.end.valid_in(&text)
770 {
771 omitted += 1;
772 continue;
773 }
774 locations.push(SemanticLocation {
775 path,
776 range,
777 target_revision: crate::hashing::sha256_hex(text.as_bytes()),
778 target_freshness: "unverified",
779 name: value
780 .get("name")
781 .and_then(serde_json::Value::as_str)
782 .map(|name| name.chars().filter(|c| !c.is_control()).take(256).collect()),
783 kind: value
784 .get("kind")
785 .and_then(serde_json::Value::as_u64)
786 .and_then(|kind| u32::try_from(kind).ok())
787 .filter(|kind| (1..=26).contains(kind)),
788 });
789 }
790 (locations, truncated, omitted)
791 }
792
793 /// Read diagnostics for several existing files through the shared LSP
794 /// transport pool. Every attempted file gets an explicit outcome, and the
795 /// same configured severity policy as post-edit diagnostics is applied:
796 /// errors are always retained and warnings depend on `include_warnings`.
797 pub(crate) async fn diagnostics_for_paths(
798 &self,
799 files: &[PathBuf],
800 ) -> Result<Vec<LintReadResult>, String> {
801 if !self.config.enabled {
802 return Err("LSP is disabled ([lsp] enabled = false)".to_string());
803 }
804
805 let mut results = Vec::with_capacity(files.len());
806 for file in files {
807 let relative_file = relative_to_workspace(&self.workspace, file);
808 let text = match self.read_workspace_text(file).await {
809 Ok(text) => text,
810 Err(err) => {
811 results.push(LintReadResult {
812 file: relative_file,
813 status: LintReadStatus::Error(format!("failed to read file: {err}")),
814 items: Vec::new(),
815 freshness: DiagnosticFreshness::default(),
816 total_diagnostic_count: None,
817 truncated: false,
818 });
819 continue;
820 }
821 };
822 let Some(transport) = self.transport_for_path(file).await else {
823 results.push(LintReadResult {
824 file: relative_file,
825 status: LintReadStatus::Error(
826 "no LSP server is available for this file".to_string(),
827 ),
828 items: Vec::new(),
829 freshness: DiagnosticFreshness::default(),
830 total_diagnostic_count: None,
831 truncated: false,
832 });
833 continue;
834 };
835 let outcome = self.poll_diagnostics_outcome(file, &text, transport).await;
836 results.push(match outcome {
837 DiagnosticPollOutcome::Success(outcome) => LintReadResult {
838 file: outcome.block.file,
839 status: LintReadStatus::Success,
840 items: outcome.block.items,
841 freshness: outcome.freshness,
842 total_diagnostic_count: Some(outcome.total_diagnostic_count),
843 truncated: outcome.truncated,
844 },
845 DiagnosticPollOutcome::Error(error) => LintReadResult {
846 file: relative_file,
847 status: LintReadStatus::Error(format!(
848 "LSP diagnostics request failed: {error}"
849 )),
850 items: Vec::new(),
851 freshness: DiagnosticFreshness::default(),
852 total_diagnostic_count: None,
853 truncated: false,
854 },
855 DiagnosticPollOutcome::Timeout => LintReadResult {
856 file: relative_file,
857 status: LintReadStatus::Timeout {
858 wait_ms: self.config.poll_after_edit_ms,
859 },
860 items: Vec::new(),
861 freshness: DiagnosticFreshness::default(),
862 total_diagnostic_count: None,
863 truncated: false,
864 },
865 });
866 }
867 Ok(results)
868 }
869
870 /// Best-effort shutdown of every spawned transport. Called when the
871 /// session ends.
872 pub async fn shutdown_all(&self) {
873 let transports: Vec<TransportSlot> =
874 self.transports.lock().await.values().cloned().collect();
875 let custom: Vec<TransportSlot> = self
876 .custom_transports
877 .lock()
878 .await
879 .values()
880 .cloned()
881 .collect();
882 for slot in transports.into_iter().chain(custom) {
883 if let Some(transport) = slot.lock().await.take() {
884 transport.shutdown().await;
885 }
886 }
887 }
888 }
889
890 impl LspConfig {
891 /// Look up a [`CustomLspDef`] for `file` when the built-in registry
892 /// would return `Language::Other`. Returns `None` when the extension is
893 /// unknown or no custom server is registered for it.
894 fn custom_for_extension(&self, file: &Path) -> Option<&CustomLspDef> {
895 let ext = file.extension()?.to_str()?;
896 self.custom.get(&ext.to_ascii_lowercase())
897 }
898 }
899
900 fn semantic_relative_path(path: &Path) -> Option<String> {
901 let parts = path
902 .components()
903 .map(|component| match component {
904 std::path::Component::Normal(part) if !crate::snapshot::is_git_metadata_name(part) => {
905 part.to_str().filter(|part| !part.contains('\\'))
906 }
907 _ => None,
908 })
909 .collect::<Option<Vec<_>>>()?;
910 let path = parts.join("/");
911 (!path.is_empty() && path.len() <= 4096).then_some(path)
912 }
913
914 /// LSP positions are zero-based UTF-16 code units, never UTF-8 byte offsets.
915 #[derive(
916 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize,
917 )]
918 struct SemanticPosition {
919 line: u32,
920 character: u32,
921 }
922 impl SemanticPosition {
923 fn valid_in(self, text: &str) -> bool {
924 let Some(line) = text.split('\n').nth(self.line as usize) else {
925 return false;
926 };
927 let line = line.strip_suffix('\r').unwrap_or(line);
928 let mut column = 0;
929 for ch in line.chars() {
930 if column == self.character {
931 return true;
932 }
933 column += ch.len_utf16() as u32;
934 if column > self.character {
935 return false;
936 }
937 }
938 column == self.character
939 }
940 }
941
942 #[derive(Debug, serde::Deserialize, serde::Serialize)]
943 struct SemanticRange {
944 start: SemanticPosition,
945 end: SemanticPosition,
946 }
947
948 #[derive(Debug, serde::Serialize)]
949 struct SemanticLocation {
950 path: String,
951 range: SemanticRange,
952 target_revision: String,
953 target_freshness: &'static str,
954 #[serde(skip_serializing_if = "Option::is_none")]
955 name: Option<String>,
956 #[serde(skip_serializing_if = "Option::is_none")]
957 kind: Option<u32>,
958 }
959
960 /// Cap intelligence payloads so a chatty language server cannot flood the
961 /// model context. Arrays keep the first `MAX` entries and set `truncated`.
962 fn truncate_intelligence_result(value: serde_json::Value) -> serde_json::Value {
963 const MAX_ITEMS: usize = 40;
964 const MAX_CHARS: usize = 12_000;
965 let bounded = match value {
966 serde_json::Value::Array(mut items) if items.len() > MAX_ITEMS => {
967 let total = items.len();
968 items.truncate(MAX_ITEMS);
969 serde_json::json!({"items":items, "truncated":true, "total":total})
970 }
971 other => other,
972 };
973 let rendered = bounded.to_string();
974 if rendered.len() > MAX_CHARS {
975 let mut boundary = MAX_CHARS;
976 while !rendered.is_char_boundary(boundary) {
977 boundary -= 1;
978 }
979 serde_json::json!({
980 "truncated": true,
981 "preview": &rendered[..boundary],
982 "total_chars": rendered.len(),
983 })
984 } else {
985 bounded
986 }
987 }
988
989 /// Render `path` relative to the workspace when possible. Falls back to
990 /// `path.file_name()` (per the issue's hard rule about not using
991 /// `display().to_string()` on the bare path) when relativization fails.
992 fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
993 if let Ok(rel) = path.strip_prefix(workspace) {
994 return rel.to_path_buf();
995 }
996 PathBuf::from(
997 path.file_name()
998 .map(|n| n.to_string_lossy().into_owned())
999 .unwrap_or_else(|| String::from("unknown")),
1000 )
1001 }
1002
1003 /// Used for tests / no-op runs. Builds an empty manager that always returns
1004 /// `None`. Needed because the engine constructs an `LspManager` even when
1005 /// the user has disabled LSP, so the field is always present.
1006 impl LspManager {
1007 #[must_use]
1008 pub fn disabled() -> Self {
1009 Self::new(
1010 LspConfig {
1011 enabled: false,
1012 ..LspConfig::default()
1013 },
1014 PathBuf::new(),
1015 )
1016 }
1017 }
1018
1019 #[cfg(test)]
1020 pub(crate) mod tests {
1021 use super::*;
1022 use async_trait::async_trait;
1023 use std::sync::atomic::{AtomicUsize, Ordering};
1024
1025 /// Fake transport: returns a fixed list of diagnostics. Used by
1026 /// integration tests so we never spawn a real LSP server in CI.
1027 pub(crate) struct FakeTransport {
1028 items: Vec<Diagnostic>,
1029 calls: AtomicUsize,
1030 }
1031
1032 impl FakeTransport {
1033 pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
1034 Self {
1035 items,
1036 calls: AtomicUsize::new(0),
1037 }
1038 }
1039
1040 pub(crate) fn call_count(&self) -> usize {
1041 self.calls.load(Ordering::Relaxed)
1042 }
1043 }
1044
1045 #[async_trait]
1046 impl LspTransport for FakeTransport {
1047 async fn diagnostics_for(
1048 &self,
1049 _path: &Path,
1050 _text: &str,
1051 _wait: Duration,
1052 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
1053 self.calls.fetch_add(1, Ordering::Relaxed);
1054 Ok(self.items.clone().into())
1055 }
1056
1057 async fn shutdown(&self) {}
1058 }
1059
1060 #[cfg(unix)]
1061 async fn cache_fixture(custom: bool) {
1062 let root = tempfile::tempdir().unwrap();
1063 let pids = root.path().join("pids");
1064 let args = vec![
1065 "-u".into(),
1066 "-c".into(),
1067 client::tests::STDIO_FIXTURE.into(),
1068 "cache".into(),
1069 pids.to_string_lossy().into_owned(),
1070 ];
1071 let mut config = LspConfig::default();
1072 if custom {
1073 config.custom.insert(
1074 "cachetest".into(),
1075 CustomLspDef {
1076 command: "python3".into(),
1077 args,
1078 language_id: "cachetest".into(),
1079 },
1080 );
1081 } else {
1082 let mut command = vec!["python3".into()];
1083 command.extend(args);
1084 config.servers.insert("python".into(), command);
1085 }
1086 let manager = LspManager::new(config, root.path().to_owned());
1087 let path = root
1088 .path()
1089 .join(if custom { "file.cachetest" } else { "file.py" });
1090 let (first, second, third) = tokio::join!(
1091 manager.transport_for_path(&path),
1092 manager.transport_for_path(&path),
1093 manager.transport_for_path(&path)
1094 );
1095 let first = first.unwrap();
1096 assert!(Arc::ptr_eq(&first, &second.unwrap()));
1097 assert!(Arc::ptr_eq(&first, &third.unwrap()));
1098 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 1);
1099 assert!(
1100 first
1101 .request(
1102 "fixture/exit",
1103 serde_json::json!({}),
1104 Duration::from_secs(2)
1105 )
1106 .await
1107 .is_err()
1108 );
1109 timeout(Duration::from_secs(2), async {
1110 while first.is_alive() {
1111 tokio::task::yield_now().await;
1112 }
1113 })
1114 .await
1115 .unwrap();
1116 let recovered = manager.transport_for_path(&path).await.unwrap();
1117 assert!(!Arc::ptr_eq(&first, &recovered));
1118 assert!(
1119 recovered
1120 .request(
1121 "fixture/ready",
1122 serde_json::json!({}),
1123 Duration::from_secs(2)
1124 )
1125 .await
1126 .is_ok()
1127 );
1128 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 2);
1129 manager.shutdown_all().await;
1130 assert!(manager.transport_for_path(&path).await.is_some());
1131 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 3);
1132 manager.shutdown_all().await;
1133 }
1134
1135 #[cfg(unix)]
1136 #[tokio::test]
1137 async fn built_in_transport_cache_shares_cold_start_and_recovers_after_exit() {
1138 cache_fixture(false).await;
1139 }
1140
1141 #[cfg(unix)]
1142 #[tokio::test]
1143 async fn custom_transport_cache_shares_cold_start_and_recovers_after_exit() {
1144 cache_fixture(true).await;
1145 }
1146
1147 #[cfg(unix)]
1148 #[tokio::test]
1149 async fn failed_transport_initialization_does_not_poison_the_cache() {
1150 let root = tempfile::tempdir().unwrap();
1151 let script = root.path().join("server.py");
1152 let mut config = LspConfig::default();
1153 config.servers.insert(
1154 "python".into(),
1155 vec![
1156 "python3".into(),
1157 "-u".into(),
1158 script.to_string_lossy().into_owned(),
1159 "cache".into(),
1160 root.path().join("pids").to_string_lossy().into_owned(),
1161 ],
1162 );
1163 let manager = LspManager::new(config, root.path().to_owned());
1164 let path = root.path().join("file.py");
1165 assert!(manager.transport_for_path(&path).await.is_none());
1166 std::fs::write(&script, client::tests::STDIO_FIXTURE).unwrap();
1167 assert!(manager.transport_for_path(&path).await.is_some());
1168 manager.shutdown_all().await;
1169 }
1170
1171 struct SemanticFixture {
1172 result: serde_json::Value,
1173 mutate: Option<PathBuf>,
1174 }
1175 #[async_trait::async_trait]
1176 impl LspTransport for SemanticFixture {
1177 async fn diagnostics_for(
1178 &self,
1179 _: &Path,
1180 _: &str,
1181 _: Duration,
1182 ) -> anyhow::Result<client::DiagnosticPublication> {
1183 Ok(vec![].into())
1184 }
1185 async fn request(
1186 &self,
1187 _: &str,
1188 _: serde_json::Value,
1189 _: Duration,
1190 ) -> anyhow::Result<serde_json::Value> {
1191 if let Some(path) = &self.mutate {
1192 tokio::fs::write(path, "changed").await?;
1193 }
1194 Ok(self.result.clone())
1195 }
1196 async fn shutdown(&self) {}
1197 }
1198
1199 #[tokio::test]
1200 async fn semantic_source_revision_rejects_stale_before_and_after_request() {
1201 let root = tempfile::tempdir().unwrap();
1202 let file = root.path().join("main.rs");
1203 tokio::fs::write(&file, "fn main() {}\n").await.unwrap();
1204 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1205 assert_eq!(
1206 manager
1207 .intelligence_at_revision(
1208 "definition",
1209 &file,
1210 Some(1),
1211 Some(1),
1212 None,
1213 Some("wrong")
1214 )
1215 .await
1216 .unwrap_err(),
1217 "stale_document"
1218 );
1219 manager
1220 .install_test_transport(
1221 Language::Rust,
1222 Arc::new(SemanticFixture {
1223 result: serde_json::json!([]),
1224 mutate: Some(file.clone()),
1225 }),
1226 )
1227 .await;
1228 let revision = crate::hashing::sha256_hex(b"fn main() {}\n");
1229 assert_eq!(
1230 manager
1231 .intelligence_at_revision(
1232 "definition",
1233 &file,
1234 Some(1),
1235 Some(1),
1236 None,
1237 Some(&revision)
1238 )
1239 .await
1240 .unwrap_err(),
1241 "stale_document"
1242 );
1243 }
1244
1245 #[tokio::test]
1246 async fn semantic_locations_preserve_raw_and_distinguish_target_readback_from_analysis() {
1247 let root = tempfile::tempdir().unwrap();
1248 let file = root.path().join("main.rs");
1249 let target = root.path().join("a b.rs");
1250 tokio::fs::write(&file, "fn main() {}\n").await.unwrap();
1251 tokio::fs::write(&target, "🐋foo\n").await.unwrap();
1252 let raw = serde_json::json!([{"targetUri":client::uri_from_path(&target),"targetSelectionRange":{"start":{"line":0,"character":2},"end":{"line":0,"character":5}}}]);
1253 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1254 manager
1255 .install_test_transport(
1256 Language::Rust,
1257 Arc::new(SemanticFixture {
1258 result: raw.clone(),
1259 mutate: None,
1260 }),
1261 )
1262 .await;
1263 let result = manager
1264 .intelligence("definition", &file, Some(1), Some(1), None)
1265 .await
1266 .unwrap();
1267 assert_eq!(result["result"], raw);
1268 assert_eq!(result["semantic_contract_version"], 1);
1269 assert_eq!(result["position_encoding"], "utf-16");
1270 assert_eq!(result["freshness"], "unverified");
1271 assert_eq!(result["locations"][0]["path"], "a b.rs");
1272 assert_eq!(
1273 result["locations"][0]["target_revision"],
1274 crate::hashing::sha256_hex("🐋foo\n".as_bytes())
1275 );
1276 assert_eq!(result["locations"][0]["target_freshness"], "unverified");
1277 assert_eq!(result["omitted"], 0);
1278 }
1279
1280 #[tokio::test]
1281 async fn semantic_locations_reject_unsafe_uri_range_and_symlink_and_bound_symbols() {
1282 let root = tempfile::tempdir().unwrap();
1283 let file = root.path().join("main.rs");
1284 tokio::fs::write(&file, "🐋foo\n").await.unwrap();
1285 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1286 let good = serde_json::json!({"name":"foo","kind":12,"selectionRange":{"start":{"line":0,"character":2},"end":{"line":0,"character":5}}});
1287 let mut split = good.clone();
1288 split["selectionRange"]["start"]["character"] = serde_json::json!(1);
1289 let mut huge = good.clone();
1290 huge["selectionRange"]["end"]["character"] = serde_json::json!(u64::MAX);
1291 let mut unsafe_uri = good.clone();
1292 unsafe_uri["uri"] = serde_json::json!("file://remote/etc/passwd");
1293 let mut outside = good.clone();
1294 outside["uri"] = serde_json::json!("file:///etc/passwd");
1295 let mut reverse = good.clone();
1296 reverse["selectionRange"]["end"]["character"] = serde_json::json!(0);
1297 let cases = vec![good.clone(), split, huge, unsafe_uri, outside, reverse];
1298 #[cfg(unix)]
1299 let cases = {
1300 let mut cases = cases;
1301 let link = root.path().join("link.rs");
1302 std::os::unix::fs::symlink(&file, &link).unwrap();
1303 let mut linked = good.clone();
1304 linked["uri"] = serde_json::json!(client::uri_from_path(&link));
1305 cases.push(linked);
1306 cases
1307 };
1308 let expected_omitted = cases.len() - 1;
1309 let (locations, truncated, omitted) = manager
1310 .semantic_locations(&serde_json::json!(cases), &file, "🐋foo\n")
1311 .await;
1312 assert_eq!(locations.len(), 1);
1313 assert!(!truncated);
1314 assert_eq!(omitted, expected_omitted);
1315 let (locations, truncated, _) = manager
1316 .semantic_locations(&serde_json::json!(vec![good; 100]), &file, "🐋foo\n")
1317 .await;
1318 assert_eq!(locations.len(), 40);
1319 assert!(truncated);
1320 assert!(
1321 !SemanticPosition {
1322 line: 0,
1323 character: 1
1324 }
1325 .valid_in("🐋")
1326 );
1327 assert!(
1328 SemanticPosition {
1329 line: 0,
1330 character: 2
1331 }
1332 .valid_in("🐋")
1333 );
1334 }
1335
1336 #[test]
1337 fn semantic_raw_result_bound_is_unicode_safe_for_arrays_too() {
1338 let result = truncate_intelligence_result(serde_json::json!(["🐋".repeat(20_000)]));
1339 assert_eq!(result["truncated"], true);
1340 assert!(result["preview"].as_str().unwrap().len() <= 12_000);
1341 }
1342
1343 #[tokio::test]
1344 async fn returns_none_when_disabled() {
1345 let mgr = LspManager::new(
1346 LspConfig {
1347 enabled: false,
1348 ..LspConfig::default()
1349 },
1350 PathBuf::from("/tmp"),
1351 );
1352 let dir = tempfile::tempdir().unwrap();
1353 let path = dir.path().join("foo.rs");
1354 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1355 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1356 }
1357
1358 #[tokio::test]
1359 async fn returns_none_for_unknown_language() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1362 let path = dir.path().join("notes.txt");
1363 tokio::fs::write(&path, b"hi").await.unwrap();
1364 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1365 }
1366
1367 #[tokio::test]
1368 async fn forwards_errors_through_fake_transport() {
1369 let dir = tempfile::tempdir().unwrap();
1370 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1371 let path = dir.path().join("foo.rs");
1372 tokio::fs::write(&path, b"let x: i32 = \"oops\";")
1373 .await
1374 .unwrap();
1375
1376 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1377 line: 1,
1378 column: 14,
1379 severity: Severity::Error,
1380 message: "expected i32, found &str".to_string(),
1381 }]));
1382 mgr.install_test_transport(Language::Rust, fake.clone())
1383 .await;
1384
1385 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1386 let rendered = block.render();
1387 assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
1388 assert!(rendered.contains("foo.rs"));
1389 assert_eq!(fake.call_count(), 1);
1390 }
1391
1392 #[tokio::test]
1393 async fn drops_warnings_by_default() {
1394 let dir = tempfile::tempdir().unwrap();
1395 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1396 let path = dir.path().join("foo.rs");
1397 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1398
1399 let fake = Arc::new(FakeTransport::new(vec![
1400 Diagnostic {
1401 line: 1,
1402 column: 1,
1403 severity: Severity::Warning,
1404 message: "unused import".to_string(),
1405 },
1406 Diagnostic {
1407 line: 2,
1408 column: 1,
1409 severity: Severity::Error,
1410 message: "type error".to_string(),
1411 },
1412 ]));
1413 mgr.install_test_transport(Language::Rust, fake).await;
1414
1415 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1416 assert_eq!(block.items.len(), 1);
1417 assert_eq!(block.items[0].severity, Severity::Error);
1418 }
1419
1420 #[tokio::test]
1421 async fn keeps_warnings_when_opted_in() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let mgr = LspManager::new(
1424 LspConfig {
1425 include_warnings: true,
1426 ..LspConfig::default()
1427 },
1428 dir.path().to_path_buf(),
1429 );
1430 let path = dir.path().join("foo.rs");
1431 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1432
1433 let fake = Arc::new(FakeTransport::new(vec![
1434 Diagnostic {
1435 line: 1,
1436 column: 1,
1437 severity: Severity::Warning,
1438 message: "unused".to_string(),
1439 },
1440 Diagnostic {
1441 line: 2,
1442 column: 1,
1443 severity: Severity::Error,
1444 message: "broken".to_string(),
1445 },
1446 ]));
1447 mgr.install_test_transport(Language::Rust, fake).await;
1448
1449 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1450 assert_eq!(block.items.len(), 2);
1451 // Errors come first after sorting.
1452 assert_eq!(block.items[0].severity, Severity::Error);
1453 assert_eq!(block.items[1].severity, Severity::Warning);
1454 }
1455
1456 #[tokio::test]
1457 async fn truncates_to_max_per_file() {
1458 let dir = tempfile::tempdir().unwrap();
1459 let mgr = LspManager::new(
1460 LspConfig {
1461 max_diagnostics_per_file: 3,
1462 ..LspConfig::default()
1463 },
1464 dir.path().to_path_buf(),
1465 );
1466 let path = dir.path().join("foo.rs");
1467 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1468
1469 let fake = Arc::new(FakeTransport::new(
1470 (0..10)
1471 .map(|i| Diagnostic {
1472 line: i + 1,
1473 column: 1,
1474 severity: Severity::Error,
1475 message: format!("err {i}"),
1476 })
1477 .collect(),
1478 ));
1479 mgr.install_test_transport(Language::Rust, fake).await;
1480
1481 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1482 assert_eq!(block.items.len(), 3);
1483 }
1484
1485 #[tokio::test]
1486 async fn render_blocks_concatenates() {
1487 let blocks = vec![
1488 DiagnosticBlock {
1489 file: PathBuf::from("a.rs"),
1490 items: vec![Diagnostic {
1491 line: 1,
1492 column: 1,
1493 severity: Severity::Error,
1494 message: "err in a".to_string(),
1495 }],
1496 },
1497 DiagnosticBlock {
1498 file: PathBuf::from("b.rs"),
1499 items: vec![Diagnostic {
1500 line: 2,
1501 column: 2,
1502 severity: Severity::Error,
1503 message: "err in b".to_string(),
1504 }],
1505 },
1506 ];
1507 let rendered = render_blocks(&blocks);
1508 assert!(rendered.contains("file=\"a.rs\""));
1509 assert!(rendered.contains("file=\"b.rs\""));
1510 }
1511
1512 #[test]
1513 fn relative_path_falls_back_to_filename_when_outside_workspace() {
1514 let workspace = PathBuf::from("/foo/bar");
1515 let path = PathBuf::from("/baz/qux.rs");
1516 assert_eq!(
1517 relative_to_workspace(&workspace, &path),
1518 PathBuf::from("qux.rs")
1519 );
1520 }
1521
1522 #[test]
1523 fn config_resolve_uses_overrides() {
1524 let mut cfg = LspConfig::default();
1525 cfg.servers.insert(
1526 "rust".to_string(),
1527 vec!["custom-rls".to_string(), "--lsp".to_string()],
1528 );
1529 let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
1530 assert_eq!(cmd, "custom-rls");
1531 assert_eq!(args, vec!["--lsp".to_string()]);
1532 }
1533
1534 #[test]
1535 fn config_resolve_falls_back_to_registry() {
1536 let cfg = LspConfig::default();
1537 let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
1538 assert_eq!(cmd, "rust-analyzer");
1539 }
1540
1541 // ── custom server extension tests ─────────────────────────────────────
1542
1543 #[test]
1544 fn custom_for_extension_none_for_empty_config() {
1545 let cfg = LspConfig::default();
1546 assert!(cfg.custom_for_extension(&PathBuf::from("foo.rb")).is_none());
1547 }
1548
1549 #[test]
1550 fn custom_for_extension_finds_registered_extension() {
1551 let mut cfg = LspConfig::default();
1552 cfg.custom.insert(
1553 "rb".to_string(),
1554 CustomLspDef {
1555 language_id: "ruby".to_string(),
1556 command: "ruby-lsp".to_string(),
1557 args: vec!["--stdio".to_string()],
1558 },
1559 );
1560 let def = cfg
1561 .custom_for_extension(&PathBuf::from("lib/hello.rb"))
1562 .expect("should find rb");
1563 assert_eq!(def.language_id, "ruby");
1564 assert_eq!(def.command, "ruby-lsp");
1565 }
1566
1567 #[test]
1568 fn custom_for_extension_case_insensitive() {
1569 let mut cfg = LspConfig::default();
1570 cfg.custom.insert(
1571 "cs".to_string(),
1572 CustomLspDef {
1573 language_id: "csharp".to_string(),
1574 command: "csharp-ls".to_string(),
1575 args: vec![],
1576 },
1577 );
1578 assert!(cfg.custom_for_extension(&PathBuf::from("App.CS")).is_some());
1579 assert!(cfg.custom_for_extension(&PathBuf::from("App.Cs")).is_some());
1580 }
1581
1582 #[tokio::test]
1583 async fn custom_fallback_only_for_other_language() {
1584 // Even if [lsp.custom.go] is configured, .go files must still use
1585 // the built-in gopls path — custom is a fallback, not an override.
1586 let dir = tempfile::tempdir().unwrap();
1587 let mut cfg = LspConfig::default();
1588 cfg.custom.insert(
1589 "go".to_string(),
1590 CustomLspDef {
1591 language_id: "go".to_string(),
1592 command: "custom-gopls".to_string(),
1593 args: vec![],
1594 },
1595 );
1596 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1597 let path = dir.path().join("main.go");
1598 tokio::fs::write(&path, b"package main\n").await.unwrap();
1599
1600 // Inject a fake transport for the built-in Go path; we do NOT
1601 // inject one for the custom path — so if it accidentally takes
1602 // the custom route it will return None.
1603 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1604 line: 1,
1605 column: 1,
1606 severity: Severity::Error,
1607 message: "builtin-go-diag".to_string(),
1608 }]));
1609 mgr.install_test_transport(Language::Go, fake).await;
1610
1611 // No custom transport injected — if it hits custom, it returns None.
1612 // If it hits built-in, it returns the fake diagnostic.
1613 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1614 let rendered = block.render();
1615 assert!(
1616 rendered.contains("builtin-go-diag"),
1617 "should use built-in Go transport, not custom override: {rendered}"
1618 );
1619 }
1620
1621 #[tokio::test]
1622 async fn diagnostics_for_custom_returns_diagnostics() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let mut cfg = LspConfig::default();
1625 cfg.custom.insert(
1626 "rb".to_string(),
1627 CustomLspDef {
1628 language_id: "ruby".to_string(),
1629 command: "ruby-lsp".to_string(),
1630 args: vec![],
1631 },
1632 );
1633 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1634 let path = dir.path().join("app.rb");
1635 tokio::fs::write(&path, b"def foo; end\n").await.unwrap();
1636
1637 // Inject fake transport into the custom-transport map.
1638 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1639 line: 1,
1640 column: 5,
1641 severity: Severity::Error,
1642 message: "ruby type error".to_string(),
1643 }]));
1644 mgr.custom_transports.lock().await.insert(
1645 "rb".to_string(),
1646 Arc::new(AsyncMutex::new(Some(fake.clone()))),
1647 );
1648
1649 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1650 let rendered = block.render();
1651 assert!(rendered.contains("ruby type error"));
1652 assert_eq!(fake.call_count(), 1);
1653 }
1654
1655 #[tokio::test]
1656 async fn custom_unregistered_extension_returns_none() {
1657 let dir = tempfile::tempdir().unwrap();
1658 let cfg = LspConfig::default();
1659 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1660 let path = dir.path().join("script.lua");
1661 tokio::fs::write(&path, b"print('hi')\n").await.unwrap();
1662
1663 // No custom config for .lua and Lua is not built-in → should be None.
1664 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1665 }
1666
1667 #[tokio::test]
1668 async fn diagnostic_freshness_intelligence_distinguishes_no_server_and_unverified_empty() {
1669 let root = tempfile::tempdir().unwrap();
1670 let unknown = root.path().join("notes.unsupported_extension");
1671 std::fs::write(&unknown, "text").unwrap();
1672 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1673 let error = manager
1674 .intelligence("diagnostics", &unknown, None, None, None)
1675 .await
1676 .unwrap_err();
1677 assert!(error.contains("no LSP server"));
1678 let file = root.path().join("main.rs");
1679 let text = "fn main() { /* 🐋 */ }";
1680 std::fs::write(&file, text).unwrap();
1681 manager
1682 .install_test_transport(Language::Rust, Arc::new(FakeTransport::new(vec![])))
1683 .await;
1684 let result = manager
1685 .intelligence("diagnostics", &file, None, None, None)
1686 .await
1687 .unwrap();
1688 assert_eq!(result["items"], serde_json::json!([]));
1689 assert_eq!(
1690 result["source_revision"],
1691 crate::hashing::sha256_hex(text.as_bytes())
1692 );
1693 assert_eq!(result["freshness"], "unverified");
1694 assert!(result["diagnostic_version"].is_null());
1695 assert_eq!(result["total_diagnostic_count"], 0);
1696 }
1697
1698 #[tokio::test]
1699 async fn diagnostic_freshness_missing_binary_is_not_clean() {
1700 let root = tempfile::tempdir().unwrap();
1701 let file = root.path().join("main.rs");
1702 std::fs::write(&file, "fn main() {}").unwrap();
1703 let mut config = LspConfig::default();
1704 config.servers.insert(
1705 "rust".into(),
1706 vec![
1707 root.path()
1708 .join("nonexistent-language-server")
1709 .display()
1710 .to_string(),
1711 ],
1712 );
1713 let manager = LspManager::new(config, root.path().to_owned());
1714 assert!(
1715 manager
1716 .intelligence("diagnostics", &file, None, None, None)
1717 .await
1718 .unwrap_err()
1719 .contains("no LSP server")
1720 );
1721 }
1722
1723 #[cfg(unix)]
1724 #[tokio::test]
1725 async fn diagnostic_freshness_confined_read_rejects_replaced_symlink() {
1726 let root = tempfile::tempdir().unwrap();
1727 let outside = tempfile::tempdir().unwrap();
1728 let secret = outside.path().join("secret.rs");
1729 std::fs::write(&secret, "outside text").unwrap();
1730 let file = root.path().join("main.rs");
1731 std::os::unix::fs::symlink(&secret, &file).unwrap();
1732 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1733 let fake = Arc::new(FakeTransport::new(vec![]));
1734 manager
1735 .install_test_transport(Language::Rust, fake.clone())
1736 .await;
1737 assert!(
1738 manager
1739 .intelligence("diagnostics", &file, None, None, None)
1740 .await
1741 .unwrap_err()
1742 .contains("read file")
1743 );
1744 assert_eq!(
1745 fake.call_count(),
1746 0,
1747 "outside bytes must not reach the language server"
1748 );
1749 }
1750
1751 #[cfg(unix)]
1752 #[tokio::test]
1753 async fn diagnostic_freshness_accepts_verified_workspace_root_alias_only() {
1754 let root = tempfile::tempdir().unwrap();
1755 let workspace = root.path().join("workspace");
1756 std::fs::create_dir(&workspace).unwrap();
1757 let alias = root.path().join("workspace-alias");
1758 std::os::unix::fs::symlink(&workspace, &alias).unwrap();
1759 let original = workspace.join("main.rs");
1760 std::fs::write(&original, "fn main() {} 🐋").unwrap();
1761 let manager = LspManager::new(LspConfig::default(), alias.clone());
1762 assert_eq!(
1763 manager
1764 .read_workspace_text(&original.canonicalize().unwrap())
1765 .await
1766 .unwrap(),
1767 "fn main() {} 🐋"
1768 );
1769 assert_eq!(
1770 manager
1771 .read_workspace_text(&alias.join("main.rs"))
1772 .await
1773 .unwrap(),
1774 "fn main() {} 🐋"
1775 );
1776 let outside = root.path().join("outside.rs");
1777 std::fs::write(&outside, "not in workspace").unwrap();
1778 assert!(manager.read_workspace_text(&outside).await.is_err());
1779 std::os::unix::fs::symlink(&outside, workspace.join("escape.rs")).unwrap();
1780 assert!(
1781 manager
1782 .read_workspace_text(&alias.join("escape.rs"))
1783 .await
1784 .is_err()
1785 );
1786 assert!(
1787 manager
1788 .read_workspace_text(&workspace.canonicalize().unwrap().join("escape.rs"))
1789 .await
1790 .is_err()
1791 );
1792 }
1793 }
1794
1794 lines RUST