use crate::model::buffer::{Buffer, LineNumber};
use crate::model::cursor::{Cursor, Cursors};
use crate::model::document_model::{
DocumentCapabilities, DocumentModel, DocumentPosition, ViewportContent, ViewportLine,
};
use crate::model::event::{
Event, MarginContentData, MarginPositionData, OverlayFace as EventOverlayFace, PopupData,
PopupPositionData,
};
use crate::model::marker::MarkerList;
use crate::primitives::grammar_registry::GrammarRegistry;
use crate::primitives::highlight_engine::HighlightEngine;
use crate::primitives::highlighter::Language;
use crate::primitives::indent::IndentCalculator;
use crate::primitives::reference_highlighter::ReferenceHighlighter;
use crate::primitives::text_property::TextPropertyManager;
use crate::view::margin::{MarginAnnotation, MarginContent, MarginManager, MarginPosition};
use crate::view::overlay::{Overlay, OverlayFace, OverlayManager, UnderlineStyle};
use crate::view::popup::{Popup, PopupContent, PopupListItem, PopupManager, PopupPosition};
use crate::view::reference_highlight_cache::ReferenceHighlightCache;
use crate::view::virtual_text::VirtualTextManager;
use anyhow::Result;
use ratatui::style::{Color, Style};
use std::cell::RefCell;
use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViewMode {
Source,
Compose,
}
pub struct EditorState {
pub buffer: Buffer,
pub cursors: Cursors,
pub highlighter: HighlightEngine,
pub indent_calculator: RefCell<IndentCalculator>,
pub overlays: OverlayManager,
pub marker_list: MarkerList,
pub virtual_texts: VirtualTextManager,
pub popups: PopupManager,
pub margins: MarginManager,
pub primary_cursor_line_number: LineNumber,
pub mode: String,
pub text_properties: TextPropertyManager,
pub show_cursors: bool,
pub editing_disabled: bool,
pub is_composite_buffer: bool,
pub show_whitespace_tabs: bool,
pub use_tabs: bool,
pub tab_size: usize,
pub reference_highlighter: ReferenceHighlighter,
pub view_mode: ViewMode,
pub debug_highlight_mode: bool,
pub compose_width: Option<u16>,
pub compose_prev_line_numbers: Option<bool>,
pub compose_column_guides: Option<Vec<u16>>,
pub view_transform: Option<crate::services::plugins::api::ViewTransformPayload>,
pub reference_highlight_cache: ReferenceHighlightCache,
pub semantic_tokens: Option<SemanticTokenStore>,
pub language: String,
}
impl EditorState {
pub fn new(_width: u16, _height: u16, large_file_threshold: usize) -> Self {
Self {
buffer: Buffer::new(large_file_threshold),
cursors: Cursors::new(),
highlighter: HighlightEngine::None, indent_calculator: RefCell::new(IndentCalculator::new()),
overlays: OverlayManager::new(),
marker_list: MarkerList::new(),
virtual_texts: VirtualTextManager::new(),
popups: PopupManager::new(),
margins: MarginManager::new(),
primary_cursor_line_number: LineNumber::Absolute(0), mode: "insert".to_string(),
text_properties: TextPropertyManager::new(),
show_cursors: true,
editing_disabled: false,
is_composite_buffer: false,
show_whitespace_tabs: true,
use_tabs: false,
tab_size: 4, reference_highlighter: ReferenceHighlighter::new(),
view_mode: ViewMode::Source,
debug_highlight_mode: false,
compose_width: None,
compose_prev_line_numbers: None,
compose_column_guides: None,
view_transform: None,
reference_highlight_cache: ReferenceHighlightCache::new(),
semantic_tokens: None,
language: "text".to_string(), }
}
pub fn set_language_from_name(&mut self, name: &str, registry: &GrammarRegistry) {
let cleaned_name = name.trim_matches('*');
let filename = if let Some(pos) = cleaned_name.rfind(':') {
&cleaned_name[pos + 1..]
} else {
cleaned_name
};
let path = std::path::Path::new(filename);
self.highlighter = HighlightEngine::for_file(path, registry);
if let Some(language) = Language::from_path(path) {
self.reference_highlighter.set_language(&language);
self.language = language.to_string();
} else {
self.language = "text".to_string();
}
tracing::debug!(
"Set highlighter for virtual buffer based on name: {} -> {} (backend: {}, language: {})",
name,
filename,
self.highlighter.backend_name(),
self.language
);
}
pub fn from_file(
path: &std::path::Path,
_width: u16,
_height: u16,
large_file_threshold: usize,
registry: &GrammarRegistry,
) -> anyhow::Result<Self> {
let buffer = Buffer::load_from_file(path, large_file_threshold)?;
let highlighter = HighlightEngine::for_file(path, registry);
tracing::debug!(
"Created highlighter for {:?} (backend: {})",
path,
highlighter.backend_name()
);
let language = Language::from_path(path);
let mut reference_highlighter = ReferenceHighlighter::new();
let language_name = if let Some(lang) = &language {
reference_highlighter.set_language(lang);
lang.to_string()
} else {
"text".to_string()
};
let mut marker_list = MarkerList::new();
if buffer.len() > 0 {
tracing::debug!(
"Initializing marker list for file with {} bytes",
buffer.len()
);
marker_list.adjust_for_insert(0, buffer.len());
}
Ok(Self {
buffer,
cursors: Cursors::new(),
highlighter,
indent_calculator: RefCell::new(IndentCalculator::new()),
overlays: OverlayManager::new(),
marker_list,
virtual_texts: VirtualTextManager::new(),
popups: PopupManager::new(),
margins: MarginManager::new(),
primary_cursor_line_number: LineNumber::Absolute(0), mode: "insert".to_string(),
text_properties: TextPropertyManager::new(),
show_cursors: true,
editing_disabled: false,
is_composite_buffer: false,
show_whitespace_tabs: true,
use_tabs: false,
tab_size: 4, reference_highlighter,
view_mode: ViewMode::Source,
debug_highlight_mode: false,
compose_width: None,
compose_prev_line_numbers: None,
compose_column_guides: None,
view_transform: None,
reference_highlight_cache: ReferenceHighlightCache::new(),
semantic_tokens: None,
language: language_name,
})
}
pub fn from_file_with_languages(
path: &std::path::Path,
_width: u16,
_height: u16,
large_file_threshold: usize,
registry: &GrammarRegistry,
languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
) -> anyhow::Result<Self> {
let buffer = Buffer::load_from_file(path, large_file_threshold)?;
let highlighter = HighlightEngine::for_file_with_languages(path, registry, languages);
tracing::debug!(
"Created highlighter for {:?} (backend: {})",
path,
highlighter.backend_name()
);
let language = Language::from_path(path);
let mut reference_highlighter = ReferenceHighlighter::new();
let language_name = if let Some(lang) = &language {
reference_highlighter.set_language(lang);
lang.to_string()
} else {
"text".to_string()
};
let mut marker_list = MarkerList::new();
if buffer.len() > 0 {
tracing::debug!(
"Initializing marker list for file with {} bytes",
buffer.len()
);
marker_list.adjust_for_insert(0, buffer.len());
}
Ok(Self {
buffer,
cursors: Cursors::new(),
highlighter,
indent_calculator: RefCell::new(IndentCalculator::new()),
overlays: OverlayManager::new(),
marker_list,
virtual_texts: VirtualTextManager::new(),
popups: PopupManager::new(),
margins: MarginManager::new(),
primary_cursor_line_number: LineNumber::Absolute(0), mode: "insert".to_string(),
text_properties: TextPropertyManager::new(),
show_cursors: true,
editing_disabled: false,
is_composite_buffer: false,
show_whitespace_tabs: true,
use_tabs: false,
tab_size: 4, reference_highlighter,
view_mode: ViewMode::Source,
debug_highlight_mode: false,
compose_width: None,
compose_prev_line_numbers: None,
compose_column_guides: None,
view_transform: None,
reference_highlight_cache: ReferenceHighlightCache::new(),
semantic_tokens: None,
language: language_name,
})
}
fn apply_insert(
&mut self,
position: usize,
text: &str,
cursor_id: crate::model::event::CursorId,
) {
let newlines_inserted = text.matches('\n').count();
self.marker_list.adjust_for_insert(position, text.len());
self.margins.adjust_for_insert(position, text.len());
self.buffer.insert(position, text);
self.highlighter
.invalidate_range(position..position + text.len());
self.cursors.adjust_for_edit(position, 0, text.len());
if let Some(cursor) = self.cursors.get_mut(cursor_id) {
cursor.position = position + text.len();
cursor.clear_selection();
}
if cursor_id == self.cursors.primary_id() {
self.primary_cursor_line_number = match self.primary_cursor_line_number {
LineNumber::Absolute(line) => LineNumber::Absolute(line + newlines_inserted),
LineNumber::Relative {
line,
from_cached_line,
} => LineNumber::Relative {
line: line + newlines_inserted,
from_cached_line,
},
};
}
}
fn apply_delete(
&mut self,
range: &std::ops::Range<usize>,
cursor_id: crate::model::event::CursorId,
deleted_text: &str,
) {
let len = range.len();
let newlines_deleted = deleted_text.matches('\n').count();
self.marker_list.adjust_for_delete(range.start, len);
self.margins.adjust_for_delete(range.start, len);
self.buffer.delete(range.clone());
self.highlighter.invalidate_range(range.clone());
self.cursors.adjust_for_edit(range.start, len, 0);
if let Some(cursor) = self.cursors.get_mut(cursor_id) {
cursor.position = range.start;
cursor.clear_selection();
}
if cursor_id == self.cursors.primary_id() {
self.primary_cursor_line_number = match self.primary_cursor_line_number {
LineNumber::Absolute(line) => {
LineNumber::Absolute(line.saturating_sub(newlines_deleted))
}
LineNumber::Relative {
line,
from_cached_line,
} => LineNumber::Relative {
line: line.saturating_sub(newlines_deleted),
from_cached_line,
},
};
}
}
pub fn apply(&mut self, event: &Event) {
match event {
Event::Insert {
position,
text,
cursor_id,
} => self.apply_insert(*position, text, *cursor_id),
Event::Delete {
range,
cursor_id,
deleted_text,
} => self.apply_delete(range, *cursor_id, deleted_text),
Event::MoveCursor {
cursor_id,
new_position,
new_anchor,
new_sticky_column,
..
} => {
if let Some(cursor) = self.cursors.get_mut(*cursor_id) {
cursor.position = *new_position;
cursor.anchor = *new_anchor;
cursor.sticky_column = *new_sticky_column;
}
if *cursor_id == self.cursors.primary_id() {
self.primary_cursor_line_number =
match self.buffer.offset_to_position(*new_position) {
Some(pos) => LineNumber::Absolute(pos.line),
None => {
let estimated_line = *new_position / 80;
LineNumber::Absolute(estimated_line)
}
};
}
}
Event::AddCursor {
cursor_id,
position,
anchor,
} => {
let cursor = if let Some(anchor) = anchor {
Cursor::with_selection(*anchor, *position)
} else {
Cursor::new(*position)
};
self.cursors.insert_with_id(*cursor_id, cursor);
self.cursors.normalize();
}
Event::RemoveCursor { cursor_id, .. } => {
self.cursors.remove(*cursor_id);
}
Event::Scroll { .. } | Event::SetViewport { .. } | Event::Recenter => {
tracing::warn!("View event {:?} reached EditorState.apply() - should be handled by SplitViewState", event);
}
Event::SetAnchor {
cursor_id,
position,
} => {
if let Some(cursor) = self.cursors.get_mut(*cursor_id) {
cursor.anchor = Some(*position);
cursor.deselect_on_move = false;
}
}
Event::ClearAnchor { cursor_id } => {
if let Some(cursor) = self.cursors.get_mut(*cursor_id) {
cursor.anchor = None;
cursor.deselect_on_move = true;
cursor.clear_block_selection();
}
}
Event::ChangeMode { mode } => {
self.mode = mode.clone();
}
Event::AddOverlay {
namespace,
range,
face,
priority,
message,
extend_to_line_end,
} => {
tracing::debug!(
"AddOverlay: namespace={:?}, range={:?}, face={:?}, priority={}",
namespace,
range,
face,
priority
);
let overlay_face = convert_event_face_to_overlay_face(face);
tracing::trace!("Converted face: {:?}", overlay_face);
let mut overlay = Overlay::with_priority(
&mut self.marker_list,
range.clone(),
overlay_face,
*priority,
);
overlay.namespace = namespace.clone();
overlay.message = message.clone();
overlay.extend_to_line_end = *extend_to_line_end;
let actual_range = overlay.range(&self.marker_list);
tracing::debug!(
"Created overlay with markers - actual range: {:?}, handle={:?}",
actual_range,
overlay.handle
);
self.overlays.add(overlay);
}
Event::RemoveOverlay { handle } => {
tracing::debug!("RemoveOverlay: handle={:?}", handle);
self.overlays
.remove_by_handle(handle, &mut self.marker_list);
}
Event::RemoveOverlaysInRange { range } => {
self.overlays.remove_in_range(range, &mut self.marker_list);
}
Event::ClearNamespace { namespace } => {
tracing::debug!("ClearNamespace: namespace={:?}", namespace);
self.overlays
.clear_namespace(namespace, &mut self.marker_list);
}
Event::ClearOverlays => {
self.overlays.clear(&mut self.marker_list);
}
Event::ShowPopup { popup } => {
let popup_obj = convert_popup_data_to_popup(popup);
self.popups.show(popup_obj);
}
Event::HidePopup => {
self.popups.hide();
}
Event::ClearPopups => {
self.popups.clear();
}
Event::PopupSelectNext => {
if let Some(popup) = self.popups.top_mut() {
popup.select_next();
}
}
Event::PopupSelectPrev => {
if let Some(popup) = self.popups.top_mut() {
popup.select_prev();
}
}
Event::PopupPageDown => {
if let Some(popup) = self.popups.top_mut() {
popup.page_down();
}
}
Event::PopupPageUp => {
if let Some(popup) = self.popups.top_mut() {
popup.page_up();
}
}
Event::AddMarginAnnotation {
line,
position,
content,
annotation_id,
} => {
let margin_position = convert_margin_position(position);
let margin_content = convert_margin_content(content);
let annotation = if let Some(id) = annotation_id {
MarginAnnotation::with_id(*line, margin_position, margin_content, id.clone())
} else {
MarginAnnotation::new(*line, margin_position, margin_content)
};
self.margins.add_annotation(annotation);
}
Event::RemoveMarginAnnotation { annotation_id } => {
self.margins.remove_by_id(annotation_id);
}
Event::RemoveMarginAnnotationsAtLine { line, position } => {
let margin_position = convert_margin_position(position);
self.margins.remove_at_line(*line, margin_position);
}
Event::ClearMarginPosition { position } => {
let margin_position = convert_margin_position(position);
self.margins.clear_position(margin_position);
}
Event::ClearMargins => {
self.margins.clear_all();
}
Event::SetLineNumbers { enabled } => {
self.margins.set_line_numbers(*enabled);
}
Event::SplitPane { .. }
| Event::CloseSplit { .. }
| Event::SetActiveSplit { .. }
| Event::AdjustSplitRatio { .. }
| Event::NextSplit
| Event::PrevSplit => {
}
Event::Batch { events, .. } => {
for event in events {
self.apply(event);
}
}
Event::BulkEdit {
new_tree,
new_cursors,
..
} => {
if let Some(tree) = new_tree {
self.buffer.restore_piece_tree(tree);
}
for (cursor_id, position, anchor) in new_cursors {
if let Some(cursor) = self.cursors.get_mut(*cursor_id) {
cursor.position = *position;
cursor.anchor = *anchor;
}
}
self.highlighter.invalidate_all();
let primary_pos = self.cursors.primary().position;
self.primary_cursor_line_number = match self.buffer.offset_to_position(primary_pos)
{
Some(pos) => crate::model::buffer::LineNumber::Absolute(pos.line),
None => crate::model::buffer::LineNumber::Absolute(0),
};
}
}
}
pub fn apply_many(&mut self, events: &[Event]) {
for event in events {
self.apply(event);
}
}
pub fn primary_cursor(&self) -> &Cursor {
self.cursors.primary()
}
pub fn primary_cursor_mut(&mut self) -> &mut Cursor {
self.cursors.primary_mut()
}
pub fn on_focus_lost(&mut self) {
if self.popups.dismiss_transient() {
tracing::debug!("Dismissed transient popup on buffer focus loss");
}
}
}
fn convert_event_face_to_overlay_face(event_face: &EventOverlayFace) -> OverlayFace {
match event_face {
EventOverlayFace::Underline { color, style } => {
let underline_style = match style {
crate::model::event::UnderlineStyle::Straight => UnderlineStyle::Straight,
crate::model::event::UnderlineStyle::Wavy => UnderlineStyle::Wavy,
crate::model::event::UnderlineStyle::Dotted => UnderlineStyle::Dotted,
crate::model::event::UnderlineStyle::Dashed => UnderlineStyle::Dashed,
};
OverlayFace::Underline {
color: Color::Rgb(color.0, color.1, color.2),
style: underline_style,
}
}
EventOverlayFace::Background { color } => OverlayFace::Background {
color: Color::Rgb(color.0, color.1, color.2),
},
EventOverlayFace::Foreground { color } => OverlayFace::Foreground {
color: Color::Rgb(color.0, color.1, color.2),
},
EventOverlayFace::Style {
color,
bg_color,
bold,
italic,
underline,
} => {
use ratatui::style::Modifier;
let mut style = Style::default().fg(Color::Rgb(color.0, color.1, color.2));
if let Some(bg) = bg_color {
style = style.bg(Color::Rgb(bg.0, bg.1, bg.2));
}
let mut modifiers = Modifier::empty();
if *bold {
modifiers |= Modifier::BOLD;
}
if *italic {
modifiers |= Modifier::ITALIC;
}
if *underline {
modifiers |= Modifier::UNDERLINED;
}
if !modifiers.is_empty() {
style = style.add_modifier(modifiers);
}
OverlayFace::Style { style }
}
}
}
fn convert_popup_data_to_popup(data: &PopupData) -> Popup {
let content = match &data.content {
crate::model::event::PopupContentData::Text(lines) => PopupContent::Text(lines.clone()),
crate::model::event::PopupContentData::List { items, selected } => PopupContent::List {
items: items
.iter()
.map(|item| PopupListItem {
text: item.text.clone(),
detail: item.detail.clone(),
icon: item.icon.clone(),
data: item.data.clone(),
})
.collect(),
selected: *selected,
},
};
let position = match data.position {
PopupPositionData::AtCursor => PopupPosition::AtCursor,
PopupPositionData::BelowCursor => PopupPosition::BelowCursor,
PopupPositionData::AboveCursor => PopupPosition::AboveCursor,
PopupPositionData::Fixed { x, y } => PopupPosition::Fixed { x, y },
PopupPositionData::Centered => PopupPosition::Centered,
PopupPositionData::BottomRight => PopupPosition::BottomRight,
};
let popup = Popup {
title: data.title.clone(),
description: data.description.clone(),
transient: data.transient,
content,
position,
width: data.width,
max_height: data.max_height,
bordered: data.bordered,
border_style: Style::default().fg(Color::Gray),
background_style: Style::default().bg(Color::Rgb(30, 30, 30)),
scroll_offset: 0,
};
popup
}
fn convert_margin_position(position: &MarginPositionData) -> MarginPosition {
match position {
MarginPositionData::Left => MarginPosition::Left,
MarginPositionData::Right => MarginPosition::Right,
}
}
fn convert_margin_content(content: &MarginContentData) -> MarginContent {
match content {
MarginContentData::Text(text) => MarginContent::Text(text.clone()),
MarginContentData::Symbol { text, color } => {
if let Some((r, g, b)) = color {
MarginContent::colored_symbol(text.clone(), Color::Rgb(*r, *g, *b))
} else {
MarginContent::symbol(text.clone(), Style::default())
}
}
MarginContentData::Empty => MarginContent::Empty,
}
}
impl EditorState {
pub fn prepare_for_render(&mut self, top_byte: usize, height: u16) -> Result<()> {
self.buffer.prepare_viewport(top_byte, height as usize)?;
Ok(())
}
pub fn get_text_range(&mut self, start: usize, end: usize) -> String {
match self
.buffer
.get_text_range_mut(start, end.saturating_sub(start))
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!("Failed to get text range {}..{}: {}", start, end, e);
String::new()
}
}
}
pub fn get_line_at_offset(&mut self, offset: usize) -> Option<(usize, String)> {
use crate::model::document_model::DocumentModel;
let mut line_start = offset;
while line_start > 0 {
if let Ok(text) = self.buffer.get_text_range_mut(line_start - 1, 1) {
if text.first() == Some(&b'\n') {
break;
}
line_start -= 1;
} else {
break;
}
}
let viewport = self
.get_viewport_content(
crate::model::document_model::DocumentPosition::byte(line_start),
1,
)
.ok()?;
viewport
.lines
.first()
.map(|line| (line.byte_offset, line.content.clone()))
}
pub fn get_text_to_end_of_line(&mut self, cursor_pos: usize) -> Result<String> {
use crate::model::document_model::DocumentModel;
let viewport = self.get_viewport_content(
crate::model::document_model::DocumentPosition::byte(cursor_pos),
1,
)?;
if let Some(line) = viewport.lines.first() {
let line_start = line.byte_offset;
let line_end = line_start + line.content.len();
if cursor_pos >= line_start && cursor_pos <= line_end {
let offset_in_line = cursor_pos - line_start;
Ok(line.content.get(offset_in_line..).unwrap_or("").to_string())
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}
}
pub fn set_semantic_tokens(&mut self, store: SemanticTokenStore) {
self.semantic_tokens = Some(store);
}
pub fn clear_semantic_tokens(&mut self) {
self.semantic_tokens = None;
}
pub fn semantic_tokens_result_id(&self) -> Option<&str> {
self.semantic_tokens
.as_ref()
.and_then(|store| store.result_id.as_deref())
}
}
impl DocumentModel for EditorState {
fn capabilities(&self) -> DocumentCapabilities {
let line_count = self.buffer.line_count();
DocumentCapabilities {
has_line_index: line_count.is_some(),
uses_lazy_loading: false, byte_length: self.buffer.len(),
approximate_line_count: line_count.unwrap_or_else(|| {
self.buffer.len() / 80
}),
}
}
fn get_viewport_content(
&mut self,
start_pos: DocumentPosition,
max_lines: usize,
) -> Result<ViewportContent> {
let start_offset = self.position_to_offset(start_pos)?;
let line_iter = self.buffer.iter_lines_from(start_offset, max_lines)?;
let has_more = line_iter.has_more;
let lines = line_iter
.map(|line_data| ViewportLine {
byte_offset: line_data.byte_offset,
content: line_data.content,
has_newline: line_data.has_newline,
approximate_line_number: line_data.line_number,
})
.collect();
Ok(ViewportContent {
start_position: DocumentPosition::ByteOffset(start_offset),
lines,
has_more,
})
}
fn position_to_offset(&self, pos: DocumentPosition) -> Result<usize> {
match pos {
DocumentPosition::ByteOffset(offset) => Ok(offset),
DocumentPosition::LineColumn { line, column } => {
if !self.has_line_index() {
anyhow::bail!("Line indexing not available for this document");
}
let position = crate::model::piece_tree::Position { line, column };
Ok(self.buffer.position_to_offset(position))
}
}
}
fn offset_to_position(&self, offset: usize) -> DocumentPosition {
if self.has_line_index() {
if let Some(pos) = self.buffer.offset_to_position(offset) {
DocumentPosition::LineColumn {
line: pos.line,
column: pos.column,
}
} else {
DocumentPosition::ByteOffset(offset)
}
} else {
DocumentPosition::ByteOffset(offset)
}
}
fn get_range(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<String> {
let start_offset = self.position_to_offset(start)?;
let end_offset = self.position_to_offset(end)?;
if start_offset > end_offset {
anyhow::bail!(
"Invalid range: start offset {} > end offset {}",
start_offset,
end_offset
);
}
let bytes = self
.buffer
.get_text_range_mut(start_offset, end_offset - start_offset)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn get_line_content(&mut self, line_number: usize) -> Option<String> {
if !self.has_line_index() {
return None;
}
let line_start_offset = self.buffer.line_start_offset(line_number)?;
let mut iter = self.buffer.line_iterator(line_start_offset, 80);
if let Some((_start, content)) = iter.next() {
let has_newline = content.ends_with('\n');
let line_content = if has_newline {
content[..content.len() - 1].to_string()
} else {
content
};
Some(line_content)
} else {
None
}
}
fn get_chunk_at_offset(&mut self, offset: usize, size: usize) -> Result<(usize, String)> {
let bytes = self.buffer.get_text_range_mut(offset, size)?;
Ok((offset, String::from_utf8_lossy(&bytes).into_owned()))
}
fn insert(&mut self, pos: DocumentPosition, text: &str) -> Result<usize> {
let offset = self.position_to_offset(pos)?;
self.buffer.insert_bytes(offset, text.as_bytes().to_vec());
Ok(text.len())
}
fn delete(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<()> {
let start_offset = self.position_to_offset(start)?;
let end_offset = self.position_to_offset(end)?;
if start_offset > end_offset {
anyhow::bail!(
"Invalid range: start offset {} > end offset {}",
start_offset,
end_offset
);
}
self.buffer.delete(start_offset..end_offset);
Ok(())
}
fn replace(
&mut self,
start: DocumentPosition,
end: DocumentPosition,
text: &str,
) -> Result<()> {
self.delete(start, end)?;
self.insert(start, text)?;
Ok(())
}
fn find_matches(
&mut self,
pattern: &str,
search_range: Option<(DocumentPosition, DocumentPosition)>,
) -> Result<Vec<usize>> {
let (start_offset, end_offset) = if let Some((start, end)) = search_range {
(
self.position_to_offset(start)?,
self.position_to_offset(end)?,
)
} else {
(0, self.buffer.len())
};
let bytes = self
.buffer
.get_text_range_mut(start_offset, end_offset - start_offset)?;
let text = String::from_utf8_lossy(&bytes);
let mut matches = Vec::new();
let mut search_offset = 0;
while let Some(pos) = text[search_offset..].find(pattern) {
matches.push(start_offset + search_offset + pos);
search_offset += pos + pattern.len();
}
Ok(matches)
}
}
#[derive(Clone, Debug)]
pub struct SemanticTokenStore {
pub version: u64,
pub result_id: Option<String>,
pub tokens: Vec<SemanticTokenSpan>,
}
#[derive(Clone, Debug)]
pub struct SemanticTokenSpan {
pub range: Range<usize>,
pub token_type: String,
pub modifiers: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::event::CursorId;
#[test]
fn test_state_new() {
let state = EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
assert!(state.buffer.is_empty());
assert_eq!(state.cursors.count(), 1);
assert_eq!(state.cursors.primary().position, 0);
}
#[test]
fn test_apply_insert() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::Insert {
position: 0,
text: "hello".to_string(),
cursor_id,
});
assert_eq!(state.buffer.to_string().unwrap(), "hello");
assert_eq!(state.cursors.primary().position, 5);
assert!(state.buffer.is_modified());
}
#[test]
fn test_apply_delete() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::Insert {
position: 0,
text: "hello world".to_string(),
cursor_id,
});
state.apply(&Event::Delete {
range: 5..11,
deleted_text: " world".to_string(),
cursor_id,
});
assert_eq!(state.buffer.to_string().unwrap(), "hello");
assert_eq!(state.cursors.primary().position, 5);
}
#[test]
fn test_apply_move_cursor() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::Insert {
position: 0,
text: "hello".to_string(),
cursor_id,
});
state.apply(&Event::MoveCursor {
cursor_id,
old_position: 5,
new_position: 2,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
});
assert_eq!(state.cursors.primary().position, 2);
}
#[test]
fn test_apply_add_cursor() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = CursorId(1);
state.apply(&Event::AddCursor {
cursor_id,
position: 5,
anchor: None,
});
assert_eq!(state.cursors.count(), 2);
}
#[test]
fn test_apply_many() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = state.cursors.primary_id();
let events = vec![
Event::Insert {
position: 0,
text: "hello ".to_string(),
cursor_id,
},
Event::Insert {
position: 6,
text: "world".to_string(),
cursor_id,
},
];
state.apply_many(&events);
assert_eq!(state.buffer.to_string().unwrap(), "hello world");
}
#[test]
fn test_cursor_adjustment_after_insert() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::AddCursor {
cursor_id: CursorId(1),
position: 5,
anchor: None,
});
state.apply(&Event::Insert {
position: 0,
text: "abc".to_string(),
cursor_id,
});
if let Some(cursor) = state.cursors.get(CursorId(1)) {
assert_eq!(cursor.position, 8);
}
}
mod document_model_tests {
use super::*;
use crate::model::document_model::{DocumentModel, DocumentPosition};
#[test]
fn test_capabilities_small_file() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("line1\nline2\nline3");
let caps = state.capabilities();
assert!(caps.has_line_index, "Small file should have line index");
assert_eq!(caps.byte_length, "line1\nline2\nline3".len());
assert_eq!(caps.approximate_line_count, 3, "Should have 3 lines");
}
#[test]
fn test_position_conversions() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello\nworld\ntest");
let pos1 = DocumentPosition::ByteOffset(6);
let offset1 = state.position_to_offset(pos1).unwrap();
assert_eq!(offset1, 6);
let pos2 = DocumentPosition::LineColumn { line: 1, column: 0 };
let offset2 = state.position_to_offset(pos2).unwrap();
assert_eq!(offset2, 6, "Line 1, column 0 should be at byte 6");
let converted = state.offset_to_position(6);
match converted {
DocumentPosition::LineColumn { line, column } => {
assert_eq!(line, 1);
assert_eq!(column, 0);
}
_ => panic!("Expected LineColumn for small file"),
}
}
#[test]
fn test_get_viewport_content() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
let content = state
.get_viewport_content(DocumentPosition::ByteOffset(0), 3)
.unwrap();
assert_eq!(content.lines.len(), 3);
assert_eq!(content.lines[0].content, "line1");
assert_eq!(content.lines[1].content, "line2");
assert_eq!(content.lines[2].content, "line3");
assert!(content.has_more);
}
#[test]
fn test_get_range() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
let text = state
.get_range(
DocumentPosition::ByteOffset(0),
DocumentPosition::ByteOffset(5),
)
.unwrap();
assert_eq!(text, "hello");
let text2 = state
.get_range(
DocumentPosition::ByteOffset(6),
DocumentPosition::ByteOffset(11),
)
.unwrap();
assert_eq!(text2, "world");
}
#[test]
fn test_get_line_content() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("line1\nline2\nline3");
let line0 = state.get_line_content(0).unwrap();
assert_eq!(line0, "line1");
let line1 = state.get_line_content(1).unwrap();
assert_eq!(line1, "line2");
let line2 = state.get_line_content(2).unwrap();
assert_eq!(line2, "line3");
}
#[test]
fn test_insert_delete() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
let bytes_inserted = state
.insert(DocumentPosition::ByteOffset(6), "beautiful ")
.unwrap();
assert_eq!(bytes_inserted, 10);
assert_eq!(state.buffer.to_string().unwrap(), "hello beautiful world");
state
.delete(
DocumentPosition::ByteOffset(6),
DocumentPosition::ByteOffset(16),
)
.unwrap();
assert_eq!(state.buffer.to_string().unwrap(), "hello world");
}
#[test]
fn test_replace() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
state
.replace(
DocumentPosition::ByteOffset(0),
DocumentPosition::ByteOffset(5),
"hi",
)
.unwrap();
assert_eq!(state.buffer.to_string().unwrap(), "hi world");
}
#[test]
fn test_find_matches() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world hello");
let matches = state.find_matches("hello", None).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0], 0);
assert_eq!(matches[1], 12);
}
#[test]
fn test_prepare_for_render() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");
state.prepare_for_render(0, 24).unwrap();
}
#[test]
fn test_helper_get_text_range() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
let text = state.get_text_range(0, 5);
assert_eq!(text, "hello");
let text2 = state.get_text_range(6, 11);
assert_eq!(text2, "world");
}
#[test]
fn test_helper_get_line_at_offset() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("line1\nline2\nline3");
let (offset, content) = state.get_line_at_offset(0).unwrap();
assert_eq!(offset, 0);
assert_eq!(content, "line1");
let (offset2, content2) = state.get_line_at_offset(8).unwrap();
assert_eq!(offset2, 6); assert_eq!(content2, "line2");
let (offset3, content3) = state.get_line_at_offset(12).unwrap();
assert_eq!(offset3, 12);
assert_eq!(content3, "line3");
}
#[test]
fn test_helper_get_text_to_end_of_line() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world\nline2");
let text = state.get_text_to_end_of_line(0).unwrap();
assert_eq!(text, "hello world");
let text2 = state.get_text_to_end_of_line(6).unwrap();
assert_eq!(text2, "world");
let text3 = state.get_text_to_end_of_line(11).unwrap();
assert_eq!(text3, "");
let text4 = state.get_text_to_end_of_line(12).unwrap();
assert_eq!(text4, "line2");
}
}
mod virtual_text_integration_tests {
use super::*;
use crate::view::virtual_text::VirtualTextPosition;
use ratatui::style::Style;
#[test]
fn test_virtual_text_add_and_query() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
if state.buffer.len() > 0 {
state.marker_list.adjust_for_insert(0, state.buffer.len());
}
let vtext_id = state.virtual_texts.add(
&mut state.marker_list,
5,
": string".to_string(),
Style::default(),
VirtualTextPosition::AfterChar,
0,
);
let results = state.virtual_texts.query_range(&state.marker_list, 0, 11);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 5); assert_eq!(results[0].1.text, ": string");
let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 11);
assert!(lookup.contains_key(&5));
assert_eq!(lookup[&5].len(), 1);
assert_eq!(lookup[&5][0].text, ": string");
state.virtual_texts.remove(&mut state.marker_list, vtext_id);
assert!(state.virtual_texts.is_empty());
}
#[test]
fn test_virtual_text_position_tracking_on_insert() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello world");
if state.buffer.len() > 0 {
state.marker_list.adjust_for_insert(0, state.buffer.len());
}
let _vtext_id = state.virtual_texts.add(
&mut state.marker_list,
6,
"/*param*/".to_string(),
Style::default(),
VirtualTextPosition::BeforeChar,
0,
);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::Insert {
position: 6,
text: "beautiful ".to_string(),
cursor_id,
});
let results = state.virtual_texts.query_range(&state.marker_list, 0, 30);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 16); assert_eq!(results[0].1.text, "/*param*/");
}
#[test]
fn test_virtual_text_position_tracking_on_delete() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("hello beautiful world");
if state.buffer.len() > 0 {
state.marker_list.adjust_for_insert(0, state.buffer.len());
}
let _vtext_id = state.virtual_texts.add(
&mut state.marker_list,
16,
": string".to_string(),
Style::default(),
VirtualTextPosition::AfterChar,
0,
);
let cursor_id = state.cursors.primary_id();
state.apply(&Event::Delete {
range: 6..16,
deleted_text: "beautiful ".to_string(),
cursor_id,
});
let results = state.virtual_texts.query_range(&state.marker_list, 0, 20);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 6); assert_eq!(results[0].1.text, ": string");
}
#[test]
fn test_multiple_virtual_texts_with_priorities() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("let x = 5");
if state.buffer.len() > 0 {
state.marker_list.adjust_for_insert(0, state.buffer.len());
}
state.virtual_texts.add(
&mut state.marker_list,
5,
": i32".to_string(),
Style::default(),
VirtualTextPosition::AfterChar,
0, );
state.virtual_texts.add(
&mut state.marker_list,
5,
" /* inferred */".to_string(),
Style::default(),
VirtualTextPosition::AfterChar,
10, );
let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 10);
assert!(lookup.contains_key(&5));
let vtexts = &lookup[&5];
assert_eq!(vtexts.len(), 2);
assert_eq!(vtexts[0].text, ": i32");
assert_eq!(vtexts[1].text, " /* inferred */");
}
#[test]
fn test_virtual_text_clear() {
let mut state =
EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
state.buffer = Buffer::from_str_test("test");
if state.buffer.len() > 0 {
state.marker_list.adjust_for_insert(0, state.buffer.len());
}
state.virtual_texts.add(
&mut state.marker_list,
0,
"hint1".to_string(),
Style::default(),
VirtualTextPosition::BeforeChar,
0,
);
state.virtual_texts.add(
&mut state.marker_list,
2,
"hint2".to_string(),
Style::default(),
VirtualTextPosition::AfterChar,
0,
);
assert_eq!(state.virtual_texts.len(), 2);
state.virtual_texts.clear(&mut state.marker_list);
assert!(state.virtual_texts.is_empty());
let results = state.virtual_texts.query_range(&state.marker_list, 0, 10);
assert!(results.is_empty());
}
}
}