返回 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 part != ".git")) => 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 #[cfg_attr(any(not(test), not(unix)), expect(dead_code))]
873 pub async fn shutdown_all(&self) {
874 let transports: Vec<TransportSlot> =
875 self.transports.lock().await.values().cloned().collect();
876 let custom: Vec<TransportSlot> = self
877 .custom_transports
878 .lock()
879 .await
880 .values()
881 .cloned()
882 .collect();
883 for slot in transports.into_iter().chain(custom) {
884 if let Some(transport) = slot.lock().await.take() {
885 transport.shutdown().await;
886 }
887 }
888 }
889 }
890
891 impl LspConfig {
892 /// Look up a [`CustomLspDef`] for `file` when the built-in registry
893 /// would return `Language::Other`. Returns `None` when the extension is
894 /// unknown or no custom server is registered for it.
895 fn custom_for_extension(&self, file: &Path) -> Option<&CustomLspDef> {
896 let ext = file.extension()?.to_str()?;
897 self.custom.get(&ext.to_ascii_lowercase())
898 }
899 }
900
901 fn semantic_relative_path(path: &Path) -> Option<String> {
902 let parts = path
903 .components()
904 .map(|component| match component {
905 std::path::Component::Normal(part) if part != ".git" => {
906 part.to_str().filter(|part| !part.contains('\\'))
907 }
908 _ => None,
909 })
910 .collect::<Option<Vec<_>>>()?;
911 let path = parts.join("/");
912 (!path.is_empty() && path.len() <= 4096).then_some(path)
913 }
914
915 /// LSP positions are zero-based UTF-16 code units, never UTF-8 byte offsets.
916 #[derive(
917 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize,
918 )]
919 struct SemanticPosition {
920 line: u32,
921 character: u32,
922 }
923 impl SemanticPosition {
924 fn valid_in(self, text: &str) -> bool {
925 let Some(line) = text.split('\n').nth(self.line as usize) else {
926 return false;
927 };
928 let line = line.strip_suffix('\r').unwrap_or(line);
929 let mut column = 0;
930 for ch in line.chars() {
931 if column == self.character {
932 return true;
933 }
934 column += ch.len_utf16() as u32;
935 if column > self.character {
936 return false;
937 }
938 }
939 column == self.character
940 }
941 }
942
943 #[derive(Debug, serde::Deserialize, serde::Serialize)]
944 struct SemanticRange {
945 start: SemanticPosition,
946 end: SemanticPosition,
947 }
948
949 #[derive(Debug, serde::Serialize)]
950 struct SemanticLocation {
951 path: String,
952 range: SemanticRange,
953 target_revision: String,
954 target_freshness: &'static str,
955 #[serde(skip_serializing_if = "Option::is_none")]
956 name: Option<String>,
957 #[serde(skip_serializing_if = "Option::is_none")]
958 kind: Option<u32>,
959 }
960
961 /// Cap intelligence payloads so a chatty language server cannot flood the
962 /// model context. Arrays keep the first `MAX` entries and set `truncated`.
963 fn truncate_intelligence_result(value: serde_json::Value) -> serde_json::Value {
964 const MAX_ITEMS: usize = 40;
965 const MAX_CHARS: usize = 12_000;
966 let bounded = match value {
967 serde_json::Value::Array(mut items) if items.len() > MAX_ITEMS => {
968 let total = items.len();
969 items.truncate(MAX_ITEMS);
970 serde_json::json!({"items":items, "truncated":true, "total":total})
971 }
972 other => other,
973 };
974 let rendered = bounded.to_string();
975 if rendered.len() > MAX_CHARS {
976 let mut boundary = MAX_CHARS;
977 while !rendered.is_char_boundary(boundary) {
978 boundary -= 1;
979 }
980 serde_json::json!({
981 "truncated": true,
982 "preview": &rendered[..boundary],
983 "total_chars": rendered.len(),
984 })
985 } else {
986 bounded
987 }
988 }
989
990 /// Render `path` relative to the workspace when possible. Falls back to
991 /// `path.file_name()` (per the issue's hard rule about not using
992 /// `display().to_string()` on the bare path) when relativization fails.
993 fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
994 if let Ok(rel) = path.strip_prefix(workspace) {
995 return rel.to_path_buf();
996 }
997 PathBuf::from(
998 path.file_name()
999 .map(|n| n.to_string_lossy().into_owned())
1000 .unwrap_or_else(|| String::from("unknown")),
1001 )
1002 }
1003
1004 /// Used for tests / no-op runs. Builds an empty manager that always returns
1005 /// `None`. Needed because the engine constructs an `LspManager` even when
1006 /// the user has disabled LSP, so the field is always present.
1007 impl LspManager {
1008 #[must_use]
1009 pub fn disabled() -> Self {
1010 Self::new(
1011 LspConfig {
1012 enabled: false,
1013 ..LspConfig::default()
1014 },
1015 PathBuf::new(),
1016 )
1017 }
1018 }
1019
1020 #[cfg(test)]
1021 pub(crate) mod tests {
1022 use super::*;
1023 use async_trait::async_trait;
1024 use std::sync::atomic::{AtomicUsize, Ordering};
1025
1026 /// Fake transport: returns a fixed list of diagnostics. Used by
1027 /// integration tests so we never spawn a real LSP server in CI.
1028 pub(crate) struct FakeTransport {
1029 items: Vec<Diagnostic>,
1030 calls: AtomicUsize,
1031 }
1032
1033 impl FakeTransport {
1034 pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
1035 Self {
1036 items,
1037 calls: AtomicUsize::new(0),
1038 }
1039 }
1040
1041 pub(crate) fn call_count(&self) -> usize {
1042 self.calls.load(Ordering::Relaxed)
1043 }
1044 }
1045
1046 #[async_trait]
1047 impl LspTransport for FakeTransport {
1048 async fn diagnostics_for(
1049 &self,
1050 _path: &Path,
1051 _text: &str,
1052 _wait: Duration,
1053 ) -> anyhow::Result<crate::lsp::client::DiagnosticPublication> {
1054 self.calls.fetch_add(1, Ordering::Relaxed);
1055 Ok(self.items.clone().into())
1056 }
1057
1058 async fn shutdown(&self) {}
1059 }
1060
1061 #[cfg(unix)]
1062 async fn cache_fixture(custom: bool) {
1063 let root = tempfile::tempdir().unwrap();
1064 let pids = root.path().join("pids");
1065 let args = vec![
1066 "-u".into(),
1067 "-c".into(),
1068 client::tests::STDIO_FIXTURE.into(),
1069 "cache".into(),
1070 pids.to_string_lossy().into_owned(),
1071 ];
1072 let mut config = LspConfig::default();
1073 if custom {
1074 config.custom.insert(
1075 "cachetest".into(),
1076 CustomLspDef {
1077 command: "python3".into(),
1078 args,
1079 language_id: "cachetest".into(),
1080 },
1081 );
1082 } else {
1083 let mut command = vec!["python3".into()];
1084 command.extend(args);
1085 config.servers.insert("python".into(), command);
1086 }
1087 let manager = LspManager::new(config, root.path().to_owned());
1088 let path = root
1089 .path()
1090 .join(if custom { "file.cachetest" } else { "file.py" });
1091 let (first, second, third) = tokio::join!(
1092 manager.transport_for_path(&path),
1093 manager.transport_for_path(&path),
1094 manager.transport_for_path(&path)
1095 );
1096 let first = first.unwrap();
1097 assert!(Arc::ptr_eq(&first, &second.unwrap()));
1098 assert!(Arc::ptr_eq(&first, &third.unwrap()));
1099 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 1);
1100 assert!(
1101 first
1102 .request(
1103 "fixture/exit",
1104 serde_json::json!({}),
1105 Duration::from_secs(2)
1106 )
1107 .await
1108 .is_err()
1109 );
1110 timeout(Duration::from_secs(2), async {
1111 while first.is_alive() {
1112 tokio::task::yield_now().await;
1113 }
1114 })
1115 .await
1116 .unwrap();
1117 let recovered = manager.transport_for_path(&path).await.unwrap();
1118 assert!(!Arc::ptr_eq(&first, &recovered));
1119 assert!(
1120 recovered
1121 .request(
1122 "fixture/ready",
1123 serde_json::json!({}),
1124 Duration::from_secs(2)
1125 )
1126 .await
1127 .is_ok()
1128 );
1129 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 2);
1130 manager.shutdown_all().await;
1131 assert!(manager.transport_for_path(&path).await.is_some());
1132 assert_eq!(std::fs::read_to_string(&pids).unwrap().lines().count(), 3);
1133 manager.shutdown_all().await;
1134 }
1135
1136 #[cfg(unix)]
1137 #[tokio::test]
1138 async fn built_in_transport_cache_shares_cold_start_and_recovers_after_exit() {
1139 cache_fixture(false).await;
1140 }
1141
1142 #[cfg(unix)]
1143 #[tokio::test]
1144 async fn custom_transport_cache_shares_cold_start_and_recovers_after_exit() {
1145 cache_fixture(true).await;
1146 }
1147
1148 #[cfg(unix)]
1149 #[tokio::test]
1150 async fn failed_transport_initialization_does_not_poison_the_cache() {
1151 let root = tempfile::tempdir().unwrap();
1152 let script = root.path().join("server.py");
1153 let mut config = LspConfig::default();
1154 config.servers.insert(
1155 "python".into(),
1156 vec![
1157 "python3".into(),
1158 "-u".into(),
1159 script.to_string_lossy().into_owned(),
1160 "cache".into(),
1161 root.path().join("pids").to_string_lossy().into_owned(),
1162 ],
1163 );
1164 let manager = LspManager::new(config, root.path().to_owned());
1165 let path = root.path().join("file.py");
1166 assert!(manager.transport_for_path(&path).await.is_none());
1167 std::fs::write(&script, client::tests::STDIO_FIXTURE).unwrap();
1168 assert!(manager.transport_for_path(&path).await.is_some());
1169 manager.shutdown_all().await;
1170 }
1171
1172 struct SemanticFixture {
1173 result: serde_json::Value,
1174 mutate: Option<PathBuf>,
1175 }
1176 #[async_trait::async_trait]
1177 impl LspTransport for SemanticFixture {
1178 async fn diagnostics_for(
1179 &self,
1180 _: &Path,
1181 _: &str,
1182 _: Duration,
1183 ) -> anyhow::Result<client::DiagnosticPublication> {
1184 Ok(vec![].into())
1185 }
1186 async fn request(
1187 &self,
1188 _: &str,
1189 _: serde_json::Value,
1190 _: Duration,
1191 ) -> anyhow::Result<serde_json::Value> {
1192 if let Some(path) = &self.mutate {
1193 tokio::fs::write(path, "changed").await?;
1194 }
1195 Ok(self.result.clone())
1196 }
1197 async fn shutdown(&self) {}
1198 }
1199
1200 #[tokio::test]
1201 async fn semantic_source_revision_rejects_stale_before_and_after_request() {
1202 let root = tempfile::tempdir().unwrap();
1203 let file = root.path().join("main.rs");
1204 tokio::fs::write(&file, "fn main() {}\n").await.unwrap();
1205 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1206 assert_eq!(
1207 manager
1208 .intelligence_at_revision(
1209 "definition",
1210 &file,
1211 Some(1),
1212 Some(1),
1213 None,
1214 Some("wrong")
1215 )
1216 .await
1217 .unwrap_err(),
1218 "stale_document"
1219 );
1220 manager
1221 .install_test_transport(
1222 Language::Rust,
1223 Arc::new(SemanticFixture {
1224 result: serde_json::json!([]),
1225 mutate: Some(file.clone()),
1226 }),
1227 )
1228 .await;
1229 let revision = crate::hashing::sha256_hex(b"fn main() {}\n");
1230 assert_eq!(
1231 manager
1232 .intelligence_at_revision(
1233 "definition",
1234 &file,
1235 Some(1),
1236 Some(1),
1237 None,
1238 Some(&revision)
1239 )
1240 .await
1241 .unwrap_err(),
1242 "stale_document"
1243 );
1244 }
1245
1246 #[tokio::test]
1247 async fn semantic_locations_preserve_raw_and_distinguish_target_readback_from_analysis() {
1248 let root = tempfile::tempdir().unwrap();
1249 let file = root.path().join("main.rs");
1250 let target = root.path().join("a b.rs");
1251 tokio::fs::write(&file, "fn main() {}\n").await.unwrap();
1252 tokio::fs::write(&target, "🐋foo\n").await.unwrap();
1253 let raw = serde_json::json!([{"targetUri":client::uri_from_path(&target),"targetSelectionRange":{"start":{"line":0,"character":2},"end":{"line":0,"character":5}}}]);
1254 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1255 manager
1256 .install_test_transport(
1257 Language::Rust,
1258 Arc::new(SemanticFixture {
1259 result: raw.clone(),
1260 mutate: None,
1261 }),
1262 )
1263 .await;
1264 let result = manager
1265 .intelligence("definition", &file, Some(1), Some(1), None)
1266 .await
1267 .unwrap();
1268 assert_eq!(result["result"], raw);
1269 assert_eq!(result["semantic_contract_version"], 1);
1270 assert_eq!(result["position_encoding"], "utf-16");
1271 assert_eq!(result["freshness"], "unverified");
1272 assert_eq!(result["locations"][0]["path"], "a b.rs");
1273 assert_eq!(
1274 result["locations"][0]["target_revision"],
1275 crate::hashing::sha256_hex("🐋foo\n".as_bytes())
1276 );
1277 assert_eq!(result["locations"][0]["target_freshness"], "unverified");
1278 assert_eq!(result["omitted"], 0);
1279 }
1280
1281 #[tokio::test]
1282 async fn semantic_locations_reject_unsafe_uri_range_and_symlink_and_bound_symbols() {
1283 let root = tempfile::tempdir().unwrap();
1284 let file = root.path().join("main.rs");
1285 tokio::fs::write(&file, "🐋foo\n").await.unwrap();
1286 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1287 let good = serde_json::json!({"name":"foo","kind":12,"selectionRange":{"start":{"line":0,"character":2},"end":{"line":0,"character":5}}});
1288 let mut split = good.clone();
1289 split["selectionRange"]["start"]["character"] = serde_json::json!(1);
1290 let mut huge = good.clone();
1291 huge["selectionRange"]["end"]["character"] = serde_json::json!(u64::MAX);
1292 let mut unsafe_uri = good.clone();
1293 unsafe_uri["uri"] = serde_json::json!("file://remote/etc/passwd");
1294 let mut outside = good.clone();
1295 outside["uri"] = serde_json::json!("file:///etc/passwd");
1296 let mut reverse = good.clone();
1297 reverse["selectionRange"]["end"]["character"] = serde_json::json!(0);
1298 let cases = vec![good.clone(), split, huge, unsafe_uri, outside, reverse];
1299 #[cfg(unix)]
1300 let cases = {
1301 let mut cases = cases;
1302 let link = root.path().join("link.rs");
1303 std::os::unix::fs::symlink(&file, &link).unwrap();
1304 let mut linked = good.clone();
1305 linked["uri"] = serde_json::json!(client::uri_from_path(&link));
1306 cases.push(linked);
1307 cases
1308 };
1309 let expected_omitted = cases.len() - 1;
1310 let (locations, truncated, omitted) = manager
1311 .semantic_locations(&serde_json::json!(cases), &file, "🐋foo\n")
1312 .await;
1313 assert_eq!(locations.len(), 1);
1314 assert!(!truncated);
1315 assert_eq!(omitted, expected_omitted);
1316 let (locations, truncated, _) = manager
1317 .semantic_locations(&serde_json::json!(vec![good; 100]), &file, "🐋foo\n")
1318 .await;
1319 assert_eq!(locations.len(), 40);
1320 assert!(truncated);
1321 assert!(
1322 !SemanticPosition {
1323 line: 0,
1324 character: 1
1325 }
1326 .valid_in("🐋")
1327 );
1328 assert!(
1329 SemanticPosition {
1330 line: 0,
1331 character: 2
1332 }
1333 .valid_in("🐋")
1334 );
1335 }
1336
1337 #[test]
1338 fn semantic_raw_result_bound_is_unicode_safe_for_arrays_too() {
1339 let result = truncate_intelligence_result(serde_json::json!(["🐋".repeat(20_000)]));
1340 assert_eq!(result["truncated"], true);
1341 assert!(result["preview"].as_str().unwrap().len() <= 12_000);
1342 }
1343
1344 #[tokio::test]
1345 async fn returns_none_when_disabled() {
1346 let mgr = LspManager::new(
1347 LspConfig {
1348 enabled: false,
1349 ..LspConfig::default()
1350 },
1351 PathBuf::from("/tmp"),
1352 );
1353 let dir = tempfile::tempdir().unwrap();
1354 let path = dir.path().join("foo.rs");
1355 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1356 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1357 }
1358
1359 #[tokio::test]
1360 async fn returns_none_for_unknown_language() {
1361 let dir = tempfile::tempdir().unwrap();
1362 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1363 let path = dir.path().join("notes.txt");
1364 tokio::fs::write(&path, b"hi").await.unwrap();
1365 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1366 }
1367
1368 #[tokio::test]
1369 async fn forwards_errors_through_fake_transport() {
1370 let dir = tempfile::tempdir().unwrap();
1371 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1372 let path = dir.path().join("foo.rs");
1373 tokio::fs::write(&path, b"let x: i32 = \"oops\";")
1374 .await
1375 .unwrap();
1376
1377 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1378 line: 1,
1379 column: 14,
1380 severity: Severity::Error,
1381 message: "expected i32, found &str".to_string(),
1382 }]));
1383 mgr.install_test_transport(Language::Rust, fake.clone())
1384 .await;
1385
1386 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1387 let rendered = block.render();
1388 assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
1389 assert!(rendered.contains("foo.rs"));
1390 assert_eq!(fake.call_count(), 1);
1391 }
1392
1393 #[tokio::test]
1394 async fn drops_warnings_by_default() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
1397 let path = dir.path().join("foo.rs");
1398 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1399
1400 let fake = Arc::new(FakeTransport::new(vec![
1401 Diagnostic {
1402 line: 1,
1403 column: 1,
1404 severity: Severity::Warning,
1405 message: "unused import".to_string(),
1406 },
1407 Diagnostic {
1408 line: 2,
1409 column: 1,
1410 severity: Severity::Error,
1411 message: "type error".to_string(),
1412 },
1413 ]));
1414 mgr.install_test_transport(Language::Rust, fake).await;
1415
1416 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1417 assert_eq!(block.items.len(), 1);
1418 assert_eq!(block.items[0].severity, Severity::Error);
1419 }
1420
1421 #[tokio::test]
1422 async fn keeps_warnings_when_opted_in() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let mgr = LspManager::new(
1425 LspConfig {
1426 include_warnings: true,
1427 ..LspConfig::default()
1428 },
1429 dir.path().to_path_buf(),
1430 );
1431 let path = dir.path().join("foo.rs");
1432 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1433
1434 let fake = Arc::new(FakeTransport::new(vec![
1435 Diagnostic {
1436 line: 1,
1437 column: 1,
1438 severity: Severity::Warning,
1439 message: "unused".to_string(),
1440 },
1441 Diagnostic {
1442 line: 2,
1443 column: 1,
1444 severity: Severity::Error,
1445 message: "broken".to_string(),
1446 },
1447 ]));
1448 mgr.install_test_transport(Language::Rust, fake).await;
1449
1450 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1451 assert_eq!(block.items.len(), 2);
1452 // Errors come first after sorting.
1453 assert_eq!(block.items[0].severity, Severity::Error);
1454 assert_eq!(block.items[1].severity, Severity::Warning);
1455 }
1456
1457 #[tokio::test]
1458 async fn truncates_to_max_per_file() {
1459 let dir = tempfile::tempdir().unwrap();
1460 let mgr = LspManager::new(
1461 LspConfig {
1462 max_diagnostics_per_file: 3,
1463 ..LspConfig::default()
1464 },
1465 dir.path().to_path_buf(),
1466 );
1467 let path = dir.path().join("foo.rs");
1468 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
1469
1470 let fake = Arc::new(FakeTransport::new(
1471 (0..10)
1472 .map(|i| Diagnostic {
1473 line: i + 1,
1474 column: 1,
1475 severity: Severity::Error,
1476 message: format!("err {i}"),
1477 })
1478 .collect(),
1479 ));
1480 mgr.install_test_transport(Language::Rust, fake).await;
1481
1482 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1483 assert_eq!(block.items.len(), 3);
1484 }
1485
1486 #[tokio::test]
1487 async fn render_blocks_concatenates() {
1488 let blocks = vec![
1489 DiagnosticBlock {
1490 file: PathBuf::from("a.rs"),
1491 items: vec![Diagnostic {
1492 line: 1,
1493 column: 1,
1494 severity: Severity::Error,
1495 message: "err in a".to_string(),
1496 }],
1497 },
1498 DiagnosticBlock {
1499 file: PathBuf::from("b.rs"),
1500 items: vec![Diagnostic {
1501 line: 2,
1502 column: 2,
1503 severity: Severity::Error,
1504 message: "err in b".to_string(),
1505 }],
1506 },
1507 ];
1508 let rendered = render_blocks(&blocks);
1509 assert!(rendered.contains("file=\"a.rs\""));
1510 assert!(rendered.contains("file=\"b.rs\""));
1511 }
1512
1513 #[test]
1514 fn relative_path_falls_back_to_filename_when_outside_workspace() {
1515 let workspace = PathBuf::from("/foo/bar");
1516 let path = PathBuf::from("/baz/qux.rs");
1517 assert_eq!(
1518 relative_to_workspace(&workspace, &path),
1519 PathBuf::from("qux.rs")
1520 );
1521 }
1522
1523 #[test]
1524 fn config_resolve_uses_overrides() {
1525 let mut cfg = LspConfig::default();
1526 cfg.servers.insert(
1527 "rust".to_string(),
1528 vec!["custom-rls".to_string(), "--lsp".to_string()],
1529 );
1530 let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
1531 assert_eq!(cmd, "custom-rls");
1532 assert_eq!(args, vec!["--lsp".to_string()]);
1533 }
1534
1535 #[test]
1536 fn config_resolve_falls_back_to_registry() {
1537 let cfg = LspConfig::default();
1538 let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
1539 assert_eq!(cmd, "rust-analyzer");
1540 }
1541
1542 // ── custom server extension tests ─────────────────────────────────────
1543
1544 #[test]
1545 fn custom_for_extension_none_for_empty_config() {
1546 let cfg = LspConfig::default();
1547 assert!(cfg.custom_for_extension(&PathBuf::from("foo.rb")).is_none());
1548 }
1549
1550 #[test]
1551 fn custom_for_extension_finds_registered_extension() {
1552 let mut cfg = LspConfig::default();
1553 cfg.custom.insert(
1554 "rb".to_string(),
1555 CustomLspDef {
1556 language_id: "ruby".to_string(),
1557 command: "ruby-lsp".to_string(),
1558 args: vec!["--stdio".to_string()],
1559 },
1560 );
1561 let def = cfg
1562 .custom_for_extension(&PathBuf::from("lib/hello.rb"))
1563 .expect("should find rb");
1564 assert_eq!(def.language_id, "ruby");
1565 assert_eq!(def.command, "ruby-lsp");
1566 }
1567
1568 #[test]
1569 fn custom_for_extension_case_insensitive() {
1570 let mut cfg = LspConfig::default();
1571 cfg.custom.insert(
1572 "cs".to_string(),
1573 CustomLspDef {
1574 language_id: "csharp".to_string(),
1575 command: "csharp-ls".to_string(),
1576 args: vec![],
1577 },
1578 );
1579 assert!(cfg.custom_for_extension(&PathBuf::from("App.CS")).is_some());
1580 assert!(cfg.custom_for_extension(&PathBuf::from("App.Cs")).is_some());
1581 }
1582
1583 #[tokio::test]
1584 async fn custom_fallback_only_for_other_language() {
1585 // Even if [lsp.custom.go] is configured, .go files must still use
1586 // the built-in gopls path — custom is a fallback, not an override.
1587 let dir = tempfile::tempdir().unwrap();
1588 let mut cfg = LspConfig::default();
1589 cfg.custom.insert(
1590 "go".to_string(),
1591 CustomLspDef {
1592 language_id: "go".to_string(),
1593 command: "custom-gopls".to_string(),
1594 args: vec![],
1595 },
1596 );
1597 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1598 let path = dir.path().join("main.go");
1599 tokio::fs::write(&path, b"package main\n").await.unwrap();
1600
1601 // Inject a fake transport for the built-in Go path; we do NOT
1602 // inject one for the custom path — so if it accidentally takes
1603 // the custom route it will return None.
1604 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1605 line: 1,
1606 column: 1,
1607 severity: Severity::Error,
1608 message: "builtin-go-diag".to_string(),
1609 }]));
1610 mgr.install_test_transport(Language::Go, fake).await;
1611
1612 // No custom transport injected — if it hits custom, it returns None.
1613 // If it hits built-in, it returns the fake diagnostic.
1614 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1615 let rendered = block.render();
1616 assert!(
1617 rendered.contains("builtin-go-diag"),
1618 "should use built-in Go transport, not custom override: {rendered}"
1619 );
1620 }
1621
1622 #[tokio::test]
1623 async fn diagnostics_for_custom_returns_diagnostics() {
1624 let dir = tempfile::tempdir().unwrap();
1625 let mut cfg = LspConfig::default();
1626 cfg.custom.insert(
1627 "rb".to_string(),
1628 CustomLspDef {
1629 language_id: "ruby".to_string(),
1630 command: "ruby-lsp".to_string(),
1631 args: vec![],
1632 },
1633 );
1634 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1635 let path = dir.path().join("app.rb");
1636 tokio::fs::write(&path, b"def foo; end\n").await.unwrap();
1637
1638 // Inject fake transport into the custom-transport map.
1639 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
1640 line: 1,
1641 column: 5,
1642 severity: Severity::Error,
1643 message: "ruby type error".to_string(),
1644 }]));
1645 mgr.custom_transports.lock().await.insert(
1646 "rb".to_string(),
1647 Arc::new(AsyncMutex::new(Some(fake.clone()))),
1648 );
1649
1650 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
1651 let rendered = block.render();
1652 assert!(rendered.contains("ruby type error"));
1653 assert_eq!(fake.call_count(), 1);
1654 }
1655
1656 #[tokio::test]
1657 async fn custom_unregistered_extension_returns_none() {
1658 let dir = tempfile::tempdir().unwrap();
1659 let cfg = LspConfig::default();
1660 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
1661 let path = dir.path().join("script.lua");
1662 tokio::fs::write(&path, b"print('hi')\n").await.unwrap();
1663
1664 // No custom config for .lua and Lua is not built-in → should be None.
1665 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
1666 }
1667
1668 #[tokio::test]
1669 async fn diagnostic_freshness_intelligence_distinguishes_no_server_and_unverified_empty() {
1670 let root = tempfile::tempdir().unwrap();
1671 let unknown = root.path().join("notes.unsupported_extension");
1672 std::fs::write(&unknown, "text").unwrap();
1673 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1674 let error = manager
1675 .intelligence("diagnostics", &unknown, None, None, None)
1676 .await
1677 .unwrap_err();
1678 assert!(error.contains("no LSP server"));
1679 let file = root.path().join("main.rs");
1680 let text = "fn main() { /* 🐋 */ }";
1681 std::fs::write(&file, text).unwrap();
1682 manager
1683 .install_test_transport(Language::Rust, Arc::new(FakeTransport::new(vec![])))
1684 .await;
1685 let result = manager
1686 .intelligence("diagnostics", &file, None, None, None)
1687 .await
1688 .unwrap();
1689 assert_eq!(result["items"], serde_json::json!([]));
1690 assert_eq!(
1691 result["source_revision"],
1692 crate::hashing::sha256_hex(text.as_bytes())
1693 );
1694 assert_eq!(result["freshness"], "unverified");
1695 assert!(result["diagnostic_version"].is_null());
1696 assert_eq!(result["total_diagnostic_count"], 0);
1697 }
1698
1699 #[tokio::test]
1700 async fn diagnostic_freshness_missing_binary_is_not_clean() {
1701 let root = tempfile::tempdir().unwrap();
1702 let file = root.path().join("main.rs");
1703 std::fs::write(&file, "fn main() {}").unwrap();
1704 let mut config = LspConfig::default();
1705 config.servers.insert(
1706 "rust".into(),
1707 vec![
1708 root.path()
1709 .join("nonexistent-language-server")
1710 .display()
1711 .to_string(),
1712 ],
1713 );
1714 let manager = LspManager::new(config, root.path().to_owned());
1715 assert!(
1716 manager
1717 .intelligence("diagnostics", &file, None, None, None)
1718 .await
1719 .unwrap_err()
1720 .contains("no LSP server")
1721 );
1722 }
1723
1724 #[cfg(unix)]
1725 #[tokio::test]
1726 async fn diagnostic_freshness_confined_read_rejects_replaced_symlink() {
1727 let root = tempfile::tempdir().unwrap();
1728 let outside = tempfile::tempdir().unwrap();
1729 let secret = outside.path().join("secret.rs");
1730 std::fs::write(&secret, "outside text").unwrap();
1731 let file = root.path().join("main.rs");
1732 std::os::unix::fs::symlink(&secret, &file).unwrap();
1733 let manager = LspManager::new(LspConfig::default(), root.path().to_owned());
1734 let fake = Arc::new(FakeTransport::new(vec![]));
1735 manager
1736 .install_test_transport(Language::Rust, fake.clone())
1737 .await;
1738 assert!(
1739 manager
1740 .intelligence("diagnostics", &file, None, None, None)
1741 .await
1742 .unwrap_err()
1743 .contains("read file")
1744 );
1745 assert_eq!(
1746 fake.call_count(),
1747 0,
1748 "outside bytes must not reach the language server"
1749 );
1750 }
1751
1752 #[cfg(unix)]
1753 #[tokio::test]
1754 async fn diagnostic_freshness_accepts_verified_workspace_root_alias_only() {
1755 let root = tempfile::tempdir().unwrap();
1756 let workspace = root.path().join("workspace");
1757 std::fs::create_dir(&workspace).unwrap();
1758 let alias = root.path().join("workspace-alias");
1759 std::os::unix::fs::symlink(&workspace, &alias).unwrap();
1760 let original = workspace.join("main.rs");
1761 std::fs::write(&original, "fn main() {} 🐋").unwrap();
1762 let manager = LspManager::new(LspConfig::default(), alias.clone());
1763 assert_eq!(
1764 manager
1765 .read_workspace_text(&original.canonicalize().unwrap())
1766 .await
1767 .unwrap(),
1768 "fn main() {} 🐋"
1769 );
1770 assert_eq!(
1771 manager
1772 .read_workspace_text(&alias.join("main.rs"))
1773 .await
1774 .unwrap(),
1775 "fn main() {} 🐋"
1776 );
1777 let outside = root.path().join("outside.rs");
1778 std::fs::write(&outside, "not in workspace").unwrap();
1779 assert!(manager.read_workspace_text(&outside).await.is_err());
1780 std::os::unix::fs::symlink(&outside, workspace.join("escape.rs")).unwrap();
1781 assert!(
1782 manager
1783 .read_workspace_text(&alias.join("escape.rs"))
1784 .await
1785 .is_err()
1786 );
1787 assert!(
1788 manager
1789 .read_workspace_text(&workspace.canonicalize().unwrap().join("escape.rs"))
1790 .await
1791 .is_err()
1792 );
1793 }
1794 }
1795
1795 lines RUST