zoi/cmd/
man.rs

1use crate::pkg::{local, resolve};
2use anyhow::{Result, anyhow};
3use crossterm::{
4    event::{
5        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
6    },
7    execute,
8    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9};
10use pulldown_cmark::{Event as CmarkEvent, HeadingLevel, Options, Parser, Tag, TagEnd};
11use ratatui::{
12    prelude::*,
13    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
14};
15use std::fs;
16use std::io;
17use syntect::{
18    easy::HighlightLines,
19    highlighting::{Style as SyntectStyle, ThemeSet},
20    parsing::SyntaxSet,
21    util::LinesWithEndings,
22};
23
24struct App<'a> {
25    lines: Vec<Line<'a>>,
26    scroll: u16,
27    content_height: u16,
28}
29
30impl<'a> App<'a> {
31    fn new(content: &'a str) -> Self {
32        let lines = parse_markdown(content);
33        let content_height = lines.len() as u16;
34        Self {
35            lines,
36            scroll: 0,
37            content_height,
38        }
39    }
40}
41
42pub fn run(package_name: &str, upstream: bool, raw: bool) -> Result<()> {
43    let (pkg, _version, _, _, registry_handle) =
44        resolve::resolve_package_and_version(package_name, false)?;
45
46    let fetch_from_upstream = || -> Result<String> {
47        if let Some(url) = pkg.man.as_ref() {
48            if !raw {
49                println!("Fetching manual from {}...", url);
50            }
51            Ok(reqwest::blocking::get(url)?.text()?)
52        } else {
53            Err(anyhow!(
54                "Package '{}' does not have a manual URL.",
55                package_name
56            ))
57        }
58    };
59
60    let content = if upstream {
61        fetch_from_upstream()?
62    } else {
63        let handle = registry_handle.as_deref().unwrap_or("local");
64        let scopes_to_check = [
65            crate::pkg::types::Scope::Project,
66            crate::pkg::types::Scope::User,
67            crate::pkg::types::Scope::System,
68        ];
69        let mut found_manual = None;
70
71        for scope in scopes_to_check {
72            if let Ok(package_dir) = local::get_package_dir(scope, handle, &pkg.repo, &pkg.name) {
73                let latest_dir = package_dir.join("latest");
74                if !latest_dir.exists() {
75                    continue;
76                }
77
78                let man_md_path = latest_dir.join("man.md");
79                let man_txt_path = latest_dir.join("man.txt");
80
81                if man_md_path.exists() {
82                    if !raw {
83                        println!(
84                            "Displaying locally installed manual (Markdown) from {:?} scope...",
85                            scope
86                        );
87                    }
88                    found_manual = Some(fs::read_to_string(man_md_path)?);
89                    break;
90                } else if man_txt_path.exists() {
91                    if !raw {
92                        println!(
93                            "Displaying locally installed manual (text) from {:?} scope...",
94                            scope
95                        );
96                    }
97                    found_manual = Some(fs::read_to_string(man_txt_path)?);
98                    break;
99                }
100            }
101        }
102
103        if let Some(manual_content) = found_manual {
104            manual_content
105        } else {
106            fetch_from_upstream()?
107        }
108    };
109
110    if raw {
111        print!("{}", content);
112        return Ok(());
113    }
114
115    enable_raw_mode()?;
116    let mut stdout = io::stdout();
117    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
118    let backend = CrosstermBackend::new(stdout);
119    let mut terminal = Terminal::new(backend)?;
120
121    let app = App::new(&content);
122    let res = run_app(&mut terminal, app);
123
124    disable_raw_mode()?;
125    execute!(
126        terminal.backend_mut(),
127        LeaveAlternateScreen,
128        DisableMouseCapture
129    )?;
130    terminal.show_cursor()?;
131
132    if let Err(err) = res {
133        eprintln!("{:?}", err)
134    }
135
136    Ok(())
137}
138
139fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, mut app: App) -> io::Result<()> {
140    loop {
141        terminal.draw(|f| ui(f, &mut app))?;
142
143        match event::read()? {
144            Event::Key(key) => {
145                if key.kind == KeyEventKind::Press {
146                    match key.code {
147                        KeyCode::Char('q') => return Ok(()),
148                        KeyCode::Down | KeyCode::Char('j') => {
149                            app.scroll = app.scroll.saturating_add(1);
150                        }
151                        KeyCode::Up | KeyCode::Char('k') => {
152                            app.scroll = app.scroll.saturating_sub(1);
153                        }
154                        KeyCode::PageDown => {
155                            app.scroll = app.scroll.saturating_add(terminal.size()?.height);
156                        }
157                        KeyCode::PageUp => {
158                            app.scroll = app.scroll.saturating_sub(terminal.size()?.height);
159                        }
160                        KeyCode::Home => app.scroll = 0,
161                        KeyCode::End => app.scroll = app.content_height,
162                        _ => {}
163                    }
164                }
165            }
166            Event::Mouse(mouse) => match mouse.kind {
167                MouseEventKind::ScrollUp => app.scroll = app.scroll.saturating_sub(3),
168                MouseEventKind::ScrollDown => app.scroll = app.scroll.saturating_add(3),
169                _ => {}
170            },
171            _ => {}
172        }
173    }
174}
175
176fn ui(f: &mut Frame, app: &mut App) {
177    let size = f.area();
178    let text = Text::from(app.lines.clone());
179
180    let paragraph = Paragraph::new(text)
181        .block(Block::default().borders(Borders::ALL).title("Manual"))
182        .wrap(Wrap { trim: true })
183        .scroll((app.scroll, 0));
184
185    f.render_widget(paragraph, size);
186
187    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
188        .begin_symbol(Some("↑"))
189        .end_symbol(Some("↓"));
190
191    let mut scrollbar_state =
192        ScrollbarState::new(app.content_height as usize).position(app.scroll as usize);
193
194    f.render_stateful_widget(
195        scrollbar,
196        size.inner(Margin {
197            vertical: 1,
198            horizontal: 0,
199        }),
200        &mut scrollbar_state,
201    );
202}
203
204fn parse_markdown(content: &str) -> Vec<Line<'_>> {
205    let mut options = Options::empty();
206    options.insert(Options::ENABLE_STRIKETHROUGH);
207    let parser = Parser::new_ext(content, options);
208
209    let mut lines = Vec::new();
210    let mut current_line = Vec::new();
211    let mut style_stack = vec![Style::default()];
212    let mut list_stack: Vec<(u64, char)> = Vec::new();
213
214    let ss = SyntaxSet::load_defaults_newlines();
215    let ts = ThemeSet::load_defaults();
216    let mut highlighter: Option<(HighlightLines, String)> = None;
217    let mut link_url = String::new();
218
219    for event in parser {
220        match event {
221            CmarkEvent::Start(tag) => match tag {
222                Tag::Paragraph => {}
223                Tag::Heading { level, .. } => {
224                    style_stack.push(
225                        Style::default()
226                            .add_modifier(Modifier::BOLD)
227                            .fg(Color::Yellow),
228                    );
229                    let level_num = match level {
230                        HeadingLevel::H1 => 1,
231                        HeadingLevel::H2 => 2,
232                        HeadingLevel::H3 => 3,
233                        HeadingLevel::H4 => 4,
234                        HeadingLevel::H5 => 5,
235                        HeadingLevel::H6 => 6,
236                    };
237                    current_line.push(Span::raw("#".repeat(level_num) + " "));
238                }
239                Tag::BlockQuote(_) => {
240                    style_stack.push(Style::default().fg(Color::Gray));
241                    current_line.push(Span::styled("> ", *style_stack.last().unwrap()));
242                }
243                Tag::CodeBlock(kind) => {
244                    let lang = if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
245                        lang.into_string()
246                    } else {
247                        "text".to_string()
248                    };
249                    if let Some(syntax) = ss.find_syntax_by_extension(&lang) {
250                        highlighter = Some((
251                            HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]),
252                            String::new(),
253                        ));
254                    } else {
255                        highlighter = None;
256                    }
257                }
258                Tag::List(start_index) => {
259                    list_stack.push((start_index.unwrap_or(1), '*'));
260                }
261                Tag::Item => {
262                    let list_len = list_stack.len();
263                    if let Some((index, _)) = list_stack.last_mut() {
264                        let marker = if *index > 0 {
265                            format!("{}. ", index)
266                        } else {
267                            "* ".to_string()
268                        };
269                        current_line.push(Span::raw("  ".repeat(list_len - 1)));
270                        current_line.push(Span::raw(marker));
271                        *index += 1;
272                    }
273                }
274                Tag::Emphasis => {
275                    style_stack.push((*style_stack.last().unwrap()).add_modifier(Modifier::ITALIC));
276                }
277                Tag::Strong => {
278                    style_stack.push((*style_stack.last().unwrap()).add_modifier(Modifier::BOLD));
279                }
280                Tag::Strikethrough => {
281                    style_stack
282                        .push((*style_stack.last().unwrap()).add_modifier(Modifier::CROSSED_OUT));
283                }
284                Tag::Link { dest_url, .. } => {
285                    link_url = dest_url.to_string();
286                    current_line.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
287                    style_stack.push(
288                        Style::default()
289                            .fg(Color::Cyan)
290                            .add_modifier(Modifier::UNDERLINED),
291                    );
292                }
293                _ => {}
294            },
295            CmarkEvent::End(tag) => {
296                match tag {
297                    TagEnd::Paragraph
298                    | TagEnd::Heading { .. }
299                    | TagEnd::BlockQuote(_)
300                    | TagEnd::Item => {
301                        lines.push(Line::from(std::mem::take(&mut current_line)));
302                    }
303                    TagEnd::CodeBlock => {
304                        if let Some((mut h, code)) = highlighter.take() {
305                            for line in LinesWithEndings::from(&code) {
306                                let ranges: Vec<(SyntectStyle, &str)> =
307                                    h.highlight_line(line, &ss).unwrap();
308                                let spans: Vec<Span> = ranges
309                                    .into_iter()
310                                    .map(|(style, text)| {
311                                        Span::styled(
312                                            text.to_string(),
313                                            Style::default()
314                                                .fg(Color::Rgb(
315                                                    style.foreground.r,
316                                                    style.foreground.g,
317                                                    style.foreground.b,
318                                                ))
319                                                .bg(Color::Rgb(
320                                                    style.background.r,
321                                                    style.background.g,
322                                                    style.background.b,
323                                                )),
324                                        )
325                                    })
326                                    .collect();
327                                lines.push(Line::from(spans));
328                            }
329                        }
330                        lines.push(Line::from(vec![]));
331                    }
332                    TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
333                        style_stack.pop();
334                    }
335                    TagEnd::Link => {
336                        style_stack.pop();
337                        current_line.push(Span::styled(
338                            format!("]({})", link_url),
339                            Style::default().fg(Color::DarkGray),
340                        ));
341                        link_url.clear();
342                    }
343                    TagEnd::List(_) => {
344                        list_stack.pop();
345                        if list_stack.is_empty() {
346                            lines.push(Line::from(vec![]));
347                        }
348                    }
349                    _ => {}
350                }
351                if let TagEnd::Heading { .. } | TagEnd::BlockQuote(_) = tag {
352                    style_stack.pop();
353                }
354            }
355            CmarkEvent::Text(text) => {
356                if let Some((_, code)) = &mut highlighter {
357                    code.push_str(&text);
358                } else {
359                    current_line.push(Span::styled(text.to_string(), *style_stack.last().unwrap()));
360                }
361            }
362            CmarkEvent::Code(text) => {
363                current_line.push(Span::styled(
364                    text.to_string(),
365                    Style::default().fg(Color::Green).bg(Color::DarkGray),
366                ));
367            }
368            CmarkEvent::HardBreak => {
369                lines.push(Line::from(std::mem::take(&mut current_line)));
370            }
371            CmarkEvent::SoftBreak => {
372                current_line.push(Span::raw(" "));
373            }
374            CmarkEvent::Rule => {
375                lines.push(Line::from("---"));
376            }
377            _ => {}
378        }
379    }
380    if !current_line.is_empty() {
381        lines.push(Line::from(std::mem::take(&mut current_line)));
382    }
383
384    lines
385}