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    /// How to obtain the pandoc AST when `use_pandoc` is set.
84    /// `Ffi` uses in-process Haskell/C bindings; `Cli` uses a subprocess.
85    #[cfg(feature = "pandoc")]
86    pub pandoc_backend: parser::pandoc::PandocBackend,
87    /// Per-language code-block configuration loaded from `[code]` in
88    /// `.snapperrc.toml`. Empty by default; an empty map disables all
89    /// per-language code-block behaviour (block passes through untouched).
90    pub code: HashMap<String, CodeLang>,
91    /// When `true`, the reflow stage invokes each language's `formatter`
92    /// after comment reflow. Default `false` preserves v0.7.7 behaviour
93    /// (no subprocess is spawned).
94    pub format_code: bool,
95}
96
97impl Default for FormatConfig {
98    fn default() -> Self {
99        Self {
100            format: Format::Plaintext,
101            max_width: 0,
102            use_neural: false,
103            neural_lang: "en".to_string(),
104            neural_model_path: None,
105            extra_abbreviations: vec![],
106            use_pandoc: false,
107            pandoc_format: None,
108            #[cfg(feature = "pandoc")]
109            pandoc_backend: parser::pandoc::PandocBackend::default(),
110            code: HashMap::new(),
111            format_code: false,
112        }
113    }
114}
115
116/// Build the appropriate sentence splitter from config.
117pub fn build_splitter(config: &FormatConfig) -> Result<Box<dyn SentenceSplitter>> {
118    if config.use_neural {
119        #[cfg(feature = "neural")]
120        {
121            let neural = if let Some(ref path) = config.neural_model_path {
122                sentence::neural::NeuralSentenceSplitter::from_path_with_extras(
123                    path,
124                    &config.neural_lang,
125                    &config.extra_abbreviations,
126                )
127            } else {
128                sentence::neural::NeuralSentenceSplitter::with_extras(
129                    &config.neural_lang,
130                    &config.extra_abbreviations,
131                )
132            };
133            Ok(Box::new(neural.map_err(|e| anyhow::anyhow!("{e}"))?))
134        }
135        #[cfg(not(feature = "neural"))]
136        {
137            Err(anyhow::anyhow!(
138                "neural sentence splitting requires the 'neural' feature"
139            ))
140        }
141    } else {
142        Ok(Box::new(UnicodeSentenceSplitter::for_lang(
143            &config.neural_lang,
144            &config.extra_abbreviations,
145        )))
146    }
147}
148
149/// Format text with semantic line breaks.
150pub fn format_text(input: &str, config: &FormatConfig) -> Result<String> {
151    let splitter = build_splitter(config)?;
152    format_text_with_splitter(input, config, splitter.as_ref())
153}
154
155/// Format text using a pre-constructed splitter (avoids reloading models per file).
156pub fn format_text_with_splitter(
157    input: &str,
158    config: &FormatConfig,
159    splitter: &dyn SentenceSplitter,
160) -> Result<String> {
161    let had_trailing_newline = input.ends_with('\n');
162    let uses_crlf = input.contains("\r\n");
163
164    // Normalize to LF for processing, restore CRLF at the end if needed.
165    let normalized;
166    let work_input = if uses_crlf {
167        normalized = input.replace("\r\n", "\n");
168        &normalized
169    } else {
170        input
171    };
172
173    // Two pipelines:
174    // - use_pandoc: pandoc parses source → AST → regions by node kind → reflow prose only.
175    // - else: native line parsers (markdown/org/…) then reflow. Never mixed after success.
176    let regions = if config.use_pandoc {
177        #[cfg(feature = "pandoc")]
178        {
179            let pandoc_fmt = config
180                .pandoc_format
181                .as_deref()
182                .unwrap_or(match config.format {
183                    Format::Org => "org",
184                    Format::Latex => "latex",
185                    Format::Markdown => "markdown",
186                    Format::Rst => "rst",
187                    Format::Plaintext => "markdown",
188                });
189            let parser =
190                parser::pandoc::PandocParser::with_backend(pandoc_fmt, config.pandoc_backend);
191            // Pandoc path: fail closed (no silent all-prose, no native re-parse).
192            parser
193                .try_parse(work_input)
194                .map_err(|e| anyhow::anyhow!("{e}"))?
195        }
196        #[cfg(not(feature = "pandoc"))]
197        {
198            return Err(anyhow::anyhow!(
199                "pandoc backend requires the 'pandoc' feature"
200            ));
201        }
202    } else {
203        parser::parser_for_format(config.format).parse(work_input)
204    };
205
206    let reflow_config = ReflowConfig {
207        max_width: config.max_width,
208        code: Some(&config.code),
209        format_code: config.format_code,
210    };
211
212    let mut output = reflow(&regions, splitter, &reflow_config);
213
214    // Preserve the original file's trailing newline convention.
215    if had_trailing_newline && !output.ends_with('\n') {
216        output.push('\n');
217    } else if !had_trailing_newline {
218        while output.ends_with('\n') {
219            output.pop();
220        }
221    }
222
223    // Restore CRLF if the input used it.
224    if uses_crlf {
225        output = output.replace('\n', "\r\n");
226    }
227
228    Ok(output)
229}
230
231/// Format only lines within a range (1-indexed, inclusive).
232/// Lines outside the range pass through unchanged.
233pub fn format_range(
234    input: &str,
235    config: &FormatConfig,
236    start: usize,
237    end: usize,
238) -> Result<String> {
239    let lines: Vec<&str> = input.lines().collect();
240    let total = lines.len();
241
242    // Clamp range
243    let start = start.max(1);
244    let end = end.min(total);
245
246    if start > total {
247        return Ok(input.to_string());
248    }
249
250    // Extract the range as a contiguous block
251    let range_text = lines[start - 1..end].join("\n");
252    let formatted = format_text(&range_text, config)?;
253
254    // Reassemble: before + formatted + after
255    let mut result = String::new();
256    for (i, line) in lines.iter().enumerate() {
257        let line_num = i + 1;
258        if line_num < start {
259            result.push_str(line);
260            result.push('\n');
261        }
262    }
263    result.push_str(&formatted);
264    if !formatted.ends_with('\n') && end < total {
265        result.push('\n');
266    }
267    for (i, line) in lines.iter().enumerate() {
268        let line_num = i + 1;
269        if line_num > end {
270            result.push_str(line);
271            if line_num < total {
272                result.push('\n');
273            }
274        }
275    }
276
277    // Preserve original trailing newline convention
278    if input.ends_with('\n') && !result.ends_with('\n') {
279        result.push('\n');
280    } else if !input.ends_with('\n') {
281        while result.ends_with('\n') {
282            result.pop();
283        }
284    }
285
286    Ok(result)
287}