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