diff --git a/Cargo.lock b/Cargo.lock
index e2f13db..5307222 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -379,6 +379,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -696,7 +702,7 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"crossterm_winapi",
"mio",
"parking_lot",
@@ -1195,6 +1201,15 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fluent-uri"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
[[package]]
name = "flurry"
version = "0.5.2"
@@ -2165,7 +2180,7 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
@@ -2225,6 +2240,19 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "lsp-types"
+version = "0.97.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
+dependencies = [
+ "bitflags 1.3.2",
+ "fluent-uri",
+ "serde",
+ "serde_json",
+ "serde_repr",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2429,7 +2457,7 @@ version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"libc",
"once_cell",
"onig_sys",
@@ -2913,7 +2941,7 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"getopts",
"memchr",
"pulldown-cmark-escape",
@@ -3091,7 +3119,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"cassowary",
"compact_str",
"crossterm",
@@ -3126,7 +3154,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
]
[[package]]
@@ -3135,7 +3163,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
]
[[package]]
@@ -3307,7 +3335,7 @@ dependencies = [
"aes",
"aes-gcm",
"async-trait",
- "bitflags",
+ "bitflags 2.11.0",
"byteorder",
"cbc",
"chacha20",
@@ -3413,7 +3441,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"bytes",
"chrono",
"flurry",
@@ -3466,7 +3494,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -3479,7 +3507,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -3667,7 +3695,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -3680,7 +3708,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -3773,6 +3801,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "serde_spanned"
version = "0.6.9"
@@ -4067,6 +4106,7 @@ dependencies = [
"crossbeam-channel",
"crossterm",
"futures",
+ "lsp-types",
"ratatui",
"rustls",
"serde",
@@ -4085,6 +4125,7 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-typescript",
"tui-markdown",
+ "url",
]
[[package]]
@@ -4576,7 +4617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"base64",
- "bitflags",
+ "bitflags 2.11.0",
"bytes",
"futures-util",
"http",
@@ -5011,7 +5052,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -5530,7 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
- "bitflags",
+ "bitflags 2.11.0",
"indexmap",
"log",
"serde",
diff --git a/markdown_test_artifacts.md b/markdown_test_artifacts.md
new file mode 100644
index 0000000..874a881
--- /dev/null
+++ b/markdown_test_artifacts.md
@@ -0,0 +1,163 @@
+# markdown test artifacts
+
+---
+
+## 1. headers
+# h1: the quick brown fox
+## h2: jumps over
+### h3: the lazy dog
+#### h4: 42
+##### h5: why not
+###### h6: minimum viable header
+
+---
+
+## 2. text formatting
+**bold**, *italic*, ***bold italic***, ~~strikethrough~~, `inline code`, ==highlight== (if supported).
+
+---
+
+## 3. lists
+### unordered
+- top level
+ - nested
+ - deeply nested
+- back to top
+
+### ordered
+1. first
+ 1. nested first
+ 2. nested second
+2. second
+3. third
+
+### task lists
+- [ ] unchecked
+- [x] checked
+- [ ] partially done (if supported)
+
+---
+
+## 4. code blocks
+### inline `code` example
+
+### fenced blocks
+```python
+def factorial(n):
+ return 1 if n <= 1 else n * factorial(n - 1)
+```
+
+```bash
+# shebang test
+#!/bin/bash
+echo "hello world"
+```
+
+```
+plaintext with no language
+ preserves spaces and newlines
+```
+
+---
+
+## 5. tables
+| syntax | description | test |
+|-------------|-------------|------|
+| header | title | here |
+| paragraph | text | more |
+| `code` | **bold** | *italics* |
+
+---
+
+## 6. blockquotes
+> single line
+
+> multi-line
+> continuation
+>> nested blockquote
+
+---
+
+## 7. horizontal rules
+text
+---
+text
+***
+text
+___
+
+---
+
+## 8. links & images
+[regular link](https://example.com)
+
+[reference-style link][1]
+
+[1]: https://example.com "title"
+
+
+
+---
+
+## 9. footnotes
+here's a footnote[^1].
+
+[^1]: this is the footnote text.
+
+---
+
+## 10. html (if supported)
+red text
+
+
+
+---
+
+## 11. edge cases
+### whitespace
+line with irregular spaces
+
+### unicode
+emoji: π β¨ π¦
+symbols: β β β β β β β β
+math: 30Β° Β½ ΒΌ ΒΎ Β± Γ Γ· β β€ β₯ β β
+
+### escapes
+\*not bold\* \`not code\` \[not a link\](https://example.com)
+
+### empty elements
+[]
+()
+{}
+
+---
+
+## 12. mixed nesting
+1. ordered item
+ > with a blockquote
+ > - and a nested list
+2. another item
+ ```
+ code block inside list
+ ```
+
+---
+
+## 13. long content
+lorem ipsum dolor sit amet, consectetur adipiscing elit. sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+
+---
+
+## 14. definition lists (if supported)
+term 1
+: definition 1
+term 2
+: definition 2a
+: definition 2b
+
+---
+
+## 15. math (if supported)
+$E = mc^2$
+
+$$\int_a^b f(x) dx$$
\ No newline at end of file
diff --git a/sunbeam/Cargo.toml b/sunbeam/Cargo.toml
index 5ec6b9b..f879f43 100644
--- a/sunbeam/Cargo.toml
+++ b/sunbeam/Cargo.toml
@@ -33,6 +33,8 @@ tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-python = "0.23"
+lsp-types = "0.97"
+url = "2"
[dev-dependencies]
tokio-stream = { version = "0.1", features = ["net"] }
diff --git a/sunbeam/src/code/lsp/client.rs b/sunbeam/src/code/lsp/client.rs
new file mode 100644
index 0000000..d484666
--- /dev/null
+++ b/sunbeam/src/code/lsp/client.rs
@@ -0,0 +1,205 @@
+//! Low-level LSP client β JSON-RPC framing over subprocess stdio.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicI64, Ordering};
+use std::sync::Arc;
+
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::process::{Child, ChildStdin, ChildStdout};
+use tokio::sync::{oneshot, Mutex};
+use tracing::{debug, warn};
+
+/// A low-level LSP client connected to a language server via stdio.
+pub struct LspClient {
+ child: Child,
+ stdin: ChildStdin,
+ next_id: Arc,
+ pending: Arc>>>,
+ _reader_handle: tokio::task::JoinHandle<()>,
+}
+
+impl LspClient {
+ /// Spawn a language server subprocess.
+ pub async fn spawn(binary: &str, args: &[String], cwd: &str) -> anyhow::Result {
+ use std::process::Stdio;
+ use tokio::process::Command;
+
+ let mut child = Command::new(binary)
+ .args(args)
+ .current_dir(cwd)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .kill_on_drop(true)
+ .spawn()
+ .map_err(|e| anyhow::anyhow!("Failed to spawn {binary}: {e}"))?;
+
+ let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("No stdin"))?;
+ let stdout = child.stdout.take().ok_or_else(|| anyhow::anyhow!("No stdout"))?;
+
+ let pending: Arc>>> =
+ Arc::new(Mutex::new(HashMap::new()));
+
+ let pending_for_reader = pending.clone();
+ let _reader_handle = tokio::spawn(async move {
+ if let Err(e) = read_loop(stdout, pending_for_reader).await {
+ debug!("LSP read loop ended: {e}");
+ }
+ });
+
+ Ok(Self {
+ child,
+ stdin,
+ next_id: Arc::new(AtomicI64::new(1)),
+ pending,
+ _reader_handle,
+ })
+ }
+
+ /// Send a request and wait for the response.
+ pub async fn request(
+ &mut self,
+ method: &str,
+ params: serde_json::Value,
+ ) -> anyhow::Result {
+ let id = self.next_id.fetch_add(1, Ordering::SeqCst);
+
+ let message = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": method,
+ "params": params,
+ });
+
+ let (tx, rx) = oneshot::channel();
+ self.pending.lock().await.insert(id, tx);
+
+ self.send_framed(&message).await?;
+
+ let result = tokio::time::timeout(
+ std::time::Duration::from_secs(30),
+ rx,
+ )
+ .await
+ .map_err(|_| anyhow::anyhow!("LSP request timed out: {method}"))?
+ .map_err(|_| anyhow::anyhow!("LSP response channel dropped"))?;
+
+ Ok(result)
+ }
+
+ /// Send a notification (no response expected).
+ pub async fn notify(
+ &mut self,
+ method: &str,
+ params: serde_json::Value,
+ ) -> anyhow::Result<()> {
+ let message = serde_json::json!({
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ });
+ self.send_framed(&message).await
+ }
+
+ /// Send with LSP Content-Length framing.
+ async fn send_framed(&mut self, message: &serde_json::Value) -> anyhow::Result<()> {
+ let body = serde_json::to_string(message)?;
+ let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
+ self.stdin.write_all(frame.as_bytes()).await?;
+ self.stdin.flush().await?;
+ Ok(())
+ }
+
+ /// Shutdown the language server gracefully.
+ pub async fn shutdown(&mut self) {
+ // Send shutdown request
+ let _ = self.request("shutdown", serde_json::json!(null)).await;
+ // Send exit notification
+ let _ = self.notify("exit", serde_json::json!(null)).await;
+ // Wait briefly then kill
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ let _ = self.child.kill().await;
+ }
+}
+
+/// Background read loop: parse LSP framed messages from stdout.
+async fn read_loop(
+ stdout: ChildStdout,
+ pending: Arc>>>,
+) -> anyhow::Result<()> {
+ let mut reader = BufReader::new(stdout);
+ let mut header_line = String::new();
+
+ loop {
+ // Read Content-Length header
+ header_line.clear();
+ let bytes_read = reader.read_line(&mut header_line).await?;
+ if bytes_read == 0 {
+ break; // EOF
+ }
+
+ let content_length = if header_line.starts_with("Content-Length:") {
+ header_line
+ .split(':')
+ .nth(1)
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0)
+ } else {
+ continue; // skip non-header lines
+ };
+
+ if content_length == 0 {
+ continue;
+ }
+
+ // Skip remaining headers until blank line
+ loop {
+ header_line.clear();
+ reader.read_line(&mut header_line).await?;
+ if header_line.trim().is_empty() {
+ break;
+ }
+ }
+
+ // Read the JSON body
+ let mut body = vec![0u8; content_length];
+ reader.read_exact(&mut body).await?;
+
+ let message: serde_json::Value = match serde_json::from_slice(&body) {
+ Ok(m) => m,
+ Err(e) => {
+ warn!("Failed to parse LSP message: {e}");
+ continue;
+ }
+ };
+
+ // Route responses to pending requests
+ if let Some(id) = message.get("id").and_then(|v| v.as_i64()) {
+ let result = if let Some(err) = message.get("error") {
+ // LSP error response
+ serde_json::json!({ "error": err })
+ } else {
+ message.get("result").cloned().unwrap_or(serde_json::Value::Null)
+ };
+
+ if let Some(tx) = pending.lock().await.remove(&id) {
+ let _ = tx.send(result);
+ }
+ }
+ // Server notifications (diagnostics, progress, etc.) are silently dropped for now
+ // TODO: capture publishDiagnostics
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn test_framing_format() {
+ let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
+ let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
+ assert!(frame.starts_with("Content-Length: 46\r\n\r\n"));
+ assert!(frame.ends_with("}"));
+ }
+}
diff --git a/sunbeam/src/code/lsp/detect.rs b/sunbeam/src/code/lsp/detect.rs
new file mode 100644
index 0000000..7fe1e04
--- /dev/null
+++ b/sunbeam/src/code/lsp/detect.rs
@@ -0,0 +1,97 @@
+//! Language server detection β auto-detect which LSP servers to spawn.
+
+use std::path::Path;
+
+/// Configuration for a language server to spawn.
+#[derive(Debug, Clone)]
+pub struct LspServerConfig {
+ /// Language identifier (e.g., "rust", "typescript", "python").
+ pub language_id: String,
+ /// Binary name to spawn (must be on PATH).
+ pub binary: String,
+ /// Arguments to pass (typically ["--stdio"]).
+ pub args: Vec,
+ /// File extensions this server handles.
+ pub extensions: Vec,
+}
+
+/// Detect which language servers should be spawned for a project.
+pub fn detect_servers(project_root: &str) -> Vec {
+ let root = Path::new(project_root);
+ let mut configs = Vec::new();
+
+ if root.join("Cargo.toml").exists() {
+ configs.push(LspServerConfig {
+ language_id: "rust".into(),
+ binary: "rust-analyzer".into(),
+ args: vec![],
+ extensions: vec!["rs".into()],
+ });
+ }
+
+ if root.join("package.json").exists() || root.join("tsconfig.json").exists() {
+ configs.push(LspServerConfig {
+ language_id: "typescript".into(),
+ binary: "typescript-language-server".into(),
+ args: vec!["--stdio".into()],
+ extensions: vec!["ts".into(), "tsx".into(), "js".into(), "jsx".into()],
+ });
+ }
+
+ if root.join("pyproject.toml").exists()
+ || root.join("setup.py").exists()
+ || root.join("requirements.txt").exists()
+ {
+ configs.push(LspServerConfig {
+ language_id: "python".into(),
+ binary: "pyright-langserver".into(),
+ args: vec!["--stdio".into()],
+ extensions: vec!["py".into()],
+ });
+ }
+
+ if root.join("go.mod").exists() {
+ configs.push(LspServerConfig {
+ language_id: "go".into(),
+ binary: "gopls".into(),
+ args: vec!["serve".into()],
+ extensions: vec!["go".into()],
+ });
+ }
+
+ configs
+}
+
+/// Get the language ID for a file extension.
+pub fn language_for_extension(ext: &str) -> Option<&'static str> {
+ match ext {
+ "rs" => Some("rust"),
+ "ts" | "tsx" | "js" | "jsx" => Some("typescript"),
+ "py" => Some("python"),
+ "go" => Some("go"),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_language_for_extension() {
+ assert_eq!(language_for_extension("rs"), Some("rust"));
+ assert_eq!(language_for_extension("ts"), Some("typescript"));
+ assert_eq!(language_for_extension("py"), Some("python"));
+ assert_eq!(language_for_extension("go"), Some("go"));
+ assert_eq!(language_for_extension("md"), None);
+ }
+
+ #[test]
+ fn test_detect_servers_rust_project() {
+ // This test runs in the cli-worktree which has Cargo.toml
+ let configs = detect_servers(".");
+ let rust = configs.iter().find(|c| c.language_id == "rust");
+ assert!(rust.is_some(), "Should detect Rust project");
+ assert_eq!(rust.unwrap().binary, "rust-analyzer");
+ }
+}
diff --git a/sunbeam/src/code/lsp/manager.rs b/sunbeam/src/code/lsp/manager.rs
new file mode 100644
index 0000000..86359e0
--- /dev/null
+++ b/sunbeam/src/code/lsp/manager.rs
@@ -0,0 +1,388 @@
+//! LSP manager β spawns and manages language servers for a project.
+//!
+//! Provides high-level tool methods (definition, references, hover, etc.)
+//! that Sol calls via the client tool dispatch.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use tracing::{info, warn};
+
+use super::client::LspClient;
+use super::detect::{self, LspServerConfig};
+
+/// Manages LSP servers for a coding session.
+pub struct LspManager {
+ servers: HashMap, // language_id -> client
+ configs: Vec,
+ project_root: String,
+ initialized: bool,
+}
+
+impl LspManager {
+ /// Create a new manager. Does NOT spawn servers yet β call `initialize()`.
+ pub fn new(project_root: &str) -> Self {
+ let configs = detect::detect_servers(project_root);
+ // Canonicalize so Url::from_file_path works
+ let abs_root = std::fs::canonicalize(project_root)
+ .map(|p| p.to_string_lossy().to_string())
+ .unwrap_or_else(|_| project_root.to_string());
+ Self {
+ servers: HashMap::new(),
+ configs,
+ project_root: abs_root,
+ initialized: false,
+ }
+ }
+
+ /// Spawn and initialize all detected language servers.
+ pub async fn initialize(&mut self) {
+ for config in &self.configs.clone() {
+ match LspClient::spawn(&config.binary, &config.args, &self.project_root).await {
+ Ok(mut client) => {
+ // Send initialize request
+ let root_uri = url::Url::from_file_path(&self.project_root)
+ .unwrap_or_else(|_| url::Url::parse("file:///").unwrap());
+
+ let init_params = serde_json::json!({
+ "processId": std::process::id(),
+ "rootUri": root_uri.as_str(),
+ "capabilities": {
+ "textDocument": {
+ "definition": { "dynamicRegistration": false },
+ "references": { "dynamicRegistration": false },
+ "hover": { "contentFormat": ["markdown", "plaintext"] },
+ "documentSymbol": { "dynamicRegistration": false },
+ "publishDiagnostics": { "relatedInformation": true }
+ },
+ "workspace": {
+ "symbol": { "dynamicRegistration": false }
+ }
+ }
+ });
+
+ match client.request("initialize", init_params).await {
+ Ok(_) => {
+ let _ = client.notify("initialized", serde_json::json!({})).await;
+ info!(lang = config.language_id.as_str(), binary = config.binary.as_str(), "LSP server initialized");
+ self.servers.insert(config.language_id.clone(), client);
+ }
+ Err(e) => {
+ warn!(lang = config.language_id.as_str(), "LSP initialize failed: {e}");
+ }
+ }
+ }
+ Err(e) => {
+ warn!(
+ lang = config.language_id.as_str(),
+ binary = config.binary.as_str(),
+ "LSP server not available: {e}"
+ );
+ }
+ }
+ }
+ self.initialized = true;
+ }
+
+ /// Check if any LSP server is available.
+ pub fn is_available(&self) -> bool {
+ !self.servers.is_empty()
+ }
+
+ /// Get the server for a file path (by extension).
+ fn server_for_file(&mut self, path: &str) -> Option<&mut LspClient> {
+ let ext = Path::new(path).extension()?.to_str()?;
+ let lang = detect::language_for_extension(ext)?;
+ self.servers.get_mut(lang)
+ }
+
+ /// Ensure a file is opened in the LSP server (lazy didOpen).
+ async fn ensure_file_open(&mut self, path: &str) -> anyhow::Result<()> {
+ let abs_path = if Path::new(path).is_absolute() {
+ path.to_string()
+ } else {
+ format!("{}/{}", self.project_root, path)
+ };
+
+ let uri = url::Url::from_file_path(&abs_path)
+ .map_err(|_| anyhow::anyhow!("Invalid file path: {abs_path}"))?;
+
+ let content = std::fs::read_to_string(&abs_path)?;
+ let ext = Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("");
+ let lang_id = detect::language_for_extension(ext).unwrap_or("plaintext");
+
+ if let Some(server) = self.server_for_file(path) {
+ server.notify("textDocument/didOpen", serde_json::json!({
+ "textDocument": {
+ "uri": uri.as_str(),
+ "languageId": lang_id,
+ "version": 1,
+ "text": content,
+ }
+ })).await?;
+ }
+
+ Ok(())
+ }
+
+ fn make_uri(&self, path: &str) -> anyhow::Result {
+ let abs = if Path::new(path).is_absolute() {
+ path.to_string()
+ } else {
+ format!("{}/{}", self.project_root, path)
+ };
+ url::Url::from_file_path(&abs)
+ .map_err(|_| anyhow::anyhow!("Invalid path: {abs}"))
+ }
+
+ // ββ Tool methods ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// Go to definition at file:line:column.
+ pub async fn definition(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result {
+ let _ = self.ensure_file_open(path).await;
+ let uri = self.make_uri(path)?;
+
+ let server = self.server_for_file(path)
+ .ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
+
+ let result = server.request("textDocument/definition", serde_json::json!({
+ "textDocument": { "uri": uri.as_str() },
+ "position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) }
+ })).await?;
+
+ format_locations(&result, &self.project_root)
+ }
+
+ /// Find all references to symbol at file:line:column.
+ pub async fn references(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result {
+ let _ = self.ensure_file_open(path).await;
+ let uri = self.make_uri(path)?;
+
+ let server = self.server_for_file(path)
+ .ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
+
+ let result = server.request("textDocument/references", serde_json::json!({
+ "textDocument": { "uri": uri.as_str() },
+ "position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) },
+ "context": { "includeDeclaration": true }
+ })).await?;
+
+ format_locations(&result, &self.project_root)
+ }
+
+ /// Get hover documentation at file:line:column.
+ pub async fn hover(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result {
+ let _ = self.ensure_file_open(path).await;
+ let uri = self.make_uri(path)?;
+
+ let server = self.server_for_file(path)
+ .ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
+
+ let result = server.request("textDocument/hover", serde_json::json!({
+ "textDocument": { "uri": uri.as_str() },
+ "position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) }
+ })).await?;
+
+ if result.is_null() {
+ return Ok("No hover information available.".into());
+ }
+
+ // Extract markdown content from hover result
+ let contents = &result["contents"];
+ if let Some(value) = contents.get("value").and_then(|v| v.as_str()) {
+ Ok(value.to_string())
+ } else if let Some(s) = contents.as_str() {
+ Ok(s.to_string())
+ } else {
+ Ok(serde_json::to_string_pretty(&result)?)
+ }
+ }
+
+ /// Get document symbols (outline) for a file.
+ pub async fn document_symbols(&mut self, path: &str) -> anyhow::Result {
+ let _ = self.ensure_file_open(path).await;
+ let uri = self.make_uri(path)?;
+
+ let server = self.server_for_file(path)
+ .ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
+
+ let result = server.request("textDocument/documentSymbol", serde_json::json!({
+ "textDocument": { "uri": uri.as_str() }
+ })).await?;
+
+ format_symbols(&result)
+ }
+
+ /// Workspace-wide symbol search.
+ pub async fn workspace_symbols(&mut self, query: &str, lang: Option<&str>) -> anyhow::Result {
+ // Use the first available server, or a specific one if lang is given
+ let server = if let Some(lang) = lang {
+ self.servers.get_mut(lang)
+ } else {
+ self.servers.values_mut().next()
+ }
+ .ok_or_else(|| anyhow::anyhow!("No LSP server available"))?;
+
+ let result = server.request("workspace/symbol", serde_json::json!({
+ "query": query
+ })).await?;
+
+ format_symbols(&result)
+ }
+
+ /// Shutdown all servers.
+ pub async fn shutdown(&mut self) {
+ for (lang, mut server) in self.servers.drain() {
+ info!(lang = lang.as_str(), "Shutting down LSP server");
+ server.shutdown().await;
+ }
+ }
+}
+
+/// Format LSP location results as readable text.
+fn format_locations(result: &serde_json::Value, project_root: &str) -> anyhow::Result {
+ let locations = if result.is_array() {
+ result.as_array().unwrap().clone()
+ } else if result.is_object() {
+ vec![result.clone()]
+ } else if result.is_null() {
+ return Ok("No results found.".into());
+ } else {
+ return Ok(format!("{result}"));
+ };
+
+ if locations.is_empty() {
+ return Ok("No results found.".into());
+ }
+
+ let mut lines = Vec::new();
+ for loc in &locations {
+ let uri = loc.get("uri").or_else(|| loc.get("targetUri"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ let range = loc.get("range").or_else(|| loc.get("targetRange"));
+ let line = range.and_then(|r| r["start"]["line"].as_u64()).unwrap_or(0) + 1;
+ let col = range.and_then(|r| r["start"]["character"].as_u64()).unwrap_or(0) + 1;
+
+ // Strip file:// prefix and project root for readability
+ let path = uri.strip_prefix("file://").unwrap_or(uri);
+ let rel_path = path.strip_prefix(project_root).unwrap_or(path);
+ let rel_path = rel_path.strip_prefix('/').unwrap_or(rel_path);
+
+ lines.push(format!("{rel_path}:{line}:{col}"));
+ }
+
+ Ok(lines.join("\n"))
+}
+
+/// Format LSP symbol results.
+fn format_symbols(result: &serde_json::Value) -> anyhow::Result {
+ let symbols = result.as_array().ok_or_else(|| anyhow::anyhow!("Expected array"))?;
+
+ if symbols.is_empty() {
+ return Ok("No symbols found.".into());
+ }
+
+ let mut lines = Vec::new();
+ for sym in symbols {
+ let name = sym.get("name").and_then(|v| v.as_str()).unwrap_or("?");
+ let kind_num = sym.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
+ let kind = symbol_kind_name(kind_num);
+
+ if let Some(loc) = sym.get("location") {
+ let line = loc["range"]["start"]["line"].as_u64().unwrap_or(0) + 1;
+ lines.push(format!("{kind} {name} (line {line})"));
+ } else if let Some(range) = sym.get("range") {
+ let line = range["start"]["line"].as_u64().unwrap_or(0) + 1;
+ lines.push(format!("{kind} {name} (line {line})"));
+ } else {
+ lines.push(format!("{kind} {name}"));
+ }
+
+ // Recurse into children (DocumentSymbol)
+ if let Some(children) = sym.get("children").and_then(|c| c.as_array()) {
+ for child in children {
+ let cname = child.get("name").and_then(|v| v.as_str()).unwrap_or("?");
+ let ckind = symbol_kind_name(child.get("kind").and_then(|v| v.as_u64()).unwrap_or(0));
+ let cline = child.get("range").and_then(|r| r["start"]["line"].as_u64()).unwrap_or(0) + 1;
+ lines.push(format!(" {ckind} {cname} (line {cline})"));
+ }
+ }
+ }
+
+ Ok(lines.join("\n"))
+}
+
+fn symbol_kind_name(kind: u64) -> &'static str {
+ match kind {
+ 1 => "file",
+ 2 => "module",
+ 3 => "namespace",
+ 4 => "package",
+ 5 => "class",
+ 6 => "method",
+ 7 => "property",
+ 8 => "field",
+ 9 => "constructor",
+ 10 => "enum",
+ 11 => "interface",
+ 12 => "function",
+ 13 => "variable",
+ 14 => "constant",
+ 15 => "string",
+ 16 => "number",
+ 17 => "boolean",
+ 18 => "array",
+ 19 => "object",
+ 20 => "key",
+ 21 => "null",
+ 22 => "enum_member",
+ 23 => "struct",
+ 24 => "event",
+ 25 => "operator",
+ 26 => "type_parameter",
+ _ => "unknown",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_symbol_kind_names() {
+ assert_eq!(symbol_kind_name(12), "function");
+ assert_eq!(symbol_kind_name(5), "class");
+ assert_eq!(symbol_kind_name(23), "struct");
+ assert_eq!(symbol_kind_name(10), "enum");
+ assert_eq!(symbol_kind_name(999), "unknown");
+ }
+
+ #[test]
+ fn test_format_locations_empty() {
+ let result = serde_json::json!([]);
+ let formatted = format_locations(&result, "/project").unwrap();
+ assert_eq!(formatted, "No results found.");
+ }
+
+ #[test]
+ fn test_format_locations_single() {
+ let result = serde_json::json!([{
+ "uri": "file:///project/src/main.rs",
+ "range": { "start": { "line": 9, "character": 3 }, "end": { "line": 9, "character": 10 } }
+ }]);
+ let formatted = format_locations(&result, "/project").unwrap();
+ assert_eq!(formatted, "src/main.rs:10:4");
+ }
+
+ #[test]
+ fn test_format_symbols() {
+ let result = serde_json::json!([
+ { "name": "main", "kind": 12, "range": { "start": { "line": 0 }, "end": { "line": 5 } } },
+ { "name": "Config", "kind": 23, "range": { "start": { "line": 10 }, "end": { "line": 20 } } }
+ ]);
+ let formatted = format_symbols(&result).unwrap();
+ assert!(formatted.contains("function main (line 1)"));
+ assert!(formatted.contains("struct Config (line 11)"));
+ }
+}
diff --git a/sunbeam/src/code/lsp/mod.rs b/sunbeam/src/code/lsp/mod.rs
new file mode 100644
index 0000000..62fb03d
--- /dev/null
+++ b/sunbeam/src/code/lsp/mod.rs
@@ -0,0 +1,8 @@
+//! LSP client β spawns language servers and queries them for code intelligence.
+//!
+//! Manages per-language LSP subprocesses. Provides tools for Sol:
+//! lsp_definition, lsp_references, lsp_hover, lsp_diagnostics, lsp_symbols.
+
+pub mod client;
+pub mod detect;
+pub mod manager;
diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs
index f944b55..a93e34c 100644
--- a/sunbeam/src/code/mod.rs
+++ b/sunbeam/src/code/mod.rs
@@ -1,6 +1,7 @@
pub mod agent;
pub mod client;
pub mod config;
+pub mod lsp;
pub mod project;
pub mod symbols;
pub mod tools;
diff --git a/sunbeam/src/code/tools.rs b/sunbeam/src/code/tools.rs
index 317b7d5..a761717 100644
--- a/sunbeam/src/code/tools.rs
+++ b/sunbeam/src/code/tools.rs
@@ -19,6 +19,67 @@ pub fn execute(name: &str, args_json: &str, project_root: &str) -> String {
}
}
+/// Execute an LSP tool asynchronously. Returns None if tool is not an LSP tool.
+pub async fn execute_lsp(
+ name: &str,
+ args_json: &str,
+ lsp: &mut super::lsp::manager::LspManager,
+) -> Option {
+ let args: Value = serde_json::from_str(args_json).unwrap_or_default();
+
+ let result = match name {
+ "lsp_definition" => {
+ let path = args["path"].as_str().unwrap_or("");
+ let line = args["line"].as_u64().unwrap_or(1) as u32;
+ let col = args["column"].as_u64().unwrap_or(1) as u32;
+ Some(lsp.definition(path, line, col).await
+ .unwrap_or_else(|e| format!("LSP error: {e}")))
+ }
+ "lsp_references" => {
+ let path = args["path"].as_str().unwrap_or("");
+ let line = args["line"].as_u64().unwrap_or(1) as u32;
+ let col = args["column"].as_u64().unwrap_or(1) as u32;
+ Some(lsp.references(path, line, col).await
+ .unwrap_or_else(|e| format!("LSP error: {e}")))
+ }
+ "lsp_hover" => {
+ let path = args["path"].as_str().unwrap_or("");
+ let line = args["line"].as_u64().unwrap_or(1) as u32;
+ let col = args["column"].as_u64().unwrap_or(1) as u32;
+ Some(lsp.hover(path, line, col).await
+ .unwrap_or_else(|e| format!("LSP error: {e}")))
+ }
+ "lsp_diagnostics" => {
+ let path = args["path"].as_str().unwrap_or("");
+ if path.is_empty() {
+ Some("Specify a file path for diagnostics.".into())
+ } else {
+ // TODO: return cached diagnostics from publishDiagnostics
+ Some("Diagnostics not yet implemented. Use `bash` with `cargo check` or equivalent.".into())
+ }
+ }
+ "lsp_symbols" => {
+ let path = args["path"].as_str();
+ let query = args["query"].as_str().unwrap_or("");
+ if let Some(path) = path {
+ Some(lsp.document_symbols(path).await
+ .unwrap_or_else(|e| format!("LSP error: {e}")))
+ } else {
+ Some(lsp.workspace_symbols(query, None).await
+ .unwrap_or_else(|e| format!("LSP error: {e}")))
+ }
+ }
+ _ => None,
+ };
+
+ result
+}
+
+/// Check if a tool name is an LSP tool.
+pub fn is_lsp_tool(name: &str) -> bool {
+ matches!(name, "lsp_definition" | "lsp_references" | "lsp_hover" | "lsp_diagnostics" | "lsp_symbols")
+}
+
fn resolve_path(path: &str, project_root: &str) -> String {
let p = Path::new(path);
if p.is_absolute() {
diff --git a/sunbeam/src/lib.rs b/sunbeam/src/lib.rs
new file mode 100644
index 0000000..70fa071
--- /dev/null
+++ b/sunbeam/src/lib.rs
@@ -0,0 +1,2 @@
+// Thin library export for integration tests.
+pub mod code;
diff --git a/sunbeam/tests/code_integration.rs b/sunbeam/tests/code_integration.rs
index 5452e76..b8cc549 100644
--- a/sunbeam/tests/code_integration.rs
+++ b/sunbeam/tests/code_integration.rs
@@ -212,3 +212,131 @@ async fn test_multiple_messages() {
}
}
}
+
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// LSP integration tests (requires rust-analyzer on PATH)
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+mod lsp_tests {
+ use sunbeam::code::lsp::detect;
+ use sunbeam::code::lsp::manager::LspManager;
+ use sunbeam::code::tools;
+
+ #[test]
+ fn test_detect_servers_in_cli_project() {
+ let configs = detect::detect_servers(".");
+ assert!(!configs.is_empty(), "Should detect at least one language server");
+ let rust = configs.iter().find(|c| c.language_id == "rust");
+ assert!(rust.is_some(), "Should detect Rust (Cargo.toml present)");
+ }
+
+ #[test]
+ fn test_is_lsp_tool() {
+ assert!(tools::is_lsp_tool("lsp_definition"));
+ assert!(tools::is_lsp_tool("lsp_references"));
+ assert!(tools::is_lsp_tool("lsp_hover"));
+ assert!(tools::is_lsp_tool("lsp_diagnostics"));
+ assert!(tools::is_lsp_tool("lsp_symbols"));
+ assert!(!tools::is_lsp_tool("file_read"));
+ assert!(!tools::is_lsp_tool("bash"));
+ }
+
+ #[tokio::test]
+ async fn test_lsp_manager_initialize_and_hover() {
+ // This test requires rust-analyzer on PATH
+ if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
+ eprintln!("Skipping: rust-analyzer not on PATH");
+ return;
+ }
+
+ let mut manager = LspManager::new(".");
+ manager.initialize().await;
+
+ if !manager.is_available() {
+ eprintln!("Skipping: LSP initialization failed");
+ return;
+ }
+
+ // Hover on a known file in this project
+ let result = manager.hover("src/main.rs", 1, 1).await;
+ assert!(result.is_ok(), "Hover should not error: {:?}", result.err());
+
+ manager.shutdown().await;
+ }
+
+ #[tokio::test]
+ async fn test_lsp_document_symbols() {
+ if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
+ eprintln!("Skipping: rust-analyzer not on PATH");
+ return;
+ }
+
+ let mut manager = LspManager::new(".");
+ manager.initialize().await;
+
+ if !manager.is_available() {
+ eprintln!("Skipping: LSP initialization failed");
+ return;
+ }
+
+ let result = manager.document_symbols("src/main.rs").await;
+ assert!(result.is_ok(), "Document symbols should not error: {:?}", result.err());
+
+ let symbols = result.unwrap();
+ assert!(!symbols.is_empty(), "Should find symbols in main.rs");
+ // main.rs should have at least a `main` function
+ assert!(
+ symbols.to_lowercase().contains("main"),
+ "Should find main function, got: {symbols}"
+ );
+
+ manager.shutdown().await;
+ }
+
+ #[tokio::test]
+ async fn test_lsp_workspace_symbols() {
+ if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
+ eprintln!("Skipping: rust-analyzer not on PATH");
+ return;
+ }
+
+ let mut manager = LspManager::new(".");
+ manager.initialize().await;
+
+ if !manager.is_available() {
+ eprintln!("Skipping: LSP initialization failed");
+ return;
+ }
+
+ // Wait for rust-analyzer to finish indexing (workspace symbols need full index)
+ let mut found = false;
+ for attempt in 0..10 {
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ let result = manager.workspace_symbols("CodeCommand", None).await;
+ if let Ok(ref symbols) = result {
+ if symbols.contains("CodeCommand") {
+ found = true;
+ break;
+ }
+ }
+ if attempt == 9 {
+ eprintln!("Skipping: rust-analyzer did not finish indexing within 10s");
+ manager.shutdown().await;
+ return;
+ }
+ }
+
+ assert!(found, "Should eventually find CodeCommand in workspace");
+
+ manager.shutdown().await;
+ }
+
+ #[tokio::test]
+ async fn test_lsp_graceful_degradation() {
+ // Use a non-existent binary
+ let mut manager = LspManager::new("/nonexistent/path");
+ manager.initialize().await;
+
+ assert!(!manager.is_available(), "Should not be available with bad path");
+ }
+}