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    /// Exit with code 1 if any file would change.
69    #[arg(long)]
70    pub check: bool,
71
72    /// Show a unified diff of what would change.
73    #[arg(long)]
74    pub diff: bool,
75
76    /// Path to config file (default: .snapperrc.toml in current or parent dirs).
77    #[arg(long)]
78    pub config: Option<PathBuf>,
79
80    /// Only format lines in this range (1-indexed, inclusive). Format: START:END.
81    #[arg(long)]
82    pub range: Option<String>,
83
84    /// Output format for --check mode.
85    #[arg(long, default_value = "text")]
86    pub output_format: OutputFormat,
87
88    /// Pipe each code block's body through the per-language formatter
89    /// configured under `[code.<lang>.formatter]` in `.snapperrc.toml`.
90    /// The formatter runs after the in-block comment reflow. Missing
91    /// binaries, non-zero exits, and timeouts surface as stderr
92    /// diagnostics; snapper still exits 0.
93    #[arg(long, default_value_t = false)]
94    pub format_code: bool,
95}
96
97#[derive(Debug, Subcommand)]
98pub enum Commands {
99    /// Initialize snapper for a project (generate config, pre-commit, gitattributes).
100    Init {
101        /// Preview what would be generated without writing files.
102        #[arg(long)]
103        dry_run: bool,
104    },
105    /// Sentence-level diff between two files.
106    Sdiff {
107        /// Original file.
108        old: PathBuf,
109        /// Modified file.
110        new: PathBuf,
111        /// Input format (auto-detected from extension if omitted).
112        #[arg(short, long)]
113        format: Option<FormatArg>,
114        /// Disable colored output.
115        #[arg(long)]
116        no_color: bool,
117    },
118    /// Sentence-level diff against a git ref.
119    GitDiff {
120        /// Git ref to compare against (default: HEAD).
121        #[arg(default_value = "HEAD")]
122        git_ref: String,
123        /// Files to diff. If omitted, diffs all changed prose files.
124        #[arg()]
125        files: Vec<PathBuf>,
126        /// Input format (auto-detected from extension if omitted).
127        #[arg(short, long)]
128        format: Option<FormatArg>,
129        /// Disable colored output.
130        #[arg(long)]
131        no_color: bool,
132    },
133    /// Start the LSP server (stdin/stdout).
134    Lsp,
135    /// Start the MCP server (stdin/stdout).
136    Mcp,
137    /// Watch files and reformat on change.
138    Watch {
139        /// Files or glob patterns to watch.
140        #[arg(required = true)]
141        patterns: Vec<String>,
142        /// Input format (auto-detected from extension if omitted).
143        #[arg(short, long)]
144        format: Option<FormatArg>,
145    },
146}
147
148/// Parse a range string "START:END" into (start, end) 1-indexed inclusive.
149pub fn parse_range(s: &str) -> Option<(usize, usize)> {
150    let parts: Vec<&str> = s.split(':').collect();
151    if parts.len() != 2 {
152        return None;
153    }
154    let start = parts[0].parse::<usize>().ok()?;
155    let end = parts[1].parse::<usize>().ok()?;
156    if start == 0 || end == 0 || start > end {
157        return None;
158    }
159    Some((start, end))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn parse_range_valid() {
168        assert_eq!(parse_range("1:10"), Some((1, 10)));
169        assert_eq!(parse_range("5:5"), Some((5, 5)));
170        assert_eq!(parse_range("1:1"), Some((1, 1)));
171    }
172
173    #[test]
174    fn parse_range_zero_rejected() {
175        assert_eq!(parse_range("0:5"), None);
176        assert_eq!(parse_range("5:0"), None);
177        assert_eq!(parse_range("0:0"), None);
178    }
179
180    #[test]
181    fn parse_range_reversed_rejected() {
182        assert_eq!(parse_range("10:5"), None);
183    }
184
185    #[test]
186    fn parse_range_bad_format() {
187        assert_eq!(parse_range("abc"), None);
188        assert_eq!(parse_range("1:2:3"), None);
189        assert_eq!(parse_range(""), None);
190        assert_eq!(parse_range("a:b"), None);
191        assert_eq!(parse_range(":5"), None);
192        assert_eq!(parse_range("5:"), None);
193    }
194}