Skip to main content

glow/
lib.rs

1#![forbid(unsafe_code)]
2// Allow pedantic lints for early-stage API ergonomics.
3#![allow(clippy::nursery)]
4#![allow(clippy::pedantic)]
5
6//! # Glow
7//!
8//! A terminal-based markdown reader and browser.
9//!
10//! Glow provides a beautiful way to read markdown files directly in the terminal:
11//! - Render local markdown files
12//! - Browse local files
13//! - Fetch GitHub READMEs (with the `github` feature)
14//! - Stash and organize documents
15//! - Customizable pager controls
16//!
17//! ## Role in `charmed_rust`
18//!
19//! Glow is the application-layer Markdown reader:
20//! - **glamour** renders Markdown content.
21//! - **bubbletea** powers the pager UI and input handling.
22//! - **bubbles** provides reusable viewport components.
23//! - **lipgloss** styles the output and chrome.
24//!
25//! ## Quick start (library)
26//!
27//! ```rust,no_run
28//! use glow::{Reader, Config};
29//!
30//! fn main() -> std::io::Result<()> {
31//!     let config = Config::new()
32//!         .pager(true)
33//!         .width(80)
34//!         .style("dark");
35//!
36//!     let reader = Reader::new(config);
37//!     let rendered = reader.read_file("README.md")?;
38//!     println!("{rendered}");
39//!     Ok(())
40//! }
41//! ```
42//!
43//! ## CLI usage
44//!
45//! ```bash
46//! glow README.md
47//! glow --style light README.md
48//! glow --width 80 README.md
49//! glow --no-pager README.md
50//! cat README.md | glow -
51//! ```
52//!
53//! ## Feature flags
54//!
55//! - `github`: enable GitHub README fetching utilities
56//! - `default`: core markdown rendering via `glamour`
57
58pub mod browser;
59
60#[cfg(feature = "github")]
61pub mod github;
62
63use std::io;
64use std::path::Path;
65
66use glamour::{Style as GlamourStyle, TermRenderer};
67
68/// Configuration for the markdown reader.
69///
70/// Defaults:
71/// - pager enabled
72/// - width uses glamour's default word wrap
73/// - style set to `"dark"`
74///
75/// # Example
76///
77/// ```rust
78/// use glow::Config;
79///
80/// let config = Config::new()
81///     .pager(false)
82///     .width(80)
83///     .style("light");
84/// ```
85#[derive(Debug, Clone)]
86pub struct Config {
87    pager: bool,
88    width: Option<usize>,
89    style: String,
90    line_numbers: bool,
91    preserve_newlines: bool,
92}
93
94impl Config {
95    /// Creates a new configuration with default settings.
96    pub fn new() -> Self {
97        Self {
98            pager: true,
99            width: None,
100            style: "dark".to_string(),
101            line_numbers: false,
102            preserve_newlines: false,
103        }
104    }
105
106    /// Enables or disables pager mode.
107    pub fn pager(mut self, enabled: bool) -> Self {
108        self.pager = enabled;
109        self
110    }
111
112    /// Sets the output width.
113    pub fn width(mut self, width: usize) -> Self {
114        self.width = Some(width);
115        self
116    }
117
118    /// Sets the style theme.
119    pub fn style(mut self, style: impl Into<String>) -> Self {
120        self.style = style.into();
121        self
122    }
123
124    /// Enables or disables line numbers in code blocks.
125    pub fn line_numbers(mut self, enabled: bool) -> Self {
126        self.line_numbers = enabled;
127        self
128    }
129
130    /// Enables or disables preserving newlines in output.
131    pub fn preserve_newlines(mut self, enabled: bool) -> Self {
132        self.preserve_newlines = enabled;
133        self
134    }
135
136    fn glamour_style(&self) -> io::Result<GlamourStyle> {
137        parse_style(&self.style).ok_or_else(|| {
138            io::Error::new(
139                io::ErrorKind::InvalidInput,
140                format!("unknown style: {}", self.style),
141            )
142        })
143    }
144
145    fn renderer(&self) -> io::Result<TermRenderer> {
146        let style = self.glamour_style()?;
147        let mut renderer = TermRenderer::new()
148            .with_style(style)
149            .with_preserved_newlines(self.preserve_newlines);
150        if let Some(width) = self.width {
151            renderer = renderer.with_word_wrap(width);
152        }
153        // line_numbers is only available with syntax-highlighting feature
154        #[cfg(feature = "syntax-highlighting")]
155        if self.line_numbers {
156            renderer.set_line_numbers(true);
157        }
158        Ok(renderer)
159    }
160}
161
162impl Default for Config {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// Markdown file reader.
169///
170/// # Example
171///
172/// ```rust,no_run
173/// use glow::{Config, Reader};
174///
175/// # fn main() -> std::io::Result<()> {
176/// let reader = Reader::new(Config::new().width(80));
177/// let output = reader.read_file("README.md")?;
178/// println!("{output}");
179/// # Ok(())
180/// # }
181/// ```
182#[derive(Debug)]
183pub struct Reader {
184    config: Config,
185}
186
187impl Reader {
188    /// Creates a new reader with the given configuration.
189    pub fn new(config: Config) -> Self {
190        Self { config }
191    }
192
193    /// Returns the reader configuration.
194    pub fn config(&self) -> &Config {
195        &self.config
196    }
197
198    /// Reads and renders a markdown file.
199    pub fn read_file<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
200        let markdown = std::fs::read_to_string(path)?;
201        self.render_markdown(&markdown)
202    }
203
204    /// Renders markdown text using the configured renderer.
205    pub fn render_markdown(&self, markdown: &str) -> io::Result<String> {
206        let renderer = self.config.renderer()?;
207        // Match Go glow: trim leading/trailing whitespace on each rendered line.
208        Ok(trim_rendered_output(&renderer.render(markdown)))
209    }
210}
211
212/// Stash for saving and organizing documents.
213///
214/// # Example
215///
216/// ```rust
217/// use glow::Stash;
218///
219/// let mut stash = Stash::new();
220/// stash.add("README.md");
221/// stash.add("docs/guide.md");
222/// assert_eq!(stash.documents().len(), 2);
223/// ```
224#[derive(Debug, Default)]
225pub struct Stash {
226    documents: Vec<String>,
227}
228
229impl Stash {
230    /// Creates a new empty stash.
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Adds a document to the stash.
236    pub fn add(&mut self, path: impl Into<String>) {
237        self.documents.push(path.into());
238    }
239
240    /// Returns all stashed documents.
241    pub fn documents(&self) -> &[String] {
242        &self.documents
243    }
244}
245
246/// Prelude module for convenient imports.
247pub mod prelude {
248    pub use crate::browser::{BrowserConfig, Entry, FileBrowser, FileSelectedMsg};
249    pub use crate::{Config, Reader, Stash};
250}
251
252fn trim_rendered_output(s: &str) -> String {
253    let mut out = String::new();
254    let mut iter = s.split('\n').peekable();
255    while let Some(line) = iter.next() {
256        out.push_str(line.trim());
257        if iter.peek().is_some() {
258            out.push('\n');
259        }
260    }
261    out
262}
263
264fn parse_style(style: &str) -> Option<GlamourStyle> {
265    match style.trim().to_ascii_lowercase().as_str() {
266        "dark" => Some(GlamourStyle::Dark),
267        "light" => Some(GlamourStyle::Light),
268        "ascii" => Some(GlamourStyle::Ascii),
269        "pink" => Some(GlamourStyle::Pink),
270        "auto" => Some(GlamourStyle::Auto),
271        "no-tty" | "notty" | "no_tty" => Some(GlamourStyle::NoTty),
272        _ => None,
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    // =========================================================================
281    // Style Parsing Tests
282    // =========================================================================
283
284    #[test]
285    fn parse_style_accepts_known_values() {
286        let cases = ["dark", "light", "ascii", "pink", "auto", "no-tty", "no_tty"];
287        for style in cases {
288            assert!(parse_style(style).is_some(), "style {style} should parse");
289        }
290    }
291
292    #[test]
293    fn parse_style_is_case_insensitive() {
294        assert!(parse_style("DARK").is_some());
295        assert!(parse_style("Dark").is_some());
296        assert!(parse_style("LIGHT").is_some());
297        assert!(parse_style("NoTTY").is_some());
298    }
299
300    #[test]
301    fn parse_style_trims_whitespace() {
302        assert!(parse_style("  dark  ").is_some());
303        assert!(parse_style("\tdark\n").is_some());
304    }
305
306    #[test]
307    fn parse_style_returns_none_for_unknown() {
308        assert!(parse_style("unknown").is_none());
309        assert!(parse_style("").is_none());
310        assert!(parse_style("dracula").is_none());
311    }
312
313    // =========================================================================
314    // Config Tests
315    // =========================================================================
316
317    #[test]
318    fn config_default_values() {
319        let config = Config::new();
320        assert!(config.pager);
321        assert!(config.width.is_none());
322        assert_eq!(config.style, "dark");
323    }
324
325    #[test]
326    fn config_default_trait() {
327        let config = Config::default();
328        assert!(config.pager);
329        assert_eq!(config.style, "dark");
330    }
331
332    #[test]
333    fn config_pager_sets_value() {
334        let config = Config::new().pager(false);
335        assert!(!config.pager);
336
337        let config = Config::new().pager(true);
338        assert!(config.pager);
339    }
340
341    #[test]
342    fn config_width_sets_value() {
343        let config = Config::new().width(80);
344        assert_eq!(config.width, Some(80));
345
346        let config = Config::new().width(120);
347        assert_eq!(config.width, Some(120));
348    }
349
350    #[test]
351    fn config_style_sets_value() {
352        let config = Config::new().style("light");
353        assert_eq!(config.style, "light");
354
355        let config = Config::new().style(String::from("pink"));
356        assert_eq!(config.style, "pink");
357    }
358
359    #[test]
360    fn config_builder_chaining() {
361        let config = Config::new().pager(false).width(100).style("ascii");
362
363        assert!(!config.pager);
364        assert_eq!(config.width, Some(100));
365        assert_eq!(config.style, "ascii");
366    }
367
368    #[test]
369    fn config_glamour_style_valid() {
370        let config = Config::new().style("dark");
371        assert!(config.glamour_style().is_ok());
372    }
373
374    #[test]
375    fn config_rejects_unknown_style() {
376        let config = Config::new().style("unknown");
377        let err = config.glamour_style().unwrap_err();
378        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
379    }
380
381    #[test]
382    fn config_renderer_creates_renderer() {
383        let config = Config::new().style("dark").width(80);
384        let result = config.renderer();
385        assert!(result.is_ok());
386    }
387
388    #[test]
389    fn config_renderer_fails_on_invalid_style() {
390        let config = Config::new().style("invalid");
391        let result = config.renderer();
392        assert!(result.is_err());
393    }
394
395    // =========================================================================
396    // Reader Tests
397    // =========================================================================
398
399    #[test]
400    fn reader_new_stores_config() {
401        let config = Config::new().style("light").width(100);
402        let reader = Reader::new(config);
403
404        assert_eq!(reader.config().style, "light");
405        assert_eq!(reader.config().width, Some(100));
406    }
407
408    #[test]
409    fn reader_render_markdown_basic() {
410        let config = Config::new().style("dark");
411        let reader = Reader::new(config);
412
413        let result = reader.render_markdown("# Hello World");
414        assert!(result.is_ok());
415        let output = result.unwrap();
416        assert!(!output.is_empty());
417    }
418
419    #[test]
420    fn reader_render_markdown_empty_input() {
421        let config = Config::new().style("dark");
422        let reader = Reader::new(config);
423
424        let result = reader.render_markdown("");
425        assert!(result.is_ok());
426    }
427
428    #[test]
429    fn reader_render_markdown_complex() {
430        let config = Config::new().style("dark").width(80);
431        let reader = Reader::new(config);
432
433        let markdown = r#"
434# Heading
435
436Some **bold** and *italic* text.
437
438- List item 1
439- List item 2
440
441```rust
442fn main() {}
443```
444"#;
445
446        let result = reader.render_markdown(markdown);
447        assert!(result.is_ok());
448    }
449
450    #[test]
451    fn reader_render_fails_on_invalid_style() {
452        let config = Config::new().style("invalid");
453        let reader = Reader::new(config);
454
455        let result = reader.render_markdown("# Test");
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn reader_read_file_nonexistent() {
461        let config = Config::new().style("dark");
462        let reader = Reader::new(config);
463
464        let result = reader.read_file("/nonexistent/path/file.md");
465        assert!(result.is_err());
466    }
467
468    // =========================================================================
469    // Stash Tests
470    // =========================================================================
471
472    #[test]
473    fn stash_new_is_empty() {
474        let stash = Stash::new();
475        assert!(stash.documents().is_empty());
476    }
477
478    #[test]
479    fn stash_default_is_empty() {
480        let stash = Stash::default();
481        assert!(stash.documents().is_empty());
482    }
483
484    #[test]
485    fn stash_add_single_document() {
486        let mut stash = Stash::new();
487        stash.add("/path/to/file.md");
488
489        assert_eq!(stash.documents().len(), 1);
490        assert_eq!(stash.documents()[0], "/path/to/file.md");
491    }
492
493    #[test]
494    fn stash_add_multiple_documents() {
495        let mut stash = Stash::new();
496        stash.add("file1.md");
497        stash.add("file2.md");
498        stash.add("file3.md");
499
500        assert_eq!(stash.documents().len(), 3);
501        assert_eq!(stash.documents(), &["file1.md", "file2.md", "file3.md"]);
502    }
503
504    #[test]
505    fn stash_add_accepts_string() {
506        let mut stash = Stash::new();
507        stash.add(String::from("owned.md"));
508
509        assert_eq!(stash.documents()[0], "owned.md");
510    }
511
512    #[test]
513    fn stash_add_accepts_str() {
514        let mut stash = Stash::new();
515        stash.add("borrowed.md");
516
517        assert_eq!(stash.documents()[0], "borrowed.md");
518    }
519}