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