Skip to main content

rsmarkdown_core/
processor.rs

1//! `MarkdownProcessor` — the streaming core. Ported from
2//! `markmend/core/src/processor.ts` + `markmend/ast` cache behavior:
3//!
4//! ```text
5//! raw content
6//!   -> normalize            (CRLF, trim, LaTeX pre-processing)
7//!   -> parse_markdown_into_blocks   (streaming mode; single block in static mode)
8//!   -> preprocess LAST block only   (syntax self-healing fixes)
9//!   -> parse each block to AST      (LRU-cached, only the tail block is new)
10//! ```
11
12use std::collections::HashMap;
13
14use crate::ast::Ast;
15use crate::blocks::parse_markdown_into_blocks;
16use crate::parse::parse_block;
17use crate::preprocess::{normalize, preprocess, PreprocessOptions};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Mode {
21    /// Whole document treated as a single block; no preprocess.
22    Static,
23    /// Block-split + preprocess on the trailing block (default).
24    Streaming,
25}
26
27/// One processed block: the (preprocessed) source text and its cached AST.
28#[derive(Debug, Clone)]
29pub struct BlockResult {
30    /// Preprocessed content actually parsed (== `source` except for the last block).
31    pub content: String,
32    /// Parsed AST (None for empty content).
33    pub ast: Option<Ast>,
34    /// True when the preprocess step modified the content (streaming tail).
35    pub loading: bool,
36}
37
38/// Snapshot handed to display adapters.
39#[derive(Debug, Clone, Default)]
40pub struct Document {
41    pub blocks: Vec<BlockResult>,
42}
43
44impl Document {
45    pub fn is_empty(&self) -> bool {
46        self.blocks.is_empty()
47    }
48    pub fn len(&self) -> usize {
49        self.blocks.len()
50    }
51}
52
53/// Tiny LRU cache (max 100 entries, mirrors the original `QuickLRU`).
54struct AstCache {
55    map: HashMap<String, Ast>,
56    order: Vec<String>,
57    cap: usize,
58}
59
60impl AstCache {
61    fn new(cap: usize) -> Self {
62        Self {
63            map: HashMap::new(),
64            order: Vec::new(),
65            cap,
66        }
67    }
68
69    fn get(&mut self, key: &str) -> Option<Ast> {
70        let hit = self.map.get(key).cloned();
71        if hit.is_some() {
72            self.order.retain(|k| k != key);
73            self.order.push(key.to_string());
74        }
75        hit
76    }
77
78    fn insert(&mut self, key: String, ast: Ast) {
79        self.map.insert(key.clone(), ast);
80        self.order.push(key);
81        if self.order.len() > self.cap {
82            let evicted = self.order.remove(0);
83            self.map.remove(&evicted);
84        }
85    }
86}
87
88#[derive(Debug, Clone, Default)]
89pub struct ProcessorOptions {
90    pub preprocess: PreprocessOptions,
91}
92
93/// Cache effectiveness counters, exposed for benchmarks and status bars.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct CacheStats {
96    /// Block parses satisfied from the LRU cache.
97    pub cache_hits: u64,
98    /// Block parses that actually ran the markdown parser.
99    pub fresh_parses: u64,
100}
101
102impl CacheStats {
103    pub fn hits(&self) -> u64 {
104        self.cache_hits
105    }
106    pub fn parses(&self) -> u64 {
107        self.cache_hits + self.fresh_parses
108    }
109    pub fn hit_rate(&self) -> f64 {
110        let total = self.parses();
111        if total == 0 {
112            0.0
113        } else {
114            self.cache_hits as f64 / total as f64
115        }
116    }
117}
118
119pub struct MarkdownProcessor {
120    options: ProcessorOptions,
121    cache: AstCache,
122    stats: CacheStats,
123}
124
125impl Default for MarkdownProcessor {
126    fn default() -> Self {
127        Self::new(ProcessorOptions::default())
128    }
129}
130
131impl MarkdownProcessor {
132    pub fn new(options: ProcessorOptions) -> Self {
133        Self {
134            options,
135            cache: AstCache::new(100),
136            stats: CacheStats::default(),
137        }
138    }
139
140    pub fn with_cache_capacity(options: ProcessorOptions, cap: usize) -> Self {
141        Self {
142            options,
143            cache: AstCache::new(cap),
144            stats: CacheStats::default(),
145        }
146    }
147
148    /// Cache effectiveness since the processor was created.
149    pub fn cache_stats(&self) -> CacheStats {
150        self.stats
151    }
152
153    pub fn normalize(&self, content: &str) -> String {
154        normalize(content)
155    }
156
157    pub fn preprocess(&self, content: &str) -> String {
158        preprocess(content, &self.options.preprocess)
159    }
160
161    pub fn parse_markdown_into_blocks(&self, content: &str) -> Vec<String> {
162        parse_markdown_into_blocks(content)
163    }
164
165    /// Parse a single content string into its AST (through the cache).
166    pub fn parse(&mut self, content: &str) -> Option<Ast> {
167        if content.is_empty() {
168            return None;
169        }
170        if let Some(ast) = self.cache.get(content) {
171            self.stats.cache_hits += 1;
172            return Some(ast);
173        }
174        let ast = parse_block(content);
175        self.cache.insert(content.to_string(), ast.clone());
176        self.stats.fresh_parses += 1;
177        Some(ast)
178    }
179
180    /// The main entry: full markdown content -> blocks + per-block ASTs.
181    pub fn process(&mut self, content: &str, mode: Mode) -> Document {
182        let normalized = self.normalize(content);
183        if normalized.is_empty() {
184            return Document::default();
185        }
186
187        let blocks = match mode {
188            Mode::Static => vec![normalized],
189            Mode::Streaming => self.parse_markdown_into_blocks(&normalized),
190        };
191
192        let mut doc = Document {
193            blocks: Vec::with_capacity(blocks.len()),
194        };
195        for (index, block) in blocks.iter().enumerate() {
196            let is_last = index == blocks.len() - 1;
197            let content = if mode == Mode::Streaming && is_last {
198                self.preprocess(block)
199            } else {
200                block.clone()
201            };
202            let loading = content != *block;
203            let ast = self.parse(&content);
204            doc.blocks.push(BlockResult {
205                content,
206                ast,
207                loading,
208            });
209        }
210        doc
211    }
212
213    pub fn process_streaming(&mut self, content: &str) -> Document {
214        self.process(content, Mode::Streaming)
215    }
216
217    pub fn process_static(&mut self, content: &str) -> Document {
218        self.process(content, Mode::Static)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn empty_input() {
228        let mut p = MarkdownProcessor::default();
229        assert!(p.process_streaming("").is_empty());
230        assert!(p.process_streaming("\n\n").is_empty());
231    }
232
233    #[test]
234    fn static_single_block() {
235        let mut p = MarkdownProcessor::default();
236        let doc = p.process_static("# Hi\n\nText");
237        assert_eq!(doc.len(), 1);
238        assert!(!doc.blocks[0].loading);
239    }
240
241    #[test]
242    fn streaming_blocks_and_loading_tail() {
243        let mut p = MarkdownProcessor::default();
244        let doc = p.process_streaming("# Hi\n\nText **bold");
245        assert_eq!(doc.len(), 2);
246        assert!(!doc.blocks[0].loading);
247        assert!(doc.blocks[1].loading); // preprocessed tail
248        assert!(doc.blocks[1].content.ends_with("**"));
249    }
250
251    #[test]
252    fn completed_blocks_stay_stable() {
253        // The classic streaming property: parsing a prefix P then P+chunk yields
254        // the same block boundaries for the completed part.
255        let mut p = MarkdownProcessor::default();
256        let a = p.process_streaming("# Hi\n\nSome text here\n\n```js\nlet x = 1");
257        let b = p.process_streaming("# Hi\n\nSome text here\n\n```js\nlet x = 1\n```");
258        assert_eq!(a.blocks.len(), 3);
259        assert_eq!(b.blocks.len(), 3);
260        for i in 0..2 {
261            assert_eq!(a.blocks[i].content, b.blocks[i].content);
262            assert_eq!(a.blocks[i].ast, b.blocks[i].ast);
263        }
264    }
265
266    #[test]
267    fn cache_hits() {
268        let mut p = MarkdownProcessor::default();
269        p.process_streaming("a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng\n\nh\n\ni\n\nj\n\nk");
270        // all blocks cached; parse again cheaply
271        let doc = p.process_streaming("a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng\n\nh\n\ni\n\nj\n\nk");
272        assert_eq!(doc.len(), 11);
273    }
274
275    /// Streamed a few characters at a time through one processor, a multi-line
276    /// display formula ends up as math once its closing `$$` has arrived.
277    #[test]
278    fn streamed_multiline_math_settles_as_math() {
279        use crate::ast::{Block, Inline};
280        let text = "例如:\n\n$$ \\boxed{ Z[J]\n=\n\\int x\\,dx } $$\n\n它结合了。";
281        let mut p = MarkdownProcessor::default();
282        let chars: Vec<char> = text.chars().collect();
283        let mut doc = Document::default();
284        for end in (1..=chars.len())
285            .step_by(3)
286            .chain(std::iter::once(chars.len()))
287        {
288            let prefix: String = chars[..end].iter().collect();
289            doc = p.process_streaming(&prefix);
290        }
291        let ast = doc.blocks[1]
292            .ast
293            .as_ref()
294            .expect("the formula block parsed");
295        assert!(
296            matches!(&ast.children[0], Block::Paragraph(p) if matches!(p.as_slice(), [Inline::Math(m, true)] if m.contains("Z[J] = \\int x\\,dx"))),
297            "{:?}",
298            ast.children
299        );
300    }
301}