Skip to main content

cosh_tools/lsp/
code_actions.rs

1//! `lsp_code_actions` engine: quickfixes and refactoring suggestions.
2//!
3//! The flow that makes this valuable for agents:
4//!
5//! 1. Model writes code → passive injection reports an error
6//! 2. Model calls `lsp_code_actions(file, line)` → gets back available fixes
7//! 3. Model re-calls with `apply_index` to apply the chosen fix to disk
8//!
9//! Without this, the model would need to manually figure out the fix from
10//! the diagnostic message — slower and more error-prone.
11
12use 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
28/// List or apply code actions at a position.
29pub 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    // Build context from stored diagnostics overlapping the target line.
55    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, &params).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            // Use the RAW action to access its edit payload.
82            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
115/// Fetch raw code actions from the first capable server.
116async 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
133/// Flatten raw items into typed entries with file info.
134fn 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
167/// Apply a WorkspaceEdit to disk using the shared flattening + splicing
168/// logic (handles both `changes` and `documentChanges`).
169fn 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}