1use crate::utils::{collect_all_annotations, find_annotation_at_position};
16use lex_core::lex::ast::{Annotation, AstNode, Document, Parameter, Position, Range};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnnotationDirection {
21 Forward,
22 Backward,
23}
24
25#[derive(Debug, Clone, PartialEq)]
30pub struct AnnotationNavigationResult {
31 pub label: String,
33 pub parameters: Vec<(String, String)>,
35 pub header: Range,
37 pub body: Option<Range>,
39}
40
41#[derive(Debug, Clone, PartialEq)]
46pub struct AnnotationEdit {
47 pub range: Range,
49 pub new_text: String,
51}
52
53pub fn next_annotation(
59 document: &Document,
60 position: Position,
61) -> Option<AnnotationNavigationResult> {
62 navigate(document, position, AnnotationDirection::Forward)
63}
64
65pub fn previous_annotation(
71 document: &Document,
72 position: Position,
73) -> Option<AnnotationNavigationResult> {
74 navigate(document, position, AnnotationDirection::Backward)
75}
76
77pub fn navigate(
83 document: &Document,
84 position: Position,
85 direction: AnnotationDirection,
86) -> Option<AnnotationNavigationResult> {
87 let mut annotations = collect_annotations(document);
88 if annotations.is_empty() {
89 return None;
90 }
91 annotations.sort_by_key(|annotation| annotation.header_location().start);
92
93 let idx = match direction {
94 AnnotationDirection::Forward => next_index(&annotations, position),
95 AnnotationDirection::Backward => previous_index(&annotations, position),
96 };
97 annotations
98 .get(idx)
99 .map(|annotation| annotation_to_result(annotation))
100}
101
102pub fn toggle_annotation_resolution(
114 document: &Document,
115 position: Position,
116 resolved: bool,
117) -> Option<AnnotationEdit> {
118 let annotation = find_annotation_at_position(document, position)
119 .or_else(|| annotation_by_line(document, position))?;
120 resolution_edit(annotation, resolved)
121}
122
123fn annotation_by_line(document: &Document, position: Position) -> Option<&Annotation> {
124 let line = position.line;
125 collect_all_annotations(document)
126 .into_iter()
127 .find(|annotation| annotation.header_location().start.line == line)
128}
129
130pub fn resolution_edit(annotation: &Annotation, resolved: bool) -> Option<AnnotationEdit> {
135 let mut params = annotation.data.parameters.clone();
136 let status_index = params
137 .iter()
138 .position(|param| param.key.eq_ignore_ascii_case("status"));
139
140 if resolved {
141 match status_index {
142 Some(idx) if params[idx].value.eq_ignore_ascii_case("resolved") => return None,
143 Some(idx) => params[idx].value = "resolved".to_string(),
144 None => params.push(Parameter::new("status".to_string(), "resolved".to_string())),
145 }
146 } else if let Some(idx) = status_index {
147 params.remove(idx);
148 } else {
149 return None;
150 }
151
152 Some(AnnotationEdit {
153 range: annotation.header_location().clone(),
154 new_text: format_header(&annotation.data.label.value, ¶ms),
155 })
156}
157
158fn annotation_to_result(annotation: &Annotation) -> AnnotationNavigationResult {
159 AnnotationNavigationResult {
160 label: annotation.data.label.value.clone(),
161 parameters: annotation
162 .data
163 .parameters
164 .iter()
165 .map(|param| (param.key.clone(), param.value.clone()))
166 .collect(),
167 header: annotation.header_location().clone(),
168 body: annotation.body_location(),
169 }
170}
171
172fn next_index(entries: &[&Annotation], position: Position) -> usize {
173 if let Some(current) = containing_index(entries, position) {
174 if current + 1 >= entries.len() {
175 0
176 } else {
177 current + 1
178 }
179 } else {
180 entries
181 .iter()
182 .enumerate()
183 .find(|(_, annotation)| annotation.header_location().start > position)
184 .map(|(idx, _)| idx)
185 .unwrap_or(0)
186 }
187}
188
189fn previous_index(entries: &[&Annotation], position: Position) -> usize {
190 if let Some(current) = containing_index(entries, position) {
191 if current == 0 {
192 entries.len() - 1
193 } else {
194 current - 1
195 }
196 } else {
197 entries
198 .iter()
199 .enumerate()
200 .filter(|(_, annotation)| annotation.header_location().start < position)
201 .map(|(idx, _)| idx)
202 .next_back()
203 .unwrap_or(entries.len() - 1)
204 }
205}
206
207fn containing_index(entries: &[&Annotation], position: Position) -> Option<usize> {
208 entries
209 .iter()
210 .position(|annotation| annotation.range().contains(position))
211}
212
213pub(crate) fn collect_annotations(document: &Document) -> Vec<&Annotation> {
214 collect_all_annotations(document)
215}
216
217fn format_header(label: &str, params: &[Parameter]) -> String {
218 let mut header = format!(":: {label}");
219 for param in params {
220 header.push(' ');
221 header.push_str(¶m.key);
222 header.push('=');
223 header.push_str(¶m.value);
224 }
225 header.push_str(" ::");
226 header
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use lex_core::lex::ast::SourceLocation;
233 use lex_core::lex::parsing;
234
235 const SAMPLE: &str = r#":: note ::
236 Doc note.
237
238Intro:
239
240 :: todo ::
241 Body
242
243Paragraph text.
244
245:: info ::
246 Extra details.
247"#;
248
249 fn parse() -> Document {
250 parsing::parse_document(SAMPLE).expect("fixture parses")
251 }
252
253 fn position_of(needle: &str) -> Position {
254 let offset = SAMPLE.find(needle).expect("needle present");
255 SourceLocation::new(SAMPLE).byte_to_position(offset)
256 }
257
258 #[test]
259 fn navigates_forward_including_wrap() {
260 let document = parse();
261 let start = position_of("Intro:");
262 let first = next_annotation(&document, start).expect("annotation");
263 assert_eq!(first.label, "todo");
264
265 let within_second = position_of("Paragraph");
266 let second = next_annotation(&document, within_second).expect("next");
267 assert_eq!(second.label, "info");
268
269 let after_last = position_of("Extra details");
270 let wrap = next_annotation(&document, after_last).expect("wrap");
271 assert_eq!(wrap.label, "note");
272 }
273
274 #[test]
275 fn navigates_backward_including_wrap() {
276 let document = parse();
277 let start = position_of(":: info");
278 let prev = previous_annotation(&document, start).expect("previous");
279 assert_eq!(prev.label, "todo");
280
281 let wrap = previous_annotation(&document, position_of(":: note")).expect("wrap");
282 assert_eq!(wrap.label, "info");
283 }
284
285 #[test]
286 fn adds_status_parameter_when_resolving() {
287 let source = ":: note ::\n";
288 let document = parsing::parse_document(source).unwrap();
289 let position = SourceLocation::new(source).byte_to_position(source.find("note").unwrap());
290 let edit = toggle_annotation_resolution(&document, position, true).expect("edit");
291 assert_eq!(edit.new_text, ":: note status=resolved ::");
292 }
293
294 #[test]
295 fn removes_status_parameter_when_unresolving() {
296 use lex_core::lex::ast::{Data, Label};
297 let data = Data::new(
298 Label::new("note".to_string()),
299 vec![
300 Parameter::new("priority".to_string(), "high".to_string()),
301 Parameter::new("status".to_string(), "resolved".to_string()),
302 ],
303 );
304 let annotation = Annotation::from_data(data, Vec::new()).at(Range::new(
305 0..0,
306 Position::new(0, 0),
307 Position::new(0, 0),
308 ));
309 let edit = resolution_edit(&annotation, false).expect("edit");
310 assert_eq!(edit.new_text, ":: note priority=high ::");
311 }
312
313 #[test]
314 fn resolves_when_cursor_at_line_start() {
315 let source = ":: note ::\n";
316 let document = parsing::parse_document(source).unwrap();
317 let edit =
318 toggle_annotation_resolution(&document, Position::new(0, 0), true).expect("edit");
319 assert_eq!(edit.new_text, ":: note status=resolved ::");
320 }
321}