返回 CodeWhale
lsp.rs
根目录 / crates / tui / src / runtime_api / lsp.rs
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 async fn lsp_manager(state: &RuntimeApiState) -> Result<Arc<LspManager>, ApiError> {
51 state
52 .workspace_scope
53 .validate()
54 .await
55 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
56 let config = state
57 .config
58 .read()
59 .lsp
60 .clone()
61 .map(|toml| toml.into_runtime())
62 .unwrap_or_default();
63 Ok(state
64 .workspace_scope
65 .lsp
66 .get_or_init(|| {
67 Arc::new(LspManager::new(
68 config,
69 state.workspace_scope.canonical.clone(),
70 ))
71 })
72 .clone())
73 }
74
75 /// Resolve a workspace-relative `path` to an absolute file inside the
76 /// workspace, refusing traversal, `.git`, links, and missing files — the same
77 /// confinement the file routes apply.
78 ///
79 /// Async because the confinement it applies is filesystem work:
80 /// `canonical_workspace` canonicalizes and `precheck_file_target` stats every
81 /// component. Both are blocking syscalls, so they ride `spawn_blocking` rather
82 /// than the caller's Tokio worker (#6149). The blocking-calls budget cannot
83 /// see this — the `std::fs` calls live in `workspace.rs`, so a handler calling
84 /// straight through to them is invisible to a per-file scanner.
85 async fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result<PathBuf, ApiError> {
86 let relative = relative_request_path(raw, false)?;
87 let workspace = state.workspace.clone();
88 tokio::task::spawn_blocking(move || {
89 let root = canonical_workspace(&workspace)?;
90 precheck_file_target(&root, &relative)?
91 .ok_or_else(|| ApiError::not_found("file not found"))?;
92 Ok(root.join(&relative))
93 })
94 .await
95 .map_err(|_| ApiError::internal("workspace file resolution failed"))?
96 }
97
98 /// `intelligence` reports ordinary states (disabled, no server) as error
99 /// strings; split them back into the honest machine-readable reasons.
100 fn lsp_failure(error: String) -> Value {
101 let reason = if error == "stale_document" {
102 "stale_document"
103 } else if error.contains("no LSP server") {
104 "no_server"
105 } else if error.contains("disabled") {
106 "lsp_disabled"
107 } else if error.contains("timed out") {
108 "timeout"
109 } else {
110 "lsp_error"
111 };
112 json!({ "ok": false, "reason": reason, "detail": error })
113 }
114
115 async fn run_intelligence(
116 state: &RuntimeApiState,
117 operation: &str,
118 file: PathBuf,
119 line: Option<u32>,
120 character: Option<u32>,
121 query: Option<String>,
122 expected_revision: Option<String>,
123 ) -> Result<Json<Value>, ApiError> {
124 let manager = lsp_manager(state).await?;
125 if !manager.config().enabled {
126 return Ok(Json(
127 json!({ "ok": false, "reason": "lsp_disabled", "enabled": false }),
128 ));
129 }
130 let result = manager
131 .intelligence_at_revision(
132 operation,
133 &file,
134 line,
135 character,
136 query.as_deref(),
137 expected_revision.as_deref(),
138 )
139 .await;
140 match result {
141 Ok(mut value) => {
142 if let Some(object) = value.as_object_mut() {
143 object.insert("ok".to_string(), Value::Bool(true));
144 }
145 Ok(Json(value))
146 }
147 Err(error) => Ok(Json(lsp_failure(error))),
148 }
149 }
150
151 #[derive(Deserialize)]
152 #[serde(deny_unknown_fields)]
153 pub(super) struct LspFileQuery {
154 path: String,
155 expected_revision: Option<String>,
156 }
157
158 #[derive(Deserialize)]
159 #[serde(deny_unknown_fields)]
160 pub(super) struct LspPositionQuery {
161 path: String,
162 expected_revision: Option<String>,
163 /// 1-based line; required by definition/references.
164 line: Option<u32>,
165 /// 1-based column; defaults to 1.
166 character: Option<u32>,
167 }
168
169 #[derive(Deserialize)]
170 #[serde(deny_unknown_fields)]
171 pub(super) struct LspSymbolsQuery {
172 path: String,
173 expected_revision: Option<String>,
174 query: Option<String>,
175 }
176
177 /// `GET /v1/lsp` — capability report: what this runtime can serve without
178 /// probing or spawning anything.
179 pub(super) async fn lsp_status(
180 State(state): State<RuntimeApiState>,
181 ) -> Result<Json<Value>, ApiError> {
182 let manager = lsp_manager(&state).await?;
183 let config = manager.config();
184 let languages: Vec<Value> = LANGUAGES
185 .iter()
186 .filter_map(|language| {
187 config
188 .resolve_command(*language)
189 .map(|(command, _)| json!({ "language": language.as_key(), "server": command }))
190 })
191 .collect();
192 let custom: Vec<Value> = config
193 .custom
194 .iter()
195 .map(|(extension, def)| {
196 json!({
197 "extension": extension,
198 "language_id": def.language_id,
199 "server": def.command,
200 })
201 })
202 .collect();
203 Ok(Json(json!({
204 "enabled": config.enabled,
205 "diagnostics_contract_version": 1,
206 "semantic_contract_version": 1,
207 "position_encoding": "utf-16",
208 "capability_source": "configuration",
209 "server_probe": "on_request",
210 "workspace": state.workspace.display().to_string(),
211 "operations": LSP_OPERATIONS,
212 "languages": languages,
213 "custom_languages": custom,
214 "poll_after_edit_ms": config.poll_after_edit_ms,
215 "max_diagnostics_per_file": config.max_diagnostics_per_file,
216 "include_warnings": config.include_warnings,
217 })))
218 }
219
220 pub(super) async fn lsp_diagnostics(
221 State(state): State<RuntimeApiState>,
222 Query(query): Query<LspFileQuery>,
223 ) -> Result<Json<Value>, ApiError> {
224 let file = resolve_workspace_file(&state, &query.path).await?;
225 let Json(result) =
226 run_intelligence(&state, "diagnostics", file, None, None, None, None).await?;
227 if result.get("ok").and_then(Value::as_bool) == Some(true)
228 && query.expected_revision.as_deref().is_some_and(|expected| {
229 result.get("source_revision").and_then(Value::as_str) != Some(expected)
230 })
231 {
232 return Ok(Json(json!({"ok":false,"reason":"stale_document"})));
233 }
234 Ok(Json(result))
235 }
236
237 pub(super) async fn lsp_definition(
238 State(state): State<RuntimeApiState>,
239 Query(query): Query<LspPositionQuery>,
240 ) -> Result<Json<Value>, ApiError> {
241 let line = query
242 .line
243 .ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?;
244 let file = resolve_workspace_file(&state, &query.path).await?;
245 run_intelligence(
246 &state,
247 "definition",
248 file,
249 Some(line),
250 query.character,
251 None,
252 query.expected_revision,
253 )
254 .await
255 }
256
257 pub(super) async fn lsp_references(
258 State(state): State<RuntimeApiState>,
259 Query(query): Query<LspPositionQuery>,
260 ) -> Result<Json<Value>, ApiError> {
261 let line = query
262 .line
263 .ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?;
264 let file = resolve_workspace_file(&state, &query.path).await?;
265 run_intelligence(
266 &state,
267 "references",
268 file,
269 Some(line),
270 query.character,
271 None,
272 query.expected_revision,
273 )
274 .await
275 }
276
277 pub(super) async fn lsp_symbols(
278 State(state): State<RuntimeApiState>,
279 Query(query): Query<LspSymbolsQuery>,
280 ) -> Result<Json<Value>, ApiError> {
281 let file = resolve_workspace_file(&state, &query.path).await?;
282 run_intelligence(
283 &state,
284 "symbols",
285 file,
286 None,
287 None,
288 query.query,
289 query.expected_revision,
290 )
291 .await
292 }
293
294 #[cfg(test)]
295 mod diagnostic_freshness_tests {
296 use super::*;
297
298 #[test]
299 fn semantic_queries_accept_expected_revision_without_changing_position_units() {
300 let position: LspPositionQuery = serde_json::from_value(
301 json!({"path":"a.rs","line":2,"character":4,"expected_revision":"abc"}),
302 )
303 .unwrap();
304 assert_eq!(position.expected_revision.as_deref(), Some("abc"));
305 assert_eq!(position.line, Some(2));
306 assert_eq!(position.character, Some(4));
307 let symbols: LspSymbolsQuery =
308 serde_json::from_value(json!({"path":"a.rs","expected_revision":"abc"})).unwrap();
309 assert_eq!(symbols.expected_revision.as_deref(), Some("abc"));
310 assert_eq!(
311 lsp_failure("stale_document".into())["reason"],
312 "stale_document"
313 );
314 }
315
316 #[test]
317 fn failures_keep_unavailable_disabled_and_timeout_distinct() {
318 for (error, reason) in [
319 ("no LSP server is available for this file", "no_server"),
320 ("LSP is disabled ([lsp] enabled = false)", "lsp_disabled"),
321 ("LSP diagnostics timed out after 5 ms", "timeout"),
322 (
323 "LSP diagnostics request failed: server crashed",
324 "lsp_error",
325 ),
326 ] {
327 let result = lsp_failure(error.to_owned());
328 assert_eq!(result["ok"], false);
329 assert_eq!(result["reason"], reason);
330 }
331 }
332 }
333
333 lines RUST