Skip to main content

lumis_core/
annotations.rs

1//! Range annotations composed into the highlight event stream.
2
3use crate::events::HighlightEvent;
4use std::collections::{BTreeMap, BTreeSet};
5use std::error::Error;
6use std::fmt;
7use std::ops::Range;
8
9/// A zero-based source position with a UTF-8 byte column.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
11pub struct Position {
12    line: usize,
13    column: usize,
14}
15
16impl Position {
17    /// Creates a zero-based source position.
18    pub const fn new(line: usize, column: usize) -> Self {
19        Self { line, column }
20    }
21
22    /// Returns the zero-based line.
23    pub const fn line(self) -> usize {
24        self.line
25    }
26
27    /// Returns the zero-based UTF-8 byte column.
28    pub const fn column(self) -> usize {
29        self.column
30    }
31}
32
33/// A half-open annotation range expressed as offsets or source positions.
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum AnnotationRange {
37    /// Absolute offsets into the source, measured in UTF-8 bytes.
38    Offset(Range<usize>),
39    /// Zero-based lines and UTF-8 byte columns.
40    Position(Range<Position>),
41}
42
43impl From<Range<usize>> for AnnotationRange {
44    fn from(range: Range<usize>) -> Self {
45        Self::Offset(range)
46    }
47}
48
49impl From<Range<Position>> for AnnotationRange {
50    fn from(range: Range<Position>) -> Self {
51        Self::Position(range)
52    }
53}
54
55/// A caller-provided semantic range with arbitrary typed data.
56///
57/// This annotation marks only `price` in a one-line source:
58///
59/// ```rust
60/// use lumis_core::annotations::Annotation;
61///
62/// let source = "let total = price;";
63/// let annotation = Annotation::new(12..17, "search-match")?;
64///
65/// assert_eq!(&source[12..17], "price");
66/// # let _ = annotation;
67/// # Ok::<(), lumis_core::annotations::AnnotationError>(())
68/// ```
69///
70/// When passed in highlighting options, a custom formatter receives
71/// [`AnnotationStart`](crate::events::HighlightEvent::AnnotationStart) before
72/// `price` and [`AnnotationEnd`](crate::events::HighlightEvent::AnnotationEnd)
73/// after it.
74#[derive(Clone, Debug, PartialEq, Eq, Hash)]
75pub struct Annotation<T = ()> {
76    /// Half-open source range.
77    range: AnnotationRange,
78    /// Caller-owned data interpreted by formatters.
79    data: T,
80}
81
82impl<T> Annotation<T> {
83    /// Creates an annotation for an offset or position range.
84    ///
85    /// An empty range is a point: it marks a position rather than covering
86    /// text, and a formatter receives
87    /// [`AnnotationStart`](crate::events::HighlightEvent::AnnotationStart)
88    /// immediately followed by
89    /// [`AnnotationEnd`](crate::events::HighlightEvent::AnnotationEnd) there.
90    /// Line-keyed overlays need this for a blank line, which has nothing to
91    /// cover but is still somewhere a review comment can land.
92    ///
93    /// Only a reversed range is rejected. Source bounds, position bounds, and
94    /// UTF-8 character boundaries are checked when the annotation is used to
95    /// format a specific source.
96    pub fn new(range: impl Into<AnnotationRange>, data: T) -> Result<Self, AnnotationError> {
97        let range = range.into();
98        let is_reversed = match &range {
99            AnnotationRange::Offset(range) => range.start > range.end,
100            AnnotationRange::Position(range) => range.start > range.end,
101        };
102
103        if is_reversed {
104            return Err(AnnotationError::InvalidRange { range });
105        }
106
107        Ok(Self { range, data })
108    }
109
110    /// Returns the caller-provided source range.
111    pub fn range(&self) -> &AnnotationRange {
112        &self.range
113    }
114
115    /// Returns the caller-owned data.
116    pub fn data(&self) -> &T {
117        &self.data
118    }
119
120    /// Consumes the annotation and returns its caller-owned data.
121    pub fn into_data(self) -> T {
122        self.data
123    }
124}
125
126#[derive(Debug, PartialEq, Eq, Hash)]
127pub(crate) struct ResolvedAnnotation<'a, T = ()> {
128    pub(crate) range: Range<usize>,
129    pub(crate) data: &'a T,
130}
131
132impl<T> Clone for ResolvedAnnotation<'_, T> {
133    fn clone(&self) -> Self {
134        Self {
135            range: self.range.clone(),
136            data: self.data,
137        }
138    }
139}
140
141/// An annotation range is invalid for construction or for a specific source.
142#[derive(Clone, Debug, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum AnnotationError {
145    InvalidRange {
146        range: AnnotationRange,
147    },
148    OutOfBounds {
149        index: usize,
150        end: usize,
151        source_len: usize,
152    },
153    NotCharBoundary {
154        index: usize,
155        offset: usize,
156    },
157    LineOutOfBounds {
158        index: usize,
159        line: usize,
160        line_count: usize,
161    },
162    ColumnOutOfBounds {
163        index: usize,
164        position: Position,
165        line_len: usize,
166    },
167}
168
169impl fmt::Display for AnnotationError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::InvalidRange { range } => {
173                write!(
174                    f,
175                    "annotation range start must not be after its end: {range:?}"
176                )
177            }
178            Self::OutOfBounds {
179                index,
180                end,
181                source_len,
182            } => write!(
183                f,
184                "annotation {index} ends at byte {end}, beyond source length {source_len}"
185            ),
186            Self::NotCharBoundary { index, offset } => write!(
187                f,
188                "annotation {index} offset {offset} is not a UTF-8 character boundary"
189            ),
190            Self::LineOutOfBounds {
191                index,
192                line,
193                line_count,
194            } => write!(
195                f,
196                "annotation {index} line {line} is outside the source's {line_count} lines"
197            ),
198            Self::ColumnOutOfBounds {
199                index,
200                position,
201                line_len,
202            } => write!(
203                f,
204                "annotation {index} column {} is beyond line {} byte length {line_len}",
205                position.column, position.line
206            ),
207        }
208    }
209}
210
211impl Error for AnnotationError {}
212
213#[derive(Clone, Copy, PartialEq, Eq)]
214enum InputLayer<'a> {
215    Syntax {
216        id: usize,
217        scope_index: usize,
218        language: &'a str,
219    },
220    Decoration {
221        id: usize,
222        decoration: crate::decorations::Decoration,
223    },
224}
225
226#[derive(Clone, Copy, PartialEq, Eq)]
227enum ActiveLayer<'s> {
228    Input(InputLayer<'s>),
229    Annotation { index: usize },
230}
231
232impl InputLayer<'_> {
233    fn open_event<'a, T>(self) -> HighlightEvent<'a, T> {
234        match self {
235            Self::Syntax {
236                scope_index,
237                language,
238                ..
239            } => HighlightEvent::Start {
240                scope_index,
241                language: language.to_string(),
242            },
243            Self::Decoration { decoration, .. } => HighlightEvent::DecorationStart { decoration },
244        }
245    }
246
247    fn close_event<'a, T>(self) -> HighlightEvent<'a, T> {
248        match self {
249            Self::Syntax { .. } => HighlightEvent::End,
250            Self::Decoration { .. } => HighlightEvent::DecorationEnd,
251        }
252    }
253}
254
255#[derive(Default)]
256struct AnnotationBoundary {
257    starts: Vec<usize>,
258    ends: Vec<usize>,
259    /// Empty annotations sitting exactly here. They never join the layer
260    /// stack — an interval that opens and closes at one offset would be
261    /// entered and never left — so they are emitted as a start/end pair.
262    points: Vec<usize>,
263}
264
265/// Compose caller-provided annotation intervals into syntax highlight events.
266///
267/// Annotations are outer layers and syntax scopes are inner layers. When an
268/// annotation begins or ends inside an existing syntax scope, the syntax scope
269/// is closed and reopened so every formatter receives a properly nested stream.
270#[doc(hidden)]
271pub fn compose_annotations<'a, T>(
272    source: &str,
273    events: &[HighlightEvent<'_, ()>],
274    annotations: &'a [Annotation<T>],
275) -> Result<Vec<HighlightEvent<'a, T>>, AnnotationError> {
276    compose_annotations_with_placement(source, events, annotations, AnnotationPlacement::Outermost)
277}
278
279/// Compose intervals inside the input layers rather than around them.
280///
281/// Rainbow brackets use this so their theme scope wins over the generic
282/// punctuation scope while still sharing the annotation boundary walk.
283pub(crate) fn compose_annotations_innermost<'a, T>(
284    source: &str,
285    events: &[HighlightEvent<'_, ()>],
286    annotations: &'a [Annotation<T>],
287) -> Result<Vec<HighlightEvent<'a, T>>, AnnotationError> {
288    compose_annotations_with_placement(source, events, annotations, AnnotationPlacement::Innermost)
289}
290
291#[derive(Clone, Copy)]
292enum AnnotationPlacement {
293    Outermost,
294    Innermost,
295}
296
297fn compose_annotations_with_placement<'a, T>(
298    source: &str,
299    events: &[HighlightEvent<'_, ()>],
300    annotations: &'a [Annotation<T>],
301    placement: AnnotationPlacement,
302) -> Result<Vec<HighlightEvent<'a, T>>, AnnotationError> {
303    if annotations.is_empty() {
304        return Ok(copy_syntax_events(events));
305    }
306
307    let annotations = resolve_annotations(source, annotations)?;
308
309    let boundaries = annotation_boundaries(&annotations);
310    let mut boundary_positions = boundaries.keys().peekable();
311    let mut pending_points: BTreeSet<usize> = boundaries
312        .iter()
313        .filter(|(_, boundary)| !boundary.points.is_empty())
314        .map(|(offset, _)| *offset)
315        .collect();
316    let mut active_annotations = BTreeSet::new();
317    let mut input_layers = Vec::new();
318    let mut next_input_id = 0usize;
319    let mut active_layers = Vec::new();
320    let mut desired_layers = Vec::new();
321    let mut output = Vec::new();
322
323    for event in events {
324        let HighlightEvent::Source { start, end } = event else {
325            update_input_layers(event, &mut input_layers, &mut next_input_id);
326            continue;
327        };
328
329        while boundary_positions
330            .peek()
331            .is_some_and(|&&position| position <= *start)
332        {
333            let &position = boundary_positions
334                .next()
335                .expect("peeked annotation boundary exists");
336            apply_boundary(&boundaries[&position], &mut active_annotations);
337        }
338
339        let mut cursor = *start;
340        while cursor < *end {
341            let next = boundary_positions
342                .peek()
343                .map(|&&position| position)
344                .filter(|position| *position < *end)
345                .unwrap_or(*end);
346
347            set_desired_layers(
348                &active_annotations,
349                &input_layers,
350                placement,
351                &mut desired_layers,
352            );
353            transition_layers(
354                &mut output,
355                &mut active_layers,
356                &mut desired_layers,
357                &annotations,
358            );
359            emit_points(
360                &mut output,
361                &boundaries,
362                &annotations,
363                &mut pending_points,
364                cursor,
365            );
366
367            if cursor < next {
368                output.push(HighlightEvent::Source {
369                    start: cursor,
370                    end: next,
371                });
372            }
373            cursor = next;
374
375            while boundary_positions
376                .peek()
377                .is_some_and(|&&position| position == cursor)
378            {
379                let &position = boundary_positions
380                    .next()
381                    .expect("peeked annotation boundary exists");
382                apply_boundary(&boundaries[&position], &mut active_annotations);
383            }
384        }
385    }
386
387    desired_layers.clear();
388    transition_layers(
389        &mut output,
390        &mut active_layers,
391        &mut desired_layers,
392        &annotations,
393    );
394
395    // A point at the end of the source has no following text to split, and one
396    // in a source with no events at all is never reached by the walk. Both land
397    // here, outside every scope, which is where the end of the document is.
398    for offset in pending_points.clone() {
399        emit_points(
400            &mut output,
401            &boundaries,
402            &annotations,
403            &mut pending_points,
404            offset,
405        );
406    }
407
408    Ok(output)
409}
410
411fn update_input_layers<'s>(
412    event: &'s HighlightEvent<'_, ()>,
413    layers: &mut Vec<InputLayer<'s>>,
414    next_id: &mut usize,
415) {
416    match event {
417        HighlightEvent::Start {
418            scope_index,
419            language,
420        } => {
421            layers.push(InputLayer::Syntax {
422                id: *next_id,
423                scope_index: *scope_index,
424                language,
425            });
426            *next_id += 1;
427        }
428        HighlightEvent::End if matches!(layers.last(), Some(InputLayer::Syntax { .. })) => {
429            layers.pop();
430        }
431        HighlightEvent::DecorationStart { decoration } => {
432            layers.push(InputLayer::Decoration {
433                id: *next_id,
434                decoration: *decoration,
435            });
436            *next_id += 1;
437        }
438        HighlightEvent::DecorationEnd
439            if matches!(layers.last(), Some(InputLayer::Decoration { .. })) =>
440        {
441            layers.pop();
442        }
443        HighlightEvent::Source { .. }
444        | HighlightEvent::End
445        | HighlightEvent::AnnotationStart { .. }
446        | HighlightEvent::AnnotationEnd
447        | HighlightEvent::DecorationEnd => {}
448    }
449}
450
451fn copy_syntax_events<'a, T>(events: &[HighlightEvent<'_, ()>]) -> Vec<HighlightEvent<'a, T>> {
452    let mut output = Vec::with_capacity(events.len());
453
454    for event in events {
455        output.push(match event {
456            HighlightEvent::Start {
457                scope_index,
458                language,
459            } => HighlightEvent::Start {
460                scope_index: *scope_index,
461                language: language.clone(),
462            },
463            HighlightEvent::Source { start, end } => HighlightEvent::Source {
464                start: *start,
465                end: *end,
466            },
467            HighlightEvent::End => HighlightEvent::End,
468            HighlightEvent::DecorationStart { decoration } => HighlightEvent::DecorationStart {
469                decoration: *decoration,
470            },
471            HighlightEvent::DecorationEnd => HighlightEvent::DecorationEnd,
472            HighlightEvent::AnnotationStart { .. } | HighlightEvent::AnnotationEnd => continue,
473        });
474    }
475
476    output
477}
478
479fn resolve_annotations<'a, T>(
480    source: &str,
481    annotations: &'a [Annotation<T>],
482) -> Result<Vec<ResolvedAnnotation<'a, T>>, AnnotationError> {
483    let line_starts = annotations
484        .iter()
485        .any(|annotation| matches!(annotation.range, AnnotationRange::Position(_)))
486        .then(|| source_line_starts(source));
487    let mut resolved = Vec::with_capacity(annotations.len());
488
489    for (index, annotation) in annotations.iter().enumerate() {
490        let range = match &annotation.range {
491            AnnotationRange::Offset(range) => range.clone(),
492            AnnotationRange::Position(range) => {
493                let line_starts = line_starts
494                    .as_deref()
495                    .expect("line starts exist when position ranges are present");
496                resolve_position(source, line_starts, index, range.start)?
497                    ..resolve_position(source, line_starts, index, range.end)?
498            }
499        };
500
501        if range.end > source.len() {
502            return Err(AnnotationError::OutOfBounds {
503                index,
504                end: range.end,
505                source_len: source.len(),
506            });
507        }
508        for offset in [range.start, range.end] {
509            if !source.is_char_boundary(offset) {
510                return Err(AnnotationError::NotCharBoundary { index, offset });
511            }
512        }
513
514        resolved.push(ResolvedAnnotation {
515            range,
516            data: &annotation.data,
517        });
518    }
519
520    Ok(resolved)
521}
522
523fn source_line_starts(source: &str) -> Vec<usize> {
524    let mut starts = vec![0];
525    starts.extend(
526        source
527            .bytes()
528            .enumerate()
529            .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
530    );
531    starts
532}
533
534fn resolve_position(
535    source: &str,
536    line_starts: &[usize],
537    index: usize,
538    position: Position,
539) -> Result<usize, AnnotationError> {
540    let Some(&line_start) = line_starts.get(position.line) else {
541        return Err(AnnotationError::LineOutOfBounds {
542            index,
543            line: position.line,
544            line_count: line_starts.len(),
545        });
546    };
547    let line_end = line_starts
548        .get(position.line + 1)
549        .map_or(source.len(), |next_start| next_start - 1);
550    let line_len = line_end - line_start;
551
552    if position.column > line_len {
553        return Err(AnnotationError::ColumnOutOfBounds {
554            index,
555            position,
556            line_len,
557        });
558    }
559
560    Ok(line_start + position.column)
561}
562
563fn annotation_boundaries<T>(
564    annotations: &[ResolvedAnnotation<'_, T>],
565) -> BTreeMap<usize, AnnotationBoundary> {
566    let mut boundaries = BTreeMap::<usize, AnnotationBoundary>::new();
567    for (index, annotation) in annotations.iter().enumerate() {
568        if annotation.range.start == annotation.range.end {
569            boundaries
570                .entry(annotation.range.start)
571                .or_default()
572                .points
573                .push(index);
574            continue;
575        }
576        boundaries
577            .entry(annotation.range.start)
578            .or_default()
579            .starts
580            .push(index);
581        boundaries
582            .entry(annotation.range.end)
583            .or_default()
584            .ends
585            .push(index);
586    }
587    boundaries
588}
589
590/// Emits the empty annotations sitting at `offset`, if any, as start/end pairs.
591///
592/// Called with the layer stack already transitioned for `offset`, so a point at
593/// the start of a span lands inside it and a point where a span ends lands
594/// outside it, matching the half-open ranges everywhere else.
595fn emit_points<'a, T>(
596    output: &mut Vec<HighlightEvent<'a, T>>,
597    boundaries: &BTreeMap<usize, AnnotationBoundary>,
598    annotations: &[ResolvedAnnotation<'a, T>],
599    pending: &mut BTreeSet<usize>,
600    offset: usize,
601) {
602    if !pending.remove(&offset) {
603        return;
604    }
605    for index in &boundaries[&offset].points {
606        let annotation = &annotations[*index];
607        output.push(HighlightEvent::AnnotationStart {
608            range: annotation.range.clone(),
609            data: annotation.data,
610        });
611        output.push(HighlightEvent::AnnotationEnd);
612    }
613}
614
615fn apply_boundary(boundary: &AnnotationBoundary, active: &mut BTreeSet<usize>) {
616    for index in &boundary.ends {
617        active.remove(index);
618    }
619    for index in &boundary.starts {
620        active.insert(*index);
621    }
622}
623
624fn set_desired_layers<'s>(
625    active_annotations: &BTreeSet<usize>,
626    input_layers: &[InputLayer<'s>],
627    placement: AnnotationPlacement,
628    layers: &mut Vec<ActiveLayer<'s>>,
629) {
630    layers.clear();
631    layers.reserve(active_annotations.len() + input_layers.len());
632
633    let push_annotations = |layers: &mut Vec<_>| {
634        for index in active_annotations {
635            layers.push(ActiveLayer::Annotation { index: *index });
636        }
637    };
638    let push_inputs = |layers: &mut Vec<_>| {
639        for layer in input_layers {
640            layers.push(ActiveLayer::Input(*layer));
641        }
642    };
643
644    match placement {
645        AnnotationPlacement::Outermost => {
646            push_annotations(layers);
647            push_inputs(layers);
648        }
649        AnnotationPlacement::Innermost => {
650            push_inputs(layers);
651            push_annotations(layers);
652        }
653    }
654}
655
656fn transition_layers<'s, 'a, T>(
657    output: &mut Vec<HighlightEvent<'a, T>>,
658    current: &mut Vec<ActiveLayer<'s>>,
659    desired: &mut Vec<ActiveLayer<'s>>,
660    annotations: &[ResolvedAnnotation<'a, T>],
661) {
662    let common = current
663        .iter()
664        .zip(desired.iter())
665        .take_while(|(left, right)| left == right)
666        .count();
667
668    for layer in current[common..].iter().rev() {
669        output.push(match layer {
670            ActiveLayer::Input(layer) => layer.close_event(),
671            ActiveLayer::Annotation { .. } => HighlightEvent::AnnotationEnd,
672        });
673    }
674
675    for layer in &desired[common..] {
676        output.push(match layer {
677            ActiveLayer::Input(layer) => layer.open_event(),
678            ActiveLayer::Annotation { index } => {
679                let annotation = &annotations[*index];
680                HighlightEvent::AnnotationStart {
681                    range: annotation.range.clone(),
682                    data: annotation.data,
683                }
684            }
685        });
686    }
687
688    std::mem::swap(current, desired);
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    // The reversed literals are the input under test.
697    #[allow(clippy::reversed_empty_ranges)]
698    fn rejects_reversed_ranges_at_construction() {
699        assert_eq!(
700            Annotation::new(3..2, ()).unwrap_err(),
701            AnnotationError::InvalidRange {
702                range: AnnotationRange::Offset(3..2),
703            }
704        );
705        assert_eq!(
706            Annotation::new(Position::new(1, 0)..Position::new(0, 0), ()).unwrap_err(),
707            AnnotationError::InvalidRange {
708                range: AnnotationRange::Position(Position::new(1, 0)..Position::new(0, 0)),
709            }
710        );
711    }
712
713    #[test]
714    fn accepts_empty_ranges_as_points() {
715        assert!(Annotation::new(2..2, ()).is_ok());
716        assert!(Annotation::new(Position::new(1, 0)..Position::new(1, 0), ()).is_ok());
717    }
718
719    #[test]
720    fn composes_an_empty_annotation_as_a_start_end_pair() {
721        // A blank line: "a\n\nb" puts line 1 at offset 2, with nothing to cover.
722        let source = "a\n\nb";
723        let syntax = vec![HighlightEvent::Source { start: 0, end: 4 }];
724        let annotations = vec![Annotation::new(2..2, "blank-line").unwrap()];
725
726        let events = compose_annotations(source, &syntax, &annotations).unwrap();
727
728        assert_eq!(
729            events,
730            vec![
731                HighlightEvent::Source { start: 0, end: 2 },
732                HighlightEvent::AnnotationStart {
733                    range: 2..2,
734                    data: &"blank-line",
735                },
736                HighlightEvent::AnnotationEnd,
737                HighlightEvent::Source { start: 2, end: 4 },
738            ]
739        );
740    }
741
742    #[test]
743    fn composes_a_point_at_the_end_of_the_source() {
744        // A trailing blank line: the point sits at source.len(), where the walk
745        // over source events never lands.
746        let source = "a\n";
747        let syntax = vec![HighlightEvent::Source { start: 0, end: 2 }];
748        let annotations = vec![Annotation::new(2..2, "trailing").unwrap()];
749
750        let events = compose_annotations(source, &syntax, &annotations).unwrap();
751
752        assert_eq!(
753            events,
754            vec![
755                HighlightEvent::Source { start: 0, end: 2 },
756                HighlightEvent::AnnotationStart {
757                    range: 2..2,
758                    data: &"trailing",
759                },
760                HighlightEvent::AnnotationEnd,
761            ]
762        );
763    }
764
765    #[test]
766    fn a_point_does_not_leak_into_later_layers() {
767        // An interval opening and closing at one offset would be entered and
768        // never left, marking the whole rest of the document.
769        let source = "abcd";
770        let syntax = vec![HighlightEvent::Source { start: 0, end: 4 }];
771        let annotations = vec![Annotation::new(1..1, "point").unwrap()];
772
773        let events = compose_annotations(source, &syntax, &annotations).unwrap();
774
775        let starts = events
776            .iter()
777            .filter(|event| matches!(event, HighlightEvent::AnnotationStart { .. }))
778            .count();
779        let ends = events
780            .iter()
781            .filter(|event| matches!(event, HighlightEvent::AnnotationEnd))
782            .count();
783
784        assert_eq!((starts, ends), (1, 1));
785        assert!(matches!(
786            events.last(),
787            Some(HighlightEvent::Source { start: 1, end: 4 })
788        ));
789    }
790
791    #[test]
792    fn a_point_nests_inside_a_span_starting_at_the_same_offset() {
793        let source = "abcd";
794        let syntax = vec![HighlightEvent::Source { start: 0, end: 4 }];
795        let annotations = vec![
796            Annotation::new(1..3, "span").unwrap(),
797            Annotation::new(1..1, "point").unwrap(),
798        ];
799
800        let events = compose_annotations(source, &syntax, &annotations).unwrap();
801        let order: Vec<&str> = events
802            .iter()
803            .filter_map(|event| match event {
804                HighlightEvent::AnnotationStart { data, .. } => Some(**data),
805                _ => None,
806            })
807            .collect();
808
809        assert_eq!(order, vec!["span", "point"]);
810    }
811
812    #[test]
813    fn composition_without_annotations_preserves_syntax_events() {
814        let source = "ab";
815        let syntax = vec![
816            HighlightEvent::Start {
817                scope_index: 1,
818                language: "rust".to_string(),
819            },
820            HighlightEvent::Source { start: 0, end: 1 },
821            HighlightEvent::End,
822            HighlightEvent::Start {
823                scope_index: 1,
824                language: "rust".to_string(),
825            },
826            HighlightEvent::Source { start: 1, end: 2 },
827            HighlightEvent::End,
828        ];
829        let annotations: [Annotation; 0] = [];
830
831        let events = compose_annotations(source, &syntax, &annotations).unwrap();
832
833        assert_eq!(events, syntax);
834    }
835
836    #[test]
837    fn composition_keeps_distinct_adjacent_syntax_layers() {
838        let source = "ab";
839        let syntax = vec![
840            HighlightEvent::Start {
841                scope_index: 1,
842                language: "rust".to_string(),
843            },
844            HighlightEvent::Source { start: 0, end: 1 },
845            HighlightEvent::End,
846            HighlightEvent::Start {
847                scope_index: 1,
848                language: "rust".to_string(),
849            },
850            HighlightEvent::Source { start: 1, end: 2 },
851            HighlightEvent::End,
852        ];
853        let annotations = [Annotation::new(0..2, ()).unwrap()];
854
855        let events = compose_annotations(source, &syntax, &annotations).unwrap();
856        let syntax_starts = events
857            .iter()
858            .filter(|event| matches!(event, HighlightEvent::Start { .. }))
859            .count();
860
861        assert_eq!(syntax_starts, 2);
862    }
863
864    #[test]
865    fn composition_preserves_source_and_balances_flat_events() {
866        let source = "let value";
867        let syntax = vec![
868            HighlightEvent::Start {
869                scope_index: 1,
870                language: "rust".to_string(),
871            },
872            HighlightEvent::Source { start: 0, end: 3 },
873            HighlightEvent::End,
874            HighlightEvent::Source { start: 3, end: 4 },
875            HighlightEvent::Start {
876                scope_index: 2,
877                language: "rust".to_string(),
878            },
879            HighlightEvent::Source { start: 4, end: 9 },
880            HighlightEvent::End,
881        ];
882        let annotations = [
883            Annotation::new(1..7, ()).unwrap(),
884            Annotation::new(4..9, ()).unwrap(),
885        ];
886
887        let events = compose_annotations(source, &syntax, &annotations).unwrap();
888        let mut rendered = String::new();
889        let mut depth = 0usize;
890
891        for event in events {
892            match event {
893                HighlightEvent::Start { .. } | HighlightEvent::AnnotationStart { .. } => {
894                    depth += 1;
895                }
896                HighlightEvent::End | HighlightEvent::AnnotationEnd => {
897                    depth = depth.checked_sub(1).expect("unbalanced closing event");
898                }
899                HighlightEvent::Source { start, end } => rendered.push_str(&source[start..end]),
900                _ => unreachable!("annotation composition emits no other event"),
901            }
902        }
903
904        assert_eq!(rendered, source);
905        assert_eq!(depth, 0);
906    }
907
908    #[test]
909    fn rejects_non_utf8_boundaries() {
910        let source = "aéz";
911        let syntax = vec![HighlightEvent::Source {
912            start: 0,
913            end: source.len(),
914        }];
915        let annotations = [Annotation::new(1..2, ()).unwrap()];
916
917        assert_eq!(
918            compose_annotations(source, &syntax, &annotations).unwrap_err(),
919            AnnotationError::NotCharBoundary {
920                index: 0,
921                offset: 2,
922            }
923        );
924    }
925
926    #[test]
927    fn position_ranges_resolve_to_utf8_byte_ranges() {
928        let source = "π\r\ncafé";
929        let syntax = vec![HighlightEvent::Source {
930            start: 0,
931            end: source.len(),
932        }];
933        let annotations = [Annotation::new(Position::new(1, 0)..Position::new(1, 5), 7).unwrap()];
934
935        let events = compose_annotations(source, &syntax, &annotations).unwrap();
936        let (range, data) = events
937            .iter()
938            .find_map(|event| match event {
939                HighlightEvent::AnnotationStart { range, data } => Some((range, data)),
940                _ => None,
941            })
942            .unwrap();
943
944        assert_eq!(range, &(4..9));
945        assert_eq!(**data, 7);
946    }
947
948    #[test]
949    fn position_columns_must_be_utf8_boundaries() {
950        let source = "π\ncafé";
951        let syntax = vec![HighlightEvent::Source {
952            start: 0,
953            end: source.len(),
954        }];
955        let annotations = [Annotation::new(Position::new(1, 0)..Position::new(1, 4), ()).unwrap()];
956
957        assert_eq!(
958            compose_annotations(source, &syntax, &annotations).unwrap_err(),
959            AnnotationError::NotCharBoundary {
960                index: 0,
961                offset: 7,
962            }
963        );
964    }
965}