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) when wrapping under `--max-width`.
77    #[arg(long, default_value_t = false)]
78    pub clause_breaks: bool,
79
80    /// Use neural sentence detection (nnsplit LSTM model).
81    #[arg(long)]
82    pub neural: bool,
83
84    /// Language for neural sentence detection (default: en).
85    /// Available: en, de, fr, no, sv, zh, tr, ru, uk.
86    #[arg(long)]
87    pub lang: Option<String>,
88
89    /// Path to custom ONNX model file for neural detection.
90    #[arg(long)]
91    pub model_path: Option<PathBuf>,
92
93    /// Use pandoc as parser backend (universal format support).
94    #[arg(long)]
95    pub use_pandoc: bool,
96
97    /// Pandoc AST source when `--use-pandoc` is set:
98    /// `auto` (prefer in-process FFI, else CLI), `ffi` (`libsnapper_pandoc`),
99    /// or `cli` (`pandoc` subprocess). Default: `auto`.
100    /// `ffi` fails explicitly if the library is missing.
101    #[arg(long, default_value = "auto", value_name = "BACKEND")]
102    pub pandoc_backend: String,
103
104    /// Exit with code 1 if any file would change.
105    #[arg(long)]
106    pub check: bool,
107
108    /// Show a unified diff of what would change.
109    #[arg(long)]
110    pub diff: bool,
111
112    /// Control when colored output is used.
113    ///
114    /// Possible values:
115    /// - auto:   Display colors if the output goes to an interactive terminal
116    ///   and the `NO_COLOR` environment variable is unset
117    /// - always: Always display colors
118    /// - never:  Never display colors
119    #[arg(long, value_enum, default_value_t = ColorWhen::Auto, global = true, value_name = "WHEN")]
120    pub color: ColorWhen,
121
122    /// Path to config file (default: .snapperrc.toml in current or parent dirs).
123    #[arg(long)]
124    pub config: Option<PathBuf>,
125
126    /// Only format lines in this range (1-indexed, inclusive). Format: START:END.
127    #[arg(long)]
128    pub range: Option<String>,
129
130    /// Output format for --check mode.
131    #[arg(long, default_value = "text")]
132    pub output_format: OutputFormat,
133
134    /// Pipe each code block's body through the per-language formatter
135    /// configured under `[code.<lang>.formatter]` in `.snapperrc.toml`.
136    /// The formatter runs after the in-block comment reflow. Missing
137    /// binaries, non-zero exits, and timeouts surface as stderr
138    /// diagnostics; snapper still exits 0.
139    #[arg(long, default_value_t = false)]
140    pub format_code: bool,
141}
142
143#[derive(Debug, Subcommand)]
144pub enum Commands {
145    /// Initialize snapper for a project (generate config, pre-commit, gitattributes).
146    Init {
147        /// Preview what would be generated without writing files.
148        #[arg(long)]
149        dry_run: bool,
150    },
151    /// Sentence-level diff between two files.
152    Sdiff {
153        /// Original file.
154        old: PathBuf,
155        /// Modified file.
156        new: PathBuf,
157        /// Input format (auto-detected from extension if omitted).
158        #[arg(short, long)]
159        format: Option<FormatArg>,
160        /// Disable colored output (alias for `--color never`).
161        #[arg(long)]
162        no_color: bool,
163    },
164    /// Sentence-level diff against a git ref.
165    GitDiff {
166        /// Git ref to compare against (default: HEAD).
167        #[arg(default_value = "HEAD")]
168        git_ref: String,
169        /// Files to diff. If omitted, diffs all changed prose files.
170        #[arg()]
171        files: Vec<PathBuf>,
172        /// Input format (auto-detected from extension if omitted).
173        #[arg(short, long)]
174        format: Option<FormatArg>,
175        /// Disable colored output (alias for `--color never`).
176        #[arg(long)]
177        no_color: bool,
178    },
179    /// Start the LSP server (stdin/stdout).
180    Lsp,
181    /// Start the MCP server (stdin/stdout).
182    Mcp,
183    /// Watch files and reformat on change.
184    Watch {
185        /// Files or glob patterns to watch.
186        #[arg(required = true)]
187        patterns: Vec<String>,
188        /// Input format (auto-detected from extension if omitted).
189        #[arg(short, long)]
190        format: Option<FormatArg>,
191    },
192}
193
194/// Parse a range string "START:END" into (start, end) 1-indexed inclusive.
195pub fn parse_range(s: &str) -> Option<(usize, usize)> {
196    let parts: Vec<&str> = s.split(':').collect();
197    if parts.len() != 2 {
198        return None;
199    }
200    let start = parts[0].parse::<usize>().ok()?;
201    let end = parts[1].parse::<usize>().ok()?;
202    if start == 0 || end == 0 || start > end {
203        return None;
204    }
205    Some((start, end))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn parse_range_valid() {
214        assert_eq!(parse_range("1:10"), Some((1, 10)));
215        assert_eq!(parse_range("5:5"), Some((5, 5)));
216        assert_eq!(parse_range("1:1"), Some((1, 1)));
217    }
218
219    #[test]
220    fn parse_range_zero_rejected() {
221        assert_eq!(parse_range("0:5"), None);
222        assert_eq!(parse_range("5:0"), None);
223        assert_eq!(parse_range("0:0"), None);
224    }
225
226    #[test]
227    fn parse_range_reversed_rejected() {
228        assert_eq!(parse_range("10:5"), None);
229    }
230
231    #[test]
232    fn parse_range_bad_format() {
233        assert_eq!(parse_range("abc"), None);
234        assert_eq!(parse_range("1:2:3"), None);
235        assert_eq!(parse_range(""), None);
236        assert_eq!(parse_range("a:b"), None);
237        assert_eq!(parse_range(":5"), None);
238        assert_eq!(parse_range("5:"), None);
239    }
240}