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