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, ®istry_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 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 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 let prepared = crate::pkg::install::installer::prepare_node(&node, action, None, None, false)?;
320
321 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 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}