Merge branch 'feat/v070-lsp' (#136 LSP diagnostics)
# Conflicts: # config.example.toml # crates/config/src/lib.rs # crates/tui/src/config.rs # crates/tui/src/core/engine.rs # crates/tui/src/main.rs # crates/tui/src/runtime_threads.rs # crates/tui/src/tui/ui.rs
This commit is contained in:
@@ -253,6 +253,35 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
# enabled = true # Snapshot workspace pre/post each turn for /restore
|
||||
# max_age_days = 7 # Older snapshots pruned at session start
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# LSP Diagnostics (post-edit) (#136)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# After every successful file edit (`edit_file`, `apply_patch`, `write_file`),
|
||||
# the engine asks an LSP server for diagnostics on the file and injects them
|
||||
# as a synthetic system message before the next API call. This lets the agent
|
||||
# see compile breaks immediately without round-tripping through the user.
|
||||
#
|
||||
# Enabled by default. Failure modes are non-blocking: a missing LSP binary,
|
||||
# a crashed server, or a timeout simply skips the post-edit hook for that
|
||||
# turn — the agent's work is never blocked.
|
||||
#
|
||||
# Built-in language → server defaults:
|
||||
# rust → rust-analyzer
|
||||
# go → gopls serve
|
||||
# python → pyright-langserver --stdio
|
||||
# typescript → typescript-language-server --stdio
|
||||
# c, cpp → clangd
|
||||
#
|
||||
# Override the defaults via the `servers` table below.
|
||||
[lsp]
|
||||
# enabled = true
|
||||
# poll_after_edit_ms = 5000
|
||||
# max_diagnostics_per_file = 20
|
||||
# include_warnings = false
|
||||
# [lsp.servers]
|
||||
# rust = ["rust-analyzer"]
|
||||
# go = ["gopls", "serve"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Hooks (optional)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,6 +137,10 @@ pub struct ConfigToml {
|
||||
/// enabled with 7-day retention when absent.
|
||||
#[serde(default)]
|
||||
pub snapshots: Option<SnapshotsToml>,
|
||||
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
|
||||
/// applies the defaults documented in [`LspConfigToml`].
|
||||
#[serde(default)]
|
||||
pub lsp: Option<LspConfigToml>,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
@@ -221,6 +225,23 @@ impl Default for NetworkPolicyToml {
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
|
||||
/// for documentation. All fields are optional so the TUI runtime can fall
|
||||
/// back to its own defaults when keys are absent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LspConfigToml {
|
||||
/// Master switch.
|
||||
pub enabled: Option<bool>,
|
||||
/// Maximum time to wait for diagnostics after an edit, in milliseconds.
|
||||
pub poll_after_edit_ms: Option<u64>,
|
||||
/// Cap on diagnostics surfaced per file.
|
||||
pub max_diagnostics_per_file: Option<usize>,
|
||||
/// When `true`, warnings (severity 2) are surfaced in addition to errors.
|
||||
pub include_warnings: Option<bool>,
|
||||
/// Optional override for the `language -> [cmd, ...args]` table.
|
||||
pub servers: Option<BTreeMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
#[must_use]
|
||||
pub fn get_value(&self, key: &str) -> Option<String> {
|
||||
|
||||
@@ -476,6 +476,11 @@ pub struct Config {
|
||||
/// retention when the table is absent.
|
||||
#[serde(default)]
|
||||
pub snapshots: Option<SnapshotsConfig>,
|
||||
|
||||
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
|
||||
/// applies the defaults documented in [`LspConfigToml`].
|
||||
#[serde(default)]
|
||||
pub lsp: Option<LspConfigToml>,
|
||||
}
|
||||
|
||||
/// `[skills]` table — knobs for the community-skill installer.
|
||||
@@ -563,6 +568,50 @@ impl NetworkPolicyToml {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
|
||||
/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
|
||||
/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct LspConfigToml {
|
||||
/// Master switch. Defaults to `true`.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// How long to wait for the LSP server to publish diagnostics after a
|
||||
/// `didOpen`/`didChange`. Defaults to 5000 ms.
|
||||
#[serde(default)]
|
||||
pub poll_after_edit_ms: Option<u64>,
|
||||
/// Cap on diagnostics surfaced per file. Defaults to 20.
|
||||
#[serde(default)]
|
||||
pub max_diagnostics_per_file: Option<usize>,
|
||||
/// Whether to surface warnings in addition to errors. Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub include_warnings: Option<bool>,
|
||||
/// Optional override for the `Language -> [cmd, ...args]` table. Keys
|
||||
/// are language slugs (`"rust"`, `"go"`, etc.).
|
||||
#[serde(default)]
|
||||
pub servers: Option<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
impl LspConfigToml {
|
||||
/// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,
|
||||
/// falling back to defaults for any unset fields.
|
||||
#[must_use]
|
||||
pub fn into_runtime(self) -> crate::lsp::LspConfig {
|
||||
let defaults = crate::lsp::LspConfig::default();
|
||||
crate::lsp::LspConfig {
|
||||
enabled: self.enabled.unwrap_or(defaults.enabled),
|
||||
poll_after_edit_ms: self
|
||||
.poll_after_edit_ms
|
||||
.unwrap_or(defaults.poll_after_edit_ms),
|
||||
max_diagnostics_per_file: self
|
||||
.max_diagnostics_per_file
|
||||
.unwrap_or(defaults.max_diagnostics_per_file),
|
||||
include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
|
||||
servers: self.servers.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_key: Option<String>,
|
||||
@@ -1469,6 +1518,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
network: override_cfg.network.or(base.network),
|
||||
skills: override_cfg.skills.or(base.skills),
|
||||
snapshots: override_cfg.snapshots.or(base.snapshots),
|
||||
lsp: override_cfg.lsp.or(base.lsp),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ pub struct EngineConfig {
|
||||
pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
|
||||
/// Whether to take side-git workspace snapshots before/after each turn.
|
||||
pub snapshots_enabled: bool,
|
||||
/// Post-edit LSP diagnostics injection (#136). When `None`, the engine
|
||||
/// constructs a disabled manager so the field is always present.
|
||||
pub lsp_config: Option<crate::lsp::LspConfig>,
|
||||
}
|
||||
|
||||
impl Default for EngineConfig {
|
||||
@@ -134,6 +137,7 @@ impl Default for EngineConfig {
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy: None,
|
||||
snapshots_enabled: true,
|
||||
lsp_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +267,13 @@ pub struct Engine {
|
||||
capacity_controller: CapacityController,
|
||||
coherence_state: CoherenceState,
|
||||
turn_counter: u64,
|
||||
/// Post-edit LSP diagnostics injection (#136). Populated unconditionally
|
||||
/// — when LSP is disabled in config, this is an inert manager that
|
||||
/// always returns `None` from `diagnostics_for`.
|
||||
lsp_manager: Arc<crate::lsp::LspManager>,
|
||||
/// Diagnostics collected during the current step's tool calls. Drained
|
||||
/// and forwarded as a synthetic user message before the next API call.
|
||||
pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>,
|
||||
}
|
||||
|
||||
// === Internal stream helpers ===
|
||||
@@ -838,6 +849,67 @@ fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str {
|
||||
caller.map_or("direct", |c| c.caller_type.as_str())
|
||||
}
|
||||
|
||||
/// #136: derive the file path(s) edited by a tool call. Returns the empty
|
||||
/// vec for tools that don't modify files. We intentionally only handle the
|
||||
/// three known edit tools — adding more (e.g. specialized refactor tools)
|
||||
/// is a one-line change here.
|
||||
fn edited_paths_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<PathBuf> {
|
||||
match tool_name {
|
||||
"edit_file" | "write_file" => {
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
vec![PathBuf::from(path)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
"apply_patch" => {
|
||||
// `apply_patch` accepts either a `path` override or a list of
|
||||
// `files` (each `{path, content}`). We try both shapes.
|
||||
let mut out = Vec::new();
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
if let Some(files) = input.get("files").and_then(|v| v.as_array()) {
|
||||
for entry in files {
|
||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: parse `---`/`+++` headers from a unified diff payload.
|
||||
if out.is_empty()
|
||||
&& let Some(patch) = input.get("patch").and_then(|v| v.as_str())
|
||||
{
|
||||
out.extend(parse_patch_paths(patch));
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight parser for `+++ b/<path>` lines in a unified diff. Used as a
|
||||
/// fallback when `apply_patch` is invoked with raw `patch` text and no
|
||||
/// `path`/`files` override. We deliberately keep this dumb — the real
|
||||
/// `apply_patch` tool already validates the patch shape; we only need a
|
||||
/// best-effort hint for the LSP hook.
|
||||
fn parse_patch_paths(patch: &str) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for line in patch.lines() {
|
||||
if let Some(rest) = line.strip_prefix("+++ ") {
|
||||
let trimmed = rest.trim();
|
||||
// Strip leading `b/` per git diff conventions.
|
||||
let path = trimmed.strip_prefix("b/").unwrap_or(trimmed);
|
||||
// Skip `/dev/null` (deletion).
|
||||
if path == "/dev/null" {
|
||||
continue;
|
||||
}
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn caller_allowed_for_tool(caller: Option<&ToolCaller>, tool_def: Option<&Tool>) -> bool {
|
||||
let requested = caller_type_for_tool_use(caller);
|
||||
if let Some(def) = tool_def
|
||||
@@ -1182,6 +1254,11 @@ impl Engine {
|
||||
let shell_manager = new_shared_shell_manager(config.workspace.clone());
|
||||
let capacity_controller = CapacityController::new(config.capacity.clone());
|
||||
|
||||
let lsp_manager = Arc::new(match config.lsp_config.clone() {
|
||||
Some(cfg) => crate::lsp::LspManager::new(cfg, config.workspace.clone()),
|
||||
None => crate::lsp::LspManager::disabled(),
|
||||
});
|
||||
|
||||
let mut engine = Engine {
|
||||
config,
|
||||
deepseek_client,
|
||||
@@ -1201,6 +1278,8 @@ impl Engine {
|
||||
capacity_controller,
|
||||
coherence_state: CoherenceState::default(),
|
||||
turn_counter: 0,
|
||||
lsp_manager,
|
||||
pending_lsp_blocks: Vec::new(),
|
||||
};
|
||||
engine.rehydrate_latest_canonical_state();
|
||||
|
||||
@@ -1416,6 +1495,57 @@ impl Engine {
|
||||
self.emit_session_updated().await;
|
||||
}
|
||||
|
||||
/// #136: post-edit hook. Inspects the tool name + input, derives the
|
||||
/// edited file path, and asks the LSP manager for diagnostics. The
|
||||
/// rendered block is queued in `pending_lsp_blocks` and flushed to the
|
||||
/// session message stream just before the next API request. Failure is
|
||||
/// silent by design — a missing/crashing LSP server must never block
|
||||
/// the agent.
|
||||
async fn run_post_edit_lsp_hook(&mut self, tool_name: &str, tool_input: &serde_json::Value) {
|
||||
if !self.lsp_manager.config().enabled {
|
||||
return;
|
||||
}
|
||||
let paths = edited_paths_for_tool(tool_name, tool_input);
|
||||
for path in paths {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.session.workspace.join(&path)
|
||||
};
|
||||
// Use a short edit-sequence based on the existing turn counter so
|
||||
// log output stays correlated even though we do not currently
|
||||
// batch by sequence.
|
||||
let seq = self.turn_counter;
|
||||
if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await {
|
||||
self.pending_lsp_blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain `pending_lsp_blocks` into a single synthetic user message so the
|
||||
/// model sees the diagnostics on its next request. Skips when nothing is
|
||||
/// pending. The message uses the standard `text` content block shape
|
||||
/// (the same shape as the post-tool steer messages) so we don't need to
|
||||
/// invent a new envelope.
|
||||
async fn flush_pending_lsp_diagnostics(&mut self) {
|
||||
if self.pending_lsp_blocks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let blocks = std::mem::take(&mut self.pending_lsp_blocks);
|
||||
let rendered = crate::lsp::render_blocks(&blocks);
|
||||
if rendered.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: rendered,
|
||||
cache_control: None,
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Handle a send message operation
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_send_message(
|
||||
@@ -2447,6 +2577,11 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
// #136: drain any LSP diagnostics collected since the last
|
||||
// request and inject them as a synthetic user message so the
|
||||
// model sees compile errors before its next reasoning step.
|
||||
self.flush_pending_lsp_diagnostics().await;
|
||||
|
||||
// Build the request
|
||||
let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty();
|
||||
let active_tools = if tool_catalog.is_empty() {
|
||||
@@ -3636,6 +3771,16 @@ impl Engine {
|
||||
Some(&output_for_context),
|
||||
&self.session.workspace,
|
||||
);
|
||||
|
||||
// #136: post-edit LSP diagnostics hook. We only run
|
||||
// this on success — failed edits leave the file
|
||||
// untouched, so polling for diagnostics would just
|
||||
// surface stale state.
|
||||
if output.success {
|
||||
self.run_post_edit_lsp_hook(&outcome.name, &tool_input)
|
||||
.await;
|
||||
}
|
||||
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
|
||||
@@ -1118,3 +1118,164 @@ fn capacity_escalation_skips_pure_transient_categories() {
|
||||
});
|
||||
assert!(only_transient);
|
||||
}
|
||||
|
||||
// ── #136: post-edit LSP diagnostics hook ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn edited_paths_for_edit_file_returns_path() {
|
||||
let input = json!({ "path": "src/foo.rs", "search": "x", "replace": "y" });
|
||||
let paths = edited_paths_for_tool("edit_file", &input);
|
||||
assert_eq!(paths, vec![PathBuf::from("src/foo.rs")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_paths_for_write_file_returns_path() {
|
||||
let input = json!({ "path": "src/bar.rs", "content": "fn main() {}" });
|
||||
let paths = edited_paths_for_tool("write_file", &input);
|
||||
assert_eq!(paths, vec![PathBuf::from("src/bar.rs")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_paths_for_apply_patch_with_files_returns_each_path() {
|
||||
let input = json!({
|
||||
"files": [
|
||||
{ "path": "a.rs", "content": "" },
|
||||
{ "path": "b.rs", "content": "" }
|
||||
]
|
||||
});
|
||||
let paths = edited_paths_for_tool("apply_patch", &input);
|
||||
assert_eq!(paths, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_paths_for_apply_patch_with_diff_text_extracts_paths() {
|
||||
let input = json!({
|
||||
"patch": "--- a/foo.rs\n+++ b/foo.rs\n@@ -1 +1 @@\n-let x: i32 = 0;\n+let x: i32 = \"oops\";\n"
|
||||
});
|
||||
let paths = edited_paths_for_tool("apply_patch", &input);
|
||||
assert_eq!(paths, vec![PathBuf::from("foo.rs")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_paths_for_unknown_tool_returns_empty() {
|
||||
let input = json!({ "path": "irrelevant.rs" });
|
||||
let paths = edited_paths_for_tool("read_file", &input);
|
||||
assert!(paths.is_empty());
|
||||
let paths = edited_paths_for_tool("grep_files", &input);
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_patch_paths_skips_dev_null() {
|
||||
let patch = "--- a/keep.rs\n+++ b/keep.rs\n--- a/deleted.rs\n+++ /dev/null\n";
|
||||
let paths = parse_patch_paths(patch);
|
||||
assert_eq!(paths, vec![PathBuf::from("keep.rs")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_edit_hook_injects_diagnostics_message_before_next_request() {
|
||||
use crate::lsp::{Diagnostic, Language, Severity};
|
||||
use std::sync::Arc;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let target = workspace.join("src").join("main.rs");
|
||||
fs::create_dir_all(workspace.join("src")).unwrap();
|
||||
fs::write(&target, "let x: i32 = \"not a number\";").unwrap();
|
||||
|
||||
let lsp_config = crate::lsp::LspConfig::default();
|
||||
let engine_config = EngineConfig {
|
||||
workspace: workspace.clone(),
|
||||
lsp_config: Some(lsp_config),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
|
||||
|
||||
// Install a fake transport that always reports a type error.
|
||||
let fake = Arc::new(crate::lsp::tests::FakeTransport::new(vec![Diagnostic {
|
||||
line: 1,
|
||||
column: 14,
|
||||
severity: Severity::Error,
|
||||
message: "expected i32, found &str".to_string(),
|
||||
}]));
|
||||
engine
|
||||
.lsp_manager
|
||||
.install_test_transport(Language::Rust, fake)
|
||||
.await;
|
||||
|
||||
// Simulate the success path of an edit_file tool call.
|
||||
let input = json!({ "path": "src/main.rs", "search": "0", "replace": "\"not a number\"" });
|
||||
engine.run_post_edit_lsp_hook("edit_file", &input).await;
|
||||
assert_eq!(engine.pending_lsp_blocks.len(), 1);
|
||||
|
||||
// Flush prepares the synthetic message.
|
||||
let messages_before = engine.session.messages.len();
|
||||
engine.flush_pending_lsp_diagnostics().await;
|
||||
assert_eq!(engine.session.messages.len(), messages_before + 1);
|
||||
|
||||
let last = engine.session.messages.last().expect("message appended");
|
||||
assert_eq!(last.role, "user");
|
||||
let text = match &last.content[0] {
|
||||
crate::models::ContentBlock::Text { text, .. } => text.clone(),
|
||||
other => panic!("expected text block, got {other:?}"),
|
||||
};
|
||||
assert!(text.contains("<diagnostics file=\""));
|
||||
assert!(text.contains("ERROR [1:14] expected i32, found &str"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_edit_hook_is_silent_when_lsp_disabled() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let target = workspace.join("src").join("main.rs");
|
||||
fs::create_dir_all(workspace.join("src")).unwrap();
|
||||
fs::write(&target, "fn main() {}").unwrap();
|
||||
|
||||
let lsp_config = crate::lsp::LspConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let engine_config = EngineConfig {
|
||||
workspace: workspace.clone(),
|
||||
lsp_config: Some(lsp_config),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
|
||||
|
||||
let input = json!({ "path": "src/main.rs", "search": "x", "replace": "y" });
|
||||
engine.run_post_edit_lsp_hook("edit_file", &input).await;
|
||||
assert!(engine.pending_lsp_blocks.is_empty());
|
||||
|
||||
let messages_before = engine.session.messages.len();
|
||||
engine.flush_pending_lsp_diagnostics().await;
|
||||
assert_eq!(engine.session.messages.len(), messages_before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_edit_hook_skips_unknown_tool_names() {
|
||||
use crate::lsp::{Diagnostic, Language, Severity};
|
||||
use std::sync::Arc;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let engine_config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
lsp_config: Some(crate::lsp::LspConfig::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
|
||||
let fake = Arc::new(crate::lsp::tests::FakeTransport::new(vec![Diagnostic {
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "should not be reported".to_string(),
|
||||
}]));
|
||||
engine
|
||||
.lsp_manager
|
||||
.install_test_transport(Language::Rust, fake.clone())
|
||||
.await;
|
||||
|
||||
let input = json!({ "path": "src/main.rs" });
|
||||
engine.run_post_edit_lsp_hook("read_file", &input).await;
|
||||
assert!(engine.pending_lsp_blocks.is_empty());
|
||||
assert_eq!(fake.call_count(), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Thin JSON-RPC over stdio client for LSP servers.
|
||||
//!
|
||||
//! We deliberately do **not** depend on `tower-lsp` — it is a server-side
|
||||
//! framework and dragging it in here would add hundreds of unnecessary
|
||||
//! transitive dependencies and slow down `cargo build` for every contributor.
|
||||
//! The LSP wire protocol is small enough that handling it ourselves is a
|
||||
//! self-contained ~400 LOC and lets us keep total control of the spawn
|
||||
//! lifecycle, timeouts, and the async surface.
|
||||
//!
|
||||
//! Architecture:
|
||||
//!
|
||||
//! - [`LspTransport`] is the trait the [`super::LspManager`] talks to. The
|
||||
//! real implementation is [`StdioLspTransport`] (forks an LSP server with
|
||||
//! `tokio::process::Command`); tests use `super::tests::FakeTransport`.
|
||||
//! - [`StdioLspTransport`] runs three tokio tasks: a reader, a writer, and
|
||||
//! the public API. Communication uses tokio mpsc channels.
|
||||
//! - We parse `Content-Length`-framed JSON-RPC and route inbound messages
|
||||
//! either to a per-request response slot (for replies) or to the
|
||||
//! diagnostics queue (for `textDocument/publishDiagnostics` notifications).
|
||||
//!
|
||||
//! The transport is one-shot per file in MVP form: the manager spawns a
|
||||
//! transport on demand for a language and reuses it. We do not implement
|
||||
//! workspace sync beyond didOpen/didChange because the goal is "post-edit
|
||||
//! diagnostics," not full IDE smartness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::diagnostics::{Diagnostic, Severity};
|
||||
use super::registry::Language;
|
||||
|
||||
/// Trait the LSP manager talks to. A real LSP server speaks this via stdio;
|
||||
/// tests use an in-process fake.
|
||||
#[async_trait]
|
||||
pub trait LspTransport: Send + Sync {
|
||||
/// Notify the server that a file was opened or its contents updated, then
|
||||
/// wait up to `wait` for a `publishDiagnostics` notification for that
|
||||
/// file. Returns the diagnostics list (possibly empty). Implementations
|
||||
/// must NOT block past `wait`.
|
||||
async fn diagnostics_for(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: &str,
|
||||
wait: Duration,
|
||||
) -> Result<Vec<Diagnostic>>;
|
||||
|
||||
/// Best-effort shutdown. Called via `LspManager::shutdown_all`.
|
||||
#[allow(dead_code)]
|
||||
async fn shutdown(&self);
|
||||
}
|
||||
|
||||
/// Stdio-backed transport. Spawns the LSP server as a child process and
|
||||
/// pipes JSON-RPC over stdin/stdout. Stderr is captured into a buffer so
|
||||
/// callers can include it in error messages without polluting our own stderr.
|
||||
pub struct StdioLspTransport {
|
||||
/// JoinHandle for the running server. Held so the child stays alive for
|
||||
/// the transport's lifetime; consumed during `shutdown`.
|
||||
#[allow(dead_code)]
|
||||
child: AsyncMutex<Option<Child>>,
|
||||
/// Outgoing message sender to the writer task.
|
||||
tx_outbound: mpsc::Sender<Vec<u8>>,
|
||||
/// Inbound diagnostics queue. We push every `publishDiagnostics`
|
||||
/// notification into here and the public API drains the relevant entries.
|
||||
diagnostics_rx: AsyncMutex<mpsc::Receiver<(PathBuf, Vec<Diagnostic>)>>,
|
||||
/// Map of in-flight request id -> reply slot. We do not currently call
|
||||
/// methods that need replies after `initialize`, but this is the hook
|
||||
/// for it.
|
||||
#[allow(dead_code)]
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
/// Monotonic request id counter. Reserved for future LSP request/reply
|
||||
/// methods (workspace symbol queries, etc.).
|
||||
#[allow(dead_code)]
|
||||
next_id: AsyncMutex<i64>,
|
||||
/// Language id passed in `textDocument/didOpen` (e.g. "rust").
|
||||
language_id: &'static str,
|
||||
/// Track which files we have opened so the second touch sends
|
||||
/// `didChange` instead of `didOpen`.
|
||||
opened: AsyncMutex<HashMap<PathBuf, i64>>,
|
||||
}
|
||||
|
||||
impl StdioLspTransport {
|
||||
/// Spawn `command args…` and run the LSP `initialize` handshake. Returns
|
||||
/// `Err` immediately if the binary is not on PATH or `initialize` fails.
|
||||
pub async fn spawn(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
language: Language,
|
||||
workspace: PathBuf,
|
||||
) -> Result<Self> {
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn LSP server `{command}`"))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("LSP child has no stdin handle")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("LSP child has no stdout handle")?;
|
||||
|
||||
let (tx_outbound, rx_outbound) = mpsc::channel::<Vec<u8>>(64);
|
||||
let (tx_inbound, rx_inbound) = mpsc::channel::<Value>(64);
|
||||
let (tx_diag, rx_diag) = mpsc::channel::<(PathBuf, Vec<Diagnostic>)>(64);
|
||||
|
||||
// Writer task: drain outbound channel, frame with Content-Length, write to stdin.
|
||||
tokio::spawn(writer_task(stdin, rx_outbound));
|
||||
// Reader task: parse Content-Length frames from stdout, push to inbound queue.
|
||||
tokio::spawn(reader_task(stdout, tx_inbound));
|
||||
// Inbound dispatcher: routes notifications to `tx_diag`, replies to a
|
||||
// pending map. We keep the pending map for completeness even though
|
||||
// diagnostics polling itself does not reuse it.
|
||||
let pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>> =
|
||||
Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
tokio::spawn(dispatcher_task(rx_inbound, tx_diag, pending.clone()));
|
||||
|
||||
// Send `initialize` and wait for `initialized`. We synthesize id=1.
|
||||
let init_payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"processId": std::process::id(),
|
||||
"rootUri": uri_from_path(&workspace),
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"publishDiagnostics": { "relatedInformation": false }
|
||||
}
|
||||
},
|
||||
"workspaceFolders": [{
|
||||
"uri": uri_from_path(&workspace),
|
||||
"name": "workspace"
|
||||
}]
|
||||
}
|
||||
});
|
||||
send_message(&tx_outbound, &init_payload).await?;
|
||||
|
||||
// We do not actually wait for the initialize response here in MVP —
|
||||
// most servers buffer notifications until they are ready, and waiting
|
||||
// for `initialize` reply doubles the latency of the first edit. Send
|
||||
// `initialized` immediately and let publishDiagnostics arrive on its
|
||||
// own clock.
|
||||
let initialized = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialized",
|
||||
"params": {}
|
||||
});
|
||||
send_message(&tx_outbound, &initialized).await?;
|
||||
|
||||
Ok(Self {
|
||||
child: AsyncMutex::new(Some(child)),
|
||||
tx_outbound,
|
||||
diagnostics_rx: AsyncMutex::new(rx_diag),
|
||||
pending,
|
||||
next_id: AsyncMutex::new(2),
|
||||
language_id: language.language_id(),
|
||||
opened: AsyncMutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LspTransport for StdioLspTransport {
|
||||
async fn diagnostics_for(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: &str,
|
||||
wait: Duration,
|
||||
) -> Result<Vec<Diagnostic>> {
|
||||
let path_buf = path.to_path_buf();
|
||||
let uri = uri_from_path(&path_buf);
|
||||
|
||||
// Either send didOpen (first time) or didChange (subsequent edits).
|
||||
let mut opened = self.opened.lock().await;
|
||||
let is_new = !opened.contains_key(&path_buf);
|
||||
let new_version = opened.get(&path_buf).copied().unwrap_or(0) + 1;
|
||||
opened.insert(path_buf.clone(), new_version);
|
||||
drop(opened);
|
||||
|
||||
let payload = if is_new {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": {
|
||||
"textDocument": {
|
||||
"uri": uri.clone(),
|
||||
"languageId": self.language_id,
|
||||
"version": new_version,
|
||||
"text": text
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didChange",
|
||||
"params": {
|
||||
"textDocument": {
|
||||
"uri": uri.clone(),
|
||||
"version": new_version
|
||||
},
|
||||
"contentChanges": [{ "text": text }]
|
||||
}
|
||||
})
|
||||
};
|
||||
send_message(&self.tx_outbound, &payload).await?;
|
||||
|
||||
// Drain matching `publishDiagnostics` notifications until `wait`
|
||||
// elapses. Servers typically publish within a few hundred ms; for
|
||||
// initial cold-start (rust-analyzer) it can be many seconds — but
|
||||
// the manager guards us with a separate timeout.
|
||||
let deadline = tokio::time::Instant::now() + wait;
|
||||
let mut latest: Option<Vec<Diagnostic>> = None;
|
||||
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
let remaining = deadline - now;
|
||||
let mut rx = self.diagnostics_rx.lock().await;
|
||||
let next = match timeout(remaining, rx.recv()).await {
|
||||
Ok(Some(item)) => item,
|
||||
Ok(None) => break, // channel closed
|
||||
Err(_) => break, // timed out
|
||||
};
|
||||
drop(rx);
|
||||
let (file, items) = next;
|
||||
if file == path_buf {
|
||||
latest = Some(items);
|
||||
// We have a payload — return immediately. If the server
|
||||
// re-publishes after rapid edits, the next call will sync.
|
||||
break;
|
||||
}
|
||||
// Otherwise: notification was for a different file we previously
|
||||
// opened. Discard and continue waiting.
|
||||
}
|
||||
Ok(latest.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
let mut child = self.child.lock().await;
|
||||
if let Some(mut c) = child.take() {
|
||||
let _ = c.start_kill();
|
||||
let _ = c.wait().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
|
||||
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
|
||||
let body = serde_json::to_vec(value).context("serialize LSP message")?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
let mut frame = Vec::with_capacity(header.len() + body.len());
|
||||
frame.extend_from_slice(header.as_bytes());
|
||||
frame.extend_from_slice(&body);
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
/// the LSP server's stdin. Exits cleanly when the channel closes.
|
||||
async fn writer_task(mut stdin: tokio::process::ChildStdin, mut rx: mpsc::Receiver<Vec<u8>>) {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
if stdin.write_all(&frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task that parses `Content-Length`-framed JSON-RPC frames from
|
||||
/// the LSP server's stdout. Pushes each parsed JSON value to `tx`. Exits
|
||||
/// when stdout closes or a frame is malformed (we choose to fail closed
|
||||
/// rather than risk hanging).
|
||||
async fn reader_task(mut stdout: tokio::process::ChildStdout, tx: mpsc::Sender<Value>) {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
|
||||
let mut tmp = [0u8; 4096];
|
||||
loop {
|
||||
let n = match stdout.read(&mut tmp).await {
|
||||
Ok(0) => return,
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
// Try to parse as many frames as we can from the accumulated buffer.
|
||||
while let Some((header_end, content_length)) = parse_header(&buf) {
|
||||
if buf.len() < header_end + content_length {
|
||||
break; // need more bytes
|
||||
}
|
||||
let body = &buf[header_end..header_end + content_length];
|
||||
let parsed = serde_json::from_slice::<Value>(body).ok();
|
||||
// Drop the consumed bytes regardless of parse result so a bad frame
|
||||
// does not stall the loop.
|
||||
buf.drain(..header_end + content_length);
|
||||
if let Some(value) = parsed
|
||||
&& tx.send(value).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JSON-RPC header block. Returns `Some((header_end, content_length))`
|
||||
/// where `header_end` is the byte offset of the first body byte. The header
|
||||
/// terminator is `\r\n\r\n`. We require a `Content-Length` header.
|
||||
fn parse_header(buf: &[u8]) -> Option<(usize, usize)> {
|
||||
let term = b"\r\n\r\n";
|
||||
let pos = buf.windows(term.len()).position(|window| window == term)?;
|
||||
let header = std::str::from_utf8(&buf[..pos]).ok()?;
|
||||
let mut content_length: Option<usize> = None;
|
||||
for line in header.split("\r\n") {
|
||||
if let Some(rest) = line.strip_prefix("Content-Length:") {
|
||||
content_length = rest.trim().parse::<usize>().ok();
|
||||
}
|
||||
}
|
||||
content_length.map(|cl| (pos + term.len(), cl))
|
||||
}
|
||||
|
||||
/// Background task that consumes inbound JSON values, classifies them as
|
||||
/// notifications/responses, and routes accordingly.
|
||||
async fn dispatcher_task(
|
||||
mut rx: mpsc::Receiver<Value>,
|
||||
tx_diag: mpsc::Sender<(PathBuf, Vec<Diagnostic>)>,
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
) {
|
||||
while let Some(value) = rx.recv().await {
|
||||
// Notifications have a `method` and no `id`.
|
||||
let method = value.get("method").and_then(|v| v.as_str());
|
||||
if method == Some("textDocument/publishDiagnostics") {
|
||||
if let Some((path, diags)) = parse_publish_diagnostics(&value) {
|
||||
let _ = tx_diag.send((path, diags)).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Replies have an `id` and a `result` or `error`.
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
|
||||
let mut map = pending.lock().await;
|
||||
if let Some(slot) = map.remove(&id) {
|
||||
let _ = slot.send(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a `textDocument/publishDiagnostics` notification.
|
||||
fn parse_publish_diagnostics(value: &Value) -> Option<(PathBuf, Vec<Diagnostic>)> {
|
||||
let params = value.get("params")?;
|
||||
let uri = params.get("uri")?.as_str()?;
|
||||
let path = path_from_uri(uri)?;
|
||||
let raw = params.get("diagnostics")?.as_array()?;
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for d in raw {
|
||||
let range = d.get("range")?;
|
||||
let start = range.get("start")?;
|
||||
let line = start.get("line")?.as_u64()? as u32 + 1;
|
||||
let column = start.get("character")?.as_u64()? as u32 + 1;
|
||||
let severity = Severity::from_lsp(d.get("severity").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(Severity::Error);
|
||||
let message = d
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
out.push(Diagnostic {
|
||||
line,
|
||||
column,
|
||||
severity,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Some((path, out))
|
||||
}
|
||||
|
||||
/// Convert a filesystem path to a `file://` URI. Best-effort — we do not
|
||||
/// support Windows drive letters perfectly, but the LSP servers in our
|
||||
/// registry accept percent-encoded paths well enough for the post-edit
|
||||
/// diagnostics use case.
|
||||
fn uri_from_path(path: &Path) -> String {
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let s = canonical.to_string_lossy();
|
||||
if s.starts_with('/') {
|
||||
format!("file://{s}")
|
||||
} else {
|
||||
format!("file:///{}", s.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`uri_from_path`]. Returns `None` when the URI is not a `file://`.
|
||||
fn path_from_uri(uri: &str) -> Option<PathBuf> {
|
||||
let stripped = uri.strip_prefix("file://")?;
|
||||
Some(PathBuf::from(stripped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_lsp_header() {
|
||||
let frame = b"Content-Length: 5\r\n\r\nhello";
|
||||
let (end, len) = parse_header(frame).expect("header parses");
|
||||
assert_eq!(end, 21);
|
||||
assert_eq!(len, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_header_returns_none_when_truncated() {
|
||||
let frame = b"Content-Length: 5\r\nMissingTerm";
|
||||
assert!(parse_header(frame).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_publish_diagnostics_payload() {
|
||||
let payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {
|
||||
"uri": "file:///tmp/foo.rs",
|
||||
"diagnostics": [
|
||||
{
|
||||
"range": {
|
||||
"start": { "line": 11, "character": 7 },
|
||||
"end": { "line": 11, "character": 8 }
|
||||
},
|
||||
"severity": 1,
|
||||
"message": "missing semicolon"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let (path, diags) = parse_publish_diagnostics(&payload).expect("parses");
|
||||
assert_eq!(path, PathBuf::from("/tmp/foo.rs"));
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].line, 12);
|
||||
assert_eq!(diags[0].column, 8);
|
||||
assert_eq!(diags[0].severity, Severity::Error);
|
||||
assert_eq!(diags[0].message, "missing semicolon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_uri_path() {
|
||||
let path = PathBuf::from("/tmp/example/foo.rs");
|
||||
let uri = format!("file://{}", path.display());
|
||||
assert_eq!(path_from_uri(&uri), Some(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Diagnostic shape returned by the LSP transport, plus the renderer that
|
||||
//! produces the `<diagnostics file="…">` block injected into the model
|
||||
//! context after a file edit.
|
||||
//!
|
||||
//! Format (matches the spec given in issue #136):
|
||||
//!
|
||||
//! ```text
|
||||
//! <diagnostics file="crates/tui/src/foo.rs">
|
||||
//! ERROR [12:8] missing semicolon
|
||||
//! ERROR [13:1] expected `,`, found `}`
|
||||
//! </diagnostics>
|
||||
//! ```
|
||||
//!
|
||||
//! Lines are 1-based. Columns are 1-based. We trim each diagnostic message
|
||||
//! to a single line so the block stays compact.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Severity bucket used in the rendered block. Mirrors the LSP severity
|
||||
/// codes (1 = Error, 2 = Warning, 3 = Information, 4 = Hint).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
Information,
|
||||
Hint,
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
/// Decode the LSP integer severity. Returns `None` when the integer is
|
||||
/// missing or unrecognized — callers default to `Error` to err on the
|
||||
/// side of surfacing the issue.
|
||||
#[must_use]
|
||||
pub fn from_lsp(code: Option<i64>) -> Option<Self> {
|
||||
match code? {
|
||||
1 => Some(Severity::Error),
|
||||
2 => Some(Severity::Warning),
|
||||
3 => Some(Severity::Information),
|
||||
4 => Some(Severity::Hint),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase label used in the rendered block.
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Severity::Error => "ERROR",
|
||||
Severity::Warning => "WARNING",
|
||||
Severity::Information => "INFO",
|
||||
Severity::Hint => "HINT",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One LSP diagnostic, normalized to 1-based line/col so we can render it
|
||||
/// directly. The transport layer is responsible for the `0-based -> 1-based`
|
||||
/// conversion.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
pub severity: Severity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
/// Trim the message to a single line for compact rendering.
|
||||
fn render_message(&self) -> String {
|
||||
let first_line = self.message.lines().next().unwrap_or("").trim();
|
||||
first_line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's worth of diagnostics, ready to render. The renderer caps the
|
||||
/// list to `max_per_file` items.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticBlock {
|
||||
/// Path used inside the `file="…"` attribute. Should be relative to the
|
||||
/// workspace root when possible (we use `path.file_name()` if relativizing
|
||||
/// fails, per the issue's hard rule).
|
||||
pub file: PathBuf,
|
||||
pub items: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl DiagnosticBlock {
|
||||
/// Render the block in the format pasted in the module docs. Returns the
|
||||
/// empty string when `self.items` is empty so callers can `if !text.is_empty()`
|
||||
/// before injecting.
|
||||
#[must_use]
|
||||
pub fn render(&self) -> String {
|
||||
if self.items.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let file_attr = self.file.display();
|
||||
let mut out = format!("<diagnostics file=\"{file_attr}\">\n");
|
||||
for item in &self.items {
|
||||
out.push_str(&format!(
|
||||
" {} [{}:{}] {}\n",
|
||||
item.severity.label(),
|
||||
item.line,
|
||||
item.column,
|
||||
item.render_message(),
|
||||
));
|
||||
}
|
||||
out.push_str("</diagnostics>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncate to at most `max_per_file` items, preserving order. The LSP
|
||||
/// manager is responsible for sorting by severity before calling this so
|
||||
/// errors are kept ahead of warnings when truncation happens.
|
||||
pub fn truncate(&mut self, max_per_file: usize) {
|
||||
if self.items.len() > max_per_file {
|
||||
self.items.truncate(max_per_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a list of [`DiagnosticBlock`]s as a single bundle. Used by the
|
||||
/// engine when one turn touched several files. Empty blocks are skipped.
|
||||
#[must_use]
|
||||
pub fn render_blocks(blocks: &[DiagnosticBlock]) -> String {
|
||||
let mut chunks = Vec::new();
|
||||
for block in blocks {
|
||||
let rendered = block.render();
|
||||
if !rendered.is_empty() {
|
||||
chunks.push(rendered);
|
||||
}
|
||||
}
|
||||
chunks.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn severity_decodes_lsp_codes() {
|
||||
assert_eq!(Severity::from_lsp(Some(1)), Some(Severity::Error));
|
||||
assert_eq!(Severity::from_lsp(Some(2)), Some(Severity::Warning));
|
||||
assert_eq!(Severity::from_lsp(Some(3)), Some(Severity::Information));
|
||||
assert_eq!(Severity::from_lsp(Some(4)), Some(Severity::Hint));
|
||||
assert_eq!(Severity::from_lsp(Some(99)), None);
|
||||
assert_eq!(Severity::from_lsp(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_block_in_required_format() {
|
||||
let block = DiagnosticBlock {
|
||||
file: PathBuf::from("crates/tui/src/foo.rs"),
|
||||
items: vec![
|
||||
Diagnostic {
|
||||
line: 12,
|
||||
column: 8,
|
||||
severity: Severity::Error,
|
||||
message: "missing semicolon".to_string(),
|
||||
},
|
||||
Diagnostic {
|
||||
line: 13,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "expected `,`, found `}`".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let rendered = block.render();
|
||||
assert!(rendered.contains("<diagnostics file=\"crates/tui/src/foo.rs\">"));
|
||||
assert!(rendered.contains("ERROR [12:8] missing semicolon"));
|
||||
assert!(rendered.contains("ERROR [13:1] expected `,`, found `}`"));
|
||||
assert!(rendered.ends_with("</diagnostics>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_block_renders_to_empty_string() {
|
||||
let block = DiagnosticBlock {
|
||||
file: PathBuf::from("foo.rs"),
|
||||
items: Vec::new(),
|
||||
};
|
||||
assert!(block.render().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_caps_to_max() {
|
||||
let mut block = DiagnosticBlock {
|
||||
file: PathBuf::from("foo.rs"),
|
||||
items: (0..30)
|
||||
.map(|i| Diagnostic {
|
||||
line: i,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: format!("err {i}"),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
block.truncate(20);
|
||||
assert_eq!(block.items.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_only_first_line_of_message() {
|
||||
let block = DiagnosticBlock {
|
||||
file: PathBuf::from("foo.rs"),
|
||||
items: vec![Diagnostic {
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "first line\nsecond line\nthird".to_string(),
|
||||
}],
|
||||
};
|
||||
let rendered = block.render();
|
||||
assert!(rendered.contains("first line"));
|
||||
assert!(!rendered.contains("second line"));
|
||||
assert!(!rendered.contains("third"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! LSP integration: post-edit diagnostics injection (#136).
|
||||
//!
|
||||
//! After the agent performs a successful file edit (`edit_file`,
|
||||
//! `apply_patch`, or `write_file`) the engine asks the [`LspManager`] for
|
||||
//! diagnostics on that file. The manager spawns the appropriate LSP server
|
||||
//! lazily on first use, sends `didOpen`/`didChange`, waits up to a bounded
|
||||
//! timeout for `publishDiagnostics`, normalizes the result, and returns it
|
||||
//! to the engine.
|
||||
//!
|
||||
//! Failure modes are non-blocking by design: a missing LSP binary, a
|
||||
//! crashed server, or a timeout all degrade to "no diagnostics this turn"
|
||||
//! rather than stalling the agent. We log a one-time warning per language
|
||||
//! when the binary is missing.
|
||||
//!
|
||||
//! # Wiring
|
||||
//!
|
||||
//! ```text
|
||||
//! Engine ── after successful edit ──▶ LspManager.diagnostics_for(path, seq)
|
||||
//! │
|
||||
//! ▼
|
||||
//! per-language LspClient
|
||||
//! │
|
||||
//! ▼
|
||||
//! LspTransport (stdio)
|
||||
//! ```
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! The `[lsp]` table in `~/.deepseek/config.toml` controls behavior:
|
||||
//! `enabled`, `poll_after_edit_ms`, `max_diagnostics_per_file`,
|
||||
//! `include_warnings`, and an optional `servers` override. See
|
||||
//! [`LspConfig`] for defaults and `config.example.toml` for documentation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub mod client;
|
||||
pub mod diagnostics;
|
||||
pub mod registry;
|
||||
|
||||
pub use client::{LspTransport, StdioLspTransport};
|
||||
pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
|
||||
pub use registry::Language;
|
||||
|
||||
/// `[lsp]` config schema. Mirrors the TOML keys documented in
|
||||
/// `config.example.toml`. Unknown keys are ignored.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct LspConfig {
|
||||
/// Master switch. When `false`, the manager skips every operation and
|
||||
/// returns an empty diagnostics list.
|
||||
pub enabled: bool,
|
||||
/// Maximum time in milliseconds to wait for the LSP server to publish
|
||||
/// diagnostics after a `didOpen`/`didChange`. Default 5000 ms.
|
||||
pub poll_after_edit_ms: u64,
|
||||
/// Maximum diagnostics to keep per file. Excess items are dropped after
|
||||
/// sorting by severity. Default 20.
|
||||
pub max_diagnostics_per_file: usize,
|
||||
/// When `true`, warnings (severity 2) are kept in the output. When
|
||||
/// `false` (default), only errors (severity 1) are surfaced.
|
||||
pub include_warnings: bool,
|
||||
/// Optional override for the `Language -> (cmd, args)` table. Keys use
|
||||
/// [`Language::as_key`] (e.g. `"rust"`).
|
||||
pub servers: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for LspConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
poll_after_edit_ms: 5_000,
|
||||
max_diagnostics_per_file: 20,
|
||||
include_warnings: false,
|
||||
servers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LspConfig {
|
||||
/// Resolve `(command, args)` for `lang`. User-supplied overrides take
|
||||
/// precedence over the built-in registry.
|
||||
fn resolve_command(&self, lang: Language) -> Option<(String, Vec<String>)> {
|
||||
if let Some(parts) = self.servers.get(lang.as_key())
|
||||
&& let Some((first, rest)) = parts.split_first()
|
||||
{
|
||||
return Some((first.clone(), rest.to_vec()));
|
||||
}
|
||||
let (cmd, args) = registry::server_for(lang)?;
|
||||
Some((
|
||||
cmd.to_string(),
|
||||
args.iter().map(|a| (*a).to_string()).collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The LspManager holds a lazily populated map of `Language -> Transport`.
|
||||
/// One transport is reused across files of the same language for the
|
||||
/// session's lifetime.
|
||||
pub struct LspManager {
|
||||
config: LspConfig,
|
||||
workspace: PathBuf,
|
||||
/// Per-language transports. Wrapped in `Arc` so we can release the outer
|
||||
/// lock before driving I/O on a single transport.
|
||||
transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
|
||||
/// Per-language "we already warned the user that the binary is missing"
|
||||
/// guard so we do not spam the audit log on every edit.
|
||||
missing_warned: AsyncMutex<HashSet<Language>>,
|
||||
/// Test seam: when set, `diagnostics_for` uses these instead of spawning
|
||||
/// real LSP processes. Keyed by language.
|
||||
test_transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// Build a new manager. Does not spawn any LSP servers — that is lazy.
|
||||
#[must_use]
|
||||
pub fn new(config: LspConfig, workspace: PathBuf) -> Self {
|
||||
Self {
|
||||
config,
|
||||
workspace,
|
||||
transports: AsyncMutex::new(HashMap::new()),
|
||||
missing_warned: AsyncMutex::new(HashSet::new()),
|
||||
test_transports: AsyncMutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only access to the resolved config. Used by the engine to skip
|
||||
/// the post-edit hook entirely when `enabled = false`.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &LspConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Inject a fake transport for a language. Used by tests so we never
|
||||
/// fork a real LSP server in CI.
|
||||
#[cfg(test)]
|
||||
pub async fn install_test_transport(&self, lang: Language, transport: Arc<dyn LspTransport>) {
|
||||
self.test_transports.lock().await.insert(lang, transport);
|
||||
}
|
||||
|
||||
/// Poll the LSP server for diagnostics on `file`. Returns the rendered
|
||||
/// [`DiagnosticBlock`] (already truncated to the configured per-file
|
||||
/// max) or `None` when the manager is disabled / has no server / the
|
||||
/// poll times out.
|
||||
///
|
||||
/// The `_edit_seq` argument is currently a no-op; it exists in the
|
||||
/// signature so the engine can correlate diagnostics back to a specific
|
||||
/// edit when we add request batching in v0.7.x.
|
||||
pub async fn diagnostics_for(&self, file: &Path, _edit_seq: u64) -> Option<DiagnosticBlock> {
|
||||
if !self.config.enabled {
|
||||
return None;
|
||||
}
|
||||
let lang = registry::detect_language(file);
|
||||
if lang == Language::Other {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = match tokio::fs::read_to_string(file).await {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let transport = match self.transport_for(lang).await {
|
||||
Some(t) => t,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let wait = Duration::from_millis(self.config.poll_after_edit_ms);
|
||||
let inner_wait = wait;
|
||||
let raw = match timeout(wait, transport.diagnostics_for(file, &text, inner_wait)).await {
|
||||
Ok(Ok(items)) => items,
|
||||
Ok(Err(err)) => {
|
||||
tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed");
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(file = %file.display(), "lsp: diagnostics timed out");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter, sort, and truncate.
|
||||
let include_warnings = self.config.include_warnings;
|
||||
let mut items: Vec<Diagnostic> = raw
|
||||
.into_iter()
|
||||
.filter(|d| match d.severity {
|
||||
Severity::Error => true,
|
||||
Severity::Warning => include_warnings,
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
items.sort_by_key(|d| match d.severity {
|
||||
Severity::Error => 0u8,
|
||||
Severity::Warning => 1u8,
|
||||
Severity::Information => 2u8,
|
||||
Severity::Hint => 3u8,
|
||||
});
|
||||
let mut block = DiagnosticBlock {
|
||||
file: relative_to_workspace(&self.workspace, file),
|
||||
items,
|
||||
};
|
||||
block.truncate(self.config.max_diagnostics_per_file);
|
||||
if block.items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(block)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve (and lazily spawn) the transport for `lang`. Tests can
|
||||
/// short-circuit this via `install_test_transport` (cfg-test only).
|
||||
async fn transport_for(&self, lang: Language) -> Option<Arc<dyn LspTransport>> {
|
||||
if let Some(t) = self.test_transports.lock().await.get(&lang) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
|
||||
if let Some(t) = self.transports.lock().await.get(&lang) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
|
||||
let (cmd, args) = self.config.resolve_command(lang)?;
|
||||
match StdioLspTransport::spawn(&cmd, &args, lang, self.workspace.clone()).await {
|
||||
Ok(transport) => {
|
||||
let arc: Arc<dyn LspTransport> = Arc::new(transport);
|
||||
self.transports.lock().await.insert(lang, arc.clone());
|
||||
Some(arc)
|
||||
}
|
||||
Err(err) => {
|
||||
self.warn_missing_once(lang, &cmd, &err).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn warn_missing_once(&self, lang: Language, cmd: &str, err: &anyhow::Error) {
|
||||
let mut warned = self.missing_warned.lock().await;
|
||||
if warned.insert(lang) {
|
||||
tracing::warn!(
|
||||
language = %lang.as_key(),
|
||||
command = %cmd,
|
||||
error = %err,
|
||||
"lsp: server unavailable; diagnostics disabled for this language"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort shutdown of every spawned transport. Called when the
|
||||
/// session ends.
|
||||
#[allow(dead_code)]
|
||||
pub async fn shutdown_all(&self) {
|
||||
let transports: Vec<Arc<dyn LspTransport>> =
|
||||
self.transports.lock().await.values().cloned().collect();
|
||||
for transport in transports {
|
||||
transport.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `path` relative to the workspace when possible. Falls back to
|
||||
/// `path.file_name()` (per the issue's hard rule about not using
|
||||
/// `display().to_string()` on the bare path) when relativization fails.
|
||||
fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
|
||||
if let Ok(rel) = path.strip_prefix(workspace) {
|
||||
return rel.to_path_buf();
|
||||
}
|
||||
PathBuf::from(
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| String::from("unknown")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Used for tests / no-op runs. Builds an empty manager that always returns
|
||||
/// `None`. Needed because the engine constructs an `LspManager` even when
|
||||
/// the user has disabled LSP, so the field is always present.
|
||||
impl LspManager {
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(
|
||||
LspConfig {
|
||||
enabled: false,
|
||||
..LspConfig::default()
|
||||
},
|
||||
PathBuf::new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Fake transport: returns a fixed list of diagnostics. Used by
|
||||
/// integration tests so we never spawn a real LSP server in CI.
|
||||
pub(crate) struct FakeTransport {
|
||||
items: Vec<Diagnostic>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FakeTransport {
|
||||
pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
|
||||
Self {
|
||||
items,
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LspTransport for FakeTransport {
|
||||
async fn diagnostics_for(
|
||||
&self,
|
||||
_path: &Path,
|
||||
_text: &str,
|
||||
_wait: Duration,
|
||||
) -> anyhow::Result<Vec<Diagnostic>> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(self.items.clone())
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_disabled() {
|
||||
let mgr = LspManager::new(
|
||||
LspConfig {
|
||||
enabled: false,
|
||||
..LspConfig::default()
|
||||
},
|
||||
PathBuf::from("/tmp"),
|
||||
);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("foo.rs");
|
||||
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
|
||||
assert!(mgr.diagnostics_for(&path, 1).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_for_unknown_language() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
|
||||
let path = dir.path().join("notes.txt");
|
||||
tokio::fs::write(&path, b"hi").await.unwrap();
|
||||
assert!(mgr.diagnostics_for(&path, 1).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwards_errors_through_fake_transport() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
|
||||
let path = dir.path().join("foo.rs");
|
||||
tokio::fs::write(&path, b"let x: i32 = \"oops\";")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
|
||||
line: 1,
|
||||
column: 14,
|
||||
severity: Severity::Error,
|
||||
message: "expected i32, found &str".to_string(),
|
||||
}]));
|
||||
mgr.install_test_transport(Language::Rust, fake.clone())
|
||||
.await;
|
||||
|
||||
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
|
||||
let rendered = block.render();
|
||||
assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
|
||||
assert!(rendered.contains("foo.rs"));
|
||||
assert_eq!(fake.call_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drops_warnings_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
|
||||
let path = dir.path().join("foo.rs");
|
||||
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
|
||||
|
||||
let fake = Arc::new(FakeTransport::new(vec![
|
||||
Diagnostic {
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: Severity::Warning,
|
||||
message: "unused import".to_string(),
|
||||
},
|
||||
Diagnostic {
|
||||
line: 2,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "type error".to_string(),
|
||||
},
|
||||
]));
|
||||
mgr.install_test_transport(Language::Rust, fake).await;
|
||||
|
||||
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
|
||||
assert_eq!(block.items.len(), 1);
|
||||
assert_eq!(block.items[0].severity, Severity::Error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keeps_warnings_when_opted_in() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = LspManager::new(
|
||||
LspConfig {
|
||||
include_warnings: true,
|
||||
..LspConfig::default()
|
||||
},
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
let path = dir.path().join("foo.rs");
|
||||
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
|
||||
|
||||
let fake = Arc::new(FakeTransport::new(vec![
|
||||
Diagnostic {
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: Severity::Warning,
|
||||
message: "unused".to_string(),
|
||||
},
|
||||
Diagnostic {
|
||||
line: 2,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "broken".to_string(),
|
||||
},
|
||||
]));
|
||||
mgr.install_test_transport(Language::Rust, fake).await;
|
||||
|
||||
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
|
||||
assert_eq!(block.items.len(), 2);
|
||||
// Errors come first after sorting.
|
||||
assert_eq!(block.items[0].severity, Severity::Error);
|
||||
assert_eq!(block.items[1].severity, Severity::Warning);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncates_to_max_per_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = LspManager::new(
|
||||
LspConfig {
|
||||
max_diagnostics_per_file: 3,
|
||||
..LspConfig::default()
|
||||
},
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
let path = dir.path().join("foo.rs");
|
||||
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
|
||||
|
||||
let fake = Arc::new(FakeTransport::new(
|
||||
(0..10)
|
||||
.map(|i| Diagnostic {
|
||||
line: i + 1,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: format!("err {i}"),
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
mgr.install_test_transport(Language::Rust, fake).await;
|
||||
|
||||
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
|
||||
assert_eq!(block.items.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_blocks_concatenates() {
|
||||
let blocks = vec![
|
||||
DiagnosticBlock {
|
||||
file: PathBuf::from("a.rs"),
|
||||
items: vec![Diagnostic {
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: Severity::Error,
|
||||
message: "err in a".to_string(),
|
||||
}],
|
||||
},
|
||||
DiagnosticBlock {
|
||||
file: PathBuf::from("b.rs"),
|
||||
items: vec![Diagnostic {
|
||||
line: 2,
|
||||
column: 2,
|
||||
severity: Severity::Error,
|
||||
message: "err in b".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
let rendered = render_blocks(&blocks);
|
||||
assert!(rendered.contains("file=\"a.rs\""));
|
||||
assert!(rendered.contains("file=\"b.rs\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_falls_back_to_filename_when_outside_workspace() {
|
||||
let workspace = PathBuf::from("/foo/bar");
|
||||
let path = PathBuf::from("/baz/qux.rs");
|
||||
assert_eq!(
|
||||
relative_to_workspace(&workspace, &path),
|
||||
PathBuf::from("qux.rs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_resolve_uses_overrides() {
|
||||
let mut cfg = LspConfig::default();
|
||||
cfg.servers.insert(
|
||||
"rust".to_string(),
|
||||
vec!["custom-rls".to_string(), "--lsp".to_string()],
|
||||
);
|
||||
let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
|
||||
assert_eq!(cmd, "custom-rls");
|
||||
assert_eq!(args, vec!["--lsp".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_resolve_falls_back_to_registry() {
|
||||
let cfg = LspConfig::default();
|
||||
let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
|
||||
assert_eq!(cmd, "rust-analyzer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Language detection + the fixed dictionary mapping a language to the LSP
|
||||
//! server binary that handles it.
|
||||
//!
|
||||
//! Kept intentionally small: a dozen languages, a hard-coded executable name
|
||||
//! per language, an optional list of args. Users can override the defaults
|
||||
//! via `[lsp.servers]` in `~/.deepseek/config.toml` (handled by
|
||||
//! [`super::LspConfig`], not this file).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A language we know how to ask an LSP server about. Detected from the file
|
||||
/// extension by [`detect_language`]. `Other` is a sentinel used when we do
|
||||
/// not have an LSP for the file — the LSP manager treats it as "skip".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Language {
|
||||
Rust,
|
||||
Go,
|
||||
Python,
|
||||
TypeScript,
|
||||
JavaScript,
|
||||
C,
|
||||
Cpp,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Stable lowercase string used as the key in `[lsp.servers]` overrides
|
||||
/// and in log lines.
|
||||
#[must_use]
|
||||
pub fn as_key(self) -> &'static str {
|
||||
match self {
|
||||
Language::Rust => "rust",
|
||||
Language::Go => "go",
|
||||
Language::Python => "python",
|
||||
Language::TypeScript => "typescript",
|
||||
Language::JavaScript => "javascript",
|
||||
Language::C => "c",
|
||||
Language::Cpp => "cpp",
|
||||
Language::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// LSP `languageId` value used in `textDocument/didOpen`. We follow the
|
||||
/// LSP-spec values: `rust`, `go`, `python`, `typescript`, `javascript`,
|
||||
/// `c`, `cpp`.
|
||||
#[must_use]
|
||||
pub fn language_id(self) -> &'static str {
|
||||
match self {
|
||||
Language::Rust => "rust",
|
||||
Language::Go => "go",
|
||||
Language::Python => "python",
|
||||
Language::TypeScript => "typescript",
|
||||
Language::JavaScript => "javascript",
|
||||
Language::C => "c",
|
||||
Language::Cpp => "cpp",
|
||||
Language::Other => "plaintext",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the language of `path` from its extension. Falls back to
|
||||
/// `Language::Other` when the extension is unknown (or the file has none),
|
||||
/// which signals "skip" to the manager.
|
||||
#[must_use]
|
||||
pub fn detect_language(path: &Path) -> Language {
|
||||
let ext = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => ext.to_ascii_lowercase(),
|
||||
None => return Language::Other,
|
||||
};
|
||||
match ext.as_str() {
|
||||
"rs" => Language::Rust,
|
||||
"go" => Language::Go,
|
||||
"py" | "pyi" => Language::Python,
|
||||
"ts" | "tsx" => Language::TypeScript,
|
||||
"js" | "jsx" | "mjs" | "cjs" => Language::JavaScript,
|
||||
"c" | "h" => Language::C,
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Language::Cpp,
|
||||
_ => Language::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed default for "what executable + args do we run for `lang`?".
|
||||
/// Returns `None` when no LSP server is wired for that language. The TUI
|
||||
/// config layer can override this dictionary at runtime.
|
||||
#[must_use]
|
||||
pub fn server_for(lang: Language) -> Option<(&'static str, &'static [&'static str])> {
|
||||
match lang {
|
||||
Language::Rust => Some(("rust-analyzer", &[])),
|
||||
Language::Go => Some(("gopls", &["serve"])),
|
||||
Language::Python => Some(("pyright-langserver", &["--stdio"])),
|
||||
Language::TypeScript | Language::JavaScript => {
|
||||
Some(("typescript-language-server", &["--stdio"]))
|
||||
}
|
||||
Language::C | Language::Cpp => Some(("clangd", &[])),
|
||||
Language::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn detects_rust_extension() {
|
||||
assert_eq!(detect_language(&PathBuf::from("foo.rs")), Language::Rust);
|
||||
assert_eq!(detect_language(&PathBuf::from("FOO.RS")), Language::Rust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_unknown_as_other() {
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("notes.txt")),
|
||||
Language::Other
|
||||
);
|
||||
assert_eq!(detect_language(&PathBuf::from("README")), Language::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_typescript_variants() {
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("foo.ts")),
|
||||
Language::TypeScript
|
||||
);
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("foo.tsx")),
|
||||
Language::TypeScript
|
||||
);
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("foo.js")),
|
||||
Language::JavaScript
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_rust_is_rust_analyzer() {
|
||||
let (cmd, args) = server_for(Language::Rust).expect("rust has a server");
|
||||
assert_eq!(cmd, "rust-analyzer");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_other_is_none() {
|
||||
assert!(server_for(Language::Other).is_none());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ mod features;
|
||||
mod hooks;
|
||||
mod llm_client;
|
||||
mod logging;
|
||||
mod lsp;
|
||||
mod mcp;
|
||||
mod mcp_server;
|
||||
mod models;
|
||||
@@ -2942,6 +2943,11 @@ async fn run_exec_agent(
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
});
|
||||
|
||||
let lsp_config = config
|
||||
.lsp
|
||||
.clone()
|
||||
.map(crate::config::LspConfigToml::into_runtime);
|
||||
|
||||
let engine_config = EngineConfig {
|
||||
model: model.to_string(),
|
||||
workspace: workspace.clone(),
|
||||
@@ -2960,6 +2966,7 @@ async fn run_exec_agent(
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy,
|
||||
snapshots_enabled: config.snapshots_config().enabled,
|
||||
lsp_config,
|
||||
};
|
||||
|
||||
let engine_handle = spawn_engine(engine_config, config);
|
||||
|
||||
@@ -1490,6 +1490,11 @@ impl RuntimeThreadManager {
|
||||
let network_policy = self.config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
});
|
||||
let lsp_config = self
|
||||
.config
|
||||
.lsp
|
||||
.clone()
|
||||
.map(crate::config::LspConfigToml::into_runtime);
|
||||
let engine_cfg = EngineConfig {
|
||||
model: thread.model.clone(),
|
||||
workspace: thread.workspace.clone(),
|
||||
@@ -1510,6 +1515,7 @@ impl RuntimeThreadManager {
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy,
|
||||
snapshots_enabled: self.config.snapshots_config().enabled,
|
||||
lsp_config,
|
||||
};
|
||||
|
||||
let engine = spawn_engine(engine_cfg, &self.config);
|
||||
|
||||
@@ -337,6 +337,10 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
}),
|
||||
snapshots_enabled: config.snapshots_config().enabled,
|
||||
lsp_config: config
|
||||
.lsp
|
||||
.clone()
|
||||
.map(crate::config::LspConfigToml::into_runtime),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user