kode_markdown/
input_rules.rs1use kode_core::{Editor, Position};
2
3pub struct InputRules;
8
9impl InputRules {
10 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 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 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 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 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 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 editor.insert(&format!("\n{next_prefix}"));
58 }
59 return true;
60 }
61
62 if trimmed.starts_with("> ") || trimmed == ">" {
64 if trimmed == ">" || trimmed == "> " {
65 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 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 }
89
90 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 let line_start = Position::new(cursor.line, 0);
102 editor.set_cursor(line_start);
103 editor.insert(" ");
104 editor.set_cursor(Position::new(cursor.line, cursor.col + 2));
106 return true;
107 }
108
109 false
110 }
111
112 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 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 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 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 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 if let Some(prefix) = Self::list_prefix(trimmed) {
146 let prefix_char_len = prefix.chars().count();
147 if cursor.col == prefix_char_len {
148 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 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 if after_indent.starts_with("- [ ] ") || after_indent.starts_with("- [x] ") {
178 return Some(format!("{indent}{}", &after_indent[..6]));
179 }
180
181 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 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 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 if after_indent.starts_with("- [ ] ") || after_indent.starts_with("- [x] ") {
214 return format!("{indent}- [ ] ");
215 }
216
217 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 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)); 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 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)); 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)); }
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)); 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}