| 1 | //! LSP-over-HTTP for native clients (APPS-93). |
| 2 | //! |
| 3 | //! A native file view needs live diagnostics and semantic references, not just |
| 4 | //! receipt-projected ones. These routes serve the *server workspace* through |
| 5 | //! one lazily-built [`LspManager`]; engine threads keep their own per-thread |
| 6 | //! managers for the post-edit hook. Language servers spawn on first use only — |
| 7 | //! a server that never serves an LSP route never pays for one. |
| 8 | //! |
| 9 | //! Fail-closed as data: a file with no language server, a disabled `[lsp]` |
| 10 | //! config, or an LSP timeout all answer `200` with `ok: false` + a machine- |
| 11 | //! readable `reason`. Only malformed input (bad path, missing `line`) is an |
| 12 | //! HTTP error — a file without a server is a normal state, not a failure. |
| 13 | //! |
| 14 | //! Routes: |
| 15 | //! GET /v1/lsp — capability: enabled, languages, operations |
| 16 | //! GET /v1/diagnostics — ?path= (workspace-relative) |
| 17 | //! GET /v1/definition — ?path=&line=&character= (1-based) |
| 18 | //! GET /v1/references — ?path=&line=&character= (1-based) |
| 19 | //! GET /v1/symbols — ?path=&query= (query empty → document symbols) |
| 20 | |
| 21 | use std::path::PathBuf; |
| 22 | use std::sync::Arc; |
| 23 | |
| 24 | use axum::Json; |
| 25 | use axum::extract::{Query, State}; |
| 26 | use serde::Deserialize; |
| 27 | use serde_json::{Value, json}; |
| 28 | |
| 29 | use super::workspace::{canonical_workspace, precheck_file_target, relative_request_path}; |
| 30 | use super::{ApiError, RuntimeApiState}; |
| 31 | use crate::lsp::LspManager; |
| 32 | use crate::lsp::registry::Language; |
| 33 | |
| 34 | const LSP_OPERATIONS: &[&str] = &["diagnostics", "symbols", "definition", "references"]; |
| 35 | const LANGUAGES: &[Language] = &[ |
| 36 | Language::Rust, |
| 37 | Language::Go, |
| 38 | Language::Python, |
| 39 | Language::TypeScript, |
| 40 | Language::JavaScript, |
| 41 | Language::Java, |
| 42 | Language::Php, |
| 43 | Language::Vue, |
| 44 | Language::C, |
| 45 | Language::Cpp, |
| 46 | ]; |
| 47 | |
| 48 | /// The shared workspace manager: built once, from the live config's `[lsp]` |
| 49 | /// table and the canonical workspace root. |
| 50 | fn lsp_manager(state: &RuntimeApiState) -> Result<Arc<LspManager>, ApiError> { |
| 51 | if let Some(manager) = state.lsp_manager.get() { |
| 52 | return Ok(manager.clone()); |
| 53 | } |
| 54 | let workspace = state |
| 55 | .workspace |
| 56 | .canonicalize() |
| 57 | .map_err(|_| ApiError::internal("workspace is unavailable"))?; |
| 58 | let config = state |
| 59 | .config |
| 60 | .read() |
| 61 | .lsp |
| 62 | .clone() |
| 63 | .map(|toml| toml.into_runtime()) |
| 64 | .unwrap_or_default(); |
| 65 | Ok(state |
| 66 | .lsp_manager |
| 67 | .get_or_init(|| Arc::new(LspManager::new(config, workspace))) |
| 68 | .clone()) |
| 69 | } |
| 70 | |
| 71 | /// Resolve a workspace-relative `path` to an absolute file inside the |
| 72 | /// workspace, refusing traversal, `.git`, links, and missing files — the same |
| 73 | /// confinement the file routes apply. |
| 74 | /// |
| 75 | /// Async because the confinement it applies is filesystem work: |
| 76 | /// `canonical_workspace` canonicalizes and `precheck_file_target` stats every |
| 77 | /// component. Both are blocking syscalls, so they ride `spawn_blocking` rather |
| 78 | /// than the caller's Tokio worker (#6149). The blocking-calls budget cannot |
| 79 | /// see this — the `std::fs` calls live in `workspace.rs`, so a handler calling |
| 80 | /// straight through to them is invisible to a per-file scanner. |
| 81 | async fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result<PathBuf, ApiError> { |
| 82 | let relative = relative_request_path(raw, false)?; |
| 83 | let workspace = state.workspace.clone(); |
| 84 | tokio::task::spawn_blocking(move || { |
| 85 | let root = canonical_workspace(&workspace)?; |
| 86 | precheck_file_target(&root, &relative)? |
| 87 | .ok_or_else(|| ApiError::not_found("file not found"))?; |
| 88 | Ok(root.join(&relative)) |
| 89 | }) |
| 90 | .await |
| 91 | .map_err(|_| ApiError::internal("workspace file resolution failed"))? |
| 92 | } |
| 93 | |
| 94 | /// `intelligence` reports ordinary states (disabled, no server) as error |
| 95 | /// strings; split them back into the honest machine-readable reasons. |
| 96 | fn lsp_failure(error: String) -> Value { |
| 97 | let reason = if error == "stale_document" { |
| 98 | "stale_document" |
| 99 | } else if error.contains("no LSP server") { |
| 100 | "no_server" |
| 101 | } else if error.contains("disabled") { |
| 102 | "lsp_disabled" |
| 103 | } else if error.contains("timed out") { |
| 104 | "timeout" |
| 105 | } else { |
| 106 | "lsp_error" |
| 107 | }; |
| 108 | json!({ "ok": false, "reason": reason, "detail": error }) |
| 109 | } |
| 110 | |
| 111 | async fn run_intelligence( |
| 112 | state: &RuntimeApiState, |
| 113 | operation: &str, |
| 114 | file: PathBuf, |
| 115 | line: Option<u32>, |
| 116 | character: Option<u32>, |
| 117 | query: Option<String>, |
| 118 | expected_revision: Option<String>, |
| 119 | ) -> Result<Json<Value>, ApiError> { |
| 120 | let manager = lsp_manager(state)?; |
| 121 | if !manager.config().enabled { |
| 122 | return Ok(Json( |
| 123 | json!({ "ok": false, "reason": "lsp_disabled", "enabled": false }), |
| 124 | )); |
| 125 | } |
| 126 | let result = manager |
| 127 | .intelligence_at_revision( |
| 128 | operation, |
| 129 | &file, |
| 130 | line, |
| 131 | character, |
| 132 | query.as_deref(), |
| 133 | expected_revision.as_deref(), |
| 134 | ) |
| 135 | .await; |
| 136 | match result { |
| 137 | Ok(mut value) => { |
| 138 | if let Some(object) = value.as_object_mut() { |
| 139 | object.insert("ok".to_string(), Value::Bool(true)); |
| 140 | } |
| 141 | Ok(Json(value)) |
| 142 | } |
| 143 | Err(error) => Ok(Json(lsp_failure(error))), |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | #[derive(Deserialize)] |
| 148 | #[serde(deny_unknown_fields)] |
| 149 | pub(super) struct LspFileQuery { |
| 150 | path: String, |
| 151 | expected_revision: Option<String>, |
| 152 | } |
| 153 | |
| 154 | #[derive(Deserialize)] |
| 155 | #[serde(deny_unknown_fields)] |
| 156 | pub(super) struct LspPositionQuery { |
| 157 | path: String, |
| 158 | expected_revision: Option<String>, |
| 159 | /// 1-based line; required by definition/references. |
| 160 | line: Option<u32>, |
| 161 | /// 1-based column; defaults to 1. |
| 162 | character: Option<u32>, |
| 163 | } |
| 164 | |
| 165 | #[derive(Deserialize)] |
| 166 | #[serde(deny_unknown_fields)] |
| 167 | pub(super) struct LspSymbolsQuery { |
| 168 | path: String, |
| 169 | expected_revision: Option<String>, |
| 170 | query: Option<String>, |
| 171 | } |
| 172 | |
| 173 | /// `GET /v1/lsp` — capability report: what this runtime can serve without |
| 174 | /// probing or spawning anything. |
| 175 | pub(super) async fn lsp_status( |
| 176 | State(state): State<RuntimeApiState>, |
| 177 | ) -> Result<Json<Value>, ApiError> { |
| 178 | let manager = lsp_manager(&state)?; |
| 179 | let config = manager.config(); |
| 180 | let languages: Vec<Value> = LANGUAGES |
| 181 | .iter() |
| 182 | .filter_map(|language| { |
| 183 | config |
| 184 | .resolve_command(*language) |
| 185 | .map(|(command, _)| json!({ "language": language.as_key(), "server": command })) |
| 186 | }) |
| 187 | .collect(); |
| 188 | let custom: Vec<Value> = config |
| 189 | .custom |
| 190 | .iter() |
| 191 | .map(|(extension, def)| { |
| 192 | json!({ |
| 193 | "extension": extension, |
| 194 | "language_id": def.language_id, |
| 195 | "server": def.command, |
| 196 | }) |
| 197 | }) |
| 198 | .collect(); |
| 199 | Ok(Json(json!({ |
| 200 | "enabled": config.enabled, |
| 201 | "diagnostics_contract_version": 1, |
| 202 | "semantic_contract_version": 1, |
| 203 | "position_encoding": "utf-16", |
| 204 | "capability_source": "configuration", |
| 205 | "server_probe": "on_request", |
| 206 | "workspace": state.workspace.display().to_string(), |
| 207 | "operations": LSP_OPERATIONS, |
| 208 | "languages": languages, |
| 209 | "custom_languages": custom, |
| 210 | "poll_after_edit_ms": config.poll_after_edit_ms, |
| 211 | "max_diagnostics_per_file": config.max_diagnostics_per_file, |
| 212 | "include_warnings": config.include_warnings, |
| 213 | }))) |
| 214 | } |
| 215 | |
| 216 | pub(super) async fn lsp_diagnostics( |
| 217 | State(state): State<RuntimeApiState>, |
| 218 | Query(query): Query<LspFileQuery>, |
| 219 | ) -> Result<Json<Value>, ApiError> { |
| 220 | let file = resolve_workspace_file(&state, &query.path).await?; |
| 221 | let Json(result) = |
| 222 | run_intelligence(&state, "diagnostics", file, None, None, None, None).await?; |
| 223 | if result.get("ok").and_then(Value::as_bool) == Some(true) |
| 224 | && query.expected_revision.as_deref().is_some_and(|expected| { |
| 225 | result.get("source_revision").and_then(Value::as_str) != Some(expected) |
| 226 | }) |
| 227 | { |
| 228 | return Ok(Json(json!({"ok":false,"reason":"stale_document"}))); |
| 229 | } |
| 230 | Ok(Json(result)) |
| 231 | } |
| 232 | |
| 233 | pub(super) async fn lsp_definition( |
| 234 | State(state): State<RuntimeApiState>, |
| 235 | Query(query): Query<LspPositionQuery>, |
| 236 | ) -> Result<Json<Value>, ApiError> { |
| 237 | let line = query |
| 238 | .line |
| 239 | .ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?; |
| 240 | let file = resolve_workspace_file(&state, &query.path).await?; |
| 241 | run_intelligence( |
| 242 | &state, |
| 243 | "definition", |
| 244 | file, |
| 245 | Some(line), |
| 246 | query.character, |
| 247 | None, |
| 248 | query.expected_revision, |
| 249 | ) |
| 250 | .await |
| 251 | } |
| 252 | |
| 253 | pub(super) async fn lsp_references( |
| 254 | State(state): State<RuntimeApiState>, |
| 255 | Query(query): Query<LspPositionQuery>, |
| 256 | ) -> Result<Json<Value>, ApiError> { |
| 257 | let line = query |
| 258 | .line |
| 259 | .ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?; |
| 260 | let file = resolve_workspace_file(&state, &query.path).await?; |
| 261 | run_intelligence( |
| 262 | &state, |
| 263 | "references", |
| 264 | file, |
| 265 | Some(line), |
| 266 | query.character, |
| 267 | None, |
| 268 | query.expected_revision, |
| 269 | ) |
| 270 | .await |
| 271 | } |
| 272 | |
| 273 | pub(super) async fn lsp_symbols( |
| 274 | State(state): State<RuntimeApiState>, |
| 275 | Query(query): Query<LspSymbolsQuery>, |
| 276 | ) -> Result<Json<Value>, ApiError> { |
| 277 | let file = resolve_workspace_file(&state, &query.path).await?; |
| 278 | run_intelligence( |
| 279 | &state, |
| 280 | "symbols", |
| 281 | file, |
| 282 | None, |
| 283 | None, |
| 284 | query.query, |
| 285 | query.expected_revision, |
| 286 | ) |
| 287 | .await |
| 288 | } |
| 289 | |
| 290 | #[cfg(test)] |
| 291 | mod diagnostic_freshness_tests { |
| 292 | use super::*; |
| 293 | |
| 294 | #[test] |
| 295 | fn semantic_queries_accept_expected_revision_without_changing_position_units() { |
| 296 | let position: LspPositionQuery = serde_json::from_value( |
| 297 | json!({"path":"a.rs","line":2,"character":4,"expected_revision":"abc"}), |
| 298 | ) |
| 299 | .unwrap(); |
| 300 | assert_eq!(position.expected_revision.as_deref(), Some("abc")); |
| 301 | assert_eq!(position.line, Some(2)); |
| 302 | assert_eq!(position.character, Some(4)); |
| 303 | let symbols: LspSymbolsQuery = |
| 304 | serde_json::from_value(json!({"path":"a.rs","expected_revision":"abc"})).unwrap(); |
| 305 | assert_eq!(symbols.expected_revision.as_deref(), Some("abc")); |
| 306 | assert_eq!( |
| 307 | lsp_failure("stale_document".into())["reason"], |
| 308 | "stale_document" |
| 309 | ); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn failures_keep_unavailable_disabled_and_timeout_distinct() { |
| 314 | for (error, reason) in [ |
| 315 | ("no LSP server is available for this file", "no_server"), |
| 316 | ("LSP is disabled ([lsp] enabled = false)", "lsp_disabled"), |
| 317 | ("LSP diagnostics timed out after 5 ms", "timeout"), |
| 318 | ( |
| 319 | "LSP diagnostics request failed: server crashed", |
| 320 | "lsp_error", |
| 321 | ), |
| 322 | ] { |
| 323 | let result = lsp_failure(error.to_owned()); |
| 324 | assert_eq!(result["ok"], false); |
| 325 | assert_eq!(result["reason"], reason); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 |