Skip to main content

kode_markdown/
input_rules.rs

1use kode_core::{Editor, Position};
2
3/// Markdown input rules — auto-behaviors triggered by specific keystrokes.
4///
5/// These are called by the view layer when the user presses Enter, Tab, etc.
6/// They inspect the current context and apply markdown-aware transformations.
7pub struct InputRules;
8
9impl InputRules {
10    /// Handle Enter key press. Returns true if a rule was applied.
11    ///
12    /// Rules:
13    /// - In a list item: continue the list with a new item
14    /// - In an empty list item: exit the list (remove the marker)
15    /// - In a blockquote: continue the quote
16    /// - In a fenced code block: just insert newline (no special behavior)
17    pub fn handle_enter(editor: &mut Editor) -> bool {
18        let cursor = editor.cursor();
19        let line_text = editor.buffer().line(cursor.line).to_string();
20        let trimmed = line_text.trim_end_matches('\n');
21
22        // Check for empty list item → exit list
23        if let Some(prefix) = Self::list_prefix(trimmed) {
24            let prefix_chars = prefix.chars().count();
25            let content_after_prefix: String = trimmed.chars().skip(prefix_chars).collect();
26            if content_after_prefix.trim().is_empty() {
27                // Empty list item — remove the marker
28                let line_start = Position::new(cursor.line, 0);
29                let line_end = Position::new(cursor.line, editor.buffer().line_len(cursor.line));
30                editor.set_selection(line_start, line_end);
31                editor.insert("");
32                return true;
33            }
34
35            // Non-empty list item — split at cursor position.
36            // Text after cursor moves to the new list item.
37            let next_prefix = Self::next_list_prefix(&prefix);
38            let line_len = editor.buffer().line_len(cursor.line);
39
40            if cursor.col >= prefix_chars && cursor.col < line_len {
41                // Cursor is mid-content (past prefix): select from cursor to end,
42                // replace with newline + prefix + text-after-cursor
43                let after_cursor: String = trimmed
44                    .chars()
45                    .skip(cursor.col)
46                    .collect();
47                let after_cursor = after_cursor.trim_end_matches('\n');
48                let line_end = Position::new(cursor.line, line_len);
49                editor.set_selection(cursor, line_end);
50                editor.insert(&format!("\n{next_prefix}{after_cursor}"));
51                // Place cursor right after the new prefix
52                let new_line = cursor.line + 1;
53                let new_col = next_prefix.chars().count();
54                editor.set_cursor(Position::new(new_line, new_col));
55            } else {
56                // Cursor at end of line: just continue list
57                editor.insert(&format!("\n{next_prefix}"));
58            }
59            return true;
60        }
61
62        // Check for blockquote continuation
63        if trimmed.starts_with("> ") || trimmed == ">" {
64            if trimmed == ">" || trimmed == "> " {
65                // Empty blockquote line — exit quote
66                let line_start = Position::new(cursor.line, 0);
67                let line_end = Position::new(cursor.line, editor.buffer().line_len(cursor.line));
68                editor.set_selection(line_start, line_end);
69                editor.insert("");
70                return true;
71            }
72            // Split blockquote at cursor, trimming the leading space from moved text
73            let line_len = editor.buffer().line_len(cursor.line);
74            if cursor.col >= 2 && cursor.col < line_len {
75                let after_cursor: String = trimmed.chars().skip(cursor.col).collect();
76                let after_trimmed = after_cursor.trim_start();
77                let line_end = Position::new(cursor.line, line_len);
78                editor.set_selection(cursor, line_end);
79                editor.insert(&format!("\n> {after_trimmed}"));
80                editor.set_cursor(Position::new(cursor.line + 1, 2));
81            } else {
82                editor.insert("\n> ");
83            }
84            return true;
85        }
86
87        false // no rule applied, caller should insert plain newline
88    }
89
90    /// Handle Tab key press. Returns true if a rule was applied.
91    ///
92    /// Rules:
93    /// - In a list item: increase indent level
94    pub fn handle_tab(editor: &mut Editor) -> bool {
95        let cursor = editor.cursor();
96        let line_text = editor.buffer().line(cursor.line).to_string();
97        let trimmed_start = line_text.trim_end_matches('\n');
98
99        if Self::list_prefix(trimmed_start).is_some() {
100            // Add 2 spaces of indent at line start
101            let line_start = Position::new(cursor.line, 0);
102            editor.set_cursor(line_start);
103            editor.insert("  ");
104            // Restore cursor position (shifted by 2)
105            editor.set_cursor(Position::new(cursor.line, cursor.col + 2));
106            return true;
107        }
108
109        false
110    }
111
112    /// Handle Shift+Tab key press. Returns true if a rule was applied.
113    ///
114    /// Rules:
115    /// - In an indented list item: decrease indent level
116    pub fn handle_shift_tab(editor: &mut Editor) -> bool {
117        let cursor = editor.cursor();
118        let line_text = editor.buffer().line(cursor.line).to_string();
119
120        // Check if line starts with whitespace (indented)
121        let indent = line_text.chars().take_while(|c| c.is_whitespace() && *c != '\n').count();
122        if indent >= 2 && Self::list_prefix(line_text.trim_start()).is_some() {
123            // Remove 2 spaces of indent from line start
124            let line_start = Position::new(cursor.line, 0);
125            let indent_end = Position::new(cursor.line, 2);
126            editor.set_selection(line_start, indent_end);
127            editor.insert("");
128            // Restore cursor position (shifted back by 2)
129            let new_col = cursor.col.saturating_sub(2);
130            editor.set_cursor(Position::new(cursor.line, new_col));
131            return true;
132        }
133
134        false
135    }
136
137    /// Handle Backspace at the start of a list item or blockquote.
138    /// Returns true if a rule was applied.
139    pub fn handle_backspace_at_prefix(editor: &mut Editor) -> bool {
140        let cursor = editor.cursor();
141        let line_text = editor.buffer().line(cursor.line).to_string();
142        let trimmed = line_text.trim_end_matches('\n');
143
144        // Only apply if cursor is right after the prefix
145        if let Some(prefix) = Self::list_prefix(trimmed) {
146            let prefix_char_len = prefix.chars().count();
147            if cursor.col == prefix_char_len {
148                // Remove the list prefix
149                let line_start = Position::new(cursor.line, 0);
150                let prefix_end = Position::new(cursor.line, prefix_char_len);
151                editor.set_selection(line_start, prefix_end);
152                editor.insert("");
153                return true;
154            }
155        }
156
157        if trimmed.starts_with("> ") && cursor.col == 2 {
158            let line_start = Position::new(cursor.line, 0);
159            let prefix_end = Position::new(cursor.line, 2);
160            editor.set_selection(line_start, prefix_end);
161            editor.insert("");
162            return true;
163        }
164
165        false
166    }
167
168    // ── Helpers ──────────────────────────────────────────────────────────
169
170    /// Detect if a line starts with a list marker. Returns the full prefix
171    /// including trailing space (e.g., "- ", "1. ", "  - ").
172    fn list_prefix(line: &str) -> Option<String> {
173        let indent: String = line.chars().take_while(|c| *c == ' ').collect();
174        let after_indent = &line[indent.len()..];
175
176        // Task list markers (check before bullet markers since they start with `- `)
177        if after_indent.starts_with("- [ ] ") || after_indent.starts_with("- [x] ") {
178            return Some(format!("{indent}{}", &after_indent[..6]));
179        }
180
181        // Bullet markers
182        if after_indent.starts_with("- ")
183            || after_indent.starts_with("* ")
184            || after_indent.starts_with("+ ")
185        {
186            return Some(format!("{indent}{}", &after_indent[..2]));
187        }
188
189        // Ordered list markers
190        if let Some(dot_pos) = after_indent.find(". ") {
191            let num_part = &after_indent[..dot_pos];
192            if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
193                return Some(format!("{indent}{}", &after_indent[..dot_pos + 2]));
194            }
195        }
196        if let Some(paren_pos) = after_indent.find(") ") {
197            let num_part = &after_indent[..paren_pos];
198            if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
199                return Some(format!("{indent}{}", &after_indent[..paren_pos + 2]));
200            }
201        }
202
203        None
204    }
205
206    /// Compute the prefix for the next list item.
207    /// Increments ordered list numbers, preserves bullet style.
208    fn next_list_prefix(prefix: &str) -> String {
209        let indent: String = prefix.chars().take_while(|c| *c == ' ').collect();
210        let after_indent = &prefix[indent.len()..];
211
212        // Task list → new unchecked item
213        if after_indent.starts_with("- [ ] ") || after_indent.starts_with("- [x] ") {
214            return format!("{indent}- [ ] ");
215        }
216
217        // Ordered list → increment number
218        if let Some(dot_pos) = after_indent.find(". ") {
219            let num_part = &after_indent[..dot_pos];
220            if let Ok(n) = num_part.parse::<usize>() {
221                return format!("{indent}{}. ", n + 1);
222            }
223        }
224        if let Some(paren_pos) = after_indent.find(") ") {
225            let num_part = &after_indent[..paren_pos];
226            if let Ok(n) = num_part.parse::<usize>() {
227                return format!("{indent}{}) ", n + 1);
228            }
229        }
230
231        // Bullet list → same marker
232        prefix.to_string()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use kode_core::Position;
240
241    #[test]
242    fn list_prefix_detection() {
243        assert_eq!(InputRules::list_prefix("- item"), Some("- ".to_string()));
244        assert_eq!(InputRules::list_prefix("* item"), Some("* ".to_string()));
245        assert_eq!(InputRules::list_prefix("1. item"), Some("1. ".to_string()));
246        assert_eq!(
247            InputRules::list_prefix("  - nested"),
248            Some("  - ".to_string())
249        );
250        assert_eq!(InputRules::list_prefix("not a list"), None);
251        assert_eq!(
252            InputRules::list_prefix("- [ ] task"),
253            Some("- [ ] ".to_string())
254        );
255    }
256
257    #[test]
258    fn next_prefix_bullet() {
259        assert_eq!(InputRules::next_list_prefix("- "), "- ");
260        assert_eq!(InputRules::next_list_prefix("  - "), "  - ");
261    }
262
263    #[test]
264    fn next_prefix_ordered() {
265        assert_eq!(InputRules::next_list_prefix("1. "), "2. ");
266        assert_eq!(InputRules::next_list_prefix("3. "), "4. ");
267        assert_eq!(InputRules::next_list_prefix("  5. "), "  6. ");
268    }
269
270    #[test]
271    fn next_prefix_task() {
272        assert_eq!(InputRules::next_list_prefix("- [ ] "), "- [ ] ");
273        assert_eq!(InputRules::next_list_prefix("- [x] "), "- [ ] ");
274    }
275
276    #[test]
277    fn enter_continues_bullet_list() {
278        let mut ed = Editor::new("- item 1");
279        ed.set_cursor(Position::new(0, 8));
280        let handled = InputRules::handle_enter(&mut ed);
281        assert!(handled);
282        assert_eq!(ed.text(), "- item 1\n- ");
283    }
284
285    #[test]
286    fn enter_continues_ordered_list() {
287        let mut ed = Editor::new("1. first");
288        ed.set_cursor(Position::new(0, 8));
289        let handled = InputRules::handle_enter(&mut ed);
290        assert!(handled);
291        assert_eq!(ed.text(), "1. first\n2. ");
292    }
293
294    #[test]
295    fn enter_exits_empty_list_item() {
296        let mut ed = Editor::new("- item 1\n- ");
297        ed.set_cursor(Position::new(1, 2));
298        let handled = InputRules::handle_enter(&mut ed);
299        assert!(handled);
300        assert_eq!(ed.text(), "- item 1\n");
301    }
302
303    #[test]
304    fn enter_continues_blockquote() {
305        let mut ed = Editor::new("> quote text");
306        ed.set_cursor(Position::new(0, 12));
307        let handled = InputRules::handle_enter(&mut ed);
308        assert!(handled);
309        assert_eq!(ed.text(), "> quote text\n> ");
310    }
311
312    #[test]
313    fn enter_mid_blockquote_splits_without_double_space() {
314        let mut ed = Editor::new("> hello world");
315        ed.set_cursor(Position::new(0, 7)); // after "> hello"
316        let handled = InputRules::handle_enter(&mut ed);
317        assert!(handled);
318        assert_eq!(ed.text(), "> hello\n> world");
319    }
320
321    #[test]
322    fn enter_exits_empty_blockquote() {
323        let mut ed = Editor::new("> text\n> ");
324        ed.set_cursor(Position::new(1, 2));
325        let handled = InputRules::handle_enter(&mut ed);
326        assert!(handled);
327        assert_eq!(ed.text(), "> text\n");
328    }
329
330    #[test]
331    fn tab_indents_list_item() {
332        let mut ed = Editor::new("- item");
333        ed.set_cursor(Position::new(0, 6));
334        let handled = InputRules::handle_tab(&mut ed);
335        assert!(handled);
336        assert_eq!(ed.text(), "  - item");
337        assert_eq!(ed.cursor(), Position::new(0, 8));
338    }
339
340    #[test]
341    fn shift_tab_outdents_list_item() {
342        let mut ed = Editor::new("  - item");
343        ed.set_cursor(Position::new(0, 8));
344        let handled = InputRules::handle_shift_tab(&mut ed);
345        assert!(handled);
346        assert_eq!(ed.text(), "- item");
347        assert_eq!(ed.cursor(), Position::new(0, 6));
348    }
349
350    #[test]
351    fn backspace_removes_list_prefix() {
352        let mut ed = Editor::new("- ");
353        ed.set_cursor(Position::new(0, 2));
354        let handled = InputRules::handle_backspace_at_prefix(&mut ed);
355        assert!(handled);
356        assert_eq!(ed.text(), "");
357    }
358
359    #[test]
360    fn no_rule_for_plain_text() {
361        let mut ed = Editor::new("just plain text");
362        ed.set_cursor(Position::new(0, 15));
363        let handled = InputRules::handle_enter(&mut ed);
364        assert!(!handled);
365        // Text should be unchanged
366        assert_eq!(ed.text(), "just plain text");
367    }
368
369    #[test]
370    fn tab_no_effect_on_plain_text() {
371        let mut ed = Editor::new("not a list");
372        ed.set_cursor(Position::new(0, 10));
373        let handled = InputRules::handle_tab(&mut ed);
374        assert!(!handled);
375        assert_eq!(ed.text(), "not a list");
376    }
377
378    #[test]
379    fn enter_mid_list_item_splits_content() {
380        let mut ed = Editor::new("- hello world");
381        ed.set_cursor(Position::new(0, 7)); // after "- hello"
382        let handled = InputRules::handle_enter(&mut ed);
383        assert!(handled);
384        assert_eq!(ed.text(), "- hello\n-  world");
385        assert_eq!(ed.cursor(), Position::new(1, 2)); // after "- "
386    }
387
388    #[test]
389    fn enter_mid_ordered_list_splits() {
390        let mut ed = Editor::new("1. hello world");
391        ed.set_cursor(Position::new(0, 8)); // after "1. hello"
392        let handled = InputRules::handle_enter(&mut ed);
393        assert!(handled);
394        assert_eq!(ed.text(), "1. hello\n2.  world");
395    }
396
397    #[test]
398    fn multi_digit_ordered_list_continuation() {
399        let mut ed = Editor::new("10. item ten");
400        ed.set_cursor(Position::new(0, 12));
401        let handled = InputRules::handle_enter(&mut ed);
402        assert!(handled);
403        assert_eq!(ed.text(), "10. item ten\n11. ");
404    }
405}