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    CalloutKind, ConcealedLine, DisplayPad, HighlightTag, Rgba, StyleSpan, StyleValue,
76    StyledSegment, SyntaxHighlighter, TextStyle, UnderlineDecoration, display_width,
77    split_line_intervals,
78};
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn delete_removes_character_at_cursor() {
86        let mut buffer = EditorBuffer::new("hello");
87        buffer.set_cursor_offset(1);
88
89        buffer.delete();
90
91        assert_eq!(buffer.text().to_string(), "hllo");
92        assert_eq!(buffer.cursor_offset(), 1);
93    }
94
95    #[test]
96    fn delete_at_end_does_nothing() {
97        let mut buffer = EditorBuffer::new("hello");
98        buffer.set_cursor_offset(5);
99
100        buffer.delete();
101
102        assert_eq!(buffer.text().to_string(), "hello");
103        assert_eq!(buffer.cursor_offset(), 5);
104    }
105
106    #[test]
107    fn delete_from_empty_buffer_does_nothing() {
108        let mut buffer = EditorBuffer::new("");
109
110        buffer.delete();
111
112        assert_eq!(buffer.text().to_string(), "");
113        assert_eq!(buffer.cursor_offset(), 0);
114    }
115
116    #[test]
117    fn delete_handles_unicode() {
118        let mut buffer = EditorBuffer::new("héllo");
119
120        buffer.set_cursor_offset(1);
121
122        assert_eq!(buffer.cursor_offset(), 1);
123        assert_eq!(buffer.text().to_string(), "héllo");
124
125        buffer.delete();
126
127        assert_eq!(buffer.text().to_string(), "hllo");
128        assert_eq!(buffer.cursor_offset(), 1);
129    }
130
131    #[test]
132    fn delete_records_undo_history() {
133        let mut buffer = EditorBuffer::new("hello");
134        buffer.set_cursor_offset(1);
135
136        buffer.delete();
137        buffer.undo();
138
139        assert_eq!(buffer.text().to_string(), "hello");
140        assert_eq!(buffer.cursor_offset(), 1);
141    }
142
143    #[test]
144    fn delete_can_be_redone() {
145        let mut buffer = EditorBuffer::new("hello");
146        buffer.set_cursor_offset(1);
147
148        buffer.delete();
149        buffer.undo();
150        buffer.redo();
151
152        assert_eq!(buffer.text().to_string(), "hllo");
153        assert_eq!(buffer.cursor_offset(), 1);
154    }
155
156    #[test]
157    fn delete_does_not_move_cursor() {
158        let mut buffer = EditorBuffer::new("hello");
159        buffer.set_cursor_offset(2);
160
161        buffer.delete();
162
163        assert_eq!(buffer.text().to_string(), "helo");
164        assert_eq!(buffer.cursor_offset(), 2);
165    }
166
167    #[test]
168    fn delete_removes_newline() {
169        let mut buffer = EditorBuffer::new("hello\nworld");
170        buffer.set_cursor_offset(5);
171
172        buffer.delete();
173
174        assert_eq!(buffer.text().to_string(), "helloworld");
175        assert_eq!(buffer.cursor_offset(), 5);
176    }
177
178    #[test]
179    fn delete_range_removes_text_and_sets_cursor() {
180        let mut buffer = EditorBuffer::new("hello world");
181        buffer.delete_range(5..11);
182
183        assert_eq!(buffer.text().to_string(), "hello");
184        assert_eq!(buffer.cursor_offset(), 5);
185
186        buffer.undo();
187        assert_eq!(buffer.text().to_string(), "hello world");
188
189        buffer.redo();
190        assert_eq!(buffer.text().to_string(), "hello");
191    }
192
193    #[test]
194    fn delete_range_all() {
195        let mut buffer = EditorBuffer::new("hello world");
196        buffer.delete_range(0..buffer.len_bytes());
197
198        assert_eq!(buffer.text().to_string(), "");
199        assert_eq!(buffer.cursor_offset(), 0);
200
201        buffer.undo();
202        assert_eq!(buffer.text().to_string(), "hello world");
203    }
204
205    #[test]
206    fn replace_range_works_and_is_undoable() {
207        let mut buffer = EditorBuffer::new("hello world");
208        buffer.replace_range(6..11, "there");
209
210        assert_eq!(buffer.text().to_string(), "hello there");
211        assert_eq!(buffer.cursor_offset(), 11);
212
213        buffer.undo();
214        assert_eq!(buffer.text().to_string(), "hello world");
215
216        buffer.redo();
217        assert_eq!(buffer.text().to_string(), "hello there");
218    }
219
220    #[test]
221    fn delete_prev_word_removes_word_and_is_undoable() {
222        let mut buffer = EditorBuffer::new("hello world");
223        buffer.set_cursor_offset(11);
224
225        assert!(buffer.delete_prev_word());
226        assert_eq!(buffer.text().to_string(), "hello ");
227        assert_eq!(buffer.cursor_offset(), 6);
228
229        buffer.undo();
230        assert_eq!(buffer.text().to_string(), "hello world");
231        assert_eq!(buffer.cursor_offset(), 11);
232
233        buffer.redo();
234        assert_eq!(buffer.text().to_string(), "hello ");
235        assert_eq!(buffer.cursor_offset(), 6);
236    }
237
238    #[test]
239    fn delete_next_word_removes_word_and_is_undoable() {
240        let mut buffer = EditorBuffer::new("hello world");
241        buffer.set_cursor_offset(0);
242
243        assert!(buffer.delete_next_word());
244        assert_eq!(buffer.text().to_string(), " world");
245        assert_eq!(buffer.cursor_offset(), 0);
246
247        buffer.undo();
248        assert_eq!(buffer.text().to_string(), "hello world");
249        assert_eq!(buffer.cursor_offset(), 0);
250
251        buffer.redo();
252        assert_eq!(buffer.text().to_string(), " world");
253        assert_eq!(buffer.cursor_offset(), 0);
254    }
255
256    #[test]
257    fn test_buffer_version_increments_on_edits() {
258        let mut buffer = EditorBuffer::new("initial");
259        assert_eq!(buffer.version(), 0);
260
261        buffer.insert(" text");
262        assert_eq!(buffer.version(), 1);
263
264        buffer.backspace();
265        assert_eq!(buffer.version(), 2);
266
267        buffer.set_cursor_offset(0);
268        buffer.delete();
269        assert_eq!(buffer.version(), 3);
270
271        buffer.undo();
272        assert_eq!(buffer.version(), 4);
273
274        buffer.redo();
275        assert_eq!(buffer.version(), 5);
276    }
277
278    #[test]
279    fn test_error_handling_validation() {
280        let mut buffer = EditorBuffer::new("hello\nworld");
281
282        assert!(buffer.validate_offset(0).is_ok());
283        assert!(buffer.validate_offset(11).is_ok());
284        assert!(matches!(
285            buffer.validate_offset(12),
286            Err(EditorError::OutOfBounds {
287                offset: 12,
288                len: 11
289            })
290        ));
291
292        assert!(buffer.validate_range(&(0..5)).is_ok());
293        let (inverted_start, inverted_end) = (5, 3);
294        assert!(matches!(
295            buffer.validate_range(&(inverted_start..inverted_end)),
296            Err(EditorError::InvalidRange { .. })
297        ));
298        assert!(matches!(
299            buffer.validate_range(&(0..20)),
300            Err(EditorError::InvalidRange { .. })
301        ));
302
303        assert_eq!(buffer.try_line_to_string(0).unwrap(), "hello\n");
304        assert_eq!(buffer.try_line_to_string(1).unwrap(), "world");
305        assert!(matches!(
306            buffer.try_line_to_string(2),
307            Err(EditorError::InvalidRow {
308                row: 2,
309                total_lines: 2
310            })
311        ));
312
313        assert!(buffer.try_replace_range(0..5, "hi").is_ok());
314        assert_eq!(buffer.text().to_string(), "hi\nworld");
315        assert!(buffer.try_delete_range(0..3).is_ok());
316        assert_eq!(buffer.text().to_string(), "world");
317    }
318
319    #[test]
320    fn test_file_io_roundtrip() {
321        let temp_dir = std::env::temp_dir();
322        let file_path = temp_dir.join(format!("twrite_test_{}.txt", buffer_version_rand()));
323
324        let buffer = EditorBuffer::new("Persistent story content\nLine 2");
325        assert!(buffer.save_to_file(&file_path).is_ok());
326
327        let loaded = EditorBuffer::from_file(&file_path);
328        assert!(loaded.is_ok());
329        let loaded = loaded.unwrap();
330        assert_eq!(
331            loaded.text().to_string(),
332            "Persistent story content\nLine 2"
333        );
334
335        let _ = std::fs::remove_file(&file_path);
336    }
337
338    #[test]
339    fn test_buffer_word_and_line_range_at() {
340        let buffer = EditorBuffer::new("hello world\nsecond line");
341        assert_eq!(buffer.word_range_at(2), 0..5);
342        assert_eq!(buffer.word_range_at(6), 6..11);
343        assert_eq!(buffer.line_range_at(3), 0..12);
344        assert_eq!(buffer.line_range_at(15), 12..23);
345    }
346
347    fn buffer_version_rand() -> u64 {
348        use std::time::{SystemTime, UNIX_EPOCH};
349        SystemTime::now()
350            .duration_since(UNIX_EPOCH)
351            .unwrap()
352            .as_nanos() as u64
353    }
354}