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