Skip to main content

kode_markdown/
commands.rs

1use kode_core::{EditStep, Editor, Position, Transaction};
2
3/// Describes which formatting is active at the current cursor position.
4///
5/// Used by toolbar UI to highlight active formatting buttons.
6#[derive(Clone, Debug, Default, PartialEq)]
7pub struct FormattingState {
8    pub bold: bool,
9    pub italic: bool,
10    pub code: bool,
11    pub strikethrough: bool,
12    /// 0 = no heading, 1-3 = H1-H3
13    pub heading_level: u8,
14    pub bullet_list: bool,
15    pub ordered_list: bool,
16    pub blockquote: bool,
17}
18
19/// Markdown-aware editing commands that operate on a kode-core Editor.
20///
21/// Each command reads the current editor state, then applies
22/// text operations via the Editor API. All edits are pure text transforms.
23///
24/// Note: inline mark toggle commands read directly from the editor buffer,
25/// not from the tree, so they always operate on current text.
26pub struct MarkdownCommands;
27
28impl MarkdownCommands {
29    /// Toggle bold (**) around the current selection.
30    /// If selection is already bold, removes the markers.
31    /// If no selection, inserts `****` and places cursor between them.
32    pub fn toggle_bold(editor: &mut Editor) {
33        Self::toggle_inline_mark(editor, "**");
34    }
35
36    /// Toggle italic (*) around the current selection.
37    pub fn toggle_italic(editor: &mut Editor) {
38        Self::toggle_inline_mark(editor, "*");
39    }
40
41    /// Toggle inline code (`) around the current selection.
42    pub fn toggle_inline_code(editor: &mut Editor) {
43        Self::toggle_inline_mark(editor, "`");
44    }
45
46    /// Toggle strikethrough (~~) around the current selection.
47    pub fn toggle_strikethrough(editor: &mut Editor) {
48        Self::toggle_inline_mark(editor, "~~");
49    }
50
51    /// Set the heading level for the current line.
52    /// Level 0 removes the heading prefix. Levels 1-6 set the corresponding heading.
53    pub fn set_heading(editor: &mut Editor, level: u8) {
54        let mut cursor = editor.cursor();
55        let line_text = editor.buffer().line(cursor.line).to_string();
56
57        // If cursor is on an empty/blank line, look at the previous line.
58        // This handles the case where End/ArrowRight moves the cursor past a
59        // heading's newline onto the blank separator line — the user's intent
60        // is to change the heading they were just on, not create a new one.
61        if line_text.trim().is_empty() && cursor.line > 0 {
62            let prev_line_text = editor.buffer().line(cursor.line - 1).to_string();
63            if prev_line_text.trim_start().starts_with('#') {
64                cursor = Position::new(cursor.line - 1, editor.buffer().line_len(cursor.line - 1));
65                editor.set_cursor(cursor);
66            } else if level > 0 {
67                return; // Don't create empty headings on random blank lines
68            }
69        }
70
71        let line_text = editor.buffer().line(cursor.line).to_string();
72
73        // Find existing heading prefix (all in chars since # and space are ASCII)
74        let trimmed = line_text.trim_start();
75        let content_start_chars = if trimmed.starts_with('#') {
76            let hashes = trimmed.chars().take_while(|&c| c == '#').count();
77            let rest: String = trimmed.chars().skip(hashes).collect();
78            let space_after = if rest.starts_with(' ') { 1 } else { 0 };
79            hashes + space_after
80        } else {
81            0
82        };
83
84        // Get the content without heading prefix
85        let content: String = trimmed.chars().skip(content_start_chars).collect();
86        let content = content.trim_end_matches('\n');
87
88        // Build new line
89        let new_line = if level == 0 {
90            content.to_string()
91        } else {
92            let prefix: String = "#".repeat(level as usize);
93            format!("{prefix} {content}")
94        };
95
96        // Replace the line content (not the newline)
97        let line_start = Position::new(cursor.line, 0);
98        let line_end_col = editor.buffer().line_len(cursor.line);
99        let line_end = Position::new(cursor.line, line_end_col);
100
101        editor.set_selection(line_start, line_end);
102        editor.insert(&new_line);
103
104        // Preserve cursor's relative position within the content.
105        // The cursor was at `cursor.col` in the old line. Adjust for the
106        // prefix length change: old prefix was `content_start_chars` chars,
107        // new prefix is `level + 1` chars (e.g. "## " = 3 chars) or 0.
108        let new_prefix_chars = if level == 0 { 0 } else { level as usize + 1 };
109        let old_content_col = cursor.col.saturating_sub(content_start_chars);
110        let new_col = (new_prefix_chars + old_content_col).min(new_line.chars().count());
111        editor.set_cursor(Position::new(cursor.line, new_col));
112    }
113
114    /// Toggle a block quote on the current line or selection.
115    /// If already quoted, removes the `> ` prefix. Otherwise adds it.
116    /// Uses atomic transaction for multi-line operations.
117    pub fn toggle_blockquote(editor: &mut Editor) {
118        let sel = editor.selection();
119        let cursor = editor.cursor();
120        let start_line = sel.start().line;
121        let end_line = sel.end().line;
122
123        // Check if all lines in range are already quoted
124        let all_quoted = (start_line..=end_line).all(|line| {
125            let text = editor.buffer().line(line).to_string();
126            text.starts_with("> ") || text.starts_with(">")
127        });
128
129        // Build steps top-to-bottom, tracking offset changes
130        let mut steps = Vec::new();
131        let mut offset_delta: isize = 0;
132        for line in start_line..=end_line {
133            let line_text = editor.buffer().line(line).to_string();
134            let line_start_char = editor.buffer().line_to_char(line);
135            let adjusted_offset = (line_start_char as isize + offset_delta) as usize;
136            let content = line_text.trim_end_matches('\n');
137            let content_chars = content.chars().count();
138
139            if all_quoted {
140                let unquoted = if let Some(s) = content.strip_prefix("> ") {
141                    s
142                } else if let Some(s) = content.strip_prefix('>') {
143                    s
144                } else {
145                    content
146                };
147                let new_chars = unquoted.chars().count();
148                steps.push(EditStep::replace(adjusted_offset, content.to_string(), unquoted.to_string()));
149                offset_delta += new_chars as isize - content_chars as isize;
150            } else {
151                let new_text = format!("> {content}");
152                let new_chars = new_text.chars().count();
153                steps.push(EditStep::replace(adjusted_offset, content.to_string(), new_text));
154                offset_delta += new_chars as isize - content_chars as isize;
155            }
156        }
157
158        if !steps.is_empty() {
159            editor.apply_transaction(Transaction::new(steps));
160            // Restore cursor position adjusted for prefix change
161            let prefix_delta: isize = if all_quoted { -2 } else { 2 };
162            let new_col = (cursor.col as isize + prefix_delta).max(0) as usize;
163            let line_len = editor.buffer().line_len(cursor.line);
164            editor.set_cursor(Position::new(cursor.line, new_col.min(line_len)));
165        }
166    }
167
168    /// Toggle a bullet list prefix on the current line or selection.
169    /// If already a list item, removes `- `. Otherwise adds `- `.
170    /// Lines already prefixed are skipped when adding (no double-prefix).
171    pub fn toggle_bullet_list(editor: &mut Editor) {
172        Self::toggle_list_prefix(editor, "- ");
173    }
174
175    /// Toggle an ordered list prefix on the current line or selection.
176    pub fn toggle_ordered_list(editor: &mut Editor) {
177        let sel = editor.selection();
178        let start_line = sel.start().line;
179        let end_line = sel.end().line;
180
181        let all_ordered = (start_line..=end_line).all(|line| {
182            let text = editor.buffer().line(line).to_string();
183            let trimmed = text.trim_start();
184            Self::strip_ordered_prefix(trimmed).is_some()
185        });
186
187        let mut steps = Vec::new();
188        let mut offset_delta: isize = 0;
189        for line in start_line..=end_line {
190            let line_text = editor.buffer().line(line).to_string();
191            let line_start_char = editor.buffer().line_to_char(line);
192            let adjusted_offset = (line_start_char as isize + offset_delta) as usize;
193            let content = line_text.trim_end_matches('\n');
194            let content_chars = content.chars().count();
195
196            if all_ordered {
197                let trimmed = content.trim_start();
198                let leading_indent = &content[..content.len() - trimmed.len()]; // safe: whitespace is ASCII
199                let stripped = Self::strip_ordered_prefix(trimmed).unwrap_or(trimmed);
200                let new_text = format!("{leading_indent}{stripped}");
201                let new_chars = new_text.chars().count();
202                steps.push(EditStep::replace(adjusted_offset, content.to_string(), new_text));
203                offset_delta += new_chars as isize - content_chars as isize;
204            } else {
205                let num = line - start_line + 1;
206                // Strip existing list prefix or block prefix (heading/blockquote)
207                let inner = content
208                    .strip_prefix("- ")
209                    .or_else(|| content.strip_prefix("* "))
210                    .or_else(|| content.strip_prefix("+ "))
211                    .unwrap_or_else(|| Self::strip_block_prefix(content));
212                let new_text = format!("{num}. {inner}");
213                let new_chars = new_text.chars().count();
214                steps.push(EditStep::replace(adjusted_offset, content.to_string(), new_text));
215                offset_delta += new_chars as isize - content_chars as isize;
216            }
217        }
218
219        if !steps.is_empty() {
220            editor.apply_transaction(Transaction::new(steps));
221        }
222    }
223
224    /// Insert a link at the cursor: `[text](url)`
225    /// If there's a selection, it becomes the link text.
226    pub fn insert_link(editor: &mut Editor, url: &str) {
227        let selected = editor.selected_text();
228        if selected.is_empty() {
229            editor.insert(&format!("[]({})", url));
230            // Move cursor between [] for typing link text
231            let cursor = editor.cursor();
232            let url_chars = url.chars().count();
233            let new_col = cursor.col - url_chars - 3; // back to after [
234            editor.set_cursor(Position::new(cursor.line, new_col));
235        } else {
236            editor.insert(&format!("[{}]({})", selected, url));
237        }
238    }
239
240    /// Insert a fenced code block at the cursor.
241    pub fn insert_code_block(editor: &mut Editor, language: &str) {
242        let has_selection = !editor.selection().is_cursor();
243        let selected = editor.selected_text();
244
245        if has_selection {
246            editor.insert(&format!("```{language}\n{selected}\n```"));
247        } else {
248            editor.insert(&format!("```{language}\n\n```"));
249            // Move cursor to the empty line inside the code block
250            let cursor = editor.cursor();
251            if cursor.line > 0 {
252                editor.set_cursor(Position::new(cursor.line - 1, 0));
253            }
254        }
255    }
256
257    /// Insert a paragraph break (Enter key), properly closing and reopening
258    /// any active inline markers (`**`, `*`, `` ` ``, `~~`) so formatting
259    /// is not broken across the line boundary.
260    ///
261    /// `newline` controls what is inserted: `"\n\n"` for a normal paragraph
262    /// break, `"\n"` for a soft break (Shift+Enter).
263    pub fn insert_paragraph_break(editor: &mut Editor, newline: &str) {
264        let cursor = editor.cursor();
265        let line_text = editor.buffer().line(cursor.line).to_string();
266        // Text from start of line up to cursor (in chars)
267        let before_cursor: String = line_text.chars().take(cursor.col).collect();
268
269        let active = Self::active_inline_markers(&before_cursor);
270        if active.is_empty() {
271            editor.insert(newline);
272        } else {
273            // Build: close markers (reverse order) + newline + reopen markers (original order)
274            let mut buf = String::new();
275            for m in active.iter().rev() {
276                buf.push_str(m);
277            }
278            buf.push_str(newline);
279            for m in &active {
280                buf.push_str(m);
281            }
282            editor.insert(&buf);
283        }
284    }
285
286    /// Insert a horizontal rule.
287    pub fn insert_horizontal_rule(editor: &mut Editor) {
288        let cursor = editor.cursor();
289        let at_line_start = cursor.col == 0;
290        let prefix = if at_line_start { "" } else { "\n" };
291        editor.insert(&format!("{prefix}---\n"));
292    }
293
294    // ── Formatting state query ────────────────────────────────────────
295
296    /// Determine which formatting is active at the current cursor position.
297    ///
298    /// Inline formatting (bold, italic, code, strikethrough) is detected by
299    /// scanning text before the cursor for open markers via `active_inline_markers`.
300    /// Block formatting (headings, lists, blockquotes) is detected by checking
301    /// the line prefix.
302    pub fn formatting_at_cursor(editor: &Editor) -> FormattingState {
303        let cursor = editor.cursor();
304        let line_text = editor.buffer().line(cursor.line).to_string();
305        let before_cursor: String = line_text.chars().take(cursor.col).collect();
306
307        // Inline formatting: reuse active_inline_markers
308        let active = Self::active_inline_markers(&before_cursor);
309
310        // Block formatting: check line prefix
311        let trimmed = line_text.trim_start();
312        let heading_level = if trimmed.starts_with("### ") || trimmed == "###" {
313            3
314        } else if trimmed.starts_with("## ") || trimmed == "##" {
315            2
316        } else if trimmed.starts_with("# ") || trimmed == "#" {
317            1
318        } else {
319            0
320        };
321
322        let ordered_list = {
323            let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
324            if digit_count > 0 {
325                let rest = &trimmed[digit_count..];
326                rest.starts_with(". ") || rest.starts_with(") ")
327            } else {
328                false
329            }
330        };
331
332        FormattingState {
333            bold: active.contains(&"**"),
334            italic: active.contains(&"*"),
335            code: active.contains(&"`"),
336            strikethrough: active.contains(&"~~"),
337            heading_level,
338            bullet_list: trimmed.starts_with("- ")
339                || trimmed.starts_with("* ")
340                || trimmed.starts_with("+ "),
341            ordered_list,
342            blockquote: trimmed.starts_with("> ") || trimmed == ">",
343        }
344    }
345
346    // ── Private helpers ──────────────────────────────────────────────────
347
348    /// Scan text from line start to cursor and return a list of inline markers
349    /// that are currently "open" (i.e. have an odd number of occurrences).
350    ///
351    /// Returns markers in the order they were opened, which matters for
352    /// correct nesting (e.g. `**` before `*` in `***bold-italic***`).
353    ///
354    /// Handles the tricky `*` vs `**` disambiguation: `**` is consumed first
355    /// (greedy), then remaining lone `*` is italic.
356    pub fn active_inline_markers(text: &str) -> Vec<&'static str> {
357        // We track open/close counts for each marker type.
358        // Order matters: check longer markers first to avoid false matches.
359        let mut bold_count = 0usize;
360        let mut italic_count = 0usize;
361        let mut code_count = 0usize;
362        let mut strike_count = 0usize;
363
364        // Track the order markers were opened so we can close/reopen in correct order.
365        // Each entry is the marker string; we push when toggling open, remove when toggling closed.
366        let mut open_stack: Vec<&'static str> = Vec::new();
367
368        let chars: Vec<char> = text.chars().collect();
369        let len = chars.len();
370        let mut i = 0;
371
372        // Inside inline code, other markers are literal text — skip until closing backtick.
373        while i < len {
374            if chars[i] == '`' {
375                code_count += 1;
376                if code_count % 2 == 1 {
377                    open_stack.push("`");
378                } else {
379                    Self::remove_last_marker(&mut open_stack, "`");
380                }
381                i += 1;
382                // If we just opened an inline code span, skip until the closing backtick
383                if code_count % 2 == 1 {
384                    while i < len && chars[i] != '`' {
385                        i += 1;
386                    }
387                    // Don't consume the closing backtick here — let the outer loop handle it
388                }
389                continue;
390            }
391
392            // Only process other markers when NOT inside inline code
393            if code_count % 2 == 0 {
394                if chars[i] == '~' && i + 1 < len && chars[i + 1] == '~' {
395                    strike_count += 1;
396                    if strike_count % 2 == 1 {
397                        open_stack.push("~~");
398                    } else {
399                        Self::remove_last_marker(&mut open_stack, "~~");
400                    }
401                    i += 2;
402                    continue;
403                }
404
405                if chars[i] == '*' && i + 1 < len && chars[i + 1] == '*' {
406                    // Check for *** (bold+italic simultaneously)
407                    if i + 2 < len && chars[i + 2] == '*' {
408                        bold_count += 1;
409                        italic_count += 1;
410                        if bold_count % 2 == 1 {
411                            open_stack.push("**");
412                        } else {
413                            Self::remove_last_marker(&mut open_stack, "**");
414                        }
415                        if italic_count % 2 == 1 {
416                            open_stack.push("*");
417                        } else {
418                            Self::remove_last_marker(&mut open_stack, "*");
419                        }
420                        i += 3;
421                        continue;
422                    }
423                    bold_count += 1;
424                    if bold_count % 2 == 1 {
425                        open_stack.push("**");
426                    } else {
427                        Self::remove_last_marker(&mut open_stack, "**");
428                    }
429                    i += 2;
430                    continue;
431                }
432
433                if chars[i] == '*' {
434                    italic_count += 1;
435                    if italic_count % 2 == 1 {
436                        open_stack.push("*");
437                    } else {
438                        Self::remove_last_marker(&mut open_stack, "*");
439                    }
440                    i += 1;
441                    continue;
442                }
443            }
444
445            i += 1;
446        }
447
448        // Return only markers that are still open (odd count)
449        open_stack
450    }
451
452    /// Remove the last occurrence of `marker` from the stack (used when a marker closes).
453    fn remove_last_marker(stack: &mut Vec<&'static str>, marker: &str) {
454        if let Some(pos) = stack.iter().rposition(|m| *m == marker) {
455            stack.remove(pos);
456        }
457    }
458
459    fn toggle_inline_mark(editor: &mut Editor, mark: &str) {
460        let sel = editor.selection();
461        let mark_chars = mark.chars().count();
462
463        if sel.is_cursor() {
464            // No selection: insert paired marks and place cursor between them
465            editor.insert(&format!("{mark}{mark}"));
466            let cursor = editor.cursor();
467            let new_col = cursor.col - mark_chars;
468            editor.set_cursor(Position::new(cursor.line, new_col));
469            return;
470        }
471
472        let selected = editor.selected_text();
473        let start = sel.start();
474        let end = sel.end();
475
476        // Check if the selection is already wrapped in this mark.
477        // Read from the editor buffer directly (not the tree, which may be stale).
478        let start_char = editor.buffer().pos_to_char(start);
479        let end_char = editor.buffer().pos_to_char(end);
480        let total_chars = editor.buffer().len_chars();
481
482        // Check if mark characters exist immediately before/after selection (O(log n) via rope)
483        let has_mark_before = start_char >= mark_chars && {
484            let before: String = editor.buffer().rope()
485                .slice((start_char - mark_chars)..start_char)
486                .to_string();
487            before == mark
488        };
489        let has_mark_after = end_char + mark_chars <= total_chars && {
490            let after: String = editor.buffer().rope()
491                .slice(end_char..(end_char + mark_chars))
492                .to_string();
493            after == mark
494        };
495
496        if has_mark_before && has_mark_after {
497            // Remove marks: select mark+content+mark and replace with just content
498            let outer_start = Position::new(start.line, start.col - mark_chars);
499            let outer_end = Position::new(end.line, end.col + mark_chars);
500            editor.set_selection(outer_start, outer_end);
501            editor.insert(&selected);
502        } else {
503            // Add marks around selection
504            editor.insert(&format!("{mark}{selected}{mark}"));
505        }
506    }
507
508    fn toggle_list_prefix(editor: &mut Editor, prefix: &str) {
509        let sel = editor.selection();
510        let start_line = sel.start().line;
511        let end_line = sel.end().line;
512
513        let all_prefixed = (start_line..=end_line).all(|line| {
514            let text = editor.buffer().line(line).to_string();
515            text.starts_with(prefix)
516        });
517
518        let prefix_chars = prefix.chars().count();
519        let mut steps = Vec::new();
520        let mut offset_delta: isize = 0;
521        for line in start_line..=end_line {
522            let line_text = editor.buffer().line(line).to_string();
523            let line_start_char = editor.buffer().line_to_char(line);
524            let adjusted_offset = (line_start_char as isize + offset_delta) as usize;
525            let content = line_text.trim_end_matches('\n');
526            let content_chars = content.chars().count();
527
528            if all_prefixed {
529                let stripped: String = content.chars().skip(prefix_chars).collect();
530                let new_chars = stripped.chars().count();
531                steps.push(EditStep::replace(adjusted_offset, content.to_string(), stripped));
532                offset_delta += new_chars as isize - content_chars as isize;
533            } else {
534                if content.starts_with(prefix) {
535                    continue; // skip — already prefixed
536                }
537                // Strip heading prefix (# ## ###) or blockquote (>) before adding list prefix
538                let stripped = Self::strip_block_prefix(content);
539                let new_text = format!("{prefix}{stripped}");
540                let new_chars = new_text.chars().count();
541                steps.push(EditStep::replace(adjusted_offset, content.to_string(), new_text));
542                offset_delta += new_chars as isize - content_chars as isize;
543            }
544        }
545
546        if !steps.is_empty() {
547            editor.apply_transaction(Transaction::new(steps));
548        }
549    }
550
551    /// Strip block-level markdown prefixes (headings, blockquotes) from a line.
552    /// Used when converting a heading/blockquote to a list item.
553    fn strip_block_prefix(s: &str) -> &str {
554        let trimmed = s.trim_start();
555        // Strip heading prefixes: # ## ### etc.
556        if trimmed.starts_with('#') {
557            let after_hashes = trimmed.trim_start_matches('#');
558            let stripped = after_hashes.strip_prefix(' ').unwrap_or(after_hashes);
559            return stripped;
560        }
561        // Strip blockquote prefix: >
562        if let Some(after) = trimmed.strip_prefix('>') {
563            return after.strip_prefix(' ').unwrap_or(after);
564        }
565        s
566    }
567
568    /// Strip an ordered list prefix (e.g., "1. ", "10) ") from the start of a string.
569    /// Returns the content after the prefix, or None if no ordered prefix found.
570    fn strip_ordered_prefix(s: &str) -> Option<&str> {
571        let digit_count = s.chars().take_while(|c| c.is_ascii_digit()).count();
572        if digit_count == 0 {
573            return None;
574        }
575        let rest = &s[digit_count..]; // safe: digits are ASCII
576        if let Some(after) = rest.strip_prefix(". ") {
577            Some(after)
578        } else if let Some(after) = rest.strip_prefix(") ") {
579            Some(after)
580        } else {
581            None
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use kode_core::Position;
590
591    #[test]
592    fn toggle_bold_no_selection() {
593        let mut ed = Editor::new("hello");
594        ed.set_cursor(Position::new(0, 5));
595        MarkdownCommands::toggle_bold(&mut ed);
596        assert_eq!(ed.text(), "hello****");
597        assert_eq!(ed.cursor(), Position::new(0, 7)); // between the **
598    }
599
600    #[test]
601    fn toggle_bold_with_selection() {
602        let mut ed = Editor::new("hello world");
603        ed.set_selection(Position::new(0, 6), Position::new(0, 11));
604        MarkdownCommands::toggle_bold(&mut ed);
605        assert_eq!(ed.text(), "hello **world**");
606    }
607
608    #[test]
609    fn toggle_bold_remove() {
610        let mut ed = Editor::new("hello **world**");
611        // Select "world" (between the **)
612        ed.set_selection(Position::new(0, 8), Position::new(0, 13));
613        MarkdownCommands::toggle_bold(&mut ed);
614        assert_eq!(ed.text(), "hello world");
615    }
616
617    #[test]
618    fn set_heading_level() {
619        let mut ed = Editor::new("Hello world");
620        ed.set_cursor(Position::new(0, 0));
621        MarkdownCommands::set_heading(&mut ed, 2);
622        assert_eq!(ed.text(), "## Hello world");
623
624        // Change level
625        MarkdownCommands::set_heading(&mut ed, 1);
626        assert_eq!(ed.text(), "# Hello world");
627
628        // Remove heading
629        MarkdownCommands::set_heading(&mut ed, 0);
630        assert_eq!(ed.text(), "Hello world");
631    }
632
633    #[test]
634    fn set_heading_non_ascii() {
635        let mut ed = Editor::new("日本語");
636        ed.set_cursor(Position::new(0, 0));
637        MarkdownCommands::set_heading(&mut ed, 2);
638        assert_eq!(ed.text(), "## 日本語");
639        // Cursor was at col 0 (start of content). After adding "## " prefix (3 chars),
640        // cursor preserves its position relative to the content → col 3.
641        assert_eq!(ed.cursor().col, 3);
642    }
643
644    #[test]
645    fn set_heading_empty_line() {
646        let mut ed = Editor::new("");
647        ed.set_cursor(Position::new(0, 0));
648        MarkdownCommands::set_heading(&mut ed, 1);
649        assert_eq!(ed.text(), "# ");
650        MarkdownCommands::set_heading(&mut ed, 0);
651        assert_eq!(ed.text(), "");
652    }
653
654    #[test]
655    fn toggle_blockquote() {
656        let mut ed = Editor::new("Hello\nWorld");
657        ed.set_selection(Position::new(0, 0), Position::new(1, 5));
658        MarkdownCommands::toggle_blockquote(&mut ed);
659        assert_eq!(ed.text(), "> Hello\n> World");
660
661        // Toggle off — undo the atomic transaction
662        ed.undo();
663        assert_eq!(ed.text(), "Hello\nWorld");
664    }
665
666    #[test]
667    fn toggle_blockquote_atomic_undo() {
668        let mut ed = Editor::new("Line 1\nLine 2\nLine 3");
669        ed.set_selection(Position::new(0, 0), Position::new(2, 6));
670        MarkdownCommands::toggle_blockquote(&mut ed);
671        assert_eq!(ed.text(), "> Line 1\n> Line 2\n> Line 3");
672
673        // Single undo should revert all 3 lines
674        ed.undo();
675        assert_eq!(ed.text(), "Line 1\nLine 2\nLine 3");
676    }
677
678    #[test]
679    fn toggle_bullet_list() {
680        let mut ed = Editor::new("Item 1\nItem 2");
681        ed.set_selection(Position::new(0, 0), Position::new(1, 6));
682        MarkdownCommands::toggle_bullet_list(&mut ed);
683        assert_eq!(ed.text(), "- Item 1\n- Item 2");
684
685        // Undo should revert atomically
686        ed.undo();
687        assert_eq!(ed.text(), "Item 1\nItem 2");
688    }
689
690    #[test]
691    fn toggle_bullet_list_mixed_lines() {
692        let mut ed = Editor::new("- already\nplain");
693        ed.set_selection(Position::new(0, 0), Position::new(1, 5));
694        MarkdownCommands::toggle_bullet_list(&mut ed);
695        // Should add prefix only to plain line, not double-prefix the listed line
696        assert_eq!(ed.text(), "- already\n- plain");
697    }
698
699    #[test]
700    fn toggle_ordered_list() {
701        let mut ed = Editor::new("First\nSecond");
702        ed.set_selection(Position::new(0, 0), Position::new(1, 6));
703        MarkdownCommands::toggle_ordered_list(&mut ed);
704        assert_eq!(ed.text(), "1. First\n2. Second");
705    }
706
707    #[test]
708    fn toggle_ordered_list_remove_with_dots_in_content() {
709        let mut ed = Editor::new("1. Dr. Smith\n2. Mr. Jones");
710        ed.set_selection(Position::new(0, 0), Position::new(1, 13));
711        MarkdownCommands::toggle_ordered_list(&mut ed);
712        // Should only strip the "1. " / "2. " prefix, preserving "Dr. Smith"
713        assert_eq!(ed.text(), "Dr. Smith\nMr. Jones");
714    }
715
716    #[test]
717    fn toggle_ordered_list_preserves_indent() {
718        let mut ed = Editor::new("  1. nested");
719        ed.set_selection(Position::new(0, 0), Position::new(0, 11));
720        MarkdownCommands::toggle_ordered_list(&mut ed);
721        assert_eq!(ed.text(), "  nested");
722    }
723
724    #[test]
725    fn insert_link_with_selection() {
726        let mut ed = Editor::new("click here for more");
727        ed.set_selection(Position::new(0, 6), Position::new(0, 10));
728        MarkdownCommands::insert_link(&mut ed, "https://example.com");
729        assert_eq!(ed.text(), "click [here](https://example.com) for more");
730    }
731
732    #[test]
733    fn insert_link_non_ascii_url() {
734        let mut ed = Editor::new("click ");
735        ed.set_cursor(Position::new(0, 6));
736        MarkdownCommands::insert_link(&mut ed, "https://日本.jp");
737        assert_eq!(ed.text(), "click [](https://日本.jp)");
738        // Cursor should be at col 7 (after "[")
739        assert_eq!(ed.cursor(), Position::new(0, 7));
740    }
741
742    #[test]
743    fn insert_code_block() {
744        let mut ed = Editor::new("Some text\n");
745        ed.set_cursor(Position::new(1, 0));
746        MarkdownCommands::insert_code_block(&mut ed, "sql");
747        assert_eq!(ed.text(), "Some text\n```sql\n\n```");
748        assert_eq!(ed.cursor(), Position::new(2, 0));
749    }
750
751    #[test]
752    fn insert_code_block_at_start_of_empty_doc() {
753        let mut ed = Editor::new("");
754        ed.set_cursor(Position::new(0, 0));
755        MarkdownCommands::insert_code_block(&mut ed, "rust");
756        assert_eq!(ed.text(), "```rust\n\n```");
757        assert_eq!(ed.cursor(), Position::new(1, 0));
758    }
759
760    #[test]
761    fn insert_code_block_with_selection() {
762        let mut ed = Editor::new("SELECT 1");
763        ed.select_all();
764        MarkdownCommands::insert_code_block(&mut ed, "sql");
765        assert_eq!(ed.text(), "```sql\nSELECT 1\n```");
766    }
767
768    #[test]
769    fn toggle_italic() {
770        let mut ed = Editor::new("hello world");
771        ed.set_selection(Position::new(0, 6), Position::new(0, 11));
772        MarkdownCommands::toggle_italic(&mut ed);
773        assert_eq!(ed.text(), "hello *world*");
774    }
775
776    #[test]
777    fn toggle_inline_code() {
778        let mut ed = Editor::new("use this function");
779        ed.set_selection(Position::new(0, 9), Position::new(0, 17));
780        MarkdownCommands::toggle_inline_code(&mut ed);
781        assert_eq!(ed.text(), "use this `function`");
782    }
783
784    #[test]
785    fn toggle_bold_undo_restores_original() {
786        let mut ed = Editor::new("hello world");
787        ed.set_selection(Position::new(0, 6), Position::new(0, 11));
788        MarkdownCommands::toggle_bold(&mut ed);
789        assert_eq!(ed.text(), "hello **world**");
790
791        ed.undo();
792        assert_eq!(ed.text(), "hello world");
793    }
794
795    // ── insert_paragraph_break tests ──────────────────────────────────
796
797    #[test]
798    fn paragraph_break_inside_bold() {
799        let mut ed = Editor::new("**bold text**");
800        // Cursor at col 7: ** b o l d   (space) → 7 chars before cursor
801        // before_cursor = "**bold " → ** is open
802        // insert: close "**" + "\n\n" + reopen "**"
803        // result: "**bold **\n\n**text**"
804        ed.set_cursor(Position::new(0, 7));
805        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
806        assert_eq!(ed.text(), "**bold **\n\n**text**");
807    }
808
809    #[test]
810    fn paragraph_break_inside_italic() {
811        let mut ed = Editor::new("*italic text*");
812        // Cursor at col 8: * i t a l i c   (space) → 8 chars
813        ed.set_cursor(Position::new(0, 8));
814        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
815        assert_eq!(ed.text(), "*italic *\n\n*text*");
816    }
817
818    #[test]
819    fn paragraph_break_inside_inline_code() {
820        let mut ed = Editor::new("`some code`");
821        // Cursor at col 5: ` s o m e → 5 chars
822        ed.set_cursor(Position::new(0, 5));
823        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
824        assert_eq!(ed.text(), "`some`\n\n` code`");
825    }
826
827    #[test]
828    fn paragraph_break_inside_strikethrough() {
829        let mut ed = Editor::new("~~struck text~~");
830        // Cursor at col 8: ~ ~ s t r u c k → 8 chars
831        ed.set_cursor(Position::new(0, 8));
832        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
833        assert_eq!(ed.text(), "~~struck~~\n\n~~ text~~");
834    }
835
836    #[test]
837    fn paragraph_break_no_markers() {
838        let mut ed = Editor::new("plain text");
839        ed.set_cursor(Position::new(0, 5));
840        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
841        assert_eq!(ed.text(), "plain\n\n text");
842    }
843
844    #[test]
845    fn paragraph_break_closed_markers_not_reopened() {
846        // If bold is already closed before cursor, don't reopen
847        let mut ed = Editor::new("**bold** and more");
848        // Cursor at col 13: after "and "
849        ed.set_cursor(Position::new(0, 13));
850        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
851        assert_eq!(ed.text(), "**bold** and \n\nmore");
852    }
853
854    #[test]
855    fn paragraph_break_nested_bold_italic() {
856        let mut ed = Editor::new("***bold italic text***");
857        // Cursor at col 15: "***bold italic " (15 chars)
858        // Active markers: ["**", "*"] (bold first, then italic)
859        // Close in reverse: "*" then "**" = "***"
860        // Reopen: "**" then "*" = "***"
861        ed.set_cursor(Position::new(0, 15));
862        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
863        assert_eq!(ed.text(), "***bold italic ***\n\n***text***");
864    }
865
866    #[test]
867    fn paragraph_break_soft_break_inside_bold() {
868        let mut ed = Editor::new("**bold text**");
869        ed.set_cursor(Position::new(0, 7));
870        MarkdownCommands::insert_paragraph_break(&mut ed, "\n");
871        assert_eq!(ed.text(), "**bold **\n**text**");
872    }
873
874    #[test]
875    fn paragraph_break_markers_inside_code_ignored() {
876        // ** inside backticks should not count as bold
877        let mut ed = Editor::new("`code **not bold**`");
878        ed.set_cursor(Position::new(0, 10));
879        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
880        // Only ` is active (code span is open at cursor... wait, let me check)
881        // ` c o d e   * * n o t ...
882        // 0 1 2 3 4 5 6 7 8 9 10
883        // At col 10: before = "`code **no"
884        // ` opens (code_count=1, odd → open)
885        // Inside code span, skip until closing `. There's no closing ` before col 10.
886        // So only ` is active.
887        assert_eq!(ed.text(), "`code **no`\n\n`t bold**`");
888    }
889
890    // ── active_inline_markers unit tests ────────────────────────────────
891
892    #[test]
893    fn active_markers_empty() {
894        assert!(MarkdownCommands::active_inline_markers("plain text").is_empty());
895    }
896
897    #[test]
898    fn active_markers_bold_open() {
899        assert_eq!(MarkdownCommands::active_inline_markers("**bold "), vec!["**"]);
900    }
901
902    #[test]
903    fn active_markers_bold_closed() {
904        assert!(MarkdownCommands::active_inline_markers("**bold** after").is_empty());
905    }
906
907    #[test]
908    fn active_markers_italic_open() {
909        assert_eq!(MarkdownCommands::active_inline_markers("*italic "), vec!["*"]);
910    }
911
912    #[test]
913    fn active_markers_code_open() {
914        assert_eq!(MarkdownCommands::active_inline_markers("`code "), vec!["`"]);
915    }
916
917    #[test]
918    fn active_markers_strike_open() {
919        assert_eq!(MarkdownCommands::active_inline_markers("~~strike "), vec!["~~"]);
920    }
921
922    #[test]
923    fn active_markers_bold_italic_open() {
924        let markers = MarkdownCommands::active_inline_markers("***bold italic ");
925        assert_eq!(markers, vec!["**", "*"]);
926    }
927
928    #[test]
929    fn active_markers_code_hides_bold() {
930        // ** inside code span should not register as bold
931        assert_eq!(MarkdownCommands::active_inline_markers("`code **bold "), vec!["`"]);
932    }
933
934    #[test]
935    fn toggle_bold_with_unicode() {
936        let mut ed = Editor::new("hello café world");
937        // Select "café"
938        ed.set_selection(Position::new(0, 6), Position::new(0, 10));
939        MarkdownCommands::toggle_bold(&mut ed);
940        assert_eq!(ed.text(), "hello **café** world");
941    }
942
943    // ── Bug #93: Triple *** markers ────────────────────────────────────
944
945    #[test]
946    fn active_markers_triple_star_open() {
947        // *** opens both bold and italic; active_inline_markers should return both
948        let markers = MarkdownCommands::active_inline_markers("Text ***bold italic");
949        assert_eq!(markers, vec!["**", "*"]);
950    }
951
952    #[test]
953    fn paragraph_break_inside_triple_star_bold_italic() {
954        // Input: "Text ***bold italic|stuff*** end" — cursor at col 19
955        // Expected: close *** then newline then reopen ***
956        let mut ed = Editor::new("Text ***bold italicstuff*** end");
957        // "Text ***bold italic" = 19 chars
958        ed.set_cursor(Position::new(0, 19));
959        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
960        assert_eq!(ed.text(), "Text ***bold italic***\n\n***stuff*** end");
961    }
962
963    // ── Bug #96: Enter at bold-italic boundary ────────────────────────
964
965    #[test]
966    fn active_markers_bold_near_triple_star_boundary() {
967        // "**bold te" — bold is open (one ** seen, not closed)
968        let markers = MarkdownCommands::active_inline_markers("**bold te");
969        assert_eq!(markers, vec!["**"]);
970    }
971
972    #[test]
973    fn paragraph_break_at_bold_italic_boundary() {
974        // Input: "**bold te|xt***italic text*"  cursor at col 9
975        // before_cursor = "**bold te" → active = ["**"]
976        // Expected: close ** + newline + reopen **
977        let mut ed = Editor::new("**bold text***italic text*");
978        ed.set_cursor(Position::new(0, 9));
979        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
980        assert_eq!(ed.text(), "**bold te**\n\n**xt***italic text*");
981    }
982
983    // ── Bug #112: Adjacent bold spans ─────────────────────────────────
984
985    #[test]
986    fn active_markers_adjacent_bold_spans_between() {
987        // "**first** " — bold opened then closed = even count, not active
988        let markers = MarkdownCommands::active_inline_markers("**first** ");
989        assert!(markers.is_empty());
990    }
991
992    #[test]
993    fn paragraph_break_between_adjacent_bold_spans() {
994        // Enter between two bold spans at the space: no active markers
995        let mut ed = Editor::new("**first** **second**");
996        // Cursor at col 10 (the space between "** " and "**second**")
997        ed.set_cursor(Position::new(0, 10));
998        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
999        assert_eq!(ed.text(), "**first** \n\n**second**");
1000    }
1001
1002    // ── Bug #119: Single-char bold split ──────────────────────────────
1003
1004    #[test]
1005    fn paragraph_break_single_char_bold_cursor_after_open() {
1006        // Cursor right after opening ** in "**X**" (col 2)
1007        // before_cursor = "**" → active = ["**"]
1008        // insert: "**\n\n**" → result: "****\n\n**X**"
1009        // This is a degenerate case: empty bold on first line.
1010        let mut ed = Editor::new("**X**");
1011        ed.set_cursor(Position::new(0, 2));
1012        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
1013        assert_eq!(ed.text(), "****\n\n**X**");
1014    }
1015
1016    #[test]
1017    fn paragraph_break_single_char_bold_cursor_inside() {
1018        // Cursor between * and X and closing ** in "Before **X** after"
1019        // Cursor at col 9 = after "Before **X" (inside the bold content, after X)
1020        // before_cursor = "Before **X" → ** open, active = ["**"]
1021        // insert: "**\n\n**" → "Before **X**\n\n** after"
1022        let mut ed = Editor::new("Before **X** after");
1023        ed.set_cursor(Position::new(0, 10));
1024        MarkdownCommands::insert_paragraph_break(&mut ed, "\n\n");
1025        assert_eq!(ed.text(), "Before **X**\n\n**** after");
1026    }
1027
1028    #[test]
1029    fn active_markers_single_char_bold_cursor_outside() {
1030        // "Before **X** aft" — bold opened then closed, not active
1031        let markers = MarkdownCommands::active_inline_markers("Before **X** aft");
1032        assert!(markers.is_empty());
1033    }
1034
1035    // ── Bug #113: Shift+Enter should check heading context ────────────
1036    // (This bug is in component.rs, tested via integration. Unit test
1037    //  verifies insert_paragraph_break with "\n" still works for non-heading lines.)
1038
1039    #[test]
1040    fn paragraph_break_soft_break_no_markers() {
1041        let mut ed = Editor::new("plain text here");
1042        ed.set_cursor(Position::new(0, 6));
1043        MarkdownCommands::insert_paragraph_break(&mut ed, "\n");
1044        assert_eq!(ed.text(), "plain \ntext here");
1045    }
1046}