1use 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
22pub 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 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}