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
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}