cosh_tools/lsp/
code_actions.rs1use std::{path::Path, sync::Arc, time::Duration};
13
14use cosh_sdk::lsp::{
15 LanguageServer, PositionEncoding,
16 lsp_types::{
17 CodeActionOrCommand, Position, WorkspaceEdit,
18 request::{CodeActionRequest as CodeActionReq, Request as _},
19 },
20};
21use serde_json::json;
22
23use super::{
24 support,
25 types::{CodeActionEntry, CodeActionsInput, CodeActionsOutput},
26};
27
28pub async fn run_code_actions(
30 deps: &super::support::Deps<'_>,
31 input: &CodeActionsInput,
32) -> Result<CodeActionsOutput, String> {
33 let path = deps.manager.root().join(&input.file_path);
34 let clients = support::prepare(deps, &path).await?;
35 if clients.is_empty() {
36 return Err(format!("no language server handles `{}`", path.display()));
37 }
38 let encoding = clients[0].position_encoding();
39
40 let line_idx = input.line.saturating_sub(1);
41 let text = std::fs::read_to_string(&path)
42 .map_err(|err| format!("cannot read `{}`: {err}", path.display()))?;
43 let line_len = text
44 .lines()
45 .nth(line_idx as usize)
46 .map(|l| l.len() as u32)
47 .unwrap_or(0);
48
49 let range = cosh_sdk::lsp::lsp_types::Range {
50 start: Position::new(line_idx, 0),
51 end: Position::new(line_idx, line_len),
52 };
53
54 let stored = deps.diagnostics.snapshot_for(&path);
56 let matching: Vec<_> = stored
57 .iter()
58 .filter(|d| d.range.start.line <= line_idx && d.range.end.line >= line_idx)
59 .cloned()
60 .collect();
61
62 let params = json!({
63 "textDocument": { "uri": support::uri(&path)? },
64 "range": range,
65 "context": {
66 "diagnostics": matching,
67 "reason": "implicit",
68 },
69 });
70
71 let raw = fetch_raw_actions(&clients, deps.request_timeout, ¶ms).await?;
72 let actions = flatten(&raw);
73
74 match input.apply_index {
75 None => Ok(CodeActionsOutput {
76 applied: false,
77 formatted: render_list(&actions),
78 actions,
79 }),
80 Some(index) => {
81 let raw_action = raw
83 .get(index)
84 .ok_or_else(|| format!("action index {index} out of range"))?;
85
86 let (title, workspace_edit) = match raw_action {
87 CodeActionOrCommand::CodeAction(action) => {
88 let edit = action.edit.as_ref().ok_or_else(|| {
89 format!(
90 "action `{}` carries a command, not an edit — cannot auto-apply",
91 action.title
92 )
93 })?;
94 (&action.title, edit)
95 }
96 CodeActionOrCommand::Command(_) => {
97 return Err("commands cannot be auto-applied".into());
98 }
99 };
100
101 apply_workspace_edit(workspace_edit, encoding)?;
102 for client in &clients {
103 let _ = client.touch_file(&path).await;
104 }
105
106 Ok(CodeActionsOutput {
107 applied: true,
108 actions: vec![],
109 formatted: format!("Applied: {title}"),
110 })
111 }
112 }
113}
114
115async fn fetch_raw_actions(
117 clients: &[Arc<LanguageServer>],
118 timeout: Duration,
119 params: &serde_json::Value,
120) -> Result<Vec<CodeActionOrCommand>, String> {
121 let response: Option<Vec<CodeActionOrCommand>> =
122 support::first_answer(clients, timeout, capability, |_client| {
123 (CodeActionReq::METHOD.to_owned(), params.clone())
124 })
125 .await?;
126 Ok(response.unwrap_or_default())
127}
128
129fn capability(caps: &cosh_sdk::lsp::lsp_types::ServerCapabilities) -> bool {
130 caps.code_action_provider.is_some()
131}
132
133fn flatten(raw: &[CodeActionOrCommand]) -> Vec<CodeActionEntry> {
135 raw.iter()
136 .enumerate()
137 .filter_map(|(index, item)| match item {
138 CodeActionOrCommand::CodeAction(action) => {
139 let files = extract_edit_files(action.edit.as_ref());
140 Some(CodeActionEntry {
141 index,
142 title: action.title.clone(),
143 kind: action.kind.as_ref().map(|k| k.as_str().to_owned()),
144 has_edit: action.edit.is_some(),
145 files,
146 })
147 }
148 CodeActionOrCommand::Command(_) => None,
149 })
150 .collect()
151}
152
153fn extract_edit_files(edit: Option<&WorkspaceEdit>) -> Vec<String> {
154 let mut files = Vec::new();
155 if let Some(edit) = edit
156 && let Some(changes) = &edit.changes
157 {
158 for uri in changes.keys() {
159 if let Some(path) = cosh_sdk::lsp::uri_to_path(uri) {
160 files.push(path.display().to_string());
161 }
162 }
163 }
164 files
165}
166
167fn apply_workspace_edit(edit: &WorkspaceEdit, encoding: PositionEncoding) -> Result<(), String> {
170 let per_file = support::flatten_workspace_edit(edit, encoding)?;
171 for (path, planned) in &per_file {
172 support::apply_edits(Path::new(path), planned)?;
173 }
174 Ok(())
175}
176
177fn render_list(actions: &[CodeActionEntry]) -> String {
178 if actions.is_empty() {
179 return "No code actions available.".to_owned();
180 }
181 actions
182 .iter()
183 .map(|action| {
184 let kind = action.kind.as_deref().unwrap_or("action");
185 let files_note = if action.files.is_empty() {
186 String::new()
187 } else {
188 format!(" [{}]", action.files.join(", "))
189 };
190 format!("[{}] ({kind}) {}{}", action.index, action.title, files_note)
191 })
192 .collect::<Vec<_>>()
193 .join("\n")
194}