Skip to main content

fresh/
state.rs

1use crate::model::buffer::{Buffer, LineNumber};
2use crate::model::cursor::{Cursor, Cursors};
3use crate::model::document_model::{
4    DocumentCapabilities, DocumentModel, DocumentPosition, ViewportContent, ViewportLine,
5};
6use crate::model::event::{
7    Event, MarginContentData, MarginPositionData, OverlayFace as EventOverlayFace, PopupData,
8    PopupPositionData,
9};
10use crate::model::filesystem::FileSystem;
11use crate::model::marker::{MarkerId, MarkerList};
12use crate::primitives::detected_language::DetectedLanguage;
13use crate::primitives::grammar::GrammarRegistry;
14use crate::primitives::highlight_engine::HighlightEngine;
15use crate::primitives::indent::IndentCalculator;
16use crate::primitives::reference_highlighter::ReferenceHighlighter;
17use crate::primitives::text_property::TextPropertyManager;
18use crate::view::bracket_highlight_overlay::BracketHighlightOverlay;
19use crate::view::conceal::ConcealManager;
20use crate::view::folding::LspFoldRanges;
21use crate::view::margin::{MarginAnnotation, MarginContent, MarginManager, MarginPosition};
22use crate::view::overlay::{Overlay, OverlayFace, OverlayManager, UnderlineStyle};
23use crate::view::popup::{
24    Popup, PopupContent, PopupKind, PopupListItem, PopupManager, PopupPosition,
25};
26use crate::view::reference_highlight_overlay::ReferenceHighlightOverlay;
27use crate::view::soft_break::SoftBreakManager;
28use crate::view::virtual_text::VirtualTextManager;
29use anyhow::Result;
30use ratatui::style::{Color, Style};
31use std::cell::RefCell;
32use std::ops::Range;
33use std::sync::Arc;
34
35/// A marker whose position was displaced by a deletion.
36/// Stored in LogEntry (for single edits) or Event::BulkEdit (for bulk edits).
37/// On undo, the marker is restored to its exact original position.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub enum DisplacedMarker {
40    /// Marker from the main marker_list (virtual text, overlays)
41    Main { id: u64, position: usize },
42    /// Marker from margins.indicator_markers (breakpoints, line indicators)
43    Margin { id: u64, position: usize },
44}
45
46impl DisplacedMarker {
47    /// Encode as (u64, usize) for compact storage. Uses high bit to tag source.
48    pub fn encode(&self) -> (u64, usize) {
49        match self {
50            Self::Main { id, position } => (*id, *position),
51            Self::Margin { id, position } => (*id | (1u64 << 63), *position),
52        }
53    }
54
55    /// Decode from (u64, usize) compact representation.
56    pub fn decode(tagged_id: u64, position: usize) -> Self {
57        if (tagged_id >> 63) == 1 {
58            Self::Margin {
59                id: tagged_id & !(1u64 << 63),
60                position,
61            }
62        } else {
63            Self::Main {
64                id: tagged_id,
65                position,
66            }
67        }
68    }
69}
70
71/// Display mode for a buffer
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ViewMode {
74    /// Plain source rendering
75    Source,
76    /// Document-style page view with centered content, concealed markers,
77    /// and plugin-driven word wrapping (previously called "compose mode")
78    PageView,
79}
80
81/// Per-buffer user settings that should be preserved across file reloads (auto-revert).
82///
83/// These are user overrides that apply to a specific buffer, separate from:
84/// - File-derived state (syntax highlighting, language detection)
85/// - View-specific state (scroll position, line wrap - those live in SplitViewState)
86///
87/// TODO: Consider moving view-related settings (line numbers, debug mode) to SplitViewState
88/// to allow per-split preferences. Currently line numbers is in margins (coupled with plugin
89/// gutters), and debug_highlight_mode is in EditorState, but both could arguably be per-view
90/// rather than per-buffer.
91#[derive(Debug, Clone)]
92pub struct BufferSettings {
93    /// Resolved whitespace indicator visibility for this buffer.
94    /// Set based on global + language config; can be toggled per-buffer by user
95    pub whitespace: crate::config::WhitespaceVisibility,
96
97    /// Whether pressing Tab should insert a tab character instead of spaces.
98    /// Set based on language config; can be toggled per-buffer by user
99    pub use_tabs: bool,
100
101    /// Tab size (number of spaces per tab character) for rendering.
102    /// Used for visual display of tab characters and indent calculations.
103    /// Set based on language config; can be changed per-buffer by user
104    pub tab_size: usize,
105
106    /// Whether to auto-close brackets, parentheses, and quotes.
107    /// Set based on global + language config.
108    pub auto_close: bool,
109
110    /// Whether to surround selected text with matching pairs when typing a delimiter.
111    /// Set based on global + language config.
112    pub auto_surround: bool,
113
114    /// Extra characters (beyond alphanumeric + `_`) considered part of
115    /// identifiers for this language. Used by completion providers.
116    pub word_characters: String,
117}
118
119impl Default for BufferSettings {
120    fn default() -> Self {
121        Self {
122            whitespace: crate::config::WhitespaceVisibility::default(),
123            use_tabs: false,
124            tab_size: 4,
125            auto_close: true,
126            auto_surround: true,
127            word_characters: String::new(),
128        }
129    }
130}
131
132/// The complete editor state - everything needed to represent the current editing session
133///
134/// NOTE: Viewport is NOT stored here - it lives in SplitViewState.
135/// This is because viewport is view-specific (each split can view the same buffer
136/// at different scroll positions), while EditorState represents the buffer content.
137pub struct EditorState {
138    /// The text buffer
139    pub buffer: Buffer,
140
141    /// Syntax highlighter (tree-sitter or TextMate based on language)
142    pub highlighter: HighlightEngine,
143
144    /// Auto-indent calculator for smart indentation (RefCell for interior mutability)
145    pub indent_calculator: RefCell<IndentCalculator>,
146
147    /// Overlays for visual decorations (underlines, highlights, etc.)
148    pub overlays: OverlayManager,
149
150    /// Marker list for content-anchored overlay positions
151    pub marker_list: MarkerList,
152
153    /// Virtual text manager for inline hints (type hints, parameter hints, etc.)
154    pub virtual_texts: VirtualTextManager,
155
156    /// Conceal ranges for hiding/replacing byte ranges during rendering
157    pub conceals: ConcealManager,
158
159    /// Soft break points for marker-based line wrapping during rendering
160    pub soft_breaks: SoftBreakManager,
161
162    /// Popups for floating windows (completion, documentation, etc.)
163    pub popups: PopupManager,
164
165    /// Margins for line numbers, annotations, gutter symbols, etc.)
166    pub margins: MarginManager,
167
168    /// Cached line number for primary cursor (0-indexed)
169    /// Maintained incrementally to avoid O(n) scanning on every render
170    pub primary_cursor_line_number: LineNumber,
171
172    /// Current mode (for modal editing, if implemented)
173    pub mode: String,
174
175    /// Text properties for virtual buffers (embedded metadata in text ranges)
176    /// Used by virtual buffers to store location info, severity, etc.
177    pub text_properties: TextPropertyManager,
178
179    /// Whether to show cursors in this buffer (default true)
180    /// Can be set to false for virtual buffers like diagnostics panels
181    pub show_cursors: bool,
182
183    /// Whether editing is disabled for this buffer (default false)
184    /// When true, typing, deletion, cut/paste, undo/redo are blocked
185    /// but navigation, selection, and copy are still allowed
186    pub editing_disabled: bool,
187
188    /// Whether this buffer can be scrolled (default true). Fixed buffer-group
189    /// panels (toolbars, headers, footers) set this to false so the mouse
190    /// wheel is ignored and no scrollbar is drawn.
191    pub scrollable: bool,
192
193    /// Per-buffer user settings (tab size, indentation style, etc.)
194    /// These settings are preserved across file reloads (auto-revert)
195    pub buffer_settings: BufferSettings,
196
197    /// Semantic highlighter for word occurrence highlighting
198    pub reference_highlighter: ReferenceHighlighter,
199
200    /// Whether this buffer is a composite view (e.g., side-by-side diff)
201    pub is_composite_buffer: bool,
202
203    /// Debug mode: reveal highlight/overlay spans (WordPerfect-style)
204    pub debug_highlight_mode: bool,
205
206    /// Debounced semantic highlight cache
207    pub reference_highlight_overlay: ReferenceHighlightOverlay,
208
209    /// Bracket matching highlight overlay
210    pub bracket_highlight_overlay: BracketHighlightOverlay,
211
212    /// Cached LSP semantic tokens (converted to buffer byte ranges)
213    pub semantic_tokens: Option<SemanticTokenStore>,
214
215    /// Last-known LSP folding ranges for this buffer, tracked by byte markers
216    /// so they auto-adjust when content is inserted or deleted around them
217    /// (issue #1571).
218    pub folding_ranges: LspFoldRanges,
219
220    /// The detected language ID for this buffer (e.g., "rust", "csharp", "text").
221    /// Used for LSP config lookup and internal identification.
222    pub language: String,
223
224    /// Human-readable language display name (e.g., "Rust", "C#", "Plain Text").
225    /// Shown in the status bar and Set Language prompt.
226    // TODO: Consider embedding `DetectedLanguage` directly in `EditorState`
227    // instead of copying its fields, to avoid duplication between the two structs.
228    pub display_name: String,
229
230    /// Cached scrollbar visual row counts to avoid O(n) recomputation per frame.
231    /// Invalidated when buffer version, viewport width, or top_byte changes.
232    pub scrollbar_row_cache: ScrollbarRowCache,
233}
234
235/// Cache for scrollbar visual row counts.
236/// Avoids re-wrapping every line in the file on each render frame.
237#[derive(Debug, Clone, Default)]
238pub struct ScrollbarRowCache {
239    /// Buffer version when this cache was computed
240    pub buffer_version: u64,
241    /// Viewport width used for wrapping
242    pub viewport_width: u16,
243    /// Whether wrap indent was enabled
244    pub wrap_indent: bool,
245    /// Cached total visual rows
246    pub total_visual_rows: usize,
247    /// top_byte → top_visual_row mapping from last computation
248    pub top_byte: usize,
249    /// Cached top visual row
250    pub top_visual_row: usize,
251    /// top_view_line_offset used in the computation
252    pub top_view_line_offset: usize,
253    /// Whether the cache has been populated at least once
254    pub valid: bool,
255}
256
257impl EditorState {
258    /// Create a new editor state with an empty buffer
259    ///
260    /// Note: width/height parameters are kept for backward compatibility but
261    /// are no longer used - viewport is now owned by SplitViewState.
262    /// Apply a detected language to this state. This is the single mutation point
263    /// for changing the language of a buffer after creation.
264    pub fn apply_language(&mut self, detected: DetectedLanguage) {
265        self.language = detected.name;
266        self.display_name = detected.display_name;
267        self.highlighter = detected.highlighter;
268        if let Some(lang) = &detected.ts_language {
269            self.reference_highlighter.set_language(lang);
270        }
271    }
272
273    /// Create a new state with a buffer and default (plain text) language.
274    /// All other fields are initialized to their defaults.
275    fn new_from_buffer(buffer: Buffer) -> Self {
276        let mut marker_list = MarkerList::new();
277        if !buffer.is_empty() {
278            marker_list.adjust_for_insert(0, buffer.len());
279        }
280
281        Self {
282            buffer,
283            highlighter: HighlightEngine::None,
284            indent_calculator: RefCell::new(IndentCalculator::new()),
285            overlays: OverlayManager::new(),
286            marker_list,
287            virtual_texts: VirtualTextManager::new(),
288            conceals: ConcealManager::new(),
289            soft_breaks: SoftBreakManager::new(),
290            popups: PopupManager::new(),
291            margins: MarginManager::new(),
292            primary_cursor_line_number: LineNumber::Absolute(0),
293            mode: "insert".to_string(),
294            text_properties: TextPropertyManager::new(),
295            show_cursors: true,
296            editing_disabled: false,
297            scrollable: true,
298            buffer_settings: BufferSettings::default(),
299            reference_highlighter: ReferenceHighlighter::new(),
300            is_composite_buffer: false,
301            debug_highlight_mode: false,
302            reference_highlight_overlay: ReferenceHighlightOverlay::new(),
303            bracket_highlight_overlay: BracketHighlightOverlay::new(),
304            semantic_tokens: None,
305            folding_ranges: LspFoldRanges::new(),
306            language: "text".to_string(),
307            display_name: "Text".to_string(),
308            scrollbar_row_cache: ScrollbarRowCache::default(),
309        }
310    }
311
312    pub fn new(
313        _width: u16,
314        _height: u16,
315        large_file_threshold: usize,
316        fs: Arc<dyn FileSystem + Send + Sync>,
317    ) -> Self {
318        Self::new_from_buffer(Buffer::new(large_file_threshold, fs))
319    }
320
321    /// Create a new editor state with an empty buffer associated with a file path.
322    /// Used for files that don't exist yet — the path is set so saving will create the file.
323    pub fn new_with_path(
324        large_file_threshold: usize,
325        fs: Arc<dyn FileSystem + Send + Sync>,
326        path: std::path::PathBuf,
327    ) -> Self {
328        Self::new_from_buffer(Buffer::new_with_path(large_file_threshold, fs, path))
329    }
330
331    /// Set the syntax highlighting language based on a virtual buffer name.
332    /// Handles names like `*OLD:test.ts*` or `*OURS*.c` by stripping markers
333    /// and detecting language from the cleaned filename.
334    pub fn set_language_from_name(&mut self, name: &str, registry: &GrammarRegistry) {
335        let detected = DetectedLanguage::from_virtual_name(name, registry);
336        tracing::debug!(
337            "Set highlighter for virtual buffer based on name: {} (backend: {}, language: {})",
338            name,
339            detected.highlighter.backend_name(),
340            detected.name
341        );
342        self.apply_language(detected);
343    }
344
345    /// Create an editor state from a file
346    ///
347    /// Note: width/height parameters are kept for backward compatibility but
348    /// are no longer used - viewport is now owned by SplitViewState.
349    pub fn from_file(
350        path: &std::path::Path,
351        _width: u16,
352        _height: u16,
353        large_file_threshold: usize,
354        registry: &GrammarRegistry,
355        fs: Arc<dyn FileSystem + Send + Sync>,
356    ) -> anyhow::Result<Self> {
357        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
358        let detected = registry
359            .find_by_path(path)
360            .map(|entry| DetectedLanguage::from_entry(entry, registry))
361            .unwrap_or_else(DetectedLanguage::plain_text);
362        let mut state = Self::new_from_buffer(buffer);
363        state.apply_language(detected);
364        Ok(state)
365    }
366
367    /// Create an editor state from a file with language configuration.
368    ///
369    /// This version uses the provided languages configuration for syntax detection,
370    /// allowing user-configured filename patterns to be respected for highlighting.
371    ///
372    /// Note: width/height parameters are kept for backward compatibility but
373    /// are no longer used - viewport is now owned by SplitViewState.
374    pub fn from_file_with_languages(
375        path: &std::path::Path,
376        _width: u16,
377        _height: u16,
378        large_file_threshold: usize,
379        registry: &GrammarRegistry,
380        languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
381        fs: Arc<dyn FileSystem + Send + Sync>,
382    ) -> anyhow::Result<Self> {
383        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
384        let detected = DetectedLanguage::from_path(path, registry, languages);
385        let mut state = Self::new_from_buffer(buffer);
386        state.apply_language(detected);
387        Ok(state)
388    }
389
390    /// Create an editor state from a buffer and a pre-built `DetectedLanguage`.
391    ///
392    /// This is useful when you have already loaded a buffer with a specific encoding
393    /// and want to create an EditorState from it.
394    pub fn from_buffer_with_language(buffer: Buffer, detected: DetectedLanguage) -> Self {
395        let mut state = Self::new_from_buffer(buffer);
396        state.apply_language(detected);
397        state
398    }
399
400    /// Handle an Insert event - adjusts markers, buffer, highlighter, cursors, and line numbers
401    fn apply_insert(
402        &mut self,
403        cursors: &mut Cursors,
404        position: usize,
405        text: &str,
406        cursor_id: crate::model::event::CursorId,
407    ) {
408        let newlines_inserted = text.matches('\n').count();
409
410        // CRITICAL: Adjust markers BEFORE modifying buffer
411        self.marker_list.adjust_for_insert(position, text.len());
412        self.margins.adjust_for_insert(position, text.len());
413
414        // Insert text into buffer
415        self.buffer.insert(position, text);
416
417        // Notify highlighter of the insert (adjusts checkpoint marker positions)
418        // and invalidate span cache for the edited range.
419        self.highlighter.notify_insert(position, text.len());
420        self.highlighter
421            .invalidate_range(position..position + text.len());
422
423        // Note: reference_highlight_overlay uses markers that auto-adjust,
424        // so no manual invalidation needed
425
426        // Adjust all cursors after the edit
427        cursors.adjust_for_edit(position, 0, text.len());
428
429        // Move the cursor that made the edit to the end of the insertion
430        if let Some(cursor) = cursors.get_mut(cursor_id) {
431            cursor.position = position + text.len();
432            cursor.clear_selection();
433        }
434
435        // Update primary cursor line number if this was the primary cursor
436        if cursor_id == cursors.primary_id() {
437            self.primary_cursor_line_number = match self.primary_cursor_line_number {
438                LineNumber::Absolute(line) => LineNumber::Absolute(line + newlines_inserted),
439                LineNumber::Relative {
440                    line,
441                    from_cached_line,
442                } => LineNumber::Relative {
443                    line: line + newlines_inserted,
444                    from_cached_line,
445                },
446            };
447        }
448    }
449
450    /// Handle a Delete event - adjusts markers, buffer, highlighter, cursors, and line numbers
451    fn apply_delete(
452        &mut self,
453        cursors: &mut Cursors,
454        range: &std::ops::Range<usize>,
455        cursor_id: crate::model::event::CursorId,
456        deleted_text: &str,
457    ) {
458        let len = range.len();
459
460        // Count newlines deleted BEFORE the primary cursor's original position.
461        // For backspace: cursor was at range.end, so all deleted newlines are before it.
462        // For forward delete: cursor was at range.start, so no deleted newlines are before it.
463        let primary_newlines_removed = if cursor_id == cursors.primary_id() {
464            let cursor_pos = cursors.get(cursor_id).map_or(range.start, |c| c.position);
465            let bytes_before_cursor = cursor_pos.saturating_sub(range.start).min(len);
466            deleted_text[..bytes_before_cursor].matches('\n').count()
467        } else {
468            0
469        };
470
471        // Drop virtual texts whose anchors are being erased. This is what
472        // makes inlay hints disappear immediately when the range containing
473        // them is deleted; without this the marker would just clamp to
474        // range.start and the hint would linger at the wrong position until
475        // the next LSP refresh.
476        self.virtual_texts
477            .remove_in_range(&mut self.marker_list, range.start, range.end);
478
479        // CRITICAL: Adjust markers BEFORE modifying buffer
480        self.marker_list.adjust_for_delete(range.start, len);
481        self.margins.adjust_for_delete(range.start, len);
482
483        // Delete from buffer
484        self.buffer.delete(range.clone());
485
486        // Notify highlighter of the delete (adjusts checkpoint marker positions)
487        // and invalidate span cache for the edited range.
488        self.highlighter.notify_delete(range.start, len);
489        self.highlighter.invalidate_range(range.clone());
490
491        // Note: reference_highlight_overlay uses markers that auto-adjust,
492        // so no manual invalidation needed
493
494        // Adjust all cursors after the edit
495        cursors.adjust_for_edit(range.start, len, 0);
496
497        // Move the cursor that made the edit to the start of deletion
498        if let Some(cursor) = cursors.get_mut(cursor_id) {
499            cursor.position = range.start;
500            cursor.clear_selection();
501        }
502
503        // Update primary cursor line number if this was the primary cursor
504        if cursor_id == cursors.primary_id() && primary_newlines_removed > 0 {
505            self.primary_cursor_line_number = match self.primary_cursor_line_number {
506                LineNumber::Absolute(line) => {
507                    LineNumber::Absolute(line.saturating_sub(primary_newlines_removed))
508                }
509                LineNumber::Relative {
510                    line,
511                    from_cached_line,
512                } => LineNumber::Relative {
513                    line: line.saturating_sub(primary_newlines_removed),
514                    from_cached_line,
515                },
516            };
517        }
518    }
519
520    /// Apply an event to the state - THE ONLY WAY TO MODIFY STATE
521    /// This is the heart of the event-driven architecture
522    pub fn apply(&mut self, cursors: &mut Cursors, event: &Event) {
523        match event {
524            Event::Insert {
525                position,
526                text,
527                cursor_id,
528            } => self.apply_insert(cursors, *position, text, *cursor_id),
529
530            Event::Delete {
531                range,
532                cursor_id,
533                deleted_text,
534            } => self.apply_delete(cursors, range, *cursor_id, deleted_text),
535
536            Event::MoveCursor {
537                cursor_id,
538                new_position,
539                new_anchor,
540                new_sticky_column,
541                ..
542            } => {
543                if let Some(cursor) = cursors.get_mut(*cursor_id) {
544                    cursor.position = *new_position;
545                    cursor.anchor = *new_anchor;
546                    cursor.sticky_column = *new_sticky_column;
547                }
548
549                // Update primary cursor line number if this is the primary cursor
550                // Try to get exact line number from buffer, or estimate for large files
551                if *cursor_id == cursors.primary_id() {
552                    self.primary_cursor_line_number =
553                        match self.buffer.offset_to_position(*new_position) {
554                            Some(pos) => LineNumber::Absolute(pos.line),
555                            None => {
556                                // Large file without line metadata - estimate line number
557                                // Use default estimated_line_length of 80 bytes
558                                let estimated_line = *new_position / 80;
559                                LineNumber::Absolute(estimated_line)
560                            }
561                        };
562                }
563            }
564
565            Event::AddCursor {
566                cursor_id,
567                position,
568                anchor,
569            } => {
570                let cursor = if let Some(anchor) = anchor {
571                    Cursor::with_selection(*anchor, *position)
572                } else {
573                    Cursor::new(*position)
574                };
575
576                // Insert cursor with the specific ID from the event
577                // This is important for undo/redo to work correctly
578                cursors.insert_with_id(*cursor_id, cursor);
579
580                cursors.normalize();
581            }
582
583            Event::RemoveCursor { cursor_id, .. } => {
584                cursors.remove(*cursor_id);
585            }
586
587            // View events (Scroll, SetViewport, Recenter) are now handled at Editor level
588            // via SplitViewState. They should not reach EditorState.apply().
589            Event::Scroll { .. } | Event::SetViewport { .. } | Event::Recenter => {
590                // These events are intercepted in Editor::apply_event_to_active_buffer
591                // and routed to SplitViewState. If we get here, something is wrong.
592                tracing::warn!("View event {:?} reached EditorState.apply() - should be handled by SplitViewState", event);
593            }
594
595            Event::SetAnchor {
596                cursor_id,
597                position,
598            } => {
599                // Set the anchor (selection start) for a specific cursor
600                // Also disable deselect_on_move so movement preserves the selection (Emacs mark mode)
601                if let Some(cursor) = cursors.get_mut(*cursor_id) {
602                    cursor.anchor = Some(*position);
603                    cursor.deselect_on_move = false;
604                }
605            }
606
607            Event::ClearAnchor { cursor_id } => {
608                // Clear the anchor and reset deselect_on_move to cancel mark mode
609                // Also clear block selection if active
610                if let Some(cursor) = cursors.get_mut(*cursor_id) {
611                    cursor.anchor = None;
612                    cursor.deselect_on_move = true;
613                    cursor.clear_block_selection();
614                }
615            }
616
617            Event::ChangeMode { mode } => {
618                self.mode = mode.clone();
619            }
620
621            Event::AddOverlay {
622                namespace,
623                range,
624                face,
625                priority,
626                message,
627                extend_to_line_end,
628                url,
629            } => {
630                tracing::trace!(
631                    "AddOverlay: namespace={:?}, range={:?}, face={:?}, priority={}",
632                    namespace,
633                    range,
634                    face,
635                    priority
636                );
637                // Convert event overlay face to overlay face
638                let overlay_face = convert_event_face_to_overlay_face(face);
639                tracing::trace!("Converted face: {:?}", overlay_face);
640
641                let mut overlay = Overlay::with_priority(
642                    &mut self.marker_list,
643                    range.clone(),
644                    overlay_face,
645                    *priority,
646                );
647                overlay.namespace = namespace.clone();
648                overlay.message = message.clone();
649                overlay.extend_to_line_end = *extend_to_line_end;
650                overlay.url = url.clone();
651
652                let actual_range = overlay.range(&self.marker_list);
653                tracing::trace!(
654                    "Created overlay with markers - actual range: {:?}, handle={:?}",
655                    actual_range,
656                    overlay.handle
657                );
658
659                self.overlays.add(overlay);
660            }
661
662            Event::RemoveOverlay { handle } => {
663                tracing::trace!("RemoveOverlay: handle={:?}", handle);
664                self.overlays
665                    .remove_by_handle(handle, &mut self.marker_list);
666            }
667
668            Event::RemoveOverlaysInRange { range } => {
669                self.overlays.remove_in_range(range, &mut self.marker_list);
670            }
671
672            Event::ClearNamespace { namespace } => {
673                tracing::trace!("ClearNamespace: namespace={:?}", namespace);
674                self.overlays
675                    .clear_namespace(namespace, &mut self.marker_list);
676            }
677
678            Event::ClearOverlays => {
679                self.overlays.clear(&mut self.marker_list);
680            }
681
682            Event::ShowPopup { popup } => {
683                let popup_obj = convert_popup_data_to_popup(popup);
684                self.popups.show_or_replace(popup_obj);
685            }
686
687            Event::HidePopup => {
688                self.popups.hide();
689            }
690
691            Event::ClearPopups => {
692                self.popups.clear();
693            }
694
695            Event::PopupSelectNext => {
696                if let Some(popup) = self.popups.top_mut() {
697                    popup.select_next();
698                }
699            }
700
701            Event::PopupSelectPrev => {
702                if let Some(popup) = self.popups.top_mut() {
703                    popup.select_prev();
704                }
705            }
706
707            Event::PopupPageDown => {
708                if let Some(popup) = self.popups.top_mut() {
709                    popup.page_down();
710                }
711            }
712
713            Event::PopupPageUp => {
714                if let Some(popup) = self.popups.top_mut() {
715                    popup.page_up();
716                }
717            }
718
719            Event::AddMarginAnnotation {
720                line,
721                position,
722                content,
723                annotation_id,
724            } => {
725                let margin_position = convert_margin_position(position);
726                let margin_content = convert_margin_content(content);
727                let annotation = if let Some(id) = annotation_id {
728                    MarginAnnotation::with_id(*line, margin_position, margin_content, id.clone())
729                } else {
730                    MarginAnnotation::new(*line, margin_position, margin_content)
731                };
732                self.margins.add_annotation(annotation);
733            }
734
735            Event::RemoveMarginAnnotation { annotation_id } => {
736                self.margins.remove_by_id(annotation_id);
737            }
738
739            Event::RemoveMarginAnnotationsAtLine { line, position } => {
740                let margin_position = convert_margin_position(position);
741                self.margins.remove_at_line(*line, margin_position);
742            }
743
744            Event::ClearMarginPosition { position } => {
745                let margin_position = convert_margin_position(position);
746                self.margins.clear_position(margin_position);
747            }
748
749            Event::ClearMargins => {
750                self.margins.clear_all();
751            }
752
753            Event::SetLineNumbers { enabled } => {
754                self.margins.configure_for_line_numbers(*enabled);
755            }
756
757            // Split events are handled at the Editor level, not at EditorState level
758            // These are no-ops here as they affect the split layout, not buffer state
759            Event::SplitPane { .. }
760            | Event::CloseSplit { .. }
761            | Event::SetActiveSplit { .. }
762            | Event::AdjustSplitRatio { .. }
763            | Event::NextSplit
764            | Event::PrevSplit => {
765                // No-op: split events are handled by Editor, not EditorState
766            }
767
768            Event::Batch { events, .. } => {
769                // Apply all events in the batch sequentially
770                // This ensures multi-cursor operations are applied atomically
771                for event in events {
772                    self.apply(cursors, event);
773                }
774            }
775
776            Event::BulkEdit {
777                new_snapshot,
778                new_cursors,
779                edits,
780                displaced_markers,
781                ..
782            } => {
783                // Restore the target buffer state (piece tree + buffers) for this event.
784                // - For undo: snapshots are swapped, so new_snapshot is the original state
785                // - For redo: new_snapshot is the state after edits
786                // Restoring buffers alongside the tree is critical because
787                // consolidate_after_save() can replace buffers between snapshot and restore.
788                if let Some(snapshot) = new_snapshot {
789                    self.buffer.restore_buffer_state(snapshot);
790                }
791
792                // Replay marker adjustments from the edit list.
793                // For redo: same adjustments as the forward path.
794                // For undo: inverse() has swapped del/ins, so adjustments are reversed.
795                // Edits are in descending position order — process as-is so later
796                // positions are adjusted first (no cascading shift errors).
797                //
798                // For replacements (del > 0 AND ins > 0 at same position), we only
799                // adjust for the net delta to avoid the marker-at-boundary problem
800                // where sequential delete+insert pushes markers incorrectly.
801                for &(pos, del_len, ins_len) in edits {
802                    if del_len > 0 && ins_len > 0 {
803                        // Replacement: adjust by net delta only
804                        if ins_len > del_len {
805                            let net = ins_len - del_len;
806                            self.marker_list.adjust_for_insert(pos, net);
807                            self.margins.adjust_for_insert(pos, net);
808                        } else if del_len > ins_len {
809                            let net = del_len - ins_len;
810                            self.marker_list.adjust_for_delete(pos, net);
811                            self.margins.adjust_for_delete(pos, net);
812                        }
813                        // If equal: net delta 0, no adjustment needed
814                    } else if del_len > 0 {
815                        self.marker_list.adjust_for_delete(pos, del_len);
816                        self.margins.adjust_for_delete(pos, del_len);
817                    } else if ins_len > 0 {
818                        self.marker_list.adjust_for_insert(pos, ins_len);
819                        self.margins.adjust_for_insert(pos, ins_len);
820                    }
821                }
822
823                // Restore displaced markers to their original positions.
824                // This fixes markers that were inside a deleted range and collapsed
825                // to the deletion boundary — they're now moved back to their exact
826                // original positions after the text has been restored by undo.
827                if !displaced_markers.is_empty() {
828                    self.restore_displaced_markers(displaced_markers);
829                }
830
831                // Clear ephemeral decorations — their source systems will re-push
832                // correct positions after the edit notification.
833                self.virtual_texts.clear(&mut self.marker_list);
834
835                use crate::view::overlay::OverlayNamespace;
836                let namespaces = ["lsp-diagnostic", "reference-highlight", "bracket-highlight"];
837                for ns in &namespaces {
838                    self.overlays.clear_namespace(
839                        &OverlayNamespace::from_string(ns.to_string()),
840                        &mut self.marker_list,
841                    );
842                }
843
844                // Update cursor positions
845                for (cursor_id, position, anchor) in new_cursors {
846                    if let Some(cursor) = cursors.get_mut(*cursor_id) {
847                        cursor.position = *position;
848                        cursor.anchor = *anchor;
849                    }
850                }
851
852                // Invalidate highlight cache for entire buffer
853                self.highlighter.invalidate_all();
854
855                // Update primary cursor line number
856                let primary_pos = cursors.primary().position;
857                self.primary_cursor_line_number = match self.buffer.offset_to_position(primary_pos)
858                {
859                    Some(pos) => crate::model::buffer::LineNumber::Absolute(pos.line),
860                    None => crate::model::buffer::LineNumber::Absolute(0),
861                };
862            }
863        }
864    }
865
866    /// Capture positions of markers strictly inside a deleted range.
867    /// Call this BEFORE applying the delete. Returns encoded displaced markers.
868    pub fn capture_displaced_markers(&self, range: &Range<usize>) -> Vec<(u64, usize)> {
869        let mut displaced = Vec::new();
870        if range.is_empty() {
871            return displaced;
872        }
873        for (marker_id, start, _end) in self.marker_list.query_range(range.start, range.end) {
874            if start > range.start && start < range.end {
875                displaced.push(
876                    DisplacedMarker::Main {
877                        id: marker_id.0,
878                        position: start,
879                    }
880                    .encode(),
881                );
882            }
883        }
884        for (marker_id, start, _end) in self.margins.query_indicator_range(range.start, range.end) {
885            if start > range.start && start < range.end {
886                displaced.push(
887                    DisplacedMarker::Margin {
888                        id: marker_id.0,
889                        position: start,
890                    }
891                    .encode(),
892                );
893            }
894        }
895        displaced
896    }
897
898    /// Capture displaced markers for multiple delete ranges (BulkEdit).
899    pub fn capture_displaced_markers_bulk(
900        &self,
901        edits: &[(usize, usize, String)],
902    ) -> Vec<(u64, usize)> {
903        let mut displaced = Vec::new();
904        for (pos, del_len, _text) in edits {
905            if *del_len > 0 {
906                displaced.extend(self.capture_displaced_markers(&(*pos..*pos + *del_len)));
907            }
908        }
909        displaced
910    }
911
912    /// Restore displaced markers to their exact original positions.
913    pub fn restore_displaced_markers(&mut self, displaced: &[(u64, usize)]) {
914        for &(tagged_id, original_pos) in displaced {
915            let dm = DisplacedMarker::decode(tagged_id, original_pos);
916            match dm {
917                DisplacedMarker::Main { id, position } => {
918                    self.marker_list.set_position(MarkerId(id), position);
919                }
920                DisplacedMarker::Margin { id, position } => {
921                    self.margins.set_indicator_position(MarkerId(id), position);
922                }
923            }
924        }
925    }
926
927    /// Apply multiple events in sequence
928    pub fn apply_many(&mut self, cursors: &mut Cursors, events: &[Event]) {
929        for event in events {
930            self.apply(cursors, event);
931        }
932    }
933
934    /// Called when this buffer loses focus (e.g., switching to another buffer,
935    /// opening a prompt, focusing file explorer, etc.)
936    /// Dismisses transient popups like Hover and Signature Help.
937    pub fn on_focus_lost(&mut self) {
938        if self.popups.dismiss_transient() {
939            tracing::debug!("Dismissed transient popup on buffer focus loss");
940        }
941    }
942}
943
944/// Convert event overlay face to the actual overlay face
945fn convert_event_face_to_overlay_face(event_face: &EventOverlayFace) -> OverlayFace {
946    match event_face {
947        EventOverlayFace::Underline { color, style } => {
948            let underline_style = match style {
949                crate::model::event::UnderlineStyle::Straight => UnderlineStyle::Straight,
950                crate::model::event::UnderlineStyle::Wavy => UnderlineStyle::Wavy,
951                crate::model::event::UnderlineStyle::Dotted => UnderlineStyle::Dotted,
952                crate::model::event::UnderlineStyle::Dashed => UnderlineStyle::Dashed,
953            };
954            OverlayFace::Underline {
955                color: Color::Rgb(color.0, color.1, color.2),
956                style: underline_style,
957            }
958        }
959        EventOverlayFace::Background { color } => OverlayFace::Background {
960            color: Color::Rgb(color.0, color.1, color.2),
961        },
962        EventOverlayFace::Foreground { color } => OverlayFace::Foreground {
963            color: Color::Rgb(color.0, color.1, color.2),
964        },
965        EventOverlayFace::Style { options } => {
966            use crate::view::theme::named_color_from_str;
967            use ratatui::style::Modifier;
968
969            // Build fallback style from RGB values or named colors
970            let mut style = Style::default();
971
972            // Extract foreground color (RGB, named color, or default white)
973            if let Some(ref fg) = options.fg {
974                if let Some((r, g, b)) = fg.as_rgb() {
975                    style = style.fg(Color::Rgb(r, g, b));
976                } else if let Some(key) = fg.as_theme_key() {
977                    if let Some(color) = named_color_from_str(key) {
978                        style = style.fg(color);
979                    }
980                }
981            }
982
983            // Extract background color (RGB, named color, or fallback)
984            if let Some(ref bg) = options.bg {
985                if let Some((r, g, b)) = bg.as_rgb() {
986                    style = style.bg(Color::Rgb(r, g, b));
987                } else if let Some(key) = bg.as_theme_key() {
988                    if let Some(color) = named_color_from_str(key) {
989                        style = style.bg(color);
990                    }
991                }
992            }
993
994            // Apply modifiers
995            let mut modifiers = Modifier::empty();
996            if options.bold {
997                modifiers |= Modifier::BOLD;
998            }
999            if options.italic {
1000                modifiers |= Modifier::ITALIC;
1001            }
1002            if options.underline {
1003                modifiers |= Modifier::UNDERLINED;
1004            }
1005            if options.strikethrough {
1006                modifiers |= Modifier::CROSSED_OUT;
1007            }
1008            if !modifiers.is_empty() {
1009                style = style.add_modifier(modifiers);
1010            }
1011
1012            // Extract theme keys (exclude recognized named colors, already resolved above)
1013            let fg_theme = options
1014                .fg
1015                .as_ref()
1016                .and_then(|c| c.as_theme_key())
1017                .filter(|key| named_color_from_str(key).is_none())
1018                .map(String::from);
1019            let bg_theme = options
1020                .bg
1021                .as_ref()
1022                .and_then(|c| c.as_theme_key())
1023                .filter(|key| named_color_from_str(key).is_none())
1024                .map(String::from);
1025
1026            // If theme keys are provided, use ThemedStyle for runtime resolution
1027            if fg_theme.is_some() || bg_theme.is_some() {
1028                OverlayFace::ThemedStyle {
1029                    fallback_style: style,
1030                    fg_theme,
1031                    bg_theme,
1032                }
1033            } else {
1034                OverlayFace::Style { style }
1035            }
1036        }
1037    }
1038}
1039
1040/// Convert popup data to the actual popup object
1041pub(crate) fn convert_popup_data_to_popup(data: &PopupData) -> Popup {
1042    let content = match &data.content {
1043        crate::model::event::PopupContentData::Text(lines) => PopupContent::Text(lines.clone()),
1044        crate::model::event::PopupContentData::List { items, selected } => PopupContent::List {
1045            items: items
1046                .iter()
1047                .map(|item| PopupListItem {
1048                    text: item.text.clone(),
1049                    detail: item.detail.clone(),
1050                    icon: item.icon.clone(),
1051                    data: item.data.clone(),
1052                    disabled: false,
1053                })
1054                .collect(),
1055            selected: *selected,
1056        },
1057    };
1058
1059    let position = match data.position {
1060        PopupPositionData::AtCursor => PopupPosition::AtCursor,
1061        PopupPositionData::BelowCursor => PopupPosition::BelowCursor,
1062        PopupPositionData::AboveCursor => PopupPosition::AboveCursor,
1063        PopupPositionData::Fixed { x, y } => PopupPosition::Fixed { x, y },
1064        PopupPositionData::Centered => PopupPosition::Centered,
1065        PopupPositionData::BottomRight => PopupPosition::BottomRight,
1066        PopupPositionData::AboveStatusBarAt { x } => PopupPosition::AboveStatusBarAt { x },
1067    };
1068
1069    // Map the explicit kind hint to PopupKind for input handling
1070    let kind = match data.kind {
1071        crate::model::event::PopupKindHint::Completion => PopupKind::Completion,
1072        crate::model::event::PopupKindHint::List => PopupKind::List,
1073        crate::model::event::PopupKindHint::Text => PopupKind::Text,
1074    };
1075
1076    Popup {
1077        kind,
1078        title: data.title.clone(),
1079        description: data.description.clone(),
1080        transient: data.transient,
1081        content,
1082        position,
1083        width: data.width,
1084        max_height: data.max_height,
1085        bordered: data.bordered,
1086        border_style: Style::default().fg(Color::Gray),
1087        background_style: Style::default().bg(Color::Rgb(30, 30, 30)),
1088        scroll_offset: 0,
1089        text_selection: None,
1090        accept_key_hint: None,
1091    }
1092}
1093
1094/// Convert margin position data to the actual margin position
1095fn convert_margin_position(position: &MarginPositionData) -> MarginPosition {
1096    match position {
1097        MarginPositionData::Left => MarginPosition::Left,
1098        MarginPositionData::Right => MarginPosition::Right,
1099    }
1100}
1101
1102/// Convert margin content data to the actual margin content
1103fn convert_margin_content(content: &MarginContentData) -> MarginContent {
1104    match content {
1105        MarginContentData::Text(text) => MarginContent::Text(text.clone()),
1106        MarginContentData::Symbol { text, color } => {
1107            if let Some((r, g, b)) = color {
1108                MarginContent::colored_symbol(text.clone(), Color::Rgb(*r, *g, *b))
1109            } else {
1110                MarginContent::symbol(text.clone(), Style::default())
1111            }
1112        }
1113        MarginContentData::Empty => MarginContent::Empty,
1114    }
1115}
1116
1117impl EditorState {
1118    /// Prepare viewport for rendering (called before frame render)
1119    ///
1120    /// This pre-loads all data that will be needed for rendering the current viewport,
1121    /// ensuring that subsequent read-only access during rendering will succeed.
1122    ///
1123    /// Takes viewport parameters since viewport is now owned by SplitViewState.
1124    pub fn prepare_for_render(&mut self, top_byte: usize, height: u16) -> Result<()> {
1125        self.buffer.prepare_viewport(top_byte, height as usize)?;
1126        Ok(())
1127    }
1128
1129    /// Resolve all plugin-injected soft-break byte positions for this buffer.
1130    ///
1131    /// Returns a sorted slice suitable for passing to `Viewport::scroll_up` /
1132    /// `scroll_down`, which use it to keep their visual-row counting in
1133    /// lock-step with the renderer (which applies these same breaks via
1134    /// `apply_soft_breaks`). Empty when no plugin is wrapping the buffer.
1135    pub fn collect_soft_break_positions(&self) -> Vec<usize> {
1136        if self.soft_breaks.is_empty() {
1137            return Vec::new();
1138        }
1139        // query_viewport already returns positions sorted ascending.
1140        self.soft_breaks
1141            .query_viewport(0, self.buffer.len() + 1, &self.marker_list)
1142            .into_iter()
1143            .map(|(pos, _indent)| pos)
1144            .collect()
1145    }
1146
1147    // ========== DocumentModel Helper Methods ==========
1148    // These methods provide convenient access to DocumentModel functionality
1149    // while maintaining backward compatibility with existing code.
1150
1151    /// Get text in a range, driving lazy loading transparently
1152    ///
1153    /// This is a convenience wrapper around DocumentModel::get_range that:
1154    /// - Drives lazy loading automatically (never fails due to unloaded data)
1155    /// - Uses byte offsets directly
1156    /// - Returns String (not Result) - errors are logged internally
1157    /// - Returns empty string for invalid ranges
1158    ///
1159    /// This is the preferred API for getting text ranges. The caller never needs
1160    /// to worry about lazy loading or buffer preparation.
1161    ///
1162    /// # Example
1163    /// ```ignore
1164    /// let text = state.get_text_range(0, 100);
1165    /// ```
1166    pub fn get_text_range(&mut self, start: usize, end: usize) -> String {
1167        // TextBuffer::get_text_range_mut() handles lazy loading automatically
1168        match self
1169            .buffer
1170            .get_text_range_mut(start, end.saturating_sub(start))
1171        {
1172            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
1173            Err(e) => {
1174                tracing::warn!("Failed to get text range {}..{}: {}", start, end, e);
1175                String::new()
1176            }
1177        }
1178    }
1179
1180    /// Get the content of a line by its byte offset
1181    ///
1182    /// Returns the line containing the given offset, along with its start position.
1183    /// This uses DocumentModel's viewport functionality for consistent behavior.
1184    ///
1185    /// # Returns
1186    /// `Some((line_start_offset, line_content))` if successful, `None` if offset is invalid
1187    pub fn get_line_at_offset(&mut self, offset: usize) -> Option<(usize, String)> {
1188        use crate::model::document_model::DocumentModel;
1189
1190        // Find the start of the line containing this offset
1191        // Scan backwards to find the previous newline or start of buffer
1192        let mut line_start = offset;
1193        while line_start > 0 {
1194            if let Ok(text) = self.buffer.get_text_range_mut(line_start - 1, 1) {
1195                if text.first() == Some(&b'\n') {
1196                    break;
1197                }
1198                line_start -= 1;
1199            } else {
1200                break;
1201            }
1202        }
1203
1204        // Get a single line viewport starting at the line start
1205        let viewport = self
1206            .get_viewport_content(
1207                crate::model::document_model::DocumentPosition::byte(line_start),
1208                1,
1209            )
1210            .ok()?;
1211
1212        viewport
1213            .lines
1214            .first()
1215            .map(|line| (line.byte_offset, line.content.clone()))
1216    }
1217
1218    /// Get text from current cursor position to end of line
1219    ///
1220    /// This is a common pattern in editing operations. Uses DocumentModel
1221    /// for consistent behavior across file sizes.
1222    pub fn get_text_to_end_of_line(&mut self, cursor_pos: usize) -> Result<String> {
1223        use crate::model::document_model::DocumentModel;
1224
1225        // Get the line containing cursor
1226        let viewport = self.get_viewport_content(
1227            crate::model::document_model::DocumentPosition::byte(cursor_pos),
1228            1,
1229        )?;
1230
1231        if let Some(line) = viewport.lines.first() {
1232            let line_start = line.byte_offset;
1233            let line_end = line_start + line.content.len();
1234
1235            if cursor_pos >= line_start && cursor_pos <= line_end {
1236                let offset_in_line = cursor_pos - line_start;
1237                // Use get() to safely handle potential non-char-boundary offsets
1238                Ok(line.content.get(offset_in_line..).unwrap_or("").to_string())
1239            } else {
1240                Ok(String::new())
1241            }
1242        } else {
1243            Ok(String::new())
1244        }
1245    }
1246
1247    /// Replace cached semantic tokens with a new store.
1248    pub fn set_semantic_tokens(&mut self, store: SemanticTokenStore) {
1249        self.semantic_tokens = Some(store);
1250    }
1251
1252    /// Clear cached semantic tokens (e.g., when tokens are invalidated).
1253    pub fn clear_semantic_tokens(&mut self) {
1254        self.semantic_tokens = None;
1255    }
1256
1257    /// Get the server-provided semantic token result_id if available.
1258    pub fn semantic_tokens_result_id(&self) -> Option<&str> {
1259        self.semantic_tokens
1260            .as_ref()
1261            .and_then(|store| store.result_id.as_deref())
1262    }
1263}
1264
1265/// Implement DocumentModel trait for EditorState
1266///
1267/// This provides a clean abstraction layer between rendering/editing operations
1268/// and the underlying text buffer implementation.
1269impl DocumentModel for EditorState {
1270    fn capabilities(&self) -> DocumentCapabilities {
1271        let line_count = self.buffer.line_count();
1272        DocumentCapabilities {
1273            has_line_index: line_count.is_some(),
1274            uses_lazy_loading: false, // TODO: add large file detection
1275            byte_length: self.buffer.len(),
1276            approximate_line_count: line_count.unwrap_or_else(|| {
1277                // Estimate assuming ~80 bytes per line
1278                self.buffer.len() / 80
1279            }),
1280        }
1281    }
1282
1283    fn get_viewport_content(
1284        &mut self,
1285        start_pos: DocumentPosition,
1286        max_lines: usize,
1287    ) -> Result<ViewportContent> {
1288        // Convert to byte offset
1289        let start_offset = self.position_to_offset(start_pos)?;
1290
1291        // Use new efficient line iteration that tracks line numbers during iteration
1292        // by accumulating line_feed_cnt from pieces (single source of truth)
1293        let line_iter = self.buffer.iter_lines_from(start_offset, max_lines)?;
1294        let has_more = line_iter.has_more;
1295
1296        let lines = line_iter
1297            .map(|line_data| ViewportLine {
1298                byte_offset: line_data.byte_offset,
1299                content: line_data.content,
1300                has_newline: line_data.has_newline,
1301                approximate_line_number: line_data.line_number,
1302            })
1303            .collect();
1304
1305        Ok(ViewportContent {
1306            start_position: DocumentPosition::ByteOffset(start_offset),
1307            lines,
1308            has_more,
1309        })
1310    }
1311
1312    fn position_to_offset(&self, pos: DocumentPosition) -> Result<usize> {
1313        match pos {
1314            DocumentPosition::ByteOffset(offset) => Ok(offset),
1315            DocumentPosition::LineColumn { line, column } => {
1316                if !self.has_line_index() {
1317                    anyhow::bail!("Line indexing not available for this document");
1318                }
1319                // Use piece tree's position conversion
1320                let position = crate::model::piece_tree::Position { line, column };
1321                Ok(self.buffer.position_to_offset(position))
1322            }
1323        }
1324    }
1325
1326    fn offset_to_position(&self, offset: usize) -> DocumentPosition {
1327        if self.has_line_index() {
1328            if let Some(pos) = self.buffer.offset_to_position(offset) {
1329                DocumentPosition::LineColumn {
1330                    line: pos.line,
1331                    column: pos.column,
1332                }
1333            } else {
1334                // Line index exists but metadata unavailable - fall back to byte offset
1335                DocumentPosition::ByteOffset(offset)
1336            }
1337        } else {
1338            DocumentPosition::ByteOffset(offset)
1339        }
1340    }
1341
1342    fn get_range(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<String> {
1343        let start_offset = self.position_to_offset(start)?;
1344        let end_offset = self.position_to_offset(end)?;
1345
1346        if start_offset > end_offset {
1347            anyhow::bail!(
1348                "Invalid range: start offset {} > end offset {}",
1349                start_offset,
1350                end_offset
1351            );
1352        }
1353
1354        let bytes = self
1355            .buffer
1356            .get_text_range_mut(start_offset, end_offset - start_offset)?;
1357
1358        Ok(String::from_utf8_lossy(&bytes).into_owned())
1359    }
1360
1361    fn get_line_content(&mut self, line_number: usize) -> Option<String> {
1362        if !self.has_line_index() {
1363            return None;
1364        }
1365
1366        // Convert line number to byte offset
1367        let line_start_offset = self.buffer.line_start_offset(line_number)?;
1368
1369        // Get line content using iterator
1370        let mut iter = self.buffer.line_iterator(line_start_offset, 80);
1371        if let Some((_start, content)) = iter.next_line() {
1372            let has_newline = content.ends_with('\n');
1373            let line_content = if has_newline {
1374                content[..content.len() - 1].to_string()
1375            } else {
1376                content
1377            };
1378            Some(line_content)
1379        } else {
1380            None
1381        }
1382    }
1383
1384    fn get_chunk_at_offset(&mut self, offset: usize, size: usize) -> Result<(usize, String)> {
1385        let bytes = self.buffer.get_text_range_mut(offset, size)?;
1386
1387        Ok((offset, String::from_utf8_lossy(&bytes).into_owned()))
1388    }
1389
1390    fn insert(&mut self, pos: DocumentPosition, text: &str) -> Result<usize> {
1391        let offset = self.position_to_offset(pos)?;
1392        self.buffer.insert_bytes(offset, text.as_bytes().to_vec());
1393        Ok(text.len())
1394    }
1395
1396    fn delete(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<()> {
1397        let start_offset = self.position_to_offset(start)?;
1398        let end_offset = self.position_to_offset(end)?;
1399
1400        if start_offset > end_offset {
1401            anyhow::bail!(
1402                "Invalid range: start offset {} > end offset {}",
1403                start_offset,
1404                end_offset
1405            );
1406        }
1407
1408        self.buffer.delete(start_offset..end_offset);
1409        Ok(())
1410    }
1411
1412    fn replace(
1413        &mut self,
1414        start: DocumentPosition,
1415        end: DocumentPosition,
1416        text: &str,
1417    ) -> Result<()> {
1418        // Delete then insert
1419        self.delete(start, end)?;
1420        self.insert(start, text)?;
1421        Ok(())
1422    }
1423
1424    fn find_matches(
1425        &mut self,
1426        pattern: &str,
1427        search_range: Option<(DocumentPosition, DocumentPosition)>,
1428    ) -> Result<Vec<usize>> {
1429        let (start_offset, end_offset) = if let Some((start, end)) = search_range {
1430            (
1431                self.position_to_offset(start)?,
1432                self.position_to_offset(end)?,
1433            )
1434        } else {
1435            (0, self.buffer.len())
1436        };
1437
1438        // Get text in range
1439        let bytes = self
1440            .buffer
1441            .get_text_range_mut(start_offset, end_offset - start_offset)?;
1442        let text = String::from_utf8_lossy(&bytes);
1443
1444        // Find all matches (simple substring search for now)
1445        let mut matches = Vec::new();
1446        let mut search_offset = 0;
1447        while let Some(pos) = text[search_offset..].find(pattern) {
1448            matches.push(start_offset + search_offset + pos);
1449            search_offset += pos + pattern.len();
1450        }
1451
1452        Ok(matches)
1453    }
1454}
1455
1456/// Cached semantic tokens for a buffer.
1457#[derive(Clone, Debug)]
1458pub struct SemanticTokenStore {
1459    /// Buffer version the tokens correspond to.
1460    pub version: u64,
1461    /// Server-provided result identifier (if any).
1462    pub result_id: Option<String>,
1463    /// Raw semantic token data (u32 array, 5 integers per token).
1464    pub data: Vec<u32>,
1465    /// All semantic token spans resolved to byte ranges.
1466    pub tokens: Vec<SemanticTokenSpan>,
1467}
1468
1469/// A semantic token span resolved to buffer byte offsets.
1470#[derive(Clone, Debug)]
1471pub struct SemanticTokenSpan {
1472    pub range: Range<usize>,
1473    pub token_type: String,
1474    pub modifiers: Vec<String>,
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479    use crate::model::filesystem::StdFileSystem;
1480    use std::sync::Arc;
1481
1482    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
1483        Arc::new(StdFileSystem)
1484    }
1485    use super::*;
1486    use crate::model::event::CursorId;
1487
1488    #[test]
1489    fn test_state_new() {
1490        let state = EditorState::new(
1491            80,
1492            24,
1493            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1494            test_fs(),
1495        );
1496        assert!(state.buffer.is_empty());
1497    }
1498
1499    #[test]
1500    fn test_apply_insert() {
1501        let mut state = EditorState::new(
1502            80,
1503            24,
1504            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1505            test_fs(),
1506        );
1507        let mut cursors = Cursors::new();
1508        let cursor_id = cursors.primary_id();
1509
1510        state.apply(
1511            &mut cursors,
1512            &Event::Insert {
1513                position: 0,
1514                text: "hello".to_string(),
1515                cursor_id,
1516            },
1517        );
1518
1519        assert_eq!(state.buffer.to_string().unwrap(), "hello");
1520        assert_eq!(cursors.primary().position, 5);
1521        assert!(state.buffer.is_modified());
1522    }
1523
1524    #[test]
1525    fn test_apply_delete() {
1526        let mut state = EditorState::new(
1527            80,
1528            24,
1529            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1530            test_fs(),
1531        );
1532        let mut cursors = Cursors::new();
1533        let cursor_id = cursors.primary_id();
1534
1535        // Insert then delete
1536        state.apply(
1537            &mut cursors,
1538            &Event::Insert {
1539                position: 0,
1540                text: "hello world".to_string(),
1541                cursor_id,
1542            },
1543        );
1544
1545        state.apply(
1546            &mut cursors,
1547            &Event::Delete {
1548                range: 5..11,
1549                deleted_text: " world".to_string(),
1550                cursor_id,
1551            },
1552        );
1553
1554        assert_eq!(state.buffer.to_string().unwrap(), "hello");
1555        assert_eq!(cursors.primary().position, 5);
1556    }
1557
1558    #[test]
1559    fn test_apply_move_cursor() {
1560        let mut state = EditorState::new(
1561            80,
1562            24,
1563            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1564            test_fs(),
1565        );
1566        let mut cursors = Cursors::new();
1567        let cursor_id = cursors.primary_id();
1568
1569        state.apply(
1570            &mut cursors,
1571            &Event::Insert {
1572                position: 0,
1573                text: "hello".to_string(),
1574                cursor_id,
1575            },
1576        );
1577
1578        state.apply(
1579            &mut cursors,
1580            &Event::MoveCursor {
1581                cursor_id,
1582                old_position: 5,
1583                new_position: 2,
1584                old_anchor: None,
1585                new_anchor: None,
1586                old_sticky_column: 0,
1587                new_sticky_column: 0,
1588            },
1589        );
1590
1591        assert_eq!(cursors.primary().position, 2);
1592    }
1593
1594    #[test]
1595    fn test_apply_add_cursor() {
1596        let mut state = EditorState::new(
1597            80,
1598            24,
1599            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1600            test_fs(),
1601        );
1602        let mut cursors = Cursors::new();
1603        let cursor_id = CursorId(1);
1604
1605        state.apply(
1606            &mut cursors,
1607            &Event::AddCursor {
1608                cursor_id,
1609                position: 5,
1610                anchor: None,
1611            },
1612        );
1613
1614        assert_eq!(cursors.count(), 2);
1615    }
1616
1617    #[test]
1618    fn test_apply_many() {
1619        let mut state = EditorState::new(
1620            80,
1621            24,
1622            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1623            test_fs(),
1624        );
1625        let mut cursors = Cursors::new();
1626        let cursor_id = cursors.primary_id();
1627
1628        let events = vec![
1629            Event::Insert {
1630                position: 0,
1631                text: "hello ".to_string(),
1632                cursor_id,
1633            },
1634            Event::Insert {
1635                position: 6,
1636                text: "world".to_string(),
1637                cursor_id,
1638            },
1639        ];
1640
1641        state.apply_many(&mut cursors, &events);
1642
1643        assert_eq!(state.buffer.to_string().unwrap(), "hello world");
1644    }
1645
1646    #[test]
1647    fn test_cursor_adjustment_after_insert() {
1648        let mut state = EditorState::new(
1649            80,
1650            24,
1651            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1652            test_fs(),
1653        );
1654        let mut cursors = Cursors::new();
1655        let cursor_id = cursors.primary_id();
1656
1657        // Add a second cursor at position 5
1658        state.apply(
1659            &mut cursors,
1660            &Event::AddCursor {
1661                cursor_id: CursorId(1),
1662                position: 5,
1663                anchor: None,
1664            },
1665        );
1666
1667        // Insert at position 0 - should push second cursor forward
1668        state.apply(
1669            &mut cursors,
1670            &Event::Insert {
1671                position: 0,
1672                text: "abc".to_string(),
1673                cursor_id,
1674            },
1675        );
1676
1677        // Second cursor should be at position 5 + 3 = 8
1678        if let Some(cursor) = cursors.get(CursorId(1)) {
1679            assert_eq!(cursor.position, 8);
1680        }
1681    }
1682
1683    // DocumentModel trait tests
1684    mod document_model_tests {
1685        use super::*;
1686        use crate::model::document_model::{DocumentModel, DocumentPosition};
1687
1688        #[test]
1689        fn test_capabilities_small_file() {
1690            let mut state = EditorState::new(
1691                80,
1692                24,
1693                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1694                test_fs(),
1695            );
1696            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1697
1698            let caps = state.capabilities();
1699            assert!(caps.has_line_index, "Small file should have line index");
1700            assert_eq!(caps.byte_length, "line1\nline2\nline3".len());
1701            assert_eq!(caps.approximate_line_count, 3, "Should have 3 lines");
1702        }
1703
1704        #[test]
1705        fn test_position_conversions() {
1706            let mut state = EditorState::new(
1707                80,
1708                24,
1709                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1710                test_fs(),
1711            );
1712            state.buffer = Buffer::from_str_test("hello\nworld\ntest");
1713
1714            // Test ByteOffset -> offset
1715            let pos1 = DocumentPosition::ByteOffset(6);
1716            let offset1 = state.position_to_offset(pos1).unwrap();
1717            assert_eq!(offset1, 6);
1718
1719            // Test LineColumn -> offset
1720            let pos2 = DocumentPosition::LineColumn { line: 1, column: 0 };
1721            let offset2 = state.position_to_offset(pos2).unwrap();
1722            assert_eq!(offset2, 6, "Line 1, column 0 should be at byte 6");
1723
1724            // Test offset -> position (should return LineColumn for small files)
1725            let converted = state.offset_to_position(6);
1726            match converted {
1727                DocumentPosition::LineColumn { line, column } => {
1728                    assert_eq!(line, 1);
1729                    assert_eq!(column, 0);
1730                }
1731                _ => panic!("Expected LineColumn for small file"),
1732            }
1733        }
1734
1735        #[test]
1736        fn test_get_viewport_content() {
1737            let mut state = EditorState::new(
1738                80,
1739                24,
1740                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1741                test_fs(),
1742            );
1743            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
1744
1745            let content = state
1746                .get_viewport_content(DocumentPosition::ByteOffset(0), 3)
1747                .unwrap();
1748
1749            assert_eq!(content.lines.len(), 3);
1750            assert_eq!(content.lines[0].content, "line1");
1751            assert_eq!(content.lines[1].content, "line2");
1752            assert_eq!(content.lines[2].content, "line3");
1753            assert!(content.has_more);
1754        }
1755
1756        #[test]
1757        fn test_get_range() {
1758            let mut state = EditorState::new(
1759                80,
1760                24,
1761                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1762                test_fs(),
1763            );
1764            state.buffer = Buffer::from_str_test("hello world");
1765
1766            let text = state
1767                .get_range(
1768                    DocumentPosition::ByteOffset(0),
1769                    DocumentPosition::ByteOffset(5),
1770                )
1771                .unwrap();
1772            assert_eq!(text, "hello");
1773
1774            let text2 = state
1775                .get_range(
1776                    DocumentPosition::ByteOffset(6),
1777                    DocumentPosition::ByteOffset(11),
1778                )
1779                .unwrap();
1780            assert_eq!(text2, "world");
1781        }
1782
1783        #[test]
1784        fn test_get_line_content() {
1785            let mut state = EditorState::new(
1786                80,
1787                24,
1788                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1789                test_fs(),
1790            );
1791            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1792
1793            let line0 = state.get_line_content(0).unwrap();
1794            assert_eq!(line0, "line1");
1795
1796            let line1 = state.get_line_content(1).unwrap();
1797            assert_eq!(line1, "line2");
1798
1799            let line2 = state.get_line_content(2).unwrap();
1800            assert_eq!(line2, "line3");
1801        }
1802
1803        #[test]
1804        fn test_insert_delete() {
1805            let mut state = EditorState::new(
1806                80,
1807                24,
1808                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1809                test_fs(),
1810            );
1811            state.buffer = Buffer::from_str_test("hello world");
1812
1813            // Insert text
1814            let bytes_inserted = state
1815                .insert(DocumentPosition::ByteOffset(6), "beautiful ")
1816                .unwrap();
1817            assert_eq!(bytes_inserted, 10);
1818            assert_eq!(state.buffer.to_string().unwrap(), "hello beautiful world");
1819
1820            // Delete text
1821            state
1822                .delete(
1823                    DocumentPosition::ByteOffset(6),
1824                    DocumentPosition::ByteOffset(16),
1825                )
1826                .unwrap();
1827            assert_eq!(state.buffer.to_string().unwrap(), "hello world");
1828        }
1829
1830        #[test]
1831        fn test_replace() {
1832            let mut state = EditorState::new(
1833                80,
1834                24,
1835                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1836                test_fs(),
1837            );
1838            state.buffer = Buffer::from_str_test("hello world");
1839
1840            state
1841                .replace(
1842                    DocumentPosition::ByteOffset(0),
1843                    DocumentPosition::ByteOffset(5),
1844                    "hi",
1845                )
1846                .unwrap();
1847            assert_eq!(state.buffer.to_string().unwrap(), "hi world");
1848        }
1849
1850        #[test]
1851        fn test_find_matches() {
1852            let mut state = EditorState::new(
1853                80,
1854                24,
1855                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1856                test_fs(),
1857            );
1858            state.buffer = Buffer::from_str_test("hello world hello");
1859
1860            let matches = state.find_matches("hello", None).unwrap();
1861            assert_eq!(matches.len(), 2);
1862            assert_eq!(matches[0], 0);
1863            assert_eq!(matches[1], 12);
1864        }
1865
1866        #[test]
1867        fn test_prepare_for_render() {
1868            let mut state = EditorState::new(
1869                80,
1870                24,
1871                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1872                test_fs(),
1873            );
1874            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
1875
1876            // Should not panic - pass top_byte=0 and height=24 (typical viewport params)
1877            state.prepare_for_render(0, 24).unwrap();
1878        }
1879
1880        #[test]
1881        fn test_helper_get_text_range() {
1882            let mut state = EditorState::new(
1883                80,
1884                24,
1885                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1886                test_fs(),
1887            );
1888            state.buffer = Buffer::from_str_test("hello world");
1889
1890            // Test normal range
1891            let text = state.get_text_range(0, 5);
1892            assert_eq!(text, "hello");
1893
1894            // Test middle range
1895            let text2 = state.get_text_range(6, 11);
1896            assert_eq!(text2, "world");
1897        }
1898
1899        #[test]
1900        fn test_helper_get_line_at_offset() {
1901            let mut state = EditorState::new(
1902                80,
1903                24,
1904                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1905                test_fs(),
1906            );
1907            state.buffer = Buffer::from_str_test("line1\nline2\nline3");
1908
1909            // Get first line (offset 0)
1910            let (offset, content) = state.get_line_at_offset(0).unwrap();
1911            assert_eq!(offset, 0);
1912            assert_eq!(content, "line1");
1913
1914            // Get second line (offset in middle of line)
1915            let (offset2, content2) = state.get_line_at_offset(8).unwrap();
1916            assert_eq!(offset2, 6); // Line starts at byte 6
1917            assert_eq!(content2, "line2");
1918
1919            // Get last line
1920            let (offset3, content3) = state.get_line_at_offset(12).unwrap();
1921            assert_eq!(offset3, 12);
1922            assert_eq!(content3, "line3");
1923        }
1924
1925        #[test]
1926        fn test_helper_get_text_to_end_of_line() {
1927            let mut state = EditorState::new(
1928                80,
1929                24,
1930                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1931                test_fs(),
1932            );
1933            state.buffer = Buffer::from_str_test("hello world\nline2");
1934
1935            // From beginning of line
1936            let text = state.get_text_to_end_of_line(0).unwrap();
1937            assert_eq!(text, "hello world");
1938
1939            // From middle of line
1940            let text2 = state.get_text_to_end_of_line(6).unwrap();
1941            assert_eq!(text2, "world");
1942
1943            // From end of line
1944            let text3 = state.get_text_to_end_of_line(11).unwrap();
1945            assert_eq!(text3, "");
1946
1947            // From second line
1948            let text4 = state.get_text_to_end_of_line(12).unwrap();
1949            assert_eq!(text4, "line2");
1950        }
1951    }
1952
1953    // Virtual text integration tests
1954    mod virtual_text_integration_tests {
1955        use super::*;
1956        use crate::view::virtual_text::VirtualTextPosition;
1957        use ratatui::style::Style;
1958
1959        #[test]
1960        fn test_virtual_text_add_and_query() {
1961            let mut state = EditorState::new(
1962                80,
1963                24,
1964                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
1965                test_fs(),
1966            );
1967            state.buffer = Buffer::from_str_test("hello world");
1968
1969            // Initialize marker list for buffer
1970            if !state.buffer.is_empty() {
1971                state.marker_list.adjust_for_insert(0, state.buffer.len());
1972            }
1973
1974            // Add virtual text at position 5 (after 'hello')
1975            let vtext_id = state.virtual_texts.add(
1976                &mut state.marker_list,
1977                5,
1978                ": string".to_string(),
1979                Style::default(),
1980                VirtualTextPosition::AfterChar,
1981                0,
1982            );
1983
1984            // Query should return the virtual text
1985            let results = state.virtual_texts.query_range(&state.marker_list, 0, 11);
1986            assert_eq!(results.len(), 1);
1987            assert_eq!(results[0].0, 5); // Position
1988            assert_eq!(results[0].1.text, ": string");
1989
1990            // Build lookup should work
1991            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 11);
1992            assert!(lookup.contains_key(&5));
1993            assert_eq!(lookup[&5].len(), 1);
1994            assert_eq!(lookup[&5][0].text, ": string");
1995
1996            // Clean up
1997            state.virtual_texts.remove(&mut state.marker_list, vtext_id);
1998            assert!(state.virtual_texts.is_empty());
1999        }
2000
2001        #[test]
2002        fn test_virtual_text_position_tracking_on_insert() {
2003            let mut state = EditorState::new(
2004                80,
2005                24,
2006                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2007                test_fs(),
2008            );
2009            state.buffer = Buffer::from_str_test("hello world");
2010
2011            // Initialize marker list for buffer
2012            if !state.buffer.is_empty() {
2013                state.marker_list.adjust_for_insert(0, state.buffer.len());
2014            }
2015
2016            // Add virtual text at position 6 (the 'w' in 'world')
2017            let _vtext_id = state.virtual_texts.add(
2018                &mut state.marker_list,
2019                6,
2020                "/*param*/".to_string(),
2021                Style::default(),
2022                VirtualTextPosition::BeforeChar,
2023                0,
2024            );
2025
2026            // Insert "beautiful " at position 6 using Event
2027            let mut cursors = Cursors::new();
2028            let cursor_id = cursors.primary_id();
2029            state.apply(
2030                &mut cursors,
2031                &Event::Insert {
2032                    position: 6,
2033                    text: "beautiful ".to_string(),
2034                    cursor_id,
2035                },
2036            );
2037
2038            // Virtual text should now be at position 16 (6 + 10)
2039            let results = state.virtual_texts.query_range(&state.marker_list, 0, 30);
2040            assert_eq!(results.len(), 1);
2041            assert_eq!(results[0].0, 16); // Position should have moved
2042            assert_eq!(results[0].1.text, "/*param*/");
2043        }
2044
2045        #[test]
2046        fn test_virtual_text_position_tracking_on_delete() {
2047            let mut state = EditorState::new(
2048                80,
2049                24,
2050                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2051                test_fs(),
2052            );
2053            state.buffer = Buffer::from_str_test("hello beautiful world");
2054
2055            // Initialize marker list for buffer
2056            if !state.buffer.is_empty() {
2057                state.marker_list.adjust_for_insert(0, state.buffer.len());
2058            }
2059
2060            // Add virtual text at position 16 (the 'w' in 'world')
2061            let _vtext_id = state.virtual_texts.add(
2062                &mut state.marker_list,
2063                16,
2064                ": string".to_string(),
2065                Style::default(),
2066                VirtualTextPosition::AfterChar,
2067                0,
2068            );
2069
2070            // Delete "beautiful " (positions 6-16) using Event
2071            let mut cursors = Cursors::new();
2072            let cursor_id = cursors.primary_id();
2073            state.apply(
2074                &mut cursors,
2075                &Event::Delete {
2076                    range: 6..16,
2077                    deleted_text: "beautiful ".to_string(),
2078                    cursor_id,
2079                },
2080            );
2081
2082            // Virtual text should now be at position 6
2083            let results = state.virtual_texts.query_range(&state.marker_list, 0, 20);
2084            assert_eq!(results.len(), 1);
2085            assert_eq!(results[0].0, 6); // Position should have moved back
2086            assert_eq!(results[0].1.text, ": string");
2087        }
2088
2089        #[test]
2090        fn test_multiple_virtual_texts_with_priorities() {
2091            let mut state = EditorState::new(
2092                80,
2093                24,
2094                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2095                test_fs(),
2096            );
2097            state.buffer = Buffer::from_str_test("let x = 5");
2098
2099            // Initialize marker list for buffer
2100            if !state.buffer.is_empty() {
2101                state.marker_list.adjust_for_insert(0, state.buffer.len());
2102            }
2103
2104            // Add type hint after 'x' (position 5)
2105            state.virtual_texts.add(
2106                &mut state.marker_list,
2107                5,
2108                ": i32".to_string(),
2109                Style::default(),
2110                VirtualTextPosition::AfterChar,
2111                0, // Lower priority - renders first
2112            );
2113
2114            // Add another hint at same position with higher priority
2115            state.virtual_texts.add(
2116                &mut state.marker_list,
2117                5,
2118                " /* inferred */".to_string(),
2119                Style::default(),
2120                VirtualTextPosition::AfterChar,
2121                10, // Higher priority - renders second
2122            );
2123
2124            // Build lookup - should have both, sorted by priority (lower first)
2125            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 10);
2126            assert!(lookup.contains_key(&5));
2127            let vtexts = &lookup[&5];
2128            assert_eq!(vtexts.len(), 2);
2129            // Lower priority first (like layer ordering)
2130            assert_eq!(vtexts[0].text, ": i32");
2131            assert_eq!(vtexts[1].text, " /* inferred */");
2132        }
2133
2134        #[test]
2135        fn test_virtual_text_clear() {
2136            let mut state = EditorState::new(
2137                80,
2138                24,
2139                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2140                test_fs(),
2141            );
2142            state.buffer = Buffer::from_str_test("test");
2143
2144            // Initialize marker list for buffer
2145            if !state.buffer.is_empty() {
2146                state.marker_list.adjust_for_insert(0, state.buffer.len());
2147            }
2148
2149            // Add multiple virtual texts
2150            state.virtual_texts.add(
2151                &mut state.marker_list,
2152                0,
2153                "hint1".to_string(),
2154                Style::default(),
2155                VirtualTextPosition::BeforeChar,
2156                0,
2157            );
2158            state.virtual_texts.add(
2159                &mut state.marker_list,
2160                2,
2161                "hint2".to_string(),
2162                Style::default(),
2163                VirtualTextPosition::AfterChar,
2164                0,
2165            );
2166
2167            assert_eq!(state.virtual_texts.len(), 2);
2168
2169            // Clear all
2170            state.virtual_texts.clear(&mut state.marker_list);
2171            assert!(state.virtual_texts.is_empty());
2172
2173            // Query should return nothing
2174            let results = state.virtual_texts.query_range(&state.marker_list, 0, 10);
2175            assert!(results.is_empty());
2176        }
2177    }
2178}