fresh-editor 0.1.96

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
//! Markdown parsing and rendering for terminal display
//!
//! This module provides markdown-to-styled-text conversion for popups,
//! hover documentation, and other UI elements. It also provides word
//! wrapping utilities for styled text.

use crate::primitives::grammar::GrammarRegistry;
use crate::primitives::highlight_engine::highlight_string;
use crate::primitives::highlighter::HighlightSpan;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};

/// Word-wrap a single line of text to fit within a given width.
/// Breaks at word boundaries (spaces) when possible.
/// Falls back to character-based breaking for words longer than max_width.
/// Returns a vector of wrapped line segments.
pub fn wrap_text_line(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![text.to_string()];
    }

    let mut result = Vec::new();
    let mut current_line = String::new();
    let mut current_width = 0;

    // Split into words while preserving spaces
    let mut chars = text.chars().peekable();
    while chars.peek().is_some() {
        // Collect a "word" (non-space characters) or a space sequence
        let mut word = String::new();
        let mut word_width = 0;

        // Collect spaces first
        while let Some(&ch) = chars.peek() {
            if ch != ' ' {
                break;
            }
            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
            word.push(ch);
            word_width += ch_width;
            chars.next();
        }

        // Then collect non-space characters
        while let Some(&ch) = chars.peek() {
            if ch == ' ' {
                break;
            }
            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
            word.push(ch);
            word_width += ch_width;
            chars.next();
        }

        if word.is_empty() {
            continue;
        }

        // Check if word fits on current line
        if current_width + word_width <= max_width {
            current_line.push_str(&word);
            current_width += word_width;
        } else if current_line.is_empty() {
            // Word is too long for a single line, must break mid-word
            for ch in word.chars() {
                let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
                if current_width + ch_width > max_width && !current_line.is_empty() {
                    result.push(current_line);
                    current_line = String::new();
                    current_width = 0;
                }
                current_line.push(ch);
                current_width += ch_width;
            }
        } else {
            // Start a new line with this word
            result.push(current_line);
            // Trim leading spaces from the word when starting a new line
            let trimmed = word.trim_start();
            current_line = trimmed.to_string();
            current_width = unicode_width::UnicodeWidthStr::width(trimmed);
        }
    }

    if !current_line.is_empty() || result.is_empty() {
        result.push(current_line);
    }

    result
}

/// Word-wrap a vector of text lines to fit within a given width.
pub fn wrap_text_lines(lines: &[String], max_width: usize) -> Vec<String> {
    let mut result = Vec::new();
    for line in lines {
        if line.is_empty() {
            result.push(String::new());
        } else {
            result.extend(wrap_text_line(line, max_width));
        }
    }
    result
}

/// Word-wrap styled lines to fit within a given width.
/// Breaks at word boundaries (spaces) when possible, preserving styling.
pub fn wrap_styled_lines(lines: &[StyledLine], max_width: usize) -> Vec<StyledLine> {
    if max_width == 0 {
        return lines.to_vec();
    }

    let mut result = Vec::new();

    for line in lines {
        // Calculate the total width of this line
        let total_width: usize = line
            .spans
            .iter()
            .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
            .sum();

        if total_width <= max_width {
            // Line fits, no wrapping needed
            result.push(line.clone());
        } else {
            // Flatten spans into styled segments (word + spaces), preserving link URLs
            let mut segments: Vec<(String, Style, Option<String>)> = Vec::new();

            for span in &line.spans {
                // Split span text into words and spaces while preserving style and link
                let mut chars = span.text.chars().peekable();
                while chars.peek().is_some() {
                    let mut segment = String::new();

                    // Collect spaces
                    while let Some(&ch) = chars.peek() {
                        if ch != ' ' {
                            break;
                        }
                        segment.push(ch);
                        chars.next();
                    }

                    // Collect non-spaces (word)
                    while let Some(&ch) = chars.peek() {
                        if ch == ' ' {
                            break;
                        }
                        segment.push(ch);
                        chars.next();
                    }

                    if !segment.is_empty() {
                        segments.push((segment, span.style, span.link_url.clone()));
                    }
                }
            }

            // Now wrap using word boundaries
            let mut current_line = StyledLine::new();
            let mut current_width = 0;

            for (segment, style, link_url) in segments {
                let seg_width = unicode_width::UnicodeWidthStr::width(segment.as_str());

                if current_width + seg_width <= max_width {
                    // Segment fits
                    current_line.push_with_link(segment, style, link_url);
                    current_width += seg_width;
                } else if current_width == 0 {
                    // Segment too long for a line, must break mid-word
                    let mut remaining = segment.as_str();
                    while !remaining.is_empty() {
                        let available = max_width.saturating_sub(current_width);
                        if available == 0 {
                            result.push(current_line);
                            current_line = StyledLine::new();
                            current_width = 0;
                            continue;
                        }

                        // Find how many chars fit
                        let mut take_chars = 0;
                        let mut take_width = 0;
                        for ch in remaining.chars() {
                            let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
                            if take_width + w > available && take_chars > 0 {
                                break;
                            }
                            take_width += w;
                            take_chars += 1;
                        }

                        let byte_idx = remaining
                            .char_indices()
                            .nth(take_chars)
                            .map(|(i, _)| i)
                            .unwrap_or(remaining.len());
                        let (take, rest) = remaining.split_at(byte_idx);
                        current_line.push_with_link(take.to_string(), style, link_url.clone());
                        current_width += take_width;
                        remaining = rest;
                    }
                } else {
                    // Start new line with this segment
                    result.push(current_line);
                    current_line = StyledLine::new();
                    // For styled content (code, etc.), preserve spacing
                    current_line.push_with_link(segment, style, link_url);
                    current_width = seg_width;
                }
            }

            if !current_line.spans.is_empty() {
                result.push(current_line);
            }
        }
    }

    result
}

/// A styled span for markdown rendering
#[derive(Debug, Clone, PartialEq)]
pub struct StyledSpan {
    pub text: String,
    pub style: Style,
    /// Optional URL if this span is part of a link
    pub link_url: Option<String>,
}

/// A line of styled spans for markdown rendering
#[derive(Debug, Clone, PartialEq)]
pub struct StyledLine {
    pub spans: Vec<StyledSpan>,
}

impl StyledLine {
    pub fn new() -> Self {
        Self { spans: Vec::new() }
    }

    pub fn push(&mut self, text: String, style: Style) {
        self.spans.push(StyledSpan {
            text,
            style,
            link_url: None,
        });
    }

    /// Push a span with an optional link URL
    pub fn push_with_link(&mut self, text: String, style: Style, link_url: Option<String>) {
        self.spans.push(StyledSpan {
            text,
            style,
            link_url,
        });
    }

    /// Find the link URL at the given column position (0-indexed)
    /// Returns None if there's no link at that position
    pub fn link_at_column(&self, column: usize) -> Option<&str> {
        let mut current_col = 0;
        for span in &self.spans {
            let span_width = unicode_width::UnicodeWidthStr::width(span.text.as_str());
            if column >= current_col && column < current_col + span_width {
                // Found the span at this column
                return span.link_url.as_deref();
            }
            current_col += span_width;
        }
        None
    }

    /// Get the plain text content (without styling)
    pub fn plain_text(&self) -> String {
        self.spans.iter().map(|s| s.text.as_str()).collect()
    }
}

impl Default for StyledLine {
    fn default() -> Self {
        Self::new()
    }
}

/// Convert highlight spans to styled lines for code blocks
fn highlight_code_to_styled_lines(
    code: &str,
    spans: &[HighlightSpan],
    theme: &crate::view::theme::Theme,
) -> Vec<StyledLine> {
    let mut result = vec![StyledLine::new()];
    let code_bg = theme.inline_code_bg;
    let default_fg = theme.help_key_fg;

    let bytes = code.as_bytes();
    let mut pos = 0;

    for span in spans {
        // Add unhighlighted text before this span
        if span.range.start > pos {
            let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
            add_code_text_to_lines(
                &mut result,
                &text,
                Style::default().fg(default_fg).bg(code_bg),
            );
        }

        // Add highlighted text
        let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
        add_code_text_to_lines(
            &mut result,
            &text,
            Style::default().fg(span.color).bg(code_bg),
        );

        pos = span.range.end;
    }

    // Add remaining unhighlighted text
    if pos < bytes.len() {
        let text = String::from_utf8_lossy(&bytes[pos..]);
        add_code_text_to_lines(
            &mut result,
            &text,
            Style::default().fg(default_fg).bg(code_bg),
        );
    }

    result
}

/// Helper to add code text to styled lines, handling newlines
fn add_code_text_to_lines(lines: &mut Vec<StyledLine>, text: &str, style: Style) {
    for (i, part) in text.split('\n').enumerate() {
        if i > 0 {
            lines.push(StyledLine::new());
        }
        if !part.is_empty() {
            if let Some(line) = lines.last_mut() {
                line.push(part.to_string(), style);
            }
        }
    }
}

/// Parse markdown text into styled lines for terminal rendering
///
/// If `registry` is provided, uses syntect for syntax highlighting in code blocks,
/// which supports ~150+ languages. If None, falls back to uniform code styling.
pub fn parse_markdown(
    text: &str,
    theme: &crate::view::theme::Theme,
    registry: Option<&GrammarRegistry>,
) -> Vec<StyledLine> {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);

    let parser = Parser::new_ext(text, options);
    let mut lines: Vec<StyledLine> = vec![StyledLine::new()];

    // Style stack for nested formatting
    let mut style_stack: Vec<Style> = vec![Style::default()];
    let mut in_code_block = false;
    let mut code_block_lang = String::new();
    // Track current link URL (if inside a link)
    let mut current_link_url: Option<String> = None;

    for event in parser {
        match event {
            Event::Start(tag) => {
                match tag {
                    Tag::Strong => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack.push(current.add_modifier(Modifier::BOLD));
                    }
                    Tag::Emphasis => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack.push(current.add_modifier(Modifier::ITALIC));
                    }
                    Tag::Strikethrough => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
                    }
                    Tag::CodeBlock(kind) => {
                        in_code_block = true;
                        code_block_lang = match kind {
                            pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
                            pulldown_cmark::CodeBlockKind::Indented => String::new(),
                        };
                        // Start new line for code block
                        if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
                            lines.push(StyledLine::new());
                        }
                    }
                    Tag::Heading { .. } => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack
                            .push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
                    }
                    Tag::Link { dest_url, .. } => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack
                            .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
                        // Store the link URL for text spans inside this link
                        current_link_url = Some(dest_url.to_string());
                    }
                    Tag::Image { .. } => {
                        let current = *style_stack.last().unwrap_or(&Style::default());
                        style_stack
                            .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
                    }
                    Tag::List(_) | Tag::Item => {
                        // Start list items on new line
                        if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
                            lines.push(StyledLine::new());
                        }
                    }
                    Tag::Paragraph => {
                        // Start paragraphs on new line if we have any prior content.
                        // This preserves blank lines from previous paragraph ends.
                        let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
                        if has_prior_content {
                            lines.push(StyledLine::new());
                        }
                    }
                    _ => {}
                }
            }
            Event::End(tag_end) => {
                match tag_end {
                    TagEnd::Strong
                    | TagEnd::Emphasis
                    | TagEnd::Strikethrough
                    | TagEnd::Heading(_)
                    | TagEnd::Image => {
                        style_stack.pop();
                    }
                    TagEnd::Link => {
                        style_stack.pop();
                        // Clear link URL when exiting the link
                        current_link_url = None;
                    }
                    TagEnd::CodeBlock => {
                        in_code_block = false;
                        code_block_lang.clear();
                        // End code block with new line
                        lines.push(StyledLine::new());
                    }
                    TagEnd::Paragraph => {
                        // Add blank line after paragraph
                        lines.push(StyledLine::new());
                    }
                    TagEnd::Item => {
                        // Items end naturally
                    }
                    _ => {}
                }
            }
            Event::Text(text) => {
                if in_code_block {
                    // Try syntax highlighting for code blocks using syntect
                    let spans = if let Some(reg) = registry {
                        if !code_block_lang.is_empty() {
                            let s = highlight_string(&text, &code_block_lang, reg, theme);
                            // Check coverage - if < 20% highlighted, content may not be valid code
                            let highlighted_bytes: usize =
                                s.iter().map(|span| span.range.end - span.range.start).sum();
                            let non_ws_bytes =
                                text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
                            let good_coverage =
                                non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
                            if good_coverage {
                                s
                            } else {
                                Vec::new()
                            }
                        } else {
                            Vec::new()
                        }
                    } else {
                        Vec::new()
                    };

                    if !spans.is_empty() {
                        let highlighted_lines =
                            highlight_code_to_styled_lines(&text, &spans, theme);
                        for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
                            if i > 0 {
                                lines.push(StyledLine::new());
                            }
                            // Merge spans into the current line
                            if let Some(current_line) = lines.last_mut() {
                                for span in styled_line.spans {
                                    current_line.push(span.text, span.style);
                                }
                            }
                        }
                    } else {
                        // Fallback: uniform code style for unknown languages
                        let code_style = Style::default()
                            .fg(theme.help_key_fg)
                            .bg(theme.inline_code_bg);
                        for (i, part) in text.split('\n').enumerate() {
                            if i > 0 {
                                lines.push(StyledLine::new());
                            }
                            if !part.is_empty() {
                                if let Some(line) = lines.last_mut() {
                                    line.push(part.to_string(), code_style);
                                }
                            }
                        }
                    }
                } else {
                    let current_style = *style_stack.last().unwrap_or(&Style::default());
                    // Split text by newlines and add to lines
                    for (i, part) in text.split('\n').enumerate() {
                        if i > 0 {
                            lines.push(StyledLine::new());
                        }
                        if !part.is_empty() {
                            if let Some(line) = lines.last_mut() {
                                // Include link URL if we're inside a link
                                line.push_with_link(
                                    part.to_string(),
                                    current_style,
                                    current_link_url.clone(),
                                );
                            }
                        }
                    }
                }
            }
            Event::Code(code) => {
                // Inline code - render with background styling (no backticks needed)
                let style = Style::default()
                    .fg(theme.help_key_fg)
                    .bg(theme.inline_code_bg);
                if let Some(line) = lines.last_mut() {
                    line.push(code.to_string(), style);
                }
            }
            Event::SoftBreak => {
                // Soft break - preserve as newline for better docstring/hover formatting
                // (Standard markdown renders soft breaks as spaces, but for LSP hover
                // content which often contains formatted docstrings, newlines are better)
                lines.push(StyledLine::new());
            }
            Event::HardBreak => {
                // Hard break - new line
                lines.push(StyledLine::new());
            }
            Event::Rule => {
                // Horizontal rule
                lines.push(StyledLine::new());
                if let Some(line) = lines.last_mut() {
                    line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
                }
                lines.push(StyledLine::new());
            }
            _ => {}
        }
    }

    // Remove trailing empty lines
    while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
        lines.pop();
    }

    lines
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::view::theme;
    use crate::view::theme::Theme;

    fn get_line_text(line: &StyledLine) -> String {
        line.spans.iter().map(|s| s.text.as_str()).collect()
    }

    fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
        line.spans
            .iter()
            .any(|s| s.style.add_modifier.contains(modifier))
    }

    #[test]
    fn test_plain_text() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Hello world", &theme, None);

        assert_eq!(lines.len(), 1);
        assert_eq!(get_line_text(&lines[0]), "Hello world");
    }

    #[test]
    fn test_bold_text() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("This is **bold** text", &theme, None);

        assert_eq!(lines.len(), 1);
        assert_eq!(get_line_text(&lines[0]), "This is bold text");

        // Check that "bold" span has BOLD modifier
        let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
        assert!(bold_span.is_some(), "Should have a 'bold' span");
        assert!(
            bold_span
                .unwrap()
                .style
                .add_modifier
                .contains(Modifier::BOLD),
            "Bold span should have BOLD modifier"
        );
    }

    #[test]
    fn test_italic_text() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("This is *italic* text", &theme, None);

        assert_eq!(lines.len(), 1);
        assert_eq!(get_line_text(&lines[0]), "This is italic text");

        let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
        assert!(italic_span.is_some(), "Should have an 'italic' span");
        assert!(
            italic_span
                .unwrap()
                .style
                .add_modifier
                .contains(Modifier::ITALIC),
            "Italic span should have ITALIC modifier"
        );
    }

    #[test]
    fn test_strikethrough_text() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);

        assert_eq!(lines.len(), 1);
        assert_eq!(get_line_text(&lines[0]), "This is deleted text");

        let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
        assert!(strike_span.is_some(), "Should have a 'deleted' span");
        assert!(
            strike_span
                .unwrap()
                .style
                .add_modifier
                .contains(Modifier::CROSSED_OUT),
            "Strikethrough span should have CROSSED_OUT modifier"
        );
    }

    #[test]
    fn test_inline_code() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Use `println!` to print", &theme, None);

        assert_eq!(lines.len(), 1);
        // Inline code is rendered without backticks (styling indicates it's code)
        assert_eq!(get_line_text(&lines[0]), "Use println! to print");

        // Inline code should have background color
        let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
        assert!(code_span.is_some(), "Should have a code span");
        assert!(
            code_span.unwrap().style.bg.is_some(),
            "Inline code should have background color"
        );
    }

    #[test]
    fn test_code_block() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);

        // Code block should have content with background
        let code_line = lines.iter().find(|l| get_line_text(l).contains("fn"));
        assert!(code_line.is_some(), "Should have code block content");

        // With syntax highlighting, "fn" may be in its own span
        // Check that at least one span has background color
        let has_bg = code_line
            .unwrap()
            .spans
            .iter()
            .any(|s| s.style.bg.is_some());
        assert!(has_bg, "Code block should have background color");
    }

    #[test]
    fn test_code_block_syntax_highlighting() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let registry =
            GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
        // Rust code with keywords and strings that should get different colors
        let markdown = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```";
        let lines = parse_markdown(markdown, &theme, Some(&registry));

        // Should have parsed lines with content
        assert!(!lines.is_empty(), "Should have parsed lines");

        // Collect all colors used in the code block
        let mut colors_used = std::collections::HashSet::new();
        for line in &lines {
            for span in &line.spans {
                if let Some(fg) = span.style.fg {
                    colors_used.insert(format!("{:?}", fg));
                }
            }
        }

        // Should have multiple different colors (syntax highlighting)
        // Not just a single uniform color
        assert!(
            colors_used.len() > 1,
            "Code block should have multiple colors for syntax highlighting, got: {:?}",
            colors_used
        );

        // Verify the code content is preserved
        let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
        assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
        assert!(all_text.contains("main"), "Should contain 'main'");
        assert!(all_text.contains("println"), "Should contain 'println'");
    }

    #[test]
    fn test_code_block_unknown_language_fallback() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        // Unknown language should fallback to uniform styling
        let markdown = "```unknownlang\nsome code here\n```";
        let lines = parse_markdown(markdown, &theme, None);

        // Should have parsed lines
        assert!(!lines.is_empty(), "Should have parsed lines");

        // Content should be preserved
        let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
        assert!(
            all_text.contains("some code here"),
            "Should contain the code"
        );

        // All spans should have the fallback code style (uniform color)
        let code_line = lines
            .iter()
            .find(|l| get_line_text(l).contains("some code"));
        if let Some(line) = code_line {
            for span in &line.spans {
                assert!(span.style.bg.is_some(), "Code should have background color");
            }
        }
    }

    #[test]
    fn test_heading() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("# Heading\n\nContent", &theme, None);

        // Heading should be bold
        let heading_line = &lines[0];
        assert!(
            has_modifier(heading_line, Modifier::BOLD),
            "Heading should be bold"
        );
        assert_eq!(get_line_text(heading_line), "Heading");
    }

    #[test]
    fn test_link() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);

        assert_eq!(lines.len(), 1);
        assert_eq!(get_line_text(&lines[0]), "Click here for more");

        // Link text should be underlined and cyan
        let link_span = lines[0].spans.iter().find(|s| s.text == "here");
        assert!(link_span.is_some(), "Should have 'here' span");
        let style = link_span.unwrap().style;
        assert!(
            style.add_modifier.contains(Modifier::UNDERLINED),
            "Link should be underlined"
        );
        assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
    }

    #[test]
    fn test_link_url_stored() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);

        assert_eq!(lines.len(), 1);

        // The "here" span should have the link URL stored
        let link_span = lines[0].spans.iter().find(|s| s.text == "here");
        assert!(link_span.is_some(), "Should have 'here' span");
        assert_eq!(
            link_span.unwrap().link_url,
            Some("https://example.com".to_string()),
            "Link span should store the URL"
        );

        // Non-link spans should not have a URL
        let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
        assert!(click_span.is_some(), "Should have 'Click ' span");
        assert_eq!(
            click_span.unwrap().link_url,
            None,
            "Non-link span should not have URL"
        );
    }

    #[test]
    fn test_link_at_column() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);

        assert_eq!(lines.len(), 1);
        let line = &lines[0];

        // "Click " is 6 chars (0-5), "here" is 4 chars (6-9), " for more" is after
        // Column 0-5: "Click " - no link
        assert_eq!(
            line.link_at_column(0),
            None,
            "Column 0 should not be a link"
        );
        assert_eq!(
            line.link_at_column(5),
            None,
            "Column 5 should not be a link"
        );

        // Column 6-9: "here" - link
        assert_eq!(
            line.link_at_column(6),
            Some("https://example.com"),
            "Column 6 should be the link"
        );
        assert_eq!(
            line.link_at_column(9),
            Some("https://example.com"),
            "Column 9 should be the link"
        );

        // Column 10+: " for more" - no link
        assert_eq!(
            line.link_at_column(10),
            None,
            "Column 10 should not be a link"
        );
    }

    #[test]
    fn test_unordered_list() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);

        // Each item should be on its own line
        assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");

        let all_text: String = lines.iter().map(get_line_text).collect();
        assert!(all_text.contains("Item 1"), "Should contain Item 1");
        assert!(all_text.contains("Item 2"), "Should contain Item 2");
        assert!(all_text.contains("Item 3"), "Should contain Item 3");
    }

    #[test]
    fn test_paragraph_separation() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);

        // Should have 3 lines: first para, blank line, second para
        assert_eq!(
            lines.len(),
            3,
            "Should have 3 lines (para, blank, para), got: {:?}",
            lines.iter().map(get_line_text).collect::<Vec<_>>()
        );

        assert_eq!(get_line_text(&lines[0]), "First paragraph.");
        assert!(
            lines[1].spans.is_empty(),
            "Second line should be empty (paragraph break)"
        );
        assert_eq!(get_line_text(&lines[2]), "Second paragraph.");
    }

    #[test]
    fn test_soft_break_becomes_newline() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        // Single newline in markdown is a soft break
        let lines = parse_markdown("Line one\nLine two", &theme, None);

        // Soft break should become a newline for better docstring/hover formatting
        assert!(
            lines.len() >= 2,
            "Soft break should create separate lines, got {} lines",
            lines.len()
        );
        let all_text: String = lines.iter().map(get_line_text).collect();
        assert!(
            all_text.contains("one") && all_text.contains("two"),
            "Should contain both lines"
        );
    }

    #[test]
    fn test_hard_break() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        // Two spaces before newline creates a hard break
        let lines = parse_markdown("Line one  \nLine two", &theme, None);

        // Hard break creates a new line within the same paragraph
        assert!(lines.len() >= 2, "Hard break should create multiple lines");
    }

    #[test]
    fn test_horizontal_rule() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);

        // Should have a line with horizontal rule characters
        let has_rule = lines.iter().any(|l| get_line_text(l).contains("─"));
        assert!(has_rule, "Should contain horizontal rule character");
    }

    #[test]
    fn test_nested_formatting() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);

        assert_eq!(lines.len(), 1);

        // Find the nested formatted span
        let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
        assert!(nested_span.is_some(), "Should have nested formatted span");

        let style = nested_span.unwrap().style;
        assert!(
            style.add_modifier.contains(Modifier::BOLD),
            "Should be bold"
        );
        assert!(
            style.add_modifier.contains(Modifier::ITALIC),
            "Should be italic"
        );
    }

    #[test]
    fn test_lsp_hover_docstring() {
        // Real-world example from Python LSP hover
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";

        let lines = parse_markdown(markdown, &theme, None);

        // Should have code block, blank line, first paragraph, blank line, second paragraph
        assert!(lines.len() >= 3, "Should have multiple sections");

        // Code block should have background
        let code_line = lines.iter().find(|l| get_line_text(l).contains("Path"));
        assert!(code_line.is_some(), "Should have code block with Path");

        // Documentation text should be present
        let all_text: String = lines.iter().map(get_line_text).collect();
        assert!(
            all_text.contains("PurePath subclass"),
            "Should contain docstring"
        );
    }

    #[test]
    fn test_python_docstring_formatting() {
        // Test Python-style docstring with keyword arguments list
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let markdown = "Keyword Arguments:\n    - prog -- The name\n    - usage -- A usage message";
        let lines = parse_markdown(markdown, &theme, None);

        // Should preserve line breaks for proper list formatting
        assert!(
            lines.len() >= 3,
            "Should have multiple lines for keyword args list, got {} lines: {:?}",
            lines.len(),
            lines.iter().map(get_line_text).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_empty_input() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("", &theme, None);

        // Empty input should produce empty or minimal output
        assert!(
            lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
            "Empty input should produce empty output"
        );
    }

    #[test]
    fn test_only_whitespace() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("   \n\n   ", &theme, None);

        // Whitespace-only should produce empty or minimal output
        for line in &lines {
            let text = get_line_text(line);
            assert!(
                text.trim().is_empty(),
                "Whitespace-only input should not produce content"
            );
        }
    }

    // ==================== Word Wrapping Tests ====================

    #[test]
    fn test_wrap_text_line_at_word_boundaries() {
        // Test that wrapping happens at word boundaries, not mid-word
        let text = "Path represents a filesystem path but unlike PurePath also offers methods";
        let wrapped = wrap_text_line(text, 30);

        // Should wrap at word boundaries
        for (i, line) in wrapped.iter().enumerate() {
            // Lines should not start with a space (spaces are trimmed when wrapping)
            if !line.is_empty() {
                assert!(
                    !line.starts_with(' '),
                    "Line {} should not start with space: {:?}",
                    i,
                    line
                );
            }

            // Each line should fit within max_width
            let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
            assert!(
                line_width <= 30,
                "Line {} exceeds max width: {} > 30, content: {:?}",
                i,
                line_width,
                line
            );
        }

        // Check that we didn't break any words mid-character
        // All words in wrapped output should be complete words from original
        let original_words: Vec<&str> = text.split_whitespace().collect();
        let wrapped_words: Vec<&str> = wrapped
            .iter()
            .flat_map(|line| line.split_whitespace())
            .collect();
        assert_eq!(
            original_words, wrapped_words,
            "Words should be preserved without breaking mid-word"
        );

        // Verify specific expected wrapping (28 chars fits: "Path represents a filesystem")
        assert_eq!(
            wrapped[0], "Path represents a filesystem",
            "First line should break at word boundary"
        );
        assert_eq!(
            wrapped[1], "path but unlike PurePath also",
            "Second line should contain next words (30 chars fits)"
        );
        assert_eq!(
            wrapped[2], "offers methods",
            "Third line should contain remaining words"
        );
    }

    #[test]
    fn test_wrap_text_line_long_word() {
        // Test that words longer than max_width are broken mid-word
        let text = "supercalifragilisticexpialidocious";
        let wrapped = wrap_text_line(text, 10);

        assert!(
            wrapped.len() > 1,
            "Long word should be split into multiple lines"
        );

        // Each line should be at most max_width
        for line in &wrapped {
            let width = unicode_width::UnicodeWidthStr::width(line.as_str());
            assert!(width <= 10, "Line should not exceed max width: {}", line);
        }

        // Content should be preserved
        let rejoined: String = wrapped.join("");
        assert_eq!(rejoined, text, "Content should be preserved");
    }

    #[test]
    fn test_wrap_text_line_empty() {
        let wrapped = wrap_text_line("", 30);
        assert_eq!(wrapped.len(), 1);
        assert_eq!(wrapped[0], "");
    }

    #[test]
    fn test_wrap_text_line_fits() {
        let text = "Short text";
        let wrapped = wrap_text_line(text, 30);
        assert_eq!(wrapped.len(), 1);
        assert_eq!(wrapped[0], text);
    }

    #[test]
    fn test_wrap_styled_lines_long_hover_content() {
        // Test that long hover lines get wrapped correctly
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();

        // Simulate a long LSP hover response (e.g., a function signature that's too long)
        let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
        let markdown = format!("```python\n{}\n```", long_text);

        let lines = parse_markdown(&markdown, &theme, None);

        // The code block should produce styled lines
        assert!(!lines.is_empty(), "Should have parsed lines");

        // Now wrap to a narrow width (40 chars)
        let wrapped = wrap_styled_lines(&lines, 40);

        // The long line should be wrapped into multiple lines
        assert!(
            wrapped.len() > lines.len(),
            "Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
            lines.len(),
            wrapped.len()
        );

        // Each wrapped line should not exceed max width
        for (i, line) in wrapped.iter().enumerate() {
            let line_width: usize = line
                .spans
                .iter()
                .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
                .sum();
            assert!(
                line_width <= 40,
                "Wrapped line {} exceeds max width: {} > 40, content: {:?}",
                i,
                line_width,
                line.spans
                    .iter()
                    .map(|s| s.text.as_str())
                    .collect::<Vec<_>>()
            );
        }

        // Verify the content is preserved (concatenate all wrapped text)
        let original_text: String = lines
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
            .collect();
        let wrapped_text: String = wrapped
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
            .collect();
        assert_eq!(
            original_text, wrapped_text,
            "Content should be preserved after wrapping"
        );
    }

    #[test]
    fn test_wrap_styled_lines_preserves_style() {
        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
        let lines = parse_markdown("**bold text that is quite long**", &theme, None);

        let wrapped = wrap_styled_lines(&lines, 15);

        // All wrapped segments should preserve the bold style
        for line in &wrapped {
            for span in &line.spans {
                if !span.text.trim().is_empty() {
                    assert!(
                        span.style.add_modifier.contains(Modifier::BOLD),
                        "Style should be preserved after wrapping: {:?}",
                        span.text
                    );
                }
            }
        }
    }

    #[test]
    fn test_wrap_text_lines_multiple() {
        let lines = vec![
            "Short".to_string(),
            "This is a longer line that needs wrapping".to_string(),
            "".to_string(),
            "Another line".to_string(),
        ];

        let wrapped = wrap_text_lines(&lines, 20);

        // Should preserve empty lines
        assert!(
            wrapped.iter().any(|l| l.is_empty()),
            "Should preserve empty lines"
        );

        // All lines should fit within max_width
        for line in &wrapped {
            let width = unicode_width::UnicodeWidthStr::width(line.as_str());
            assert!(width <= 20, "Line exceeds max width: {}", line);
        }
    }
}