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**: blocks, drawers, tables, keywords preserved
13//! - **LaTeX**: preamble, math, environments, comments preserved
14//! - **Markdown**: code blocks, front matter, headings preserved
15//! - **Plaintext**: everything treated as prose
16//!
17//! ## Library usage
18//!
19//! ```rust
20//! use snapper_fmt::{format_text, FormatConfig};
21//! use snapper_fmt::format::Format;
22//!
23//! let input = "Hello world. This is a test. Another sentence.";
24//! let config = FormatConfig {
25//!     format: Format::Plaintext,
26//!     max_width: 0,
27//!     use_neural: false,
28//!     neural_lang: "en".to_string(),
29//!     neural_model_path: None,
30//!     extra_abbreviations: vec![],
31//!     use_pandoc: false,
32//!     pandoc_format: None,
33//! };
34//! let output = format_text(input, &config).unwrap();
35//! assert_eq!(output, "Hello world.\nThis is a test.\nAnother sentence.");
36//! ```
37
38pub mod abbreviations;
39pub mod cli;
40pub mod config;
41pub mod diff;
42pub mod files;
43pub mod format;
44pub mod git_diff;
45pub mod init;
46pub mod lsp;
47pub mod output;
48pub mod parser;
49pub mod reflow;
50pub mod sdiff;
51pub mod sentence;
52pub mod watch;
53
54use anyhow::Result;
55
56use crate::format::Format;
57use crate::parser::FormatParser;
58use crate::parser::latex::LatexParser;
59use crate::parser::markdown::MarkdownParser;
60use crate::parser::org::OrgParser;
61use crate::parser::plaintext::PlaintextParser;
62use crate::reflow::{ReflowConfig, reflow};
63use crate::sentence::SentenceSplitter;
64use crate::sentence::unicode::UnicodeSentenceSplitter;
65
66/// Configuration for the formatting pipeline.
67pub struct FormatConfig {
68    pub format: Format,
69    pub max_width: usize,
70    pub use_neural: bool,
71    pub neural_lang: String,
72    pub neural_model_path: Option<std::path::PathBuf>,
73    pub extra_abbreviations: Vec<String>,
74    pub use_pandoc: bool,
75    /// Pandoc input format string (for pandoc backend).
76    pub pandoc_format: Option<String>,
77}
78
79/// Build the appropriate sentence splitter from config.
80pub fn build_splitter(config: &FormatConfig) -> Result<Box<dyn SentenceSplitter>> {
81    if config.use_neural {
82        let neural = if let Some(ref path) = config.neural_model_path {
83            sentence::neural::NeuralSentenceSplitter::from_path(path)
84        } else {
85            sentence::neural::NeuralSentenceSplitter::new(&config.neural_lang)
86        };
87        Ok(Box::new(neural.map_err(|e| anyhow::anyhow!("{e}"))?))
88    } else {
89        Ok(Box::new(UnicodeSentenceSplitter::for_lang(
90            &config.neural_lang,
91            &config.extra_abbreviations,
92        )))
93    }
94}
95
96/// Format text with semantic line breaks.
97pub fn format_text(input: &str, config: &FormatConfig) -> Result<String> {
98    let splitter = build_splitter(config)?;
99    format_text_with_splitter(input, config, splitter.as_ref())
100}
101
102/// Format text using a pre-constructed splitter (avoids reloading models per file).
103pub fn format_text_with_splitter(
104    input: &str,
105    config: &FormatConfig,
106    splitter: &dyn SentenceSplitter,
107) -> Result<String> {
108    let parser: Box<dyn FormatParser> = if config.use_pandoc {
109        // Use pandoc backend -- determine the pandoc format string
110        let pandoc_fmt = config
111            .pandoc_format
112            .as_deref()
113            .unwrap_or(match config.format {
114                Format::Org => "org",
115                Format::Latex => "latex",
116                Format::Markdown => "markdown",
117                Format::Rst => "rst",
118                Format::Plaintext => "markdown",
119            });
120        Box::new(parser::pandoc::PandocParser::new(pandoc_fmt))
121    } else {
122        match config.format {
123            Format::Org => Box::new(OrgParser),
124            Format::Latex => Box::new(LatexParser),
125            Format::Markdown => Box::new(MarkdownParser),
126            Format::Rst => Box::new(parser::rst::RstParser),
127            Format::Plaintext => Box::new(PlaintextParser),
128        }
129    };
130
131    let had_trailing_newline = input.ends_with('\n');
132    let uses_crlf = input.contains("\r\n");
133
134    // Normalize to LF for processing, restore CRLF at the end if needed.
135    let normalized;
136    let work_input = if uses_crlf {
137        normalized = input.replace("\r\n", "\n");
138        &normalized
139    } else {
140        input
141    };
142
143    let regions = parser.parse(work_input);
144    let reflow_config = ReflowConfig {
145        max_width: config.max_width,
146    };
147
148    let mut output = reflow(&regions, splitter, &reflow_config);
149
150    // Preserve the original file's trailing newline convention.
151    if had_trailing_newline && !output.ends_with('\n') {
152        output.push('\n');
153    } else if !had_trailing_newline {
154        while output.ends_with('\n') {
155            output.pop();
156        }
157    }
158
159    // Restore CRLF if the input used it.
160    if uses_crlf {
161        output = output.replace('\n', "\r\n");
162    }
163
164    Ok(output)
165}
166
167/// Format only lines within a range (1-indexed, inclusive).
168/// Lines outside the range pass through unchanged.
169pub fn format_range(
170    input: &str,
171    config: &FormatConfig,
172    start: usize,
173    end: usize,
174) -> Result<String> {
175    let lines: Vec<&str> = input.lines().collect();
176    let total = lines.len();
177
178    // Clamp range
179    let start = start.max(1);
180    let end = end.min(total);
181
182    if start > total {
183        return Ok(input.to_string());
184    }
185
186    // Extract the range as a contiguous block
187    let range_text = lines[start - 1..end].join("\n");
188    let formatted = format_text(&range_text, config)?;
189
190    // Reassemble: before + formatted + after
191    let mut result = String::new();
192    for (i, line) in lines.iter().enumerate() {
193        let line_num = i + 1;
194        if line_num < start {
195            result.push_str(line);
196            result.push('\n');
197        }
198    }
199    result.push_str(&formatted);
200    if !formatted.ends_with('\n') && end < total {
201        result.push('\n');
202    }
203    for (i, line) in lines.iter().enumerate() {
204        let line_num = i + 1;
205        if line_num > end {
206            result.push_str(line);
207            if line_num < total {
208                result.push('\n');
209            }
210        }
211    }
212
213    // Preserve original trailing newline convention
214    if input.ends_with('\n') && !result.ends_with('\n') {
215        result.push('\n');
216    } else if !input.ends_with('\n') {
217        while result.ends_with('\n') {
218            result.pop();
219        }
220    }
221
222    Ok(result)
223}