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::
238
239Intro:
240
241 :: todo ::
242 Body
243 ::
244
245Paragraph text.
246
247:: info ::
248 Extra details.
249::
250"#;
251
252 fn parse() -> Document {
253 parsing::parse_document(SAMPLE).expect("fixture parses")
254 }
255
256 fn position_of(needle: &str) -> Position {
257 let offset = SAMPLE.find(needle).expect("needle present");
258 SourceLocation::new(SAMPLE).byte_to_position(offset)
259 }
260
261 #[test]
262 fn navigates_forward_including_wrap() {
263 let document = parse();
264 let start = position_of("Intro:");
265 let first = next_annotation(&document, start).expect("annotation");
266 assert_eq!(first.label, "todo");
267
268 let within_second = position_of("Paragraph");
269 let second = next_annotation(&document, within_second).expect("next");
270 assert_eq!(second.label, "info");
271
272 let after_last = position_of("Extra details");
273 let wrap = next_annotation(&document, after_last).expect("wrap");
274 assert_eq!(wrap.label, "note");
275 }
276
277 #[test]
278 fn navigates_backward_including_wrap() {
279 let document = parse();
280 let start = position_of("Paragraph text");
281 let prev = previous_annotation(&document, start).expect("previous");
282 assert_eq!(prev.label, "todo");
283
284 let wrap = previous_annotation(&document, position_of(":: note")).expect("wrap");
285 assert_eq!(wrap.label, "info");
286 }
287
288 #[test]
289 fn adds_status_parameter_when_resolving() {
290 let source = ":: note ::\n";
291 let document = parsing::parse_document(source).unwrap();
292 let position = SourceLocation::new(source).byte_to_position(source.find("note").unwrap());
293 let edit = toggle_annotation_resolution(&document, position, true).expect("edit");
294 assert_eq!(edit.new_text, ":: note status=resolved ::");
295 }
296
297 #[test]
298 fn removes_status_parameter_when_unresolving() {
299 use lex_core::lex::ast::{Data, Label};
300 let data = Data::new(
301 Label::new("note".to_string()),
302 vec![
303 Parameter::new("priority".to_string(), "high".to_string()),
304 Parameter::new("status".to_string(), "resolved".to_string()),
305 ],
306 );
307 let annotation = Annotation::from_data(data, Vec::new()).at(Range::new(
308 0..0,
309 Position::new(0, 0),
310 Position::new(0, 0),
311 ));
312 let edit = resolution_edit(&annotation, false).expect("edit");
313 assert_eq!(edit.new_text, ":: note priority=high ::");
314 }
315
316 #[test]
317 fn resolves_when_cursor_at_line_start() {
318 let source = ":: note ::\n";
319 let document = parsing::parse_document(source).unwrap();
320 let edit =
321 toggle_annotation_resolution(&document, Position::new(0, 0), true).expect("edit");
322 assert_eq!(edit.new_text, ":: note status=resolved ::");
323 }
324}