atproto blogging
1//! State structures for EditorWriter, grouped by concern.
2
3use std::collections::HashMap;
4use std::ops::Range;
5
6use markdown_weaver::Alignment;
7use smol_str::{SmolStr, ToSmolStr, format_smolstr};
8
9use crate::offset_map::OffsetMapping;
10use crate::syntax::{SyntaxSpanInfo, SyntaxType};
11
12/// Table rendering state.
13#[derive(Debug, Clone, Default)]
14pub struct TableContext {
15 pub state: TableState,
16 pub alignments: Vec<Alignment>,
17 pub cell_index: usize,
18 pub render_as_markdown: bool,
19 pub start_offset: Option<usize>,
20}
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub enum TableState {
24 #[default]
25 Head,
26 Body,
27}
28
29/// Code block buffering state.
30#[derive(Debug, Clone, Default)]
31pub struct CodeBlockContext {
32 /// (language, content) being buffered
33 pub buffer: Option<(Option<SmolStr>, String)>,
34 /// Byte range of buffered content
35 pub byte_range: Option<Range<usize>>,
36 /// Char range of buffered content
37 pub char_range: Option<Range<usize>>,
38 /// Char offset where code block started
39 pub block_start: Option<usize>,
40 /// Index of opening fence syntax span
41 pub opening_span_idx: Option<usize>,
42}
43
44impl CodeBlockContext {
45 pub fn is_active(&self) -> bool {
46 self.buffer.is_some()
47 }
48
49 pub fn clear(&mut self) {
50 *self = Self::default();
51 }
52}
53
54/// Node ID generation for DOM element IDs.
55#[derive(Debug, Clone)]
56pub struct NodeIdGenerator {
57 /// Paragraph ID prefix (e.g., "p-0")
58 pub prefix: Option<SmolStr>,
59 /// Auto-increment base for paragraph prefixes
60 pub auto_increment_base: Option<usize>,
61 /// Override for specific paragraph index
62 pub static_override: Option<(usize, SmolStr)>,
63 /// Current paragraph index (0-indexed)
64 pub current_paragraph: usize,
65 /// Next node ID counter within paragraph
66 pub next_node_id: usize,
67 /// Next syntax span ID counter
68 pub next_syn_id: usize,
69}
70
71impl Default for NodeIdGenerator {
72 fn default() -> Self {
73 Self {
74 prefix: None,
75 auto_increment_base: None,
76 static_override: None,
77 current_paragraph: 0,
78 next_node_id: 0,
79 next_syn_id: 0,
80 }
81 }
82}
83
84impl NodeIdGenerator {
85 /// Get the current paragraph prefix.
86 pub fn current_prefix(&self) -> SmolStr {
87 if let Some((idx, ref prefix)) = self.static_override {
88 if idx == self.current_paragraph {
89 return prefix.clone();
90 }
91 }
92 if let Some(base) = self.auto_increment_base {
93 return format_smolstr!("p-{}", base + self.current_paragraph);
94 }
95 self.prefix.clone().unwrap_or_else(|| "p-0".to_smolstr())
96 }
97
98 /// Generate a node ID (e.g., "p-0-n3")
99 pub fn next_node(&mut self) -> SmolStr {
100 let id = if let Some(ref prefix) = self.prefix {
101 format_smolstr!("{}-n{}", prefix, self.next_node_id)
102 } else {
103 format_smolstr!("n{}", self.next_node_id)
104 };
105 self.next_node_id += 1;
106 SmolStr::new(id)
107 }
108
109 /// Generate a syntax span ID (e.g., "s5")
110 pub fn next_syn(&mut self) -> SmolStr {
111 let id = format_smolstr!("s{}", self.next_syn_id);
112 self.next_syn_id += 1;
113 SmolStr::new(id)
114 }
115
116 /// Advance to next paragraph.
117 pub fn next_paragraph(&mut self) {
118 self.current_paragraph += 1;
119 self.next_node_id = 0;
120
121 // Update prefix for next paragraph
122 if let Some((override_idx, ref override_prefix)) = self.static_override {
123 if self.current_paragraph == override_idx {
124 self.prefix = Some(override_prefix.clone());
125 } else if let Some(base) = self.auto_increment_base {
126 self.prefix = Some(format_smolstr!("p-{}", base + self.current_paragraph));
127 }
128 } else if let Some(base) = self.auto_increment_base {
129 self.prefix = Some(format_smolstr!("p-{}", base + self.current_paragraph));
130 }
131 }
132}
133
134/// Current DOM node tracking for offset mapping.
135#[derive(Debug, Clone, Default)]
136pub struct CurrentNodeState {
137 /// Node ID for current text container
138 pub id: Option<SmolStr>,
139 /// UTF-16 offset within current node
140 pub char_offset: usize,
141 /// Number of child elements in current container
142 pub child_count: usize,
143}
144
145impl CurrentNodeState {
146 pub fn begin(&mut self, id: SmolStr) {
147 self.id = Some(id);
148 self.char_offset = 0;
149 self.child_count = 0;
150 }
151
152 pub fn end(&mut self) {
153 self.id = None;
154 self.char_offset = 0;
155 self.child_count = 0;
156 }
157}
158
159/// Paragraph boundary tracking.
160#[derive(Debug, Clone, Default)]
161pub struct ParagraphTracker {
162 /// Completed paragraph ranges: (byte_range, char_range)
163 pub ranges: Vec<(Range<usize>, Range<usize>)>,
164 /// Start of current paragraph: (byte_offset, char_offset)
165 pub current_start: Option<(usize, usize)>,
166 /// Pre-gap position for paragraph start (captured before gap emission).
167 /// This ensures the paragraph's char_range includes leading whitespace.
168 pub pre_gap_start: Option<(usize, usize)>,
169 /// List nesting depth (suppress paragraph boundaries inside lists)
170 pub list_depth: usize,
171 /// In footnote definition (suppress inner paragraph boundaries)
172 pub in_footnote_def: bool,
173}
174
175impl ParagraphTracker {
176 pub fn start_paragraph(&mut self, byte_offset: usize, char_offset: usize) {
177 self.current_start = Some((byte_offset, char_offset));
178 }
179
180 pub fn end_paragraph(
181 &mut self,
182 byte_offset: usize,
183 char_offset: usize,
184 ) -> Option<(Range<usize>, Range<usize>)> {
185 if let Some((start_byte, start_char)) = self.current_start.take() {
186 let ranges = (start_byte..byte_offset, start_char..char_offset);
187 self.ranges.push(ranges.clone());
188 Some(ranges)
189 } else {
190 None
191 }
192 }
193
194 pub fn in_list(&self) -> bool {
195 self.list_depth > 0
196 }
197
198 pub fn should_track_boundaries(&self) -> bool {
199 self.list_depth == 0 && !self.in_footnote_def
200 }
201}
202
203/// Current paragraph build state (offset maps, syntax spans, refs).
204#[derive(Debug, Clone, Default)]
205pub struct ParagraphBuildState {
206 /// Offset mappings for current paragraph
207 pub offset_maps: Vec<OffsetMapping>,
208 /// Syntax spans for current paragraph
209 pub syntax_spans: Vec<SyntaxSpanInfo>,
210 /// Collected refs for current paragraph
211 pub collected_refs: Vec<weaver_common::ExtractedRef>,
212 /// Stack of pending inline formats: (syn_id, char_start)
213 pub pending_inline_formats: Vec<(SmolStr, usize)>,
214}
215
216impl ParagraphBuildState {
217 pub fn take_all(
218 &mut self,
219 ) -> (
220 Vec<OffsetMapping>,
221 Vec<SyntaxSpanInfo>,
222 Vec<weaver_common::ExtractedRef>,
223 ) {
224 (
225 std::mem::take(&mut self.offset_maps),
226 std::mem::take(&mut self.syntax_spans),
227 std::mem::take(&mut self.collected_refs),
228 )
229 }
230
231 /// Finalize a paired inline format (Strong, Emphasis, Strikethrough).
232 pub fn finalize_paired_format(&mut self, last_char_offset: usize) {
233 if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() {
234 let formatted_range = format_start..last_char_offset;
235
236 // Update opening span
237 if let Some(span) = self
238 .syntax_spans
239 .iter_mut()
240 .find(|s| s.syn_id == opening_syn_id)
241 {
242 span.formatted_range = Some(formatted_range.clone());
243 }
244
245 // Update closing span (most recent)
246 if let Some(closing) = self.syntax_spans.last_mut() {
247 if closing.syntax_type == SyntaxType::Inline {
248 closing.formatted_range = Some(formatted_range);
249 }
250 }
251 }
252 }
253}
254
255/// WeaverBlock prefix system state.
256#[derive(Debug, Clone, Default)]
257pub struct WeaverBlockContext {
258 /// Pending attrs to apply to next block element
259 pub pending_attrs: Option<markdown_weaver::WeaverAttributes<'static>>,
260 /// Type of wrapper element currently open
261 pub active_wrapper: Option<WrapperElement>,
262 /// Buffer for WeaverBlock text content
263 pub buffer: String,
264 /// Start char offset of current WeaverBlock
265 pub char_start: Option<usize>,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum WrapperElement {
270 Aside,
271 Div,
272}
273
274/// Footnote reference/definition linking state.
275#[derive(Debug, Clone, Default)]
276pub struct FootnoteContext {
277 /// Maps footnote name -> (syntax_span_index, char_start)
278 pub ref_spans: HashMap<SmolStr, (usize, usize)>,
279 /// Current footnote def being processed: (name, span_idx, char_start)
280 pub current_def: Option<(SmolStr, usize, usize)>,
281}
282
283/// UTF-16 offset checkpoints for incremental tracking.
284#[derive(Debug, Clone, Default)]
285pub struct Utf16Tracker {
286 /// Checkpoints: (char_offset, utf16_offset)
287 pub checkpoints: Vec<(usize, usize)>,
288}
289
290impl Utf16Tracker {
291 pub fn new() -> Self {
292 Self {
293 checkpoints: vec![(0, 0)],
294 }
295 }
296
297 /// Add a checkpoint.
298 pub fn checkpoint(&mut self, char_offset: usize, utf16_offset: usize) {
299 if self.checkpoints.last().map(|(c, _)| *c) != Some(char_offset) {
300 self.checkpoints.push((char_offset, utf16_offset));
301 }
302 }
303
304 /// Get the last checkpoint.
305 pub fn last(&self) -> (usize, usize) {
306 self.checkpoints.last().copied().unwrap_or((0, 0))
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_node_id_generator() {
316 let mut generator = NodeIdGenerator::default();
317 generator.prefix = Some("p-0".to_smolstr());
318
319 assert_eq!(generator.next_node().as_str(), "p-0-n0");
320 assert_eq!(generator.next_node().as_str(), "p-0-n1");
321 assert_eq!(generator.next_syn().as_str(), "s0");
322 assert_eq!(generator.next_syn().as_str(), "s1");
323 }
324
325 #[test]
326 fn test_node_id_generator_auto_increment() {
327 let mut generator = NodeIdGenerator::default();
328 generator.auto_increment_base = Some(0);
329 generator.prefix = Some("p-0".to_smolstr());
330
331 assert_eq!(generator.next_node().as_str(), "p-0-n0");
332 generator.next_paragraph();
333 assert_eq!(generator.prefix, Some("p-1".to_smolstr()));
334 assert_eq!(generator.next_node().as_str(), "p-1-n0");
335 }
336
337 #[test]
338 fn test_paragraph_tracker() {
339 let mut tracker = ParagraphTracker::default();
340
341 tracker.start_paragraph(0, 0);
342 let ranges = tracker.end_paragraph(10, 10);
343 assert_eq!(ranges, Some((0..10, 0..10)));
344
345 tracker.list_depth = 1;
346 assert!(tracker.in_list());
347 assert!(!tracker.should_track_boundaries());
348 }
349
350 #[test]
351 fn test_code_block_context() {
352 let mut ctx = CodeBlockContext::default();
353 assert!(!ctx.is_active());
354
355 ctx.buffer = Some((Some("rust".to_smolstr()), String::new()));
356 assert!(ctx.is_active());
357
358 ctx.clear();
359 assert!(!ctx.is_active());
360 }
361}