Skip to main content

zoi_cli/cmd/
man.rs

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