feat: wire formatter discovery and LSP status endpoints

This commit is contained in:
Nathan Flurry 2026-02-04 14:32:25 -08:00
parent 7378abee46
commit 1ce03d078b
8 changed files with 470 additions and 5 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -2487,8 +2487,18 @@ async fn oc_log() -> impl IntoResponse {
responses((status = 200)), responses((status = 200)),
tag = "opencode" tag = "opencode"
)] )]
async fn oc_lsp_status() -> impl IntoResponse { async fn oc_lsp_status(
(StatusCode::OK, Json(json!([]))) State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let statuses = state
.inner
.session_manager()
.lsp_status_for_directory(&directory)
.await;
(StatusCode::OK, Json(statuses))
} }
#[utoipa::path( #[utoipa::path(
@ -2497,8 +2507,18 @@ async fn oc_lsp_status() -> impl IntoResponse {
responses((status = 200)), responses((status = 200)),
tag = "opencode" tag = "opencode"
)] )]
async fn oc_formatter_status() -> impl IntoResponse { async fn oc_formatter_status(
(StatusCode::OK, Json(json!([]))) State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Query(query): Query<DirectoryQuery>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let statuses = state
.inner
.session_manager()
.formatter_status_for_directory(&directory)
.await;
(StatusCode::OK, Json(statuses))
} }
#[utoipa::path( #[utoipa::path(

View file

@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::convert::Infallible; use std::convert::Infallible;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
@ -812,12 +812,270 @@ struct AgentServerManager {
restart_notifier: Mutex<Option<mpsc::UnboundedSender<AgentId>>>, restart_notifier: Mutex<Option<mpsc::UnboundedSender<AgentId>>>,
} }
#[derive(Debug, Clone, Serialize)]
struct FormatterStatus {
name: String,
extensions: Vec<String>,
enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
struct LspStatus {
id: String,
name: String,
root: String,
status: String,
}
#[derive(Debug, Clone)]
struct FormatterDefinition {
name: &'static str,
extensions: &'static [&'static str],
}
#[derive(Debug, Clone)]
struct LspDefinition {
id: &'static str,
name: &'static str,
extensions: &'static [&'static str],
binaries: &'static [&'static str],
}
const FORMATTER_DEFINITIONS: &[FormatterDefinition] = &[
FormatterDefinition {
name: "prettier",
extensions: &[
"js", "jsx", "ts", "tsx", "json", "css", "scss", "html", "md", "mdx", "yaml", "yml",
],
},
FormatterDefinition {
name: "rustfmt",
extensions: &["rs"],
},
FormatterDefinition {
name: "gofmt",
extensions: &["go"],
},
FormatterDefinition {
name: "black",
extensions: &["py"],
},
FormatterDefinition {
name: "stylua",
extensions: &["lua"],
},
FormatterDefinition {
name: "clang-format",
extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"],
},
];
const LSP_DEFINITIONS: &[LspDefinition] = &[
LspDefinition {
id: "typescript-language-server",
name: "TypeScript Language Server",
extensions: &["js", "jsx", "ts", "tsx"],
binaries: &["typescript-language-server"],
},
LspDefinition {
id: "rust-analyzer",
name: "Rust Analyzer",
extensions: &["rs"],
binaries: &["rust-analyzer"],
},
LspDefinition {
id: "gopls",
name: "gopls",
extensions: &["go"],
binaries: &["gopls"],
},
LspDefinition {
id: "pyright",
name: "Pyright",
extensions: &["py"],
binaries: &["pyright-langserver", "pylsp"],
},
LspDefinition {
id: "lua-language-server",
name: "Lua Language Server",
extensions: &["lua"],
binaries: &["lua-language-server"],
},
LspDefinition {
id: "clangd",
name: "clangd",
extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"],
binaries: &["clangd"],
},
];
#[derive(Debug, Clone)]
struct FormatterService {
max_files: usize,
max_depth: usize,
}
impl FormatterService {
fn new() -> Self {
Self {
max_files: 5000,
max_depth: 25,
}
}
fn status_for_directory(&self, directory: &Path) -> Vec<FormatterStatus> {
let extensions = collect_workspace_extensions(directory, self.max_files, self.max_depth);
FORMATTER_DEFINITIONS
.iter()
.filter(|definition| {
definition
.extensions
.iter()
.any(|ext| extensions.contains(*ext))
})
.map(|definition| FormatterStatus {
name: definition.name.to_string(),
extensions: definition
.extensions
.iter()
.map(|ext| ext.to_string())
.collect(),
enabled: true,
})
.collect()
}
}
#[derive(Debug)]
struct LspStatusRegistry {
entries: Mutex<HashMap<String, Vec<LspStatus>>>,
}
impl LspStatusRegistry {
fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
async fn update(&self, root: &str, statuses: Vec<LspStatus>) -> Vec<LspStatus> {
let mut entries = self.entries.lock().await;
entries.insert(root.to_string(), statuses.clone());
statuses
}
}
fn collect_workspace_extensions(
root: &Path,
max_files: usize,
max_depth: usize,
) -> HashSet<String> {
let mut extensions = HashSet::new();
if !root.exists() {
return extensions;
}
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new();
queue.push_back((root.to_path_buf(), 0));
let mut files_scanned = 0usize;
while let Some((directory, depth)) = queue.pop_front() {
if depth > max_depth {
continue;
}
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
if files_scanned >= max_files {
return extensions;
}
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
if should_skip_dir(name) {
continue;
}
}
queue.push_back((path, depth + 1));
} else if file_type.is_file() {
files_scanned += 1;
if let Some(ext) = path.extension().and_then(|value| value.to_str()) {
extensions.insert(ext.to_ascii_lowercase());
}
}
}
}
extensions
}
fn should_skip_dir(name: &str) -> bool {
matches!(
name,
".git"
| "node_modules"
| "target"
| "dist"
| "build"
| ".venv"
| ".idea"
| ".vscode"
| ".cargo"
| ".turbo"
| ".next"
| "out"
)
}
fn lsp_binary_available(definition: &LspDefinition) -> bool {
definition
.binaries
.iter()
.any(|binary| command_on_path(binary))
}
#[cfg(windows)]
fn command_candidates(command: &str) -> Vec<String> {
if command.ends_with(".exe") {
vec![command.to_string()]
} else {
vec![format!("{command}.exe"), command.to_string()]
}
}
#[cfg(not(windows))]
fn command_candidates(command: &str) -> Vec<String> {
vec![command.to_string()]
}
fn command_on_path(command: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&paths) {
for candidate in command_candidates(command) {
if dir.join(&candidate).is_file() {
return true;
}
}
}
false
}
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct SessionManager { pub(crate) struct SessionManager {
agent_manager: Arc<AgentManager>, agent_manager: Arc<AgentManager>,
sessions: Mutex<Vec<SessionState>>, sessions: Mutex<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>, server_manager: Arc<AgentServerManager>,
http_client: Client, http_client: Client,
formatter_service: FormatterService,
lsp_registry: LspStatusRegistry,
} }
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC. /// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
@ -1538,9 +1796,45 @@ impl SessionManager {
sessions: Mutex::new(Vec::new()), sessions: Mutex::new(Vec::new()),
server_manager, server_manager,
http_client: Client::new(), http_client: Client::new(),
formatter_service: FormatterService::new(),
lsp_registry: LspStatusRegistry::new(),
} }
} }
pub(crate) async fn formatter_status_for_directory(
&self,
directory: &str,
) -> Vec<FormatterStatus> {
let root = Path::new(directory);
self.formatter_service.status_for_directory(root)
}
pub(crate) async fn lsp_status_for_directory(&self, directory: &str) -> Vec<LspStatus> {
let root = Path::new(directory);
let extensions = collect_workspace_extensions(root, 5000, 25);
let root_string = root.to_string_lossy().to_string();
let statuses = LSP_DEFINITIONS
.iter()
.filter(|definition| {
definition
.extensions
.iter()
.any(|ext| extensions.contains(*ext))
})
.map(|definition| LspStatus {
id: definition.id.to_string(),
name: definition.name.to_string(),
root: root_string.clone(),
status: if lsp_binary_available(definition) {
"connected".to_string()
} else {
"error".to_string()
},
})
.collect();
self.lsp_registry.update(&root_string, statuses).await
}
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
sessions sessions
.iter() .iter()

View file

@ -0,0 +1,146 @@
include!("../common/http.rs");
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::Path;
struct PathGuard {
old_path: Option<std::ffi::OsString>,
}
impl PathGuard {
fn new(bin_dir: &Path) -> Self {
let old_path = env::var_os("PATH");
let mut paths = vec![bin_dir.to_path_buf()];
if let Some(existing) = &old_path {
paths.extend(env::split_paths(existing));
}
let joined = env::join_paths(paths).expect("join PATH");
env::set_var("PATH", &joined);
Self { old_path }
}
}
impl Drop for PathGuard {
fn drop(&mut self) {
if let Some(value) = &self.old_path {
env::set_var("PATH", value);
} else {
env::remove_var("PATH");
}
}
}
#[cfg(windows)]
fn binary_filename(name: &str) -> String {
format!("{name}.exe")
}
#[cfg(not(windows))]
fn binary_filename(name: &str) -> String {
name.to_string()
}
fn write_executable(path: &Path) {
fs::write(path, "").expect("write fake binary");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.expect("metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).expect("set permissions");
}
}
fn add_fake_binary(dir: &Path, name: &str) {
let path = dir.join(binary_filename(name));
write_executable(&path);
}
fn create_fixture_file(root: &Path, name: &str) {
let path = root.join(name);
fs::write(path, "test").expect("write fixture");
}
fn collect_names(value: &serde_json::Value, field: &str) -> HashSet<String> {
value
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item.get(field).and_then(|value| value.as_str()))
.map(|value| value.to_string())
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opencode_formatter_lsp_status_for_workspace() {
let fixture_dir = tempfile::tempdir().expect("fixture dir");
create_fixture_file(fixture_dir.path(), "main.rs");
create_fixture_file(fixture_dir.path(), "main.ts");
create_fixture_file(fixture_dir.path(), "main.py");
create_fixture_file(fixture_dir.path(), "main.go");
let bin_dir = tempfile::tempdir().expect("bin dir");
add_fake_binary(bin_dir.path(), "rust-analyzer");
add_fake_binary(bin_dir.path(), "typescript-language-server");
add_fake_binary(bin_dir.path(), "pyright-langserver");
add_fake_binary(bin_dir.path(), "gopls");
let _guard = PathGuard::new(bin_dir.path());
let app = TestApp::new();
let directory = fixture_dir
.path()
.to_str()
.expect("fixture dir path");
let formatter_request = Request::builder()
.method(Method::GET)
.uri("/opencode/formatter")
.header("x-opencode-directory", directory)
.body(Body::empty())
.expect("formatter request");
let (status, _headers, payload) = send_json_request(&app.app, formatter_request).await;
assert_eq!(status, StatusCode::OK, "formatter status");
let formatter_names = collect_names(&payload, "name");
for expected in ["prettier", "rustfmt", "gofmt", "black"] {
assert!(
formatter_names.contains(expected),
"expected formatter {expected}"
);
}
let lsp_request = Request::builder()
.method(Method::GET)
.uri("/opencode/lsp")
.header("x-opencode-directory", directory)
.body(Body::empty())
.expect("lsp request");
let (status, _headers, payload) = send_json_request(&app.app, lsp_request).await;
assert_eq!(status, StatusCode::OK, "lsp status");
let lsp_ids = collect_names(&payload, "id");
for expected in [
"typescript-language-server",
"rust-analyzer",
"gopls",
"pyright",
] {
assert!(lsp_ids.contains(expected), "expected lsp {expected}");
}
let statuses: HashSet<String> = payload
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item.get("status").and_then(|value| value.as_str()))
.map(|value| value.to_string())
.collect();
assert!(
statuses.iter().all(|value| value == "connected"),
"expected all LSPs connected"
);
}

View file

@ -1,2 +1,3 @@
#[path = "http/agent_endpoints.rs"] #[path = "http/agent_endpoints.rs"]
mod agent_endpoints; mod agent_endpoints;
mod opencode_endpoints;

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target