Skip to main content

snapper_fmt/parser/
mod.rs

1pub mod latex;
2pub mod markdown;
3pub mod org;
4#[cfg(feature = "pandoc")]
5pub mod pandoc;
6pub mod plaintext;
7pub mod rst;
8pub mod span;
9
10pub use span::{
11    ByteSpan, CodeSpans, Line, RegionOrigin, SpannedRegion, flush_prose_spanned, iter_lines,
12    push_prose_line,
13};
14
15/// A region of text classified by a format parser.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Region {
18    /// Prose text that should be reflowed with semantic line breaks.
19    Prose(String),
20    /// Structural content that must pass through unchanged.
21    Structure(String),
22    /// Blank line(s) preserved as paragraph separators.
23    BlankLines(String),
24    /// A fenced code block. `header` and `footer` carry the fence lines
25    /// (with their trailing newline) verbatim. `body` is the raw block
26    /// contents between the fences; the reflow stage may rewrite comments
27    /// inside `body` per the `[code]` configuration. `lang` is `None`
28    /// when the parser could not infer a language identifier.
29    Code {
30        lang: Option<String>,
31        header: String,
32        body: String,
33        footer: String,
34    },
35}
36
37/// Trait for format-specific parsers that classify text into regions.
38pub trait FormatParser {
39    /// Classify `input` and record source byte ranges where possible.
40    fn parse_full(&self, input: &str) -> Vec<SpannedRegion>;
41
42    /// Classify `input` into regions, dropping recorded spans.
43    fn parse(&self, input: &str) -> Vec<Region> {
44        self.parse_full(input)
45            .into_iter()
46            .map(|s| s.region)
47            .collect()
48    }
49}
50
51/// Per-source-line prose payload from the same `parse_full` walk format uses.
52///
53/// `None` means the line has no prose rewrite range (structure, code, blank,
54/// pragma-off). `Some` is the original-source slice the parser sent to the
55/// splitter for that line (list/quote body, mid-line comment prefix).
56/// `config` supplies `[latex]` extras; `None` keeps the built-in lists.
57pub fn source_line_payloads(
58    input: &str,
59    format: crate::format::Format,
60    config: Option<&crate::FormatConfig>,
61) -> Vec<Option<String>> {
62    let spanned = parser_for_format_config(format, config).parse_full(input);
63    iter_lines(input)
64        .into_iter()
65        .map(|line| line_prose_payload(input, line, &spanned))
66        .collect()
67}
68
69fn line_prose_payload(input: &str, line: Line<'_>, spanned: &[SpannedRegion]) -> Option<String> {
70    let lo = line.start;
71    let hi = line.start + line.text.len();
72    let mut out = String::new();
73    for sr in spanned {
74        if !matches!(sr.region, Region::Prose(_)) {
75            continue;
76        }
77        let Some(origin) = sr.origin else {
78            continue;
79        };
80        let span = origin.whole();
81        let start = span.start.max(lo);
82        let end = span.end.min(hi);
83        if start < end {
84            out.push_str(&input[start..end]);
85        }
86    }
87    if out.trim().is_empty() {
88        None
89    } else {
90        Some(out)
91    }
92}
93
94/// Create the appropriate parser for a given format (built-in lists only).
95pub fn parser_for_format(format: crate::format::Format) -> Box<dyn FormatParser> {
96    parser_for_format_config(format, None)
97}
98
99/// Create a parser, applying `[latex]` extras from `config` when present.
100pub fn parser_for_format_config(
101    format: crate::format::Format,
102    config: Option<&crate::FormatConfig>,
103) -> Box<dyn FormatParser> {
104    use crate::format::Format;
105    match format {
106        Format::Org => Box::new(org::OrgParser),
107        Format::Latex => Box::new(latex::LatexParser::from_config(config)),
108        Format::Markdown => Box::new(markdown::MarkdownParser),
109        Format::Rst => Box::new(rst::RstParser),
110        Format::Plaintext => Box::new(plaintext::PlaintextParser),
111    }
112}
113
114/// Flush accumulated prose into the region list, clearing the buffer.
115///
116/// Prefer [`flush_prose_spanned`] in native parsers so the rewrite range
117/// is recorded. This helper remains for tests and the pandoc AST path.
118pub fn flush_prose(prose: &mut String, regions: &mut Vec<Region>) {
119    if !prose.is_empty() {
120        regions.push(Region::Prose(prose.clone()));
121        prose.clear();
122    }
123}
124
125/// Check if a line contains a snapper pragma.
126/// Returns Some(false) for "snapper:off", Some(true) for "snapper:on", None otherwise.
127pub fn check_pragma(line: &str) -> Option<bool> {
128    let trimmed = line.trim();
129    // Strip format-specific comment markers
130    let content = trimmed
131        .strip_prefix("# ") // Org comment
132        .or_else(|| trimmed.strip_prefix("% ")) // LaTeX comment
133        .or_else(|| {
134            // HTML/Markdown comment
135            trimmed
136                .strip_prefix("<!-- ")
137                .and_then(|s| s.strip_suffix(" -->"))
138        })
139        .unwrap_or(trimmed); // Plaintext: bare pragma
140    let content = content.trim();
141    if content == "snapper:off" {
142        Some(false)
143    } else if content == "snapper:on" {
144        Some(true)
145    } else {
146        None
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn pragma_org_comment() {
156        assert_eq!(check_pragma("# snapper:off"), Some(false));
157        assert_eq!(check_pragma("# snapper:on"), Some(true));
158    }
159
160    #[test]
161    fn pragma_latex_comment() {
162        assert_eq!(check_pragma("% snapper:off"), Some(false));
163        assert_eq!(check_pragma("% snapper:on"), Some(true));
164    }
165
166    #[test]
167    fn pragma_html_comment() {
168        assert_eq!(check_pragma("<!-- snapper:off -->"), Some(false));
169        assert_eq!(check_pragma("<!-- snapper:on -->"), Some(true));
170    }
171
172    #[test]
173    fn pragma_bare() {
174        assert_eq!(check_pragma("snapper:off"), Some(false));
175        assert_eq!(check_pragma("snapper:on"), Some(true));
176    }
177
178    #[test]
179    fn pragma_none() {
180        assert_eq!(check_pragma("regular text"), None);
181        assert_eq!(check_pragma("# a comment"), None);
182        assert_eq!(check_pragma(""), None);
183    }
184}