use std::sync::Arc;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::agent::tools::{AskSender, PermCheck, ToolError};
use crate::lsp::manager::LspManager;
use crate::lsp::query::{self, Operation};
#[allow(dead_code)]
const DESCRIPTION: &str = "Interact with Language Server Protocol (LSP) servers for code intelligence.\n\
\n\
Supported operations (pass as the `operation` arg):\n\
- definition: where a symbol is defined\n\
- references: every reference to a symbol\n\
- hover: documentation / type info at a position\n\
- documentSymbol: all symbols in a file\n\
- workspaceSymbol: project-wide symbol search by name\n\
- implementation: implementors of an interface / abstract method\n\
- prepareCallHierarchy: call-hierarchy seed item at a position\n\
- incomingCalls: callers of the function at a position\n\
- outgoingCalls: callees of the function at a position\n\
\n\
All operations require `file_path`. Position-based operations also need\n\
`line` and `character` (1-based, as shown in editors — the tool converts\n\
internally). For `workspaceSymbol` the file isn't sent over the wire; it\n\
just tells the tool which workspace to search.\n\
\n\
Returns the raw LSP response JSON so the agent can introspect; an empty\n\
result for an operation is reported as `(no results)`.";
pub struct LspTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
pub manager: Arc<LspManager>,
}
impl LspTool {
pub fn new(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
manager: Arc<LspManager>,
) -> Self {
Self {
permission,
ask_tx,
manager,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct LspArgs {
pub operation: String,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub line: Option<u32>,
#[serde(default)]
pub character: Option<u32>,
#[serde(default)]
pub query: Option<String>,
}
impl Tool for LspTool {
const NAME: &'static str = "lsp";
type Error = ToolError;
type Args = LspArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "lsp".to_string(),
description: DESCRIPTION.to_string(),
parameters: json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"definition",
"references",
"hover",
"documentSymbol",
"workspaceSymbol",
"implementation",
"prepareCallHierarchy",
"incomingCalls",
"outgoingCalls"
],
"description": "Which LSP capability to invoke."
},
"file_path": {
"type": "string",
"description": "Absolute file path (must be absolute, not relative). Required for every operation.",
"dirge-hints": {"semantic": "absolute_path"}
},
"line": {
"type": "integer",
"minimum": 1,
"description": "1-based line number (as shown in editors). Required for position-based operations."
},
"character": {
"type": "integer",
"minimum": 1,
"description": "1-based character offset. Required for position-based operations."
},
"query": {
"type": "string",
"description": "Search string for workspaceSymbol — REQUIRED when operation is 'workspaceSymbol' (pass empty string to list all symbols). Ignored for other operations."
},
"reason": { "type": "string", "description": "Why you're querying: the specific code-structure question this answers and how it serves the current task. Be targeted." }
},
"required": ["operation", "file_path", "reason"]
}),
}
}
async fn call(&self, args: LspArgs) -> Result<String, ToolError> {
let op = Operation::parse(&args.operation).ok_or_else(|| {
ToolError::Msg(format!(
"unknown lsp operation {:?}; see the tool description for valid values",
args.operation
))
})?;
if args.file_path.is_none() {
return Err(ToolError::Msg(format!(
"operation {:?} requires file_path",
args.operation
)));
}
if op == Operation::WorkspaceSymbol && args.query.is_none() {
return Err(ToolError::Msg(
"workspaceSymbol requires query (pass an empty string to list all)".to_string(),
));
}
if op.needs_position() && (args.line.is_none() || args.character.is_none()) {
return Err(ToolError::Msg(format!(
"operation {:?} requires line and character (1-based)",
args.operation
)));
}
let abs_path = if let Some(p) = args.file_path.as_ref() {
let resolved = crate::agent::tools::require_and_resolve(
&self.permission,
&self.ask_tx,
"lsp",
p,
"the lsp file_path",
)
.await?;
Some(std::path::PathBuf::from(resolved))
} else {
None
};
if let Some(p) = &abs_path
&& !p.exists()
{
return Err(ToolError::Msg(format!("file not found: {}", p.display())));
}
let p = abs_path.as_ref().unwrap();
let result: Value = query::run(
&self.manager,
op,
p,
args.line.unwrap_or(1),
args.character.unwrap_or(1),
args.query.as_deref().unwrap_or(""),
)
.await;
let is_empty = match &result {
Value::Array(arr) => arr.is_empty() || arr.iter().all(|v| v.is_null()),
Value::Null => true,
_ => false,
};
if is_empty {
return Ok(format!("(no results from {})", args.operation));
}
Ok(serde_json::to_string_pretty(&result)
.unwrap_or_else(|_| "(failed to serialize LSP response)".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::spawn::{Spawned, Spawner};
use futures::future::BoxFuture;
use serde_json::Value as JsonValue;
use std::path::{Path, PathBuf};
struct ScriptedSpawner {
seen_methods: std::sync::Mutex<Vec<String>>,
response: std::sync::Mutex<Value>,
}
impl ScriptedSpawner {
fn new(response: Value) -> Self {
Self {
seen_methods: std::sync::Mutex::new(Vec::new()),
response: std::sync::Mutex::new(response),
}
}
#[allow(dead_code)] fn seen_methods(&self) -> Vec<String> {
self.seen_methods.lock().unwrap().clone()
}
}
impl Spawner for ScriptedSpawner {
fn spawn<'a>(
&'a self,
_server_id: &'a str,
_root: &'a Path,
) -> BoxFuture<'a, std::io::Result<Spawned>> {
Box::pin(async move {
let seen = self.seen_methods.lock().unwrap().clone();
let response = self.response.lock().unwrap().clone();
let (client_in, mut server_writer) = tokio::io::duplex(8192);
let (mut server_reader, client_out) = tokio::io::duplex(8192);
let seen_arc = std::sync::Arc::new(std::sync::Mutex::new(seen));
let seen_outer = std::sync::Arc::clone(&seen_arc);
let response_clone = response.clone();
let fake_server = tokio::spawn(async move {
use crate::jsonrpc_framing::{decode_frame, encode_frame};
let mut reader = tokio::io::BufReader::new(&mut server_reader);
loop {
let frame = match decode_frame(&mut reader).await {
Ok(b) => b,
Err(_) => break,
};
let req: Value = match serde_json::from_slice(&frame) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(method) = req["method"].as_str() {
seen_arc.lock().unwrap().push(method.to_string());
}
if req.get("id").is_none() {
continue;
}
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
let result = if method == "initialize" {
json!({"capabilities": {}})
} else {
response_clone.clone()
};
let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
if encode_frame(&mut server_writer, &serde_json::to_vec(&resp).unwrap())
.await
.is_err()
{
break;
}
}
});
*self.seen_methods.lock().unwrap() = seen_outer.lock().unwrap().clone();
Ok(Spawned {
reader: Box::new(tokio::io::BufReader::new(client_in)),
writer: Box::new(client_out),
init_options: Value::Null,
guard: Box::new(fake_server),
})
})
}
}
fn cargo_tree(suffix: &str) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!(
"dirge-lsp-tool-test-{}-{}-{suffix}",
std::process::id(),
crate::time_util::now_unix_nanos(),
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let file = root.join("src/lib.rs");
std::fs::write(&file, "// hello\nfn main() {}\n").unwrap();
(root, file)
}
fn make_tool(response: Value, cwd: PathBuf) -> LspTool {
let spawner = std::sync::Arc::new(ScriptedSpawner::new(response));
let manager = std::sync::Arc::new(LspManager::new(spawner, cwd));
LspTool::new(None, None, manager)
}
#[tokio::test]
async fn definition_has_correct_name() {
let (tree, _) = cargo_tree("def-name");
let tool = make_tool(Value::Null, tree.clone());
let def = tool.definition(String::new()).await;
assert_eq!(def.name, "lsp");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn regression_unknown_operation_returns_clear_error() {
let (tree, _) = cargo_tree("unknown-op");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "renameSymbol".into(),
file_path: None,
line: None,
character: None,
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("unknown lsp operation"), "got: {err}");
assert!(err.contains("renameSymbol"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn regression_position_op_without_file_path_errors() {
let (tree, _) = cargo_tree("missing-file");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "hover".into(),
file_path: None,
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("requires file_path"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn rejects_relative_file_path() {
let (tree, _) = cargo_tree("rel-path");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some("src/main.rs".into()),
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("absolute path"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn position_op_without_line_or_character_errors() {
let (tree, file) = cargo_tree("missing-pos");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: None,
character: None,
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("line and character"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn regression_workspace_symbol_requires_file_path() {
let (tree, _) = cargo_tree("ws-no-file");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "workspaceSymbol".into(),
file_path: None,
line: None,
character: None,
query: Some("Foo".into()),
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("requires file_path"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn workspace_symbol_without_query_errors() {
let (tree, file) = cargo_tree("missing-query");
let tool = make_tool(Value::Null, tree.clone());
let err = tool
.call(LspArgs {
operation: "workspaceSymbol".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: None,
character: None,
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("workspaceSymbol requires query"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn missing_file_returns_clean_error() {
let (tree, _) = cargo_tree("missing-file-on-disk");
let tool = make_tool(Value::Null, tree.clone());
let bogus = tree.join("does-not-exist.rs");
let err = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(bogus.to_string_lossy().into_owned()),
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("file not found"), "got: {err}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn regression_one_based_coordinates_converted_to_zero_based() {
let (tree, file) = cargo_tree("coord-conv");
let response = json!({"contents": "from line 0, col 0"});
let tool = make_tool(response, tree.clone());
let result = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap();
assert!(result.contains("from line 0"), "got: {result}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn regression_document_symbol_accepts_no_position() {
let (tree, file) = cargo_tree("doc-symbol-no-pos");
let response = json!([{"name": "main", "kind": 12}]);
let tool = make_tool(response, tree.clone());
let result = tool
.call(LspArgs {
operation: "documentSymbol".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: None,
character: None,
query: None,
})
.await;
assert!(
result.is_ok(),
"documentSymbol must not need position: {result:?}"
);
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn successful_hover_returns_pretty_json() {
let (tree, file) = cargo_tree("hover-happy");
let response = json!({"contents": "fn main()"});
let tool = make_tool(response, tree.clone());
let out = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: Some(2),
character: Some(4),
query: None,
})
.await
.unwrap();
assert!(out.contains("fn main()"), "got: {out}");
assert!(
out.contains("\n"),
"expected pretty-printed JSON, got: {out}"
);
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn empty_result_reports_no_results() {
let (tree, file) = cargo_tree("empty-result");
let response = JsonValue::Null;
let tool = make_tool(response, tree.clone());
let out = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap();
assert!(out.contains("(no results"), "got: {out}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn accepts_opencode_camelcase_alias_for_definition() {
let (tree, file) = cargo_tree("camel-alias");
let response = json!([{"uri": "file:///x.rs", "range": {"start": {"line":0,"character":0},"end":{"line":0,"character":0}}}]);
let tool = make_tool(response, tree.clone());
let out = tool
.call(LspArgs {
operation: "goToDefinition".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: Some(1),
character: Some(1),
query: None,
})
.await
.unwrap();
assert!(out.contains("file:///x.rs"), "got: {out}");
let _ = std::fs::remove_dir_all(&tree);
}
#[tokio::test]
async fn line_zero_or_character_zero_does_not_panic() {
let (tree, file) = cargo_tree("zero-coord");
let tool = make_tool(json!({"contents": "x"}), tree.clone());
let result = tool
.call(LspArgs {
operation: "hover".into(),
file_path: Some(file.to_string_lossy().into_owned()),
line: Some(0),
character: Some(0),
query: None,
})
.await;
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&tree);
}
}