返回 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 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
329 lines RUST