Skip to main content

mdcat/mdless/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Interactive `mdless` viewer.
6//!
7//! [`run`] renders the document once with `push_tty`, enters the
8//! alternate screen, and loops on crossterm events. Submodules
9//! carry the real work: [`buffer`], [`keys`], [`view`], [`search`],
10//! [`highlight`], [`toc`].
11
12pub mod buffer;
13pub mod highlight;
14pub mod keys;
15pub mod search;
16pub mod toc;
17pub mod view;
18
19use std::collections::HashMap;
20use std::io::{self, BufWriter, Write};
21use std::path::Path;
22
23use anyhow::{Context, Result};
24use crossterm::cursor::{Hide, Show};
25use crossterm::event::{self, Event};
26use crossterm::terminal::{
27    disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen,
28};
29use crossterm::{execute, queue};
30use pulldown_cmark::Parser;
31
32use buffer::{build, HeadingRecorder, RenderedDoc};
33use keys::{Command, Decoder, SearchDirection};
34use search::{CaseMode, Direction, SearchState};
35use toc::Toc;
36use view::View;
37
38use crate::args::CommonArgs;
39use crate::resources::ResourceUrlHandler;
40use crate::terminal::capabilities::TerminalCapabilities;
41use crate::{read_input, Environment, Multiplexer, Settings, TerminalProgram, Theme};
42
43/// Options passed from the `mdless` CLI into the pager session.
44#[derive(Debug, Clone, Default)]
45pub struct MdlessOptions {
46    /// Pattern to commit before the interactive loop starts.
47    pub initial: Option<String>,
48    /// Force case-sensitive matching.
49    pub case_sensitive: bool,
50    /// Interpret the pattern as a regex.
51    pub regex: bool,
52    /// Show a rendered-line number gutter on the left edge.
53    pub line_numbers: bool,
54}
55
56/// Mutable pager state held across one session.
57struct Session {
58    doc: RenderedDoc,
59    view: View,
60    decoder: Decoder,
61    /// Compiled search + matches from the last committed query.
62    search: Option<SearchState>,
63    /// Currently-active direction for `n` / `N` cycling.
64    direction: SearchDirection,
65    /// Pattern being typed. Empty unless the decoder is in search mode.
66    input: String,
67    /// Transient status text (search prompt, no-matches, error). Takes
68    /// precedence over the default "line N/M" indicator for one frame.
69    status: Option<String>,
70    /// Regex mode + case mode for pattern compilation; populated from CLI.
71    regex: bool,
72    case: CaseMode,
73    /// `Some` while the TOC modal owns the frame, `None` otherwise.
74    toc: Option<Toc>,
75    /// Named bookmark registers: `m a` stores, `'a` recalls. Lines are
76    /// rendered-line indexes so bookmarks survive search jumps.
77    bookmarks: HashMap<char, usize>,
78}
79
80impl Session {
81    fn matches(&self) -> &[search::Match] {
82        self.search.as_ref().map_or(&[][..], SearchState::all)
83    }
84
85    fn current_match(&self) -> Option<&search::Match> {
86        self.search.as_ref().and_then(SearchState::current)
87    }
88}
89
90/// Render `filename` and drive the interactive pager until the user quits.
91///
92/// Returns `0` on normal exit, non-zero on fatal errors (propagated via
93/// [`anyhow::Result`] so the caller decides exit code / stderr shape).
94pub fn run(
95    filename: &str,
96    common: &CommonArgs,
97    opts: MdlessOptions,
98    resource_handler: &dyn ResourceUrlHandler,
99) -> Result<i32> {
100    let doc = render_doc(filename, common, opts.line_numbers, resource_handler)?;
101    let (cols, rows) = size().unwrap_or((80, 24));
102
103    let mut session = Session {
104        doc,
105        view: View::new(cols, rows).with_line_numbers(opts.line_numbers),
106        decoder: Decoder::default(),
107        search: None,
108        direction: SearchDirection::Forward,
109        input: String::new(),
110        status: None,
111        regex: opts.regex,
112        case: if opts.case_sensitive {
113            CaseMode::Sensitive
114        } else {
115            CaseMode::Smart
116        },
117        toc: None,
118        bookmarks: HashMap::new(),
119    };
120
121    // Honour --search: commit the pattern before the event loop starts.
122    if let Some(initial) = opts.initial {
123        apply_search(&mut session, initial);
124    }
125
126    let _guard = TerminalGuard::enter()?;
127    let mut out = BufWriter::new(io::stdout());
128    draw(&session, &mut out)?;
129
130    loop {
131        match event::read()? {
132            Event::Key(key) if key.kind == event::KeyEventKind::Press => {
133                let cmd = session.decoder.feed(key);
134                if dispatch_cmd(&mut session, cmd) {
135                    break;
136                }
137                draw(&session, &mut out)?;
138            }
139            Event::Resize(cols, rows) => {
140                session.view.resize(cols, rows, &session.doc);
141                draw(&session, &mut out)?;
142            }
143            _ => {}
144        }
145    }
146    Ok(0)
147}
148
149/// Act on one decoded [`Command`]. Returns `true` when the pager should quit.
150fn dispatch_cmd(s: &mut Session, cmd: Command) -> bool {
151    if s.toc.is_some() {
152        return dispatch_toc(s, cmd);
153    }
154    match cmd {
155        Command::Noop => false,
156        Command::Quit => true,
157        Command::Redraw => false,
158        Command::BeginSearch(dir) => {
159            s.direction = dir;
160            s.input.clear();
161            s.status = Some(prompt_for(dir).to_string());
162            false
163        }
164        Command::SearchChar(c) => {
165            s.input.push(c);
166            s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
167            false
168        }
169        Command::SearchBackspace => {
170            s.input.pop();
171            s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
172            false
173        }
174        Command::SearchCommit => {
175            let pattern = std::mem::take(&mut s.input);
176            if pattern.is_empty() {
177                s.status = None;
178            } else {
179                apply_search(s, pattern);
180            }
181            false
182        }
183        Command::SearchCancel | Command::ClearHighlights => {
184            s.search = None;
185            s.input.clear();
186            s.status = None;
187            false
188        }
189        Command::SearchNext => {
190            step_search(s, Direction::Forward);
191            false
192        }
193        Command::SearchPrev => {
194            step_search(s, Direction::Backward);
195            false
196        }
197        Command::NextHeading => {
198            jump_heading(s, Direction::Forward);
199            false
200        }
201        Command::PrevHeading => {
202            jump_heading(s, Direction::Backward);
203            false
204        }
205        Command::ToggleToc => {
206            s.toc = Some(Toc::new(&s.doc.headings));
207            s.status = None;
208            false
209        }
210        Command::SetBookmark(c) => {
211            s.bookmarks.insert(c, s.view.top);
212            s.status = Some(format!("bookmark {c} set at line {}", s.view.top + 1));
213            false
214        }
215        Command::JumpBookmark(c) => {
216            if let Some(&line) = s.bookmarks.get(&c) {
217                s.view.jump_to(line, &s.doc);
218                s.status = None;
219            } else {
220                s.status = Some(format!("no bookmark `{c}`"));
221            }
222            false
223        }
224        Command::ToggleLineNumbers => {
225            s.view.line_numbers = !s.view.line_numbers;
226            false
227        }
228        // `Enter` outside the TOC currently has no binding; ignore it.
229        Command::TocActivate => false,
230        other => {
231            s.view.apply(other, &s.doc);
232            false
233        }
234    }
235}
236
237/// Handle keys while the TOC modal is open.
238///
239/// Returns `true` when the quit command was issued. The modal consumes
240/// navigation keystrokes for selection; `Enter` jumps to the highlighted
241/// heading, `Esc`/`T`/`q` close without moving.
242fn dispatch_toc(s: &mut Session, cmd: Command) -> bool {
243    let total = s.doc.headings.len();
244    match cmd {
245        Command::Quit => true,
246        Command::ToggleToc | Command::ClearHighlights | Command::SearchCancel => {
247            s.toc = None;
248            false
249        }
250        Command::ScrollDown(n) => {
251            if let Some(t) = s.toc.as_mut() {
252                t.step(isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
253            }
254            false
255        }
256        Command::ScrollUp(n) => {
257            if let Some(t) = s.toc.as_mut() {
258                t.step(-isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
259            }
260            false
261        }
262        Command::Home => {
263            if let Some(t) = s.toc.as_mut() {
264                t.selected = 0;
265            }
266            false
267        }
268        Command::End => {
269            if let Some(t) = s.toc.as_mut() {
270                t.selected = total.saturating_sub(1);
271            }
272            false
273        }
274        Command::TocActivate => {
275            let target = s.toc.as_ref().and_then(|t| s.doc.headings.get(t.selected));
276            if let Some(h) = target {
277                let line = s.doc.line_for_plain_offset(h.plain_offset);
278                s.view.scroll_to(line, &s.doc);
279            }
280            s.toc = None;
281            false
282        }
283        _ => false,
284    }
285}
286
287/// Scroll to the next heading relative to the viewport top.
288///
289/// Forward skips the current heading if its line equals `view.top` so
290/// repeated `]]` keystrokes walk down the document rather than sticking.
291fn jump_heading(s: &mut Session, dir: Direction) {
292    let top = s.view.top;
293    let target = match dir {
294        Direction::Forward => s
295            .doc
296            .headings
297            .iter()
298            .map(|h| s.doc.line_for_plain_offset(h.plain_offset))
299            .find(|&line| line > top),
300        Direction::Backward => s
301            .doc
302            .headings
303            .iter()
304            .rev()
305            .map(|h| s.doc.line_for_plain_offset(h.plain_offset))
306            .find(|&line| line < top),
307    };
308    if let Some(line) = target {
309        s.view.scroll_to(line, &s.doc);
310    } else {
311        s.status = Some(match dir {
312            Direction::Forward => "no next heading".to_string(),
313            Direction::Backward => "no previous heading".to_string(),
314        });
315    }
316}
317
318/// Prompt prefix shown in the status bar while search input is active.
319fn prompt_for(dir: SearchDirection) -> &'static str {
320    match dir {
321        SearchDirection::Forward => "/",
322        SearchDirection::Backward => "?",
323    }
324}
325
326/// Compile `pattern`, jump to the first match, update the status text.
327fn apply_search(s: &mut Session, pattern: String) {
328    let mut state = match SearchState::compile(&s.doc, &pattern, s.regex, s.case) {
329        Ok(state) => state,
330        Err(error) => {
331            s.status = Some(format!("{error}"));
332            return;
333        }
334    };
335    let initial = match s.direction {
336        SearchDirection::Forward => Direction::Forward,
337        SearchDirection::Backward => Direction::Backward,
338    };
339    let jump = state
340        .current()
341        .map(|m| m.line)
342        .or_else(|| state.step(initial).map(|(m, _)| m.line));
343    let total = state.len();
344    s.search = Some(state);
345    s.status = Some(if total == 0 {
346        format!("Pattern not found: {pattern}")
347    } else {
348        format!("{total} matches  n/N:next/prev  Esc:clear")
349    });
350    if let Some(line) = jump {
351        s.view.scroll_to(line, &s.doc);
352    }
353}
354
355/// Advance the match cursor and scroll so the new match is visible.
356fn step_search(s: &mut Session, dir: Direction) {
357    let Some(state) = s.search.as_mut() else {
358        return;
359    };
360    if let Some((m, wrapped)) = state.step(dir) {
361        if wrapped {
362            s.status = Some("search wrapped".to_string());
363        }
364        s.view.scroll_to(m.line, &s.doc);
365    }
366}
367
368/// Emit the next frame — body or TOC modal, depending on session state.
369fn draw<W: Write>(s: &Session, out: &mut W) -> io::Result<()> {
370    match s.toc.as_ref() {
371        Some(toc) => s.view.draw_toc(out, &s.doc.headings, toc),
372        None => s.view.draw(
373            out,
374            &s.doc,
375            s.matches(),
376            s.current_match(),
377            s.status.as_deref(),
378        ),
379    }
380}
381
382/// Cap rendered lines at ~120 columns on wide terminals; prose and code
383/// fences past that wrap into a wall of text. `--columns` overrides.
384const MAX_RENDER_COLS: u16 = 120;
385
386/// Render the document once with image protocols disabled and return the
387/// pager's in-memory buffer.
388fn render_doc(
389    filename: &str,
390    common: &CommonArgs,
391    line_numbers: bool,
392    resource_handler: &dyn ResourceUrlHandler,
393) -> Result<RenderedDoc> {
394    let (base_dir, input) = read_input(filename)?;
395    let parser = Parser::new_ext(&input, crate::markdown_options());
396    let env =
397        Environment::for_local_directory(&base_dir).context("build environment for mdless")?;
398
399    let (cols, _rows) = size().unwrap_or((80, 24));
400    // Reserve the gutter footprint up front so code-block frames,
401    // tables, and rules never spill past the right edge of the
402    // viewport once the gutter steals its columns.
403    let reserved = if line_numbers { view::GUTTER } else { 2 };
404    let columns = common
405        .columns
406        .unwrap_or_else(|| cols.saturating_sub(reserved).clamp(20, MAX_RENDER_COLS));
407    let terminal_size = crate::TerminalSize::default().with_max_columns(columns);
408
409    let syntax_set = syntect::parsing::SyntaxSet::load_defaults_newlines();
410    let settings = Settings {
411        terminal_capabilities: ansi_without_images(),
412        terminal_size,
413        multiplexer: Multiplexer::None,
414        syntax_set: &syntax_set,
415        theme: Theme::default(),
416        wrap_code: common.wrap_code,
417    };
418
419    let mut styled = Vec::with_capacity(input.len() * 2);
420    let mut recorder = HeadingRecorder::default();
421    crate::push_tty_with_observer(
422        &settings,
423        &env,
424        resource_handler,
425        &mut styled,
426        parser,
427        &mut recorder,
428    )
429    .with_context(|| format!("rendering {}", Path::new(filename).display()))?;
430
431    Ok(build(styled, recorder.finish()))
432}
433
434/// ANSI styling + OSC 8 links, no image protocols.
435fn ansi_without_images() -> TerminalCapabilities {
436    let mut caps = TerminalProgram::Ansi.capabilities();
437    caps.image = None;
438    caps
439}
440
441/// RAII guard that enters the alternate screen + raw mode on construction
442/// and restores the terminal on drop, even on panic.
443struct TerminalGuard;
444
445impl TerminalGuard {
446    fn enter() -> Result<Self> {
447        enable_raw_mode().context("enable raw mode")?;
448        execute!(io::stdout(), EnterAlternateScreen, Hide).context("enter alternate screen")?;
449        install_panic_hook();
450        Ok(Self)
451    }
452}
453
454/// Restore the terminal on panic so a crashed pager doesn't strand the
455/// user in raw mode on the alternate screen.
456///
457/// Chains to the previous hook after cleanup so panics still print.
458fn install_panic_hook() {
459    use std::sync::Once;
460    static HOOK: Once = Once::new();
461    HOOK.call_once(|| {
462        let previous = std::panic::take_hook();
463        std::panic::set_hook(Box::new(move |info| {
464            let mut out = io::stdout();
465            let _ = queue!(out, Show, LeaveAlternateScreen);
466            let _ = out.flush();
467            let _ = disable_raw_mode();
468            previous(info);
469        }));
470    });
471}
472
473impl Drop for TerminalGuard {
474    fn drop(&mut self) {
475        // Best-effort cleanup; we're already tearing down.
476        let mut out = io::stdout();
477        let _ = queue!(out, Show, LeaveAlternateScreen);
478        let _ = out.flush();
479        let _ = disable_raw_mode();
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use std::collections::HashMap;
486
487    use super::buffer::build;
488    use super::keys::{Command, Decoder};
489    use super::view::View;
490    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
491
492    fn doc(lines: &[&str]) -> super::RenderedDoc {
493        let styled = lines
494            .iter()
495            .flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
496            .collect();
497        build(styled, Vec::new())
498    }
499
500    /// End-to-end: scroll down with `j`, page down with Space, quit with `q`.
501    /// Assert the resulting viewport top matches the expected sequence.
502    #[test]
503    fn scripted_keystrokes_drive_viewport() {
504        let d = doc(&[
505            "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
506        ]);
507        let mut v = View::new(80, 4); // 3 body rows
508        let mut dec = Decoder::default();
509
510        let script = [
511            (KeyCode::Char('j'), 1),
512            (KeyCode::Char('j'), 2),
513            (KeyCode::Char(' '), 5),
514            (KeyCode::Char('k'), 4),
515            (KeyCode::Char('G'), 7),
516            (KeyCode::Char('g'), 7), // first g — Noop
517            (KeyCode::Char('g'), 0), // second g — Home
518        ];
519        for (code, expected_top) in script {
520            let cmd = dec.feed(KeyEvent::new(code, KeyModifiers::NONE));
521            v.apply(cmd, &d);
522            assert_eq!(v.top, expected_top, "after {code:?}");
523        }
524
525        let quit_cmd = dec.feed(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
526        assert!(v.apply(quit_cmd, &d));
527    }
528
529    /// Resize while scrolled past the new end clamps back into range.
530    #[test]
531    fn resize_clamp_preserves_visibility() {
532        let d = doc(&["a"; 20]);
533        let mut v = View::new(80, 10);
534        v.apply(Command::End, &d); // top = 11
535        v.resize(80, 80, &d); // body_rows 79 → can show whole doc
536        assert_eq!(v.top, 0);
537    }
538
539    /// Build a session wrapping hand-crafted styled bytes + headings so
540    /// heading-jump / TOC tests don't need to run the full renderer.
541    ///
542    /// View is 80x5 (4 body rows) so small doc fixtures can still test
543    /// `scroll_to` without being clamped to `top = 0`.
544    fn session_with_headings(
545        lines: &[&str],
546        headings: Vec<super::buffer::HeadingEntry>,
547    ) -> super::Session {
548        let styled: Vec<u8> = lines
549            .iter()
550            .flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
551            .collect();
552        let doc = build(styled, headings);
553        super::Session {
554            doc,
555            view: View::new(80, 5),
556            decoder: Decoder::default(),
557            search: None,
558            direction: super::SearchDirection::Forward,
559            input: String::new(),
560            status: None,
561            regex: false,
562            case: super::CaseMode::Smart,
563            toc: None,
564            bookmarks: HashMap::new(),
565        }
566    }
567
568    /// Press a single-character key and process it through `dispatch`.
569    fn press(s: &mut super::Session, c: char) {
570        let cmd = s
571            .decoder
572            .feed(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
573        let _ = super::dispatch_cmd(s, cmd);
574    }
575
576    /// `]]` scrolls forward past the viewport top to the next heading.
577    #[test]
578    fn double_bracket_jumps_to_next_heading() {
579        let lines = (0..20)
580            .map(|i| if i == 5 { "## Sub" } else { "body" })
581            .collect::<Vec<_>>();
582        // One heading at rendered line 5, plain offset = sum of prior line lengths.
583        let offset = (0..5).map(|_| "body".len() + 1).sum::<usize>();
584        let headings = vec![super::buffer::HeadingEntry {
585            level: 2,
586            text: "Sub".to_string(),
587            plain_offset: offset,
588        }];
589        let mut s = session_with_headings(&lines, headings);
590        super::jump_heading(&mut s, super::Direction::Forward);
591        // scroll_to places target near top with a two-line breadcrumb.
592        assert_eq!(s.view.top, 3);
593    }
594
595    /// Pressing `T` opens the TOC and selects the first heading.
596    #[test]
597    fn t_opens_toc_modal() {
598        let headings = vec![
599            super::buffer::HeadingEntry {
600                level: 1,
601                text: "Intro".to_string(),
602                plain_offset: 0,
603            },
604            super::buffer::HeadingEntry {
605                level: 2,
606                text: "Body".to_string(),
607                plain_offset: 20,
608            },
609        ];
610        let lines = ["# Intro", "x", "x", "x", "x", "## Body", "x"];
611        let mut s = session_with_headings(&lines, headings);
612        press(&mut s, 'T');
613        assert!(s.toc.is_some());
614        assert_eq!(s.toc.unwrap().selected, 0);
615    }
616
617    /// Inside the TOC, `j` advances the selection; `Enter` jumps and closes.
618    #[test]
619    fn toc_navigation_jumps_to_selected_heading() {
620        // Plain layout: "# First\n" (0..8), "a\n" (8..10), "b\n" (10..12),
621        // "c\n" (12..14), "d\n" (14..16), "# Second\n" (16..25), "e\n" (25..27).
622        let headings = vec![
623            super::buffer::HeadingEntry {
624                level: 1,
625                text: "First".to_string(),
626                plain_offset: 0,
627            },
628            super::buffer::HeadingEntry {
629                level: 1,
630                text: "Second".to_string(),
631                plain_offset: 16,
632            },
633        ];
634        let lines = ["# First", "a", "b", "c", "d", "# Second", "e"];
635        let mut s = session_with_headings(&lines, headings);
636        press(&mut s, 'T');
637        press(&mut s, 'j');
638        let cmd = s
639            .decoder
640            .feed(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
641        let _ = super::dispatch_cmd(&mut s, cmd);
642        assert!(s.toc.is_none(), "TOC should close after activation");
643        // scroll_to subtracts 2 for breadcrumb; heading line 5 → top = 3.
644        assert_eq!(s.view.top, 3);
645    }
646
647    /// `m a` saves the current top; `'a` jumps back to the exact line.
648    #[test]
649    fn bookmark_roundtrip_restores_view_top() {
650        let lines: Vec<&str> = (0..20).map(|_| "line").collect();
651        let mut s = session_with_headings(&lines, Vec::new());
652        // Start with a wider view that can actually scroll.
653        s.view = View::new(80, 10);
654
655        s.view.top = 7;
656        press(&mut s, 'm');
657        press(&mut s, 'a');
658        // Scroll away, then jump back via the bookmark.
659        s.view.top = 0;
660        press(&mut s, '\'');
661        press(&mut s, 'a');
662        assert_eq!(s.view.top, 7);
663    }
664}