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 let rest = &text[pos..];
441 let offset = pos;
442 let mut it = RE_OPAQUE.captures_iter(rest).peekable();
443 let mut last_match_end = 0;
444
445 while let Some(caps) = it.next() {
446 let m = caps.get(0).unwrap();
447 if m.start() > last_match_end {
448 segments.push(ParsedSegment::Text(
449 offset + last_match_end..offset + m.start(),
450 ));
451 }
452
453 let start = offset + m.start();
454 let mut end = offset + m.end();
455
456 if let Some(code) = caps.name("code") {
457 let ticks = code.as_str();
458 let n = ticks.len();
459 let search_start = m.end();
460 if let Some(close_idx) = find_backtick_closer(&rest[search_start..], n) {
461 end = offset + search_start + close_idx + n;
462 while it
463 .peek()
464 .is_some_and(|next| offset + next.get(0).unwrap().start() < end)
465 {
466 it.next();
467 }
468 segments.push(ParsedSegment::CodeSpan {
469 range: start..end,
470 delimiter_len: n,
471 });
472 last_match_end = search_start + close_idx + n;
473 continue;
474 } else {
475 segments.push(ParsedSegment::CodeSpanOpener {
476 range: start..end,
477 delimiter_len: n,
478 });
479 if end < text.len() {
480 segments.push(ParsedSegment::CodeSpanContent(end..text.len()));
481 }
482 return segments;
483 }
484 } else if caps.name("link").is_some() {
485 segments.push(ParsedSegment::Link(start..end));
486 } else if caps.name("autolink").is_some() {
487 segments.push(ParsedSegment::Autolink(start..end));
488 } else if caps.name("math").is_some() {
489 segments.push(ParsedSegment::Math(start..end));
490 } else if caps.name("escape").is_some() {
491 segments.push(ParsedSegment::Escape(start..end));
492 } else if caps.name("ansi").is_some() {
493 segments.push(ParsedSegment::Ansi(start..end));
494 } else if caps.name("delim").is_some() {
495 segments.push(ParsedSegment::Delim(start..end));
496 }
497 last_match_end = m.end();
498 }
499
500 if offset + last_match_end < text.len() {
501 segments.push(ParsedSegment::Text(offset + last_match_end..text.len()));
502 }
503 segments
504}
505
506fn split_table_row<'a>(text: &'a str, segments: &[ParsedSegment]) -> Vec<&'a str> {
507 let mut cells = Vec::new();
508 let mut start = 0;
509
510 for seg in segments {
511 if let ParsedSegment::Text(range) = seg {
512 for (i, c) in text[range.clone()].char_indices() {
513 if c == '|' {
514 cells.push(&text[start..range.start + i]);
515 start = range.start + i + 1;
516 }
517 }
518 }
519 }
520 cells.push(&text[start..]);
521 cells
522}
523
524pub struct MarkdownStreamer {
525 active_fence: Option<(char, usize, usize)>, code_lang: String,
528
529 inline_code: InlineCodeState,
531
532 in_math_block: bool,
534 math_buffer: String,
535
536 in_table: bool,
538 table_header_printed: bool,
539
540 highlighter: Option<HighlightLines<'static>>,
542 line_buffer: String,
543
544 margin: usize,
546 blockquote_depth: usize,
547 list_context: ListContext,
548 pending_newline: bool,
549
550 manual_width: Option<usize>,
552
553 scratch_buffer: String,
555}
556
557impl Default for MarkdownStreamer {
558 fn default() -> Self {
559 Self::new()
560 }
561}
562
563impl MarkdownStreamer {
564 pub fn new() -> Self {
565 Self {
566 active_fence: None,
567 code_lang: "bash".to_string(),
568 inline_code: InlineCodeState::new(),
569 in_math_block: false,
570 math_buffer: String::new(),
571 in_table: false,
572 table_header_printed: false,
573 highlighter: None,
574 line_buffer: String::new(),
575 margin: 2,
576 blockquote_depth: 0,
577 list_context: ListContext::new(),
578 pending_newline: false,
579 manual_width: None,
580 scratch_buffer: String::with_capacity(1024),
581 }
582 }
583
584 pub fn set_width(&mut self, width: usize) {
586 self.manual_width = Some(width);
587 }
588
589 pub fn set_margin(&mut self, margin: usize) {
591 self.margin = margin;
592 }
593
594 fn get_width(&self) -> usize {
595 self.manual_width
596 .unwrap_or_else(crate::console::get_terminal_width)
597 }
598
599 fn visible_width(&self, text: &str) -> usize {
600 UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
601 }
602
603 pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
605 self.line_buffer.push_str(text);
606 while let Some(pos) = self.line_buffer.find('\n') {
607 let line = self.line_buffer[..pos + 1].to_string();
608 self.line_buffer.drain(..pos + 1);
609 self.process_line(writer, &line)?;
610 }
611 Ok(())
612 }
613
614 pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
616 if !self.line_buffer.is_empty() {
617 let line = std::mem::take(&mut self.line_buffer);
618 self.process_line(writer, &line)?;
619 }
620
621 self.flush_pending_inline(writer)?;
622 self.commit_newline(writer)?;
623 writer.flush()
624 }
625
626 fn commit_newline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
627 if self.pending_newline {
628 queue!(writer, Print("\n"))?;
629 self.pending_newline = false;
630 }
631 Ok(())
632 }
633
634 fn flush_pending_inline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
635 if let Some((ticks, buffer)) = self.inline_code.flush_incomplete() {
636 let prefix = "`".repeat(ticks);
637 let formatted =
638 self.format_inline_code_content(&format!("{}{}", prefix, buffer), None, None);
639 queue!(writer, Print(formatted))?;
640 self.inline_code.reset();
641 }
642 Ok(())
643 }
644
645 fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
647 let expanded = self.expand_tabs(raw_line);
648 let classified = self.classify_line(&expanded);
649
650 self.blockquote_depth = classified.blockquote_depth;
651 self.update_block_state(&classified);
652
653 match classified.kind {
654 BlockKind::FenceOpen { lang, .. } => {
655 self.render_fence_open(w, &lang)?;
656 }
657 BlockKind::FenceClose => {
658 self.render_fence_close(w)?;
659 }
660 BlockKind::FenceContent => {
661 self.render_code_line(w, &expanded)?;
662 }
663 BlockKind::MathOpen => {
664 self.render_math_open(w)?;
665 }
666 BlockKind::MathClose => {
667 self.render_math_close(w)?;
668 }
669 BlockKind::MathContent => {
670 let trimmed = expanded.trim_end();
671 self.render_math_content(trimmed);
672 }
673 BlockKind::TableSeparator => {
674 }
676 BlockKind::TableRow => {
677 let trimmed = expanded.trim_end();
678 self.render_stream_table_row(w, trimmed)?;
679 }
680 BlockKind::Header { level, text } => {
681 let layout = self.compute_block_layout();
682 self.render_header(w, level, &text, &layout)?;
683 }
684 BlockKind::ThematicBreak => {
685 let layout = self.compute_block_layout();
686 self.render_thematic_break(w, &layout)?;
687 }
688 BlockKind::ListItem {
689 indent,
690 marker,
691 separator,
692 content,
693 is_ordered,
694 } => {
695 let layout = self.compute_block_layout();
696 self.render_list_item(
697 w, indent, &marker, &separator, &content, is_ordered, &layout,
698 )?;
699 }
700 BlockKind::BlankLine | BlockKind::Paragraph => {
701 let layout = self.compute_block_layout();
702 self.render_standard_text(w, &classified.content, &layout)?;
703 }
704 }
705 Ok(())
706 }
707
708 fn render_fence_open<W: Write>(&mut self, w: &mut W, lang: &str) -> io::Result<()> {
711 self.flush_pending_inline(w)?;
712 self.commit_newline(w)?;
713 self.start_highlighter(lang);
714 Ok(())
715 }
716
717 fn render_fence_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
718 self.commit_newline(w)?;
719 queue!(w, ResetColor)?;
720 self.pending_newline = true;
721 Ok(())
722 }
723
724 fn render_math_open<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
725 self.flush_pending_inline(w)?;
726 self.commit_newline(w)?;
727 Ok(())
728 }
729
730 fn render_math_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
731 let converted = unicodeit::replace(&self.math_buffer);
732 let p_width = self.margin + (self.blockquote_depth * 2);
733 let avail = self.get_width().saturating_sub(p_width + self.margin);
734 let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
735
736 self.commit_newline(w)?;
737 queue!(
738 w,
739 Print(" ".repeat(self.margin + padding)),
740 Print(STYLE_MATH),
741 Print(converted),
742 Print(STYLE_RESET)
743 )?;
744 self.pending_newline = true;
745 self.math_buffer.clear();
746 Ok(())
747 }
748
749 fn render_math_content(&mut self, trimmed: &str) {
750 self.math_buffer.push_str(trimmed);
751 self.math_buffer.push(' ');
752 }
753
754 fn compute_block_layout(&self) -> BlockLayout {
755 let prefix = self.build_block_prefix();
756 let term_width = self.get_width();
757 let prefix_width = self.margin + (self.blockquote_depth * 2);
758 let avail_width = term_width.saturating_sub(prefix_width + self.margin);
759 BlockLayout {
760 prefix,
761 avail_width,
762 }
763 }
764
765 fn render_header<W: Write>(
766 &mut self,
767 w: &mut W,
768 level: usize,
769 text: &str,
770 layout: &BlockLayout,
771 ) -> io::Result<()> {
772 self.flush_pending_inline(w)?;
773 self.commit_newline(w)?;
774
775 let prefix = &layout.prefix;
776 let avail = layout.avail_width;
777
778 queue!(w, Print(prefix))?;
779 if level <= 2 {
780 queue!(w, Print("\n"))?;
781 }
782
783 self.scratch_buffer.clear();
784 let style = match level {
785 1 => STYLE_H1,
786 2 => STYLE_H2,
787 3 => STYLE_H3,
788 _ => STYLE_H_DEFAULT,
789 };
790 self.render_inline(text, None, Some(style));
791
792 if level <= 2 {
793 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
794 for (i, line) in lines.iter().enumerate() {
795 let pad = avail.saturating_sub(self.visible_width(line)) / 2;
796 if i > 0 {
797 queue!(w, Print("\n"), Print(prefix))?;
798 }
799 queue!(
800 w,
801 Print(" ".repeat(pad)),
802 Print(format!("{}{}{}", style, line, STYLE_RESET)),
803 ResetColor
804 )?;
805 }
806 if level == 1 {
807 queue!(w, Print("\n"), Print(prefix), Print("─".repeat(avail)))?;
808 }
809 self.pending_newline = true;
810 } else {
811 queue!(
812 w,
813 Print(style),
814 Print(&self.scratch_buffer),
815 Print(STYLE_RESET)
816 )?;
817 self.pending_newline = true;
818 }
819 Ok(())
820 }
821
822 #[allow(clippy::too_many_arguments)]
823 fn render_list_item<W: Write>(
824 &mut self,
825 w: &mut W,
826 indent: usize,
827 marker: &str,
828 separator: &str,
829 text: &str,
830 is_ordered: bool,
831 layout: &BlockLayout,
832 ) -> io::Result<()> {
833 let prefix = &layout.prefix;
834 let avail = layout.avail_width;
835
836 self.flush_pending_inline(w)?;
837 self.commit_newline(w)?;
838
839 let disp_bullet = if is_ordered { marker } else { "•" };
840 let marker_width = self.visible_width(disp_bullet) + separator.len();
841
842 let last_indent = self.list_context.last_indent().unwrap_or(0);
843 if self.list_context.is_empty() || indent > last_indent {
844 self.list_context.push(indent, marker_width);
845 } else if indent < last_indent {
846 self.list_context.pop_to_indent(indent);
847 if self.list_context.last_indent().is_some_and(|d| d != indent) {
848 self.list_context.push(indent, marker_width);
849 }
850 } else {
851 self.list_context.update_last_marker_width(marker_width);
852 }
853
854 let full_stack_width = self.list_context.structural_width();
855 let parent_width = self.list_context.parent_width();
856
857 let hang_indent = " ".repeat(full_stack_width);
858 let content_width = avail.saturating_sub(full_stack_width);
859
860 queue!(
861 w,
862 Print(prefix),
863 Print(" ".repeat(parent_width)),
864 Print(STYLE_LIST_BULLET),
865 Print(disp_bullet),
866 Print(STYLE_RESET),
867 Print(separator)
868 )?;
869
870 if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
871 queue!(w, Print("\n"))?;
872
873 let fence_chars = &fcaps[2];
874 let info = fcaps[3].trim();
875
876 if let Some(f_char) = fence_chars.chars().next() {
877 self.active_fence = Some((f_char, fence_chars.len(), 0));
878
879 let lang = info.split_whitespace().next().unwrap_or("bash");
880 self.code_lang = lang.to_string();
881 self.start_highlighter(&self.code_lang.clone());
882 }
883 return Ok(());
884 }
885
886 self.scratch_buffer.clear();
887 self.render_inline(text, None, None);
888 let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
889
890 if lines.is_empty() {
891 self.pending_newline = true;
892 } else {
893 for (i, line) in lines.iter().enumerate() {
894 if i > 0 {
895 queue!(w, Print("\n"), Print(prefix), Print(&hang_indent))?;
896 }
897 queue!(w, Print(line), ResetColor)?;
898 }
899 self.pending_newline = true;
900 }
901 Ok(())
902 }
903
904 fn render_thematic_break<W: Write>(
905 &mut self,
906 w: &mut W,
907 layout: &BlockLayout,
908 ) -> io::Result<()> {
909 let prefix = &layout.prefix;
910 let avail = layout.avail_width;
911
912 self.flush_pending_inline(w)?;
913 self.commit_newline(w)?;
914 queue!(
915 w,
916 Print(prefix),
917 SetForegroundColor(Color::DarkGrey),
918 Print("─".repeat(avail)),
919 ResetColor
920 )?;
921 self.pending_newline = true;
922 Ok(())
923 }
924
925 fn render_standard_text<W: Write>(
926 &mut self,
927 w: &mut W,
928 content: &str,
929 layout: &BlockLayout,
930 ) -> io::Result<()> {
931 let prefix = &layout.prefix;
932 let avail = layout.avail_width;
933
934 self.commit_newline(w)?;
935 let mut line_content = content.trim_end_matches(['\n', '\r']);
936 if line_content.trim().is_empty() {
937 self.exit_block_context();
938 if self.blockquote_depth > 0 {
939 queue!(w, Print(prefix))?;
940 }
941 self.pending_newline = true;
942 return Ok(());
943 }
944
945 if !line_content.is_empty() || self.inline_code.is_active() {
946 let mut eff_prefix = self.build_block_prefix();
947 if !self.list_context.is_empty() {
948 let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
949 if current_indent == 0 {
950 self.list_context.clear();
951 } else {
952 self.list_context.pop_to_indent(current_indent);
953 }
954
955 if !self.list_context.is_empty() {
956 let structural_indent = self.list_context.structural_width();
957 eff_prefix.push_str(&" ".repeat(structural_indent));
958
959 let skip = current_indent.min(structural_indent);
962 line_content = &line_content[skip..];
963 }
964 }
965
966 self.scratch_buffer.clear();
967 if self.inline_code.is_active() {
968 self.inline_code.append_space();
969 }
970 self.render_inline(line_content, None, None);
971
972 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
973 let has_visible_content = self.visible_width(&self.scratch_buffer) > 0;
974
975 for (i, line) in lines.iter().enumerate() {
976 if i > 0 {
977 queue!(w, Print("\n"))?;
978 }
979 queue!(
980 w,
981 ResetColor,
982 SetAttribute(Attribute::Reset),
983 Print(&eff_prefix),
984 Print(line),
985 ResetColor
986 )?;
987 }
988 if !lines.is_empty() && has_visible_content {
989 self.pending_newline = true;
990 }
991 }
992 Ok(())
993 }
994
995 fn update_block_state(&mut self, classified: &ClassifiedLine) {
996 match &classified.kind {
997 BlockKind::FenceOpen {
998 fence_char,
999 fence_len,
1000 indent,
1001 lang,
1002 } => {
1003 self.list_context.pop_to_indent(*indent);
1004 self.active_fence = Some((*fence_char, *fence_len, *indent));
1005 self.code_lang = lang.clone();
1006 }
1007 BlockKind::FenceClose => {
1008 self.active_fence = None;
1009 }
1010 BlockKind::FenceContent => {}
1011 BlockKind::MathOpen => {
1012 self.exit_block_context();
1013 self.in_math_block = true;
1014 }
1015 BlockKind::MathClose => {
1016 self.in_math_block = false;
1017 }
1018 BlockKind::MathContent => {}
1019 BlockKind::TableSeparator => {
1020 self.table_header_printed = true;
1021 }
1022 BlockKind::TableRow => {
1023 if !self.in_table {
1024 self.exit_block_context();
1025 self.in_table = true;
1026 }
1027 }
1028 BlockKind::Header { .. } => {
1029 self.exit_block_context();
1030 }
1031 BlockKind::ThematicBreak => {
1032 self.exit_block_context();
1033 }
1034 BlockKind::ListItem { .. } => {
1035 self.in_table = false;
1036 self.table_header_printed = false;
1037 }
1038 BlockKind::BlankLine | BlockKind::Paragraph => {
1039 self.in_table = false;
1040 self.table_header_printed = false;
1041 }
1042 }
1043 }
1044
1045 fn exit_block_context(&mut self) {
1046 self.list_context.clear();
1047 self.in_table = false;
1048 self.table_header_printed = false;
1049 }
1050
1051 fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
1052 let mut lines = Vec::new();
1053 let mut current_line = String::new();
1054 let mut current_len = 0;
1055 let mut active_codes: Vec<String> = Vec::new();
1056
1057 for caps in RE_SPLIT_ANSI.captures_iter(text) {
1058 let token = caps.get(1).unwrap().as_str();
1059 if token.starts_with("\x1b") {
1060 current_line.push_str(token);
1061 self.update_ansi_state(&mut active_codes, token);
1062 } else {
1063 let mut token_str = token;
1064 let mut token_len = UnicodeWidthStr::width(token_str);
1065
1066 while current_len + token_len > width && width > 0 {
1067 if current_len == 0 {
1068 let mut split_idx = 0;
1070 let mut split_len = 0;
1071 for (idx, c) in token_str.char_indices() {
1072 let c_w = c.width().unwrap_or(0);
1073 if split_len + c_w > width {
1074 break;
1075 }
1076 split_idx = idx + c.len_utf8();
1077 split_len += c_w;
1078 }
1079 if split_idx == 0 {
1080 split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
1081 }
1082 if split_idx == 0 {
1083 break;
1084 }
1085
1086 current_line.push_str(&token_str[..split_idx]);
1087 lines.push(current_line);
1088 current_line = active_codes.join("");
1089 token_str = &token_str[split_idx..];
1090 token_len = UnicodeWidthStr::width(token_str);
1091 current_len = 0;
1092 } else if !token_str.trim().is_empty() {
1093 lines.push(current_line);
1094 current_line = active_codes.join("");
1095 current_len = 0;
1096 } else {
1097 token_str = "";
1098 token_len = 0;
1099 }
1100 }
1101 if !token_str.is_empty() {
1102 current_line.push_str(token_str);
1103 current_len += token_len;
1104 }
1105 }
1106 }
1107 if !current_line.is_empty() {
1108 lines.push(current_line);
1109 }
1110 lines
1111 }
1112
1113 fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
1114 if RE_OSC8.is_match(code) {
1115 return;
1116 }
1117 if let Some(caps) = RE_ANSI_PARTS.captures(code) {
1118 let content = caps.get(1).map_or("", |m| m.as_str());
1119 if content == "0" || content.is_empty() {
1120 state.clear();
1121 return;
1122 }
1123
1124 let num: i32 = content
1125 .split(';')
1126 .next()
1127 .unwrap_or("0")
1128 .parse()
1129 .unwrap_or(0);
1130 let category = match num {
1131 1 | 22 => "bold",
1132 3 | 23 => "italic",
1133 4 | 24 => "underline",
1134 30..=39 | 90..=97 => "fg",
1135 40..=49 | 100..=107 => "bg",
1136 _ => "other",
1137 };
1138 if category != "other" {
1139 state.retain(|exist| {
1140 let e_num: i32 = RE_ANSI_PARTS
1141 .captures(exist)
1142 .and_then(|c| c.get(1))
1143 .map_or("0", |m| m.as_str())
1144 .split(';')
1145 .next()
1146 .unwrap_or("0")
1147 .parse()
1148 .unwrap_or(0);
1149 let e_cat = match e_num {
1150 1 | 22 => "bold",
1151 3 | 23 => "italic",
1152 4 | 24 => "underline",
1153 30..=39 | 90..=97 => "fg",
1154 40..=49 | 100..=107 => "bg",
1155 _ => "other",
1156 };
1157 e_cat != category
1158 });
1159 }
1160 state.push(code.to_string());
1161 }
1162 }
1163
1164 fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
1165 self.commit_newline(w)?;
1166 let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
1167
1168 let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
1169
1170 let skip = raw_line
1172 .chars()
1173 .take(fence_indent)
1174 .take_while(|&c| c == ' ')
1175 .count();
1176 let line_content = &raw_line[skip..];
1177
1178 let mut prefix = " ".repeat(self.margin);
1179 if !self.list_context.is_empty() {
1180 let indent_width = self.list_context.structural_width();
1181 prefix.push_str(&" ".repeat(indent_width));
1182 }
1183
1184 let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
1185
1186 let mut spans = Vec::new();
1187 if let Some(h) = &mut self.highlighter {
1188 if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
1189 spans = ranges;
1190 } else {
1191 spans.push((syntect::highlighting::Style::default(), line_content));
1192 }
1193 } else {
1194 spans.push((syntect::highlighting::Style::default(), line_content));
1195 }
1196
1197 self.scratch_buffer.clear();
1199 for (style, text) in spans {
1200 let _ = write!(
1201 self.scratch_buffer,
1202 "\x1b[38;2;{};{};{}m{}",
1203 style.foreground.r, style.foreground.g, style.foreground.b, text
1204 );
1205 }
1206
1207 let content_width = self.visible_width(line_content);
1209
1210 if content_width <= avail_width {
1211 let pad = avail_width.saturating_sub(content_width);
1213 queue!(
1214 w,
1215 Print(&prefix),
1216 SetBackgroundColor(COLOR_CODE_BG),
1217 Print(&self.scratch_buffer),
1218 Print(" ".repeat(pad)),
1219 ResetColor
1220 )?;
1221 } else {
1222 let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
1224
1225 if wrapped_lines.is_empty() {
1226 queue!(
1227 w,
1228 Print(&prefix),
1229 SetBackgroundColor(COLOR_CODE_BG),
1230 Print(" ".repeat(avail_width)),
1231 ResetColor
1232 )?;
1233 } else {
1234 for (i, line) in wrapped_lines.iter().enumerate() {
1235 if i > 0 {
1236 queue!(w, Print("\n"))?;
1237 }
1238 let vis_len = self.visible_width(line);
1239 let pad = avail_width.saturating_sub(vis_len);
1240
1241 queue!(
1242 w,
1243 Print(&prefix),
1244 SetBackgroundColor(COLOR_CODE_BG),
1245 Print(line),
1246 Print(" ".repeat(pad)),
1247 ResetColor
1248 )?;
1249 }
1250 }
1251 }
1252 self.pending_newline = true;
1253 Ok(())
1254 }
1255
1256 fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
1257 self.flush_pending_inline(w)?;
1258 self.commit_newline(w)?;
1259 let term_width = self.get_width();
1260
1261 let trimmed_row = row_str.trim().trim_matches('|');
1262 let segments = parse_segments(trimmed_row, None);
1263 let cells = split_table_row(trimmed_row, &segments);
1264
1265 if cells.is_empty() {
1266 return Ok(());
1267 }
1268
1269 let prefix_width = self.margin + (self.blockquote_depth * 2);
1270 let cell_overhead = (cells.len() * 3).saturating_sub(1);
1271 let avail = term_width.saturating_sub(prefix_width + self.margin + cell_overhead);
1272 if avail == 0 {
1273 return Ok(());
1274 }
1275 let base_w = avail / cells.len();
1276 let rem = avail % cells.len();
1277
1278 let bg = if !self.table_header_printed {
1279 Color::Rgb {
1280 r: 60,
1281 g: 60,
1282 b: 80,
1283 }
1284 } else {
1285 COLOR_CODE_BG
1286 };
1287 let mut wrapped_cells = Vec::new();
1288 let mut max_h = 1;
1289
1290 for (i, cell) in cells.iter().enumerate() {
1291 let width = std::cmp::max(
1292 1,
1293 if i == cells.len() - 1 {
1294 base_w + rem
1295 } else {
1296 base_w
1297 },
1298 );
1299 self.scratch_buffer.clear();
1300 if !self.table_header_printed {
1301 self.scratch_buffer.push_str("\x1b[1;33m");
1302 }
1303 self.render_inline(
1304 cell.trim(),
1305 Some(bg),
1306 if !self.table_header_printed {
1307 Some("\x1b[1;33m")
1308 } else {
1309 None
1310 },
1311 );
1312 if !self.table_header_printed {
1313 self.scratch_buffer.push_str("\x1b[0m");
1314 }
1315
1316 let lines = self.wrap_ansi(&self.scratch_buffer, width);
1317 if lines.len() > max_h {
1318 max_h = lines.len();
1319 }
1320 wrapped_cells.push((lines, width));
1321 }
1322
1323 let prefix = self.build_block_prefix();
1324
1325 for i in 0..max_h {
1326 if i > 0 {
1327 queue!(w, Print("\n"))?;
1328 }
1329 queue!(w, Print(&prefix))?;
1330 for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
1331 let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
1332 let pad = width.saturating_sub(self.visible_width(text));
1333 queue!(
1334 w,
1335 SetBackgroundColor(bg),
1336 Print(" "),
1337 Print(text),
1338 SetBackgroundColor(bg),
1339 Print(" ".repeat(pad + 1)),
1340 ResetColor
1341 )?;
1342 if col < cells.len() - 1 {
1343 queue!(
1344 w,
1345 SetBackgroundColor(bg),
1346 SetForegroundColor(Color::White),
1347 Print("│"),
1348 ResetColor
1349 )?;
1350 }
1351 }
1352 }
1353 self.pending_newline = true;
1354 Ok(())
1355 }
1356
1357 pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
1358 let mut parts = self.build_inline_parts(text, def_bg, restore_fg);
1359 self.resolve_delimiters(&mut parts);
1360
1361 for part in parts {
1362 for s in &part.pre_style {
1363 self.scratch_buffer.push_str(s);
1364 }
1365 self.scratch_buffer.push_str(&part.content());
1366 for s in &part.post_style {
1367 self.scratch_buffer.push_str(s);
1368 }
1369 }
1370 }
1371
1372 fn build_inline_parts(
1373 &mut self,
1374 text: &str,
1375 def_bg: Option<Color>,
1376 restore_fg: Option<&str>,
1377 ) -> Vec<InlinePart> {
1378 let active_ticks = self.inline_code.ticks;
1379 let segments = parse_segments(text, active_ticks);
1380 let mut parts: Vec<InlinePart> = Vec::new();
1381
1382 for seg in &segments {
1383 match seg {
1384 ParsedSegment::CodeSpan {
1385 range,
1386 delimiter_len,
1387 } => {
1388 let n = *delimiter_len;
1389 let content_range = range.start + n..range.end - n;
1390 let raw_content = &text[content_range];
1391 let normalized = InlineCodeState::normalize_content_static(raw_content);
1392 let formatted =
1393 self.format_inline_code_content(&normalized, def_bg, restore_fg);
1394 parts.push(InlinePart::text(formatted));
1395 }
1396 ParsedSegment::CodeSpanOpener {
1397 range: _,
1398 delimiter_len,
1399 } => {
1400 self.inline_code.open(*delimiter_len);
1401 }
1402 ParsedSegment::CodeSpanContent(range) => {
1403 self.inline_code.push_content(&text[range.clone()]);
1404 }
1405 ParsedSegment::CodeSpanCloser {
1406 range: _,
1407 delimiter_len: _,
1408 } => {
1409 let content = self.inline_code.close();
1410 let formatted = self.format_inline_code_content(&content, def_bg, restore_fg);
1411 parts.push(InlinePart::text(formatted));
1412 }
1413 ParsedSegment::Escape(r) => {
1414 parts.push(InlinePart::text(text[r.start + 1..r.end].to_string()));
1415 }
1416 ParsedSegment::Math(r) => {
1417 let tok = &text[r.clone()];
1418 if tok.len() > 1 && tok.starts_with('$') && tok.ends_with('$') {
1419 parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
1420 } else {
1421 parts.push(InlinePart::text(tok.to_string()));
1422 }
1423 }
1424 ParsedSegment::Autolink(r) => {
1425 let url = &text[r.start + 1..r.end - 1];
1426 parts.push(InlinePart::text(format!(
1427 "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
1428 url, url
1429 )));
1430 }
1431 ParsedSegment::Link(r) => {
1432 if let Some(caps) = RE_LINK.captures(&text[r.clone()]) {
1433 let link_text = caps.get(1).map_or("", |m| m.as_str());
1434 let url = caps.get(2).map_or("", |m| m.as_str());
1435 parts.push(InlinePart::text(format!(
1436 "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
1437 url, link_text
1438 )));
1439 }
1440 }
1441 ParsedSegment::Ansi(r) => {
1442 parts.push(InlinePart::text(text[r.clone()].to_string()));
1443 }
1444 ParsedSegment::Delim(r) => {
1445 let tok = &text[r.clone()];
1446 let c = tok.chars().next().unwrap();
1447
1448 let prev_char = if r.start > 0 {
1449 text[..r.start].chars().last().unwrap_or(' ')
1450 } else {
1451 ' '
1452 };
1453 let next_char = text[r.end..].chars().next().unwrap_or(' ');
1454
1455 let is_ws_next = next_char.is_whitespace();
1456 let is_ws_prev = prev_char.is_whitespace();
1457 let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
1458 let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
1459 let left_flanking =
1460 !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
1461 let right_flanking =
1462 !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
1463
1464 let (can_open, can_close) = if c == '_' {
1465 (
1466 left_flanking && (!right_flanking || is_punct_prev),
1467 right_flanking && (!left_flanking || is_punct_next),
1468 )
1469 } else {
1470 (left_flanking, right_flanking)
1471 };
1472
1473 parts.push(InlinePart::delimiter(c, tok.len(), can_open, can_close));
1474 }
1475 ParsedSegment::Text(r) => {
1476 parts.push(InlinePart::text(text[r.clone()].to_string()));
1477 }
1478 }
1479 }
1480
1481 parts
1482 }
1483
1484 fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1485 let mut stack: Vec<usize> = Vec::new();
1486
1487 for i in 0..parts.len() {
1488 if !parts[i].is_delim() {
1489 continue;
1490 }
1491
1492 if parts[i].can_close() {
1493 let mut stack_idx = stack.len();
1494 while stack_idx > 0 {
1495 let open_pos = stack_idx - 1;
1496 let open_idx = stack[open_pos];
1497
1498 if parts[open_idx].delim_char() == parts[i].delim_char()
1499 && parts[open_idx].can_open()
1500 {
1501 if (parts[open_idx].can_open() && parts[open_idx].can_close())
1503 || (parts[i].can_open() && parts[i].can_close())
1504 {
1505 let sum = parts[open_idx].delim_len() + parts[i].delim_len();
1506 if sum.is_multiple_of(3)
1507 && (!parts[open_idx].delim_len().is_multiple_of(3)
1508 || !parts[i].delim_len().is_multiple_of(3))
1509 {
1510 stack_idx -= 1;
1511 continue;
1512 }
1513 }
1514
1515 if open_idx + 1 == i {
1517 stack_idx -= 1;
1518 continue;
1519 }
1520
1521 let open_len = parts[open_idx].delim_len();
1523 let close_len = parts[i].delim_len();
1524 let use_len = if close_len == 3 && open_len == 3 {
1525 1
1526 } else if close_len >= 2 && open_len >= 2 {
1527 2
1528 } else {
1529 1
1530 };
1531
1532 let (style_on, style_off) = match (parts[open_idx].delim_char(), use_len) {
1533 ('~', _) => ("\x1b[9m", "\x1b[29m"),
1534 ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1535 (_, 1) => ("\x1b[3m", "\x1b[23m"),
1536 (_, 2) => ("\x1b[1m", "\x1b[22m"),
1537 _ => ("", ""),
1538 };
1539
1540 if use_len == 1 {
1542 parts[open_idx].pre_style.push(style_on.to_string());
1543 parts[i].post_style.push(style_off.to_string());
1544 } else {
1545 parts[open_idx].post_style.push(style_on.to_string());
1546 parts[i].pre_style.push(style_off.to_string());
1547 }
1548
1549 parts[open_idx].consume(use_len);
1551 parts[i].consume(use_len);
1552
1553 if parts[open_idx].delim_len() == 0 {
1555 stack.remove(open_pos);
1556 stack_idx -= 1;
1557 }
1558
1559 if parts[i].delim_len() == 0 {
1560 break;
1561 }
1562 } else {
1563 stack_idx -= 1;
1564 }
1565 }
1566 }
1567
1568 if parts[i].delim_len() > 0 && parts[i].can_open() {
1569 stack.push(i);
1570 }
1571 }
1572 }
1573
1574 fn build_block_prefix(&self) -> String {
1575 let mut prefix = " ".repeat(self.margin);
1576 if self.blockquote_depth > 0 {
1577 prefix.push_str(STYLE_BLOCKQUOTE);
1578 for _ in 0..self.blockquote_depth {
1579 prefix.push_str("│ ");
1580 }
1581 prefix.push_str(STYLE_RESET);
1582 }
1583 prefix
1584 }
1585
1586 fn format_inline_code_content(
1587 &self,
1588 content: &str,
1589 def_bg: Option<Color>,
1590 restore_fg: Option<&str>,
1591 ) -> String {
1592 let mut out = String::new();
1593 let _ = write!(out, "{}{}", STYLE_INLINE_CODE, content);
1594 if let Some(Color::Rgb { r, g, b }) = def_bg {
1595 let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1596 } else {
1597 out.push_str(STYLE_RESET_BG);
1598 }
1599 out.push_str(restore_fg.unwrap_or(STYLE_RESET_FG));
1600 out
1601 }
1602
1603 fn expand_tabs(&self, line: &str) -> String {
1604 let mut expanded = String::with_capacity(line.len());
1605 let mut col = 0;
1606 for c in line.chars() {
1607 if c == '\t' {
1608 let n = 4 - (col % 4);
1609 expanded.push_str(&" ".repeat(n));
1610 col += n;
1611 } else {
1612 expanded.push(c);
1613 col += UnicodeWidthChar::width(c).unwrap_or(0);
1614 }
1615 }
1616 expanded
1617 }
1618
1619 pub fn classify_line(&self, expanded: &str) -> ClassifiedLine {
1620 let trimmed = expanded.trim_end();
1621
1622 if let Some((f_char, min_len, _indent)) = self.active_fence {
1626 if let Some(caps) = RE_CODE_FENCE.captures(trimmed) {
1627 let fence = &caps[2];
1628 if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
1629 {
1630 return ClassifiedLine {
1631 blockquote_depth: 0,
1632 content: expanded.to_string(),
1633 kind: BlockKind::FenceClose,
1634 };
1635 }
1636 }
1637 return ClassifiedLine {
1638 blockquote_depth: 0,
1639 content: expanded.to_string(),
1640 kind: BlockKind::FenceContent,
1641 };
1642 }
1643
1644 if self.in_math_block {
1646 if RE_MATH_BLOCK.is_match(trimmed) {
1647 return ClassifiedLine {
1648 blockquote_depth: 0,
1649 content: expanded.to_string(),
1650 kind: BlockKind::MathClose,
1651 };
1652 }
1653 return ClassifiedLine {
1654 blockquote_depth: 0,
1655 content: expanded.to_string(),
1656 kind: BlockKind::MathContent,
1657 };
1658 }
1659
1660 if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
1662 return ClassifiedLine {
1663 blockquote_depth: 0,
1664 content: expanded.to_string(),
1665 kind: BlockKind::TableSeparator,
1666 };
1667 }
1668
1669 if RE_TABLE_ROW.is_match(trimmed) {
1671 return ClassifiedLine {
1672 blockquote_depth: 0,
1673 content: expanded.to_string(),
1674 kind: BlockKind::TableRow,
1675 };
1676 }
1677
1678 let mut content = expanded.to_string();
1680 let mut blockquote_depth = 0;
1681 loop {
1682 let trimmed_content = content.clone();
1683 if let Some(caps) = RE_BLOCKQUOTE.captures(&trimmed_content) {
1684 blockquote_depth += 1;
1685 content = caps.get(2).map_or("", |m| m.as_str()).to_string();
1686 } else {
1687 break;
1688 }
1689 }
1690
1691 let clean = content.trim_end();
1692
1693 if let Some(caps) = RE_CODE_FENCE.captures(clean) {
1697 let fence = &caps[2];
1698 let indent_len = caps[1].len();
1699 let info = caps[3].trim();
1700 if let Some(f_char) = fence.chars().next()
1701 && (f_char != '`' || !info.contains('`'))
1702 {
1703 let lang = info.split_whitespace().next().unwrap_or("bash").to_string();
1704 return ClassifiedLine {
1705 blockquote_depth,
1706 content: content.clone(),
1707 kind: BlockKind::FenceOpen {
1708 fence_char: f_char,
1709 fence_len: fence.len(),
1710 indent: indent_len,
1711 lang,
1712 },
1713 };
1714 }
1715 }
1716
1717 if RE_MATH_BLOCK.is_match(clean) {
1719 return ClassifiedLine {
1720 blockquote_depth,
1721 content: content.clone(),
1722 kind: BlockKind::MathOpen,
1723 };
1724 }
1725
1726 if let Some(caps) = RE_HEADER.captures(clean) {
1728 let level = caps.get(1).map_or(0, |m| m.len());
1729 let raw_text = caps.get(2).map_or("", |m| m.as_str());
1730 let text = Self::clean_atx_header_text_static(raw_text).to_string();
1731 return ClassifiedLine {
1732 blockquote_depth,
1733 content: content.clone(),
1734 kind: BlockKind::Header { level, text },
1735 };
1736 }
1737
1738 {
1740 let stripped = clean.trim_start();
1741 let hash_count = stripped.chars().take_while(|&c| c == '#').count();
1742 if (1..=6).contains(&hash_count) {
1743 let after_hashes = &stripped[hash_count..];
1744 if after_hashes.is_empty() || after_hashes.chars().all(|c| c.is_whitespace()) {
1745 return ClassifiedLine {
1746 blockquote_depth,
1747 content: content.clone(),
1748 kind: BlockKind::Header {
1749 level: hash_count,
1750 text: "".to_string(),
1751 },
1752 };
1753 }
1754 }
1755 }
1756
1757 if RE_HR.is_match(clean) {
1759 let hr_chars: Vec<char> = clean.chars().filter(|c| !c.is_whitespace()).collect();
1760 if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1761 return ClassifiedLine {
1762 blockquote_depth,
1763 content: content.clone(),
1764 kind: BlockKind::ThematicBreak,
1765 };
1766 }
1767 }
1768
1769 if let Some(caps) = RE_LIST.captures(clean) {
1771 let indent = caps.get(1).map_or(0, |m| m.len());
1772 let marker = caps.get(2).map_or("", |m| m.as_str()).to_string();
1773 let separator = caps.get(3).map_or(" ", |m| m.as_str()).to_string();
1774 let content_text = caps.get(4).map_or("", |m| m.as_str()).to_string();
1775 let is_ordered = marker.chars().any(|c| c.is_numeric());
1776
1777 if !content_text.is_empty() {
1780 let ct = content_text.trim_end();
1781 if RE_HR.is_match(ct) {
1782 let hr_chars: Vec<char> = ct.chars().filter(|c| !c.is_whitespace()).collect();
1783 if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1784 return ClassifiedLine {
1785 blockquote_depth,
1786 content: content.clone(),
1787 kind: BlockKind::ThematicBreak,
1788 };
1789 }
1790 }
1791 }
1792
1793 return ClassifiedLine {
1794 blockquote_depth,
1795 content: content.clone(),
1796 kind: BlockKind::ListItem {
1797 indent,
1798 marker,
1799 separator,
1800 content: content_text,
1801 is_ordered,
1802 },
1803 };
1804 }
1805
1806 if clean.is_empty() {
1808 return ClassifiedLine {
1809 blockquote_depth,
1810 content,
1811 kind: BlockKind::BlankLine,
1812 };
1813 }
1814
1815 ClassifiedLine {
1817 blockquote_depth,
1818 content,
1819 kind: BlockKind::Paragraph,
1820 }
1821 }
1822
1823 fn clean_atx_header_text_static(text: &str) -> &str {
1824 let trimmed = text.trim_end();
1825 let mut end = trimmed.len();
1826 let bytes = trimmed.as_bytes();
1827 while end > 0 && bytes[end - 1] == b'#' {
1828 end -= 1;
1829 }
1830 if end == 0 {
1831 ""
1833 } else if end < trimmed.len() && bytes[end - 1] == b' ' {
1834 trimmed[..end - 1].trim_end()
1836 } else {
1837 trimmed
1839 }
1840 }
1841
1842 fn start_highlighter(&mut self, lang: &str) {
1843 let ss = &*SYNTAX_SET;
1844 let syntax = ss
1845 .find_syntax_by_token(lang)
1846 .unwrap_or_else(|| ss.find_syntax_plain_text());
1847 self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1848 }
1849}