Skip to main content

zoi_cli/cmd/
man.rs

1//! Command for displaying manual pages for packages.
2//!
3//! This module provides a TUI (Terminal User Interface) and a pager-based
4//! way to view manual pages, supporting both locally installed pages
5//! and fetching pages from upstream registries.
6
7use 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
38/// State for the manual page viewer TUI.
39struct App<'a> {
40    /// The manual pages to display, parsed as TUI lines.
41    pages: Vec<(String, Vec<Line<'a>>)>,
42    /// Index of the currently displayed page.
43    current_page: usize,
44    /// Vertical scroll position.
45    scroll: u16,
46    /// Total height of the current page's content.
47    content_height: u16
48}
49
50impl App<'_> {
51    /// Tries to create a new TUI app from a map of page names to content.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if parsing any page fails or if no pages are provided.
56    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
79/// Runs the manual page viewer for the specified package.
80///
81/// # Errors
82///
83/// Returns an error if:
84/// - The package cannot be resolved.
85/// - Manual pages cannot be gathered.
86/// - The terminal cannot be initialized.
87pub 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
155/// Runs a pager to display the given content.
156fn 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
176/// Spawns a specific pager process and writes content to its stdin.
177///
178/// # Errors
179///
180/// Returns an error if the pager fails to spawn or if writing to its stdin
181/// fails.
182fn 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
200/// Resolves a package and optional registry handle for a given search term.
201///
202/// # Errors
203///
204/// Returns an error if:
205/// - The package or binary cannot be found.
206/// - The project configuration cannot be read.
207pub 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
236/// Gathers manual pages for a package, checking locally and then upstream.
237///
238/// # Errors
239///
240/// Returns an error if:
241/// - Local manual pages cannot be found.
242/// - Upstream manual pages cannot be gathered.
243pub 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            // Also check standard system locations if scope is system
280            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
315/// Recursively finds manual pages in a directory hierarchy.
316///
317/// # Errors
318///
319/// Returns an error if directory traversal or file reading fails.
320fn 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
349/// Fetches manual pages for a package from the upstream registry.
350///
351/// # Errors
352///
353/// Returns an error if:
354/// - Dependency resolution fails.
355/// - The package archive cannot be downloaded or extracted.
356/// - File reading fails.
357///
358/// # Panics
359///
360/// Panics if the internal dependency graph is inconsistent.
361fn gather_manual_pages_from_upstream(
362    pkg: &types::Package,
363    registry_handle: Option<&str>
364) -> Result<BTreeMap<String, String>> {
365    // Resolve the package to get its archive source
366    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    // Prepare the node (download/build)
409    let prepared = crate::pkg::install::installer::prepare_node(
410        &node, action, None, None, false
411    )?;
412
413    // Extract to a temp directory
414    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    // Look for man pages in the extracted content
427    // We check:
428    // - manifest.json (for pooled ZPA)
429    // - data/pkgstore/man/
430    // - data/usrroot/usr/share/man/
431    // - any .pkg.lua in the root
432
433    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
482/// Finds manual pages in a package's installation directory.
483fn 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/// Parses a ROFF-formatted string (traditional man page) into a simplified
530/// Markdown string.
531#[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
574/// Main loop for the TUI application.
575fn 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
644/// Renders the TUI.
645fn 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
720/// Parses Markdown content into TUI lines.
721///
722/// # Errors
723///
724/// Returns an error if:
725/// - A style stack underflow occurs.
726/// - Syntax highlighting fails.
727///
728/// # Panics
729///
730/// Panics if a style stack invariant is violated.
731fn 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}