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