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;
36pub mod check;
37#[cfg(feature = "cli")]
38pub mod cli;
39pub mod code_block;
40pub mod config;
41pub mod diff;
42#[cfg(not(target_arch = "wasm32"))]
43pub mod files;
44pub mod format;
45#[cfg(not(target_arch = "wasm32"))]
46pub mod git_diff;
47#[cfg(feature = "cli")]
48pub mod init;
49#[cfg(feature = "lsp")]
50pub mod lsp;
51#[cfg(feature = "mcp")]
52pub mod mcp;
53pub mod oracle;
54pub mod output;
55pub mod parser;
56pub mod reflow;
57#[cfg(not(target_arch = "wasm32"))]
58pub mod sdiff;
59pub mod sentence;
60#[cfg(feature = "treesitter")]
61mod ts_comments;
62#[cfg(feature = "wasm")]
63pub mod wasm;
64#[cfg(feature = "watch")]
65pub mod watch;
66
67use std::collections::HashMap;
68
69use anyhow::Result;
70
71use crate::config::CodeLang;
72use crate::format::Format;
73use crate::reflow::ReflowConfig;
74use crate::sentence::SentenceSplitter;
75use crate::sentence::unicode::UnicodeSentenceSplitter;
76
77/// Configuration for the formatting pipeline.
78pub struct FormatConfig {
79    pub format: Format,
80    pub max_width: usize,
81    pub use_neural: bool,
82    pub neural_lang: String,
83    pub neural_model_path: Option<std::path::PathBuf>,
84    pub extra_abbreviations: Vec<String>,
85    pub use_pandoc: bool,
86    /// Pandoc input format string (for pandoc backend).
87    pub pandoc_format: Option<String>,
88    /// How to obtain the pandoc AST when `use_pandoc` is set.
89    /// `Ffi` uses in-process Haskell/C bindings; `Cli` uses a subprocess.
90    #[cfg(feature = "pandoc")]
91    pub pandoc_backend: parser::pandoc::PandocBackend,
92    /// Per-language code-block configuration loaded from `[code]` in
93    /// `.snapperrc.toml`. Empty by default; an empty map disables all
94    /// per-language code-block behaviour (block passes through untouched).
95    pub code: HashMap<String, CodeLang>,
96    /// When `true`, the reflow stage invokes each language's `formatter`
97    /// after comment reflow. Default `false` preserves v0.7.7 behaviour
98    /// (no subprocess is spawned).
99    pub format_code: bool,
100    /// Prefer soft breaks after independent-clause punctuation (sembr
101    /// rule 5). When `max_width` is 0, every such mark that is already
102    /// followed by whitespace starts a new line. When `max_width` is
103    /// greater than 0, overflowing sentences prefer those marks.
104    /// Default `false` keeps one sentence per line (greedy wrap only
105    /// under `max_width`).
106    pub clause_breaks: bool,
107    /// Run `format_text` to a byte fixpoint (cap 4). Production default
108    /// `true`; tests set `false` so a planner that needs the backstop fails.
109    pub fixpoint_backstop: bool,
110    /// After the fixpoint, a format-local oracle mismatch returns the
111    /// original document. Production default `true`; tests set `false`
112    /// and assert the oracle themselves.
113    pub render_backstop: bool,
114    /// Extra LaTeX environments treated as code (no reflow), added to
115    /// minted/lstlisting/verbatim. Empty keeps the built-in list.
116    pub latex_verbatim_envs: Vec<String>,
117    /// Extra LaTeX environments treated as structure (no reflow), added
118    /// to `NON_PROSE_ENVS`. Empty keeps the built-in list.
119    pub latex_structure_envs: Vec<String>,
120    /// Extra LaTeX command names tokenized like `\verb` before split.
121    /// Empty keeps verb/lstinline.
122    pub latex_verbatim_commands: Vec<String>,
123}
124
125impl Default for FormatConfig {
126    fn default() -> Self {
127        Self {
128            format: Format::Plaintext,
129            max_width: 0,
130            use_neural: false,
131            neural_lang: "en".to_string(),
132            neural_model_path: None,
133            extra_abbreviations: vec![],
134            use_pandoc: false,
135            pandoc_format: None,
136            #[cfg(feature = "pandoc")]
137            pandoc_backend: parser::pandoc::PandocBackend::default(),
138            code: HashMap::new(),
139            format_code: false,
140            clause_breaks: false,
141            fixpoint_backstop: true,
142            render_backstop: true,
143            latex_verbatim_envs: vec![],
144            latex_structure_envs: vec![],
145            latex_verbatim_commands: vec![],
146        }
147    }
148}
149
150/// Typed error for invalid UTF-8 input. Branch with `error.downcast_ref`.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
152#[error("input is not valid UTF-8")]
153pub struct InvalidUtf8Error;
154
155/// Pandoc's AST has no source offsets, so it cannot splice into original
156/// bytes. `format_text` refuses rather than reconstruct.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
158#[error("pandoc backend cannot splice original source bytes")]
159pub struct PandocCannotSplice;
160
161/// Maximum pipeline passes including the first. Cap hit (or an A/B cycle)
162/// returns the original document unchanged.
163const MAX_FORMAT_PASSES: usize = 4;
164
165impl FormatConfig {
166    /// Tests that assert planner properties (idempotence, oracle, splice)
167    /// must disable both backstops so a planner that needs them fails.
168    pub fn without_safety_backstops(mut self) -> Self {
169        self.fixpoint_backstop = false;
170        self.render_backstop = false;
171        self
172    }
173}
174
175/// Run `step` until the output is a byte fixpoint or the cap is hit.
176///
177/// A cycle (including A/B) or a cap miss returns `original`. `enabled`
178/// false runs `step` once. Public so tests can inject a cycling step.
179pub fn run_fixpoint<F>(original: &str, enabled: bool, mut step: F) -> Result<String>
180where
181    F: FnMut(&str) -> Result<String>,
182{
183    let once = step(original)?;
184    if !enabled {
185        return Ok(once);
186    }
187    let mut cur = once;
188    let mut seen = std::collections::HashSet::new();
189    seen.insert(original.to_string());
190    seen.insert(cur.clone());
191    for _ in 1..MAX_FORMAT_PASSES {
192        let next = step(&cur)?;
193        if next == cur {
194            return Ok(cur);
195        }
196        if !seen.insert(next.clone()) {
197            return Ok(original.to_string());
198        }
199        cur = next;
200    }
201    Ok(original.to_string())
202}
203
204/// Build the appropriate sentence splitter from config.
205pub fn build_splitter(config: &FormatConfig) -> Result<Box<dyn SentenceSplitter>> {
206    if config.use_neural {
207        #[cfg(feature = "neural")]
208        {
209            let neural = if let Some(ref path) = config.neural_model_path {
210                sentence::neural::NeuralSentenceSplitter::from_path_with_extras(
211                    path,
212                    &config.neural_lang,
213                    &config.extra_abbreviations,
214                )
215            } else {
216                sentence::neural::NeuralSentenceSplitter::with_extras(
217                    &config.neural_lang,
218                    &config.extra_abbreviations,
219                )
220            };
221            Ok(Box::new(
222                neural
223                    .map_err(|e| anyhow::anyhow!("{e}"))?
224                    .with_verbatim_commands(config.latex_verbatim_commands.clone()),
225            ))
226        }
227        #[cfg(not(feature = "neural"))]
228        {
229            Err(anyhow::anyhow!(
230                "neural sentence splitting requires the 'neural' feature"
231            ))
232        }
233    } else {
234        Ok(Box::new(
235            UnicodeSentenceSplitter::for_lang(&config.neural_lang, &config.extra_abbreviations)
236                .with_verbatim_commands(config.latex_verbatim_commands.clone()),
237        ))
238    }
239}
240
241/// Format text with semantic line breaks.
242pub fn format_text(input: &str, config: &FormatConfig) -> Result<String> {
243    let splitter = build_splitter(config)?;
244    format_text_with_splitter(input, config, splitter.as_ref())
245}
246
247/// Format raw bytes. Invalid UTF-8 is a hard error ([`InvalidUtf8Error`]).
248pub fn format_bytes(input: &[u8], config: &FormatConfig) -> Result<Vec<u8>> {
249    let s = std::str::from_utf8(input).map_err(|_| anyhow::Error::new(InvalidUtf8Error))?;
250    format_text(s, config).map(|s| s.into_bytes())
251}
252
253/// Format text using a pre-constructed splitter (avoids reloading models per file).
254pub fn format_text_with_splitter(
255    input: &str,
256    config: &FormatConfig,
257    splitter: &dyn SentenceSplitter,
258) -> Result<String> {
259    let had_trailing_newline = input.ends_with('\n');
260    let uses_crlf = input.contains("\r\n");
261
262    // Normalize to LF for processing, restore CRLF at the end if needed.
263    let normalized;
264    let work_input = if uses_crlf {
265        normalized = input.replace("\r\n", "\n");
266        &normalized
267    } else {
268        input
269    };
270
271    let once = format_once(work_input, config, splitter, config.format_code)?;
272    // Later passes prove prose stability. External code formatters
273    // already ran on the first pass; re-invoking them multiplies
274    // timeout budgets and is not part of the planner fixpoint.
275    let candidate = run_fixpoint(work_input, config.fixpoint_backstop, |cur| {
276        if cur == work_input {
277            Ok(once.clone())
278        } else {
279            format_once(cur, config, splitter, false)
280        }
281    })?;
282
283    let candidate = if config.render_backstop
284        && candidate != work_input
285        && !oracle::matches_ex(
286            config.format,
287            work_input,
288            &candidate,
289            config.format_code,
290            Some(config),
291        ) {
292        work_input.to_string()
293    } else {
294        candidate
295    };
296
297    let mut output = candidate;
298
299    // Preserve the original file's trailing newline convention.
300    if had_trailing_newline && !output.ends_with('\n') {
301        output.push('\n');
302    } else if !had_trailing_newline {
303        while output.ends_with('\n') {
304            output.pop();
305        }
306    }
307
308    // Restore CRLF if the input used it.
309    if uses_crlf {
310        output = output.replace('\n', "\r\n");
311    }
312
313    Ok(output)
314}
315
316/// One parse+reflow pass. Native parsers splice into original bytes;
317/// pandoc concatenates reconstructed regions.
318fn format_once(
319    work_input: &str,
320    config: &FormatConfig,
321    splitter: &dyn SentenceSplitter,
322    format_code: bool,
323) -> Result<String> {
324    use crate::parser::SpannedRegion;
325    use crate::reflow::reflow_spanned;
326
327    let reflow_config = ReflowConfig {
328        max_width: config.max_width,
329        code: Some(&config.code),
330        format_code,
331        clause_breaks: config.clause_breaks,
332        format: config.format,
333    };
334
335    // Two pipelines:
336    // - use_pandoc: pandoc parses source → AST → regions by node kind → reflow prose only.
337    // - else: native line parsers (markdown/org/…) then splice. Never mixed after success.
338    if config.use_pandoc {
339        #[cfg(feature = "pandoc")]
340        {
341            let pandoc_fmt = config
342                .pandoc_format
343                .as_deref()
344                .unwrap_or(match config.format {
345                    Format::Org => "org",
346                    Format::Latex => "latex",
347                    Format::Markdown => "markdown",
348                    Format::Rst => "rst",
349                    Format::Plaintext => "markdown",
350                });
351            let parser =
352                parser::pandoc::PandocParser::with_backend(pandoc_fmt, config.pandoc_backend);
353            // Surface parse errors (no silent all-prose). Splice still
354            // requires source offsets the AST does not carry.
355            parser
356                .try_parse(work_input)
357                .map_err(|e| anyhow::anyhow!("{e}"))?;
358            return Err(anyhow::Error::new(PandocCannotSplice));
359        }
360        #[cfg(not(feature = "pandoc"))]
361        {
362            return Err(anyhow::anyhow!(
363                "pandoc backend requires the 'pandoc' feature"
364            ));
365        }
366    }
367
368    let spanned: Vec<SpannedRegion> =
369        parser::parser_for_format_config(config.format, Some(config)).parse_full(work_input);
370    match reflow_spanned(work_input, &spanned, splitter, &reflow_config) {
371        Ok(out) => Ok(out),
372        Err(_) => Ok(work_input.to_string()),
373    }
374}
375
376/// Format only lines within a range (1-indexed, inclusive).
377/// Lines outside the range pass through unchanged.
378pub fn format_range(
379    input: &str,
380    config: &FormatConfig,
381    start: usize,
382    end: usize,
383) -> Result<String> {
384    let lines: Vec<&str> = input.lines().collect();
385    let total = lines.len();
386
387    // Clamp range
388    let start = start.max(1);
389    let end = end.min(total);
390
391    if start > total {
392        return Ok(input.to_string());
393    }
394
395    // Extract the range as a contiguous block
396    let range_text = lines[start - 1..end].join("\n");
397    let formatted = format_text(&range_text, config)?;
398
399    // Reassemble: before + formatted + after
400    let mut result = String::new();
401    for (i, line) in lines.iter().enumerate() {
402        let line_num = i + 1;
403        if line_num < start {
404            result.push_str(line);
405            result.push('\n');
406        }
407    }
408    result.push_str(&formatted);
409    if !formatted.ends_with('\n') && end < total {
410        result.push('\n');
411    }
412    for (i, line) in lines.iter().enumerate() {
413        let line_num = i + 1;
414        if line_num > end {
415            result.push_str(line);
416            if line_num < total {
417                result.push('\n');
418            }
419        }
420    }
421
422    // Preserve original trailing newline convention
423    if input.ends_with('\n') && !result.ends_with('\n') {
424        result.push('\n');
425    } else if !input.ends_with('\n') {
426        while result.ends_with('\n') {
427            result.pop();
428        }
429    }
430
431    Ok(result)
432}