1#![forbid(unsafe_code)]
2#![allow(clippy::nursery)]
4#![allow(clippy::pedantic)]
5#![allow(clippy::len_zero)]
6#![allow(clippy::single_char_pattern)]
7
8#[cfg(feature = "syntax-highlighting")]
60pub mod syntax;
61
62pub mod table;
64
65use lipgloss::Style as LipglossStyle;
66use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
67use std::collections::HashMap;
68#[cfg(feature = "syntax-highlighting")]
69use std::collections::HashSet;
70
71#[cfg(all(feature = "syntax-highlighting", feature = "serde"))]
73use serde::{Deserialize, Serialize};
74
75const DEFAULT_WIDTH: usize = 80;
77const DEFAULT_MARGIN: usize = 2;
78const DEFAULT_LIST_INDENT: usize = 2;
79const DEFAULT_LIST_LEVEL_INDENT: usize = 4;
80
81#[derive(Debug, Clone, Default)]
87pub struct StylePrimitive {
88 pub block_prefix: String,
90 pub block_suffix: String,
92 pub prefix: String,
94 pub suffix: String,
96 pub color: Option<String>,
98 pub background_color: Option<String>,
100 pub underline: Option<bool>,
102 pub bold: Option<bool>,
104 pub italic: Option<bool>,
106 pub crossed_out: Option<bool>,
108 pub faint: Option<bool>,
110 pub format: String,
112}
113
114impl StylePrimitive {
115 pub fn new() -> Self {
117 Self::default()
118 }
119
120 pub fn prefix(mut self, p: impl Into<String>) -> Self {
122 self.prefix = p.into();
123 self
124 }
125
126 pub fn suffix(mut self, s: impl Into<String>) -> Self {
128 self.suffix = s.into();
129 self
130 }
131
132 pub fn block_prefix(mut self, p: impl Into<String>) -> Self {
134 self.block_prefix = p.into();
135 self
136 }
137
138 pub fn block_suffix(mut self, s: impl Into<String>) -> Self {
140 self.block_suffix = s.into();
141 self
142 }
143
144 pub fn color(mut self, c: impl Into<String>) -> Self {
146 self.color = Some(c.into());
147 self
148 }
149
150 pub fn background_color(mut self, c: impl Into<String>) -> Self {
152 self.background_color = Some(c.into());
153 self
154 }
155
156 pub fn bold(mut self, b: bool) -> Self {
158 self.bold = Some(b);
159 self
160 }
161
162 pub fn italic(mut self, i: bool) -> Self {
164 self.italic = Some(i);
165 self
166 }
167
168 pub fn underline(mut self, u: bool) -> Self {
170 self.underline = Some(u);
171 self
172 }
173
174 pub fn crossed_out(mut self, c: bool) -> Self {
176 self.crossed_out = Some(c);
177 self
178 }
179
180 pub fn faint(mut self, f: bool) -> Self {
182 self.faint = Some(f);
183 self
184 }
185
186 pub fn format(mut self, f: impl Into<String>) -> Self {
188 self.format = f.into();
189 self
190 }
191
192 pub fn to_lipgloss(&self) -> LipglossStyle {
194 let mut style = LipglossStyle::new();
195
196 if let Some(ref color) = self.color {
197 style = style.foreground(color.as_str());
198 }
199 if let Some(ref bg) = self.background_color {
200 style = style.background(bg.as_str());
201 }
202 if self.bold == Some(true) {
203 style = style.bold();
204 }
205 if self.italic == Some(true) {
206 style = style.italic();
207 }
208 if self.underline == Some(true) {
209 style = style.underline();
210 }
211 if self.crossed_out == Some(true) {
212 style = style.strikethrough();
213 }
214 if self.faint == Some(true) {
215 style = style.faint();
216 }
217
218 style
219 }
220}
221
222#[derive(Debug, Clone, Default)]
224pub struct StyleBlock {
225 pub style: StylePrimitive,
227 pub indent: Option<usize>,
229 pub indent_prefix: Option<String>,
231 pub margin: Option<usize>,
233}
234
235impl StyleBlock {
236 pub fn new() -> Self {
238 Self::default()
239 }
240
241 pub fn style(mut self, s: StylePrimitive) -> Self {
243 self.style = s;
244 self
245 }
246
247 pub fn indent(mut self, i: usize) -> Self {
249 self.indent = Some(i);
250 self
251 }
252
253 pub fn indent_prefix(mut self, s: impl Into<String>) -> Self {
255 self.indent_prefix = Some(s.into());
256 self
257 }
258
259 pub fn margin(mut self, m: usize) -> Self {
261 self.margin = Some(m);
262 self
263 }
264}
265
266#[derive(Debug, Clone, Default)]
268pub struct StyleCodeBlock {
269 pub block: StyleBlock,
271 pub theme: Option<String>,
273}
274
275impl StyleCodeBlock {
276 pub fn new() -> Self {
278 Self::default()
279 }
280
281 pub fn block(mut self, b: StyleBlock) -> Self {
283 self.block = b;
284 self
285 }
286
287 pub fn theme(mut self, t: impl Into<String>) -> Self {
289 self.theme = Some(t.into());
290 self
291 }
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct StyleList {
297 pub block: StyleBlock,
299 pub level_indent: usize,
301}
302
303impl StyleList {
304 pub fn new() -> Self {
306 Self {
307 level_indent: DEFAULT_LIST_LEVEL_INDENT,
308 ..Default::default()
309 }
310 }
311
312 pub fn block(mut self, b: StyleBlock) -> Self {
314 self.block = b;
315 self
316 }
317
318 pub fn level_indent(mut self, i: usize) -> Self {
320 self.level_indent = i;
321 self
322 }
323}
324
325#[derive(Debug, Clone, Default)]
327pub struct StyleTable {
328 pub block: StyleBlock,
330 pub center_separator: Option<String>,
332 pub column_separator: Option<String>,
334 pub row_separator: Option<String>,
336}
337
338impl StyleTable {
339 pub fn new() -> Self {
341 Self::default()
342 }
343
344 pub fn separators(
346 mut self,
347 center: impl Into<String>,
348 column: impl Into<String>,
349 row: impl Into<String>,
350 ) -> Self {
351 self.center_separator = Some(center.into());
352 self.column_separator = Some(column.into());
353 self.row_separator = Some(row.into());
354 self
355 }
356}
357
358#[derive(Debug, Clone, Default)]
360pub struct StyleTask {
361 pub style: StylePrimitive,
363 pub ticked: String,
365 pub unticked: String,
367}
368
369impl StyleTask {
370 pub fn new() -> Self {
372 Self {
373 ticked: "[x] ".to_string(),
374 unticked: "[ ] ".to_string(),
375 ..Default::default()
376 }
377 }
378
379 pub fn ticked(mut self, t: impl Into<String>) -> Self {
381 self.ticked = t.into();
382 self
383 }
384
385 pub fn unticked(mut self, u: impl Into<String>) -> Self {
387 self.unticked = u.into();
388 self
389 }
390}
391
392#[cfg(feature = "syntax-highlighting")]
421#[derive(Debug, Clone)]
422#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
423pub struct SyntaxThemeConfig {
424 pub theme_name: String,
427 pub line_numbers: bool,
429 pub language_aliases: HashMap<String, String>,
432 pub disabled_languages: HashSet<String>,
434}
435
436#[cfg(feature = "syntax-highlighting")]
437impl Default for SyntaxThemeConfig {
438 fn default() -> Self {
439 Self {
440 theme_name: "base16-ocean.dark".to_string(),
441 line_numbers: false,
442 language_aliases: HashMap::new(),
443 disabled_languages: HashSet::new(),
444 }
445 }
446}
447
448#[cfg(feature = "syntax-highlighting")]
449impl SyntaxThemeConfig {
450 pub fn new() -> Self {
452 Self::default()
453 }
454
455 pub fn theme(mut self, name: impl Into<String>) -> Self {
465 self.theme_name = name.into();
466 self
467 }
468
469 pub fn line_numbers(mut self, enabled: bool) -> Self {
471 self.line_numbers = enabled;
472 self
473 }
474
475 pub fn language_alias(mut self, alias: impl Into<String>, language: impl Into<String>) -> Self {
481 self.language_aliases.insert(alias.into(), language.into());
482 self
483 }
484
485 pub fn disable_language(mut self, lang: impl Into<String>) -> Self {
489 self.disabled_languages.insert(lang.into());
490 self
491 }
492
493 pub fn try_language_alias(
504 mut self,
505 alias: impl Into<String>,
506 language: impl Into<String>,
507 ) -> Result<Self, String> {
508 let alias = alias.into();
509 let language = language.into();
510
511 if alias == language {
513 return Err(format!(
514 "Alias '{}' -> '{}' would create a cycle (self-referential).",
515 alias, language
516 ));
517 }
518
519 let detector = crate::syntax::LanguageDetector::new();
522 if !detector.is_supported(&language) {
523 return Err(format!(
524 "Unknown target language '{}'. The language must be recognized by the syntax highlighter.",
525 language
526 ));
527 }
528
529 if self.would_create_cycle(&alias, &language) {
532 return Err(format!(
533 "Alias '{}' -> '{}' would create a cycle in the alias chain.",
534 alias, language
535 ));
536 }
537
538 self.language_aliases.insert(alias, language);
539 Ok(self)
540 }
541
542 fn would_create_cycle(&self, alias: &str, target: &str) -> bool {
544 let mut visited = HashSet::new();
545 visited.insert(alias);
546
547 let mut current = target;
548 while let Some(next) = self.language_aliases.get(current) {
549 if !visited.insert(next.as_str()) {
550 return true;
551 }
552 current = next;
553 }
554 false
555 }
556
557 pub fn validate(&self) -> Result<(), String> {
569 use crate::syntax::SyntaxTheme;
570
571 if SyntaxTheme::from_name(&self.theme_name).is_none() {
572 let available = SyntaxTheme::available_themes().join(", ");
573 return Err(format!(
574 "Unknown syntax theme '{}'. Available themes: {}",
575 self.theme_name, available
576 ));
577 }
578
579 let detector = crate::syntax::LanguageDetector::new();
581 for (alias, target) in &self.language_aliases {
582 if !detector.is_supported(target) {
583 return Err(format!(
584 "Language alias '{}' points to unrecognized language '{}'.",
585 alias, target
586 ));
587 }
588 }
589
590 for alias in self.language_aliases.keys() {
592 let mut visited = HashSet::new();
593 visited.insert(alias.as_str());
594 let mut current = alias.as_str();
595 while let Some(next) = self.language_aliases.get(current) {
596 if !visited.insert(next.as_str()) {
597 return Err(format!(
598 "Alias chain starting at '{}' contains a cycle.",
599 alias
600 ));
601 }
602 current = next;
603 }
604 }
605
606 Ok(())
607 }
608
609 pub fn resolve_language<'a>(&'a self, lang: &'a str) -> &'a str {
614 self.language_aliases
615 .get(lang)
616 .map(|s| s.as_str())
617 .unwrap_or(lang)
618 }
619
620 pub fn is_disabled(&self, lang: &str) -> bool {
622 self.disabled_languages.contains(lang)
623 }
624}
625
626#[derive(Debug, Clone, Default)]
628pub struct StyleConfig {
629 pub document: StyleBlock,
631
632 pub block_quote: StyleBlock,
634 pub paragraph: StyleBlock,
635 pub list: StyleList,
636
637 pub heading: StyleBlock,
639 pub h1: StyleBlock,
640 pub h2: StyleBlock,
641 pub h3: StyleBlock,
642 pub h4: StyleBlock,
643 pub h5: StyleBlock,
644 pub h6: StyleBlock,
645
646 pub text: StylePrimitive,
648 pub strikethrough: StylePrimitive,
649 pub emph: StylePrimitive,
650 pub strong: StylePrimitive,
651 pub horizontal_rule: StylePrimitive,
652
653 pub item: StylePrimitive,
655 pub enumeration: StylePrimitive,
656 pub task: StyleTask,
657
658 pub link: StylePrimitive,
660 pub link_text: StylePrimitive,
661 pub image: StylePrimitive,
662 pub image_text: StylePrimitive,
663
664 pub code: StyleBlock,
666 pub code_block: StyleCodeBlock,
667
668 pub table: StyleTable,
670
671 pub definition_list: StyleBlock,
673 pub definition_term: StylePrimitive,
674 pub definition_description: StylePrimitive,
675
676 #[cfg(feature = "syntax-highlighting")]
678 pub syntax_config: SyntaxThemeConfig,
679}
680
681impl StyleConfig {
682 pub fn new() -> Self {
684 Self::default()
685 }
686
687 pub fn heading_style(&self, level: HeadingLevel) -> &StyleBlock {
689 match level {
690 HeadingLevel::H1 => &self.h1,
691 HeadingLevel::H2 => &self.h2,
692 HeadingLevel::H3 => &self.h3,
693 HeadingLevel::H4 => &self.h4,
694 HeadingLevel::H5 => &self.h5,
695 HeadingLevel::H6 => &self.h6,
696 }
697 }
698
699 #[cfg(feature = "syntax-highlighting")]
710 pub fn syntax_theme(mut self, theme: impl Into<String>) -> Self {
711 self.syntax_config.theme_name = theme.into();
712 self
713 }
714
715 #[cfg(feature = "syntax-highlighting")]
719 pub fn with_line_numbers(mut self, enabled: bool) -> Self {
720 self.syntax_config.line_numbers = enabled;
721 self
722 }
723
724 #[cfg(feature = "syntax-highlighting")]
730 pub fn language_alias(mut self, alias: impl Into<String>, language: impl Into<String>) -> Self {
731 self.syntax_config
732 .language_aliases
733 .insert(alias.into(), language.into());
734 self
735 }
736
737 #[cfg(feature = "syntax-highlighting")]
749 pub fn try_language_alias(
750 self,
751 alias: impl Into<String>,
752 language: impl Into<String>,
753 ) -> Result<Self, String> {
754 let syntax_config = self.syntax_config.try_language_alias(alias, language)?;
755 Ok(Self {
756 syntax_config,
757 ..self
758 })
759 }
760
761 #[cfg(feature = "syntax-highlighting")]
767 pub fn disable_language(mut self, lang: impl Into<String>) -> Self {
768 self.syntax_config.disabled_languages.insert(lang.into());
769 self
770 }
771
772 #[cfg(feature = "syntax-highlighting")]
776 pub fn with_syntax_config(mut self, config: SyntaxThemeConfig) -> Self {
777 self.syntax_config = config;
778 self
779 }
780
781 #[cfg(feature = "syntax-highlighting")]
785 pub fn syntax(&self) -> &SyntaxThemeConfig {
786 &self.syntax_config
787 }
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
796pub enum Style {
797 Ascii,
799 #[default]
801 Dark,
802 Dracula,
804 Light,
806 Pink,
808 TokyoNight,
810 NoTty,
812 Auto,
814}
815
816impl Style {
817 pub fn config(&self) -> StyleConfig {
819 match self {
820 Style::Ascii | Style::NoTty => ascii_style(),
821 Style::Dark | Style::Auto => dark_style(),
822 Style::Dracula => dracula_style(),
823 Style::Light => light_style(),
824 Style::Pink => pink_style(),
825 Style::TokyoNight => tokyo_night_style(),
826 }
827 }
828}
829
830pub fn ascii_style() -> StyleConfig {
832 StyleConfig {
833 document: StyleBlock::new()
834 .style(StylePrimitive::new().block_prefix("\n").block_suffix("\n"))
835 .margin(DEFAULT_MARGIN),
836 block_quote: StyleBlock::new().indent(1).indent_prefix("| "),
837 paragraph: StyleBlock::new(),
838 list: StyleList::new().level_indent(DEFAULT_LIST_LEVEL_INDENT),
839 heading: StyleBlock::new().style(StylePrimitive::new().block_suffix("\n")),
840 h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ")),
841 h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
842 h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
843 h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
844 h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
845 h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
846 strikethrough: StylePrimitive::new().block_prefix("~~").block_suffix("~~"),
847 emph: StylePrimitive::new().block_prefix("*").block_suffix("*"),
848 strong: StylePrimitive::new().block_prefix("**").block_suffix("**"),
849 horizontal_rule: StylePrimitive::new().format("\n--------\n"),
850 item: StylePrimitive::new().block_prefix("• "),
851 enumeration: StylePrimitive::new().block_prefix(". "),
852 task: StyleTask::new().ticked("[x] ").unticked("[ ] "),
853 image_text: StylePrimitive::new().format("Image: {{.text}} →"),
854 code: StyleBlock::new(),
855 code_block: StyleCodeBlock::new().block(StyleBlock::new().margin(DEFAULT_MARGIN)),
856 table: StyleTable::new().separators("|", "|", "-"),
857 definition_description: StylePrimitive::new().block_prefix("\n* "),
858 ..Default::default()
859 }
860}
861
862pub fn dark_style() -> StyleConfig {
864 StyleConfig {
865 document: StyleBlock::new()
866 .style(
867 StylePrimitive::new()
868 .block_prefix("\n")
869 .block_suffix("\n")
870 .color("252"),
871 )
872 .margin(DEFAULT_MARGIN),
873 block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
874 paragraph: StyleBlock::new().style(StylePrimitive::new().color("252")),
875 list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
876 heading: StyleBlock::new().style(
877 StylePrimitive::new()
878 .block_suffix("\n")
879 .color("39")
880 .bold(true),
881 ),
882 h1: StyleBlock::new().style(
883 StylePrimitive::new()
884 .prefix(" ")
885 .suffix(" ")
886 .color("228")
887 .background_color("63")
888 .bold(true),
889 ),
890 h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
891 h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
892 h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
893 h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
894 h6: StyleBlock::new().style(
895 StylePrimitive::new()
896 .prefix("###### ")
897 .color("35")
898 .bold(false),
899 ),
900 strikethrough: StylePrimitive::new().crossed_out(true),
901 emph: StylePrimitive::new().italic(true),
902 strong: StylePrimitive::new().bold(true),
903 horizontal_rule: StylePrimitive::new().color("240").format("\n--------\n"),
904 item: StylePrimitive::new().block_prefix("• "),
905 enumeration: StylePrimitive::new().block_prefix(". "),
906 task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
907 link: StylePrimitive::new().color("30").underline(true),
908 link_text: StylePrimitive::new().color("35").bold(true),
909 image: StylePrimitive::new().color("212").underline(true),
910 image_text: StylePrimitive::new()
911 .color("243")
912 .format("Image: {{.text}} →"),
913 code: StyleBlock::new().style(
914 StylePrimitive::new()
915 .prefix(" ")
916 .suffix(" ")
917 .color("203")
918 .background_color("236"),
919 ),
920 code_block: StyleCodeBlock::new().block(
921 StyleBlock::new()
922 .style(StylePrimitive::new().color("244"))
923 .margin(DEFAULT_MARGIN),
924 ),
925 definition_description: StylePrimitive::new().block_prefix("\n→ "),
926 ..Default::default()
927 }
928}
929
930pub fn light_style() -> StyleConfig {
932 StyleConfig {
933 document: StyleBlock::new()
934 .style(
935 StylePrimitive::new()
936 .block_prefix("\n")
937 .block_suffix("\n")
938 .color("234"),
939 )
940 .margin(DEFAULT_MARGIN),
941 block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
942 paragraph: StyleBlock::new().style(StylePrimitive::new().color("234")),
943 list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
944 heading: StyleBlock::new().style(
945 StylePrimitive::new()
946 .block_suffix("\n")
947 .color("27")
948 .bold(true),
949 ),
950 h1: StyleBlock::new().style(
951 StylePrimitive::new()
952 .prefix(" ")
953 .suffix(" ")
954 .color("228")
955 .background_color("63")
956 .bold(true),
957 ),
958 h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
959 h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
960 h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
961 h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
962 h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ").bold(false)),
963 strikethrough: StylePrimitive::new().crossed_out(true),
964 emph: StylePrimitive::new().italic(true),
965 strong: StylePrimitive::new().bold(true),
966 horizontal_rule: StylePrimitive::new().color("249").format("\n--------\n"),
967 item: StylePrimitive::new().block_prefix("• "),
968 enumeration: StylePrimitive::new().block_prefix(". "),
969 task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
970 link: StylePrimitive::new().color("36").underline(true),
971 link_text: StylePrimitive::new().color("29").bold(true),
972 image: StylePrimitive::new().color("205").underline(true),
973 image_text: StylePrimitive::new()
974 .color("243")
975 .format("Image: {{.text}} →"),
976 code: StyleBlock::new().style(
977 StylePrimitive::new()
978 .prefix(" ")
979 .suffix(" ")
980 .color("203")
981 .background_color("254"),
982 ),
983 code_block: StyleCodeBlock::new().block(
984 StyleBlock::new()
985 .style(StylePrimitive::new().color("242"))
986 .margin(DEFAULT_MARGIN),
987 ),
988 definition_description: StylePrimitive::new().block_prefix("\n→ "),
989 ..Default::default()
990 }
991}
992
993pub fn pink_style() -> StyleConfig {
995 StyleConfig {
996 document: StyleBlock::new().margin(DEFAULT_MARGIN),
997 block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
998 list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
999 heading: StyleBlock::new().style(
1000 StylePrimitive::new()
1001 .block_suffix("\n")
1002 .color("212")
1003 .bold(true),
1004 ),
1005 h1: StyleBlock::new().style(StylePrimitive::new().block_prefix("\n").block_suffix("\n")),
1006 h2: StyleBlock::new().style(StylePrimitive::new().prefix("▌ ")),
1007 h3: StyleBlock::new().style(StylePrimitive::new().prefix("┃ ")),
1008 h4: StyleBlock::new().style(StylePrimitive::new().prefix("│ ")),
1009 h5: StyleBlock::new().style(StylePrimitive::new().prefix("┆ ")),
1010 h6: StyleBlock::new().style(StylePrimitive::new().prefix("┊ ").bold(false)),
1011 strikethrough: StylePrimitive::new().crossed_out(true),
1012 emph: StylePrimitive::new().italic(true),
1013 strong: StylePrimitive::new().bold(true),
1014 horizontal_rule: StylePrimitive::new().color("212").format("\n──────\n"),
1015 item: StylePrimitive::new().block_prefix("• "),
1016 enumeration: StylePrimitive::new().block_prefix(". "),
1017 task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1018 link: StylePrimitive::new().color("99").underline(true),
1019 link_text: StylePrimitive::new().bold(true),
1020 image: StylePrimitive::new().underline(true),
1021 image_text: StylePrimitive::new().format("Image: {{.text}}"),
1022 code: StyleBlock::new().style(
1023 StylePrimitive::new()
1024 .prefix(" ")
1025 .suffix(" ")
1026 .color("212")
1027 .background_color("236"),
1028 ),
1029 definition_description: StylePrimitive::new().block_prefix("\n→ "),
1030 ..Default::default()
1031 }
1032}
1033
1034pub fn dracula_style() -> StyleConfig {
1044 StyleConfig {
1045 document: StyleBlock::new()
1046 .style(
1047 StylePrimitive::new()
1048 .block_prefix("\n")
1049 .block_suffix("\n")
1050 .color("#f8f8f2"),
1051 )
1052 .margin(DEFAULT_MARGIN),
1053 block_quote: StyleBlock::new()
1054 .style(StylePrimitive::new().color("#f1fa8c").italic(true))
1055 .indent(DEFAULT_MARGIN),
1056 list: StyleList::new()
1057 .block(StyleBlock::new().style(StylePrimitive::new().color("#f8f8f2")))
1058 .level_indent(DEFAULT_MARGIN),
1059 heading: StyleBlock::new().style(
1060 StylePrimitive::new()
1061 .block_suffix("\n")
1062 .color("#bd93f9")
1063 .bold(true),
1064 ),
1065 h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ")),
1067 h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
1068 h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
1069 h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
1070 h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
1071 h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
1072 strikethrough: StylePrimitive::new().crossed_out(true),
1073 emph: StylePrimitive::new().italic(true).color("#f1fa8c"),
1074 strong: StylePrimitive::new().bold(true).color("#ffb86c"),
1075 horizontal_rule: StylePrimitive::new()
1076 .color("#6272A4")
1077 .format("\n--------\n"),
1078 item: StylePrimitive::new().block_prefix("• "),
1079 enumeration: StylePrimitive::new().block_prefix(". ").color("#8be9fd"),
1080 task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1081 link: StylePrimitive::new().color("#8be9fd").underline(true),
1082 link_text: StylePrimitive::new().color("#ff79c6"),
1083 image: StylePrimitive::new().color("#8be9fd").underline(true),
1084 image_text: StylePrimitive::new()
1085 .color("#ff79c6")
1086 .format("Image: {{.text}} →"),
1087 code: StyleBlock::new().style(StylePrimitive::new().color("#50fa7b")),
1088 code_block: StyleCodeBlock::new().block(
1089 StyleBlock::new()
1090 .style(StylePrimitive::new().color("#ffb86c"))
1091 .margin(DEFAULT_MARGIN),
1092 ),
1093 definition_description: StylePrimitive::new().block_prefix("\n🠶 "),
1094 ..Default::default()
1095 }
1096}
1097
1098pub fn tokyo_night_style() -> StyleConfig {
1106 StyleConfig {
1107 document: StyleBlock::new()
1108 .style(
1109 StylePrimitive::new()
1110 .block_prefix("\n")
1111 .block_suffix("\n")
1112 .color("#a9b1d6"),
1113 )
1114 .margin(DEFAULT_MARGIN),
1115 block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
1116 list: StyleList::new()
1117 .block(StyleBlock::new().style(StylePrimitive::new().color("#a9b1d6")))
1118 .level_indent(DEFAULT_LIST_INDENT),
1119 heading: StyleBlock::new().style(
1120 StylePrimitive::new()
1121 .block_suffix("\n")
1122 .color("#bb9af7")
1123 .bold(true),
1124 ),
1125 h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ").bold(true)),
1126 h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
1127 h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
1128 h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
1129 h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
1130 h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
1131 strikethrough: StylePrimitive::new().crossed_out(true),
1132 emph: StylePrimitive::new().italic(true),
1133 strong: StylePrimitive::new().bold(true),
1134 horizontal_rule: StylePrimitive::new()
1135 .color("#565f89")
1136 .format("\n--------\n"),
1137 item: StylePrimitive::new().block_prefix("• "),
1138 enumeration: StylePrimitive::new().block_prefix(". ").color("#7aa2f7"),
1139 task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1140 link: StylePrimitive::new().color("#7aa2f7").underline(true),
1141 link_text: StylePrimitive::new().color("#2ac3de"),
1142 image: StylePrimitive::new().color("#7aa2f7").underline(true),
1143 image_text: StylePrimitive::new()
1144 .color("#2ac3de")
1145 .format("Image: {{.text}} →"),
1146 code: StyleBlock::new().style(StylePrimitive::new().color("#9ece6a")),
1147 code_block: StyleCodeBlock::new().block(
1148 StyleBlock::new()
1149 .style(StylePrimitive::new().color("#ff9e64"))
1150 .margin(DEFAULT_MARGIN),
1151 ),
1152 definition_description: StylePrimitive::new().block_prefix("\n🠶 "),
1153 ..Default::default()
1154 }
1155}
1156
1157#[derive(Debug, Clone)]
1165pub struct AnsiOptions {
1166 pub word_wrap: usize,
1168 pub base_url: Option<String>,
1170 pub preserve_newlines: bool,
1172 pub styles: StyleConfig,
1174}
1175
1176pub type RendererOptions = AnsiOptions;
1178
1179impl Default for AnsiOptions {
1180 fn default() -> Self {
1181 Self {
1182 word_wrap: DEFAULT_WIDTH,
1183 base_url: None,
1184 preserve_newlines: false,
1185 styles: dark_style(),
1186 }
1187 }
1188}
1189
1190#[derive(Debug, Clone)]
1197pub struct TermRenderer {
1198 options: AnsiOptions,
1199}
1200
1201pub type Renderer = TermRenderer;
1203
1204impl Default for TermRenderer {
1205 fn default() -> Self {
1206 Self::new()
1207 }
1208}
1209
1210impl TermRenderer {
1211 pub fn new() -> Self {
1213 Self {
1214 options: AnsiOptions::default(),
1215 }
1216 }
1217
1218 pub fn with_style(mut self, style: Style) -> Self {
1220 self.options.styles = style.config();
1221 self
1222 }
1223
1224 pub fn with_style_config(mut self, config: StyleConfig) -> Self {
1226 self.options.styles = config;
1227 self
1228 }
1229
1230 pub fn with_word_wrap(mut self, width: usize) -> Self {
1232 self.options.word_wrap = width;
1233 self
1234 }
1235
1236 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
1238 self.options.base_url = Some(url.into());
1239 self
1240 }
1241
1242 pub fn with_preserved_newlines(mut self, preserve: bool) -> Self {
1244 self.options.preserve_newlines = preserve;
1245 self
1246 }
1247
1248 pub fn render(&self, markdown: &str) -> String {
1250 let mut ctx = RenderContext::new(&self.options);
1251 ctx.render(markdown)
1252 }
1253
1254 pub fn render_bytes(&self, markdown: &[u8]) -> Result<String, std::str::Utf8Error> {
1256 let text = std::str::from_utf8(markdown)?;
1257 Ok(self.render(text))
1258 }
1259
1260 #[cfg(feature = "syntax-highlighting")]
1283 pub fn set_syntax_theme(&mut self, theme: impl Into<String>) -> Result<(), String> {
1284 let theme_name = theme.into();
1285
1286 use crate::syntax::SyntaxTheme;
1288 if SyntaxTheme::from_name(&theme_name).is_none() {
1289 let available = SyntaxTheme::available_themes().join(", ");
1290 return Err(format!(
1291 "Unknown syntax theme '{}'. Available themes: {}",
1292 theme_name, available
1293 ));
1294 }
1295
1296 self.options.styles.syntax_config.theme_name = theme_name;
1297 Ok(())
1298 }
1299
1300 #[cfg(feature = "syntax-highlighting")]
1311 pub fn set_line_numbers(&mut self, enabled: bool) {
1312 self.options.styles.syntax_config.line_numbers = enabled;
1313 }
1314
1315 #[cfg(feature = "syntax-highlighting")]
1319 pub fn syntax_config(&self) -> &SyntaxThemeConfig {
1320 &self.options.styles.syntax_config
1321 }
1322
1323 #[cfg(feature = "syntax-highlighting")]
1338 pub fn syntax_config_mut(&mut self) -> &mut SyntaxThemeConfig {
1339 &mut self.options.styles.syntax_config
1340 }
1341}
1342
1343struct RenderContext<'a> {
1345 options: &'a AnsiOptions,
1346 output: String,
1347 in_heading: Option<HeadingLevel>,
1349 in_emphasis: bool,
1350 in_strong: bool,
1351 in_strikethrough: bool,
1352 in_link: bool,
1353 in_image: bool,
1354 in_code_block: bool,
1355 block_quote_depth: usize,
1356 block_quote_pending_separator: Option<usize>,
1357 pending_block_quote_decrement: usize,
1358 in_paragraph: bool,
1359 in_list: bool,
1360 ordered_list_stack: Vec<bool>,
1361 list_depth: usize,
1362 list_item_number: Vec<usize>,
1363 in_table: bool,
1364 table_alignments: Vec<pulldown_cmark::Alignment>,
1365 table_row: Vec<String>,
1366 table_rows: Vec<Vec<String>>,
1367 table_header_row: Option<Vec<String>>,
1368 table_header: bool,
1369 current_cell: String,
1370 text_buffer: String,
1372 link_url: String,
1373 link_title: String,
1374 link_is_autolink_email: bool,
1375 image_url: String,
1376 image_title: String,
1377 code_block_language: String,
1378 code_block_content: String,
1379}
1380
1381impl<'a> RenderContext<'a> {
1382 fn new(options: &'a AnsiOptions) -> Self {
1383 Self {
1384 options,
1385 output: String::new(),
1386 in_heading: None,
1387 in_emphasis: false,
1388 in_strong: false,
1389 in_strikethrough: false,
1390 in_link: false,
1391 in_image: false,
1392 in_code_block: false,
1393 block_quote_depth: 0,
1394 block_quote_pending_separator: None,
1395 pending_block_quote_decrement: 0,
1396 in_paragraph: false,
1397 in_list: false,
1398 ordered_list_stack: Vec::new(),
1399 list_depth: 0,
1400 list_item_number: Vec::new(),
1401 in_table: false,
1402 table_alignments: Vec::new(),
1403 table_row: Vec::new(),
1404 table_rows: Vec::new(),
1405 table_header_row: None,
1406 table_header: false,
1407 current_cell: String::new(),
1408 text_buffer: String::new(),
1409 link_url: String::new(),
1410 link_title: String::new(),
1411 link_is_autolink_email: false,
1412 image_url: String::new(),
1413 image_title: String::new(),
1414 code_block_language: String::new(),
1415 code_block_content: String::new(),
1416 }
1417 }
1418
1419 fn render(&mut self, markdown: &str) -> String {
1420 let mut opts = Options::empty();
1422 opts.insert(Options::ENABLE_TABLES);
1423 opts.insert(Options::ENABLE_STRIKETHROUGH);
1424 opts.insert(Options::ENABLE_TASKLISTS);
1425
1426 let parser = Parser::new_ext(markdown, opts);
1427
1428 self.output
1430 .push_str(&self.options.styles.document.style.block_prefix);
1431
1432 let margin = self.options.styles.document.margin.unwrap_or(0);
1434
1435 for event in parser {
1436 self.handle_event(event);
1437 }
1438
1439 self.output
1441 .push_str(&self.options.styles.document.style.block_suffix);
1442
1443 if margin > 0 {
1445 let margin_str = " ".repeat(margin);
1446 self.output = self
1447 .output
1448 .lines()
1449 .map(|line| format!("{}{}", margin_str, line))
1450 .collect::<Vec<_>>()
1451 .join("\n");
1452 }
1453
1454 std::mem::take(&mut self.output)
1455 }
1456
1457 fn handle_event(&mut self, event: Event) {
1458 match event {
1459 Event::Start(Tag::Heading { level, .. }) => {
1461 self.in_heading = Some(level);
1462 self.text_buffer.clear();
1463 }
1464 Event::End(TagEnd::Heading(_level)) => {
1465 self.flush_heading();
1466 self.in_heading = None;
1467 }
1468
1469 Event::Start(Tag::Paragraph) => {
1470 if let Some(depth) = self.block_quote_pending_separator.take()
1471 && depth > 0
1472 {
1473 let indent_prefix = self
1474 .options
1475 .styles
1476 .block_quote
1477 .indent_prefix
1478 .as_deref()
1479 .unwrap_or("│ ");
1480 let prefix = indent_prefix.repeat(depth);
1481 self.output.push_str(&prefix);
1482 self.output.push('\n');
1483 }
1484 if !self.in_list {
1485 self.text_buffer.clear();
1486 }
1487 self.in_paragraph = true;
1488 }
1489 Event::End(TagEnd::Paragraph) => {
1490 if !self.in_list && !self.in_table {
1491 self.flush_paragraph();
1492 }
1493 self.in_paragraph = false;
1494 if self.pending_block_quote_decrement > 0 {
1495 self.block_quote_depth = self
1496 .block_quote_depth
1497 .saturating_sub(self.pending_block_quote_decrement);
1498 self.pending_block_quote_decrement = 0;
1499 if let Some(ref mut sep_depth) = self.block_quote_pending_separator
1500 && *sep_depth > self.block_quote_depth
1501 {
1502 *sep_depth = self.block_quote_depth;
1503 }
1504 }
1505 if self.block_quote_depth == 0 {
1506 self.block_quote_pending_separator = None;
1507 }
1508 }
1509
1510 Event::Start(Tag::BlockQuote(_kind)) => {
1511 if self.block_quote_depth == 0 {
1512 self.output.push('\n');
1513 }
1514 self.block_quote_depth += 1;
1515 }
1516 Event::End(TagEnd::BlockQuote(_)) => {
1517 if self.in_paragraph {
1518 self.pending_block_quote_decrement += 1;
1519 } else {
1520 self.block_quote_depth = self.block_quote_depth.saturating_sub(1);
1521 if let Some(ref mut sep_depth) = self.block_quote_pending_separator
1524 && *sep_depth > self.block_quote_depth
1525 {
1526 *sep_depth = self.block_quote_depth;
1527 }
1528 if self.block_quote_depth == 0 {
1529 self.block_quote_pending_separator = None;
1530 }
1531 }
1532 }
1533
1534 Event::Start(Tag::CodeBlock(kind)) => {
1535 self.in_code_block = true;
1536 self.code_block_content.clear();
1537 match kind {
1538 CodeBlockKind::Fenced(lang) => {
1539 self.code_block_language = lang.to_string();
1540 }
1541 CodeBlockKind::Indented => {
1542 self.code_block_language.clear();
1543 }
1544 }
1545 }
1546 Event::End(TagEnd::CodeBlock) => {
1547 self.flush_code_block();
1548 self.in_code_block = false;
1549 }
1550
1551 Event::Start(Tag::List(first_item)) => {
1553 if self.list_depth > 0 && !self.text_buffer.is_empty() {
1556 self.flush_list_item();
1557 }
1558 self.in_list = true;
1559 self.list_depth += 1;
1560 self.ordered_list_stack.push(first_item.is_some());
1562 self.list_item_number.push(first_item.unwrap_or(1) as usize);
1563 if self.list_depth == 1 {
1564 self.output.push('\n');
1565 }
1566 }
1567 Event::End(TagEnd::List(_)) => {
1568 self.list_depth = self.list_depth.saturating_sub(1);
1569 self.list_item_number.pop();
1570 self.ordered_list_stack.pop();
1571 if self.list_depth == 0 {
1572 self.in_list = false;
1573 }
1574 }
1575
1576 Event::Start(Tag::Item) => {
1577 self.text_buffer.clear();
1578 }
1579 Event::End(TagEnd::Item) => {
1580 self.flush_list_item();
1581 }
1582
1583 Event::Start(Tag::Table(alignments)) => {
1585 self.in_table = true;
1586 self.table_alignments = alignments;
1587 self.table_rows.clear();
1588 self.table_header_row = None;
1589 }
1590 Event::End(TagEnd::Table) => {
1591 self.flush_table();
1592 self.in_table = false;
1593 self.table_alignments.clear();
1594 self.table_rows.clear();
1595 self.table_header_row = None;
1596 }
1597
1598 Event::Start(Tag::TableHead) => {
1599 self.table_header = true;
1600 self.table_row.clear();
1601 }
1602 Event::End(TagEnd::TableHead) => {
1603 self.table_header_row = Some(std::mem::take(&mut self.table_row));
1605 self.table_header = false;
1606 }
1607
1608 Event::Start(Tag::TableRow) => {
1609 self.table_row.clear();
1610 }
1611 Event::End(TagEnd::TableRow) => {
1612 self.table_rows.push(std::mem::take(&mut self.table_row));
1614 }
1615
1616 Event::Start(Tag::TableCell) => {
1617 self.current_cell.clear();
1618 }
1619 Event::End(TagEnd::TableCell) => {
1620 self.table_row.push(std::mem::take(&mut self.current_cell));
1621 }
1622
1623 Event::Start(Tag::Emphasis) => {
1625 self.in_emphasis = true;
1626 if self.options.styles.emph.italic == Some(true) && !self.in_table {
1627 self.text_buffer.push_str("\x1b[3m");
1629 }
1630 if !self.in_table {
1631 self.text_buffer
1632 .push_str(&self.options.styles.emph.block_prefix);
1633 } else {
1634 self.current_cell
1635 .push_str(&self.options.styles.emph.block_prefix);
1636 }
1637 }
1638 Event::End(TagEnd::Emphasis) => {
1639 self.in_emphasis = false;
1640 if !self.in_table {
1641 self.text_buffer
1642 .push_str(&self.options.styles.emph.block_suffix);
1643 if self.options.styles.emph.italic == Some(true) {
1644 self.text_buffer.push_str("\x1b[23m");
1646 }
1647 } else {
1648 self.current_cell
1649 .push_str(&self.options.styles.emph.block_suffix);
1650 }
1651 }
1652
1653 Event::Start(Tag::Strong) => {
1654 self.in_strong = true;
1655 if self.options.styles.strong.bold == Some(true) && !self.in_table {
1656 self.text_buffer.push_str("\x1b[1m");
1658 }
1659 if !self.in_table {
1660 self.text_buffer
1661 .push_str(&self.options.styles.strong.block_prefix);
1662 } else {
1663 self.current_cell
1664 .push_str(&self.options.styles.strong.block_prefix);
1665 }
1666 }
1667 Event::End(TagEnd::Strong) => {
1668 self.in_strong = false;
1669 if !self.in_table {
1670 self.text_buffer
1671 .push_str(&self.options.styles.strong.block_suffix);
1672 if self.options.styles.strong.bold == Some(true) {
1673 self.text_buffer.push_str("\x1b[22m");
1675 }
1676 } else {
1677 self.current_cell
1678 .push_str(&self.options.styles.strong.block_suffix);
1679 }
1680 }
1681
1682 Event::Start(Tag::Strikethrough) => {
1683 self.in_strikethrough = true;
1684 if self.options.styles.strikethrough.crossed_out == Some(true) && !self.in_table {
1685 self.text_buffer.push_str("\x1b[9m");
1687 }
1688 if !self.in_table {
1689 self.text_buffer
1690 .push_str(&self.options.styles.strikethrough.block_prefix);
1691 } else {
1692 self.current_cell
1693 .push_str(&self.options.styles.strikethrough.block_prefix);
1694 }
1695 }
1696 Event::End(TagEnd::Strikethrough) => {
1697 self.in_strikethrough = false;
1698 if !self.in_table {
1699 self.text_buffer
1700 .push_str(&self.options.styles.strikethrough.block_suffix);
1701 if self.options.styles.strikethrough.crossed_out == Some(true) {
1702 self.text_buffer.push_str("\x1b[29m");
1704 }
1705 } else {
1706 self.current_cell
1707 .push_str(&self.options.styles.strikethrough.block_suffix);
1708 }
1709 }
1710
1711 Event::Start(Tag::Link {
1712 link_type,
1713 dest_url,
1714 title,
1715 ..
1716 }) => {
1717 self.in_link = true;
1718 self.link_url = dest_url.to_string();
1719 self.link_title = title.to_string();
1720 self.link_is_autolink_email = matches!(link_type, pulldown_cmark::LinkType::Email);
1721 }
1722 Event::End(TagEnd::Link) => {
1723 if self.link_is_autolink_email
1726 && !self.link_url.is_empty()
1727 && !self.link_url.starts_with("mailto:")
1728 {
1729 self.link_url = format!("mailto:{}", self.link_url);
1730 }
1731 if !self.link_url.is_empty() && !self.text_buffer.ends_with(&self.link_url) {
1732 self.text_buffer.push(' ');
1733 self.text_buffer.push_str(&self.link_url);
1734 }
1735 self.in_link = false;
1736 self.link_is_autolink_email = false;
1737 self.link_url.clear();
1738 self.link_title.clear();
1739 }
1740
1741 Event::Start(Tag::Image {
1742 dest_url, title, ..
1743 }) => {
1744 self.in_image = true;
1745 self.image_url = dest_url.to_string();
1746 self.image_title = title.to_string();
1747 }
1748 Event::End(TagEnd::Image) => {
1749 self.flush_image();
1750 self.in_image = false;
1751 }
1752
1753 Event::Text(text) => {
1755 if self.in_code_block {
1756 self.code_block_content.push_str(&text);
1757 } else if self.in_table {
1758 self.current_cell.push_str(&text);
1759 } else if self.in_image {
1760 self.text_buffer.push_str(&text);
1762 } else {
1763 self.text_buffer.push_str(&text);
1764 }
1765 }
1766
1767 Event::Code(code) => {
1768 let styled = self.style_inline_code(&code);
1769 if self.in_table {
1770 self.current_cell.push_str(&styled);
1771 } else {
1772 self.text_buffer.push_str(&styled);
1773 }
1774 }
1775
1776 Event::SoftBreak => {
1777 if self.options.preserve_newlines {
1778 if self.in_table {
1779 self.current_cell.push('\n');
1780 } else {
1781 self.text_buffer.push('\n');
1782 }
1783 } else if self.in_table {
1784 self.current_cell.push(' ');
1785 } else {
1786 self.text_buffer.push(' ');
1787 }
1788 }
1789
1790 Event::HardBreak => {
1791 if self.in_table {
1792 self.current_cell.push('\n');
1793 } else {
1794 self.text_buffer.push('\n');
1795 }
1796 }
1797
1798 Event::Rule => {
1799 self.output
1800 .push_str(&self.options.styles.horizontal_rule.format);
1801 }
1802
1803 Event::TaskListMarker(checked) => {
1804 if checked {
1805 self.text_buffer.push_str(&self.options.styles.task.ticked);
1806 } else {
1807 self.text_buffer
1808 .push_str(&self.options.styles.task.unticked);
1809 }
1810 }
1811
1812 _ => {}
1814 }
1815 }
1816
1817 fn flush_heading(&mut self) {
1818 if let Some(level) = self.in_heading {
1819 let heading_style = self.options.styles.heading_style(level);
1820 let base_heading = &self.options.styles.heading;
1821
1822 let mut heading_text = String::new();
1824 heading_text.push_str(&heading_style.style.prefix);
1825 heading_text.push_str(&self.text_buffer);
1826 heading_text.push_str(&heading_style.style.suffix);
1827
1828 let mut style = base_heading.style.to_lipgloss();
1830
1831 if let Some(ref color) = heading_style.style.color {
1833 style = style.foreground(color.as_str());
1834 }
1835 if let Some(ref bg) = heading_style.style.background_color {
1836 style = style.background(bg.as_str());
1837 }
1838 if heading_style.style.bold == Some(true) {
1839 style = style.bold();
1840 }
1841 if heading_style.style.italic == Some(true) {
1842 style = style.italic();
1843 }
1844
1845 let rendered = style.render(&heading_text);
1846
1847 self.output.push_str(&heading_style.style.block_prefix);
1848 self.output.push('\n');
1849 self.output.push_str(&rendered);
1850 self.output.push_str(&base_heading.style.block_suffix);
1851
1852 self.text_buffer.clear();
1853 }
1854 }
1855
1856 fn flush_paragraph(&mut self) {
1857 if !self.text_buffer.is_empty() {
1858 let text = std::mem::take(&mut self.text_buffer);
1859
1860 let wrapped = self.word_wrap(&text);
1862
1863 let style = self.options.styles.paragraph.style.to_lipgloss();
1865 let rendered = style.render(&wrapped);
1866
1867 if self.block_quote_depth > 0 {
1869 let indent_prefix = self
1870 .options
1871 .styles
1872 .block_quote
1873 .indent_prefix
1874 .as_deref()
1875 .unwrap_or("│ ");
1876 let prefix = indent_prefix.repeat(self.block_quote_depth);
1877 let indented = rendered
1878 .lines()
1879 .map(|line| format!("{}{}", prefix, line))
1880 .collect::<Vec<_>>()
1881 .join("\n");
1882 self.output.push_str(&indented);
1883 self.output.push('\n');
1884 self.block_quote_pending_separator = Some(self.block_quote_depth);
1885 } else {
1886 self.output.push_str(&rendered);
1887 self.output.push_str("\n\n");
1888 }
1889 }
1890 }
1891
1892 fn flush_list_item(&mut self) {
1893 let mut text = std::mem::take(&mut self.text_buffer);
1894 if text.is_empty() {
1895 return;
1896 }
1897
1898 let mut task_marker: Option<String> = None;
1899 for marker in [
1900 &self.options.styles.task.ticked,
1901 &self.options.styles.task.unticked,
1902 ] {
1903 if text.starts_with(marker) {
1904 task_marker = Some(marker.clone());
1905 text = text[marker.len()..].to_string();
1906 break;
1907 }
1908 }
1909
1910 let indent = (self.list_depth - 1) * self.options.styles.list.level_indent;
1911 let indent_str = " ".repeat(indent);
1912
1913 let is_ordered = self.ordered_list_stack.last().copied().unwrap_or(false);
1914 let mut prefix = if is_ordered {
1915 let num = self.list_item_number.last().copied().unwrap_or(1);
1916 if let Some(last) = self.list_item_number.last_mut() {
1917 *last += 1;
1918 }
1919 format!("{}{}", num, &self.options.styles.enumeration.block_prefix)
1920 } else {
1921 self.options.styles.item.block_prefix.clone()
1922 };
1923 if let Some(marker) = task_marker {
1924 prefix = marker;
1925 }
1926
1927 let line = format!("{}{}{}", indent_str, prefix, text.trim());
1928 let doc_style = self.options.styles.document.style.to_lipgloss();
1929 self.output.push_str(&doc_style.render(&line));
1930 self.output.push('\n');
1931 }
1932
1933 fn flush_code_block(&mut self) {
1934 let content = std::mem::take(&mut self.code_block_content);
1935 let language = std::mem::take(&mut self.code_block_language);
1936 let style = &self.options.styles.code_block;
1937
1938 self.output.push('\n');
1939
1940 let margin = style.block.margin.unwrap_or(0);
1942 let margin_str = " ".repeat(margin);
1943
1944 #[cfg(feature = "syntax-highlighting")]
1946 {
1947 use crate::syntax::{LanguageDetector, SyntaxTheme, highlight_code};
1948
1949 let syntax_config = &self.options.styles.syntax_config;
1950
1951 if !language.is_empty() && !syntax_config.is_disabled(&language) {
1952 let resolved_lang = syntax_config.resolve_language(&language);
1954
1955 let detector = LanguageDetector::new();
1956 if detector.is_supported(resolved_lang) {
1957 let theme = SyntaxTheme::from_name(&syntax_config.theme_name)
1959 .or_else(|| {
1960 style
1961 .theme
1962 .as_ref()
1963 .and_then(|name| SyntaxTheme::from_name(name))
1964 })
1965 .unwrap_or_else(SyntaxTheme::default_dark);
1966
1967 let highlighted = highlight_code(&content, resolved_lang, &theme);
1968
1969 for (idx, line) in highlighted.lines().enumerate() {
1971 self.output.push_str(&margin_str);
1972 if syntax_config.line_numbers {
1973 let line_num = idx + 1;
1975 self.output.push_str(&format!("{:4} │ ", line_num));
1976 }
1977 self.output.push_str(line);
1978 self.output.push('\n');
1979 }
1980
1981 self.output.push('\n');
1982 return;
1983 }
1984 }
1985 }
1986
1987 let _ = &language;
1989
1990 for line in content.lines() {
1992 self.output.push_str(&margin_str);
1993 self.output.push_str(line);
1994 self.output.push('\n');
1995 }
1996
1997 self.output.push('\n');
1998 }
1999
2000 fn flush_table(&mut self) {
2001 use crate::table::{
2002 ColumnWidthConfig, MINIMAL_ASCII_BORDER, MINIMAL_BORDER, ParsedTable, TableCell,
2003 calculate_column_widths, render_minimal_row, render_minimal_separator,
2004 };
2005
2006 let num_cols = self.table_alignments.len();
2008 if num_cols == 0 {
2009 return;
2010 }
2011
2012 let mut parsed_table = ParsedTable::new();
2013 parsed_table.alignments = self.table_alignments.clone();
2014
2015 if let Some(header_strs) = &self.table_header_row {
2016 parsed_table.header = header_strs
2017 .iter()
2018 .enumerate()
2019 .map(|(i, s)| {
2020 let align = self
2021 .table_alignments
2022 .get(i)
2023 .copied()
2024 .unwrap_or(pulldown_cmark::Alignment::None);
2025 TableCell::new(s.clone(), align)
2026 })
2027 .collect();
2028 }
2029
2030 for row_strs in &self.table_rows {
2031 let row_cells = row_strs
2032 .iter()
2033 .enumerate()
2034 .map(|(i, s)| {
2035 let align = self
2036 .table_alignments
2037 .get(i)
2038 .copied()
2039 .unwrap_or(pulldown_cmark::Alignment::None);
2040 TableCell::new(s.clone(), align)
2041 })
2042 .collect();
2043 parsed_table.rows.push(row_cells);
2044 }
2045
2046 if parsed_table.is_empty() {
2047 return;
2048 }
2049
2050 let col_sep = self
2053 .options
2054 .styles
2055 .table
2056 .column_separator
2057 .as_deref()
2058 .unwrap_or("│");
2059 let border = if col_sep == "|" {
2060 MINIMAL_ASCII_BORDER
2061 } else {
2062 MINIMAL_BORDER
2063 };
2064
2065 let margin = self
2067 .options
2068 .styles
2069 .document
2070 .margin
2071 .unwrap_or(DEFAULT_MARGIN);
2072 let max_width = self.options.word_wrap.saturating_sub(2 * margin);
2073 let cell_padding = 1;
2074
2075 let width_config = ColumnWidthConfig::new()
2077 .cell_padding(cell_padding)
2078 .border_width(1) .max_table_width(max_width);
2080
2081 let column_widths = calculate_column_widths(&parsed_table, &width_config);
2082 let widths = &column_widths.widths;
2083
2084 let doc_style = &self.options.styles.document.style;
2086 let lipgloss = doc_style.to_lipgloss();
2087 self.output.push('\n');
2089
2090 if !parsed_table.header.is_empty() {
2094 let rendered_header =
2095 render_minimal_row(&parsed_table.header, widths, &border, cell_padding);
2096 self.output.push_str(&lipgloss.render(&rendered_header));
2097 self.output.push('\n');
2098
2099 let sep = render_minimal_separator(widths, &border, cell_padding);
2101 if !sep.is_empty() {
2102 self.output.push_str(&lipgloss.render(&sep));
2103 self.output.push('\n');
2104 }
2105 }
2106
2107 for row in parsed_table.rows.iter() {
2109 let rendered_row = render_minimal_row(row, widths, &border, cell_padding);
2110 self.output.push_str(&lipgloss.render(&rendered_row));
2111 self.output.push('\n');
2112 }
2113
2114 self.output.push('\n');
2117 }
2118
2119 fn flush_image(&mut self) {
2120 let alt_text = std::mem::take(&mut self.text_buffer);
2121 let url = std::mem::take(&mut self.image_url);
2122
2123 let style = &self.options.styles.image_text;
2124 let format = if style.format.is_empty() {
2125 "Image: {{.text}} →"
2126 } else {
2127 &style.format
2128 };
2129
2130 let text = format.replace("{{.text}}", &alt_text);
2131
2132 let link_style = self.options.styles.image.to_lipgloss();
2133 let rendered_url = link_style.render(&url);
2134
2135 self.output.push_str(&text);
2136 self.output.push(' ');
2137 self.output.push_str(&rendered_url);
2138 }
2139
2140 fn style_inline_code(&self, code: &str) -> String {
2141 let style = &self.options.styles.code;
2142 let lipgloss_style = style.style.to_lipgloss();
2143
2144 let code_with_padding = format!("{}{}{}", style.style.prefix, code, style.style.suffix);
2147 lipgloss_style.render(&code_with_padding)
2148 }
2149
2150 #[allow(dead_code)]
2153 fn visible_width(&self, s: &str) -> usize {
2154 visible_width(s)
2155 }
2156
2157 fn word_wrap(&self, text: &str) -> String {
2158 let width = self.options.word_wrap;
2159 if width == 0 {
2160 return text.to_string();
2161 }
2162
2163 let mut result = String::new();
2164 let mut current_line = String::new();
2165
2166 for word in text.split_whitespace() {
2167 if current_line.is_empty() {
2168 current_line.push_str(word);
2169 } else if visible_width(¤t_line) + 1 + visible_width(word) <= width {
2170 current_line.push(' ');
2171 current_line.push_str(word);
2172 } else {
2173 result.push_str(¤t_line);
2174 result.push('\n');
2175 current_line = word.to_string();
2176 }
2177 }
2178
2179 if !current_line.is_empty() {
2180 result.push_str(¤t_line);
2181 }
2182
2183 result
2184 }
2185}
2186
2187pub(crate) fn visible_width(s: &str) -> usize {
2189 let mut width = 0;
2190 #[derive(Clone, Copy, PartialEq)]
2191 enum State {
2192 Normal,
2193 Esc,
2194 Csi,
2195 Osc,
2196 }
2197 let mut state = State::Normal;
2198
2199 for c in s.chars() {
2200 match state {
2201 State::Normal => {
2202 if c == '\x1b' {
2203 state = State::Esc;
2204 } else {
2205 width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
2206 }
2207 }
2208 State::Esc => {
2209 if c == '[' {
2210 state = State::Csi;
2211 } else if c == ']' {
2212 state = State::Osc;
2213 } else {
2214 state = State::Normal;
2217 }
2218 }
2219 State::Csi => {
2220 if ('@'..='~').contains(&c) {
2223 state = State::Normal;
2224 }
2225 }
2226 State::Osc => {
2227 if c == '\x07' {
2230 state = State::Normal;
2231 } else if c == '\x1b' {
2232 state = State::Esc;
2234 }
2235 }
2236 }
2237 }
2238
2239 width
2240}
2241
2242pub fn render(markdown: &str, style: Style) -> Result<String, std::convert::Infallible> {
2248 Ok(Renderer::new().with_style(style).render(markdown))
2249}
2250
2251pub fn render_with_environment_config(markdown: &str) -> String {
2253 let style = std::env::var("GLAMOUR_STYLE")
2255 .ok()
2256 .and_then(|s| match s.as_str() {
2257 "ascii" => Some(Style::Ascii),
2258 "dark" => Some(Style::Dark),
2259 "dracula" => Some(Style::Dracula),
2260 "light" => Some(Style::Light),
2261 "pink" => Some(Style::Pink),
2262 "notty" => Some(Style::NoTty),
2263 "auto" => Some(Style::Auto),
2264 _ => None,
2265 })
2266 .unwrap_or(Style::Auto);
2267
2268 Renderer::new().with_style(style).render(markdown)
2269}
2270
2271pub fn available_styles() -> HashMap<&'static str, Style> {
2273 let mut styles = HashMap::new();
2274 styles.insert("ascii", Style::Ascii);
2275 styles.insert("dark", Style::Dark);
2276 styles.insert("dracula", Style::Dracula);
2277 styles.insert("light", Style::Light);
2278 styles.insert("pink", Style::Pink);
2279 styles.insert("notty", Style::NoTty);
2280 styles.insert("auto", Style::Auto);
2281 styles
2282}
2283
2284pub mod prelude {
2286 pub use crate::{
2287 AnsiOptions, Renderer, RendererOptions, Style, StyleBlock, StyleCodeBlock, StyleConfig,
2288 StyleList, StylePrimitive, StyleTable, StyleTask, TermRenderer, ascii_style,
2289 available_styles, dark_style, dracula_style, light_style, pink_style, render,
2290 render_with_environment_config,
2291 };
2292}
2293
2294#[cfg(test)]
2299mod tests {
2300 use super::*;
2301
2302 #[test]
2303 fn test_renderer_new() {
2304 let renderer = Renderer::new();
2305 assert_eq!(renderer.options.word_wrap, DEFAULT_WIDTH);
2306 }
2307
2308 #[test]
2309 fn test_renderer_with_word_wrap() {
2310 let renderer = Renderer::new().with_word_wrap(120);
2311 assert_eq!(renderer.options.word_wrap, 120);
2312 }
2313
2314 #[test]
2315 fn test_renderer_with_style() {
2316 let renderer = Renderer::new().with_style(Style::Light);
2317 assert!(renderer.options.styles.document.style.color.is_some());
2319 }
2320
2321 #[test]
2322 fn test_render_simple_text() {
2323 let renderer = Renderer::new().with_style(Style::Ascii);
2324 let output = renderer.render("Hello, world!");
2325 assert!(output.contains("Hello, world!"));
2326 }
2327
2328 #[test]
2329 fn test_render_heading() {
2330 let renderer = Renderer::new().with_style(Style::Ascii);
2331 let output = renderer.render("# Heading");
2332 assert!(output.contains("# Heading"));
2333 }
2334
2335 #[test]
2336 fn test_render_emphasis() {
2337 let renderer = Renderer::new().with_style(Style::Ascii);
2338 let output = renderer.render("*italic*");
2339 assert!(output.contains("*italic*"));
2340 }
2341
2342 #[test]
2343 fn test_render_strong() {
2344 let renderer = Renderer::new().with_style(Style::Ascii);
2345 let output = renderer.render("**bold**");
2346 assert!(output.contains("**bold**"));
2347 }
2348
2349 #[test]
2350 fn test_render_code() {
2351 let renderer = Renderer::new().with_style(Style::Ascii);
2352 let output = renderer.render("`code`");
2353 assert!(output.contains("code"));
2355 assert!(!output.contains("`"));
2356 }
2357
2358 #[test]
2359 fn test_render_horizontal_rule() {
2360 let renderer = Renderer::new().with_style(Style::Ascii);
2361 let output = renderer.render("---");
2362 assert!(output.contains("--------"));
2363 }
2364
2365 #[test]
2366 fn test_render_list() {
2367 let renderer = Renderer::new().with_style(Style::Ascii);
2368 let output = renderer.render("* item 1\n* item 2");
2369 assert!(output.contains("item 1"));
2370 assert!(output.contains("item 2"));
2371 }
2372
2373 #[test]
2374 fn test_render_nested_list() {
2375 let renderer = Renderer::new().with_style(Style::Dark);
2376 let output = renderer.render("- Item 1\n - Nested 1\n - Nested 2\n- Item 2");
2377 assert!(output.contains("Item 1"));
2378 assert!(output.contains("Nested 1"));
2379 assert!(output.contains("Nested 2"));
2380 assert!(output.contains("Item 2"));
2381 }
2382
2383 #[test]
2384 fn test_render_mixed_nested_list() {
2385 let renderer = Renderer::new().with_style(Style::Dark);
2386 let output = renderer.render("1. First\n - Sub item\n - Sub item\n2. Second");
2387 assert!(output.contains("First"));
2388 assert!(output.contains("Sub item"));
2389 assert!(output.contains("Second"));
2390 assert!(output.contains("2."));
2392 }
2393
2394 #[test]
2395 fn test_render_link() {
2396 let renderer = Renderer::new().with_style(Style::Dark);
2397 let output = renderer.render("[Link text](https://example.com)");
2398 assert!(output.contains("Link text"));
2399 assert!(output.contains("https://example.com"));
2401 }
2402
2403 #[test]
2404 fn test_render_autolink() {
2405 let renderer = Renderer::new().with_style(Style::Dark);
2406 let output = renderer.render("<https://example.com>");
2407 let url_count = output.matches("https://example.com").count();
2409 assert_eq!(url_count, 1, "Autolink URL should appear exactly once");
2410 }
2411
2412 #[test]
2413 fn test_render_autolink_email() {
2414 let renderer = Renderer::new().with_style(Style::Dark);
2415 let output = renderer.render("<user@example.com>");
2416 assert!(output.contains("user@example.com"));
2417 assert!(output.contains("mailto:user@example.com"));
2418 let mailto_count = output.matches("mailto:user@example.com").count();
2419 assert_eq!(mailto_count, 1, "Email autolink should include mailto once");
2420 }
2421
2422 #[test]
2423 fn test_render_ordered_list() {
2424 let renderer = Renderer::new().with_style(Style::Ascii);
2425 let output = renderer.render("1. first\n2. second");
2426 assert!(output.contains("first"));
2427 assert!(output.contains("second"));
2428 }
2429
2430 #[test]
2431 fn test_render_table() {
2432 let renderer = Renderer::new().with_style(Style::Ascii);
2433 let output = renderer.render("| A | B |\n|---|---|\n| 1 | 2 |");
2434 assert!(output.contains("|"));
2435 assert!(output.contains("A"));
2436 assert!(output.contains("B"));
2437 }
2438
2439 #[test]
2440 fn test_render_table_dark_debug() {
2441 let renderer = Renderer::new().with_style(Style::Dark);
2442 let output = renderer.render("| A | B |\n|---|---|\n| 1 | 2 |");
2443
2444 eprintln!("=== RUST TABLE OUTPUT (2x2, dark) ===");
2446 for (i, line) in output.lines().enumerate() {
2447 eprintln!("Line {}: len={} chars", i, line.chars().count());
2448 let escaped: String = line
2450 .chars()
2451 .map(|c| {
2452 if c == '\x1b' {
2453 "\\x1b".to_string()
2454 } else if c == '│' {
2455 "│".to_string()
2456 } else if c == '─' {
2457 "─".to_string()
2458 } else if c == '┼' {
2459 "┼".to_string()
2460 } else {
2461 c.to_string()
2462 }
2463 })
2464 .collect();
2465 eprintln!(" {:?}", escaped);
2466 }
2467 eprintln!("=== END OUTPUT ===");
2468
2469 assert!(
2471 output.contains("│") || output.contains("|"),
2472 "Should contain column separator"
2473 );
2474 assert!(output.contains("A"), "Should contain header A");
2475 }
2476
2477 #[test]
2478 fn test_style_primitive_builder() {
2479 let style = StylePrimitive::new()
2480 .color("red")
2481 .bold(true)
2482 .prefix("> ")
2483 .suffix(" <");
2484
2485 assert_eq!(style.color, Some("red".to_string()));
2486 assert_eq!(style.bold, Some(true));
2487 assert_eq!(style.prefix, "> ");
2488 assert_eq!(style.suffix, " <");
2489 }
2490
2491 #[test]
2492 fn test_style_block_builder() {
2493 let block = StyleBlock::new().margin(4).indent(2).indent_prefix(" ");
2494
2495 assert_eq!(block.margin, Some(4));
2496 assert_eq!(block.indent, Some(2));
2497 assert_eq!(block.indent_prefix, Some(" ".to_string()));
2498 }
2499
2500 #[test]
2501 fn test_style_config_heading() {
2502 let config = dark_style();
2503 let h1 = config.heading_style(HeadingLevel::H1);
2504 assert!(
2505 !h1.style.prefix.is_empty() || h1.style.suffix.len() > 0 || h1.style.color.is_some()
2506 );
2507 }
2508
2509 #[test]
2510 fn test_available_styles() {
2511 let styles = available_styles();
2512 assert!(styles.contains_key("dark"));
2513 assert!(styles.contains_key("light"));
2514 assert!(styles.contains_key("ascii"));
2515 assert!(styles.contains_key("pink"));
2516 }
2517
2518 #[test]
2519 fn test_render_function() {
2520 let result = render("# Test", Style::Ascii);
2521 assert!(result.is_ok());
2522 assert!(result.unwrap().contains("Test"));
2523 }
2524
2525 #[test]
2526 fn test_dark_style() {
2527 let config = dark_style();
2528 assert!(config.heading.style.bold == Some(true));
2529 assert!(config.document.margin.is_some());
2530 }
2531
2532 #[test]
2533 fn test_light_style() {
2534 let config = light_style();
2535 assert!(config.heading.style.bold == Some(true));
2536 }
2537
2538 #[test]
2539 fn test_ascii_style() {
2540 let config = ascii_style();
2541 assert_eq!(config.h1.style.prefix, "# ");
2542 }
2543
2544 #[test]
2545 fn test_ascii_style_inline_code_and_lists() {
2546 let renderer = Renderer::new().with_style(Style::Ascii);
2547 let output = renderer.render("A `code` example.\n\n- Item 1\n- Item 2");
2548 assert!(output.contains("code"));
2549 assert!(!output.contains("`code`"));
2550 assert!(output.contains("• Item 1"));
2551 assert!(output.contains("• Item 2"));
2552 }
2553
2554 #[test]
2555 fn test_pink_style() {
2556 let config = pink_style();
2557 assert!(config.heading.style.color.is_some());
2558 }
2559
2560 #[test]
2561 fn test_dracula_style() {
2562 let config = dracula_style();
2563 assert_eq!(config.h1.style.prefix, "# ");
2565 assert_eq!(config.h2.style.prefix, "## ");
2566 assert_eq!(config.h3.style.prefix, "### ");
2567 assert!(config.heading.style.bold == Some(true));
2569 assert!(config.heading.style.color.is_some());
2570 assert_eq!(config.heading.style.color.as_deref(), Some("#bd93f9")); assert_eq!(config.strong.color.as_deref(), Some("#ffb86c")); assert_eq!(config.emph.color.as_deref(), Some("#f1fa8c")); }
2575
2576 #[test]
2577 fn test_dracula_heading_output() {
2578 let renderer = Renderer::new().with_style(Style::Dracula);
2579 let output = renderer.render("# Heading");
2580 assert!(output.contains("# "), "Dracula h1 should have '# ' prefix");
2582 assert!(output.contains("Heading"));
2583 }
2584
2585 #[test]
2586 fn test_word_wrap() {
2587 let renderer = Renderer::new().with_word_wrap(20);
2588 let output = renderer.render("This is a very long line that should be wrapped.");
2589 assert!(output.len() > 0);
2591 }
2592
2593 #[test]
2594 fn test_render_code_block() {
2595 let renderer = Renderer::new().with_style(Style::Ascii);
2596 let output = renderer.render("```rust\nfn main() {}\n```");
2597 assert!(output.contains("fn"));
2600 assert!(output.contains("main"));
2601 }
2602
2603 #[test]
2604 fn test_render_blockquote() {
2605 let renderer = Renderer::new().with_style(Style::Dark);
2606 let output = renderer.render("> quoted text");
2607 assert!(output.contains("quoted"));
2608 }
2609
2610 #[test]
2611 fn test_strikethrough() {
2612 let renderer = Renderer::new().with_style(Style::Ascii);
2613 let output = renderer.render("~~deleted~~");
2614 assert!(output.contains("~~"));
2615 assert!(output.contains("deleted"));
2616 }
2617
2618 #[test]
2619 fn test_task_list() {
2620 let renderer = Renderer::new().with_style(Style::Ascii);
2621 let output = renderer.render("- [ ] todo\n- [x] done");
2622 assert!(output.contains("[ ] todo"));
2623 assert!(output.contains("[x] done"));
2624 assert!(!output.contains("* [ ]"));
2625 }
2626
2627 #[cfg(feature = "syntax-highlighting")]
2632 mod syntax_config_tests {
2633 use super::*;
2634
2635 #[test]
2636 fn test_syntax_theme_config_default() {
2637 let config = SyntaxThemeConfig::default();
2638 assert_eq!(config.theme_name, "base16-ocean.dark");
2639 assert!(!config.line_numbers);
2640 assert!(config.language_aliases.is_empty());
2641 assert!(config.disabled_languages.is_empty());
2642 }
2643
2644 #[test]
2645 fn test_syntax_theme_config_builder() {
2646 let config = SyntaxThemeConfig::new()
2647 .theme("Solarized (dark)")
2648 .line_numbers(true)
2649 .language_alias("dockerfile", "docker")
2650 .disable_language("text");
2651
2652 assert_eq!(config.theme_name, "Solarized (dark)");
2653 assert!(config.line_numbers);
2654 assert_eq!(
2655 config.language_aliases.get("dockerfile"),
2656 Some(&"docker".to_string())
2657 );
2658 assert!(config.disabled_languages.contains("text"));
2659 }
2660
2661 #[test]
2662 fn test_syntax_theme_config_resolve_language() {
2663 let config = SyntaxThemeConfig::new()
2664 .language_alias("rs", "rust")
2665 .language_alias("dockerfile", "docker");
2666
2667 assert_eq!(config.resolve_language("rs"), "rust");
2668 assert_eq!(config.resolve_language("dockerfile"), "docker");
2669 assert_eq!(config.resolve_language("python"), "python"); }
2671
2672 #[test]
2673 fn test_syntax_theme_config_is_disabled() {
2674 let config = SyntaxThemeConfig::new()
2675 .disable_language("text")
2676 .disable_language("plain");
2677
2678 assert!(config.is_disabled("text"));
2679 assert!(config.is_disabled("plain"));
2680 assert!(!config.is_disabled("rust"));
2681 }
2682
2683 #[test]
2684 fn test_syntax_theme_config_validate() {
2685 let valid = SyntaxThemeConfig::new().theme("base16-ocean.dark");
2686 assert!(valid.validate().is_ok());
2687
2688 let invalid = SyntaxThemeConfig::new().theme("nonexistent-theme");
2689 assert!(invalid.validate().is_err());
2690 let err = invalid.validate().unwrap_err();
2691 assert!(err.contains("Unknown syntax theme"));
2692 assert!(err.contains("nonexistent-theme"));
2693 }
2694
2695 #[test]
2696 fn test_style_config_syntax_methods() {
2697 let config = StyleConfig::default()
2698 .syntax_theme("Solarized (dark)")
2699 .with_line_numbers(true)
2700 .language_alias("rs", "rust")
2701 .disable_language("text");
2702
2703 assert_eq!(config.syntax().theme_name, "Solarized (dark)");
2704 assert!(config.syntax().line_numbers);
2705 assert_eq!(
2706 config.syntax().language_aliases.get("rs"),
2707 Some(&"rust".to_string())
2708 );
2709 assert!(config.syntax().disabled_languages.contains("text"));
2710 }
2711
2712 #[test]
2713 fn test_style_config_with_syntax_config() {
2714 let syntax_config = SyntaxThemeConfig::new()
2715 .theme("InspiredGitHub")
2716 .line_numbers(true);
2717
2718 let style_config = StyleConfig::default().with_syntax_config(syntax_config);
2719
2720 assert_eq!(style_config.syntax().theme_name, "InspiredGitHub");
2721 assert!(style_config.syntax().line_numbers);
2722 }
2723
2724 #[test]
2725 fn test_render_with_line_numbers() {
2726 let config = StyleConfig::default().with_line_numbers(true);
2727 let renderer = Renderer::new().with_style_config(config);
2728
2729 let output = renderer.render("```rust\nfn main() {\n println!(\"Hello\");\n}\n```");
2730
2731 assert!(output.contains("1 │"));
2733 assert!(output.contains("2 │"));
2734 assert!(output.contains("3 │"));
2735 }
2736
2737 #[test]
2738 fn test_render_with_disabled_language() {
2739 let config = StyleConfig::default().disable_language("rust");
2740 let renderer = Renderer::new().with_style_config(config);
2741
2742 let output = renderer.render("```rust\nfn main() {}\n```");
2743
2744 assert!(output.contains("fn main()"));
2747 }
2748
2749 #[test]
2750 fn test_render_with_language_alias() {
2751 let config = StyleConfig::default().language_alias("rs", "rust");
2752 let renderer = Renderer::new().with_style_config(config);
2753
2754 let output = renderer.render("```rs\nfn main() {}\n```");
2755
2756 assert!(output.contains("fn"));
2758 assert!(output.contains("main"));
2759 assert!(output.contains('\x1b'));
2760 }
2761
2762 #[test]
2763 fn test_runtime_theme_switching() {
2764 let mut renderer = Renderer::new();
2765
2766 let original_theme = renderer.syntax_config().theme_name.clone();
2768 assert_eq!(original_theme, "base16-ocean.dark");
2769
2770 renderer.set_syntax_theme("Solarized (dark)").unwrap();
2772 assert_eq!(renderer.syntax_config().theme_name, "Solarized (dark)");
2773
2774 let output = renderer.render("```rust\nfn main() {}\n```");
2776 assert!(output.contains('\x1b')); }
2778
2779 #[test]
2780 fn test_runtime_theme_switching_invalid_theme() {
2781 let mut renderer = Renderer::new();
2782
2783 let result = renderer.set_syntax_theme("nonexistent-theme-xyz");
2784 assert!(result.is_err());
2785
2786 let err = result.unwrap_err();
2787 assert!(err.contains("Unknown syntax theme"));
2788 assert!(err.contains("nonexistent-theme-xyz"));
2789 assert!(err.contains("Available themes"));
2790
2791 assert_eq!(renderer.syntax_config().theme_name, "base16-ocean.dark");
2793 }
2794
2795 #[test]
2796 fn test_runtime_line_numbers_toggle() {
2797 let mut renderer = Renderer::new();
2798
2799 assert!(!renderer.syntax_config().line_numbers);
2801
2802 renderer.set_line_numbers(true);
2804 assert!(renderer.syntax_config().line_numbers);
2805
2806 let output = renderer.render("```rust\nfn main() {}\n```");
2807 assert!(output.contains("1 │"));
2808
2809 renderer.set_line_numbers(false);
2811 assert!(!renderer.syntax_config().line_numbers);
2812 }
2813
2814 #[test]
2815 fn test_syntax_config_mut() {
2816 let mut renderer = Renderer::new();
2817
2818 renderer
2820 .syntax_config_mut()
2821 .language_aliases
2822 .insert("myrs".to_string(), "rust".to_string());
2823
2824 let config = renderer.syntax_config();
2825 assert_eq!(
2826 config.language_aliases.get("myrs"),
2827 Some(&"rust".to_string())
2828 );
2829 }
2830
2831 #[test]
2836 fn try_alias_valid_language_succeeds() {
2837 let config = SyntaxThemeConfig::new()
2838 .try_language_alias("rs", "rust")
2839 .unwrap();
2840 assert_eq!(config.resolve_language("rs"), "rust");
2841 }
2842
2843 #[test]
2844 fn try_alias_invalid_language_fails() {
2845 let result = SyntaxThemeConfig::new().try_language_alias("foo", "nonexistent-lang-xyz");
2846 assert!(result.is_err());
2847 let err = result.unwrap_err();
2848 assert!(err.contains("Unknown target language"));
2849 assert!(err.contains("nonexistent-lang-xyz"));
2850 }
2851
2852 #[test]
2853 fn try_alias_direct_cycle_detected() {
2854 let config = SyntaxThemeConfig::new()
2857 .try_language_alias("py3", "python")
2858 .unwrap();
2859 let result = config.try_language_alias("python", "py3");
2860 assert!(result.is_err());
2861 let err = result.unwrap_err();
2862 assert!(err.contains("cycle"));
2863 }
2864
2865 #[test]
2866 fn try_alias_indirect_cycle_detected() {
2867 let config = SyntaxThemeConfig::new()
2870 .try_language_alias("py3", "python")
2871 .unwrap()
2872 .try_language_alias("python", "rs")
2873 .unwrap();
2874 let result = config.try_language_alias("rs", "py3");
2875 assert!(result.is_err());
2876 let err = result.unwrap_err();
2877 assert!(err.contains("cycle"));
2878 }
2879
2880 #[test]
2881 fn try_alias_no_false_cycle_for_chain() {
2882 let config = SyntaxThemeConfig::new()
2884 .try_language_alias("a", "rust")
2885 .unwrap()
2886 .try_language_alias("b", "rust")
2887 .unwrap();
2888 assert_eq!(config.resolve_language("a"), "rust");
2889 assert_eq!(config.resolve_language("b"), "rust");
2890 }
2891
2892 #[test]
2893 fn try_alias_overwrite_existing_alias() {
2894 let config = SyntaxThemeConfig::new()
2896 .try_language_alias("rs", "rust")
2897 .unwrap()
2898 .try_language_alias("rs", "python")
2899 .unwrap();
2900 assert_eq!(config.resolve_language("rs"), "python");
2901 }
2902
2903 #[test]
2904 fn try_alias_via_style_config_valid() {
2905 let config = StyleConfig::default()
2906 .try_language_alias("rs", "rust")
2907 .unwrap();
2908 assert_eq!(
2909 config.syntax().language_aliases.get("rs"),
2910 Some(&"rust".to_string())
2911 );
2912 }
2913
2914 #[test]
2915 fn try_alias_via_style_config_invalid() {
2916 let result = StyleConfig::default().try_language_alias("foo", "nonexistent-lang-xyz");
2917 assert!(result.is_err());
2918 }
2919
2920 #[test]
2921 fn validate_catches_bad_alias_target() {
2922 let mut config = SyntaxThemeConfig::new();
2923 config
2925 .language_aliases
2926 .insert("foo".into(), "nonexistent-lang".into());
2927 let result = config.validate();
2928 assert!(result.is_err());
2929 let err = result.unwrap_err();
2930 assert!(err.contains("unrecognized language"));
2931 assert!(err.contains("nonexistent-lang"));
2932 }
2933
2934 #[test]
2935 fn validate_catches_alias_cycle() {
2936 let mut config = SyntaxThemeConfig::new();
2937 config.language_aliases.insert("python".into(), "rs".into());
2941 config.language_aliases.insert("rs".into(), "python".into());
2942 let result = config.validate();
2943 assert!(result.is_err());
2944 let err = result.unwrap_err();
2945 assert!(err.contains("cycle"));
2946 }
2947
2948 #[test]
2949 fn validate_accepts_good_config() {
2950 let config = SyntaxThemeConfig::new()
2951 .try_language_alias("rs", "rust")
2952 .unwrap()
2953 .try_language_alias("py3", "python")
2954 .unwrap();
2955 assert!(config.validate().is_ok());
2956 }
2957
2958 #[test]
2959 fn unchecked_alias_still_works() {
2960 let config = SyntaxThemeConfig::new().language_alias("foo", "nonexistent");
2962 assert_eq!(config.resolve_language("foo"), "nonexistent");
2963 }
2964
2965 #[test]
2966 fn self_alias_is_cycle() {
2967 let result = SyntaxThemeConfig::new().try_language_alias("rust", "rust");
2968 assert!(result.is_err());
2969 let err = result.unwrap_err();
2970 assert!(err.contains("cycle"));
2971 }
2972 }
2973}
2974
2975#[cfg(test)]
2980#[cfg(feature = "syntax-highlighting")]
2981mod e2e_highlighting_tests {
2982 use super::*;
2983
2984 #[test]
2989 fn test_document_with_mixed_code_blocks() {
2990 let markdown = r#"
2991# My Document
2992
2993Here's some Rust:
2994
2995```rust
2996fn main() {
2997 println!("Hello");
2998}
2999```
3000
3001And some Python:
3002
3003```python
3004def main():
3005 print("Hello")
3006```
3007
3008And some JSON:
3009
3010```json
3011{"key": "value"}
3012```
3013"#;
3014
3015 let renderer = Renderer::new().with_style(Style::Dark);
3016 let output = renderer.render(markdown);
3017
3018 assert!(output.contains("\x1b["), "Should have color codes");
3020
3021 assert!(output.contains("fn"), "Should contain Rust fn keyword");
3023 assert!(output.contains("main"), "Should contain main function");
3024 assert!(output.contains("def"), "Should contain Python def keyword");
3025 assert!(output.contains("key"), "Should contain JSON key");
3026 }
3027
3028 #[test]
3029 fn test_document_with_inline_code_not_syntax_highlighted() {
3030 let renderer = Renderer::new().with_style(Style::Dark);
3031 let markdown = "Here is `inline code` in a sentence.";
3032 let output = renderer.render(markdown);
3033
3034 assert!(
3036 output.contains("inline code"),
3037 "Should contain inline code text"
3038 );
3039 }
3041
3042 #[test]
3043 fn test_real_readme_rendering() {
3044 let readme = r#"
3046# My Project
3047
3048A library for doing things.
3049
3050## Installation
3051
3052```bash
3053cargo add my-project
3054```
3055
3056## Usage
3057
3058```rust
3059use my_project::do_thing;
3060
3061fn main() {
3062 do_thing();
3063}
3064```
3065
3066## Features
3067
3068- Feature 1
3069- Feature 2
3070- Feature 3
3071
3072| Column A | Column B |
3073|----------|----------|
3074| Value 1 | Value 2 |
3075
3076## License
3077
3078MIT
3079"#;
3080
3081 let config = StyleConfig::default().syntax_theme("base16-ocean.dark");
3082 let renderer = Renderer::new().with_style_config(config);
3083
3084 let output = renderer.render(readme);
3086
3087 assert!(
3089 output.len() > readme.len() / 2,
3090 "Output should be substantial, got {} chars from {} input chars",
3091 output.len(),
3092 readme.len()
3093 );
3094
3095 assert!(output.contains("My Project"), "Should contain title");
3097 assert!(output.contains("cargo"), "Should contain cargo command");
3098 assert!(output.contains("do_thing"), "Should contain Rust code");
3099 }
3100
3101 #[test]
3106 fn test_theme_consistency_across_blocks() {
3107 let markdown = r#"
3108```rust
3109fn a() {}
3110```
3111
3112Some text in between.
3113
3114```rust
3115fn b() {}
3116```
3117"#;
3118
3119 let renderer = Renderer::new().with_style(Style::Dark);
3120 let output = renderer.render(markdown);
3121
3122 let fn_indices: Vec<_> = output.match_indices("fn").collect();
3124 assert!(
3125 fn_indices.len() >= 2,
3126 "Should have at least 2 'fn' keywords, found {}",
3127 fn_indices.len()
3128 );
3129
3130 let get_escape_before = |idx: usize| -> Option<&str> {
3132 let prefix = &output[..idx];
3133 if let Some(esc_start) = prefix.rfind("\x1b[") {
3135 let search_area = &prefix[esc_start..];
3137 if let Some(m_pos) = search_area.find('m') {
3138 return Some(&prefix[esc_start..esc_start + m_pos + 1]);
3139 }
3140 }
3141 None
3142 };
3143
3144 let color1 = get_escape_before(fn_indices[0].0);
3145 let color2 = get_escape_before(fn_indices[1].0);
3146
3147 assert_eq!(
3148 color1, color2,
3149 "Same tokens should have same colors: {:?} vs {:?}",
3150 color1, color2
3151 );
3152 }
3153
3154 #[test]
3159 fn test_malformed_language_tag() {
3160 let markdown = "```rust with extra stuff\nfn main() {}\n```";
3162
3163 let renderer = Renderer::new().with_style(Style::Dark);
3164 let output = renderer.render(markdown);
3166
3167 assert!(
3169 output.contains("fn main"),
3170 "Should contain code content even with malformed tag"
3171 );
3172 }
3173
3174 #[test]
3175 fn test_very_long_code_block() {
3176 let code = "let x = 1;\n".repeat(1000); let markdown = format!("```rust\n{}```", code);
3178
3179 let start = std::time::Instant::now();
3181 let renderer = Renderer::new().with_style(Style::Dark);
3182 let output = renderer.render(&markdown);
3183 let duration = start.elapsed();
3184
3185 assert!(
3186 duration.as_secs() < 5,
3187 "Should complete in <5s, took {:?}",
3188 duration
3189 );
3190 assert!(output.contains("let"), "Should contain let keyword");
3192 assert!(output.contains("x"), "Should contain variable x");
3193 }
3194
3195 #[test]
3196 fn test_code_block_with_unicode() {
3197 let markdown = r#"
3198```rust
3199fn main() {
3200 let emoji = "🦀";
3201 let chinese = "你好";
3202 let japanese = "こんにちは";
3203 let arabic = "مرحبا";
3204}
3205```
3206"#;
3207
3208 let renderer = Renderer::new().with_style(Style::Dark);
3209 let output = renderer.render(markdown);
3210
3211 assert!(output.contains("🦀"), "Should preserve crab emoji");
3212 assert!(
3213 output.contains("你好"),
3214 "Should preserve Chinese characters"
3215 );
3216 assert!(
3217 output.contains("こんにちは"),
3218 "Should preserve Japanese characters"
3219 );
3220 assert!(
3221 output.contains("مرحبا"),
3222 "Should preserve Arabic characters"
3223 );
3224 }
3225
3226 #[test]
3227 fn test_empty_code_block() {
3228 let markdown = "```rust\n```";
3229
3230 let renderer = Renderer::new().with_style(Style::Dark);
3231 let output = renderer.render(markdown);
3233
3234 assert!(output.len() > 0, "Should produce some output");
3236 }
3237
3238 #[test]
3239 fn test_code_block_with_only_whitespace() {
3240 let markdown = "```rust\n \n\t\n \n```";
3241
3242 let renderer = Renderer::new().with_style(Style::Dark);
3243 let output = renderer.render(markdown);
3245
3246 assert!(output.len() > 0, "Should produce some output");
3248 }
3249
3250 #[test]
3251 fn test_unknown_language_graceful_fallback() {
3252 let markdown = "```notareallanguage123\nsome code here\n```";
3253
3254 let renderer = Renderer::new().with_style(Style::Dark);
3255 let output = renderer.render(markdown);
3257
3258 assert!(
3259 output.contains("some code here"),
3260 "Should render unknown language code as plain text"
3261 );
3262 }
3263
3264 #[test]
3265 fn test_special_characters_in_code() {
3266 let markdown = r#"
3267```rust
3268fn main() {
3269 let s = "<script>alert('xss')</script>";
3270 let regex = r"[a-z]+\d*";
3271 let backslash = "\\";
3272 let null_byte = "\0";
3273}
3274```
3275"#;
3276
3277 let renderer = Renderer::new().with_style(Style::Dark);
3278 let output = renderer.render(markdown);
3280
3281 assert!(
3282 output.contains("script"),
3283 "Should handle HTML-like content in code"
3284 );
3285 assert!(output.contains("regex"), "Should handle regex patterns");
3286 }
3287
3288 #[test]
3293 fn test_different_themes_produce_different_output() {
3294 let markdown = "```rust\nfn main() {}\n```";
3295
3296 let theme1 = StyleConfig::default().syntax_theme("base16-ocean.dark");
3297 let theme2 = StyleConfig::default().syntax_theme("Solarized (dark)");
3298
3299 let renderer1 = Renderer::new().with_style_config(theme1);
3300 let renderer2 = Renderer::new().with_style_config(theme2);
3301
3302 let output1 = renderer1.render(markdown);
3303 let output2 = renderer2.render(markdown);
3304
3305 assert_ne!(
3307 output1, output2,
3308 "Different themes should produce different output"
3309 );
3310
3311 assert!(output1.contains("fn"), "Theme 1 should contain code");
3313 assert!(output2.contains("fn"), "Theme 2 should contain code");
3314 }
3315
3316 #[test]
3317 fn test_all_available_themes_render_without_panic() {
3318 use crate::syntax::SyntaxTheme;
3319
3320 let markdown = "```rust\nfn main() { println!(\"hello\"); }\n```";
3321
3322 for theme_name in SyntaxTheme::available_themes() {
3323 let config = StyleConfig::default().syntax_theme(theme_name);
3324 let renderer = Renderer::new().with_style_config(config);
3325
3326 let output = renderer.render(markdown);
3328 assert!(
3329 output.contains("fn"),
3330 "Theme '{}' should render code content",
3331 theme_name
3332 );
3333 }
3334 }
3335
3336 #[test]
3341 fn test_line_numbers_correct_count() {
3342 let markdown = "```rust\nline1\nline2\nline3\nline4\nline5\n```";
3343
3344 let config = StyleConfig::default().with_line_numbers(true);
3345 let renderer = Renderer::new().with_style_config(config);
3346 let output = renderer.render(markdown);
3347
3348 assert!(output.contains("1 │"), "Should have line 1");
3350 assert!(output.contains("2 │"), "Should have line 2");
3351 assert!(output.contains("3 │"), "Should have line 3");
3352 assert!(output.contains("4 │"), "Should have line 4");
3353 assert!(output.contains("5 │"), "Should have line 5");
3354 }
3355
3356 #[test]
3357 fn test_line_numbers_disabled_by_default() {
3358 let markdown = "```rust\nfn main() {}\n```";
3359
3360 let renderer = Renderer::new().with_style(Style::Dark);
3361 let output = renderer.render(markdown);
3362
3363 assert!(
3365 !output.contains("1 │"),
3366 "Line numbers should be disabled by default"
3367 );
3368 }
3369
3370 #[test]
3375 fn test_custom_language_alias_applied() {
3376 let markdown = "```myrust\nfn main() {}\n```";
3377
3378 let config = StyleConfig::default().language_alias("myrust", "rust");
3379 let renderer = Renderer::new().with_style_config(config);
3380 let output = renderer.render(markdown);
3381
3382 assert!(
3384 output.contains('\x1b'),
3385 "Custom alias 'myrust' should be highlighted as Rust"
3386 );
3387 }
3388
3389 #[test]
3394 fn test_many_small_code_blocks_performance() {
3395 let mut markdown = String::new();
3397 for i in 0..100 {
3398 markdown.push_str(&format!("\n```rust\nfn func_{}() {{ }}\n```\n", i));
3399 }
3400
3401 let start = std::time::Instant::now();
3402 let renderer = Renderer::new().with_style(Style::Dark);
3403 let output = renderer.render(&markdown);
3404 let duration = start.elapsed();
3405
3406 assert!(
3407 duration.as_secs() < 5,
3408 "100 code blocks should render in <5s, took {:?}",
3409 duration
3410 );
3411 assert!(output.contains("func_0"), "Should contain first function");
3412 assert!(output.contains("func_99"), "Should contain last function");
3413 }
3414
3415 #[test]
3420 fn test_code_blocks_with_surrounding_elements() {
3421 let markdown = r#"
3422# Header
3423
3424Some **bold** and *italic* text.
3425
3426> A blockquote with `inline code`.
3427
3428```rust
3429fn main() {}
3430```
3431
3432| Table | Header |
3433|-------|--------|
3434| cell | cell |
3435
34361. List item 1
34372. List item 2
3438
3439```python
3440def hello():
3441 pass
3442```
3443
3444---
3445
3446The end.
3447"#;
3448
3449 let renderer = Renderer::new().with_style(Style::Dark);
3450 let output = renderer.render(markdown);
3452
3453 assert!(output.contains("Header"), "Should contain heading");
3455 assert!(output.contains("fn"), "Should contain Rust fn keyword");
3456 assert!(output.contains("def"), "Should contain Python def keyword");
3457 assert!(output.contains("Table"), "Should contain table");
3458 assert!(output.contains("List item"), "Should contain list");
3459 }
3460}
3461
3462#[cfg(test)]
3463mod table_spacing_tests {
3464 use super::*;
3465
3466 #[test]
3467 fn test_table_spacing_matches_go() {
3468 let renderer = Renderer::new().with_style(Style::Dark);
3469 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
3470 let output = renderer.render(md);
3471
3472 for (i, line) in output.lines().enumerate() {
3474 eprintln!("Line {}: {:?}", i, line);
3475 }
3476
3477 let lines: Vec<&str> = output.lines().collect();
3478 assert!(
3487 lines.len() >= 4,
3488 "Expected at least 4 lines for minimal table"
3489 );
3490
3491 let header_line = lines
3493 .iter()
3494 .find(|l| l.contains('A') && l.contains('B'))
3495 .expect("Should have header row with A and B");
3496 assert!(
3497 header_line.contains('│'),
3498 "Should have internal column separator"
3499 );
3500
3501 let sep_line = lines
3503 .iter()
3504 .find(|l| l.contains('─') && l.contains('┼'))
3505 .expect("Should have header separator");
3506 assert!(sep_line.contains('┼'), "Should have cross junction");
3507
3508 for line in &lines {
3510 assert!(!line.contains('╭'), "Should NOT have top-left corner");
3511 assert!(!line.contains('╰'), "Should NOT have bottom-left corner");
3512 }
3513 }
3514
3515 #[test]
3516 fn test_table_respects_word_wrap() {
3517 let markdown = "| A | B |\n|---|---|\n| 1 | 2 |";
3518
3519 let renderer_small = Renderer::new().with_word_wrap(40).with_style(Style::Ascii);
3521 let output_small = renderer_small.render(markdown);
3522
3523 let renderer_large = Renderer::new().with_word_wrap(120).with_style(Style::Ascii);
3525 let output_large = renderer_large.render(markdown);
3526
3527 let small_sep = output_small
3530 .lines()
3531 .find(|l| l.contains('─') && l.contains('|'))
3532 .expect("Could not find header separator in small output");
3533
3534 let large_sep = output_large
3535 .lines()
3536 .find(|l| l.contains('─') && l.contains('|'))
3537 .expect("Could not find header separator in large output");
3538
3539 let width_small = small_sep.chars().count();
3547 let width_large = large_sep.chars().count();
3548
3549 assert!(width_small <= 40, "Small table should fit in 40 chars");
3550 assert_eq!(
3551 width_small, width_large,
3552 "Table should be compact (content-sized) when it fits"
3553 );
3554 }
3555
3556 #[test]
3557 fn test_image_link_arrow_glyph() {
3558 let renderer = Renderer::new().with_style(Style::Dark);
3560 let output = renderer.render("");
3561 assert!(
3562 output.contains("→"),
3563 "Image link should use Unicode arrow (→), got: {}",
3564 output
3565 );
3566 assert!(output.contains("Image: Alt text"));
3567 assert!(output.contains("https://example.com/image.png"));
3568 }
3569
3570 #[test]
3571 fn test_image_link_arrow_in_all_styles() {
3572 for style in [Style::Dark, Style::Light, Style::Dracula] {
3574 let renderer = Renderer::new().with_style(style);
3575 let output = renderer.render("");
3576 assert!(
3577 output.contains("→"),
3578 "{:?} style should use Unicode arrow (→)",
3579 style
3580 );
3581 }
3582 }
3583}