Skip to main content

litedoc_core/
parser.rs

1//! Zero-allocation block parser for LiteDoc
2//!
3//! Borrows directly from input, avoiding String allocations.
4//! Features graceful error recovery to continue parsing after errors.
5
6use std::borrow::Cow;
7
8use crate::ast::{
9    AttrValue, Block, Callout, CodeBlock, CowStr, Document, Figure, FootnoteDef, Footnotes,
10    Heading, HtmlBlock, List, ListItem, ListKind, MathBlock, Metadata, Module, Paragraph, Profile,
11    Quote, RawBlock, Table, TableCell, TableRow,
12};
13use crate::error::{ParseError, ParseErrors};
14use crate::lexer::Lexer;
15use crate::span::Span;
16
17/// Result type for parsing that includes recovered errors.
18#[derive(Debug)]
19pub struct ParseResult<'a> {
20    /// The parsed document (may be partial if errors occurred).
21    pub document: Document<'a>,
22    /// Errors encountered during parsing.
23    pub errors: ParseErrors,
24}
25
26impl<'a> ParseResult<'a> {
27    /// Check if parsing completed without errors.
28    pub fn is_ok(&self) -> bool {
29        self.errors.is_empty()
30    }
31
32    /// Check if any fatal errors occurred.
33    pub fn has_fatal_errors(&self) -> bool {
34        self.errors.has_fatal()
35    }
36}
37
38/// LiteDoc parser with configurable profile and error recovery.
39pub struct Parser {
40    profile: Profile,
41    modules: Vec<Module>,
42    /// Errors collected during parsing (for recovery mode).
43    errors: ParseErrors,
44    /// Whether to attempt recovery on errors.
45    recover_on_error: bool,
46}
47
48impl Parser {
49    /// Create a new parser with the given profile.
50    #[inline]
51    pub fn new(profile: Profile) -> Self {
52        Self {
53            profile,
54            modules: Vec::new(),
55            errors: ParseErrors::new(),
56            recover_on_error: true,
57        }
58    }
59
60    /// Enable or disable error recovery mode.
61    ///
62    /// When enabled (default), the parser will attempt to continue
63    /// parsing after encountering errors, collecting them for later
64    /// inspection. When disabled, parsing stops at the first error.
65    pub fn with_recovery(mut self, recover: bool) -> Self {
66        self.recover_on_error = recover;
67        self
68    }
69
70    /// Parse with error recovery, returning both document and errors.
71    #[inline]
72    pub fn parse_with_recovery<'a>(&mut self, input: &'a str) -> ParseResult<'a> {
73        self.errors = ParseErrors::new();
74        let doc = self.parse_internal(input);
75        ParseResult {
76            document: doc,
77            errors: std::mem::take(&mut self.errors),
78        }
79    }
80
81    /// Parse the input, returning an error on first failure.
82    #[inline]
83    pub fn parse<'a>(&mut self, input: &'a str) -> Result<Document<'a>, ParseError> {
84        self.errors = ParseErrors::new();
85        let doc = self.parse_internal(input);
86        if self.errors.is_empty() {
87            Ok(doc)
88        } else {
89            // Return the first error
90            Err(self.errors.iter().next().unwrap().clone())
91        }
92    }
93
94    #[inline]
95    fn parse_internal<'a>(&mut self, input: &'a str) -> Document<'a> {
96        let mut lexer = Lexer::new(input);
97
98        lexer.skip_blank_lines();
99
100        let profile = self.parse_profile_directive(&mut lexer);
101        let modules = self.parse_modules_directive(&mut lexer);
102        self.modules = modules.clone();
103
104        lexer.skip_blank_lines();
105
106        let metadata = self.parse_metadata(&mut lexer, input);
107
108        lexer.skip_blank_lines();
109
110        let blocks = self.parse_blocks(&mut lexer, input);
111
112        Document {
113            profile: profile.unwrap_or(self.profile),
114            modules,
115            metadata,
116            blocks,
117            span: Span::new(0, input.len() as u32),
118        }
119    }
120
121    /// Record an error during parsing.
122    #[inline]
123    fn record_error(&mut self, error: ParseError) {
124        self.errors.push(error);
125    }
126
127    /// Check if the given module is enabled.
128    #[inline]
129    pub fn has_module(&self, module: Module) -> bool {
130        self.modules.contains(&module)
131    }
132
133    #[inline]
134    fn parse_profile_directive(&self, lexer: &mut Lexer) -> Option<Profile> {
135        let line = lexer.peek_line()?;
136        let trimmed = line.trimmed();
137
138        if let Some(rest) = trimmed.strip_prefix("@profile") {
139            let profile = match rest.trim() {
140                "litedoc" => Some(Profile::Litedoc),
141                "md" => Some(Profile::Md),
142                "md-strict" => Some(Profile::MdStrict),
143                _ => None,
144            };
145            if profile.is_some() {
146                lexer.next_line();
147                lexer.skip_blank_lines();
148            }
149            profile
150        } else {
151            None
152        }
153    }
154
155    #[inline]
156    fn parse_modules_directive(&self, lexer: &mut Lexer) -> Vec<Module> {
157        let line = lexer.peek_line();
158        let trimmed = match line {
159            Some(l) => l.trimmed(),
160            None => return Vec::new(),
161        };
162
163        if let Some(rest) = trimmed.strip_prefix("@modules") {
164            let mut modules = Vec::with_capacity(4);
165            for part in rest.split(',') {
166                match part.trim() {
167                    "tables" => modules.push(Module::Tables),
168                    "footnotes" => modules.push(Module::Footnotes),
169                    "math" => modules.push(Module::Math),
170                    "tasks" => modules.push(Module::Tasks),
171                    "strikethrough" => modules.push(Module::Strikethrough),
172                    "autolink" => modules.push(Module::Autolink),
173                    "html" => modules.push(Module::Html),
174                    _ => {}
175                }
176            }
177            lexer.next_line();
178            lexer.skip_blank_lines();
179            modules
180        } else {
181            Vec::new()
182        }
183    }
184
185    #[inline]
186    fn parse_metadata<'a>(&self, lexer: &mut Lexer, input: &'a str) -> Option<Metadata<'a>> {
187        let start_span;
188        {
189            let line = lexer.peek_line()?;
190            let trimmed = line.trimmed();
191            if !trimmed.starts_with("---") || !trimmed.contains("meta") {
192                return None;
193            }
194            start_span = line.span;
195        }
196        lexer.next_line();
197
198        let mut entries: Vec<(CowStr<'a>, AttrValue<'a>)> = Vec::with_capacity(8);
199        let mut end_span = start_span;
200
201        loop {
202            let (trimmed, span, is_end) = {
203                match lexer.peek_line() {
204                    Some(line) => {
205                        let t = line.trimmed();
206                        let s = line.span;
207                        let end = t == "---";
208                        (t.to_string(), s, end)
209                    }
210                    None => break,
211                }
212            };
213
214            if is_end {
215                end_span = span;
216                lexer.next_line();
217                break;
218            }
219
220            if let Some(_colon_pos) = trimmed.find(':') {
221                // We need to get slices from input, not from trimmed
222                let line_start = span.start as usize;
223                let line_text = &input[line_start..span.end as usize];
224
225                if let Some(cp) = line_text.find(':') {
226                    let key_slice = line_text[..cp].trim();
227                    let val_slice = line_text[cp + 1..].trim();
228
229                    let key: CowStr<'a> = Cow::Borrowed(key_slice);
230                    let value = self.parse_attr_value(val_slice);
231                    entries.push((key, value));
232                }
233            }
234
235            end_span = span;
236            lexer.next_line();
237        }
238
239        Some(Metadata {
240            entries,
241            span: Span::new(start_span.start, end_span.end),
242        })
243    }
244
245    #[inline]
246    fn parse_attr_value<'a>(&self, s: &'a str) -> AttrValue<'a> {
247        if s == "true" {
248            return AttrValue::Bool(true);
249        }
250        if s == "false" {
251            return AttrValue::Bool(false);
252        }
253
254        if s.starts_with('[') && s.ends_with(']') {
255            let inner = &s[1..s.len() - 1];
256            let items = self.parse_list_items(inner);
257            return AttrValue::List(items);
258        }
259
260        if let Ok(i) = s.parse::<i64>() {
261            return AttrValue::Int(i);
262        }
263
264        if s.contains('.') {
265            if let Ok(f) = s.parse::<f64>() {
266                return AttrValue::Float(f);
267            }
268        }
269
270        let unquoted = if (s.starts_with('"') && s.ends_with('"'))
271            || (s.starts_with('\'') && s.ends_with('\''))
272        {
273            &s[1..s.len() - 1]
274        } else {
275            s
276        };
277
278        AttrValue::Str(Cow::Borrowed(unquoted))
279    }
280
281    #[inline]
282    fn parse_list_items<'a>(&self, s: &'a str) -> Vec<AttrValue<'a>> {
283        let mut items = Vec::with_capacity(4);
284        let mut start = 0;
285        let mut in_quotes = false;
286        let bytes = s.as_bytes();
287
288        for i in 0..bytes.len() {
289            match bytes[i] {
290                b'"' | b'\'' => in_quotes = !in_quotes,
291                b',' if !in_quotes => {
292                    let item = s[start..i].trim();
293                    if !item.is_empty() {
294                        items.push(self.parse_attr_value(item));
295                    }
296                    start = i + 1;
297                }
298                _ => {}
299            }
300        }
301
302        let item = s[start..].trim();
303        if !item.is_empty() {
304            items.push(self.parse_attr_value(item));
305        }
306
307        items
308    }
309
310    #[inline]
311    fn parse_blocks<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Vec<Block<'a>> {
312        let mut blocks = Vec::with_capacity(16);
313
314        while !lexer.is_eof() {
315            lexer.skip_blank_lines();
316
317            if lexer.is_eof() {
318                break;
319            }
320
321            if let Some(block) = self.parse_block(lexer, input) {
322                blocks.push(block);
323            }
324        }
325
326        blocks
327    }
328
329    #[inline]
330    fn parse_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
331        let (first_byte, trimmed_starts_triple, is_hr, starts_colon, span) = {
332            let line = lexer.peek_line()?;
333            let trimmed = line.trimmed();
334            (
335                trimmed.as_bytes().first().copied(),
336                trimmed.starts_with("```"),
337                trimmed == "---",
338                trimmed.starts_with("::"),
339                line.span,
340            )
341        };
342
343        match first_byte {
344            Some(b'#') => self.parse_heading(lexer, input),
345            Some(b'`') if trimmed_starts_triple => self.parse_code_block(lexer, input),
346            Some(b'-') if is_hr => {
347                lexer.next_line();
348                Some(Block::ThematicBreak(span))
349            }
350            Some(b':') if starts_colon => self.parse_fenced_block(lexer, input),
351            _ => self.parse_paragraph(lexer, input),
352        }
353    }
354
355    #[inline]
356    fn parse_heading<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
357        let line = lexer.next_line()?;
358        let text = &input[line.span.start as usize..line.span.end as usize];
359        let bytes = text.as_bytes();
360
361        let level = bytes.iter().take_while(|&&b| b == b'#').count() as u8;
362
363        if level == 0 || level > 6 {
364            return Some(Block::Paragraph(Paragraph {
365                content: crate::inline::parse_inlines(text, line.span.start, input),
366                span: line.span,
367            }));
368        }
369
370        let rest = &text[level as usize..];
371        if !rest.starts_with(' ') && !rest.is_empty() {
372            return Some(Block::Paragraph(Paragraph {
373                content: crate::inline::parse_inlines(text, line.span.start, input),
374                span: line.span,
375            }));
376        }
377
378        let content_text = rest.trim_start();
379        let content_offset = line.span.start + (text.len() - content_text.len()) as u32;
380
381        Some(Block::Heading(Heading {
382            level,
383            content: crate::inline::parse_inlines(content_text, content_offset, input),
384            span: line.span,
385        }))
386    }
387
388    #[inline]
389    fn parse_code_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
390        let (start_span, lang_start, lang_end) = {
391            let open_line = lexer.next_line()?;
392            let text = &input[open_line.span.start as usize..open_line.span.end as usize];
393            let trimmed = text.trim();
394            let after_ticks = trimmed.strip_prefix("```").unwrap_or("");
395            let lang = after_ticks.trim();
396
397            // Calculate where lang is in input
398            let lang_offset = if lang.is_empty() {
399                open_line.span.end as usize
400            } else {
401                // Find lang in the line
402                open_line.span.start as usize + text.find(lang).unwrap_or(0)
403            };
404
405            (open_line.span, lang_offset, lang_offset + lang.len())
406        };
407
408        let lang = &input[lang_start..lang_end];
409
410        // Content starts after the opening fence line. Add 1 to skip newline if present,
411        // but clamp to input length to handle EOF without trailing newline.
412        let content_start = (start_span.end as usize + 1).min(input.len());
413        let mut content_end = content_start;
414        let mut end_span = start_span;
415
416        loop {
417            let (is_close, span) = {
418                match lexer.peek_line() {
419                    Some(line) => (line.trimmed() == "```", line.span),
420                    None => break,
421                }
422            };
423
424            if is_close {
425                end_span = span;
426                lexer.next_line();
427                break;
428            }
429
430            end_span = span;
431            content_end = span.end as usize;
432            lexer.next_line();
433        }
434
435        let content = if content_start < content_end && content_end <= input.len() {
436            &input[content_start..content_end]
437        } else {
438            ""
439        };
440
441        Some(Block::CodeBlock(CodeBlock {
442            lang: Cow::Borrowed(lang),
443            content: Cow::Borrowed(content),
444            span: Span::new(start_span.start, end_span.end),
445        }))
446    }
447
448    #[inline]
449    fn parse_fenced_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
450        let (block_type, span) = {
451            let line = lexer.peek_line()?;
452            let trimmed = line.trimmed();
453            let after_colons = trimmed.strip_prefix("::")?;
454            let bt = after_colons
455                .split_whitespace()
456                .next()
457                .unwrap_or("")
458                .to_string();
459            (bt, line.span)
460        };
461
462        match block_type.as_str() {
463            "list" => self.parse_list_block(lexer, input),
464            "callout" => self.parse_callout_block(lexer, input),
465            "quote" => self.parse_quote_block(lexer, input),
466            "figure" => self.parse_figure_block(lexer, input),
467            "table" => self.parse_table_block(lexer, input),
468            "footnotes" => self.parse_footnotes_block(lexer, input),
469            "math" => self.parse_math_block(lexer, input),
470            "html" => self.parse_html_block(lexer, input),
471            _ => {
472                // Record error for unknown directive but continue with raw block
473                if self.recover_on_error && !block_type.is_empty() {
474                    self.record_error(ParseError::unknown_directive(&block_type, Some(span)));
475                }
476                self.parse_raw_fenced_block(lexer, input)
477            }
478        }
479    }
480
481    #[inline]
482    fn parse_html_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
483        // Check if HTML module is enabled
484        if !self.has_module(Module::Html) {
485            return self.parse_raw_fenced_block(lexer, input);
486        }
487
488        let start_span = lexer.next_line()?.span;
489
490        // Content starts after opening fence, clamped to input length
491        let content_start = (start_span.end as usize + 1).min(input.len());
492        let mut content_end = content_start;
493        let mut end_span = start_span;
494
495        loop {
496            let (is_close, span) = {
497                match lexer.peek_line() {
498                    Some(line) => (line.trimmed() == "::", line.span),
499                    None => {
500                        // Record unclosed HTML block error
501                        self.record_error(ParseError::unclosed_delimiter(
502                            "HTML block",
503                            Some(start_span),
504                        ));
505                        break;
506                    }
507                }
508            };
509
510            if is_close {
511                end_span = span;
512                lexer.next_line();
513                break;
514            }
515
516            content_end = span.end as usize;
517            end_span = span;
518            lexer.next_line();
519        }
520
521        let content = if content_start < content_end && content_end <= input.len() {
522            &input[content_start..content_end]
523        } else {
524            ""
525        };
526
527        Some(Block::Html(HtmlBlock {
528            content: Cow::Borrowed(content),
529            span: Span::new(start_span.start, end_span.end),
530        }))
531    }
532
533    #[inline]
534    fn parse_list_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
535        let (start_span, kind, start_num) = {
536            let open_line = lexer.next_line()?;
537            let text = &input[open_line.span.start as usize..open_line.span.end as usize];
538            let trimmed = text.trim();
539            let after_list = trimmed.strip_prefix("::list").unwrap_or("").trim();
540
541            let mut k = ListKind::Unordered;
542            let mut sn: Option<u64> = None;
543
544            for part in after_list.split_whitespace() {
545                match part {
546                    "ordered" => k = ListKind::Ordered,
547                    "unordered" => k = ListKind::Unordered,
548                    _ if part.starts_with("start=") => {
549                        sn = part[6..].parse().ok();
550                    }
551                    _ => {}
552                }
553            }
554            (open_line.span, k, sn)
555        };
556
557        let mut items: Vec<ListItem<'a>> = Vec::with_capacity(8);
558        let mut item_start: Option<u32> = None;
559        let mut item_end: u32 = start_span.end;
560        let mut end_span = start_span;
561        let mut last_span = start_span;
562
563        let finalize_item = |items: &mut Vec<ListItem<'a>>, start: Option<u32>, end: u32| {
564            if let Some(start) = start {
565                let content_slice = &input[start as usize..end as usize];
566                let content = crate::inline::parse_inlines(content_slice, start, input);
567                items.push(ListItem {
568                    blocks: vec![Block::Paragraph(Paragraph {
569                        content,
570                        span: Span::new(start, end),
571                    })],
572                    span: Span::new(start, end),
573                });
574            }
575        };
576
577        while let Some(&line) = lexer.peek_line() {
578            let text = &input[line.span.start as usize..line.span.end as usize];
579            let trimmed = text.trim();
580
581            if trimmed == "::" {
582                lexer.next_line();
583                finalize_item(&mut items, item_start.take(), item_end);
584                end_span = line.span;
585                break;
586            }
587
588            if trimmed.starts_with("- ") {
589                lexer.next_line();
590                finalize_item(&mut items, item_start.take(), item_end);
591                let dash_offset = text.find("- ").unwrap_or(0);
592                item_start = Some(line.span.start + dash_offset as u32 + 2);
593                item_end = line.span.end;
594                last_span = line.span;
595                end_span = line.span;
596                continue;
597            }
598
599            if trimmed.starts_with("| ") {
600                lexer.next_line();
601                item_end = line.span.end;
602                last_span = line.span;
603                end_span = line.span;
604                continue;
605            }
606
607            if line.is_blank() {
608                lexer.next_line();
609                last_span = line.span;
610                end_span = line.span;
611                continue;
612            }
613
614            if trimmed.starts_with("::")
615                || trimmed.starts_with("```")
616                || trimmed.starts_with('#')
617                || trimmed.starts_with("@profile")
618                || trimmed.starts_with("@modules")
619                || trimmed == "---"
620                || trimmed.starts_with("--- meta ---")
621            {
622                self.record_error(ParseError::unclosed_delimiter("::list", Some(start_span)));
623                finalize_item(&mut items, item_start.take(), item_end);
624                end_span = last_span;
625                break;
626            }
627
628            self.record_error(ParseError::invalid_syntax("list item", Some(line.span)));
629            finalize_item(&mut items, item_start.take(), item_end);
630            end_span = last_span;
631            break;
632        }
633
634        Some(Block::List(List {
635            kind,
636            start: start_num,
637            items,
638            span: Span::new(start_span.start, end_span.end),
639        }))
640    }
641
642    #[inline]
643    fn parse_callout_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
644        let (start_span, kind, title) = {
645            let open_line = lexer.next_line()?;
646            let text = &input[open_line.span.start as usize..open_line.span.end as usize];
647            let trimmed = text.trim();
648            let after_callout = trimmed.strip_prefix("::callout").unwrap_or("").trim();
649            let (k, t) = self.parse_callout_attrs(after_callout, input, open_line.span.start);
650            (open_line.span, k, t)
651        };
652
653        let (blocks, end_span) = self.parse_until_fence_close(lexer, input);
654
655        Some(Block::Callout(Callout {
656            kind,
657            title,
658            blocks,
659            span: Span::new(start_span.start, end_span.end),
660        }))
661    }
662
663    #[inline]
664    fn parse_callout_attrs<'a>(
665        &self,
666        s: &str,
667        _input: &'a str,
668        _base: u32,
669    ) -> (CowStr<'a>, Option<CowStr<'a>>) {
670        let mut kind: CowStr<'a> = Cow::Owned("note".to_string());
671        let mut title: Option<CowStr<'a>> = None;
672
673        let mut remaining = s;
674        while !remaining.is_empty() {
675            let eq_pos = match remaining.find('=') {
676                Some(p) => p,
677                None => break,
678            };
679
680            let key = remaining[..eq_pos].trim();
681            remaining = remaining[eq_pos + 1..].trim_start();
682
683            if remaining.starts_with('"') {
684                if let Some(end) = remaining[1..].find('"') {
685                    let val = &remaining[1..end + 1];
686                    remaining = remaining[end + 2..].trim_start();
687
688                    match key {
689                        "type" => kind = Cow::Owned(val.to_string()),
690                        "title" => title = Some(Cow::Owned(val.to_string())),
691                        _ => {}
692                    }
693                } else {
694                    break;
695                }
696            } else {
697                let end = remaining.find(' ').unwrap_or(remaining.len());
698                let val = &remaining[..end];
699                remaining = remaining[end..].trim_start();
700
701                match key {
702                    "type" => kind = Cow::Owned(val.to_string()),
703                    "title" => title = Some(Cow::Owned(val.to_string())),
704                    _ => {}
705                }
706            }
707        }
708
709        (kind, title)
710    }
711
712    #[inline]
713    fn parse_quote_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
714        let start_span = lexer.next_line()?.span;
715        let (blocks, end_span) = self.parse_until_fence_close(lexer, input);
716
717        Some(Block::Quote(Quote {
718            blocks,
719            span: Span::new(start_span.start, end_span.end),
720        }))
721    }
722
723    #[inline]
724    fn parse_figure_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
725        let (start_span, src, alt, caption) = {
726            let open_line = lexer.next_line()?;
727            let text = &input[open_line.span.start as usize..open_line.span.end as usize];
728            let trimmed = text.trim();
729            let after_figure = trimmed.strip_prefix("::figure").unwrap_or("").trim();
730            let (s, a, c) = self.parse_figure_attrs(after_figure);
731            (open_line.span, s, a, c)
732        };
733
734        let mut end_span = start_span;
735        if let Some(line) = lexer.peek_line() {
736            if line.trimmed() == "::" {
737                end_span = line.span;
738                lexer.next_line();
739            }
740        }
741
742        Some(Block::Figure(Figure {
743            src,
744            alt,
745            caption,
746            span: Span::new(start_span.start, end_span.end),
747        }))
748    }
749
750    #[inline]
751    fn parse_figure_attrs<'a>(&self, s: &str) -> (CowStr<'a>, CowStr<'a>, Option<CowStr<'a>>) {
752        let mut src: CowStr<'a> = Cow::Owned(String::new());
753        let mut alt: CowStr<'a> = Cow::Owned(String::new());
754        let mut caption: Option<CowStr<'a>> = None;
755
756        let mut remaining = s;
757        while !remaining.is_empty() {
758            let eq_pos = match remaining.find('=') {
759                Some(p) => p,
760                None => break,
761            };
762
763            let key = remaining[..eq_pos].trim();
764            remaining = remaining[eq_pos + 1..].trim_start();
765
766            if remaining.starts_with('"') {
767                if let Some(end) = remaining[1..].find('"') {
768                    let val = remaining[1..end + 1].to_string();
769                    remaining = remaining[end + 2..].trim_start();
770
771                    match key {
772                        "src" => src = Cow::Owned(val),
773                        "alt" => alt = Cow::Owned(val),
774                        "caption" => caption = Some(Cow::Owned(val)),
775                        _ => {}
776                    }
777                } else {
778                    break;
779                }
780            } else {
781                let end = remaining.find(' ').unwrap_or(remaining.len());
782                let val = remaining[..end].to_string();
783                remaining = remaining[end..].trim_start();
784
785                match key {
786                    "src" => src = Cow::Owned(val),
787                    "alt" => alt = Cow::Owned(val),
788                    "caption" => caption = Some(Cow::Owned(val)),
789                    _ => {}
790                }
791            }
792        }
793
794        (src, alt, caption)
795    }
796
797    #[inline]
798    fn parse_table_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
799        let start_span = lexer.next_line()?.span;
800
801        let mut rows: Vec<TableRow<'a>> = Vec::with_capacity(8);
802        let mut end_span = start_span;
803        let mut found_separator = false;
804
805        loop {
806            let (is_close, is_sep, is_row, span, line_text) = {
807                match lexer.peek_line() {
808                    Some(line) => {
809                        let text = &input[line.span.start as usize..line.span.end as usize];
810                        let trimmed = text.trim();
811                        (
812                            trimmed == "::",
813                            trimmed.starts_with('|') && trimmed.contains("---"),
814                            trimmed.starts_with('|'),
815                            line.span,
816                            trimmed,
817                        )
818                    }
819                    None => break,
820                }
821            };
822
823            if is_close {
824                end_span = span;
825                lexer.next_line();
826                break;
827            }
828
829            if is_sep {
830                found_separator = true;
831                end_span = span;
832                lexer.next_line();
833                continue;
834            }
835
836            if is_row {
837                let cells = self.parse_table_row(line_text, span.start, input);
838                let is_header = !found_separator && rows.is_empty();
839                rows.push(TableRow {
840                    cells,
841                    header: is_header,
842                    span,
843                });
844                end_span = span;
845                lexer.next_line();
846                continue;
847            }
848
849            self.record_error(ParseError::unclosed_delimiter("::table", Some(start_span)));
850            break;
851        }
852
853        Some(Block::Table(Table {
854            rows,
855            span: Span::new(start_span.start, end_span.end),
856        }))
857    }
858
859    #[inline]
860    fn parse_table_row<'a>(
861        &self,
862        line: &'a str,
863        base_offset: u32,
864        input: &'a str,
865    ) -> Vec<TableCell<'a>> {
866        let mut cells = Vec::with_capacity(8);
867        let mut offset = base_offset;
868
869        for (i, part) in line.split('|').enumerate() {
870            if i == 0 {
871                offset += part.len() as u32 + 1;
872                continue;
873            }
874
875            let trimmed = part.trim();
876            if trimmed.is_empty() {
877                offset += part.len() as u32 + 1;
878                continue;
879            }
880
881            let content = crate::inline::parse_inlines(trimmed, offset, input);
882            cells.push(TableCell {
883                content,
884                span: Span::new(offset, offset + part.len() as u32),
885            });
886
887            offset += part.len() as u32 + 1;
888        }
889
890        cells
891    }
892
893    #[inline]
894    fn parse_footnotes_block<'a>(
895        &mut self,
896        lexer: &mut Lexer,
897        input: &'a str,
898    ) -> Option<Block<'a>> {
899        let start_span = lexer.next_line()?.span;
900
901        let mut defs: Vec<FootnoteDef<'a>> = Vec::with_capacity(4);
902        let mut end_span = start_span;
903
904        loop {
905            let (is_close, is_def, span, label, content_text) = {
906                match lexer.peek_line() {
907                    Some(line) => {
908                        let text = &input[line.span.start as usize..line.span.end as usize];
909                        let trimmed = text.trim();
910                        let is_close = trimmed == "::";
911                        let mut is_def = false;
912                        let mut label = "";
913                        let mut content = "";
914
915                        if trimmed.starts_with("[^") {
916                            if let Some(bracket_end) = trimmed.find("]:") {
917                                is_def = true;
918                                label = &trimmed[2..bracket_end];
919                                content = trimmed[bracket_end + 2..].trim();
920                            }
921                        }
922                        (is_close, is_def, line.span, label, content)
923                    }
924                    None => break,
925                }
926            };
927
928            if is_close {
929                end_span = span;
930                lexer.next_line();
931                break;
932            }
933
934            if is_def {
935                let content_inlines = crate::inline::parse_inlines(content_text, span.start, input);
936                defs.push(FootnoteDef {
937                    label: Cow::Owned(label.to_string()),
938                    blocks: vec![Block::Paragraph(Paragraph {
939                        content: content_inlines,
940                        span,
941                    })],
942                    span,
943                });
944            }
945
946            end_span = span;
947            lexer.next_line();
948        }
949
950        Some(Block::Footnotes(Footnotes {
951            defs,
952            span: Span::new(start_span.start, end_span.end),
953        }))
954    }
955
956    #[inline]
957    fn parse_math_block<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
958        let (start_span, display) = {
959            let open_line = lexer.next_line()?;
960            let text = &input[open_line.span.start as usize..open_line.span.end as usize];
961            let trimmed = text.trim();
962            let d = trimmed.contains("block") || trimmed.contains("display");
963            (open_line.span, d)
964        };
965
966        // Content starts after opening fence, clamped to input length
967        let content_start = (start_span.end as usize + 1).min(input.len());
968        let mut content_end = content_start;
969        let mut end_span = start_span;
970
971        loop {
972            let (is_close, span) = {
973                match lexer.peek_line() {
974                    Some(line) => (line.trimmed() == "::", line.span),
975                    None => break,
976                }
977            };
978
979            if is_close {
980                end_span = span;
981                lexer.next_line();
982                break;
983            }
984
985            content_end = span.end as usize;
986            end_span = span;
987            lexer.next_line();
988        }
989
990        let content = if content_start < content_end && content_end <= input.len() {
991            &input[content_start..content_end]
992        } else {
993            ""
994        };
995
996        Some(Block::Math(MathBlock {
997            display,
998            content: Cow::Borrowed(content),
999            span: Span::new(start_span.start, end_span.end),
1000        }))
1001    }
1002
1003    #[inline]
1004    fn parse_raw_fenced_block<'a>(
1005        &mut self,
1006        lexer: &mut Lexer,
1007        input: &'a str,
1008    ) -> Option<Block<'a>> {
1009        let start_span = lexer.next_line()?.span;
1010
1011        // Content starts after opening fence, clamped to input length
1012        let content_start = (start_span.end as usize + 1).min(input.len());
1013        let mut content_end = content_start;
1014        let mut end_span = start_span;
1015
1016        loop {
1017            let (is_close, span) = {
1018                match lexer.peek_line() {
1019                    Some(line) => (line.trimmed() == "::", line.span),
1020                    None => break,
1021                }
1022            };
1023
1024            if is_close {
1025                end_span = span;
1026                lexer.next_line();
1027                break;
1028            }
1029
1030            content_end = span.end as usize;
1031            end_span = span;
1032            lexer.next_line();
1033        }
1034
1035        let content = if content_start < content_end && content_end <= input.len() {
1036            &input[content_start..content_end]
1037        } else {
1038            ""
1039        };
1040
1041        Some(Block::Raw(RawBlock {
1042            content: Cow::Borrowed(content),
1043            span: Span::new(start_span.start, end_span.end),
1044        }))
1045    }
1046
1047    #[inline]
1048    fn parse_until_fence_close<'a>(
1049        &mut self,
1050        lexer: &mut Lexer,
1051        input: &'a str,
1052    ) -> (Vec<Block<'a>>, Span) {
1053        let mut blocks = Vec::with_capacity(4);
1054        let mut para_start: Option<u32> = None;
1055        let mut para_end: u32 = 0;
1056        let mut end_span = Span::new(0, 0);
1057
1058        loop {
1059            let (is_close, is_blank, span) = {
1060                match lexer.next_line() {
1061                    Some(line) => (line.trimmed() == "::", line.is_blank(), line.span),
1062                    None => break,
1063                }
1064            };
1065
1066            if is_close {
1067                if let Some(start) = para_start {
1068                    let content_slice = &input[start as usize..para_end as usize];
1069                    let content = crate::inline::parse_inlines(content_slice, start, input);
1070                    blocks.push(Block::Paragraph(Paragraph {
1071                        content,
1072                        span: Span::new(start, para_end),
1073                    }));
1074                }
1075                end_span = span;
1076                break;
1077            }
1078
1079            if is_blank {
1080                if let Some(start) = para_start.take() {
1081                    let content_slice = &input[start as usize..para_end as usize];
1082                    let content = crate::inline::parse_inlines(content_slice, start, input);
1083                    blocks.push(Block::Paragraph(Paragraph {
1084                        content,
1085                        span: Span::new(start, para_end),
1086                    }));
1087                }
1088            } else {
1089                if para_start.is_none() {
1090                    para_start = Some(span.start);
1091                }
1092                para_end = span.end;
1093            }
1094
1095            end_span = span;
1096        }
1097
1098        (blocks, end_span)
1099    }
1100
1101    #[inline]
1102    fn parse_paragraph<'a>(&mut self, lexer: &mut Lexer, input: &'a str) -> Option<Block<'a>> {
1103        let mut start_span: Option<Span> = None;
1104        let mut end_span = Span::new(0, 0);
1105
1106        loop {
1107            let should_break = {
1108                match lexer.peek_line() {
1109                    Some(line) => {
1110                        if line.is_blank() {
1111                            true
1112                        } else {
1113                            let trimmed = line.trimmed();
1114                            let first = trimmed.as_bytes().first().copied();
1115                            match first {
1116                                Some(b'#') | Some(b':') => true,
1117                                Some(b'`') if trimmed.starts_with("```") => true,
1118                                Some(b'-') if trimmed == "---" => true,
1119                                _ => false,
1120                            }
1121                        }
1122                    }
1123                    None => true,
1124                }
1125            };
1126
1127            if should_break {
1128                break;
1129            }
1130
1131            let line = lexer.next_line().unwrap();
1132            if start_span.is_none() {
1133                start_span = Some(line.span);
1134            }
1135            end_span = line.span;
1136        }
1137
1138        let start = start_span?;
1139        let content_slice = &input[start.start as usize..end_span.end as usize];
1140        let content = crate::inline::parse_inlines(content_slice, start.start, input);
1141
1142        Some(Block::Paragraph(Paragraph {
1143            content,
1144            span: Span::new(start.start, end_span.end),
1145        }))
1146    }
1147}