Skip to main content

fresh/view/
virtual_text.rs

1//! Virtual text rendering infrastructure
2//!
3//! Provides a system for rendering virtual text that doesn't exist in the buffer.
4//! Used for inlay hints (type annotations, parameter names), git blame headers, etc.
5//!
6//! Two types of virtual text are supported:
7//! - **Inline**: Text inserted before/after a character (e.g., `: i32` type hints)
8//! - **Line**: Full lines inserted above/below a position (e.g., git blame headers)
9//!
10//! Virtual text is rendered during the render phase by reading from VirtualTextManager.
11//! The buffer content remains unchanged - we just inject extra styled text during rendering.
12//!
13//! ## Architecture
14//!
15//! This follows an Emacs-like model where:
16//! 1. Plugins add virtual text in response to buffer changes (async, fire-and-forget)
17//! 2. Virtual text is stored persistently with marker-based position tracking
18//! 3. Render loop reads virtual text synchronously from memory (no async waiting)
19//!
20//! This ensures frame coherence: render always sees a consistent snapshot of virtual text.
21
22use ratatui::style::Style;
23use std::collections::HashMap;
24
25use crate::model::marker::{MarkerId, MarkerList};
26
27/// Position relative to the character at the marker position
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum VirtualTextPosition {
30    // ─── Inline positions (within a line) ───
31    /// Render before the character (e.g., parameter hints: `/*count=*/5`)
32    BeforeChar,
33    /// Render after the character (e.g., type hints: `x: i32`)
34    AfterChar,
35
36    // ─── Line positions (full lines) ───
37    /// Render as a full line ABOVE the line containing this position
38    /// Used for git blame headers, section separators, etc.
39    /// These lines do NOT get line numbers in the gutter.
40    LineAbove,
41    /// Render as a full line BELOW the line containing this position
42    /// Used for inline documentation, fold previews, etc.
43    /// These lines do NOT get line numbers in the gutter.
44    LineBelow,
45}
46
47impl VirtualTextPosition {
48    /// Returns true if this is a line-level position (LineAbove/LineBelow)
49    pub fn is_line(&self) -> bool {
50        matches!(self, Self::LineAbove | Self::LineBelow)
51    }
52
53    /// Returns true if this is an inline position (BeforeChar/AfterChar)
54    pub fn is_inline(&self) -> bool {
55        matches!(self, Self::BeforeChar | Self::AfterChar)
56    }
57}
58
59/// Namespace for grouping virtual texts (for efficient bulk removal).
60/// Similar to OverlayNamespace - plugins create a namespace once and use it for all their virtual texts.
61#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62pub struct VirtualTextNamespace(pub String);
63
64impl VirtualTextNamespace {
65    /// Create a namespace from a string (for plugin registration)
66    pub fn from_string(s: String) -> Self {
67        Self(s)
68    }
69
70    /// Get the internal string representation
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76/// A piece of virtual text to render at a specific position
77#[derive(Debug, Clone)]
78pub struct VirtualText {
79    /// Marker tracking the position (auto-adjusts on edits)
80    pub marker_id: MarkerId,
81    /// Text to display (for LineAbove/LineBelow, this is the full line content)
82    pub text: String,
83    /// Styling (typically dimmed/gray for hints)
84    pub style: Style,
85    /// Where to render relative to the marker position
86    pub position: VirtualTextPosition,
87    /// Priority for ordering multiple items at same position (higher = later)
88    pub priority: i32,
89    /// Optional string identifier for this virtual text (for plugin use)
90    pub string_id: Option<String>,
91    /// Optional namespace for bulk removal (like Overlay's namespace)
92    pub namespace: Option<VirtualTextNamespace>,
93}
94
95/// Unique identifier for a virtual text entry
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub struct VirtualTextId(pub u64);
98
99/// Manages virtual text entries for a buffer
100///
101/// Uses the marker system for position tracking, so virtual text automatically
102/// adjusts when the buffer is edited.
103pub struct VirtualTextManager {
104    /// Map from virtual text ID to virtual text entry
105    texts: HashMap<VirtualTextId, VirtualText>,
106    /// Next ID to assign
107    next_id: u64,
108}
109
110impl VirtualTextManager {
111    /// Create a new empty manager
112    pub fn new() -> Self {
113        Self {
114            texts: HashMap::new(),
115            next_id: 0,
116        }
117    }
118
119    /// Add a virtual text entry
120    ///
121    /// # Arguments
122    /// * `marker_list` - The marker list to create a position marker in
123    /// * `position` - Byte offset in the buffer
124    /// * `text` - Text to display
125    /// * `style` - Styling for the text
126    /// * `vtext_position` - Whether to render before or after the character
127    /// * `priority` - Ordering priority (higher = later in render order)
128    ///
129    /// # Returns
130    /// The ID of the created virtual text entry
131    pub fn add(
132        &mut self,
133        marker_list: &mut MarkerList,
134        position: usize,
135        text: String,
136        style: Style,
137        vtext_position: VirtualTextPosition,
138        priority: i32,
139    ) -> VirtualTextId {
140        // Create marker at position
141        // Use right affinity (false) so the marker stays with the following character
142        let marker_id = marker_list.create(position, false);
143
144        let id = VirtualTextId(self.next_id);
145        self.next_id += 1;
146
147        self.texts.insert(
148            id,
149            VirtualText {
150                marker_id,
151                text,
152                style,
153                position: vtext_position,
154                priority,
155                string_id: None,
156                namespace: None,
157            },
158        );
159
160        id
161    }
162
163    /// Add a virtual text entry with a string identifier
164    ///
165    /// This is useful for plugins that need to track and remove virtual texts by name.
166    #[allow(clippy::too_many_arguments)]
167    pub fn add_with_id(
168        &mut self,
169        marker_list: &mut MarkerList,
170        position: usize,
171        text: String,
172        style: Style,
173        vtext_position: VirtualTextPosition,
174        priority: i32,
175        string_id: String,
176    ) -> VirtualTextId {
177        let marker_id = marker_list.create(position, false);
178
179        let id = VirtualTextId(self.next_id);
180        self.next_id += 1;
181
182        self.texts.insert(
183            id,
184            VirtualText {
185                marker_id,
186                text,
187                style,
188                position: vtext_position,
189                priority,
190                string_id: Some(string_id),
191                namespace: None,
192            },
193        );
194
195        id
196    }
197
198    /// Add a virtual line (LineAbove or LineBelow) with namespace for bulk removal
199    ///
200    /// This is the primary API for features like git blame headers.
201    ///
202    /// # Arguments
203    /// * `marker_list` - The marker list to create a position marker in
204    /// * `position` - Byte offset in the buffer (anchors the line to this position)
205    /// * `text` - Full line content to display
206    /// * `style` - Styling for the line
207    /// * `placement` - LineAbove or LineBelow
208    /// * `namespace` - Namespace for bulk removal (e.g., "git-blame")
209    /// * `priority` - Ordering when multiple lines at same position
210    #[allow(clippy::too_many_arguments)]
211    pub fn add_line(
212        &mut self,
213        marker_list: &mut MarkerList,
214        position: usize,
215        text: String,
216        style: Style,
217        placement: VirtualTextPosition,
218        namespace: VirtualTextNamespace,
219        priority: i32,
220    ) -> VirtualTextId {
221        debug_assert!(
222            placement.is_line(),
223            "add_line requires LineAbove or LineBelow"
224        );
225
226        let marker_id = marker_list.create(position, false);
227
228        let id = VirtualTextId(self.next_id);
229        self.next_id += 1;
230
231        self.texts.insert(
232            id,
233            VirtualText {
234                marker_id,
235                text,
236                style,
237                position: placement,
238                priority,
239                string_id: None,
240                namespace: Some(namespace),
241            },
242        );
243
244        id
245    }
246
247    /// Remove a virtual text entry by its string identifier
248    pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool {
249        // Find the entry with matching string_id
250        let to_remove: Vec<VirtualTextId> = self
251            .texts
252            .iter()
253            .filter_map(|(id, vtext)| {
254                if vtext.string_id.as_deref() == Some(string_id) {
255                    Some(*id)
256                } else {
257                    None
258                }
259            })
260            .collect();
261
262        let mut removed = false;
263        for id in to_remove {
264            if let Some(vtext) = self.texts.remove(&id) {
265                marker_list.delete(vtext.marker_id);
266                removed = true;
267            }
268        }
269        removed
270    }
271
272    /// Remove all virtual text entries whose string_id starts with the given prefix
273    pub fn remove_by_prefix(&mut self, marker_list: &mut MarkerList, prefix: &str) {
274        // Collect markers to delete
275        let markers_to_delete: Vec<(VirtualTextId, MarkerId)> = self
276            .texts
277            .iter()
278            .filter_map(|(id, vtext)| {
279                if let Some(ref sid) = vtext.string_id {
280                    if sid.starts_with(prefix) {
281                        return Some((*id, vtext.marker_id));
282                    }
283                }
284                None
285            })
286            .collect();
287
288        // Delete markers and remove entries
289        for (id, marker_id) in markers_to_delete {
290            marker_list.delete(marker_id);
291            self.texts.remove(&id);
292        }
293    }
294
295    /// Remove a virtual text entry
296    pub fn remove(&mut self, marker_list: &mut MarkerList, id: VirtualTextId) -> bool {
297        if let Some(vtext) = self.texts.remove(&id) {
298            marker_list.delete(vtext.marker_id);
299            true
300        } else {
301            false
302        }
303    }
304
305    /// Clear all virtual text entries
306    pub fn clear(&mut self, marker_list: &mut MarkerList) {
307        for vtext in self.texts.values() {
308            marker_list.delete(vtext.marker_id);
309        }
310        self.texts.clear();
311    }
312
313    /// Get the number of virtual text entries
314    pub fn len(&self) -> usize {
315        self.texts.len()
316    }
317
318    /// Check if there are no virtual text entries
319    pub fn is_empty(&self) -> bool {
320        self.texts.is_empty()
321    }
322
323    /// Query virtual texts in a byte range
324    ///
325    /// Returns a vector of (byte_position, &VirtualText) pairs, sorted by:
326    /// 1. Byte position (ascending)
327    /// 2. Priority (ascending, so higher priority renders later)
328    ///
329    /// # Arguments
330    /// * `marker_list` - The marker list to query positions from
331    /// * `start` - Start byte offset (inclusive)
332    /// * `end` - End byte offset (exclusive)
333    pub fn query_range(
334        &self,
335        marker_list: &MarkerList,
336        start: usize,
337        end: usize,
338    ) -> Vec<(usize, &VirtualText)> {
339        let mut results: Vec<(usize, &VirtualText)> = self
340            .texts
341            .values()
342            .filter_map(|vtext| {
343                let pos = marker_list.get_position(vtext.marker_id)?;
344                if pos >= start && pos < end {
345                    Some((pos, vtext))
346                } else {
347                    None
348                }
349            })
350            .collect();
351
352        // Sort by position, then by priority
353        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
354
355        results
356    }
357
358    /// Build a lookup map for efficient per-character access during rendering
359    ///
360    /// Returns a HashMap where keys are byte positions and values are vectors
361    /// of virtual texts at that position, sorted by priority.
362    pub fn build_lookup(
363        &self,
364        marker_list: &MarkerList,
365        start: usize,
366        end: usize,
367    ) -> HashMap<usize, Vec<&VirtualText>> {
368        let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
369
370        for vtext in self.texts.values() {
371            if let Some(pos) = marker_list.get_position(vtext.marker_id) {
372                if pos >= start && pos < end {
373                    lookup.entry(pos).or_default().push(vtext);
374                }
375            }
376        }
377
378        // Sort each position's texts by priority
379        for texts in lookup.values_mut() {
380            texts.sort_by_key(|vt| vt.priority);
381        }
382
383        lookup
384    }
385
386    /// Clear all virtual texts in a namespace
387    ///
388    /// This is the primary way plugins remove their virtual texts (e.g., before updating blame data).
389    pub fn clear_namespace(
390        &mut self,
391        marker_list: &mut MarkerList,
392        namespace: &VirtualTextNamespace,
393    ) {
394        let to_remove: Vec<VirtualTextId> = self
395            .texts
396            .iter()
397            .filter_map(|(id, vtext)| {
398                if vtext.namespace.as_ref() == Some(namespace) {
399                    Some(*id)
400                } else {
401                    None
402                }
403            })
404            .collect();
405
406        for id in to_remove {
407            if let Some(vtext) = self.texts.remove(&id) {
408                marker_list.delete(vtext.marker_id);
409            }
410        }
411    }
412
413    /// Query only virtual LINES (LineAbove/LineBelow) in a byte range
414    ///
415    /// Used by the render pipeline to inject header/footer lines.
416    /// Returns (byte_position, &VirtualText) pairs sorted by position then priority.
417    pub fn query_lines_in_range(
418        &self,
419        marker_list: &MarkerList,
420        start: usize,
421        end: usize,
422    ) -> Vec<(usize, &VirtualText)> {
423        let mut results: Vec<(usize, &VirtualText)> = self
424            .texts
425            .values()
426            .filter(|vtext| vtext.position.is_line())
427            .filter_map(|vtext| {
428                let pos = marker_list.get_position(vtext.marker_id)?;
429                if pos >= start && pos < end {
430                    Some((pos, vtext))
431                } else {
432                    None
433                }
434            })
435            .collect();
436
437        // Sort by position, then by priority
438        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
439
440        results
441    }
442
443    /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range
444    ///
445    /// Used by the render pipeline to inject inline hints.
446    pub fn query_inline_in_range(
447        &self,
448        marker_list: &MarkerList,
449        start: usize,
450        end: usize,
451    ) -> Vec<(usize, &VirtualText)> {
452        let mut results: Vec<(usize, &VirtualText)> = self
453            .texts
454            .values()
455            .filter(|vtext| vtext.position.is_inline())
456            .filter_map(|vtext| {
457                let pos = marker_list.get_position(vtext.marker_id)?;
458                if pos >= start && pos < end {
459                    Some((pos, vtext))
460                } else {
461                    None
462                }
463            })
464            .collect();
465
466        // Sort by position, then by priority
467        results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority)));
468
469        results
470    }
471
472    /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position
473    ///
474    /// For each source line, the renderer can quickly check if there are
475    /// LineAbove or LineBelow virtual texts anchored to positions within that line.
476    pub fn build_lines_lookup(
477        &self,
478        marker_list: &MarkerList,
479        start: usize,
480        end: usize,
481    ) -> HashMap<usize, Vec<&VirtualText>> {
482        let mut lookup: HashMap<usize, Vec<&VirtualText>> = HashMap::new();
483
484        for vtext in self.texts.values() {
485            if !vtext.position.is_line() {
486                continue;
487            }
488            if let Some(pos) = marker_list.get_position(vtext.marker_id) {
489                if pos >= start && pos < end {
490                    lookup.entry(pos).or_default().push(vtext);
491                }
492            }
493        }
494
495        // Sort each position's texts by priority
496        for texts in lookup.values_mut() {
497            texts.sort_by_key(|vt| vt.priority);
498        }
499
500        lookup
501    }
502}
503
504impl Default for VirtualTextManager {
505    fn default() -> Self {
506        Self::new()
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use ratatui::style::Color;
514
515    fn hint_style() -> Style {
516        Style::default().fg(Color::DarkGray)
517    }
518
519    #[test]
520    fn test_new_manager() {
521        let manager = VirtualTextManager::new();
522        assert_eq!(manager.len(), 0);
523        assert!(manager.is_empty());
524    }
525
526    #[test]
527    fn test_add_virtual_text() {
528        let mut marker_list = MarkerList::new();
529        let mut manager = VirtualTextManager::new();
530
531        let id = manager.add(
532            &mut marker_list,
533            10,
534            ": i32".to_string(),
535            hint_style(),
536            VirtualTextPosition::AfterChar,
537            0,
538        );
539
540        assert_eq!(manager.len(), 1);
541        assert!(!manager.is_empty());
542        assert_eq!(id.0, 0);
543    }
544
545    #[test]
546    fn test_remove_virtual_text() {
547        let mut marker_list = MarkerList::new();
548        let mut manager = VirtualTextManager::new();
549
550        let id = manager.add(
551            &mut marker_list,
552            10,
553            ": i32".to_string(),
554            hint_style(),
555            VirtualTextPosition::AfterChar,
556            0,
557        );
558
559        assert_eq!(manager.len(), 1);
560
561        let removed = manager.remove(&mut marker_list, id);
562        assert!(removed);
563        assert_eq!(manager.len(), 0);
564
565        // Marker should also be removed
566        assert_eq!(marker_list.marker_count(), 0);
567    }
568
569    #[test]
570    fn test_remove_nonexistent() {
571        let mut marker_list = MarkerList::new();
572        let mut manager = VirtualTextManager::new();
573
574        let removed = manager.remove(&mut marker_list, VirtualTextId(999));
575        assert!(!removed);
576    }
577
578    #[test]
579    fn test_clear() {
580        let mut marker_list = MarkerList::new();
581        let mut manager = VirtualTextManager::new();
582
583        manager.add(
584            &mut marker_list,
585            10,
586            ": i32".to_string(),
587            hint_style(),
588            VirtualTextPosition::AfterChar,
589            0,
590        );
591        manager.add(
592            &mut marker_list,
593            20,
594            ": String".to_string(),
595            hint_style(),
596            VirtualTextPosition::AfterChar,
597            0,
598        );
599
600        assert_eq!(manager.len(), 2);
601        assert_eq!(marker_list.marker_count(), 2);
602
603        manager.clear(&mut marker_list);
604
605        assert_eq!(manager.len(), 0);
606        assert_eq!(marker_list.marker_count(), 0);
607    }
608
609    #[test]
610    fn test_query_range() {
611        let mut marker_list = MarkerList::new();
612        let mut manager = VirtualTextManager::new();
613
614        // Add three virtual texts at positions 10, 20, 30
615        manager.add(
616            &mut marker_list,
617            10,
618            ": i32".to_string(),
619            hint_style(),
620            VirtualTextPosition::AfterChar,
621            0,
622        );
623        manager.add(
624            &mut marker_list,
625            20,
626            ": String".to_string(),
627            hint_style(),
628            VirtualTextPosition::AfterChar,
629            0,
630        );
631        manager.add(
632            &mut marker_list,
633            30,
634            ": bool".to_string(),
635            hint_style(),
636            VirtualTextPosition::AfterChar,
637            0,
638        );
639
640        // Query range [15, 35) should return positions 20 and 30
641        let results = manager.query_range(&marker_list, 15, 35);
642        assert_eq!(results.len(), 2);
643        assert_eq!(results[0].0, 20);
644        assert_eq!(results[0].1.text, ": String");
645        assert_eq!(results[1].0, 30);
646        assert_eq!(results[1].1.text, ": bool");
647
648        // Query range [0, 15) should return position 10
649        let results = manager.query_range(&marker_list, 0, 15);
650        assert_eq!(results.len(), 1);
651        assert_eq!(results[0].0, 10);
652        assert_eq!(results[0].1.text, ": i32");
653    }
654
655    #[test]
656    fn test_query_empty_range() {
657        let mut marker_list = MarkerList::new();
658        let mut manager = VirtualTextManager::new();
659
660        manager.add(
661            &mut marker_list,
662            10,
663            ": i32".to_string(),
664            hint_style(),
665            VirtualTextPosition::AfterChar,
666            0,
667        );
668
669        // Query range with no virtual texts
670        let results = manager.query_range(&marker_list, 100, 200);
671        assert!(results.is_empty());
672    }
673
674    #[test]
675    fn test_priority_ordering() {
676        let mut marker_list = MarkerList::new();
677        let mut manager = VirtualTextManager::new();
678
679        // Add multiple virtual texts at the same position with different priorities
680        manager.add(
681            &mut marker_list,
682            10,
683            "low".to_string(),
684            hint_style(),
685            VirtualTextPosition::AfterChar,
686            0,
687        );
688        manager.add(
689            &mut marker_list,
690            10,
691            "high".to_string(),
692            hint_style(),
693            VirtualTextPosition::AfterChar,
694            10,
695        );
696        manager.add(
697            &mut marker_list,
698            10,
699            "medium".to_string(),
700            hint_style(),
701            VirtualTextPosition::AfterChar,
702            5,
703        );
704
705        let results = manager.query_range(&marker_list, 0, 20);
706        assert_eq!(results.len(), 3);
707        // Should be sorted by priority: 0, 5, 10
708        assert_eq!(results[0].1.text, "low");
709        assert_eq!(results[1].1.text, "medium");
710        assert_eq!(results[2].1.text, "high");
711    }
712
713    #[test]
714    fn test_build_lookup() {
715        let mut marker_list = MarkerList::new();
716        let mut manager = VirtualTextManager::new();
717
718        manager.add(
719            &mut marker_list,
720            10,
721            ": i32".to_string(),
722            hint_style(),
723            VirtualTextPosition::AfterChar,
724            0,
725        );
726        manager.add(
727            &mut marker_list,
728            10,
729            " = 5".to_string(),
730            hint_style(),
731            VirtualTextPosition::AfterChar,
732            1,
733        );
734        manager.add(
735            &mut marker_list,
736            20,
737            ": String".to_string(),
738            hint_style(),
739            VirtualTextPosition::AfterChar,
740            0,
741        );
742
743        let lookup = manager.build_lookup(&marker_list, 0, 30);
744
745        assert_eq!(lookup.len(), 2); // Two unique positions
746
747        let at_10 = lookup.get(&10).unwrap();
748        assert_eq!(at_10.len(), 2);
749        assert_eq!(at_10[0].text, ": i32"); // priority 0
750        assert_eq!(at_10[1].text, " = 5"); // priority 1
751
752        let at_20 = lookup.get(&20).unwrap();
753        assert_eq!(at_20.len(), 1);
754        assert_eq!(at_20[0].text, ": String");
755    }
756
757    #[test]
758    fn test_position_tracking_after_insert() {
759        let mut marker_list = MarkerList::new();
760        let mut manager = VirtualTextManager::new();
761
762        manager.add(
763            &mut marker_list,
764            10,
765            ": i32".to_string(),
766            hint_style(),
767            VirtualTextPosition::AfterChar,
768            0,
769        );
770
771        // Insert 5 bytes before position 10
772        marker_list.adjust_for_insert(5, 5);
773
774        // Virtual text should now be at position 15
775        let results = manager.query_range(&marker_list, 0, 20);
776        assert_eq!(results.len(), 1);
777        assert_eq!(results[0].0, 15);
778    }
779
780    #[test]
781    fn test_position_tracking_after_delete() {
782        let mut marker_list = MarkerList::new();
783        let mut manager = VirtualTextManager::new();
784
785        manager.add(
786            &mut marker_list,
787            20,
788            ": i32".to_string(),
789            hint_style(),
790            VirtualTextPosition::AfterChar,
791            0,
792        );
793
794        // Delete 5 bytes before position 20 (at position 10)
795        marker_list.adjust_for_delete(10, 5);
796
797        // Virtual text should now be at position 15
798        let results = manager.query_range(&marker_list, 0, 20);
799        assert_eq!(results.len(), 1);
800        assert_eq!(results[0].0, 15);
801    }
802
803    #[test]
804    fn test_before_and_after_positions() {
805        let mut marker_list = MarkerList::new();
806        let mut manager = VirtualTextManager::new();
807
808        manager.add(
809            &mut marker_list,
810            10,
811            "/*param=*/".to_string(),
812            hint_style(),
813            VirtualTextPosition::BeforeChar,
814            0,
815        );
816        manager.add(
817            &mut marker_list,
818            10,
819            ": Type".to_string(),
820            hint_style(),
821            VirtualTextPosition::AfterChar,
822            0,
823        );
824
825        let lookup = manager.build_lookup(&marker_list, 0, 20);
826        let at_10 = lookup.get(&10).unwrap();
827
828        assert_eq!(at_10.len(), 2);
829        // Both at same position, check they have different positions
830        let before = at_10
831            .iter()
832            .find(|vt| vt.position == VirtualTextPosition::BeforeChar);
833        let after = at_10
834            .iter()
835            .find(|vt| vt.position == VirtualTextPosition::AfterChar);
836
837        assert!(before.is_some());
838        assert!(after.is_some());
839        assert_eq!(before.unwrap().text, "/*param=*/");
840        assert_eq!(after.unwrap().text, ": Type");
841    }
842}