Skip to main content

gruel_lsp/
code_actions.rs

1//! Code actions for diagnostic suggestions (ADR-0091 Phase 2).
2//!
3//! Every `JsonDiagnostic.suggestions` entry the compiler produces becomes
4//! a `quickfix` code action when the editor's cursor (or selected range)
5//! overlaps the diagnostic's range. We stashed the suggestions on the
6//! diagnostic's `data` field in Phase 1, so we just decode them here.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use dashmap::DashMap;
12use gruel_compiler::JsonSuggestion;
13use lsp_types::{
14    CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, Range, TextEdit, Url,
15    WorkspaceEdit,
16};
17
18use crate::diagnostics::suggestions_from_diagnostic_data;
19use crate::document::DocState;
20use crate::position::{PositionEncoding, byte_to_position};
21
22/// Build LSP `CodeAction`s for every `JsonSuggestion` attached to
23/// `diagnostics` (via Phase 1's `data` field) whose primary range overlaps
24/// the requested `range`.
25///
26/// `docs` is used to look up `LineMap`s so we can convert the suggestion's
27/// byte offsets to LSP positions. Falls back to fetching the source from
28/// disk if the file isn't open in the editor.
29pub fn code_actions_for_range(
30    diagnostics: &[Diagnostic],
31    range: Range,
32    docs: &DashMap<Url, DocState>,
33    encoding: PositionEncoding,
34    workspace_root: Option<&Path>,
35) -> Vec<CodeActionOrCommand> {
36    let mut actions = Vec::new();
37    for diag in diagnostics {
38        if !ranges_overlap(diag.range, range) {
39            continue;
40        }
41        let Some(data) = diag.data.as_ref() else {
42            continue;
43        };
44        for suggestion in suggestions_from_diagnostic_data(data) {
45            if let Some(action) =
46                build_code_action(diag, &suggestion, docs, encoding, workspace_root)
47            {
48                actions.push(CodeActionOrCommand::CodeAction(action));
49            }
50        }
51    }
52    actions
53}
54
55fn build_code_action(
56    diag: &Diagnostic,
57    suggestion: &JsonSuggestion,
58    docs: &DashMap<Url, DocState>,
59    encoding: PositionEncoding,
60    workspace_root: Option<&Path>,
61) -> Option<CodeAction> {
62    let abs_path = resolve_path(&suggestion.file, workspace_root)?;
63    let uri = Url::from_file_path(&abs_path).ok()?;
64    let range = position_range_for_suggestion(&uri, suggestion, docs, encoding)?;
65
66    let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
67    changes.insert(
68        uri,
69        vec![TextEdit {
70            range,
71            new_text: suggestion.replacement.clone(),
72        }],
73    );
74
75    let is_preferred = match suggestion.applicability.as_str() {
76        "MachineApplicable" => Some(true),
77        _ => None,
78    };
79
80    Some(CodeAction {
81        title: suggestion.message.clone(),
82        kind: Some(CodeActionKind::QUICKFIX),
83        diagnostics: Some(vec![diag.clone()]),
84        edit: Some(WorkspaceEdit {
85            changes: Some(changes),
86            document_changes: None,
87            change_annotations: None,
88        }),
89        command: None,
90        is_preferred,
91        disabled: None,
92        data: None,
93    })
94}
95
96fn position_range_for_suggestion(
97    uri: &Url,
98    suggestion: &JsonSuggestion,
99    docs: &DashMap<Url, DocState>,
100    encoding: PositionEncoding,
101) -> Option<Range> {
102    if let Some(doc) = docs.get(uri) {
103        let start = byte_to_position(&doc.line_map, &doc.text, suggestion.start, encoding);
104        let end = byte_to_position(&doc.line_map, &doc.text, suggestion.end, encoding);
105        return Some(Range { start, end });
106    }
107    // Fallback: read from disk so we can compute positions.
108    let path = uri.to_file_path().ok()?;
109    let text = std::fs::read_to_string(&path).ok()?;
110    let line_map = crate::position::LineMap::new(&text);
111    let start = byte_to_position(&line_map, &text, suggestion.start, encoding);
112    let end = byte_to_position(&line_map, &text, suggestion.end, encoding);
113    Some(Range { start, end })
114}
115
116fn ranges_overlap(a: Range, b: Range) -> bool {
117    !(a.end < b.start || b.end < a.start)
118}
119
120fn resolve_path(raw: &str, workspace_root: Option<&Path>) -> Option<std::path::PathBuf> {
121    let p = std::path::PathBuf::from(raw);
122    if p.is_absolute() {
123        return Some(p);
124    }
125    if let Some(root) = workspace_root {
126        return Some(root.join(p));
127    }
128    std::env::current_dir().ok().map(|cwd| cwd.join(p))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use lsp_types::{DiagnosticSeverity, Position};
135
136    fn make_diag_with_suggestion(start: u32, end: u32) -> Diagnostic {
137        let suggestion = JsonSuggestion {
138            message: "did you mean `i32`?".to_string(),
139            file: "main.gruel".to_string(),
140            start,
141            end,
142            replacement: "i32".to_string(),
143            applicability: "MachineApplicable".to_string(),
144        };
145        Diagnostic {
146            range: Range {
147                start: Position {
148                    line: 0,
149                    character: start,
150                },
151                end: Position {
152                    line: 0,
153                    character: end,
154                },
155            },
156            severity: Some(DiagnosticSeverity::ERROR),
157            code: None,
158            code_description: None,
159            source: Some("gruel".to_string()),
160            message: "type mismatch".to_string(),
161            related_information: None,
162            tags: None,
163            data: Some(serde_json::to_value(vec![suggestion]).unwrap()),
164        }
165    }
166
167    #[test]
168    fn produces_action_for_overlapping_diagnostic() {
169        let tmp = tempfile::tempdir().unwrap();
170        let path = tmp.path().join("main.gruel");
171        std::fs::write(&path, "fn main() -> i64 { 0 }").unwrap();
172
173        let docs = DashMap::new();
174        let diags = vec![make_diag_with_suggestion(13, 16)];
175        let range = Range {
176            start: Position {
177                line: 0,
178                character: 13,
179            },
180            end: Position {
181                line: 0,
182                character: 13,
183            },
184        };
185        let actions = code_actions_for_range(
186            &diags,
187            range,
188            &docs,
189            PositionEncoding::Utf8,
190            Some(tmp.path()),
191        );
192        assert_eq!(actions.len(), 1, "got: {:?}", actions);
193        let CodeActionOrCommand::CodeAction(ref action) = actions[0] else {
194            panic!("expected CodeAction");
195        };
196        assert_eq!(action.is_preferred, Some(true));
197        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
198    }
199
200    #[test]
201    fn no_action_when_range_disjoint() {
202        let tmp = tempfile::tempdir().unwrap();
203        let path = tmp.path().join("main.gruel");
204        std::fs::write(&path, "fn main() -> i64 { 0 }").unwrap();
205
206        let docs = DashMap::new();
207        let diags = vec![make_diag_with_suggestion(13, 16)];
208        let range = Range {
209            start: Position {
210                line: 5,
211                character: 0,
212            },
213            end: Position {
214                line: 5,
215                character: 0,
216            },
217        };
218        let actions = code_actions_for_range(
219            &diags,
220            range,
221            &docs,
222            PositionEncoding::Utf8,
223            Some(tmp.path()),
224        );
225        assert!(actions.is_empty());
226    }
227}