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}