1use std::collections::{BTreeMap, HashMap};
8use std::path::Path;
9use std::{fs, io};
10
11use anyhow::{Result, anyhow};
12use crossterm::event::{
13 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode,
14 KeyEventKind, MouseEventKind
15};
16use crossterm::execute;
17use crossterm::terminal::{
18 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
19 enable_raw_mode
20};
21use pulldown_cmark::{
22 Event as CmarkEvent, HeadingLevel, Options, Parser, Tag, TagEnd
23};
24use ratatui::prelude::*;
25use ratatui::widgets::{
26 Block, Borders, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
27 ScrollbarState, Wrap
28};
29use syntect::easy::HighlightLines;
30use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
31use syntect::parsing::SyntaxSet;
32use syntect::util::LinesWithEndings;
33use walkdir::WalkDir;
34
35use crate::pkg::types::{self};
36use crate::pkg::{db, local, resolve};
37
38struct App<'a> {
40 pages: Vec<(String, Vec<Line<'a>>)>,
42 current_page: usize,
44 scroll: u16,
46 content_height: u16
48}
49
50impl App<'_> {
51 fn try_new(pages: BTreeMap<String, String>) -> Result<Self> {
57 let mut parsed_pages = Vec::new();
58 for (name, content) in pages {
59 let lines = parse_markdown(&content)?;
60 parsed_pages.push((name, lines));
61 }
62
63 if parsed_pages.is_empty() {
64 return Err(anyhow!("No manual pages found."));
65 }
66
67 let content_height =
68 u16::try_from(parsed_pages.first().map_or(0, |p| p.1.len()))
69 .unwrap_or(u16::MAX);
70 Ok(Self {
71 pages: parsed_pages,
72 current_page: 0,
73 scroll: 0,
74 content_height
75 })
76 }
77}
78
79pub fn run(
88 package_name: &str,
89 upstream: bool,
90 raw: bool,
91 no_tui: bool
92) -> Result<()> {
93 let (pkg, registry_handle) = resolve_package_for_man(package_name)?;
94
95 let pages =
96 gather_manual_pages(&pkg, registry_handle.as_deref(), upstream, raw)?;
97
98 if pages.is_empty() {
99 return Err(anyhow!(
100 "Package '{}' does not have any manual pages.",
101 pkg.name
102 ));
103 }
104
105 if raw {
106 let multi = pages.len() > 1;
107 for (name, content) in pages {
108 if multi {
109 println!("--- {name} ---");
110 }
111 println!("{content}");
112 }
113 return Ok(());
114 }
115
116 if no_tui {
117 use std::fmt::Write;
118 let mut full_content = String::new();
119 let multi = pages.len() > 1;
120 for (name, content) in pages {
121 if multi {
122 let _ = writeln!(full_content, "--- {name} ---\n");
123 }
124 full_content.push_str(&content);
125 full_content.push('\n');
126 }
127 run_pager(&full_content);
128 return Ok(());
129 }
130
131 enable_raw_mode()?;
132 let mut stdout = io::stdout();
133 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
134 let backend = CrosstermBackend::new(stdout);
135 let mut terminal = Terminal::new(backend)?;
136
137 let app = App::try_new(pages)?;
138 let res = run_app(&mut terminal, app);
139
140 disable_raw_mode()?;
141 execute!(
142 terminal.backend_mut(),
143 LeaveAlternateScreen,
144 DisableMouseCapture
145 )?;
146 terminal.show_cursor()?;
147
148 if let Err(err) = res {
149 eprintln!("{err:?}");
150 }
151
152 Ok(())
153}
154
155fn run_pager(content: &str) {
157 let pager = std::env::var("PAGER").ok();
158
159 if let Some(p) = pager
160 && spawn_pager(&p, content).is_ok()
161 {
162 return;
163 }
164
165 if spawn_pager("less", content).is_ok() {
166 return;
167 }
168
169 if spawn_pager("more", content).is_ok() {
170 return;
171 }
172
173 println!("{content}");
174}
175
176fn spawn_pager(pager: &str, content: &str) -> Result<()> {
183 let mut child = std::process::Command::new(pager)
184 .stdin(std::process::Stdio::piped())
185 .spawn()
186 .map_err(|e| anyhow!("Failed to spawn pager '{pager}': {e}"))?;
187
188 let mut stdin = child
189 .stdin
190 .take()
191 .ok_or_else(|| anyhow!("Failed to open stdin for pager"))?;
192
193 io::Write::write_all(&mut stdin, content.as_bytes())?;
194 drop(stdin);
195
196 child.wait()?;
197 Ok(())
198}
199
200pub fn resolve_package_for_man(
208 term: &str
209) -> Result<(types::Package, Option<String>)> {
210 if let Ok((pkg, _, _, _, registry_handle, _, _)) =
211 resolve::resolve_package_and_version(term, None, false, false)
212 {
213 return Ok((pkg, registry_handle));
214 }
215
216 let config = crate::pkg::config::read_config()?;
217 let mut registries = Vec::new();
218 if let Some(default) = &config.default_registry {
219 registries.push(default.handle.clone());
220 }
221 for reg in &config.added_registries {
222 registries.push(reg.handle.clone());
223 }
224
225 for handle in registries {
226 if let Ok(results) = db::find_provides(&handle, term)
227 && let Some(result) = results.first()
228 {
229 return Ok((result.0.clone(), Some(handle)));
230 }
231 }
232
233 Err(anyhow!("Could not find package or binary named '{term}'."))
234}
235
236pub fn gather_manual_pages(
244 pkg: &types::Package,
245 registry_handle: Option<&str>,
246 upstream: bool,
247 raw: bool
248) -> Result<BTreeMap<String, String>> {
249 let mut pages = BTreeMap::new();
250
251 if !upstream {
252 let handle = registry_handle.unwrap_or("local");
253 let scopes_to_check = [
254 types::Scope::Project,
255 types::Scope::User,
256 types::Scope::System
257 ];
258
259 for scope in scopes_to_check {
260 if let Ok(package_dir) =
261 local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)
262 {
263 let latest_dir = package_dir.join("latest");
264 if latest_dir.exists() {
265 let local_pages = find_local_man_pages(&latest_dir)?;
266 if !local_pages.is_empty() {
267 if !raw {
268 println!(
269 "Displaying locally installed manual from \
270 {scope:?} scope..."
271 );
272 }
273 pages.extend(local_pages);
274 break;
275 }
276 }
277 }
278
279 if scope == types::Scope::System {
281 let system_man = Path::new("/usr/share/man");
282 if system_man.exists() {
283 let system_pages =
284 find_man_pages_in_hierarchy(system_man, &pkg.name)?;
285 if !system_pages.is_empty() {
286 if !raw {
287 println!(
288 "Displaying manual from system \
289 /usr/share/man..."
290 );
291 }
292 pages.extend(system_pages);
293 break;
294 }
295 }
296 }
297 }
298 }
299
300 if pages.is_empty() {
301 if !raw {
302 println!(
303 "Package not installed or local manual not found. Fetching \
304 from upstream..."
305 );
306 }
307 let upstream_pages =
308 gather_manual_pages_from_upstream(pkg, registry_handle)?;
309 pages.extend(upstream_pages);
310 }
311
312 Ok(pages)
313}
314
315fn find_man_pages_in_hierarchy(
321 root: &Path,
322 term: &str
323) -> Result<BTreeMap<String, String>> {
324 let mut pages = BTreeMap::new();
325 if !root.exists() {
326 return Ok(pages);
327 }
328
329 for entry in WalkDir::new(root).max_depth(3) {
330 let entry = entry?;
331 if entry.file_type().is_file() {
332 let name = entry.file_name().to_string_lossy();
333 if name.starts_with(term) {
334 let content = fs::read_to_string(entry.path())?;
335 pages.insert(
336 name.to_string(),
337 if content.starts_with('.') {
338 parse_roff(&content)
339 } else {
340 content
341 }
342 );
343 }
344 }
345 }
346 Ok(pages)
347}
348
349fn gather_manual_pages_from_upstream(
362 pkg: &types::Package,
363 registry_handle: Option<&str>
364) -> Result<BTreeMap<String, String>> {
365 let source = registry_handle.map_or_else(
367 || pkg.name.clone(),
368 |handle| format!("#{}@{}", handle, pkg.name)
369 );
370
371 let (mut graph, _) =
372 crate::pkg::install::resolver::resolve_dependency_graph(
373 &[source],
374 None,
375 false,
376 true,
377 true,
378 None,
379 true,
380 None
381 )?;
382
383 if graph.nodes.is_empty() {
384 return Ok(BTreeMap::new());
385 }
386
387 let node_id = graph
388 .nodes
389 .keys()
390 .next()
391 .expect("Graph should not be empty")
392 .clone();
393 let node = graph
394 .nodes
395 .remove(&node_id)
396 .expect("Node should exist in graph");
397
398 let install_plan = crate::pkg::install::plan::create_install_plan(
399 &HashMap::from([(node_id.clone(), node.clone())]),
400 None,
401 false
402 )?;
403
404 let action = install_plan
405 .get(&node_id)
406 .ok_or_else(|| anyhow!("No install action for package"))?;
407
408 let prepared = crate::pkg::install::installer::prepare_node(
410 &node, action, None, None, false
411 )?;
412
413 let temp_dir = tempfile::Builder::new()
415 .prefix("zoi-man-extract-")
416 .tempdir()?;
417 let extract_path = temp_dir.path();
418
419 if prepared.archive_path.exists() {
420 let file = fs::File::open(&prepared.archive_path)?;
421 let decoder = zstd::stream::read::Decoder::new(file)?;
422 let mut archive = tar::Archive::new(decoder);
423 archive.unpack(extract_path)?;
424 }
425
426 let mut pages = BTreeMap::new();
434
435 let pooled_manifest = extract_path.join("manifest.json");
436 if pooled_manifest.exists() {
437 let content = fs::read_to_string(&pooled_manifest)?;
438 let manifest: types::PooledZpaManifest =
439 serde_json::from_str(&content)?;
440 let pool_dir = extract_path.join("pool");
441
442 for (sub_name, sub_mapping) in manifest.mappings {
443 for (scope, scope_mapping) in sub_mapping.scopes {
444 for file in scope_mapping.files {
445 if file.dest.contains("/man/")
446 || file.dest.ends_with(".1")
447 || file.dest.ends_with(".5")
448 {
449 let pool_file = pool_dir.join(&file.hash);
450 if pool_file.exists() {
451 let content = fs::read_to_string(pool_file)?;
452 let file_name = Path::new(&file.dest)
453 .file_name()
454 .expect("Dest should have a file name")
455 .to_string_lossy();
456 let display_name =
457 format!("{file_name}[{sub_name}:{scope:?}]");
458 pages.insert(
459 display_name,
460 if content.starts_with('.') {
461 parse_roff(&content)
462 } else {
463 content
464 }
465 );
466 }
467 }
468 }
469 }
470 }
471 }
472
473 let legacy_man = extract_path.join("data/pkgstore/man");
474 if legacy_man.exists() {
475 pages
476 .extend(find_local_man_pages(&extract_path.join("data/pkgstore"))?);
477 }
478
479 Ok(pages)
480}
481
482fn find_local_man_pages(latest_dir: &Path) -> Result<BTreeMap<String, String>> {
484 let mut pages = BTreeMap::new();
485
486 let md_path = latest_dir.join("man.md");
487 let txt_path = latest_dir.join("man.txt");
488
489 if md_path.exists() {
490 pages.insert("main".to_string(), fs::read_to_string(md_path)?);
491 return Ok(pages);
492 }
493
494 if txt_path.exists() {
495 pages.insert("main".to_string(), fs::read_to_string(txt_path)?);
496 return Ok(pages);
497 }
498
499 let search_dirs =
500 [latest_dir.join("share").join("man"), latest_dir.join("man")];
501
502 for dir in search_dirs {
503 if dir.exists() {
504 for entry in WalkDir::new(dir) {
505 let entry = entry?;
506 if entry.file_type().is_file() {
507 let path = entry.path();
508 let name = path
509 .file_name()
510 .expect("Path should have a file name")
511 .to_string_lossy()
512 .to_string();
513 let content = fs::read_to_string(path)?;
514 if name.to_lowercase().ends_with(".md") {
515 pages.insert(name, content);
516 } else if content.starts_with('.') {
517 pages.insert(name, parse_roff(&content));
518 } else {
519 pages.insert(name, content);
520 }
521 }
522 }
523 }
524 }
525
526 Ok(pages)
527}
528
529#[must_use]
532pub fn parse_roff(content: &str) -> String {
533 use std::fmt::Write;
534 let mut md = String::new();
535 for line in content.lines() {
536 let line = line.trim();
537 if line.starts_with(".TH") {
538 let parts: Vec<&str> = line.split_whitespace().collect();
539 if let Some(part) = parts.get(1) {
540 let _ = writeln!(md, "# {part}\n");
541 }
542 } else if line.starts_with(".SH") {
543 let title = line.trim_start_matches(".SH").trim();
544 let _ = writeln!(md, "## {title}\n");
545 } else if line.starts_with(".SS") {
546 let title = line.trim_start_matches(".SS").trim();
547 let _ = writeln!(md, "### {title}\n");
548 } else if line.starts_with(".PP")
549 || line.starts_with(".P")
550 || line.starts_with(".LP")
551 {
552 md.push_str("\n\n");
553 } else if line.starts_with(".B ") {
554 let _ = write!(md, "**{}**", line.trim_start_matches(".B ").trim());
555 } else if line.starts_with(".I ") {
556 let _ = write!(md, "*{}*", line.trim_start_matches(".I ").trim());
557 } else if line.starts_with(".BR ") {
558 let parts: Vec<&str> = line.split_whitespace().skip(1).collect();
559 if let Some(first) = parts.first() {
560 let _ = write!(md, "**{first}**");
561 for p in parts.iter().skip(1) {
562 md.push_str(p);
563 }
564 }
565 } else if line.starts_with('.') {
566 } else {
567 md.push_str(line);
568 md.push('\n');
569 }
570 }
571 md
572}
573
574fn run_app(
576 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
577 mut app: App
578) -> io::Result<()> {
579 loop {
580 terminal.draw(|f| ui(f, &mut app))?;
581
582 match event::read()? {
583 Event::Key(key) if key.kind == KeyEventKind::Press => {
584 match key.code {
585 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
586 KeyCode::Down | KeyCode::Char('j') => {
587 app.scroll = app.scroll.saturating_add(1);
588 }
589 KeyCode::Up | KeyCode::Char('k') => {
590 app.scroll = app.scroll.saturating_sub(1);
591 }
592 KeyCode::PageDown => {
593 app.scroll =
594 app.scroll.saturating_add(terminal.size()?.height);
595 }
596 KeyCode::PageUp => {
597 app.scroll =
598 app.scroll.saturating_sub(terminal.size()?.height);
599 }
600 KeyCode::Home => app.scroll = 0,
601 KeyCode::End => app.scroll = app.content_height,
602 KeyCode::Tab => {
603 app.current_page =
604 (app.current_page + 1) % app.pages.len();
605 app.scroll = 0;
606 app.content_height = u16::try_from(
607 app.pages
608 .get(app.current_page)
609 .map_or(0, |p| p.1.len())
610 )
611 .unwrap_or(u16::MAX);
612 }
613 KeyCode::BackTab => {
614 app.current_page = if app.current_page == 0 {
615 app.pages.len() - 1
616 } else {
617 app.current_page - 1
618 };
619 app.scroll = 0;
620 app.content_height = u16::try_from(
621 app.pages
622 .get(app.current_page)
623 .map_or(0, |p| p.1.len())
624 )
625 .unwrap_or(u16::MAX);
626 }
627 _ => {}
628 }
629 }
630 Event::Mouse(mouse) => match mouse.kind {
631 MouseEventKind::ScrollUp => {
632 app.scroll = app.scroll.saturating_sub(3);
633 }
634 MouseEventKind::ScrollDown => {
635 app.scroll = app.scroll.saturating_add(3);
636 }
637 _ => {}
638 },
639 _ => {}
640 }
641 }
642}
643
644fn ui(f: &mut Frame, app: &mut App) {
646 let size = f.area();
647
648 let has_sidebar = app.pages.len() > 1;
649 let main_area = if has_sidebar {
650 let chunks = Layout::default()
651 .direction(Direction::Horizontal)
652 .constraints([
653 Constraint::Percentage(20),
654 Constraint::Percentage(80)
655 ])
656 .split(size);
657
658 let items: Vec<ListItem> = app
659 .pages
660 .iter()
661 .enumerate()
662 .map(|(i, (name, _))| {
663 let style = if i == app.current_page {
664 Style::default()
665 .fg(Color::Yellow)
666 .add_modifier(Modifier::BOLD)
667 } else {
668 Style::default()
669 };
670 ListItem::new(name.as_str()).style(style)
671 })
672 .collect();
673
674 let list = List::new(items)
675 .block(Block::default().borders(Borders::ALL).title("Pages"))
676 .highlight_style(Style::default().add_modifier(Modifier::BOLD))
677 .highlight_symbol("> ");
678
679 if let Some(sidebar_chunk) = chunks.first() {
680 f.render_widget(list, *sidebar_chunk);
681 }
682 chunks.get(1).copied().unwrap_or(size)
683 } else {
684 size
685 };
686
687 let Some((name, lines)) = app.pages.get(app.current_page) else {
688 return;
689 };
690 let text = Text::from(lines.clone());
691
692 let paragraph = Paragraph::new(text)
693 .block(
694 Block::default()
695 .borders(Borders::ALL)
696 .title(format!("Manual: {name}"))
697 )
698 .wrap(Wrap { trim: true })
699 .scroll((app.scroll, 0));
700
701 f.render_widget(paragraph, main_area);
702
703 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
704 .begin_symbol(Some("↑"))
705 .end_symbol(Some("↓"));
706
707 let mut scrollbar_state = ScrollbarState::new(app.content_height as usize)
708 .position(app.scroll as usize);
709
710 f.render_stateful_widget(
711 scrollbar,
712 main_area.inner(Margin {
713 vertical: 1,
714 horizontal: 0
715 }),
716 &mut scrollbar_state
717 );
718}
719
720fn parse_markdown(content: &str) -> Result<Vec<Line<'static>>> {
732 let mut options = Options::empty();
733 options.insert(Options::ENABLE_STRIKETHROUGH);
734 let parser = Parser::new_ext(content, options);
735
736 let mut lines = Vec::new();
737 let mut current_line = Vec::new();
738 let mut style_stack = vec![Style::default()];
739 let mut list_stack: Vec<(u64, char)> = Vec::new();
740
741 let ss = SyntaxSet::load_defaults_newlines();
742 let ts = ThemeSet::load_defaults();
743 let mut highlighter: Option<(HighlightLines, String)> = None;
744 let mut link_url = String::new();
745
746 for event in parser {
747 match event {
748 CmarkEvent::Start(tag) => match tag {
749 Tag::Heading { level, .. } => {
750 style_stack.push(
751 Style::default()
752 .add_modifier(Modifier::BOLD)
753 .fg(Color::Yellow)
754 );
755 let level_num = match level {
756 HeadingLevel::H1 => 1,
757 HeadingLevel::H2 => 2,
758 HeadingLevel::H3 => 3,
759 HeadingLevel::H4 => 4,
760 HeadingLevel::H5 => 5,
761 HeadingLevel::H6 => 6
762 };
763 current_line.push(Span::raw("#".repeat(level_num) + " "));
764 }
765 Tag::BlockQuote(_) => {
766 style_stack.push(Style::default().fg(Color::Gray));
767 current_line.push(Span::styled(
768 "> ",
769 *style_stack.last().ok_or_else(|| {
770 anyhow!("Style stack should never be empty")
771 })?
772 ));
773 }
774 Tag::CodeBlock(kind) => {
775 let lang =
776 if let pulldown_cmark::CodeBlockKind::Fenced(lang) =
777 kind
778 {
779 lang.into_string()
780 } else {
781 "text".to_string()
782 };
783 if let Some(syntax) = ss.find_syntax_by_extension(&lang) {
784 if let Some(theme) = ts.themes.get("base16-ocean.dark")
785 {
786 highlighter = Some((
787 HighlightLines::new(syntax, theme),
788 String::new()
789 ));
790 } else {
791 highlighter = None;
792 }
793 } else {
794 highlighter = None;
795 }
796 }
797 Tag::List(start_index) => {
798 list_stack.push((start_index.unwrap_or(1), '*'));
799 }
800 Tag::Item => {
801 let list_len = list_stack.len();
802 if let Some((index, _)) = list_stack.last_mut() {
803 let marker = if *index > 0 {
804 format!("{index}. ")
805 } else {
806 "* ".to_string()
807 };
808 current_line.push(Span::raw(" ".repeat(list_len - 1)));
809 current_line.push(Span::raw(marker));
810 *index += 1;
811 }
812 }
813 Tag::Emphasis => {
814 style_stack.push(
815 (*style_stack.last().ok_or_else(|| {
816 anyhow!("Style stack should never be empty")
817 })?)
818 .add_modifier(Modifier::ITALIC)
819 );
820 }
821 Tag::Strong => {
822 style_stack.push(
823 (*style_stack.last().ok_or_else(|| {
824 anyhow!("Style stack should never be empty")
825 })?)
826 .add_modifier(Modifier::BOLD)
827 );
828 }
829 Tag::Strikethrough => {
830 style_stack.push(
831 (*style_stack.last().ok_or_else(|| {
832 anyhow!("Style stack should never be empty")
833 })?)
834 .add_modifier(Modifier::CROSSED_OUT)
835 );
836 }
837 Tag::Link { dest_url, .. } => {
838 link_url = dest_url.to_string();
839 current_line.push(Span::styled(
840 "[",
841 Style::default().fg(Color::DarkGray)
842 ));
843 style_stack.push(
844 Style::default()
845 .fg(Color::Cyan)
846 .add_modifier(Modifier::UNDERLINED)
847 );
848 }
849 _ => {}
850 },
851 CmarkEvent::End(tag) => {
852 match tag {
853 TagEnd::Paragraph
854 | TagEnd::Heading { .. }
855 | TagEnd::BlockQuote(_)
856 | TagEnd::Item => {
857 lines.push(Line::from(std::mem::take(
858 &mut current_line
859 )));
860 }
861 TagEnd::CodeBlock => {
862 if let Some((mut h, code)) = highlighter.take() {
863 for line in LinesWithEndings::from(&code) {
864 let ranges: Vec<(SyntectStyle, &str)> = h
865 .highlight_line(line, &ss)
866 .map_err(|e| {
867 anyhow!(
868 "Syntax highlighting failed: {e}"
869 )
870 })?;
871 let spans: Vec<Span<'static>> = ranges
872 .into_iter()
873 .map(|(style, text)| {
874 Span::styled(
875 text.to_string(),
876 Style::default()
877 .fg(Color::Rgb(
878 style.foreground.r,
879 style.foreground.g,
880 style.foreground.b
881 ))
882 .bg(Color::Rgb(
883 style.background.r,
884 style.background.g,
885 style.background.b
886 ))
887 )
888 })
889 .collect();
890 lines.push(Line::from(spans));
891 }
892 }
893 lines.push(Line::from(vec![]));
894 }
895 TagEnd::Emphasis
896 | TagEnd::Strong
897 | TagEnd::Strikethrough => {
898 style_stack.pop();
899 }
900 TagEnd::Link => {
901 style_stack.pop();
902 current_line.push(Span::styled(
903 format!("]({link_url})"),
904 Style::default().fg(Color::DarkGray)
905 ));
906 link_url.clear();
907 }
908 TagEnd::List(_) => {
909 list_stack.pop();
910 if list_stack.is_empty() {
911 lines.push(Line::from(vec![]));
912 }
913 }
914 _ => {}
915 }
916 if let TagEnd::Heading { .. } | TagEnd::BlockQuote(_) = tag {
917 style_stack.pop();
918 }
919 }
920 CmarkEvent::Text(text) => {
921 if let Some((_, code)) = &mut highlighter {
922 code.push_str(&text);
923 } else {
924 current_line.push(Span::styled(
925 text.to_string(),
926 *style_stack.last().ok_or_else(|| {
927 anyhow!("Style stack should never be empty")
928 })?
929 ));
930 }
931 }
932 CmarkEvent::Code(text) => {
933 current_line.push(Span::styled(
934 text.to_string(),
935 Style::default().fg(Color::Green).bg(Color::DarkGray)
936 ));
937 }
938 CmarkEvent::HardBreak => {
939 lines.push(Line::from(std::mem::take(&mut current_line)));
940 }
941 CmarkEvent::SoftBreak => {
942 current_line.push(Span::raw(" "));
943 }
944 CmarkEvent::Rule => {
945 lines.push(Line::from("---"));
946 }
947 _ => {}
948 }
949 }
950 if !current_line.is_empty() {
951 lines.push(Line::from(std::mem::take(&mut current_line)));
952 }
953
954 Ok(lines)
955}