rsmarkdown_core/
processor.rs1use 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 Static,
23 Streaming,
25}
26
27#[derive(Debug, Clone)]
29pub struct BlockResult {
30 pub content: String,
32 pub ast: Option<Ast>,
34 pub loading: bool,
36}
37
38#[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
53struct 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct CacheStats {
96 pub cache_hits: u64,
98 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 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 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 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); assert!(doc.blocks[1].content.ends_with("**"));
249 }
250
251 #[test]
252 fn completed_blocks_stay_stable() {
253 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 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 #[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}