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