Skip to main content

lex_analysis/
annotations.rs

1//! Annotation navigation and resolution editing.
2//!
3//! This module provides editor-oriented utilities for working with annotations:
4//!
5//! - **Navigation**: Jump between annotations in document order with circular wrapping.
6//!   Useful for implementing "next annotation" / "previous annotation" commands.
7//!
8//! - **Resolution**: Toggle the `status=resolved` parameter on annotations, enabling
9//!   review workflows where annotations mark items needing attention.
10//!
11//! All functions are stateless and operate on the parsed document AST. They return
12//! enough information for editors to apply changes (ranges, text edits) without
13//! needing to understand the Lex format internals.
14
15use crate::utils::{collect_all_annotations, find_annotation_at_position};
16use lex_core::lex::ast::{Annotation, AstNode, Document, Parameter, Position, Range};
17
18/// Direction for annotation navigation.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnnotationDirection {
21    Forward,
22    Backward,
23}
24
25/// Result of navigating to an annotation.
26///
27/// Contains the annotation's metadata and location information so editors
28/// can display context and position the cursor appropriately.
29#[derive(Debug, Clone, PartialEq)]
30pub struct AnnotationNavigationResult {
31    /// The annotation label (e.g., "note", "todo", "warning").
32    pub label: String,
33    /// Key-value parameters from the annotation header.
34    pub parameters: Vec<(String, String)>,
35    /// Range covering the annotation header line (for cursor positioning).
36    pub header: Range,
37    /// Range covering the annotation body, if present.
38    pub body: Option<Range>,
39}
40
41/// A text edit that modifies an annotation's header.
42///
43/// Used by [`toggle_annotation_resolution`] to add or remove the `status=resolved`
44/// parameter. Editors should replace the text at `range` with `new_text`.
45#[derive(Debug, Clone, PartialEq)]
46pub struct AnnotationEdit {
47    /// The range to replace (the annotation header line).
48    pub range: Range,
49    /// The new header text with updated parameters.
50    pub new_text: String,
51}
52
53/// Finds the next annotation after the current position, wrapping to the first if needed.
54///
55/// Navigation wraps circularly: if the cursor is at or after the last annotation,
56/// returns the first annotation in the document. Returns `None` only if the document
57/// has no annotations.
58pub fn next_annotation(
59    document: &Document,
60    position: Position,
61) -> Option<AnnotationNavigationResult> {
62    navigate(document, position, AnnotationDirection::Forward)
63}
64
65/// Finds the previous annotation before the current position, wrapping to the last if needed.
66///
67/// Navigation wraps circularly: if the cursor is at or before the first annotation,
68/// returns the last annotation in the document. Returns `None` only if the document
69/// has no annotations.
70pub fn previous_annotation(
71    document: &Document,
72    position: Position,
73) -> Option<AnnotationNavigationResult> {
74    navigate(document, position, AnnotationDirection::Backward)
75}
76
77/// Navigates to an annotation in the specified direction.
78///
79/// This is the lower-level function used by [`next_annotation`] and [`previous_annotation`].
80/// Annotations are sorted by their header position, and navigation wraps at document
81/// boundaries.
82pub 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
102/// Toggles the resolution status of the annotation at the given position.
103///
104/// When `resolved` is `true`, adds or updates `status=resolved` in the annotation header.
105/// When `resolved` is `false`, removes the `status` parameter if present.
106///
107/// Returns `None` if:
108/// - No annotation exists at the position
109/// - The annotation already has the requested status (no change needed)
110///
111/// The returned [`AnnotationEdit`] contains the header range and new text, which
112/// the editor should apply as a text replacement.
113pub 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
130/// Computes the edit needed to change an annotation's resolution status.
131///
132/// This is the lower-level function that works directly on an [`Annotation`] reference.
133/// Use [`toggle_annotation_resolution`] for position-based lookup.
134pub 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, &params),
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(&param.key);
222        header.push('=');
223        header.push_str(&param.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}