1use crate::console::ANSI_REGEX_PATTERN;
3use crossterm::{
4 queue,
5 style::{
6 Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
7 },
8};
9use regex::Regex;
10use std::fmt::Write as _;
11use std::io::{self, Write};
12use std::ops::Range;
13use std::sync::LazyLock;
14use syntect::easy::HighlightLines;
15use syntect::highlighting::{Theme, ThemeSet};
16use syntect::parsing::SyntaxSet;
17use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
18
19static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_no_newlines);
21static THEME: LazyLock<Theme> = LazyLock::new(|| {
22 let ts = ThemeSet::load_defaults();
23 ts.themes
24 .get("base16-ocean.dark")
25 .or_else(|| ts.themes.values().next())
26 .expect("No themes found")
27 .clone()
28});
29
30static RE_CODE_FENCE: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"^(\s*)([`~]{3,})(.*)$").unwrap());
33static RE_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)").unwrap());
34static RE_HR: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"^[ ]{0,3}(?:[-*_][ \t]*){3,}$").unwrap());
36static RE_LIST: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"^(\s*)([-*+]|\d+[.)])(?:(\s+)(.*)|$)").unwrap());
38static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*>\s?)(.*)").unwrap());
39
40static RE_TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|(.*)\|\s*$").unwrap());
41static RE_TABLE_SEP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\s\|\-\:]+$").unwrap());
42
43static RE_MATH_BLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\$\$\s*$").unwrap());
44
45const STYLE_H1: &str = "\x1b[1m";
47const STYLE_H2: &str = "\x1b[1m\x1b[94m";
48const STYLE_H3: &str = "\x1b[1m\x1b[36m";
49const STYLE_H_DEFAULT: &str = "\x1b[1m\x1b[33m";
50const STYLE_INLINE_CODE: &str = "\x1b[48;2;60;60;60m\x1b[38;2;255;255;255m";
51const STYLE_BLOCKQUOTE: &str = "\x1b[38;5;240m";
52const STYLE_LIST_BULLET: &str = "\x1b[33m";
53const STYLE_MATH: &str = "\x1b[36;3m";
54const STYLE_RESET: &str = "\x1b[0m";
55const STYLE_RESET_BG: &str = "\x1b[49m";
56const STYLE_RESET_FG: &str = "\x1b[39m";
57
58const COLOR_CODE_BG: Color = Color::Rgb {
59 r: 30,
60 g: 30,
61 b: 30,
62};
63
64static RE_INVISIBLE: LazyLock<Regex> =
66 LazyLock::new(|| Regex::new(&format!("({}|{})", OSC8_PATTERN, ANSI_REGEX_PATTERN)).unwrap());
67
68const OSC8_PATTERN: &str = r"\x1b]8;;.*?\x1b\\";
71
72static RE_LINK: LazyLock<Regex> = LazyLock::new(|| {
74 Regex::new(
75 r"\[((?:[^\[\]]|\[(?:[^\[\]]|\[[^\[\]]*\])*\])*)\]\(((?:[^()\s]|\((?:[^()\s]|\([^()\s]*\))*\))*)\)",
76 )
77 .unwrap()
78});
79static RE_OSC8: LazyLock<Regex> = LazyLock::new(|| Regex::new(OSC8_PATTERN).unwrap());
80
81static RE_AUTOLINK: LazyLock<Regex> =
82 LazyLock::new(|| Regex::new(r"<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^<> \x00-\x1f]+)>").unwrap());
83
84static RE_OPAQUE: LazyLock<Regex> = LazyLock::new(|| {
85 Regex::new(&format!(
86 r"(?x)
87 (?P<code>`+) |
88 (?P<link>{}) |
89 (?P<autolink>{}) |
90 (?P<math>\$[^\$\s](?:[^\$\n]*?[^\$\s])?\$ | \$) |
91 (?P<escape>\\[\s\S]) |
92 (?P<ansi>{}|{}) |
93 (?P<delim>~~|~|\*\*\*|___|\*\*|__|\*|_)",
94 RE_LINK.as_str(),
95 RE_AUTOLINK.as_str(),
96 OSC8_PATTERN,
97 ANSI_REGEX_PATTERN
98 ))
99 .unwrap()
100});
101
102static RE_SPLIT_ANSI: LazyLock<Regex> = LazyLock::new(|| {
103 let pattern = format!(
104 "({}|{}|\\s+|[^\\s\\x1b]+)",
105 OSC8_PATTERN, ANSI_REGEX_PATTERN
106 );
107 Regex::new(&pattern).unwrap()
108});
109static RE_ANSI_PARTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[([0-9;]*)m").unwrap());
110
111struct BlockLayout {
114 prefix: String,
115 avail_width: usize,
116}
117
118struct ListLevel {
119 source_indent: usize,
120 marker_width: usize,
121}
122
123impl ListLevel {
124 fn new(source_indent: usize, marker_width: usize) -> Self {
125 Self {
126 source_indent,
127 marker_width,
128 }
129 }
130}
131
132struct ListContext {
133 levels: Vec<ListLevel>,
134}
135
136impl ListContext {
137 fn new() -> Self {
138 Self { levels: Vec::new() }
139 }
140
141 fn is_empty(&self) -> bool {
142 self.levels.is_empty()
143 }
144
145 fn structural_width(&self) -> usize {
146 self.levels.iter().map(|l| l.marker_width).sum()
147 }
148
149 fn parent_width(&self) -> usize {
150 if self.levels.is_empty() {
151 0
152 } else {
153 self.levels[..self.levels.len() - 1]
154 .iter()
155 .map(|l| l.marker_width)
156 .sum()
157 }
158 }
159
160 fn last_indent(&self) -> Option<usize> {
161 self.levels.last().map(|l| l.source_indent)
162 }
163
164 fn push(&mut self, source_indent: usize, marker_width: usize) {
165 self.levels
166 .push(ListLevel::new(source_indent, marker_width));
167 }
168
169 fn pop_to_indent(&mut self, indent: usize) {
170 while self.levels.last().is_some_and(|l| l.source_indent > indent) {
171 self.levels.pop();
172 }
173 }
174
175 fn update_last_marker_width(&mut self, marker_width: usize) {
176 if let Some(last) = self.levels.last_mut() {
177 last.marker_width = marker_width;
178 }
179 }
180
181 fn clear(&mut self) {
182 self.levels.clear();
183 }
184}
185
186struct InlineCodeState {
187 ticks: Option<usize>,
188 buffer: String,
189}
190
191impl InlineCodeState {
192 fn new() -> Self {
193 Self {
194 ticks: None,
195 buffer: String::new(),
196 }
197 }
198
199 fn is_active(&self) -> bool {
200 self.ticks.is_some()
201 }
202
203 fn open(&mut self, tick_count: usize) {
204 self.ticks = Some(tick_count);
205 self.buffer.clear();
206 }
207
208 fn push_content(&mut self, content: &str) {
209 self.buffer.push_str(&content.replace('\n', " "));
210 }
211
212 fn close(&mut self) -> String {
213 let result = Self::normalize_content_static(&self.buffer);
214 self.ticks = None;
215 self.buffer.clear();
216 result
217 }
218
219 fn append_space(&mut self) {
220 if self.is_active() {
221 self.buffer.push(' ');
222 }
223 }
224
225 fn normalize_content_static(s: &str) -> String {
226 if s.len() >= 2 && s.starts_with(' ') && s.ends_with(' ') && s.chars().any(|c| c != ' ') {
227 s[1..s.len() - 1].to_string()
228 } else {
229 s.to_string()
230 }
231 }
232
233 fn flush_incomplete(&self) -> Option<(usize, String)> {
234 self.ticks.map(|n| (n, self.buffer.clone()))
235 }
236
237 fn reset(&mut self) {
238 self.ticks = None;
239 self.buffer.clear();
240 }
241}
242
243enum InlineToken {
244 Text(String),
245 Delimiter {
246 char: char,
247 len: usize,
248 can_open: bool,
249 can_close: bool,
250 },
251}
252
253struct InlinePart {
254 token: InlineToken,
255 pre_style: Vec<String>,
256 post_style: Vec<String>,
257}
258
259impl InlinePart {
260 fn text(content: String) -> Self {
261 Self {
262 token: InlineToken::Text(content),
263 pre_style: vec![],
264 post_style: vec![],
265 }
266 }
267
268 fn delimiter(char: char, len: usize, can_open: bool, can_close: bool) -> Self {
269 Self {
270 token: InlineToken::Delimiter {
271 char,
272 len,
273 can_open,
274 can_close,
275 },
276 pre_style: vec![],
277 post_style: vec![],
278 }
279 }
280
281 fn content(&self) -> String {
282 match &self.token {
283 InlineToken::Text(s) => s.clone(),
284 InlineToken::Delimiter { char, len, .. } => char.to_string().repeat(*len),
285 }
286 }
287
288 fn is_delim(&self) -> bool {
289 matches!(self.token, InlineToken::Delimiter { .. })
290 }
291
292 fn delim_char(&self) -> char {
293 match &self.token {
294 InlineToken::Delimiter { char, .. } => *char,
295 _ => '\0',
296 }
297 }
298
299 fn delim_len(&self) -> usize {
300 match &self.token {
301 InlineToken::Delimiter { len, .. } => *len,
302 _ => 0,
303 }
304 }
305
306 fn can_open(&self) -> bool {
307 match &self.token {
308 InlineToken::Delimiter { can_open, .. } => *can_open,
309 _ => false,
310 }
311 }
312
313 fn can_close(&self) -> bool {
314 match &self.token {
315 InlineToken::Delimiter { can_close, .. } => *can_close,
316 _ => false,
317 }
318 }
319
320 fn consume(&mut self, amount: usize) {
321 if let InlineToken::Delimiter { len, .. } = &mut self.token {
322 *len = len.saturating_sub(amount);
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq)]
330pub enum BlockKind {
331 FenceOpen {
332 fence_char: char,
333 fence_len: usize,
334 indent: usize,
335 lang: String,
336 },
337 FenceClose,
338 FenceContent,
339 MathOpen,
340 MathClose,
341 MathContent,
342 TableSeparator,
343 TableRow,
344 Header {
345 level: usize,
346 text: String,
347 },
348 ThematicBreak,
349 ListItem {
350 indent: usize,
351 marker: String,
352 separator: String,
353 content: String,
354 is_ordered: bool,
355 },
356 BlankLine,
357 Paragraph,
358}
359
360#[derive(Debug, Clone, PartialEq)]
361pub struct ClassifiedLine {
362 pub blockquote_depth: usize,
363 pub content: String,
364 pub kind: BlockKind,
365}
366
367#[derive(Debug, Clone)]
368enum ParsedSegment {
369 CodeSpan {
371 range: Range<usize>,
372 delimiter_len: usize,
373 },
374 CodeSpanOpener {
376 range: Range<usize>,
377 delimiter_len: usize,
378 },
379 CodeSpanContent(Range<usize>),
381 CodeSpanCloser {
383 range: Range<usize>,
384 delimiter_len: usize,
385 },
386 Link(Range<usize>),
387 Autolink(Range<usize>),
388 Math(Range<usize>),
389 Escape(Range<usize>),
390 Ansi(Range<usize>),
391 Delim(Range<usize>),
392 Text(Range<usize>),
393}
394
395fn find_backtick_closer(text: &str, n: usize) -> Option<usize> {
396 let bytes = text.as_bytes();
397 let mut i = 0;
398 while i < bytes.len() {
399 if bytes[i] == b'`' {
400 let mut count = 0;
401 while i + count < bytes.len() && bytes[i + count] == b'`' {
402 count += 1;
403 }
404 if count == n {
405 return Some(i);
406 }
407 i += count;
408 } else {
409 i += 1;
410 }
411 }
412 None
413}
414
415fn parse_segments(text: &str, active_ticks: Option<usize>) -> Vec<ParsedSegment> {
416 let mut segments = Vec::new();
417 let mut pos = 0;
418
419 if let Some(n) = active_ticks {
421 if let Some(close_idx) = find_backtick_closer(text, n) {
422 if close_idx > 0 {
423 segments.push(ParsedSegment::CodeSpanContent(pos..close_idx));
424 }
425 let close_start = close_idx;
426 let close_end = close_idx + n;
427 segments.push(ParsedSegment::CodeSpanCloser {
428 range: close_start..close_end,
429 delimiter_len: n,
430 });
431 pos = close_end;
432 } else {
433 if !text.is_empty() {
434 segments.push(ParsedSegment::CodeSpanContent(pos..text.len()));
435 }
436 return segments;
437 }
438 }
439
440 while pos < text.len() {
441 let rest = &text[pos..];
442 if let Some(caps) = RE_OPAQUE.captures(rest) {
443 let m = caps.get(0).unwrap();
444 if m.start() > 0 {
445 segments.push(ParsedSegment::Text(pos..pos + m.start()));
446 }
447
448 let start = pos + m.start();
449 let end = pos + m.end();
450
451 if let Some(code) = caps.name("code") {
452 let ticks = code.as_str();
453 let n = ticks.len();
454 let search_start = m.end();
455 if let Some(close_idx) = find_backtick_closer(&rest[search_start..], n) {
456 let span_end = pos + search_start + close_idx + n;
457 segments.push(ParsedSegment::CodeSpan {
458 range: start..span_end,
459 delimiter_len: n,
460 });
461 pos = span_end;
462 } else {
463 segments.push(ParsedSegment::CodeSpanOpener {
464 range: start..end,
465 delimiter_len: n,
466 });
467 if end < text.len() {
468 segments.push(ParsedSegment::CodeSpanContent(end..text.len()));
469 }
470 return segments;
471 }
472 } else {
473 if caps.name("link").is_some() {
474 segments.push(ParsedSegment::Link(start..end));
475 } else if caps.name("autolink").is_some() {
476 segments.push(ParsedSegment::Autolink(start..end));
477 } else if caps.name("math").is_some() {
478 segments.push(ParsedSegment::Math(start..end));
479 } else if caps.name("escape").is_some() {
480 segments.push(ParsedSegment::Escape(start..end));
481 } else if caps.name("ansi").is_some() {
482 segments.push(ParsedSegment::Ansi(start..end));
483 } else if caps.name("delim").is_some() {
484 segments.push(ParsedSegment::Delim(start..end));
485 }
486 pos = end;
487 }
488 } else {
489 segments.push(ParsedSegment::Text(pos..text.len()));
490 break;
491 }
492 }
493
494 segments
495}
496
497fn split_table_row<'a>(text: &'a str, segments: &[ParsedSegment]) -> Vec<&'a str> {
498 let mut cells = Vec::new();
499 let mut start = 0;
500
501 for seg in segments {
502 if let ParsedSegment::Text(range) = seg {
503 for (i, c) in text[range.clone()].char_indices() {
504 if c == '|' {
505 cells.push(&text[start..range.start + i]);
506 start = range.start + i + 1;
507 }
508 }
509 }
510 }
511 cells.push(&text[start..]);
512 cells
513}
514
515pub struct MarkdownStreamer {
516 active_fence: Option<(char, usize, usize)>, code_lang: String,
519
520 inline_code: InlineCodeState,
522
523 in_math_block: bool,
525 math_buffer: String,
526
527 in_table: bool,
529 table_header_printed: bool,
530
531 highlighter: Option<HighlightLines<'static>>,
533 line_buffer: String,
534
535 margin: usize,
537 blockquote_depth: usize,
538 list_context: ListContext,
539 pending_newline: bool,
540
541 manual_width: Option<usize>,
543
544 scratch_buffer: String,
546}
547
548impl Default for MarkdownStreamer {
549 fn default() -> Self {
550 Self::new()
551 }
552}
553
554impl MarkdownStreamer {
555 pub fn new() -> Self {
556 Self {
557 active_fence: None,
558 code_lang: "bash".to_string(),
559 inline_code: InlineCodeState::new(),
560 in_math_block: false,
561 math_buffer: String::new(),
562 in_table: false,
563 table_header_printed: false,
564 highlighter: None,
565 line_buffer: String::new(),
566 margin: 2,
567 blockquote_depth: 0,
568 list_context: ListContext::new(),
569 pending_newline: false,
570 manual_width: None,
571 scratch_buffer: String::with_capacity(1024),
572 }
573 }
574
575 pub fn set_width(&mut self, width: usize) {
577 self.manual_width = Some(width);
578 }
579
580 pub fn set_margin(&mut self, margin: usize) {
582 self.margin = margin;
583 }
584
585 fn get_width(&self) -> usize {
586 self.manual_width
587 .unwrap_or_else(crate::console::get_terminal_width)
588 }
589
590 fn visible_width(&self, text: &str) -> usize {
591 UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
592 }
593
594 pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
596 self.line_buffer.push_str(text);
597 while let Some(pos) = self.line_buffer.find('\n') {
598 let line = self.line_buffer[..pos + 1].to_string();
599 self.line_buffer.drain(..pos + 1);
600 self.process_line(writer, &line)?;
601 }
602 Ok(())
603 }
604
605 pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
607 if !self.line_buffer.is_empty() {
608 let line = std::mem::take(&mut self.line_buffer);
609 self.process_line(writer, &line)?;
610 }
611
612 self.flush_pending_inline(writer)?;
613 self.commit_newline(writer)?;
614 writer.flush()
615 }
616
617 fn commit_newline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
618 if self.pending_newline {
619 queue!(writer, Print("\n"))?;
620 self.pending_newline = false;
621 }
622 Ok(())
623 }
624
625 fn flush_pending_inline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
626 if let Some((ticks, buffer)) = self.inline_code.flush_incomplete() {
627 let prefix = "`".repeat(ticks);
628 let formatted =
629 self.format_inline_code_content(&format!("{}{}", prefix, buffer), None, None);
630 queue!(writer, Print(formatted))?;
631 self.inline_code.reset();
632 }
633 Ok(())
634 }
635
636 fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
638 let expanded = self.expand_tabs(raw_line);
639 let classified = self.classify_line(&expanded);
640
641 self.blockquote_depth = classified.blockquote_depth;
642 self.update_block_state(&classified);
643
644 match classified.kind {
645 BlockKind::FenceOpen { lang, .. } => {
646 self.render_fence_open(w, &lang)?;
647 }
648 BlockKind::FenceClose => {
649 self.render_fence_close(w)?;
650 }
651 BlockKind::FenceContent => {
652 self.render_code_line(w, &expanded)?;
653 }
654 BlockKind::MathOpen => {
655 self.render_math_open(w)?;
656 }
657 BlockKind::MathClose => {
658 self.render_math_close(w)?;
659 }
660 BlockKind::MathContent => {
661 let trimmed = expanded.trim_end();
662 self.render_math_content(trimmed);
663 }
664 BlockKind::TableSeparator => {
665 }
667 BlockKind::TableRow => {
668 let trimmed = expanded.trim_end();
669 self.render_stream_table_row(w, trimmed)?;
670 }
671 BlockKind::Header { level, text } => {
672 let layout = self.compute_block_layout();
673 self.render_header(w, level, &text, &layout)?;
674 }
675 BlockKind::ThematicBreak => {
676 let layout = self.compute_block_layout();
677 self.render_thematic_break(w, &layout)?;
678 }
679 BlockKind::ListItem {
680 indent,
681 marker,
682 separator,
683 content,
684 is_ordered,
685 } => {
686 let layout = self.compute_block_layout();
687 self.render_list_item(
688 w, indent, &marker, &separator, &content, is_ordered, &layout,
689 )?;
690 }
691 BlockKind::BlankLine | BlockKind::Paragraph => {
692 let layout = self.compute_block_layout();
693 self.render_standard_text(w, &classified.content, &layout)?;
694 }
695 }
696 Ok(())
697 }
698
699 fn render_fence_open<W: Write>(&mut self, w: &mut W, lang: &str) -> io::Result<()> {
702 self.flush_pending_inline(w)?;
703 self.commit_newline(w)?;
704 self.start_highlighter(lang);
705 Ok(())
706 }
707
708 fn render_fence_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
709 self.commit_newline(w)?;
710 queue!(w, ResetColor)?;
711 self.pending_newline = true;
712 Ok(())
713 }
714
715 fn render_math_open<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
716 self.flush_pending_inline(w)?;
717 self.commit_newline(w)?;
718 Ok(())
719 }
720
721 fn render_math_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
722 let converted = unicodeit::replace(&self.math_buffer);
723 let p_width = self.margin + (self.blockquote_depth * 2);
724 let avail = self.get_width().saturating_sub(p_width + self.margin);
725 let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
726
727 self.commit_newline(w)?;
728 queue!(
729 w,
730 Print(" ".repeat(self.margin + padding)),
731 Print(STYLE_MATH),
732 Print(converted),
733 Print(STYLE_RESET)
734 )?;
735 self.pending_newline = true;
736 self.math_buffer.clear();
737 Ok(())
738 }
739
740 fn render_math_content(&mut self, trimmed: &str) {
741 self.math_buffer.push_str(trimmed);
742 self.math_buffer.push(' ');
743 }
744
745 fn compute_block_layout(&self) -> BlockLayout {
746 let prefix = self.build_block_prefix();
747 let term_width = self.get_width();
748 let prefix_width = self.margin + (self.blockquote_depth * 2);
749 let avail_width = term_width.saturating_sub(prefix_width + self.margin);
750 BlockLayout {
751 prefix,
752 avail_width,
753 }
754 }
755
756 fn render_header<W: Write>(
757 &mut self,
758 w: &mut W,
759 level: usize,
760 text: &str,
761 layout: &BlockLayout,
762 ) -> io::Result<()> {
763 self.flush_pending_inline(w)?;
764 self.commit_newline(w)?;
765
766 let prefix = &layout.prefix;
767 let avail = layout.avail_width;
768
769 queue!(w, Print(prefix))?;
770 if level <= 2 {
771 queue!(w, Print("\n"))?;
772 }
773
774 self.scratch_buffer.clear();
775 let style = match level {
776 1 => STYLE_H1,
777 2 => STYLE_H2,
778 3 => STYLE_H3,
779 _ => STYLE_H_DEFAULT,
780 };
781 self.render_inline(text, None, Some(style));
782
783 if level <= 2 {
784 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
785 for (i, line) in lines.iter().enumerate() {
786 let pad = avail.saturating_sub(self.visible_width(line)) / 2;
787 if i > 0 {
788 queue!(w, Print("\n"), Print(prefix))?;
789 }
790 queue!(
791 w,
792 Print(" ".repeat(pad)),
793 Print(format!("{}{}{}", style, line, STYLE_RESET)),
794 ResetColor
795 )?;
796 }
797 if level == 1 {
798 queue!(w, Print("\n"), Print(prefix), Print("─".repeat(avail)))?;
799 }
800 self.pending_newline = true;
801 } else {
802 queue!(
803 w,
804 Print(style),
805 Print(&self.scratch_buffer),
806 Print(STYLE_RESET)
807 )?;
808 self.pending_newline = true;
809 }
810 Ok(())
811 }
812
813 #[allow(clippy::too_many_arguments)]
814 fn render_list_item<W: Write>(
815 &mut self,
816 w: &mut W,
817 indent: usize,
818 marker: &str,
819 separator: &str,
820 text: &str,
821 is_ordered: bool,
822 layout: &BlockLayout,
823 ) -> io::Result<()> {
824 let prefix = &layout.prefix;
825 let avail = layout.avail_width;
826
827 self.flush_pending_inline(w)?;
828 self.commit_newline(w)?;
829
830 let disp_bullet = if is_ordered { marker } else { "•" };
831 let marker_width = self.visible_width(disp_bullet) + separator.len();
832
833 let last_indent = self.list_context.last_indent().unwrap_or(0);
834 if self.list_context.is_empty() || indent > last_indent {
835 self.list_context.push(indent, marker_width);
836 } else if indent < last_indent {
837 self.list_context.pop_to_indent(indent);
838 if self.list_context.last_indent().is_some_and(|d| d != indent) {
839 self.list_context.push(indent, marker_width);
840 }
841 } else {
842 self.list_context.update_last_marker_width(marker_width);
843 }
844
845 let full_stack_width = self.list_context.structural_width();
846 let parent_width = self.list_context.parent_width();
847
848 let hang_indent = " ".repeat(full_stack_width);
849 let content_width = avail.saturating_sub(full_stack_width);
850
851 queue!(
852 w,
853 Print(prefix),
854 Print(" ".repeat(parent_width)),
855 Print(STYLE_LIST_BULLET),
856 Print(disp_bullet),
857 Print(STYLE_RESET),
858 Print(separator)
859 )?;
860
861 if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
862 queue!(w, Print("\n"))?;
863
864 let fence_chars = &fcaps[2];
865 let info = fcaps[3].trim();
866
867 if let Some(f_char) = fence_chars.chars().next() {
868 self.active_fence = Some((f_char, fence_chars.len(), 0));
869
870 let lang = info.split_whitespace().next().unwrap_or("bash");
871 self.code_lang = lang.to_string();
872 self.start_highlighter(&self.code_lang.clone());
873 }
874 return Ok(());
875 }
876
877 self.scratch_buffer.clear();
878 self.render_inline(text, None, None);
879 let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
880
881 if lines.is_empty() {
882 self.pending_newline = true;
883 } else {
884 for (i, line) in lines.iter().enumerate() {
885 if i > 0 {
886 queue!(w, Print("\n"), Print(prefix), Print(&hang_indent))?;
887 }
888 queue!(w, Print(line), ResetColor)?;
889 }
890 self.pending_newline = true;
891 }
892 Ok(())
893 }
894
895 fn render_thematic_break<W: Write>(
896 &mut self,
897 w: &mut W,
898 layout: &BlockLayout,
899 ) -> io::Result<()> {
900 let prefix = &layout.prefix;
901 let avail = layout.avail_width;
902
903 self.flush_pending_inline(w)?;
904 self.commit_newline(w)?;
905 queue!(
906 w,
907 Print(prefix),
908 SetForegroundColor(Color::DarkGrey),
909 Print("─".repeat(avail)),
910 ResetColor
911 )?;
912 self.pending_newline = true;
913 Ok(())
914 }
915
916 fn render_standard_text<W: Write>(
917 &mut self,
918 w: &mut W,
919 content: &str,
920 layout: &BlockLayout,
921 ) -> io::Result<()> {
922 let prefix = &layout.prefix;
923 let avail = layout.avail_width;
924
925 self.commit_newline(w)?;
926 let mut line_content = content.trim_end_matches(['\n', '\r']);
927 if line_content.trim().is_empty() {
928 self.exit_block_context();
929 if self.blockquote_depth > 0 {
930 queue!(w, Print(prefix))?;
931 }
932 self.pending_newline = true;
933 return Ok(());
934 }
935
936 if !line_content.is_empty() || self.inline_code.is_active() {
937 let mut eff_prefix = self.build_block_prefix();
938 if !self.list_context.is_empty() {
939 let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
940 if current_indent == 0 {
941 self.list_context.clear();
942 } else {
943 self.list_context.pop_to_indent(current_indent);
944 }
945
946 if !self.list_context.is_empty() {
947 let structural_indent = self.list_context.structural_width();
948 eff_prefix.push_str(&" ".repeat(structural_indent));
949
950 let skip = current_indent.min(structural_indent);
953 line_content = &line_content[skip..];
954 }
955 }
956
957 self.scratch_buffer.clear();
958 if self.inline_code.is_active() {
959 self.inline_code.append_space();
960 }
961 self.render_inline(line_content, None, None);
962
963 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
964 let has_visible_content = self.visible_width(&self.scratch_buffer) > 0;
965
966 for (i, line) in lines.iter().enumerate() {
967 if i > 0 {
968 queue!(w, Print("\n"))?;
969 }
970 queue!(
971 w,
972 ResetColor,
973 SetAttribute(Attribute::Reset),
974 Print(&eff_prefix),
975 Print(line),
976 ResetColor
977 )?;
978 }
979 if !lines.is_empty() && has_visible_content {
980 self.pending_newline = true;
981 }
982 }
983 Ok(())
984 }
985
986 fn update_block_state(&mut self, classified: &ClassifiedLine) {
987 match &classified.kind {
988 BlockKind::FenceOpen {
989 fence_char,
990 fence_len,
991 indent,
992 lang,
993 } => {
994 self.list_context.pop_to_indent(*indent);
995 self.active_fence = Some((*fence_char, *fence_len, *indent));
996 self.code_lang = lang.clone();
997 }
998 BlockKind::FenceClose => {
999 self.active_fence = None;
1000 }
1001 BlockKind::FenceContent => {}
1002 BlockKind::MathOpen => {
1003 self.exit_block_context();
1004 self.in_math_block = true;
1005 }
1006 BlockKind::MathClose => {
1007 self.in_math_block = false;
1008 }
1009 BlockKind::MathContent => {}
1010 BlockKind::TableSeparator => {
1011 self.table_header_printed = true;
1012 }
1013 BlockKind::TableRow => {
1014 if !self.in_table {
1015 self.exit_block_context();
1016 self.in_table = true;
1017 }
1018 }
1019 BlockKind::Header { .. } => {
1020 self.exit_block_context();
1021 }
1022 BlockKind::ThematicBreak => {
1023 self.exit_block_context();
1024 }
1025 BlockKind::ListItem { .. } => {
1026 self.in_table = false;
1027 self.table_header_printed = false;
1028 }
1029 BlockKind::BlankLine | BlockKind::Paragraph => {
1030 self.in_table = false;
1031 self.table_header_printed = false;
1032 }
1033 }
1034 }
1035
1036 fn exit_block_context(&mut self) {
1037 self.list_context.clear();
1038 self.in_table = false;
1039 self.table_header_printed = false;
1040 }
1041
1042 fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
1043 let mut lines = Vec::new();
1044 let mut current_line = String::new();
1045 let mut current_len = 0;
1046 let mut active_codes: Vec<String> = Vec::new();
1047
1048 for caps in RE_SPLIT_ANSI.captures_iter(text) {
1049 let token = caps.get(1).unwrap().as_str();
1050 if token.starts_with("\x1b") {
1051 current_line.push_str(token);
1052 self.update_ansi_state(&mut active_codes, token);
1053 } else {
1054 let mut token_str = token;
1055 let mut token_len = UnicodeWidthStr::width(token_str);
1056
1057 while current_len + token_len > width && width > 0 {
1058 if current_len == 0 {
1059 let mut split_idx = 0;
1061 let mut split_len = 0;
1062 for (idx, c) in token_str.char_indices() {
1063 let c_w = c.width().unwrap_or(0);
1064 if split_len + c_w > width {
1065 break;
1066 }
1067 split_idx = idx + c.len_utf8();
1068 split_len += c_w;
1069 }
1070 if split_idx == 0 {
1071 split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
1072 }
1073 if split_idx == 0 {
1074 break;
1075 }
1076
1077 current_line.push_str(&token_str[..split_idx]);
1078 lines.push(current_line);
1079 current_line = active_codes.join("");
1080 token_str = &token_str[split_idx..];
1081 token_len = UnicodeWidthStr::width(token_str);
1082 current_len = 0;
1083 } else if !token_str.trim().is_empty() {
1084 lines.push(current_line);
1085 current_line = active_codes.join("");
1086 current_len = 0;
1087 } else {
1088 token_str = "";
1089 token_len = 0;
1090 }
1091 }
1092 if !token_str.is_empty() {
1093 current_line.push_str(token_str);
1094 current_len += token_len;
1095 }
1096 }
1097 }
1098 if !current_line.is_empty() {
1099 lines.push(current_line);
1100 }
1101 lines
1102 }
1103
1104 fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
1105 if RE_OSC8.is_match(code) {
1106 return;
1107 }
1108 if let Some(caps) = RE_ANSI_PARTS.captures(code) {
1109 let content = caps.get(1).map_or("", |m| m.as_str());
1110 if content == "0" || content.is_empty() {
1111 state.clear();
1112 return;
1113 }
1114
1115 let num: i32 = content
1116 .split(';')
1117 .next()
1118 .unwrap_or("0")
1119 .parse()
1120 .unwrap_or(0);
1121 let category = match num {
1122 1 | 22 => "bold",
1123 3 | 23 => "italic",
1124 4 | 24 => "underline",
1125 30..=39 | 90..=97 => "fg",
1126 40..=49 | 100..=107 => "bg",
1127 _ => "other",
1128 };
1129 if category != "other" {
1130 state.retain(|exist| {
1131 let e_num: i32 = RE_ANSI_PARTS
1132 .captures(exist)
1133 .and_then(|c| c.get(1))
1134 .map_or("0", |m| m.as_str())
1135 .split(';')
1136 .next()
1137 .unwrap_or("0")
1138 .parse()
1139 .unwrap_or(0);
1140 let e_cat = match e_num {
1141 1 | 22 => "bold",
1142 3 | 23 => "italic",
1143 4 | 24 => "underline",
1144 30..=39 | 90..=97 => "fg",
1145 40..=49 | 100..=107 => "bg",
1146 _ => "other",
1147 };
1148 e_cat != category
1149 });
1150 }
1151 state.push(code.to_string());
1152 }
1153 }
1154
1155 fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
1156 self.commit_newline(w)?;
1157 let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
1158
1159 let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
1160
1161 let skip = raw_line
1163 .chars()
1164 .take(fence_indent)
1165 .take_while(|&c| c == ' ')
1166 .count();
1167 let line_content = &raw_line[skip..];
1168
1169 let mut prefix = " ".repeat(self.margin);
1170 if !self.list_context.is_empty() {
1171 let indent_width = self.list_context.structural_width();
1172 prefix.push_str(&" ".repeat(indent_width));
1173 }
1174
1175 let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
1176
1177 let mut spans = Vec::new();
1178 if let Some(h) = &mut self.highlighter {
1179 if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
1180 spans = ranges;
1181 } else {
1182 spans.push((syntect::highlighting::Style::default(), line_content));
1183 }
1184 } else {
1185 spans.push((syntect::highlighting::Style::default(), line_content));
1186 }
1187
1188 self.scratch_buffer.clear();
1190 for (style, text) in spans {
1191 let _ = write!(
1192 self.scratch_buffer,
1193 "\x1b[38;2;{};{};{}m{}",
1194 style.foreground.r, style.foreground.g, style.foreground.b, text
1195 );
1196 }
1197
1198 let content_width = self.visible_width(line_content);
1200
1201 if content_width <= avail_width {
1202 let pad = avail_width.saturating_sub(content_width);
1204 queue!(
1205 w,
1206 Print(&prefix),
1207 SetBackgroundColor(COLOR_CODE_BG),
1208 Print(&self.scratch_buffer),
1209 Print(" ".repeat(pad)),
1210 ResetColor
1211 )?;
1212 } else {
1213 let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
1215
1216 if wrapped_lines.is_empty() {
1217 queue!(
1218 w,
1219 Print(&prefix),
1220 SetBackgroundColor(COLOR_CODE_BG),
1221 Print(" ".repeat(avail_width)),
1222 ResetColor
1223 )?;
1224 } else {
1225 for (i, line) in wrapped_lines.iter().enumerate() {
1226 if i > 0 {
1227 queue!(w, Print("\n"))?;
1228 }
1229 let vis_len = self.visible_width(line);
1230 let pad = avail_width.saturating_sub(vis_len);
1231
1232 queue!(
1233 w,
1234 Print(&prefix),
1235 SetBackgroundColor(COLOR_CODE_BG),
1236 Print(line),
1237 Print(" ".repeat(pad)),
1238 ResetColor
1239 )?;
1240 }
1241 }
1242 }
1243 self.pending_newline = true;
1244 Ok(())
1245 }
1246
1247 fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
1248 self.flush_pending_inline(w)?;
1249 self.commit_newline(w)?;
1250 let term_width = self.get_width();
1251
1252 let trimmed_row = row_str.trim().trim_matches('|');
1253 let segments = parse_segments(trimmed_row, None);
1254 let cells = split_table_row(trimmed_row, &segments);
1255
1256 if cells.is_empty() {
1257 return Ok(());
1258 }
1259
1260 let prefix_width = self.margin + (self.blockquote_depth * 2);
1261 let cell_overhead = (cells.len() * 3).saturating_sub(1);
1262 let avail = term_width.saturating_sub(prefix_width + self.margin + cell_overhead);
1263 if avail == 0 {
1264 return Ok(());
1265 }
1266 let base_w = avail / cells.len();
1267 let rem = avail % cells.len();
1268
1269 let bg = if !self.table_header_printed {
1270 Color::Rgb {
1271 r: 60,
1272 g: 60,
1273 b: 80,
1274 }
1275 } else {
1276 COLOR_CODE_BG
1277 };
1278 let mut wrapped_cells = Vec::new();
1279 let mut max_h = 1;
1280
1281 for (i, cell) in cells.iter().enumerate() {
1282 let width = std::cmp::max(
1283 1,
1284 if i == cells.len() - 1 {
1285 base_w + rem
1286 } else {
1287 base_w
1288 },
1289 );
1290 self.scratch_buffer.clear();
1291 if !self.table_header_printed {
1292 self.scratch_buffer.push_str("\x1b[1;33m");
1293 }
1294 self.render_inline(
1295 cell.trim(),
1296 Some(bg),
1297 if !self.table_header_printed {
1298 Some("\x1b[1;33m")
1299 } else {
1300 None
1301 },
1302 );
1303 if !self.table_header_printed {
1304 self.scratch_buffer.push_str("\x1b[0m");
1305 }
1306
1307 let lines = self.wrap_ansi(&self.scratch_buffer, width);
1308 if lines.len() > max_h {
1309 max_h = lines.len();
1310 }
1311 wrapped_cells.push((lines, width));
1312 }
1313
1314 let prefix = self.build_block_prefix();
1315
1316 for i in 0..max_h {
1317 if i > 0 {
1318 queue!(w, Print("\n"))?;
1319 }
1320 queue!(w, Print(&prefix))?;
1321 for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
1322 let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
1323 let pad = width.saturating_sub(self.visible_width(text));
1324 queue!(
1325 w,
1326 SetBackgroundColor(bg),
1327 Print(" "),
1328 Print(text),
1329 SetBackgroundColor(bg),
1330 Print(" ".repeat(pad + 1)),
1331 ResetColor
1332 )?;
1333 if col < cells.len() - 1 {
1334 queue!(
1335 w,
1336 SetBackgroundColor(bg),
1337 SetForegroundColor(Color::White),
1338 Print("│"),
1339 ResetColor
1340 )?;
1341 }
1342 }
1343 }
1344 self.pending_newline = true;
1345 Ok(())
1346 }
1347
1348 pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
1349 let mut parts = self.build_inline_parts(text, def_bg, restore_fg);
1350 self.resolve_delimiters(&mut parts);
1351
1352 for part in parts {
1353 for s in &part.pre_style {
1354 self.scratch_buffer.push_str(s);
1355 }
1356 self.scratch_buffer.push_str(&part.content());
1357 for s in &part.post_style {
1358 self.scratch_buffer.push_str(s);
1359 }
1360 }
1361 }
1362
1363 fn build_inline_parts(
1364 &mut self,
1365 text: &str,
1366 def_bg: Option<Color>,
1367 restore_fg: Option<&str>,
1368 ) -> Vec<InlinePart> {
1369 let active_ticks = self.inline_code.ticks;
1370 let segments = parse_segments(text, active_ticks);
1371 let mut parts: Vec<InlinePart> = Vec::new();
1372
1373 for seg in &segments {
1374 match seg {
1375 ParsedSegment::CodeSpan {
1376 range,
1377 delimiter_len,
1378 } => {
1379 let n = *delimiter_len;
1380 let content_range = range.start + n..range.end - n;
1381 let raw_content = &text[content_range];
1382 let normalized = InlineCodeState::normalize_content_static(raw_content);
1383 let formatted =
1384 self.format_inline_code_content(&normalized, def_bg, restore_fg);
1385 parts.push(InlinePart::text(formatted));
1386 }
1387 ParsedSegment::CodeSpanOpener {
1388 range: _,
1389 delimiter_len,
1390 } => {
1391 self.inline_code.open(*delimiter_len);
1392 }
1393 ParsedSegment::CodeSpanContent(range) => {
1394 self.inline_code.push_content(&text[range.clone()]);
1395 }
1396 ParsedSegment::CodeSpanCloser {
1397 range: _,
1398 delimiter_len: _,
1399 } => {
1400 let content = self.inline_code.close();
1401 let formatted = self.format_inline_code_content(&content, def_bg, restore_fg);
1402 parts.push(InlinePart::text(formatted));
1403 }
1404 ParsedSegment::Escape(r) => {
1405 parts.push(InlinePart::text(text[r.start + 1..r.end].to_string()));
1406 }
1407 ParsedSegment::Math(r) => {
1408 let tok = &text[r.clone()];
1409 if tok.len() > 1 && tok.starts_with('$') && tok.ends_with('$') {
1410 parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
1411 } else {
1412 parts.push(InlinePart::text(tok.to_string()));
1413 }
1414 }
1415 ParsedSegment::Autolink(r) => {
1416 let url = &text[r.start + 1..r.end - 1];
1417 parts.push(InlinePart::text(format!(
1418 "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
1419 url, url
1420 )));
1421 }
1422 ParsedSegment::Link(r) => {
1423 if let Some(caps) = RE_LINK.captures(&text[r.clone()]) {
1424 let link_text = caps.get(1).map_or("", |m| m.as_str());
1425 let url = caps.get(2).map_or("", |m| m.as_str());
1426 parts.push(InlinePart::text(format!(
1427 "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
1428 url, link_text
1429 )));
1430 }
1431 }
1432 ParsedSegment::Ansi(r) => {
1433 parts.push(InlinePart::text(text[r.clone()].to_string()));
1434 }
1435 ParsedSegment::Delim(r) => {
1436 let tok = &text[r.clone()];
1437 let c = tok.chars().next().unwrap();
1438
1439 let prev_char = if r.start > 0 {
1440 text[..r.start].chars().last().unwrap_or(' ')
1441 } else {
1442 ' '
1443 };
1444 let next_char = text[r.end..].chars().next().unwrap_or(' ');
1445
1446 let is_ws_next = next_char.is_whitespace();
1447 let is_ws_prev = prev_char.is_whitespace();
1448 let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
1449 let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
1450 let left_flanking =
1451 !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
1452 let right_flanking =
1453 !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
1454
1455 let (can_open, can_close) = if c == '_' {
1456 (
1457 left_flanking && (!right_flanking || is_punct_prev),
1458 right_flanking && (!left_flanking || is_punct_next),
1459 )
1460 } else {
1461 (left_flanking, right_flanking)
1462 };
1463
1464 parts.push(InlinePart::delimiter(c, tok.len(), can_open, can_close));
1465 }
1466 ParsedSegment::Text(r) => {
1467 parts.push(InlinePart::text(text[r.clone()].to_string()));
1468 }
1469 }
1470 }
1471
1472 parts
1473 }
1474
1475 fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1476 let mut stack: Vec<usize> = Vec::new();
1477
1478 for i in 0..parts.len() {
1479 if !parts[i].is_delim() {
1480 continue;
1481 }
1482
1483 if parts[i].can_close() {
1484 let mut stack_idx = stack.len();
1485 while stack_idx > 0 {
1486 let open_pos = stack_idx - 1;
1487 let open_idx = stack[open_pos];
1488
1489 if parts[open_idx].delim_char() == parts[i].delim_char()
1490 && parts[open_idx].can_open()
1491 {
1492 if (parts[open_idx].can_open() && parts[open_idx].can_close())
1494 || (parts[i].can_open() && parts[i].can_close())
1495 {
1496 let sum = parts[open_idx].delim_len() + parts[i].delim_len();
1497 if sum.is_multiple_of(3)
1498 && (!parts[open_idx].delim_len().is_multiple_of(3)
1499 || !parts[i].delim_len().is_multiple_of(3))
1500 {
1501 stack_idx -= 1;
1502 continue;
1503 }
1504 }
1505
1506 if open_idx + 1 == i {
1508 stack_idx -= 1;
1509 continue;
1510 }
1511
1512 let open_len = parts[open_idx].delim_len();
1514 let close_len = parts[i].delim_len();
1515 let use_len = if close_len == 3 && open_len == 3 {
1516 1
1517 } else if close_len >= 2 && open_len >= 2 {
1518 2
1519 } else {
1520 1
1521 };
1522
1523 let (style_on, style_off) = match (parts[open_idx].delim_char(), use_len) {
1524 ('~', _) => ("\x1b[9m", "\x1b[29m"),
1525 ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1526 (_, 1) => ("\x1b[3m", "\x1b[23m"),
1527 (_, 2) => ("\x1b[1m", "\x1b[22m"),
1528 _ => ("", ""),
1529 };
1530
1531 if use_len == 1 {
1533 parts[open_idx].pre_style.push(style_on.to_string());
1534 parts[i].post_style.push(style_off.to_string());
1535 } else {
1536 parts[open_idx].post_style.push(style_on.to_string());
1537 parts[i].pre_style.push(style_off.to_string());
1538 }
1539
1540 parts[open_idx].consume(use_len);
1542 parts[i].consume(use_len);
1543
1544 if parts[open_idx].delim_len() == 0 {
1546 stack.remove(open_pos);
1547 stack_idx -= 1;
1548 }
1549
1550 if parts[i].delim_len() == 0 {
1551 break;
1552 }
1553 } else {
1554 stack_idx -= 1;
1555 }
1556 }
1557 }
1558
1559 if parts[i].delim_len() > 0 && parts[i].can_open() {
1560 stack.push(i);
1561 }
1562 }
1563 }
1564
1565 fn build_block_prefix(&self) -> String {
1566 let mut prefix = " ".repeat(self.margin);
1567 if self.blockquote_depth > 0 {
1568 prefix.push_str(STYLE_BLOCKQUOTE);
1569 for _ in 0..self.blockquote_depth {
1570 prefix.push_str("│ ");
1571 }
1572 prefix.push_str(STYLE_RESET);
1573 }
1574 prefix
1575 }
1576
1577 fn format_inline_code_content(
1578 &self,
1579 content: &str,
1580 def_bg: Option<Color>,
1581 restore_fg: Option<&str>,
1582 ) -> String {
1583 let mut out = String::new();
1584 let _ = write!(out, "{}{}", STYLE_INLINE_CODE, content);
1585 if let Some(Color::Rgb { r, g, b }) = def_bg {
1586 let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1587 } else {
1588 out.push_str(STYLE_RESET_BG);
1589 }
1590 out.push_str(restore_fg.unwrap_or(STYLE_RESET_FG));
1591 out
1592 }
1593
1594 fn expand_tabs(&self, line: &str) -> String {
1595 let mut expanded = String::with_capacity(line.len());
1596 let mut col = 0;
1597 for c in line.chars() {
1598 if c == '\t' {
1599 let n = 4 - (col % 4);
1600 expanded.push_str(&" ".repeat(n));
1601 col += n;
1602 } else {
1603 expanded.push(c);
1604 col += UnicodeWidthChar::width(c).unwrap_or(0);
1605 }
1606 }
1607 expanded
1608 }
1609
1610 pub fn classify_line(&self, expanded: &str) -> ClassifiedLine {
1611 let trimmed = expanded.trim_end();
1612
1613 if let Some((f_char, min_len, _indent)) = self.active_fence {
1617 if let Some(caps) = RE_CODE_FENCE.captures(trimmed) {
1618 let fence = &caps[2];
1619 if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
1620 {
1621 return ClassifiedLine {
1622 blockquote_depth: 0,
1623 content: expanded.to_string(),
1624 kind: BlockKind::FenceClose,
1625 };
1626 }
1627 }
1628 return ClassifiedLine {
1629 blockquote_depth: 0,
1630 content: expanded.to_string(),
1631 kind: BlockKind::FenceContent,
1632 };
1633 }
1634
1635 if self.in_math_block {
1637 if RE_MATH_BLOCK.is_match(trimmed) {
1638 return ClassifiedLine {
1639 blockquote_depth: 0,
1640 content: expanded.to_string(),
1641 kind: BlockKind::MathClose,
1642 };
1643 }
1644 return ClassifiedLine {
1645 blockquote_depth: 0,
1646 content: expanded.to_string(),
1647 kind: BlockKind::MathContent,
1648 };
1649 }
1650
1651 if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
1653 return ClassifiedLine {
1654 blockquote_depth: 0,
1655 content: expanded.to_string(),
1656 kind: BlockKind::TableSeparator,
1657 };
1658 }
1659
1660 if RE_TABLE_ROW.is_match(trimmed) {
1662 return ClassifiedLine {
1663 blockquote_depth: 0,
1664 content: expanded.to_string(),
1665 kind: BlockKind::TableRow,
1666 };
1667 }
1668
1669 let mut content = expanded.to_string();
1671 let mut blockquote_depth = 0;
1672 loop {
1673 let trimmed_content = content.clone();
1674 if let Some(caps) = RE_BLOCKQUOTE.captures(&trimmed_content) {
1675 blockquote_depth += 1;
1676 content = caps.get(2).map_or("", |m| m.as_str()).to_string();
1677 } else {
1678 break;
1679 }
1680 }
1681
1682 let clean = content.trim_end();
1683
1684 if let Some(caps) = RE_CODE_FENCE.captures(clean) {
1688 let fence = &caps[2];
1689 let indent_len = caps[1].len();
1690 let info = caps[3].trim();
1691 if let Some(f_char) = fence.chars().next()
1692 && (f_char != '`' || !info.contains('`'))
1693 {
1694 let lang = info.split_whitespace().next().unwrap_or("bash").to_string();
1695 return ClassifiedLine {
1696 blockquote_depth,
1697 content: content.clone(),
1698 kind: BlockKind::FenceOpen {
1699 fence_char: f_char,
1700 fence_len: fence.len(),
1701 indent: indent_len,
1702 lang,
1703 },
1704 };
1705 }
1706 }
1707
1708 if RE_MATH_BLOCK.is_match(clean) {
1710 return ClassifiedLine {
1711 blockquote_depth,
1712 content: content.clone(),
1713 kind: BlockKind::MathOpen,
1714 };
1715 }
1716
1717 if let Some(caps) = RE_HEADER.captures(clean) {
1719 let level = caps.get(1).map_or(0, |m| m.len());
1720 let raw_text = caps.get(2).map_or("", |m| m.as_str());
1721 let text = Self::clean_atx_header_text_static(raw_text).to_string();
1722 return ClassifiedLine {
1723 blockquote_depth,
1724 content: content.clone(),
1725 kind: BlockKind::Header { level, text },
1726 };
1727 }
1728
1729 {
1731 let stripped = clean.trim_start();
1732 let hash_count = stripped.chars().take_while(|&c| c == '#').count();
1733 if (1..=6).contains(&hash_count) {
1734 let after_hashes = &stripped[hash_count..];
1735 if after_hashes.is_empty() || after_hashes.chars().all(|c| c.is_whitespace()) {
1736 return ClassifiedLine {
1737 blockquote_depth,
1738 content: content.clone(),
1739 kind: BlockKind::Header {
1740 level: hash_count,
1741 text: "".to_string(),
1742 },
1743 };
1744 }
1745 }
1746 }
1747
1748 if RE_HR.is_match(clean) {
1750 let hr_chars: Vec<char> = clean.chars().filter(|c| !c.is_whitespace()).collect();
1751 if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1752 return ClassifiedLine {
1753 blockquote_depth,
1754 content: content.clone(),
1755 kind: BlockKind::ThematicBreak,
1756 };
1757 }
1758 }
1759
1760 if let Some(caps) = RE_LIST.captures(clean) {
1762 let indent = caps.get(1).map_or(0, |m| m.len());
1763 let marker = caps.get(2).map_or("", |m| m.as_str()).to_string();
1764 let separator = caps.get(3).map_or(" ", |m| m.as_str()).to_string();
1765 let content_text = caps.get(4).map_or("", |m| m.as_str()).to_string();
1766 let is_ordered = marker.chars().any(|c| c.is_numeric());
1767
1768 if !content_text.is_empty() {
1771 let ct = content_text.trim_end();
1772 if RE_HR.is_match(ct) {
1773 let hr_chars: Vec<char> = ct.chars().filter(|c| !c.is_whitespace()).collect();
1774 if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1775 return ClassifiedLine {
1776 blockquote_depth,
1777 content: content.clone(),
1778 kind: BlockKind::ThematicBreak,
1779 };
1780 }
1781 }
1782 }
1783
1784 return ClassifiedLine {
1785 blockquote_depth,
1786 content: content.clone(),
1787 kind: BlockKind::ListItem {
1788 indent,
1789 marker,
1790 separator,
1791 content: content_text,
1792 is_ordered,
1793 },
1794 };
1795 }
1796
1797 if clean.is_empty() {
1799 return ClassifiedLine {
1800 blockquote_depth,
1801 content,
1802 kind: BlockKind::BlankLine,
1803 };
1804 }
1805
1806 ClassifiedLine {
1808 blockquote_depth,
1809 content,
1810 kind: BlockKind::Paragraph,
1811 }
1812 }
1813
1814 fn clean_atx_header_text_static(text: &str) -> &str {
1815 let trimmed = text.trim_end();
1816 let mut end = trimmed.len();
1817 let bytes = trimmed.as_bytes();
1818 while end > 0 && bytes[end - 1] == b'#' {
1819 end -= 1;
1820 }
1821 if end == 0 {
1822 ""
1824 } else if end < trimmed.len() && bytes[end - 1] == b' ' {
1825 trimmed[..end - 1].trim_end()
1827 } else {
1828 trimmed
1830 }
1831 }
1832
1833 fn start_highlighter(&mut self, lang: &str) {
1834 let ss = &*SYNTAX_SET;
1835 let syntax = ss
1836 .find_syntax_by_token(lang)
1837 .unwrap_or_else(|| ss.find_syntax_plain_text());
1838 self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1839 }
1840}