leaf-markdown-viewer 1.26.2

Terminal Markdown previewer with a GUI-like experience
use anyhow::{bail, Result};

use crate::inline::{self, InlineSpec};

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ConfigAction {
    Open,
    Reset,
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) struct AutoCompleteArg {
    pub(crate) shell: Option<String>,
    pub(crate) dump: bool,
}

#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct CliOptions {
    pub(crate) picker: bool,
    pub(crate) watch: bool,
    pub(crate) update: bool,
    pub(crate) config: Option<ConfigAction>,
    pub(crate) auto_complete: Option<AutoCompleteArg>,
    pub(crate) debug_input: bool,
    pub(crate) print_help: bool,
    pub(crate) print_version: bool,
    pub(crate) file_arg: Option<String>,
    pub(crate) theme: Option<String>,
    pub(crate) editor: Option<String>,
    pub(crate) inline: Option<InlineSpec>,
    pub(crate) width: Option<usize>,
}

pub(crate) fn usage_text() -> &'static str {
    "Usage:  leaf [OPTIONS] [file.md | directory]\n\
     \x20       leaf [--watch] --picker\n\
     \x20       leaf --update\n\
     \x20       echo '# Hello' | leaf\n\
     \n\
     Options:\n\
     \x20 -h, --help                   Show this help message and exit\n\
     \x20 -V, --version                Show version information and exit\n\
     \x20 -w, --watch                  Watch the file for changes and reload automatically\n\
     \x20     --theme <NAME>           Set color theme preset or custom config theme\n\
     \x20 -e, --editor <NAME>          Set external editor (nano|vim|code|subl|emacs)\n\
     \x20     --inline [SPEC]          Render to stdout (no TUI) [ansi|plain][:<width>]\n\
     \x20     --width <N>              Set maximum content width (min: 20)\n\
     \x20     --picker                 Open the file browser picker\n\
     \x20     --config [reset]         Open or reset configuration file\n\
     \x20     --update                 Update leaf to the latest version\n\
     \x20     --auto-complete [SPEC]   Install or dump shell completions [bash|zsh|fish|powershell][:dump]"
}

pub(crate) fn version_text() -> &'static str {
    concat!("leaf ", env!("CARGO_PKG_VERSION"))
}

pub(crate) fn print_usage() {
    println!("{}", usage_text());
}

pub(crate) fn print_version() {
    println!("{}", version_text());
}

pub(crate) fn parse_cli(args: &[String]) -> Result<CliOptions> {
    let mut options = CliOptions::default();
    let mut positional_only = false;
    let mut iter = args.iter().skip(1).peekable();

    while let Some(arg) = iter.next() {
        if positional_only {
            if options.file_arg.is_none() {
                options.file_arg = Some(arg.clone());
            } else {
                anyhow::bail!("Too many file arguments");
            }
            continue;
        }

        match arg.as_str() {
            "--picker" => options.picker = true,
            "--watch" | "-w" => options.watch = true,
            "--update" => options.update = true,
            "--config" => {
                let action = match iter.peek() {
                    Some(next) if next.as_str() == "reset" => {
                        iter.next();
                        ConfigAction::Reset
                    }
                    _ => ConfigAction::Open,
                };
                options.config = Some(action);
            }
            "--auto-complete" => {
                let ac_arg = match iter.peek() {
                    Some(next) if !next.starts_with('-') => {
                        let value = iter.next().unwrap();
                        parse_auto_complete_value(value)?
                    }
                    _ => AutoCompleteArg {
                        shell: None,
                        dump: false,
                    },
                };
                options.auto_complete = Some(ac_arg);
            }
            "--debug-input" => options.debug_input = true,
            "--help" | "-h" => options.print_help = true,
            "--version" | "-V" => options.print_version = true,
            "--theme" => {
                let Some(name) = iter.next() else {
                    anyhow::bail!("Missing value for --theme");
                };
                options.theme = Some(parse_theme_name(name)?);
            }
            _ if arg.starts_with("--theme=") => {
                let name = &arg["--theme=".len()..];
                options.theme = Some(parse_theme_name(name)?);
            }
            "--editor" | "-e" => {
                let Some(value) = iter.next() else {
                    anyhow::bail!("Missing value for --editor");
                };
                options.editor = Some(value.clone());
            }
            _ if arg.starts_with("--editor=") => {
                options.editor = Some(arg["--editor=".len()..].to_string());
            }
            "--inline" => {
                let spec = match iter.peek() {
                    Some(next) if inline::is_inline_spec(next) => {
                        let value = iter.next().unwrap();
                        inline::parse_inline_spec(value)?
                    }
                    _ => InlineSpec {
                        format: inline::InlineFormat::Auto,
                        width: None,
                    },
                };
                options.inline = Some(spec);
            }
            _ if arg.starts_with("--inline=") => {
                let value = &arg["--inline=".len()..];
                options.inline = Some(inline::parse_inline_spec(value)?);
            }
            "--width" => {
                let Some(value) = iter.next() else {
                    anyhow::bail!("Missing value for --width");
                };
                options.width = Some(parse_width_value(value)?);
            }
            _ if arg.starts_with("--width=") => {
                let value = &arg["--width=".len()..];
                options.width = Some(parse_width_value(value)?);
            }
            "--" => positional_only = true,
            _ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"),
            _ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()),
            _ => anyhow::bail!("Too many file arguments"),
        }
    }

    let standalone = [
        (options.update, "--update"),
        (options.config.is_some(), "--config"),
        (options.auto_complete.is_some(), "--auto-complete"),
    ];
    let standalone_count = standalone.iter().filter(|(set, _)| *set).count();
    for &(set, name) in &standalone {
        if !set {
            continue;
        }
        let has_other = standalone_count > 1
            || options.watch
            || options.picker
            || options.debug_input
            || options.file_arg.is_some()
            || options.theme.is_some()
            || options.editor.is_some();
        if has_other {
            anyhow::bail!("{name} must be used on its own");
        }
    }

    if options.inline.is_some() {
        if options.watch {
            anyhow::bail!("--inline cannot be combined with --watch");
        }
        if options.picker {
            anyhow::bail!("--inline cannot be combined with --picker");
        }
    }

    Ok(options)
}

fn parse_theme_name(name: &str) -> Result<String> {
    let name = name.trim();
    if name.is_empty() {
        anyhow::bail!("Missing value for --theme");
    }
    Ok(name.to_string())
}

const KNOWN_SHELLS: &[&str] = &["bash", "zsh", "fish", "powershell"];

fn parse_auto_complete_value(s: &str) -> Result<AutoCompleteArg> {
    if s == "dump" {
        return Ok(AutoCompleteArg {
            shell: None,
            dump: true,
        });
    }
    if let Some(prefix) = s.strip_suffix(":dump") {
        if KNOWN_SHELLS.contains(&prefix) {
            return Ok(AutoCompleteArg {
                shell: Some(prefix.to_string()),
                dump: true,
            });
        }
        bail!("Unknown shell: '{prefix}'. Expected: bash, zsh, fish, powershell");
    }
    if KNOWN_SHELLS.contains(&s) {
        return Ok(AutoCompleteArg {
            shell: Some(s.to_string()),
            dump: false,
        });
    }
    bail!(
        "Invalid argument for --auto-complete: '{s}'. \
         Expected: bash, zsh, fish, powershell, dump, or SHELL:dump"
    );
}

fn parse_width_value(s: &str) -> Result<usize> {
    let w: usize = s
        .trim()
        .parse()
        .map_err(|_| anyhow::anyhow!("Invalid width: {s}"))?;
    if w < 20 {
        bail!("Width must be at least 20");
    }
    Ok(w)
}