Skip to main content

snapper_fmt/
cli.rs

1use std::path::PathBuf;
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5use crate::diff::ColorMode;
6
7#[derive(Debug, Clone, Copy, ValueEnum)]
8pub enum FormatArg {
9    Org,
10    Latex,
11    Markdown,
12    Rst,
13    Plaintext,
14}
15
16#[derive(Debug, Clone, Copy, ValueEnum)]
17pub enum OutputFormat {
18    Text,
19    Json,
20    Sarif,
21}
22
23/// Control when colored output is used (ruff-style).
24#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
25pub enum ColorWhen {
26    /// Display colors if the output goes to an interactive terminal.
27    #[default]
28    Auto,
29    /// Always display colors.
30    Always,
31    /// Never display colors.
32    Never,
33}
34
35impl From<ColorWhen> for ColorMode {
36    fn from(value: ColorWhen) -> Self {
37        match value {
38            ColorWhen::Auto => ColorMode::Auto,
39            ColorWhen::Always => ColorMode::Always,
40            ColorWhen::Never => ColorMode::Never,
41        }
42    }
43}
44
45#[derive(Debug, Parser)]
46#[command(name = "snapper", version, about = "Semantic line break formatter")]
47pub struct Cli {
48    #[command(subcommand)]
49    pub command: Option<Commands>,
50
51    /// Input files. Reads stdin if omitted.
52    #[arg()]
53    pub files: Vec<PathBuf>,
54
55    /// Input format (auto-detected from extension if omitted).
56    #[arg(short, long)]
57    pub format: Option<FormatArg>,
58
59    /// Assume this filename when reading stdin (for format auto-detection).
60    #[arg(long)]
61    pub stdin_filepath: Option<PathBuf>,
62
63    /// Output file (stdout if omitted).
64    #[arg(short, long)]
65    pub output: Option<PathBuf>,
66
67    /// Modify files in place.
68    #[arg(short, long)]
69    pub in_place: bool,
70
71    /// Maximum line width (0 = unlimited).
72    #[arg(short = 'w', long, default_value_t = 0)]
73    pub max_width: usize,
74
75    /// Prefer soft breaks after independent-clause punctuation (comma,
76    /// semicolon, colon, em dash). With `--max-width 0` (the default),
77    /// insert a newline after every such mark that is already followed
78    /// by whitespace. With `--max-width` set, prefer those marks when
79    /// wrapping an overflowing sentence.
80    #[arg(long, default_value_t = false)]
81    pub clause_breaks: bool,
82
83    /// Use neural sentence detection (nnsplit LSTM model).
84    #[arg(long)]
85    pub neural: bool,
86
87    /// Language for neural sentence detection (default: en).
88    /// Available: en, de, fr, no, sv, zh, tr, ru, uk.
89    #[arg(long)]
90    pub lang: Option<String>,
91
92    /// Path to custom ONNX model file for neural detection.
93    #[arg(long)]
94    pub model_path: Option<PathBuf>,
95
96    /// Use pandoc as parser backend (universal format support).
97    #[arg(long)]
98    pub use_pandoc: bool,
99
100    /// Pandoc AST source when `--use-pandoc` is set:
101    /// `auto` (prefer in-process FFI, else CLI), `ffi` (`libsnapper_pandoc`),
102    /// or `cli` (`pandoc` subprocess). Default: `auto`.
103    /// `ffi` fails explicitly if the library is missing.
104    #[arg(long, default_value = "auto", value_name = "BACKEND")]
105    pub pandoc_backend: String,
106
107    /// Exit with code 1 if any file would change.
108    #[arg(long)]
109    pub check: bool,
110
111    /// Show a unified diff of what would change.
112    #[arg(long)]
113    pub diff: bool,
114
115    /// Control when colored output is used.
116    ///
117    /// Possible values:
118    /// - auto:   Display colors if the output goes to an interactive terminal
119    ///   and the `NO_COLOR` environment variable is unset
120    /// - always: Always display colors
121    /// - never:  Never display colors
122    #[arg(long, value_enum, default_value_t = ColorWhen::Auto, global = true, value_name = "WHEN")]
123    pub color: ColorWhen,
124
125    /// Path to config file (default: .snapperrc.toml in current or parent dirs).
126    #[arg(long)]
127    pub config: Option<PathBuf>,
128
129    /// Only format lines in this range (1-indexed, inclusive). Format: START:END.
130    #[arg(long)]
131    pub range: Option<String>,
132
133    /// Output format for --check mode.
134    #[arg(long, default_value = "text")]
135    pub output_format: OutputFormat,
136
137    /// Treat advisory `long` diagnostics as `--check` failures.
138    ///
139    /// `long` never fails the check on its own unless this flag is set.
140    #[arg(long, default_value_t = false)]
141    pub strict_long: bool,
142
143    /// Pipe each code block's body through the per-language formatter
144    /// configured under `[code.<lang>.formatter]` in `.snapperrc.toml`.
145    /// The formatter runs after the in-block comment reflow. Missing
146    /// binaries, non-zero exits, and timeouts surface as stderr
147    /// diagnostics; snapper still exits 0.
148    #[arg(long, default_value_t = false)]
149    pub format_code: bool,
150}
151
152#[derive(Debug, Subcommand)]
153pub enum Commands {
154    /// Initialize snapper for a project (generate config, pre-commit, gitattributes).
155    Init {
156        /// Preview what would be generated without writing files.
157        #[arg(long)]
158        dry_run: bool,
159    },
160    /// Sentence-level diff between two files.
161    Sdiff {
162        /// Original file.
163        old: PathBuf,
164        /// Modified file.
165        new: PathBuf,
166        /// Input format (auto-detected from extension if omitted).
167        #[arg(short, long)]
168        format: Option<FormatArg>,
169        /// Disable colored output (alias for `--color never`).
170        #[arg(long)]
171        no_color: bool,
172    },
173    /// Sentence-level diff against a git ref.
174    GitDiff {
175        /// Git ref to compare against (default: HEAD).
176        #[arg(default_value = "HEAD")]
177        git_ref: String,
178        /// Files to diff. If omitted, diffs all changed prose files.
179        #[arg()]
180        files: Vec<PathBuf>,
181        /// Input format (auto-detected from extension if omitted).
182        #[arg(short, long)]
183        format: Option<FormatArg>,
184        /// Disable colored output (alias for `--color never`).
185        #[arg(long)]
186        no_color: bool,
187    },
188    /// Start the LSP server (stdin/stdout).
189    Lsp,
190    /// Start the MCP server (stdin/stdout).
191    Mcp,
192    /// Watch files and reformat on change.
193    Watch {
194        /// Files or glob patterns to watch.
195        #[arg(required = true)]
196        patterns: Vec<String>,
197        /// Input format (auto-detected from extension if omitted).
198        #[arg(short, long)]
199        format: Option<FormatArg>,
200    },
201}
202
203/// Parse a range string "START:END" into (start, end) 1-indexed inclusive.
204pub fn parse_range(s: &str) -> Option<(usize, usize)> {
205    let parts: Vec<&str> = s.split(':').collect();
206    if parts.len() != 2 {
207        return None;
208    }
209    let start = parts[0].parse::<usize>().ok()?;
210    let end = parts[1].parse::<usize>().ok()?;
211    if start == 0 || end == 0 || start > end {
212        return None;
213    }
214    Some((start, end))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn parse_range_valid() {
223        assert_eq!(parse_range("1:10"), Some((1, 10)));
224        assert_eq!(parse_range("5:5"), Some((5, 5)));
225        assert_eq!(parse_range("1:1"), Some((1, 1)));
226    }
227
228    #[test]
229    fn parse_range_zero_rejected() {
230        assert_eq!(parse_range("0:5"), None);
231        assert_eq!(parse_range("5:0"), None);
232        assert_eq!(parse_range("0:0"), None);
233    }
234
235    #[test]
236    fn parse_range_reversed_rejected() {
237        assert_eq!(parse_range("10:5"), None);
238    }
239
240    #[test]
241    fn parse_range_bad_format() {
242        assert_eq!(parse_range("abc"), None);
243        assert_eq!(parse_range("1:2:3"), None);
244        assert_eq!(parse_range(""), None);
245        assert_eq!(parse_range("a:b"), None);
246        assert_eq!(parse_range(":5"), None);
247        assert_eq!(parse_range("5:"), None);
248    }
249}