LSP client: spawn language servers, expose code intelligence tools
New lsp.rs: LspRegistry manages persistent LSP server connections. Spawns child processes, speaks LSP protocol (Content-Length framed JSON-RPC over stdio). Server indexes the project once; queries are cheap. Tools: lsp_definition, lsp_references, lsp_hover, lsp_symbols, lsp_callers. Each takes file/line/character, queries the running language server. LspRegistry lives on Agent as Option<Arc>, shared across forks. Still needs: config-driven server startup (like MCP). Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
8b5614ba99
commit
6ec0e1c766
3 changed files with 437 additions and 0 deletions
419
src/agent/tools/lsp.rs
Normal file
419
src/agent/tools/lsp.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
// tools/lsp.rs — LSP client for code intelligence
|
||||||
|
//
|
||||||
|
// Spawns language servers on demand when a file is first queried.
|
||||||
|
// Finds the project root (git/cargo/etc.) automatically. Maintains
|
||||||
|
// persistent connections — the server indexes once, queries are cheap.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||||
|
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||||
|
|
||||||
|
struct LspServer {
|
||||||
|
root_path: String,
|
||||||
|
stdin: BufWriter<ChildStdin>,
|
||||||
|
stdout: BufReader<ChildStdout>,
|
||||||
|
_child: Child,
|
||||||
|
next_id: i64,
|
||||||
|
opened_files: HashSet<String>,
|
||||||
|
last_access: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LspServer {
|
||||||
|
async fn request(&mut self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||||
|
self.next_id += 1;
|
||||||
|
let id = self.next_id;
|
||||||
|
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||||
|
self.send_message(&msg).await?;
|
||||||
|
self.read_response(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn notify(&mut self, method: &str, params: serde_json::Value) -> Result<()> {
|
||||||
|
let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params });
|
||||||
|
self.send_message(&msg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(&mut self, msg: &serde_json::Value) -> Result<()> {
|
||||||
|
let body = serde_json::to_string(msg)?;
|
||||||
|
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||||
|
self.stdin.write_all(header.as_bytes()).await?;
|
||||||
|
self.stdin.write_all(body.as_bytes()).await?;
|
||||||
|
self.stdin.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_response(&mut self, expected_id: i64) -> Result<serde_json::Value> {
|
||||||
|
loop {
|
||||||
|
let mut content_length: usize = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
self.stdout.read_line(&mut line).await?;
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() { break; }
|
||||||
|
if let Some(len) = line.strip_prefix("Content-Length: ") {
|
||||||
|
content_length = len.parse()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content_length == 0 {
|
||||||
|
anyhow::bail!("LSP: no Content-Length header");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = vec![0u8; content_length];
|
||||||
|
self.stdout.read_exact(&mut body).await?;
|
||||||
|
let msg: serde_json::Value = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
if let Some(id) = msg.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
if id == expected_id {
|
||||||
|
if let Some(err) = msg.get("error") {
|
||||||
|
anyhow::bail!("LSP error: {}", err);
|
||||||
|
}
|
||||||
|
return Ok(msg.get("result").cloned().unwrap_or(serde_json::Value::Null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_open(&mut self, path: &str) -> Result<String> {
|
||||||
|
let uri = format!("file://{}", path);
|
||||||
|
if !self.opened_files.contains(&uri) {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading {}", path))?;
|
||||||
|
self.notify("textDocument/didOpen", json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri,
|
||||||
|
"languageId": detect_language(path),
|
||||||
|
"version": 1,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
})).await?;
|
||||||
|
self.opened_files.insert(uri.clone());
|
||||||
|
}
|
||||||
|
Ok(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_language(path: &str) -> &'static str {
|
||||||
|
match Path::new(path).extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("rs") => "rust",
|
||||||
|
Some("c" | "h") => "c",
|
||||||
|
Some("cpp" | "cc" | "cxx" | "hpp") => "cpp",
|
||||||
|
Some("py") => "python",
|
||||||
|
Some("js") => "javascript",
|
||||||
|
Some("ts") => "typescript",
|
||||||
|
Some("go") => "go",
|
||||||
|
Some("java") => "java",
|
||||||
|
_ => "plaintext",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_project_root(file_path: &str) -> Option<String> {
|
||||||
|
let mut dir = Path::new(file_path).parent()?;
|
||||||
|
loop {
|
||||||
|
for marker in &[".git", "Cargo.toml", "package.json", "go.mod", "pyproject.toml", "Makefile"] {
|
||||||
|
if dir.join(marker).exists() {
|
||||||
|
return Some(dir.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir = dir.parent()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_TIMEOUT_SECS: u64 = 600;
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
struct Registry {
|
||||||
|
configs: Vec<crate::config::LspServerConfig>,
|
||||||
|
servers: Vec<LspServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static REGISTRY: OnceLock<TokioMutex<Registry>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn registry() -> &'static TokioMutex<Registry> {
|
||||||
|
REGISTRY.get_or_init(|| {
|
||||||
|
let configs = crate::config::get().lsp_servers.clone();
|
||||||
|
TokioMutex::new(Registry { configs, servers: Vec::new() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LspServer {
|
||||||
|
async fn spawn(command: &str, args: &[String], root_path: &str) -> Result<LspServer> {
|
||||||
|
let mut child = Command::new(command)
|
||||||
|
.args(args)
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning LSP: {} {}", command, args.join(" ")))?;
|
||||||
|
|
||||||
|
let mut server = LspServer {
|
||||||
|
root_path: root_path.to_string(),
|
||||||
|
stdin: BufWriter::new(child.stdin.take().unwrap()),
|
||||||
|
stdout: BufReader::new(child.stdout.take().unwrap()),
|
||||||
|
_child: child,
|
||||||
|
next_id: 0,
|
||||||
|
opened_files: HashSet::new(),
|
||||||
|
last_access: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
server.request("initialize", json!({
|
||||||
|
"processId": std::process::id(),
|
||||||
|
"rootUri": format!("file://{}", root_path),
|
||||||
|
"capabilities": {
|
||||||
|
"textDocument": {
|
||||||
|
"definition": { "dynamicRegistration": false },
|
||||||
|
"references": { "dynamicRegistration": false },
|
||||||
|
"hover": { "dynamicRegistration": false },
|
||||||
|
"documentSymbol": { "dynamicRegistration": false },
|
||||||
|
"callHierarchy": { "dynamicRegistration": false },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})).await.with_context(|| format!("initializing LSP for {}", root_path))?;
|
||||||
|
|
||||||
|
server.notify("initialized", json!({})).await?;
|
||||||
|
dbglog!("[lsp] server started for {}", root_path);
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
fn reap_idle(&mut self) {
|
||||||
|
let n = now();
|
||||||
|
self.servers.retain(|s| n.saturating_sub(s.last_access) < IDLE_TIMEOUT_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_config(&self, lang: &str) -> Option<&crate::config::LspServerConfig> {
|
||||||
|
self.configs.iter().find(|c| {
|
||||||
|
if c.languages.is_empty() {
|
||||||
|
// Auto: rust-analyzer for rust, etc.
|
||||||
|
c.command.contains(lang) || c.name == lang
|
||||||
|
} else {
|
||||||
|
c.languages.iter().any(|l| l == lang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_server(&mut self, file_path: &str) -> Result<usize> {
|
||||||
|
let root = find_project_root(file_path)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no project root found for {}", file_path))?;
|
||||||
|
let lang = detect_language(file_path);
|
||||||
|
|
||||||
|
self.reap_idle();
|
||||||
|
|
||||||
|
if let Some(idx) = self.servers.iter().position(|s| s.root_path == root) {
|
||||||
|
self.servers[idx].last_access = now();
|
||||||
|
return Ok(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = self.find_config(lang)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no LSP server configured for {}", lang))?
|
||||||
|
.clone();
|
||||||
|
let server = LspServer::spawn(&config.command, &config.args, &root).await?;
|
||||||
|
self.servers.push(server);
|
||||||
|
Ok(self.servers.len() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn conn_for(&mut self, path: &str) -> Result<(&mut LspServer, String)> {
|
||||||
|
let idx = self.ensure_server(path).await?;
|
||||||
|
let server = &mut self.servers[idx];
|
||||||
|
let uri = server.ensure_open(path).await?;
|
||||||
|
Ok((server, uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Operation table ----------------------------------------------------------
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
struct LspOp {
|
||||||
|
tool_name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
method: &'static str,
|
||||||
|
needs_position: bool,
|
||||||
|
extra_params: fn() -> serde_json::Value,
|
||||||
|
format: fn(&serde_json::Value) -> String,
|
||||||
|
// Two-step RPCs (e.g. incoming_calls) use a second method on the first result
|
||||||
|
followup: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_extra() -> serde_json::Value { json!({}) }
|
||||||
|
fn ref_extra() -> serde_json::Value { json!({"context": {"includeDeclaration": true}}) }
|
||||||
|
|
||||||
|
fn fmt_locations(result: &serde_json::Value) -> String {
|
||||||
|
let locations = if result.is_array() {
|
||||||
|
result.as_array().unwrap().clone()
|
||||||
|
} else if result.is_object() {
|
||||||
|
vec![result.clone()]
|
||||||
|
} else {
|
||||||
|
return "No results.".into();
|
||||||
|
};
|
||||||
|
let mut out = String::new();
|
||||||
|
for loc in &locations {
|
||||||
|
let uri = loc["uri"].as_str().or_else(|| loc["targetUri"].as_str()).unwrap_or("");
|
||||||
|
let range = if loc.get("range").is_some() { &loc["range"] } else { &loc["targetRange"] };
|
||||||
|
let line = range["start"]["line"].as_u64().unwrap_or(0) + 1;
|
||||||
|
let file = uri.strip_prefix("file://").unwrap_or(uri);
|
||||||
|
out.push_str(&format!("{}:{}\n", file, line));
|
||||||
|
}
|
||||||
|
if out.is_empty() { "No results.".into() } else { out }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_hover(result: &serde_json::Value) -> String {
|
||||||
|
if result.is_null() { return "No hover information.".into(); }
|
||||||
|
let contents = &result["contents"];
|
||||||
|
if let Some(s) = contents.as_str() { return s.to_string(); }
|
||||||
|
if let Some(obj) = contents.as_object() {
|
||||||
|
return obj.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(result).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_symbols(result: &serde_json::Value) -> String {
|
||||||
|
if let Some(symbols) = result.as_array() {
|
||||||
|
let mut out = String::new();
|
||||||
|
fmt_symbols_recursive(symbols, &mut out, 0);
|
||||||
|
if out.is_empty() { "No symbols found.".into() } else { out }
|
||||||
|
} else {
|
||||||
|
"No symbols found.".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_symbols_recursive(symbols: &[serde_json::Value], out: &mut String, depth: usize) {
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
for sym in symbols {
|
||||||
|
let name = sym["name"].as_str().unwrap_or("?");
|
||||||
|
let kind = match sym["kind"].as_u64().unwrap_or(0) {
|
||||||
|
2 => "Module", 5 => "Class", 6 => "Method", 8 => "Field",
|
||||||
|
10 => "Enum", 11 => "Interface", 12 => "Function", 13 => "Variable",
|
||||||
|
14 => "Constant", 22 => "EnumMember", 23 => "Struct", 26 => "TypeParameter",
|
||||||
|
_ => "Symbol",
|
||||||
|
};
|
||||||
|
let line = sym["range"]["start"]["line"].as_u64()
|
||||||
|
.or_else(|| sym["location"]["range"]["start"]["line"].as_u64())
|
||||||
|
.unwrap_or(0) + 1;
|
||||||
|
out.push_str(&format!("{}{} ({}) - Line {}\n", indent, name, kind, line));
|
||||||
|
if let Some(children) = sym.get("children").and_then(|c| c.as_array()) {
|
||||||
|
fmt_symbols_recursive(children, out, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_callers(result: &serde_json::Value) -> String {
|
||||||
|
if let Some(calls) = result.as_array() {
|
||||||
|
let mut out = String::new();
|
||||||
|
for call in calls {
|
||||||
|
if let Some(from) = call.get("from") {
|
||||||
|
let name = from["name"].as_str().unwrap_or("?");
|
||||||
|
let uri = from["uri"].as_str().unwrap_or("");
|
||||||
|
let line = from["range"]["start"]["line"].as_u64().unwrap_or(0) + 1;
|
||||||
|
let file = uri.strip_prefix("file://").unwrap_or(uri);
|
||||||
|
out.push_str(&format!("{}:{}: {}\n", file, line, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() { "No incoming calls.".into() } else { out }
|
||||||
|
} else {
|
||||||
|
"No incoming calls.".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static OPS: &[LspOp] = &[
|
||||||
|
LspOp {
|
||||||
|
tool_name: "lsp_definition",
|
||||||
|
description: "Find where a symbol is defined.",
|
||||||
|
method: "textDocument/definition",
|
||||||
|
needs_position: true,
|
||||||
|
extra_params: no_extra,
|
||||||
|
format: fmt_locations,
|
||||||
|
followup: None,
|
||||||
|
},
|
||||||
|
LspOp {
|
||||||
|
tool_name: "lsp_references",
|
||||||
|
description: "Find all references to a symbol.",
|
||||||
|
method: "textDocument/references",
|
||||||
|
needs_position: true,
|
||||||
|
extra_params: ref_extra,
|
||||||
|
format: fmt_locations,
|
||||||
|
followup: None,
|
||||||
|
},
|
||||||
|
LspOp {
|
||||||
|
tool_name: "lsp_hover",
|
||||||
|
description: "Get type info and documentation for a symbol.",
|
||||||
|
method: "textDocument/hover",
|
||||||
|
needs_position: true,
|
||||||
|
extra_params: no_extra,
|
||||||
|
format: fmt_hover,
|
||||||
|
followup: None,
|
||||||
|
},
|
||||||
|
LspOp {
|
||||||
|
tool_name: "lsp_symbols",
|
||||||
|
description: "List all symbols in a file.",
|
||||||
|
method: "textDocument/documentSymbol",
|
||||||
|
needs_position: false,
|
||||||
|
extra_params: no_extra,
|
||||||
|
format: fmt_symbols,
|
||||||
|
followup: None,
|
||||||
|
},
|
||||||
|
LspOp {
|
||||||
|
tool_name: "lsp_callers",
|
||||||
|
description: "Find all functions that call the function at a position.",
|
||||||
|
method: "textDocument/prepareCallHierarchy",
|
||||||
|
needs_position: true,
|
||||||
|
extra_params: no_extra,
|
||||||
|
format: fmt_callers,
|
||||||
|
followup: Some("callHierarchy/incomingCalls"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const POS_PARAMS: &str = r#"{"type":"object","properties":{"file":{"type":"string"},"line":{"type":"integer"},"character":{"type":"integer"}},"required":["file","line","character"]}"#;
|
||||||
|
const FILE_PARAMS: &str = r#"{"type":"object","properties":{"file":{"type":"string"}},"required":["file"]}"#;
|
||||||
|
|
||||||
|
async fn dispatch_op(op: &LspOp, v: &serde_json::Value) -> Result<String> {
|
||||||
|
let file = v["file"].as_str().ok_or_else(|| anyhow::anyhow!("file required"))?;
|
||||||
|
|
||||||
|
let mut reg = registry().lock().await;
|
||||||
|
let (conn, uri) = reg.conn_for(file).await?;
|
||||||
|
|
||||||
|
let mut params = json!({ "textDocument": { "uri": uri } });
|
||||||
|
if op.needs_position {
|
||||||
|
let line = v["line"].as_u64().ok_or_else(|| anyhow::anyhow!("line required"))? as u32 - 1;
|
||||||
|
let character = v["character"].as_u64().unwrap_or(0) as u32;
|
||||||
|
params["position"] = json!({ "line": line, "character": character });
|
||||||
|
}
|
||||||
|
let extra = (op.extra_params)();
|
||||||
|
if let Some(obj) = extra.as_object() {
|
||||||
|
for (k, v) in obj { params[k] = v.clone(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = conn.request(op.method, params).await?;
|
||||||
|
|
||||||
|
if let Some(followup) = op.followup {
|
||||||
|
let item = result.as_array().and_then(|a| a.first())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no item at this position"))?;
|
||||||
|
let result2 = conn.request(followup, json!({ "item": item })).await?;
|
||||||
|
return Ok((op.format)(&result2));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((op.format)(&result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn tools() -> Vec<super::Tool> {
|
||||||
|
OPS.iter().map(|op| {
|
||||||
|
let name = op.tool_name;
|
||||||
|
super::Tool {
|
||||||
|
name: op.tool_name,
|
||||||
|
description: op.description,
|
||||||
|
parameters_json: if op.needs_position { POS_PARAMS } else { FILE_PARAMS },
|
||||||
|
handler: Arc::new(move |_agent, v| Box::pin(async move {
|
||||||
|
let op = OPS.iter().find(|o| o.tool_name == name).unwrap();
|
||||||
|
dispatch_op(op, &v).await
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
// Core tools
|
// Core tools
|
||||||
mod ast_grep;
|
mod ast_grep;
|
||||||
|
pub mod lsp;
|
||||||
pub mod mcp_client;
|
pub mod mcp_client;
|
||||||
mod bash;
|
mod bash;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
|
@ -184,6 +185,7 @@ pub fn tools() -> Vec<Tool> {
|
||||||
all.extend(memory::journal_tools());
|
all.extend(memory::journal_tools());
|
||||||
all.extend(channels::tools());
|
all.extend(channels::tools());
|
||||||
all.extend(control::tools());
|
all.extend(control::tools());
|
||||||
|
all.extend(lsp::tools());
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ pub struct Config {
|
||||||
pub agent_types: Vec<String>,
|
pub agent_types: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_servers: Vec<McpServerConfig>,
|
pub mcp_servers: Vec<McpServerConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lsp_servers: Vec<LspServerConfig>,
|
||||||
/// Surface agent timeout in seconds.
|
/// Surface agent timeout in seconds.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub surface_timeout_secs: Option<u32>,
|
pub surface_timeout_secs: Option<u32>,
|
||||||
|
|
@ -167,6 +169,7 @@ impl Default for Config {
|
||||||
surface_conversation_bytes: None,
|
surface_conversation_bytes: None,
|
||||||
surface_hooks: vec![],
|
surface_hooks: vec![],
|
||||||
mcp_servers: vec![],
|
mcp_servers: vec![],
|
||||||
|
lsp_servers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +354,8 @@ pub struct AppConfig {
|
||||||
pub default_model: String,
|
pub default_model: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_servers: Vec<McpServerConfig>,
|
pub mcp_servers: Vec<McpServerConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lsp_servers: Vec<LspServerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -361,6 +366,16 @@ pub struct McpServerConfig {
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LspServerConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub languages: Vec<String>, // e.g. ["rust"], ["c", "cpp"]. Empty = auto-detect
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct BackendConfig {
|
pub struct BackendConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -450,6 +465,7 @@ impl Default for AppConfig {
|
||||||
models: HashMap::new(),
|
models: HashMap::new(),
|
||||||
default_model: String::new(),
|
default_model: String::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
|
lsp_servers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue