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