use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
http::{header, HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
Json,
};
use camino::Utf8Path;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{mpsc, OwnedSemaphorePermit, Semaphore},
};
use tracing::{debug, error, info, warn};
use super::{cors, AppState};
const LSP_MESSAGE_BUFFER_SIZE: usize = 32;
pub const DEFAULT_MAX_SESSIONS: u16 = 8;
pub struct SessionLimit {
permits: Arc<Semaphore>,
max: u16,
}
impl SessionLimit {
pub fn new(max: u16) -> Self {
Self {
permits: Arc::new(Semaphore::new(usize::from(max))),
max,
}
}
fn try_claim(&self) -> Option<OwnedSemaphorePermit> {
Arc::clone(&self.permits).try_acquire_owned().ok()
}
fn max(&self) -> u16 {
self.max
}
}
pub async fn lsp_websocket(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
uri: Uri,
) -> Response {
if state.csrf_check && !origin_may_connect(&state.cors, &headers, &uri) {
warn!(
origin = ?headers.get(header::ORIGIN),
host = ?cors::request_authority(&headers, &uri),
"refused a cross-origin WebSocket connection to the language server; \
start the server with --cors-origin <ORIGIN> to allow that origin"
);
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "Cross-origin pages may not connect to the language server. Start \
the server with --cors-origin <ORIGIN> to allow this origin, or \
--no-csrf-check to disable this check."
})),
)
.into_response();
}
let Some(permit) = state.lsp_sessions.try_claim() else {
let max = state.lsp_sessions.max();
warn!("refused an LSP WebSocket: {max} concurrent sessions allowed");
let error = if max == 0 {
"The language server bridge is disabled (--max-lsp-sessions 0).".to_string()
} else {
format!(
"All {max} language server sessions are in use. Close an editor tab, \
or restart the server with a larger --max-lsp-sessions."
)
};
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": error })),
)
.into_response();
};
ws.on_upgrade(move |socket| handle_lsp_connection(socket, state, permit))
}
fn origin_may_connect(cors: &cors::CorsConfig, headers: &HeaderMap, uri: &Uri) -> bool {
let Some(origin) = headers.get(header::ORIGIN) else {
return true;
};
let host = cors::request_authority(headers, uri).unwrap_or_default();
origin
.to_str()
.is_ok_and(|origin| cors.trusts(origin, host))
}
async fn handle_lsp_connection(
socket: WebSocket,
state: Arc<AppState>,
_permit: OwnedSemaphorePermit,
) {
info!("LSP WebSocket connection established");
let Some(workspace) = Workspace::new(&state.base_path) else {
error!(
"Cannot express {} as a file URI; refusing to start the LSP",
state.base_path
);
return;
};
let lsp_process = match spawn_lsp_process(&state.base_path).await {
Ok(process) => process,
Err(e) => {
error!("Failed to spawn LSP process: {}", e);
return;
}
};
if let Err(e) = run_bridge(socket, lsp_process, workspace).await {
error!("LSP bridge error: {}", e);
}
info!("LSP WebSocket connection closed");
}
struct Workspace {
uri: String,
path: String,
name: String,
}
impl Workspace {
fn new(base_path: &Utf8Path) -> Option<Self> {
let uri = url::Url::from_file_path(base_path).ok()?;
Some(Self {
uri: uri.to_string(),
path: base_path.to_string(),
name: base_path.file_name().unwrap_or_default().to_string(),
})
}
fn pin(&self, text: &str) -> Option<String> {
let mut message: Value = serde_json::from_str(text).ok()?;
let object = message.as_object_mut()?;
match object.get("method").and_then(Value::as_str) {
Some("initialize") => {
let params = object.get_mut("params")?.as_object_mut()?;
params.insert("rootUri".into(), json!(self.uri));
params.insert("rootPath".into(), json!(self.path));
params.insert(
"workspaceFolders".into(),
json!([{ "uri": self.uri, "name": self.name }]),
);
}
Some("workspace/didChangeWorkspaceFolders") => return None,
_ => {}
}
Some(message.to_string())
}
}
async fn spawn_lsp_process(base_path: &Utf8Path) -> Result<Child, std::io::Error> {
let exe_path = std::env::current_exe()?;
debug!("Spawning LSP process: {} lsp", exe_path.display());
Command::new(exe_path)
.arg("lsp")
.current_dir(base_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit()) .kill_on_drop(true)
.spawn()
}
async fn run_bridge(
socket: WebSocket,
mut lsp_process: Child,
workspace: Workspace,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let stdin = lsp_process.stdin.take().ok_or("Failed to get stdin")?;
let stdout = lsp_process.stdout.take().ok_or("Failed to get stdout")?;
let (mut ws_sender, mut ws_receiver) = socket.split();
let mut stdin_writer = stdin;
let mut stdout_reader = BufReader::new(stdout);
let (tx, mut rx) = mpsc::channel::<String>(LSP_MESSAGE_BUFFER_SIZE);
let mut stdout_handle = tokio::spawn(async move {
loop {
let mut content_length: usize = 0;
loop {
let mut line = String::new();
match stdout_reader.read_line(&mut line).await {
Ok(0) => {
debug!("LSP stdout closed");
return;
}
Ok(_) => {
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
if let Ok(len) = len_str.parse() {
content_length = len;
}
}
}
Err(e) => {
error!("Error reading LSP stdout: {}", e);
return;
}
}
}
if content_length == 0 {
warn!("No Content-Length header found");
continue;
}
let mut content = vec![0u8; content_length];
if let Err(e) =
tokio::io::AsyncReadExt::read_exact(&mut stdout_reader, &mut content).await
{
error!("Error reading LSP content: {}", e);
return;
}
let json = match String::from_utf8(content) {
Ok(s) => s,
Err(e) => {
error!("Invalid UTF-8 from LSP: {}", e);
continue;
}
};
debug!("LSP -> WS: {}", json);
if tx.send(json).await.is_err() {
debug!("WebSocket channel closed");
return;
}
}
});
let mut stdin_handle = tokio::spawn(async move {
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
let Some(text) = workspace.pin(text.as_str()) else {
warn!(
"Dropped an LSP client message: not a single JSON object, \
or an attempt to move the workspace root"
);
continue;
};
debug!("WS -> LSP: {}", text);
let message = format!("Content-Length: {}\r\n\r\n{}", text.len(), text);
if let Err(e) = stdin_writer.write_all(message.as_bytes()).await {
error!("Error writing to LSP stdin: {}", e);
return;
}
if let Err(e) = stdin_writer.flush().await {
error!("Error flushing LSP stdin: {}", e);
return;
}
}
Ok(Message::Close(_)) => {
debug!("WebSocket closed by client");
return;
}
Ok(_) => {
}
Err(e) => {
error!("WebSocket error: {}", e);
return;
}
}
}
});
let mut ws_send_handle = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let Err(e) = ws_sender.send(Message::Text(msg.into())).await {
error!("Error sending to WebSocket: {}", e);
return;
}
}
});
tokio::select! {
result = &mut stdout_handle => {
debug!("LSP stdout task completed: {:?}", result);
}
result = &mut stdin_handle => {
debug!("WebSocket stdin task completed: {:?}", result);
}
result = &mut ws_send_handle => {
debug!("WebSocket send task completed: {:?}", result);
}
}
stdout_handle.abort();
stdin_handle.abort();
ws_send_handle.abort();
let _ = lsp_process.kill().await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace() -> Workspace {
Workspace {
uri: "file:///srv/recipes".to_string(),
path: "/srv/recipes".to_string(),
name: "recipes".to_string(),
}
}
fn pin(message: Value) -> Option<Value> {
workspace()
.pin(&message.to_string())
.map(|text| serde_json::from_str(&text).expect("pin emits JSON"))
}
fn initialize(params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params })
}
fn assert_pinned(params: &Value) {
assert_eq!(params["rootUri"], "file:///srv/recipes");
assert_eq!(params["rootPath"], "/srv/recipes");
assert_eq!(
params["workspaceFolders"],
json!([{ "uri": "file:///srv/recipes", "name": "recipes" }])
);
}
#[test]
fn initialize_gets_the_base_path_whatever_root_the_client_names() {
let pinned = pin(initialize(json!({
"processId": null,
"rootUri": "file:///",
"rootPath": "/",
"workspaceFolders": [{ "uri": "file:///home", "name": "home" }],
"capabilities": { "textDocument": {} }
})))
.expect("initialize is forwarded");
assert_pinned(&pinned["params"]);
assert_eq!(pinned["id"], 1);
assert_eq!(
pinned["params"]["capabilities"],
json!({ "textDocument": {} })
);
}
#[test]
fn initialize_without_a_root_still_gets_one() {
let pinned = pin(initialize(json!({ "capabilities": {} }))).expect("forwarded");
assert_pinned(&pinned["params"]);
}
#[test]
fn initialize_that_cannot_be_rewritten_by_name_is_dropped() {
assert_eq!(pin(initialize(json!(["file:///", {}]))), None);
assert_eq!(pin(initialize(Value::Null)), None);
assert_eq!(
pin(json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" })),
None
);
}
#[test]
fn workspace_folder_changes_are_dropped() {
let change = json!({
"jsonrpc": "2.0",
"method": "workspace/didChangeWorkspaceFolders",
"params": { "event": { "added": [{ "uri": "file:///", "name": "root" }], "removed": [] } }
});
assert_eq!(pin(change), None);
}
#[test]
fn anything_but_a_single_object_is_dropped() {
let batch = json!([initialize(
json!({ "rootUri": "file:///", "capabilities": {} })
)]);
assert_eq!(pin(batch), None);
assert_eq!(workspace().pin("not json"), None);
assert_eq!(workspace().pin("42"), None);
}
#[test]
fn a_duplicate_method_key_means_the_same_thing_to_the_server() {
let text = r#"{"jsonrpc":"2.0","id":1,"method":"textDocument/hover","method":"initialize","params":{"rootUri":"file:///","capabilities":{}}}"#;
let pinned: Value =
serde_json::from_str(&workspace().pin(text).expect("forwarded")).expect("JSON");
assert_eq!(pinned["method"], "initialize");
assert_pinned(&pinned["params"]);
}
#[test]
fn other_messages_pass_through() {
let completion = json!({
"jsonrpc": "2.0",
"id": 7,
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": "file:///elsewhere/Probe.cook" },
"position": { "line": 0, "character": 3 }
}
});
assert_eq!(pin(completion.clone()), Some(completion));
let response = json!({ "jsonrpc": "2.0", "id": 3, "result": null });
assert_eq!(pin(response.clone()), Some(response));
}
#[test]
fn the_workspace_is_the_base_path_as_a_file_uri() {
let dir = tempfile::TempDir::new().expect("temp dir");
let path = Utf8Path::from_path(dir.path()).expect("UTF-8 temp dir");
let workspace = Workspace::new(path).expect("an absolute path has a file URI");
let uri = url::Url::parse(&workspace.uri).expect("valid URI");
assert_eq!(uri.scheme(), "file");
assert_eq!(uri.to_file_path().expect("file path"), dir.path());
assert_eq!(workspace.path, path.as_str());
assert_eq!(Some(workspace.name.as_str()), path.file_name());
}
#[cfg(windows)]
#[test]
fn a_canonicalized_windows_base_path_becomes_a_plain_drive_uri() {
let workspace = Workspace::new(Utf8Path::new(r"\\?\C:\Users\cook\recipes"))
.expect("verbatim disk paths have a file URI");
assert_eq!(workspace.uri, "file:///C:/Users/cook/recipes");
}
}