Skip to main content

snapper_fmt/
cli.rs

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