1use 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#[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
45const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
47
48impl CliLspProvider {
49 pub fn new(manager: Arc<LspManager>) -> Self {
51 Self { manager }
52 }
53
54 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 pub fn manager(&self) -> &LspManager {
63 &self.manager
64 }
65
66 pub async fn shutdown_all(&self) {
68 self.manager.shutdown_all().await;
69 }
70
71 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 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 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 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 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 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 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 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 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 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
665fn 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
758fn 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 }
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
803fn 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
824fn 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
857fn 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
873fn 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 #[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 let s = "안녕하세요";
977 assert_eq!(u16_to_byte_offset(s, 0), 0); assert_eq!(u16_to_byte_offset(s, 1), 3); assert_eq!(u16_to_byte_offset(s, 2), 6); assert_eq!(u16_to_byte_offset(s, 5), 15); }
982
983 #[test]
984 fn u16_offset_mixed_ascii_korean() {
985 let s = "abc안녕";
988 assert_eq!(u16_to_byte_offset(s, 0), 0); assert_eq!(u16_to_byte_offset(s, 2), 2); assert_eq!(u16_to_byte_offset(s, 3), 3); assert_eq!(u16_to_byte_offset(s, 4), 6); assert_eq!(u16_to_byte_offset(s, 5), 9); }
994
995 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 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 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 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 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 let text = "first\nsecond\nthird";
1105 let mut lines: Vec<String> = text.lines().map(String::from).collect();
1106
1107 let mut edits = vec![
1109 make_edit(0, 0, 0, 5, "FIRST"), make_edit(2, 0, 2, 5, "THIRD"), ];
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}