Skip to main content

twrite_core/
lib.rs

1//! Core headless text buffer, syntax, movement, selection, and hook primitives.
2
3/// Battery registry: optional, feature-gated editor batteries (see it for the
4/// contract and checklist for adding new batteries).
5pub mod batteries;
6/// Text buffer implementation backed by a Rope.
7pub mod buffer;
8/// Headless right-click context menu state and item merging.
9pub mod context_menu;
10/// 2D text coordinates (row and column).
11pub mod coordinates;
12/// App-level hook effects (save, load, quit, messages) for headless frontends.
13pub mod effect;
14/// Strongly-typed error types and results for editor operations.
15pub mod error;
16/// Granular undo/redo transaction history.
17pub mod history;
18/// Extensible hook system and input interceptors.
19pub mod hook;
20/// Structured key identity for hooks, hints, and keymaps.
21pub mod keycode;
22/// CommonMark and GitHub Flavored Markdown highlighter and interactive hook.
23///
24/// Lives at `src/batteries/markdown/`; this shim keeps the public path
25/// `twrite_core::markdown` stable regardless of file layout.
26#[cfg(feature = "markdown")]
27#[path = "batteries/markdown/mod.rs"]
28pub mod markdown;
29/// Text movement and boundary calculation primitives.
30pub mod movement;
31/// Headless prompt / input-box primitive (bottom bar, command palette).
32pub mod prompt;
33/// Headless find & replace engine (literal and regex search, single-undo batch replace).
34pub mod search;
35/// Stock search/replace hook binding the engine to the shared prompt.
36pub mod search_hook;
37/// Text selection ranges and anchor/head management.
38pub mod selection;
39/// Headless syntax highlighting, styling tokens, and interval splitting.
40pub mod syntax;
41
42pub use buffer::EditorBuffer;
43pub use context_menu::{
44    COPY_ID, CUT_ID, ContextMenuCaps, ContextMenuContext, ContextMenuItem, ContextMenuState,
45    DELETE_ID, KeyHint, PASTE_ID, REDO_ID, SELECT_ALL_ID, UNDO_ID, collect_context_items,
46    default_context_items,
47};
48pub use coordinates::Point;
49pub use effect::HookEffect;
50pub use error::{EditorError, Result as EditorResult};
51pub use hook::{
52    AutoPairsHook, CursorStyle, EditorHook, HookContext, HookOutcome, KeyEvent, Modifiers,
53    SearchSnapshot,
54};
55pub use keycode::KeyCode;
56#[cfg(feature = "markdown")]
57pub use markdown::{
58    ConcealMode, MarkdownConfig, MarkdownHighlighter, MarkdownHook, TABLE_CELL_TAG,
59    TABLE_DELIMITER_TAG, TABLE_HEADER_TAG, TableAlignment, TableBlock, TableLayout, TableRowKind,
60    fence_rows, find_unescaped_pipes, is_fenced_row, parse_delimiter_row, split_table_cells,
61    table_block_at, table_block_at_with_fences, table_layouts, table_layouts_with_fences,
62};
63pub use movement::{
64    CharKind, classify_char, find_line_end, find_line_range_at, find_line_start,
65    find_next_word_end, find_prev_word_start, find_word_range_at,
66};
67pub use prompt::{
68    PromptAction, PromptItem, PromptPlacement, PromptSpec, PromptState, fuzzy_filter, fuzzy_score,
69};
70pub use search::{SearchQuery, SearchState, find_matches, find_next, find_prev, replace_all_query};
71pub use search::{collect_replacements, replace_one_query};
72pub use search_hook::{REPLACE_PROMPT_ID, SEARCH_PROMPT_ID, SearchAction, SearchHook};
73pub use selection::Selection;
74pub use syntax::{
75    ConcealedLine, DisplayPad, HighlightTag, Rgba, StyleSpan, StyleValue, StyledSegment,
76    SyntaxHighlighter, TextStyle, UnderlineDecoration, display_width, split_line_intervals,
77};
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn delete_removes_character_at_cursor() {
85        let mut buffer = EditorBuffer::new("hello");
86        buffer.set_cursor_offset(1);
87
88        buffer.delete();
89
90        assert_eq!(buffer.text().to_string(), "hllo");
91        assert_eq!(buffer.cursor_offset(), 1);
92    }
93
94    #[test]
95    fn delete_at_end_does_nothing() {
96        let mut buffer = EditorBuffer::new("hello");
97        buffer.set_cursor_offset(5);
98
99        buffer.delete();
100
101        assert_eq!(buffer.text().to_string(), "hello");
102        assert_eq!(buffer.cursor_offset(), 5);
103    }
104
105    #[test]
106    fn delete_from_empty_buffer_does_nothing() {
107        let mut buffer = EditorBuffer::new("");
108
109        buffer.delete();
110
111        assert_eq!(buffer.text().to_string(), "");
112        assert_eq!(buffer.cursor_offset(), 0);
113    }
114
115    #[test]
116    fn delete_handles_unicode() {
117        let mut buffer = EditorBuffer::new("héllo");
118
119        buffer.set_cursor_offset(1);
120
121        assert_eq!(buffer.cursor_offset(), 1);
122        assert_eq!(buffer.text().to_string(), "héllo");
123
124        buffer.delete();
125
126        assert_eq!(buffer.text().to_string(), "hllo");
127        assert_eq!(buffer.cursor_offset(), 1);
128    }
129
130    #[test]
131    fn delete_records_undo_history() {
132        let mut buffer = EditorBuffer::new("hello");
133        buffer.set_cursor_offset(1);
134
135        buffer.delete();
136        buffer.undo();
137
138        assert_eq!(buffer.text().to_string(), "hello");
139        assert_eq!(buffer.cursor_offset(), 1);
140    }
141
142    #[test]
143    fn delete_can_be_redone() {
144        let mut buffer = EditorBuffer::new("hello");
145        buffer.set_cursor_offset(1);
146
147        buffer.delete();
148        buffer.undo();
149        buffer.redo();
150
151        assert_eq!(buffer.text().to_string(), "hllo");
152        assert_eq!(buffer.cursor_offset(), 1);
153    }
154
155    #[test]
156    fn delete_does_not_move_cursor() {
157        let mut buffer = EditorBuffer::new("hello");
158        buffer.set_cursor_offset(2);
159
160        buffer.delete();
161
162        assert_eq!(buffer.text().to_string(), "helo");
163        assert_eq!(buffer.cursor_offset(), 2);
164    }
165
166    #[test]
167    fn delete_removes_newline() {
168        let mut buffer = EditorBuffer::new("hello\nworld");
169        buffer.set_cursor_offset(5);
170
171        buffer.delete();
172
173        assert_eq!(buffer.text().to_string(), "helloworld");
174        assert_eq!(buffer.cursor_offset(), 5);
175    }
176
177    #[test]
178    fn delete_range_removes_text_and_sets_cursor() {
179        let mut buffer = EditorBuffer::new("hello world");
180        buffer.delete_range(5..11);
181
182        assert_eq!(buffer.text().to_string(), "hello");
183        assert_eq!(buffer.cursor_offset(), 5);
184
185        buffer.undo();
186        assert_eq!(buffer.text().to_string(), "hello world");
187
188        buffer.redo();
189        assert_eq!(buffer.text().to_string(), "hello");
190    }
191
192    #[test]
193    fn delete_range_all() {
194        let mut buffer = EditorBuffer::new("hello world");
195        buffer.delete_range(0..buffer.len_bytes());
196
197        assert_eq!(buffer.text().to_string(), "");
198        assert_eq!(buffer.cursor_offset(), 0);
199
200        buffer.undo();
201        assert_eq!(buffer.text().to_string(), "hello world");
202    }
203
204    #[test]
205    fn replace_range_works_and_is_undoable() {
206        let mut buffer = EditorBuffer::new("hello world");
207        buffer.replace_range(6..11, "there");
208
209        assert_eq!(buffer.text().to_string(), "hello there");
210        assert_eq!(buffer.cursor_offset(), 11);
211
212        buffer.undo();
213        assert_eq!(buffer.text().to_string(), "hello world");
214
215        buffer.redo();
216        assert_eq!(buffer.text().to_string(), "hello there");
217    }
218
219    #[test]
220    fn delete_prev_word_removes_word_and_is_undoable() {
221        let mut buffer = EditorBuffer::new("hello world");
222        buffer.set_cursor_offset(11);
223
224        assert!(buffer.delete_prev_word());
225        assert_eq!(buffer.text().to_string(), "hello ");
226        assert_eq!(buffer.cursor_offset(), 6);
227
228        buffer.undo();
229        assert_eq!(buffer.text().to_string(), "hello world");
230        assert_eq!(buffer.cursor_offset(), 11);
231
232        buffer.redo();
233        assert_eq!(buffer.text().to_string(), "hello ");
234        assert_eq!(buffer.cursor_offset(), 6);
235    }
236
237    #[test]
238    fn delete_next_word_removes_word_and_is_undoable() {
239        let mut buffer = EditorBuffer::new("hello world");
240        buffer.set_cursor_offset(0);
241
242        assert!(buffer.delete_next_word());
243        assert_eq!(buffer.text().to_string(), " world");
244        assert_eq!(buffer.cursor_offset(), 0);
245
246        buffer.undo();
247        assert_eq!(buffer.text().to_string(), "hello world");
248        assert_eq!(buffer.cursor_offset(), 0);
249
250        buffer.redo();
251        assert_eq!(buffer.text().to_string(), " world");
252        assert_eq!(buffer.cursor_offset(), 0);
253    }
254
255    #[test]
256    fn test_buffer_version_increments_on_edits() {
257        let mut buffer = EditorBuffer::new("initial");
258        assert_eq!(buffer.version(), 0);
259
260        buffer.insert(" text");
261        assert_eq!(buffer.version(), 1);
262
263        buffer.backspace();
264        assert_eq!(buffer.version(), 2);
265
266        buffer.set_cursor_offset(0);
267        buffer.delete();
268        assert_eq!(buffer.version(), 3);
269
270        buffer.undo();
271        assert_eq!(buffer.version(), 4);
272
273        buffer.redo();
274        assert_eq!(buffer.version(), 5);
275    }
276
277    #[test]
278    fn test_error_handling_validation() {
279        let mut buffer = EditorBuffer::new("hello\nworld");
280
281        assert!(buffer.validate_offset(0).is_ok());
282        assert!(buffer.validate_offset(11).is_ok());
283        assert!(matches!(
284            buffer.validate_offset(12),
285            Err(EditorError::OutOfBounds {
286                offset: 12,
287                len: 11
288            })
289        ));
290
291        assert!(buffer.validate_range(&(0..5)).is_ok());
292        let (inverted_start, inverted_end) = (5, 3);
293        assert!(matches!(
294            buffer.validate_range(&(inverted_start..inverted_end)),
295            Err(EditorError::InvalidRange { .. })
296        ));
297        assert!(matches!(
298            buffer.validate_range(&(0..20)),
299            Err(EditorError::InvalidRange { .. })
300        ));
301
302        assert_eq!(buffer.try_line_to_string(0).unwrap(), "hello\n");
303        assert_eq!(buffer.try_line_to_string(1).unwrap(), "world");
304        assert!(matches!(
305            buffer.try_line_to_string(2),
306            Err(EditorError::InvalidRow {
307                row: 2,
308                total_lines: 2
309            })
310        ));
311
312        assert!(buffer.try_replace_range(0..5, "hi").is_ok());
313        assert_eq!(buffer.text().to_string(), "hi\nworld");
314        assert!(buffer.try_delete_range(0..3).is_ok());
315        assert_eq!(buffer.text().to_string(), "world");
316    }
317
318    #[test]
319    fn test_file_io_roundtrip() {
320        let temp_dir = std::env::temp_dir();
321        let file_path = temp_dir.join(format!("twrite_test_{}.txt", buffer_version_rand()));
322
323        let buffer = EditorBuffer::new("Persistent story content\nLine 2");
324        assert!(buffer.save_to_file(&file_path).is_ok());
325
326        let loaded = EditorBuffer::from_file(&file_path);
327        assert!(loaded.is_ok());
328        let loaded = loaded.unwrap();
329        assert_eq!(
330            loaded.text().to_string(),
331            "Persistent story content\nLine 2"
332        );
333
334        let _ = std::fs::remove_file(&file_path);
335    }
336
337    #[test]
338    fn test_buffer_word_and_line_range_at() {
339        let buffer = EditorBuffer::new("hello world\nsecond line");
340        assert_eq!(buffer.word_range_at(2), 0..5);
341        assert_eq!(buffer.word_range_at(6), 6..11);
342        assert_eq!(buffer.line_range_at(3), 0..12);
343        assert_eq!(buffer.line_range_at(15), 12..23);
344    }
345
346    fn buffer_version_rand() -> u64 {
347        use std::time::{SystemTime, UNIX_EPOCH};
348        SystemTime::now()
349            .duration_since(UNIX_EPOCH)
350            .unwrap()
351            .as_nanos() as u64
352    }
353}