Skip to main content

snapper_fmt/
lib.rs

1//! # snapper
2//!
3//! Semantic line break formatter for prose documents. Reformats text so each
4//! sentence occupies its own line, producing minimal git diffs when
5//! collaborating on papers and documentation.
6//!
7//! The crate is published as `snapper-fmt` on crates.io; the binary it
8//! installs is called `snapper`.
9//!
10//! ## Supported formats
11//!
12//! - **Org-mode**: blocks, drawers, tables, keywords preserved
13//! - **LaTeX**: preamble, math, environments, comments preserved
14//! - **Markdown**: code blocks, front matter, headings preserved
15//! - **Plaintext**: everything treated as prose
16//!
17//! ## Library usage
18//!
19//! ```rust
20//! use snapper_fmt::{format_text, FormatConfig};
21//! use snapper_fmt::format::Format;
22//!
23//! let input = "Hello world. This is a test. Another sentence.";
24//! let config = FormatConfig {
25//!     format: Format::Plaintext,
26//!     ..Default::default()
27//! };
28//! let output = format_text(input, &config).unwrap();
29//! assert_eq!(output, "Hello world.\nThis is a test.\nAnother sentence.");
30//! ```
31
32pub mod abbreviations;
33#[cfg(feature = "cli")]
34pub mod cli;
35pub mod code_block;
36pub mod config;
37pub mod diff;
38#[cfg(not(target_arch = "wasm32"))]
39pub mod files;
40pub mod format;
41#[cfg(not(target_arch = "wasm32"))]
42pub mod git_diff;
43#[cfg(feature = "cli")]
44pub mod init;
45#[cfg(feature = "lsp")]
46pub mod lsp;
47#[cfg(feature = "mcp")]
48pub mod mcp;
49pub mod output;
50pub mod parser;
51pub mod reflow;
52#[cfg(not(target_arch = "wasm32"))]
53pub mod sdiff;
54pub mod sentence;
55#[cfg(feature = "wasm")]
56pub mod wasm;
57#[cfg(feature = "watch")]
58pub mod watch;
59
60use std::collections::HashMap;
61
62use anyhow::Result;
63
64use crate::config::CodeLang;
65use crate::format::Format;
66use crate::reflow::{ReflowConfig, reflow};
67use crate::sentence::SentenceSplitter;
68use crate::sentence::unicode::UnicodeSentenceSplitter;
69
70/// Configuration for the formatting pipeline.
71pub struct FormatConfig {
72    pub format: Format,
73    pub max_width: usize,
74    pub use_neural: bool,
75    pub neural_lang: String,
76    pub neural_model_path: Option<std::path::PathBuf>,
77    pub extra_abbreviations: Vec<String>,
78    pub use_pandoc: bool,
79    /// Pandoc input format string (for pandoc backend).
80    pub pandoc_format: Option<String>,
81    /// Per-language code-block configuration loaded from `[code]` in
82    /// `.snapperrc.toml`. Empty by default; an empty map disables all
83    /// per-language code-block behaviour (block passes through untouched).
84    pub code: HashMap<String, CodeLang>,
85    /// When `true`, the reflow stage invokes each language's `formatter`
86    /// after comment reflow. Default `false` preserves v0.7.7 behaviour
87    /// (no subprocess is spawned).
88    pub format_code: bool,
89}
90
91impl Default for FormatConfig {
92    fn default() -> Self {
93        Self {
94            format: Format::Plaintext,
95            max_width: 0,
96            use_neural: false,
97            neural_lang: "en".to_string(),
98            neural_model_path: None,
99            extra_abbreviations: vec![],
100            use_pandoc: false,
101            pandoc_format: None,
102            code: HashMap::new(),
103            format_code: false,
104        }
105    }
106}
107
108/// Build the appropriate sentence splitter from config.
109pub fn build_splitter(config: &FormatConfig) -> Result<Box<dyn SentenceSplitter>> {
110    if config.use_neural {
111        #[cfg(feature = "neural")]
112        {
113            let neural = if let Some(ref path) = config.neural_model_path {
114                sentence::neural::NeuralSentenceSplitter::from_path(path)
115            } else {
116                sentence::neural::NeuralSentenceSplitter::new(&config.neural_lang)
117            };
118            Ok(Box::new(neural.map_err(|e| anyhow::anyhow!("{e}"))?))
119        }
120        #[cfg(not(feature = "neural"))]
121        {
122            Err(anyhow::anyhow!(
123                "neural sentence splitting requires the 'neural' feature"
124            ))
125        }
126    } else {
127        Ok(Box::new(UnicodeSentenceSplitter::for_lang(
128            &config.neural_lang,
129            &config.extra_abbreviations,
130        )))
131    }
132}
133
134/// Format text with semantic line breaks.
135pub fn format_text(input: &str, config: &FormatConfig) -> Result<String> {
136    let splitter = build_splitter(config)?;
137    format_text_with_splitter(input, config, splitter.as_ref())
138}
139
140/// Format text using a pre-constructed splitter (avoids reloading models per file).
141pub fn format_text_with_splitter(
142    input: &str,
143    config: &FormatConfig,
144    splitter: &dyn SentenceSplitter,
145) -> Result<String> {
146    let parser: Box<dyn parser::FormatParser> = if config.use_pandoc {
147        #[cfg(feature = "pandoc")]
148        {
149            let pandoc_fmt = config
150                .pandoc_format
151                .as_deref()
152                .unwrap_or(match config.format {
153                    Format::Org => "org",
154                    Format::Latex => "latex",
155                    Format::Markdown => "markdown",
156                    Format::Rst => "rst",
157                    Format::Plaintext => "markdown",
158                });
159            Box::new(parser::pandoc::PandocParser::new(pandoc_fmt))
160        }
161        #[cfg(not(feature = "pandoc"))]
162        {
163            return Err(anyhow::anyhow!(
164                "pandoc backend requires the 'pandoc' feature"
165            ));
166        }
167    } else {
168        parser::parser_for_format(config.format)
169    };
170
171    let had_trailing_newline = input.ends_with('\n');
172    let uses_crlf = input.contains("\r\n");
173
174    // Normalize to LF for processing, restore CRLF at the end if needed.
175    let normalized;
176    let work_input = if uses_crlf {
177        normalized = input.replace("\r\n", "\n");
178        &normalized
179    } else {
180        input
181    };
182
183    let regions = parser.parse(work_input);
184    let reflow_config = ReflowConfig {
185        max_width: config.max_width,
186        code: Some(&config.code),
187        format_code: config.format_code,
188    };
189
190    let mut output = reflow(&regions, splitter, &reflow_config);
191
192    // Preserve the original file's trailing newline convention.
193    if had_trailing_newline && !output.ends_with('\n') {
194        output.push('\n');
195    } else if !had_trailing_newline {
196        while output.ends_with('\n') {
197            output.pop();
198        }
199    }
200
201    // Restore CRLF if the input used it.
202    if uses_crlf {
203        output = output.replace('\n', "\r\n");
204    }
205
206    Ok(output)
207}
208
209/// Format only lines within a range (1-indexed, inclusive).
210/// Lines outside the range pass through unchanged.
211pub fn format_range(
212    input: &str,
213    config: &FormatConfig,
214    start: usize,
215    end: usize,
216) -> Result<String> {
217    let lines: Vec<&str> = input.lines().collect();
218    let total = lines.len();
219
220    // Clamp range
221    let start = start.max(1);
222    let end = end.min(total);
223
224    if start > total {
225        return Ok(input.to_string());
226    }
227
228    // Extract the range as a contiguous block
229    let range_text = lines[start - 1..end].join("\n");
230    let formatted = format_text(&range_text, config)?;
231
232    // Reassemble: before + formatted + after
233    let mut result = String::new();
234    for (i, line) in lines.iter().enumerate() {
235        let line_num = i + 1;
236        if line_num < start {
237            result.push_str(line);
238            result.push('\n');
239        }
240    }
241    result.push_str(&formatted);
242    if !formatted.ends_with('\n') && end < total {
243        result.push('\n');
244    }
245    for (i, line) in lines.iter().enumerate() {
246        let line_num = i + 1;
247        if line_num > end {
248            result.push_str(line);
249            if line_num < total {
250                result.push('\n');
251            }
252        }
253    }
254
255    // Preserve original trailing newline convention
256    if input.ends_with('\n') && !result.ends_with('\n') {
257        result.push('\n');
258    } else if !input.ends_with('\n') {
259        while result.ends_with('\n') {
260            result.pop();
261        }
262    }
263
264    Ok(result)
265}