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    )?;
300
301    if graph.nodes.is_empty() {
302        return Ok(BTreeMap::new());
303    }
304
305    let node_id = graph.nodes.keys().next().unwrap().clone();
306    let node = graph.nodes.remove(&node_id).unwrap();
307
308    let install_plan = crate::pkg::install::plan::create_install_plan(
309        &HashMap::from([(node_id.clone(), node.clone())]),
310        None,
311        false,
312    )?;
313
314    let action = install_plan
315        .get(&node_id)
316        .ok_or_else(|| anyhow!("No install action for package"))?;
317
318    // Prepare the node (download/build)
319    let prepared = crate::pkg::install::installer::prepare_node(&node, action, None, None, false)?;
320
321    // Extract to a temp directory
322    let temp_dir = tempfile::Builder::new()
323        .prefix("zoi-man-extract-")
324        .tempdir()?;
325    let extract_path = temp_dir.path();
326
327    if prepared.archive_path.exists() {
328        let file = fs::File::open(&prepared.archive_path)?;
329        let decoder = zstd::stream::read::Decoder::new(file)?;
330        let mut archive = tar::Archive::new(decoder);
331        archive.unpack(extract_path)?;
332    }
333
334    // Look for man pages in the extracted content
335    // We check:
336    // - manifest.json (for pooled ZPA)
337    // - data/pkgstore/man/
338    // - data/usrroot/usr/share/man/
339    // - any .pkg.lua in the root
340
341    let mut pages = BTreeMap::new();
342
343    let pooled_manifest = extract_path.join("manifest.json");
344    if pooled_manifest.exists() {
345        let content = fs::read_to_string(&pooled_manifest)?;
346        let manifest: types::PooledZpaManifest = serde_json::from_str(&content)?;
347        let pool_dir = extract_path.join("pool");
348
349        for (sub_name, sub_mapping) in manifest.mappings {
350            for (scope, scope_mapping) in sub_mapping.scopes {
351                for file in scope_mapping.files {
352                    if file.dest.contains("/man/")
353                        || file.dest.ends_with(".1")
354                        || file.dest.ends_with(".5")
355                    {
356                        let pool_file = pool_dir.join(&file.hash);
357                        if pool_file.exists() {
358                            let content = fs::read_to_string(pool_file)?;
359                            let display_name = format!(
360                                "{}[{}:{:?}]",
361                                Path::new(&file.dest).file_name().unwrap().to_string_lossy(),
362                                sub_name,
363                                scope
364                            );
365                            pages.insert(
366                                display_name,
367                                if content.starts_with('.') {
368                                    parse_roff(&content)
369                                } else {
370                                    content
371                                },
372                            );
373                        }
374                    }
375                }
376            }
377        }
378    }
379
380    let legacy_man = extract_path.join("data/pkgstore/man");
381    if legacy_man.exists() {
382        pages.extend(find_local_man_pages(&extract_path.join("data/pkgstore"))?);
383    }
384
385    Ok(pages)
386}
387
388fn find_local_man_pages(latest_dir: &Path) -> Result<BTreeMap<String, String>> {
389    let mut pages = BTreeMap::new();
390
391    let md_path = latest_dir.join("man.md");
392    let txt_path = latest_dir.join("man.txt");
393
394    if md_path.exists() {
395        pages.insert("main".to_string(), fs::read_to_string(md_path)?);
396        return Ok(pages);
397    }
398
399    if txt_path.exists() {
400        pages.insert("main".to_string(), fs::read_to_string(txt_path)?);
401        return Ok(pages);
402    }
403
404    let search_dirs = [latest_dir.join("share").join("man"), latest_dir.join("man")];
405
406    for dir in search_dirs {
407        if dir.exists() {
408            for entry in WalkDir::new(dir) {
409                let entry = entry?;
410                if entry.file_type().is_file() {
411                    let path = entry.path();
412                    let name = path.file_name().unwrap().to_string_lossy().to_string();
413                    let content = fs::read_to_string(path)?;
414                    if name.ends_with(".md") {
415                        pages.insert(name, content);
416                    } else if content.starts_with('.') {
417                        pages.insert(name, parse_roff(&content));
418                    } else {
419                        pages.insert(name, content);
420                    }
421                }
422            }
423        }
424    }
425
426    Ok(pages)
427}
428
429pub fn parse_roff(content: &str) -> String {
430    let mut md = String::new();
431    for line in content.lines() {
432        let line = line.trim();
433        if line.starts_with(".TH") {
434            let parts: Vec<&str> = line.split_whitespace().collect();
435            if parts.len() > 1 {
436                md.push_str(&format!("# {}\n\n", parts[1]));
437            }
438        } else if line.starts_with(".SH") {
439            let title = line.trim_start_matches(".SH").trim();
440            md.push_str(&format!("## {}\n\n", title));
441        } else if line.starts_with(".SS") {
442            let title = line.trim_start_matches(".SS").trim();
443            md.push_str(&format!("### {}\n\n", title));
444        } else if line.starts_with(".PP") || line.starts_with(".P") || line.starts_with(".LP") {
445            md.push_str("\n\n");
446        } else if line.starts_with(".B ") {
447            md.push_str(&format!("**{}**", line.trim_start_matches(".B ").trim()));
448        } else if line.starts_with(".I ") {
449            md.push_str(&format!("*{}*", line.trim_start_matches(".I ").trim()));
450        } else if line.starts_with(".BR ") {
451            let parts: Vec<&str> = line.split_whitespace().skip(1).collect();
452            if !parts.is_empty() {
453                md.push_str(&format!("**{}**", parts[0]));
454                for p in parts.iter().skip(1) {
455                    md.push_str(p);
456                }
457            }
458        } else if line.starts_with('.') {
459        } else {
460            md.push_str(line);
461            md.push('\n');
462        }
463    }
464    md
465}
466
467fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, mut app: App) -> io::Result<()> {
468    loop {
469        terminal.draw(|f| ui(f, &mut app))?;
470
471        match event::read()? {
472            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
473                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
474                KeyCode::Down | KeyCode::Char('j') => {
475                    app.scroll = app.scroll.saturating_add(1);
476                }
477                KeyCode::Up | KeyCode::Char('k') => {
478                    app.scroll = app.scroll.saturating_sub(1);
479                }
480                KeyCode::PageDown => {
481                    app.scroll = app.scroll.saturating_add(terminal.size()?.height);
482                }
483                KeyCode::PageUp => {
484                    app.scroll = app.scroll.saturating_sub(terminal.size()?.height);
485                }
486                KeyCode::Home => app.scroll = 0,
487                KeyCode::End => app.scroll = app.content_height,
488                KeyCode::Tab => {
489                    app.current_page = (app.current_page + 1) % app.pages.len();
490                    app.scroll = 0;
491                    app.content_height = app.pages[app.current_page].1.len() as u16;
492                }
493                KeyCode::BackTab => {
494                    app.current_page = if app.current_page == 0 {
495                        app.pages.len() - 1
496                    } else {
497                        app.current_page - 1
498                    };
499                    app.scroll = 0;
500                    app.content_height = app.pages[app.current_page].1.len() as u16;
501                }
502                _ => {}
503            },
504            Event::Mouse(mouse) => match mouse.kind {
505                MouseEventKind::ScrollUp => app.scroll = app.scroll.saturating_sub(3),
506                MouseEventKind::ScrollDown => app.scroll = app.scroll.saturating_add(3),
507                _ => {}
508            },
509            _ => {}
510        }
511    }
512}
513
514fn ui(f: &mut Frame, app: &mut App) {
515    let size = f.area();
516
517    let has_sidebar = app.pages.len() > 1;
518    let main_area = if has_sidebar {
519        let chunks = Layout::default()
520            .direction(Direction::Horizontal)
521            .constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
522            .split(size);
523
524        let items: Vec<ListItem> = app
525            .pages
526            .iter()
527            .enumerate()
528            .map(|(i, (name, _))| {
529                let style = if i == app.current_page {
530                    Style::default()
531                        .fg(Color::Yellow)
532                        .add_modifier(Modifier::BOLD)
533                } else {
534                    Style::default()
535                };
536                ListItem::new(name.as_str()).style(style)
537            })
538            .collect();
539
540        let list = List::new(items)
541            .block(Block::default().borders(Borders::ALL).title("Pages"))
542            .highlight_style(Style::default().add_modifier(Modifier::BOLD))
543            .highlight_symbol("> ");
544
545        f.render_widget(list, chunks[0]);
546        chunks[1]
547    } else {
548        size
549    };
550
551    let (name, lines) = &app.pages[app.current_page];
552    let text = Text::from(lines.clone());
553
554    let paragraph = Paragraph::new(text)
555        .block(
556            Block::default()
557                .borders(Borders::ALL)
558                .title(format!("Manual: {}", name)),
559        )
560        .wrap(Wrap { trim: true })
561        .scroll((app.scroll, 0));
562
563    f.render_widget(paragraph, main_area);
564
565    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
566        .begin_symbol(Some("↑"))
567        .end_symbol(Some("↓"));
568
569    let mut scrollbar_state =
570        ScrollbarState::new(app.content_height as usize).position(app.scroll as usize);
571
572    f.render_stateful_widget(
573        scrollbar,
574        main_area.inner(Margin {
575            vertical: 1,
576            horizontal: 0,
577        }),
578        &mut scrollbar_state,
579    );
580}
581
582fn parse_markdown(content: &str) -> Result<Vec<Line<'static>>> {
583    let mut options = Options::empty();
584    options.insert(Options::ENABLE_STRIKETHROUGH);
585    let parser = Parser::new_ext(content, options);
586
587    let mut lines = Vec::new();
588    let mut current_line = Vec::new();
589    let mut style_stack = vec![Style::default()];
590    let mut list_stack: Vec<(u64, char)> = Vec::new();
591
592    let ss = SyntaxSet::load_defaults_newlines();
593    let ts = ThemeSet::load_defaults();
594    let mut highlighter: Option<(HighlightLines, String)> = None;
595    let mut link_url = String::new();
596
597    for event in parser {
598        match event {
599            CmarkEvent::Start(tag) => match tag {
600                Tag::Paragraph => {}
601                Tag::Heading { level, .. } => {
602                    style_stack.push(
603                        Style::default()
604                            .add_modifier(Modifier::BOLD)
605                            .fg(Color::Yellow),
606                    );
607                    let level_num = match level {
608                        HeadingLevel::H1 => 1,
609                        HeadingLevel::H2 => 2,
610                        HeadingLevel::H3 => 3,
611                        HeadingLevel::H4 => 4,
612                        HeadingLevel::H5 => 5,
613                        HeadingLevel::H6 => 6,
614                    };
615                    current_line.push(Span::raw("#".repeat(level_num) + " "));
616                }
617                Tag::BlockQuote(_) => {
618                    style_stack.push(Style::default().fg(Color::Gray));
619                    current_line.push(Span::styled(
620                        "> ",
621                        *style_stack
622                            .last()
623                            .ok_or_else(|| anyhow!("Style stack should never be empty"))?,
624                    ));
625                }
626                Tag::CodeBlock(kind) => {
627                    let lang = if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
628                        lang.into_string()
629                    } else {
630                        "text".to_string()
631                    };
632                    if let Some(syntax) = ss.find_syntax_by_extension(&lang) {
633                        highlighter = Some((
634                            HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]),
635                            String::new(),
636                        ));
637                    } else {
638                        highlighter = None;
639                    }
640                }
641                Tag::List(start_index) => {
642                    list_stack.push((start_index.unwrap_or(1), '*'));
643                }
644                Tag::Item => {
645                    let list_len = list_stack.len();
646                    if let Some((index, _)) = list_stack.last_mut() {
647                        let marker = if *index > 0 {
648                            format!("{}. ", index)
649                        } else {
650                            "* ".to_string()
651                        };
652                        current_line.push(Span::raw("  ".repeat(list_len - 1)));
653                        current_line.push(Span::raw(marker));
654                        *index += 1;
655                    }
656                }
657                Tag::Emphasis => {
658                    style_stack.push(
659                        (*style_stack
660                            .last()
661                            .ok_or_else(|| anyhow!("Style stack should never be empty"))?)
662                        .add_modifier(Modifier::ITALIC),
663                    );
664                }
665                Tag::Strong => {
666                    style_stack.push(
667                        (*style_stack
668                            .last()
669                            .ok_or_else(|| anyhow!("Style stack should never be empty"))?)
670                        .add_modifier(Modifier::BOLD),
671                    );
672                }
673                Tag::Strikethrough => {
674                    style_stack.push(
675                        (*style_stack
676                            .last()
677                            .ok_or_else(|| anyhow!("Style stack should never be empty"))?)
678                        .add_modifier(Modifier::CROSSED_OUT),
679                    );
680                }
681                Tag::Link { dest_url, .. } => {
682                    link_url = dest_url.to_string();
683                    current_line.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
684                    style_stack.push(
685                        Style::default()
686                            .fg(Color::Cyan)
687                            .add_modifier(Modifier::UNDERLINED),
688                    );
689                }
690                _ => {}
691            },
692            CmarkEvent::End(tag) => {
693                match tag {
694                    TagEnd::Paragraph
695                    | TagEnd::Heading { .. }
696                    | TagEnd::BlockQuote(_)
697                    | TagEnd::Item => {
698                        lines.push(Line::from(std::mem::take(&mut current_line)));
699                    }
700                    TagEnd::CodeBlock => {
701                        if let Some((mut h, code)) = highlighter.take() {
702                            for line in LinesWithEndings::from(&code) {
703                                let ranges: Vec<(SyntectStyle, &str)> = h
704                                    .highlight_line(line, &ss)
705                                    .map_err(|e| anyhow!("Syntax highlighting failed: {}", e))?;
706                                let spans: Vec<Span<'static>> = ranges
707                                    .into_iter()
708                                    .map(|(style, text)| {
709                                        Span::styled(
710                                            text.to_string(),
711                                            Style::default()
712                                                .fg(Color::Rgb(
713                                                    style.foreground.r,
714                                                    style.foreground.g,
715                                                    style.foreground.b,
716                                                ))
717                                                .bg(Color::Rgb(
718                                                    style.background.r,
719                                                    style.background.g,
720                                                    style.background.b,
721                                                )),
722                                        )
723                                    })
724                                    .collect();
725                                lines.push(Line::from(spans));
726                            }
727                        }
728                        lines.push(Line::from(vec![]));
729                    }
730                    TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
731                        style_stack.pop();
732                    }
733                    TagEnd::Link => {
734                        style_stack.pop();
735                        current_line.push(Span::styled(
736                            format!("]({})", link_url),
737                            Style::default().fg(Color::DarkGray),
738                        ));
739                        link_url.clear();
740                    }
741                    TagEnd::List(_) => {
742                        list_stack.pop();
743                        if list_stack.is_empty() {
744                            lines.push(Line::from(vec![]));
745                        }
746                    }
747                    _ => {}
748                }
749                if let TagEnd::Heading { .. } | TagEnd::BlockQuote(_) = tag {
750                    style_stack.pop();
751                }
752            }
753            CmarkEvent::Text(text) => {
754                if let Some((_, code)) = &mut highlighter {
755                    code.push_str(&text);
756                } else {
757                    current_line.push(Span::styled(
758                        text.to_string(),
759                        *style_stack
760                            .last()
761                            .ok_or_else(|| anyhow!("Style stack should never be empty"))?,
762                    ));
763                }
764            }
765            CmarkEvent::Code(text) => {
766                current_line.push(Span::styled(
767                    text.to_string(),
768                    Style::default().fg(Color::Green).bg(Color::DarkGray),
769                ));
770            }
771            CmarkEvent::HardBreak => {
772                lines.push(Line::from(std::mem::take(&mut current_line)));
773            }
774            CmarkEvent::SoftBreak => {
775                current_line.push(Span::raw(" "));
776            }
777            CmarkEvent::Rule => {
778                lines.push(Line::from("---"));
779            }
780            _ => {}
781        }
782    }
783    if !current_line.is_empty() {
784        lines.push(Line::from(std::mem::take(&mut current_line)));
785    }
786
787    Ok(lines)
788}