Skip to main content

oxicode/lsp/
provider.rs

1//! CLI-side `LspProvider` implementation — bridges the agent's `lsp`
2//! tool to [`super::LspManager`].
3//!
4//! Implements every method of [`oxicode_agent::tools::LspProvider`] by
5//! routing to the manager's lazy-spawned [`oxicode_lsp::LspClient`]s.
6//! Errors are returned as plain `String` (the `ToolError` alias).
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13
14use oxicode_agent::tools::{
15    DiagnosticsSummary, FileDiagnosticEntry, LspAction, LspProvider, ToolError,
16};
17
18use lsp_types::request::{
19    CodeActionRequest, DocumentSymbolRequest, GotoDefinition, GotoImplementation,
20    GotoTypeDefinition, HoverRequest, References, Rename, WillRenameFiles, WorkspaceSymbolRequest,
21};
22use lsp_types::{
23    CodeActionContext, CodeActionParams, DocumentSymbolParams, FileRename, GotoDefinitionParams,
24    HoverParams, Position, Range, ReferenceParams, RenameParams, TextDocumentIdentifier,
25    TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceSymbolParams,
26};
27
28use crate::lsp::manager::LspManager;
29
30/// CLI-side `LspProvider` wrapping an [`LspManager`].
31#[derive(Clone)]
32pub struct CliLspProvider {
33    manager: Arc<LspManager>,
34}
35
36impl std::fmt::Debug for CliLspProvider {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("CliLspProvider")
39            .field("workspace_root", &self.manager.workspace_root())
40            .field("server_count", &self.manager.server_count())
41            .finish_non_exhaustive()
42    }
43}
44
45/// Per-RPC timeout for LSP requests (definition, references, hover, …).
46const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
47
48impl CliLspProvider {
49    /// Construct a provider that shares the given manager.
50    pub fn new(manager: Arc<LspManager>) -> Self {
51        Self { manager }
52    }
53
54    /// Construct a provider owning a fresh manager configured from
55    /// [`crate::lsp::manager::default_servers()`].
56    pub fn with_defaults(workspace_root: PathBuf) -> Self {
57        let manager = Arc::new(LspManager::with_defaults(workspace_root));
58        Self::new(manager)
59    }
60
61    /// Borrow the wrapped manager.
62    pub fn manager(&self) -> &LspManager {
63        &self.manager
64    }
65
66    /// Best-effort shutdown of every spawned server.
67    pub async fn shutdown_all(&self) {
68        self.manager.shutdown_all().await;
69    }
70
71    /// Resolve a file argument to an absolute path inside the
72    /// workspace root. Returns a `ToolError` when the file does not
73    /// exist.
74    fn resolve_file(&self, file: &str) -> Result<PathBuf, ToolError> {
75        let p = PathBuf::from(file);
76        let abs = if p.is_absolute() {
77            p
78        } else {
79            self.manager.workspace_root().join(&p)
80        };
81        if !abs.exists() {
82            return Err(format!("LSP file does not exist: {}", abs.display()));
83        }
84        Ok(abs)
85    }
86
87    fn work_done() -> WorkDoneProgressParams {
88        WorkDoneProgressParams {
89            work_done_token: None,
90        }
91    }
92
93    fn position_params(uri: lsp_types::Url, line: u32) -> TextDocumentPositionParams {
94        TextDocumentPositionParams {
95            text_document: TextDocumentIdentifier { uri },
96            position: Position {
97                line: line.saturating_sub(1),
98                character: 0,
99            },
100        }
101    }
102
103    fn goto_params(uri: lsp_types::Url, line: u32) -> GotoDefinitionParams {
104        GotoDefinitionParams {
105            text_document_position_params: Self::position_params(uri, line),
106            work_done_progress_params: Self::work_done(),
107            partial_result_params: lsp_types::PartialResultParams {
108                partial_result_token: None,
109            },
110        }
111    }
112
113    /// Spawn-or-fetch the LSP client owning `abs`'s extension.
114    async fn client_for(
115        &self,
116        abs: &std::path::Path,
117    ) -> Result<Arc<oxicode_lsp::LspClient>, ToolError> {
118        self.manager
119            .client_for_path(abs)
120            .await
121            .map_err(|e| format!("LSP spawn failed: {e}"))?
122            .ok_or_else(|| {
123                format!(
124                    "no LSP server configured for extension {:?}",
125                    abs.extension().and_then(|e| e.to_str()).unwrap_or("")
126                )
127            })
128    }
129
130    fn uri(abs: &std::path::Path) -> Result<lsp_types::Url, ToolError> {
131        oxicode_lsp::uri_for(abs).ok_or_else(|| "invalid path for URI".to_string())
132    }
133
134    // ── Action handlers ──────────────────────────────────────────
135
136    async fn action_diagnostics(&self, file: &str) -> Result<String, ToolError> {
137        let abs = self.resolve_file(file)?;
138        let client = self.client_for(&abs).await?;
139        let uri_string = oxicode_lsp::uri_for(&abs)
140            .map(|u| u.to_string())
141            .unwrap_or_default();
142        let entries = client.read_diagnostics(&[uri_string]);
143        Ok(summarize_entries(&abs, &entries))
144    }
145
146    async fn action_definition(&self, file: &str, line: u32) -> Result<String, ToolError> {
147        let abs = self.resolve_file(file)?;
148        let client = self.client_for(&abs).await?;
149        let uri = Self::uri(&abs)?;
150        let params = Self::goto_params(uri, line);
151        let resp: lsp_types::GotoDefinitionResponse = client
152            .request::<GotoDefinition>(params, REQUEST_TIMEOUT)
153            .await
154            .map_err(lsp_err)?
155            .unwrap_or(lsp_types::GotoDefinitionResponse::Array(vec![]));
156        Ok(format_locations(&resp))
157    }
158
159    async fn action_references(&self, file: &str, line: u32) -> Result<String, ToolError> {
160        let abs = self.resolve_file(file)?;
161        let client = self.client_for(&abs).await?;
162        let uri = Self::uri(&abs)?;
163        let params = ReferenceParams {
164            text_document_position: Self::position_params(uri, line),
165            work_done_progress_params: Self::work_done(),
166            partial_result_params: lsp_types::PartialResultParams {
167                partial_result_token: None,
168            },
169            context: lsp_types::ReferenceContext {
170                include_declaration: true,
171            },
172        };
173        let locs: Vec<lsp_types::Location> = client
174            .request::<References>(params, REQUEST_TIMEOUT)
175            .await
176            .map_err(lsp_err)?
177            .unwrap_or_default();
178        let resp = lsp_types::GotoDefinitionResponse::Array(locs);
179        Ok(format_locations(&resp))
180    }
181
182    async fn action_hover(&self, file: &str, line: u32) -> Result<String, ToolError> {
183        let abs = self.resolve_file(file)?;
184        let client = self.client_for(&abs).await?;
185        let uri = Self::uri(&abs)?;
186        let params = HoverParams {
187            text_document_position_params: Self::position_params(uri, line),
188            work_done_progress_params: Self::work_done(),
189        };
190        let hover: Option<lsp_types::Hover> = client
191            .request::<HoverRequest>(params, REQUEST_TIMEOUT)
192            .await
193            .map_err(lsp_err)?;
194        Ok(hover
195            .map(|h| match h.contents {
196                lsp_types::HoverContents::Scalar(s) => marked_string_to_string(&s),
197                lsp_types::HoverContents::Array(arr) => arr
198                    .into_iter()
199                    .map(|m| marked_string_to_string(&m))
200                    .collect::<Vec<_>>()
201                    .join("\n"),
202                lsp_types::HoverContents::Markup(m) => m.value,
203            })
204            .unwrap_or_else(|| "(no hover info)".into()))
205    }
206
207    async fn action_rename(
208        &self,
209        file: &str,
210        line: u32,
211        new_name: String,
212        apply: bool,
213    ) -> Result<String, ToolError> {
214        let abs = self.resolve_file(file)?;
215        let client = self.client_for(&abs).await?;
216        let uri = Self::uri(&abs)?;
217        let params = RenameParams {
218            text_document_position: Self::position_params(uri, line),
219            new_name,
220            work_done_progress_params: Self::work_done(),
221        };
222        let resp: Option<lsp_types::WorkspaceEdit> = client
223            .request::<Rename>(params, REQUEST_TIMEOUT)
224            .await
225            .map_err(lsp_err)?;
226        Ok(match resp {
227            None => "(no edits)".into(),
228            Some(edit) => {
229                if apply {
230                    let summary = summarize_workspace_edit(&edit);
231                    apply_workspace_edit(&edit)?;
232                    format!("Rename applied: {summary}")
233                } else {
234                    format!("Rename preview:\n{}", summarize_workspace_edit(&edit))
235                }
236            }
237        })
238    }
239
240    async fn action_symbols(&self, file: &str, query: Option<String>) -> Result<String, ToolError> {
241        let abs = self.resolve_file(file)?;
242        let client = self.client_for(&abs).await?;
243
244        // Workspace symbol search when a non-empty query is given.
245        if let Some(q) = query
246            && !q.is_empty()
247        {
248            let params = WorkspaceSymbolParams {
249                query: q,
250                work_done_progress_params: Self::work_done(),
251                partial_result_params: lsp_types::PartialResultParams {
252                    partial_result_token: None,
253                },
254            };
255            let resp: Option<lsp_types::WorkspaceSymbolResponse> = client
256                .request::<WorkspaceSymbolRequest>(params, REQUEST_TIMEOUT)
257                .await
258                .map_err(lsp_err)?;
259            let symbols: Vec<lsp_types::SymbolInformation> = match resp {
260                Some(lsp_types::WorkspaceSymbolResponse::Flat(arr)) => arr,
261                Some(lsp_types::WorkspaceSymbolResponse::Nested(_)) => {
262                    return Ok("(nested workspace symbols not supported)".into());
263                }
264                None => Vec::new(),
265            };
266            return Ok(format_symbols_flat(&symbols));
267        }
268
269        // Document symbols otherwise.
270        let uri = Self::uri(&abs)?;
271        let params = DocumentSymbolParams {
272            text_document: TextDocumentIdentifier { uri },
273            work_done_progress_params: Self::work_done(),
274            partial_result_params: lsp_types::PartialResultParams {
275                partial_result_token: None,
276            },
277        };
278        let resp: Option<lsp_types::DocumentSymbolResponse> = client
279            .request::<DocumentSymbolRequest>(params, REQUEST_TIMEOUT)
280            .await
281            .map_err(lsp_err)?;
282        Ok(match resp {
283            Some(lsp_types::DocumentSymbolResponse::Flat(flat)) => format_symbols_flat(&flat),
284            Some(lsp_types::DocumentSymbolResponse::Nested(_)) => {
285                "(nested document symbols — showing top-level only)".into()
286            }
287            None => "(no symbols)".into(),
288        })
289    }
290
291    async fn action_code_actions(&self, file: &str, line: u32) -> Result<String, ToolError> {
292        let abs = self.resolve_file(file)?;
293        let client = self.client_for(&abs).await?;
294        let uri = Self::uri(&abs)?;
295        let params = CodeActionParams {
296            text_document: TextDocumentIdentifier { uri },
297            range: Range {
298                start: Position {
299                    line: line.saturating_sub(1),
300                    character: 0,
301                },
302                end: Position {
303                    line: line.saturating_sub(1),
304                    character: u32::MAX,
305                },
306            },
307            context: CodeActionContext::default(),
308            work_done_progress_params: Self::work_done(),
309            partial_result_params: lsp_types::PartialResultParams {
310                partial_result_token: None,
311            },
312        };
313        let actions: Vec<lsp_types::CodeActionOrCommand> = client
314            .request::<CodeActionRequest>(params, REQUEST_TIMEOUT)
315            .await
316            .map_err(lsp_err)?
317            .unwrap_or_default();
318        Ok(format_code_actions(&actions))
319    }
320
321    async fn action_type_definition(&self, file: &str, line: u32) -> Result<String, ToolError> {
322        let abs = self.resolve_file(file)?;
323        let client = self.client_for(&abs).await?;
324        let uri = Self::uri(&abs)?;
325        let params = Self::goto_params(uri, line);
326        let resp: lsp_types::GotoDefinitionResponse = client
327            .request::<GotoTypeDefinition>(params, REQUEST_TIMEOUT)
328            .await
329            .map_err(lsp_err)?
330            .unwrap_or(lsp_types::GotoDefinitionResponse::Array(vec![]));
331        Ok(format_locations(&resp))
332    }
333
334    async fn action_implementation(&self, file: &str, line: u32) -> Result<String, ToolError> {
335        let abs = self.resolve_file(file)?;
336        let client = self.client_for(&abs).await?;
337        let uri = Self::uri(&abs)?;
338        let params = Self::goto_params(uri, line);
339        let resp = client
340            .request::<GotoImplementation>(params, REQUEST_TIMEOUT)
341            .await
342            .map_err(lsp_err)?
343            .unwrap_or(lsp_types::GotoDefinitionResponse::Array(vec![]));
344        Ok(format_locations(&resp))
345    }
346
347    async fn action_file_rename(
348        &self,
349        old_path: &str,
350        new_path: &str,
351        apply: bool,
352    ) -> Result<String, ToolError> {
353        let old_abs = self.resolve_file(old_path)?;
354        let client = self.client_for(&old_abs).await?;
355        let old_uri = Self::uri(&old_abs)?;
356        let new_uri = oxicode_lsp::uri_for(&PathBuf::from(new_path))
357            .ok_or_else(|| "invalid new_path for URI".to_string())?;
358        let params = lsp_types::RenameFilesParams {
359            files: vec![FileRename {
360                old_uri: old_uri.to_string(),
361                new_uri: new_uri.to_string(),
362            }],
363        };
364        let edit: Option<lsp_types::WorkspaceEdit> = client
365            .request::<WillRenameFiles>(params, REQUEST_TIMEOUT)
366            .await
367            .map_err(lsp_err)?;
368        Ok(match edit {
369            None => "(no willRenameFiles response)".into(),
370            Some(e) => {
371                if apply {
372                    let summary = summarize_workspace_edit(&e);
373                    apply_workspace_edit(&e)?;
374                    format!("File rename applied: {summary}")
375                } else {
376                    format!("File rename preview:\n{}", summarize_workspace_edit(&e))
377                }
378            }
379        })
380    }
381
382    async fn action_reload(&self) -> Result<String, ToolError> {
383        use lsp_types::notification::DidChangeConfiguration;
384        let clients = self.manager.live_clients();
385        if clients.is_empty() {
386            return Err("No active LSP servers to reload".into());
387        }
388        let mut results = Vec::new();
389        for (name, client) in &clients {
390            // Send workspace/didChangeConfiguration with empty settings to
391            // trigger a config reload.
392            let _ =
393                client.notify::<DidChangeConfiguration>(lsp_types::DidChangeConfigurationParams {
394                    settings: serde_json::Value::Null,
395                });
396            results.push(format!("{name}: configuration reloaded"));
397        }
398        Ok(results.join("\n"))
399    }
400
401    async fn action_capabilities(&self) -> Result<String, ToolError> {
402        let clients = self.manager.live_clients();
403        if clients.is_empty() {
404            return Err("No active LSP servers".into());
405        }
406        let mut parts = Vec::new();
407        for (name, client) in &clients {
408            match client.cached_capabilities() {
409                Some(caps) => {
410                    let json = serde_json::to_string_pretty(&caps)
411                        .unwrap_or_else(|_| "(serialization failed)".into());
412                    parts.push(format!("=== {name} ===\n{json}"));
413                }
414                None => {
415                    parts.push(format!("=== {name} ===\n(capabilities not yet captured)"));
416                }
417            }
418        }
419        Ok(parts.join("\n\n"))
420    }
421
422    async fn action_raw_request(
423        &self,
424        method: &str,
425        payload: Option<serde_json::Value>,
426    ) -> Result<String, ToolError> {
427        // Route to typed lsp_types requests for known methods. The
428        // async-lsp client requires compile-time method routing, so we
429        // map common methods here. Unknown methods return an error.
430        let clients = self.manager.live_clients();
431        let (_name, client) = clients
432            .first()
433            .ok_or::<ToolError>("No active LSP servers".into())?;
434        let params = payload.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
435
436        match method {
437            "workspace/symbol" => {
438                let query = params
439                    .get("query")
440                    .and_then(|v| v.as_str())
441                    .unwrap_or("")
442                    .to_string();
443                let result = client
444                    .request::<WorkspaceSymbolRequest>(
445                        lsp_types::WorkspaceSymbolParams {
446                            query,
447                            ..Default::default()
448                        },
449                        REQUEST_TIMEOUT,
450                    )
451                    .await
452                    .map_err(lsp_err)?;
453                Ok(serde_json::to_string_pretty(&result)
454                    .unwrap_or_else(|_| "(serialization failed)".into()))
455            }
456            "textDocument/documentSymbol" => {
457                let uri = params
458                    .get("textDocument")
459                    .and_then(|v| v.get("uri"))
460                    .and_then(|v| v.as_str())
461                    .ok_or::<ToolError>("Missing textDocument.uri in params".into())?;
462                let result = client
463                    .request::<DocumentSymbolRequest>(
464                        lsp_types::DocumentSymbolParams {
465                            text_document: lsp_types::TextDocumentIdentifier {
466                                uri: uri.parse().map_err(|_| "Invalid URI".to_string())?,
467                            },
468                            work_done_progress_params: WorkDoneProgressParams::default(),
469                            partial_result_params: lsp_types::PartialResultParams::default(),
470                        },
471                        REQUEST_TIMEOUT,
472                    )
473                    .await
474                    .map_err(lsp_err)?;
475                Ok(serde_json::to_string_pretty(&result)
476                    .unwrap_or_else(|_| "(serialization failed)".into()))
477            }
478            other => Err(format!(
479                "Raw request '{other}' is not supported via the typed LSP client. \
480                 Supported methods: workspace/symbol, textDocument/documentSymbol. \
481                 Use the dedicated LSP action (diagnostics, hover, etc.) instead."
482            )),
483        }
484    }
485}
486
487#[async_trait]
488impl LspProvider for CliLspProvider {
489    fn ensure_started_background<'a>(
490        &'a self,
491    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
492        // Lazy spawn happens on first request. Eager pre-warm is a
493        // documented follow-up — the hook exists so future work can
494        // pre-warm configured servers without changing call sites.
495        Box::pin(async {})
496    }
497
498    fn ensure_ready<'a>(
499        &'a self,
500    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send + 'a>> {
501        // Lazy initialization happens on first request, so there's
502        // nothing to "wait for" until at least one server has been
503        // spawned. Once spawned, `LspClient::start` already awaits
504        // `initialize` before returning, so we're effectively ready
505        // as soon as a client exists.
506        Box::pin(async { Ok(()) })
507    }
508
509    fn drain_diagnostics<'a>(
510        &'a self,
511        timeout: Duration,
512    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<DiagnosticsSummary>> + Send + 'a>>
513    {
514        let live = self.manager.live_clients();
515        Box::pin(async move {
516            let mut merged = DiagnosticsSummary::default();
517            for (_name, client) in live {
518                if let Some(entries) = client.drain_diagnostics(timeout).await {
519                    for entry in entries {
520                        let counts = count_diagnostics(&entry.diagnostics);
521                        merged.count += counts.total;
522                        merged.errors += counts.errors;
523                        merged.warnings += counts.warnings;
524                        merged.entries.push(FileDiagnosticEntry {
525                            uri: entry.uri,
526                            path: String::new(),
527                            diagnostics: entry.diagnostics,
528                        });
529                    }
530                }
531            }
532            if merged.count == 0 {
533                None
534            } else {
535                Some(merged)
536            }
537        })
538    }
539
540    fn read_diagnostics<'a>(
541        &'a self,
542        paths: &'a [PathBuf],
543    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<FileDiagnosticEntry>> + Send + 'a>>
544    {
545        Box::pin(async move {
546            let mut out = Vec::new();
547            for path in paths {
548                let Ok(Some(client)) = self.manager.client_for_path(path).await else {
549                    continue;
550                };
551                let Some(uri) = oxicode_lsp::uri_for(path) else {
552                    continue;
553                };
554                let uri_string = uri.to_string();
555                for entry in client.read_diagnostics(&[uri_string]) {
556                    out.push(FileDiagnosticEntry {
557                        uri: entry.uri,
558                        path: path.to_string_lossy().into_owned(),
559                        diagnostics: entry.diagnostics,
560                    });
561                }
562            }
563            out
564        })
565    }
566
567    fn notify_file_changed<'a>(
568        &'a self,
569        path: &'a std::path::Path,
570        content: &'a str,
571    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
572        Box::pin(async move {
573            let Ok(Some(client)) = self.manager.client_for_path(path).await else {
574                return;
575            };
576            let Some(uri) = oxicode_lsp::uri_for(path) else {
577                return;
578            };
579            // Best-effort didOpen with the new content. We send
580            // didOpen rather than didChange because the agent's
581            // write/edit tools have already flushed the new content
582            // to disk; the server sees the file as freshly opened
583            // with the latest text.
584            let _ = client.notify::<lsp_types::notification::DidOpenTextDocument>(
585                lsp_types::DidOpenTextDocumentParams {
586                    text_document: lsp_types::TextDocumentItem {
587                        uri,
588                        language_id: path
589                            .extension()
590                            .and_then(|e| e.to_str())
591                            .unwrap_or("plaintext")
592                            .into(),
593                        version: 1,
594                        text: content.into(),
595                    },
596                },
597            );
598        })
599    }
600
601    fn execute_action<'a>(
602        &'a self,
603        action: &'a LspAction,
604    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, ToolError>> + Send + 'a>>
605    {
606        Box::pin(async move {
607            match action {
608                LspAction::Status => {
609                    let live = self.manager.live_clients();
610                    let mut s = format!(
611                        "LSP status: {} configured, {} live\n",
612                        self.manager.server_count(),
613                        live.len()
614                    );
615                    for (name, _) in &live {
616                        s.push_str(&format!("  - {name}\n"));
617                    }
618                    Ok(s)
619                }
620                LspAction::Diagnostics { file } => self.action_diagnostics(file).await,
621                LspAction::Definition { file, line, .. } => {
622                    self.action_definition(file, *line).await
623                }
624                LspAction::References { file, line, .. } => {
625                    self.action_references(file, *line).await
626                }
627                LspAction::Hover { file, line, .. } => self.action_hover(file, *line).await,
628                LspAction::Rename {
629                    file,
630                    line,
631                    new_name,
632                    apply,
633                    ..
634                } => {
635                    self.action_rename(file, *line, new_name.clone(), *apply)
636                        .await
637                }
638                LspAction::Symbols { file, query, .. } => {
639                    self.action_symbols(file, query.clone()).await
640                }
641                LspAction::CodeActions { file, line, .. } => {
642                    self.action_code_actions(file, *line).await
643                }
644                LspAction::TypeDefinition { file, line, .. } => {
645                    self.action_type_definition(file, *line).await
646                }
647                LspAction::Implementation { file, line, .. } => {
648                    self.action_implementation(file, *line).await
649                }
650                LspAction::FileRename {
651                    old_path,
652                    new_path,
653                    apply,
654                } => self.action_file_rename(old_path, new_path, *apply).await,
655                LspAction::Reload => self.action_reload().await,
656                LspAction::Capabilities => self.action_capabilities().await,
657                LspAction::Request { query, payload } => {
658                    self.action_raw_request(query, payload.clone()).await
659                }
660            }
661        })
662    }
663}
664
665// ── formatting helpers ───────────────────────────────────────────
666
667fn lsp_err(e: oxicode_lsp::LspError) -> ToolError {
668    format!("LSP request failed: {e}")
669}
670
671fn marked_string_to_string(s: &lsp_types::MarkedString) -> String {
672    match s {
673        lsp_types::MarkedString::String(s) => s.clone(),
674        lsp_types::MarkedString::LanguageString(ls) => ls.value.clone(),
675    }
676}
677
678fn format_locations(resp: &lsp_types::GotoDefinitionResponse) -> String {
679    use lsp_types::GotoDefinitionResponse::*;
680    let locs: Vec<lsp_types::Location> = match resp {
681        Scalar(l) => vec![l.clone()],
682        Array(arr) => arr.clone(),
683        Link(links) => links
684            .iter()
685            .map(|l| lsp_types::Location {
686                uri: l.target_uri.clone(),
687                range: l.target_selection_range,
688            })
689            .collect(),
690    };
691    if locs.is_empty() {
692        return "(no locations)".into();
693    }
694    locs.iter()
695        .map(|l| {
696            format!(
697                "{}:{}:{}",
698                l.uri.as_str(),
699                l.range.start.line + 1,
700                l.range.start.character + 1
701            )
702        })
703        .collect::<Vec<_>>()
704        .join("\n")
705}
706
707fn format_symbols_flat(symbols: &[lsp_types::SymbolInformation]) -> String {
708    if symbols.is_empty() {
709        return "(no symbols)".into();
710    }
711    symbols
712        .iter()
713        .map(|s| {
714            let kind = format!("{:?}", s.kind).to_lowercase();
715            format!(
716                "{} ({}) — {}:{}",
717                s.name,
718                kind,
719                s.location.uri.as_str(),
720                s.location.range.start.line + 1
721            )
722        })
723        .collect::<Vec<_>>()
724        .join("\n")
725}
726
727fn format_code_actions(actions: &[lsp_types::CodeActionOrCommand]) -> String {
728    if actions.is_empty() {
729        return "(no code actions)".into();
730    }
731    actions
732        .iter()
733        .map(|a| match a {
734            lsp_types::CodeActionOrCommand::CodeAction(ca) => {
735                format!("CodeAction: {} ({:?})", ca.title, ca.kind)
736            }
737            lsp_types::CodeActionOrCommand::Command(cmd) => {
738                format!("Command: {} ({})", cmd.title, cmd.command)
739            }
740        })
741        .collect::<Vec<_>>()
742        .join("\n")
743}
744
745fn summarize_workspace_edit(edit: &lsp_types::WorkspaceEdit) -> String {
746    let changes = edit.changes.as_ref().map(|c| c.len()).unwrap_or(0);
747    let doc_changes = edit
748        .document_changes
749        .as_ref()
750        .map(|c| match c {
751            lsp_types::DocumentChanges::Edits(e) => e.len(),
752            lsp_types::DocumentChanges::Operations(o) => o.len(),
753        })
754        .unwrap_or(0);
755    format!("WorkspaceEdit: {changes} uri-changes, {doc_changes} document-changes")
756}
757
758/// Apply a `WorkspaceEdit` to disk.
759///
760/// Supports `documentChanges` (modern) and `changes` (legacy) formats.
761/// Edits are applied bottom-to-top per file to preserve positions.
762/// Uses atomic temp+rename writes (crash-safe).
763fn apply_workspace_edit(edit: &lsp_types::WorkspaceEdit) -> Result<(), ToolError> {
764    if let Some(changes) = &edit.document_changes {
765        match changes {
766            lsp_types::DocumentChanges::Edits(edits) => {
767                for doc_edit in edits {
768                    apply_text_document_edit(doc_edit)?;
769                }
770            }
771            lsp_types::DocumentChanges::Operations(_ops) => {
772                // Resource operations (create/delete/rename) are rare;
773                // skip for now to keep the implementation focused.
774            }
775        }
776    } else if let Some(changes) = &edit.changes {
777        for (_uri, text_edits) in changes {
778            let path = _uri
779                .to_file_path()
780                .map_err(|_| format!("invalid URI in changes: {_uri}"))?;
781            let content = std::fs::read_to_string(&path)
782                .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
783            let has_trailing_newline = content.ends_with('\n');
784            let mut lines: Vec<String> = content.lines().map(String::from).collect();
785
786            let mut sorted = text_edits.clone();
787            sorted.sort_by_key(|b| std::cmp::Reverse(b.range.start.line));
788
789            for text_edit in &sorted {
790                apply_text_edit_to_lines(&mut lines, text_edit);
791            }
792
793            let mut output = lines.join("\n");
794            if has_trailing_newline {
795                output.push('\n');
796            }
797            atomic_write(&path, &output)?;
798        }
799    }
800    Ok(())
801}
802
803/// Atomic file write: write to a temp file in the same directory, then
804/// rename. Crash-safe: a crash mid-write leaves the original intact.
805fn atomic_write(path: &std::path::Path, content: &str) -> Result<(), ToolError> {
806    let dir = path.parent().unwrap_or(std::path::Path::new("."));
807    let mut tmp = dir.to_path_buf();
808    tmp.push(format!(
809        ".tmp.{}",
810        path.file_name().unwrap_or_default().to_string_lossy()
811    ));
812    std::fs::write(&tmp, content)
813        .map_err(|e| format!("failed to write temp {}: {e}", tmp.display()))?;
814    std::fs::rename(&tmp, path).map_err(|e| {
815        format!(
816            "failed to rename {} -> {}: {e}",
817            tmp.display(),
818            path.display()
819        )
820    })?;
821    Ok(())
822}
823
824/// Apply a single `TextDocumentEdit` (modern format).
825fn apply_text_document_edit(doc_edit: &lsp_types::TextDocumentEdit) -> Result<(), ToolError> {
826    let uri = &doc_edit.text_document.uri;
827    let path = uri
828        .to_file_path()
829        .map_err(|_| format!("invalid URI: {uri}"))?;
830    let content = std::fs::read_to_string(&path)
831        .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
832    let has_trailing_newline = content.ends_with('\n');
833    let mut lines: Vec<String> = content.lines().map(String::from).collect();
834
835    let mut edits: Vec<&lsp_types::TextEdit> = Vec::with_capacity(doc_edit.edits.len());
836    for edit in &doc_edit.edits {
837        match edit {
838            lsp_types::OneOf::Left(text_edit) => edits.push(text_edit),
839            lsp_types::OneOf::Right(annotated) => edits.push(&annotated.text_edit),
840        }
841    }
842
843    edits.sort_by_key(|b| std::cmp::Reverse(b.range.start.line));
844
845    for text_edit in edits {
846        apply_text_edit_to_lines(&mut lines, text_edit);
847    }
848
849    let mut output = lines.join("\n");
850    if has_trailing_newline {
851        output.push('\n');
852    }
853    atomic_write(&path, &output)?;
854    Ok(())
855}
856
857/// Convert an LSP UTF-16 code-unit offset to a Rust byte offset.
858///
859/// LSP uses UTF-16 code units for character positions (per the spec),
860/// while Rust uses byte indices. Direct byte slicing with an LSP
861/// offset would panic on non-ASCII characters.
862fn u16_to_byte_offset(line: &str, u16_offset: usize) -> usize {
863    let mut u16_pos = 0;
864    for (byte_off, c) in line.char_indices() {
865        if u16_pos >= u16_offset {
866            return byte_off;
867        }
868        u16_pos += c.len_utf16();
869    }
870    line.len()
871}
872
873/// Apply a single `TextEdit` to a mutable line buffer (bottom-to-top safe).
874///
875/// Converts LSP UTF-16 code-unit offsets to byte offsets before slicing
876/// to avoid panicking on non-ASCII characters.
877fn apply_text_edit_to_lines(lines: &mut Vec<String>, edit: &lsp_types::TextEdit) {
878    let start_line = edit.range.start.line as usize;
879    let end_line = edit.range.end.line as usize;
880
881    if start_line >= lines.len() {
882        lines.push(edit.new_text.clone());
883        return;
884    }
885
886    if start_line == end_line {
887        let line = &lines[start_line];
888        let start_col = u16_to_byte_offset(line, edit.range.start.character as usize);
889        let end_col = u16_to_byte_offset(line, edit.range.end.character as usize);
890        let end_col = end_col.min(line.len());
891        let before = &line[..start_col.min(line.len())];
892        let after = &line[end_col..];
893        let mut new_line = String::with_capacity(before.len() + edit.new_text.len() + after.len());
894        new_line.push_str(before);
895        new_line.push_str(&edit.new_text);
896        new_line.push_str(after);
897        lines[start_line] = new_line;
898    } else if end_line < lines.len() {
899        let start_col = u16_to_byte_offset(&lines[start_line], edit.range.start.character as usize);
900        let end_col = u16_to_byte_offset(&lines[end_line], edit.range.end.character as usize);
901        let before = lines[start_line][..start_col.min(lines[start_line].len())].to_string();
902        let after = lines[end_line][end_col.min(lines[end_line].len())..].to_string();
903        let mut new_line = String::with_capacity(before.len() + edit.new_text.len() + after.len());
904        new_line.push_str(&before);
905        new_line.push_str(&edit.new_text);
906        new_line.push_str(&after);
907        let _ = lines.splice(start_line..=end_line, std::iter::once(new_line));
908    }
909}
910
911fn summarize_entries(
912    path: &std::path::Path,
913    entries: &[oxicode_lsp::PublishedDiagnostics],
914) -> String {
915    if entries.is_empty() {
916        return format!("{}: no diagnostics", path.display());
917    }
918    let mut out = String::new();
919    for entry in entries {
920        let counts = count_diagnostics(&entry.diagnostics);
921        out.push_str(&format!(
922            "{}: {} diagnostics ({} errors, {} warnings)\n",
923            entry.uri, counts.total, counts.errors, counts.warnings
924        ));
925    }
926    out
927}
928
929#[derive(Default)]
930struct DiagnosticCounts {
931    total: usize,
932    errors: usize,
933    warnings: usize,
934}
935
936fn count_diagnostics(value: &serde_json::Value) -> DiagnosticCounts {
937    let mut c = DiagnosticCounts::default();
938    if let Some(arr) = value.as_array() {
939        c.total = arr.len();
940        for d in arr {
941            if let Some(sev) = d.get("severity").and_then(|v| v.as_u64()) {
942                match sev {
943                    1 => c.errors += 1,
944                    2 => c.warnings += 1,
945                    _ => {}
946                }
947            }
948        }
949    }
950    c
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    // ── u16_to_byte_offset ───────────────────────────────────────────
958
959    #[test]
960    fn u16_offset_ascii_returns_same() {
961        assert_eq!(u16_to_byte_offset("hello", 0), 0);
962        assert_eq!(u16_to_byte_offset("hello", 3), 3);
963        assert_eq!(u16_to_byte_offset("hello", 5), 5);
964    }
965
966    #[test]
967    fn u16_offset_beyond_line_returns_len() {
968        assert_eq!(u16_to_byte_offset("hi", 100), 2);
969        assert_eq!(u16_to_byte_offset("", 5), 0);
970    }
971
972    #[test]
973    fn u16_offset_korean_converts_correctly() {
974        // 한글 (Hangul) is in BMP (U+AC00-U+D7AF) = 1 UTF-16 unit each
975        // "안녕하세요" = 5 chars = 5 UTF-16 units, each 3 UTF-8 bytes = 15 bytes
976        let s = "안녕하세요";
977        assert_eq!(u16_to_byte_offset(s, 0), 0); // '안'
978        assert_eq!(u16_to_byte_offset(s, 1), 3); // '녕'
979        assert_eq!(u16_to_byte_offset(s, 2), 6); // '하'
980        assert_eq!(u16_to_byte_offset(s, 5), 15); // end
981    }
982
983    #[test]
984    fn u16_offset_mixed_ascii_korean() {
985        // "abc안녕" = 'a'(1U16,1byte) 'b'(1,1) 'c'(1,1) '안'(1U16,3byte) '녕'(1U16,3byte)
986        // UTF-16 positions: 0→0, 1→1, 2→2, 3→3, 4→6, 5→9(end)
987        let s = "abc안녕";
988        assert_eq!(u16_to_byte_offset(s, 0), 0); // 'a'
989        assert_eq!(u16_to_byte_offset(s, 2), 2); // 'c'
990        assert_eq!(u16_to_byte_offset(s, 3), 3); // '안' start (byte 3)
991        assert_eq!(u16_to_byte_offset(s, 4), 6); // '녕' start (byte 6)
992        assert_eq!(u16_to_byte_offset(s, 5), 9); // end
993    }
994
995    // ── apply_text_edit_to_lines ─────────────────────────────────────
996
997    fn make_edit(
998        start_line: u32,
999        start_char: u32,
1000        end_line: u32,
1001        end_char: u32,
1002        new_text: &str,
1003    ) -> lsp_types::TextEdit {
1004        lsp_types::TextEdit {
1005            range: lsp_types::Range {
1006                start: lsp_types::Position {
1007                    line: start_line,
1008                    character: start_char,
1009                },
1010                end: lsp_types::Position {
1011                    line: end_line,
1012                    character: end_char,
1013                },
1014            },
1015            new_text: new_text.to_string(),
1016        }
1017    }
1018
1019    #[test]
1020    fn apply_single_line_replace_mid() {
1021        let mut lines = vec!["hello world".to_string()];
1022        let edit = make_edit(0, 6, 0, 11, "there");
1023        apply_text_edit_to_lines(&mut lines, &edit);
1024        assert_eq!(lines, vec!["hello there"]);
1025    }
1026
1027    #[test]
1028    fn apply_single_line_insert() {
1029        let mut lines = vec!["ab".to_string()];
1030        let edit = make_edit(0, 1, 0, 1, "XX");
1031        apply_text_edit_to_lines(&mut lines, &edit);
1032        assert_eq!(lines, vec!["aXXb"]);
1033    }
1034
1035    #[test]
1036    fn apply_multi_line_replace() {
1037        let mut lines = vec!["aaa".to_string(), "bbb".to_string(), "ccc".to_string()];
1038        // Replace from line 0 char 1 to line 2 char 2 with "X\nY"
1039        // After splice: one string "aX\nYc" replaces 3 lines.
1040        // The join will produce two lines separated by the embedded \n.
1041        let edit = make_edit(0, 1, 2, 2, "X\nY");
1042        apply_text_edit_to_lines(&mut lines, &edit);
1043        assert_eq!(
1044            lines.len(),
1045            1,
1046            "splice merges replaced range into one string"
1047        );
1048        assert_eq!(lines[0], "aX\nYc", "embedded newline in new_text");
1049        // When joined, the embedded \n produces correct vertical result
1050        let result = lines.join("\n");
1051        assert_eq!(result, "aX\nYc");
1052    }
1053
1054    #[test]
1055    fn apply_edit_beyond_eof_appends() {
1056        let mut lines = vec!["a".to_string()];
1057        let edit = make_edit(5, 0, 5, 0, "new line");
1058        apply_text_edit_to_lines(&mut lines, &edit);
1059        assert_eq!(lines, vec!["a".to_string(), "new line".to_string()]);
1060    }
1061
1062    #[test]
1063    fn apply_edit_korean_column() {
1064        let mut lines = vec!["abc안녕def".to_string()];
1065        // '안' is at UTF-16 offset 3, '녕' at 4, 'd' at 5 = byte 9
1066        // Replace from '안' (U16=3) to 'd' (U16=5) with "X"
1067        let edit = make_edit(0, 3, 0, 5, "X");
1068        apply_text_edit_to_lines(&mut lines, &edit);
1069        assert_eq!(lines, vec!["abcXdef"]);
1070    }
1071
1072    #[test]
1073    fn apply_edit_preserves_trailing_newline() {
1074        let text = "line1\nline2\n";
1075        let edit = lsp_types::TextEdit {
1076            range: lsp_types::Range {
1077                start: lsp_types::Position {
1078                    line: 0,
1079                    character: 0,
1080                },
1081                end: lsp_types::Position {
1082                    line: 0,
1083                    character: 5,
1084                },
1085            },
1086            new_text: "LINE1".to_string(),
1087        };
1088
1089        // Simulate what apply_text_document_edit does
1090        let has_trailing_newline = text.ends_with('\n');
1091        let mut lines: Vec<String> = text.lines().map(String::from).collect();
1092        apply_text_edit_to_lines(&mut lines, &edit);
1093        let mut output = lines.join("\n");
1094        if has_trailing_newline {
1095            output.push('\n');
1096        }
1097        assert_eq!(output, "LINE1\nline2\n");
1098    }
1099
1100    #[test]
1101    fn apply_sorts_bottom_to_top() {
1102        // When two edits touch the same file, bottom-to-top order preserves
1103        // positions. This test verifies the sort used in apply_text_document_edit.
1104        let text = "first\nsecond\nthird";
1105        let mut lines: Vec<String> = text.lines().map(String::from).collect();
1106
1107        // Edit line 0 first, then line 2 — but apply bottom-to-top
1108        let mut edits = vec![
1109            make_edit(0, 0, 0, 5, "FIRST"), // line 0: "first" → "FIRST"
1110            make_edit(2, 0, 2, 5, "THIRD"), // line 2: "third" → "THIRD"
1111        ];
1112        edits.sort_by_key(|b| std::cmp::Reverse(b.range.start.line));
1113
1114        for edit in &edits {
1115            apply_text_edit_to_lines(&mut lines, edit);
1116        }
1117
1118        assert_eq!(lines, vec!["FIRST", "second", "THIRD"]);
1119    }
1120}