Skip to main content

cortexkit_rich_ui_syntax/
parser.rs

1use crate::expression::{literal, reply_value, ExprParser, Result};
2use crate::*;
3use std::collections::{HashMap, HashSet};
4
5pub(crate) struct Engine<'s> {
6    pub source: &'s str,
7    pub mode: Mode,
8    nodes: usize,
9    pub blocks: usize,
10    tags: usize,
11    declarations: usize,
12    names: HashSet<String>,
13    remaining_work: usize,
14    diagnostics: Vec<Diagnostic>,
15    suppressed: usize,
16    pub fatal: bool,
17    body_depth: usize,
18    pub expression_frames: usize,
19    structural_failures: usize,
20}
21
22impl<'s> Engine<'s> {
23    pub fn work(&mut self, count: usize) -> Result<()> {
24        if count > self.remaining_work {
25            self.fatal = true;
26            return Err("E_PARSE_WORK");
27        }
28        self.remaining_work -= count;
29        Ok(())
30    }
31    pub fn report(&mut self, code: &'static str, span: Span) {
32        if self.diagnostics.len() < DIAGNOSTICS {
33            self.diagnostics.push(Diagnostic { code, span });
34        } else {
35            self.suppressed += 1;
36        }
37    }
38    pub fn node(&mut self, span: Span, kind: Kind, children: Vec<Node>) -> Result<Node> {
39        self.nodes += 1;
40        if self.nodes > AST_NODES {
41            self.fatal = true;
42            return Err("E_LIMIT_AST");
43        }
44        Ok(Node {
45            span,
46            kind,
47            children,
48        })
49    }
50    fn opaque(&mut self, start: usize, end: usize) -> Result<Node> {
51        self.node(
52            Span::new(start, end),
53            Kind::Literal(self.source[start..end].replace('\0', "\u{fffd}")),
54            vec![],
55        )
56    }
57    fn expr(&mut self, start: usize, end: usize, tag_depth: usize) -> Result<(Node, usize)> {
58        let mut p = ExprParser {
59            engine: self,
60            pos: start,
61            end,
62            tag_depth,
63        };
64        let node = p.expression(0, 0)?;
65        p.ws();
66        Ok((node, p.pos))
67    }
68    /// Opening-tag errors have no component span. Keep just the candidate prefix
69    /// opaque, stopping at the first blank line so later paragraphs remain live.
70    fn candidate_end(&mut self, start: usize, end: usize) -> usize {
71        let mut pos = start;
72        let mut quote = None;
73        let mut escaped = false;
74        let mut braces = 0;
75        while pos < end {
76            if self.work(1).is_err() {
77                return pos;
78            }
79            if at_line_start(self.source, pos)
80                && self.source[pos..line_content_end(self.source, pos, end)]
81                    .trim()
82                    .is_empty()
83            {
84                return pos;
85            }
86            let c = self.source.as_bytes()[pos];
87            if escaped {
88                escaped = false;
89            } else if c == b'\\' {
90                escaped = true;
91            } else if let Some(q) = quote {
92                if c == q {
93                    quote = None;
94                }
95            } else if matches!(c, b'\'' | b'"') {
96                quote = Some(c);
97            } else if c == b'{' {
98                braces += 1;
99            } else if c == b'}' {
100                braces = (braces - 1).max(0);
101            } else if c == b'>' && braces == 0 {
102                return pos + 1;
103            }
104            pos += self.source[pos..end]
105                .chars()
106                .next()
107                .map_or(1, char::len_utf8);
108        }
109        end
110    }
111    pub fn tag(
112        &mut self,
113        start: usize,
114        end: usize,
115        depth: usize,
116        block_candidate: bool,
117    ) -> Result<(Node, usize, bool)> {
118        let structural_before = self.structural_failures;
119        let opening = self.open_tag(start, end, depth);
120        let (name, props, open_end, self_closing) = match opening {
121            Ok(value) => value,
122            Err(code) => {
123                if code == "E_NESTING" {
124                    self.structural_failures += 1;
125                }
126                let stop = self.candidate_end(start, end).max(start + 1);
127                self.report(code, Span::new(start, stop));
128                return Ok((self.opaque(start, stop)?, stop, true));
129            }
130        };
131        self.tags += 1;
132        let over_tags = self.mode == Mode::Reply && self.tags > 64;
133        if self.tags == 65 && self.mode == Mode::Reply {
134            self.report("E_LIMIT_TAGS", Span::new(start, open_end));
135        }
136        let block = block_candidate
137            && self.source[open_end..line_content_end(self.source, open_end, end)]
138                .trim()
139                .is_empty();
140        let mut stop = open_end;
141        let mut children = vec![];
142        let mut broken = false;
143        if !self_closing {
144            let (content, content_end, close) =
145                self.body(open_end, end, depth, !block, true, true)?;
146            children = content;
147            stop = content_end;
148            match close {
149                Some((close_name, close_end)) => {
150                    stop = close_end;
151                    if close_name != name {
152                        broken = true;
153                        self.report("E_MISMATCHED_CLOSE", Span::new(start, stop));
154                    }
155                    if block {
156                        let tail = line_content_end(self.source, close_end, end);
157                        if !block_position(self.source, content_end)
158                            || !self.source[close_end..tail].trim().is_empty()
159                        {
160                            broken = true;
161                            self.report("E_MISMATCHED_CLOSE", Span::new(start, stop));
162                        }
163                    }
164                }
165                None => {
166                    broken = true;
167                    self.report("E_UNCLOSED_TAG", Span::new(start, stop));
168                }
169            }
170        }
171        if depth > TAG_DEPTH {
172            broken = true;
173            self.report("E_NESTING", Span::new(start, stop));
174        }
175        if broken {
176            self.structural_failures += 1;
177        }
178        if !children.is_empty() && props.iter().any(|p| p.name == "children") {
179            broken = true;
180            self.report("E_DUPLICATE_PROP", Span::new(start, stop));
181        }
182        if self.mode == Mode::Reply && props.iter().any(|p| !reply_value(&p.value)) {
183            broken = true;
184            self.report("E_REPLY_FORBIDDEN", Span::new(start, stop));
185        }
186        // A crossed or missing close invalidates the outer component span. A
187        // child's forbidden prop, by contrast, is a local literal-text failure.
188        if self.structural_failures > structural_before {
189            broken = true;
190        }
191        if broken || over_tags {
192            return Ok((self.opaque(start, stop)?, stop, true));
193        }
194        let node = self.node(
195            Span::new(start, stop),
196            Kind::Tag { name, block, props },
197            children,
198        )?;
199        Ok((node, stop, false))
200    }
201    fn open_tag(
202        &mut self,
203        start: usize,
204        end: usize,
205        depth: usize,
206    ) -> Result<(String, Vec<Prop>, usize, bool)> {
207        if depth > TAG_DEPTH {
208            return Err("E_NESTING");
209        }
210        let bytes = self.source.as_bytes();
211        if bytes.get(start) != Some(&b'<')
212            || !bytes.get(start + 1).is_some_and(u8::is_ascii_uppercase)
213        {
214            return Err("E_SYNTAX");
215        }
216        let mut pos = start + 2;
217        while pos < end && bytes[pos].is_ascii_alphanumeric() {
218            pos += 1;
219        }
220        let name = self.source[start + 1..pos].to_owned();
221        let mut props = Vec::new();
222        let mut unique = HashSet::new();
223        loop {
224            self.work(1)?;
225            let before = pos;
226            while pos < end && bytes[pos].is_ascii_whitespace() {
227                pos += 1;
228            }
229            if has_blank_line(&self.source[before..pos]) {
230                return Err("E_SYNTAX");
231            }
232            if self.source[pos..end].starts_with("/>") {
233                return Ok((name, props, pos + 2, true));
234            }
235            if self.source[pos..end].starts_with('>') {
236                return Ok((name, props, pos + 1, false));
237            }
238            if before == pos {
239                return Err("E_SYNTAX");
240            }
241            let prop_start = pos;
242            let mut p = ExprParser {
243                engine: self,
244                pos,
245                end,
246                tag_depth: depth,
247            };
248            let prop_name = p.identifier()?;
249            if !unique.insert(prop_name.clone()) {
250                return Err("E_DUPLICATE_PROP");
251            }
252            let name_end = p.pos;
253            p.ws();
254            let value = if p.eat("=") {
255                p.ws();
256                if p.rest_is_string() {
257                    let start = p.pos;
258                    let text = p.string()?;
259                    p.engine.node(
260                        Span::new(start, p.pos),
261                        Kind::Expression(Expression::String(text)),
262                        vec![],
263                    )?
264                } else {
265                    if !p.eat("{") {
266                        return Err("E_SYNTAX");
267                    }
268                    let node = p.expression(0, 0)?;
269                    if !p.eat("}") {
270                        return Err("E_SYNTAX");
271                    }
272                    node
273                }
274            } else {
275                p.pos = name_end;
276                p.engine.node(
277                    Span::new(prop_start, p.pos),
278                    Kind::Expression(Expression::Boolean(true)),
279                    vec![],
280                )?
281            };
282            pos = p.pos;
283            if has_blank_line(&self.source[prop_start..pos]) {
284                return Err("E_SYNTAX");
285            }
286            props.push(Prop {
287                name: prop_name,
288                span: Span::new(prop_start, pos),
289                value,
290            });
291        }
292    }
293    fn declaration_end(&mut self, start: usize, end: usize) -> Result<usize> {
294        let mut stop = line_content_end(self.source, start, end);
295        while stop < end {
296            self.work(stop - start + 1)?;
297            let next_start = line_end(self.source, stop, end);
298            let next_end = line_content_end(self.source, next_start, end);
299            if self.source[next_start..next_end].trim().is_empty() {
300                break;
301            }
302            let text = self.source[start..stop].trim_end();
303            let next = self.source[next_start..next_end].trim_start();
304            let continuation = declaration_continues(text)
305                || ["?", ":", "&&", "||", "??", "."]
306                    .iter()
307                    .any(|x| next.starts_with(x));
308            if !continuation {
309                break;
310            }
311            stop = next_end;
312        }
313        Ok(stop)
314    }
315    fn declaration(
316        &mut self,
317        start: usize,
318        stop: usize,
319        tag_depth: usize,
320    ) -> Result<(Node, usize)> {
321        self.declarations += 1;
322        if self.declarations > DECLARATIONS {
323            return Err("E_LIMIT_DECLARATIONS");
324        }
325        let mut p = ExprParser {
326            engine: self,
327            pos: start + 6,
328            end: stop,
329            tag_depth,
330        };
331        let state = if p.eat("let") {
332            true
333        } else if p.eat("const") {
334            false
335        } else {
336            return Err("E_SYNTAX");
337        };
338        let before = p.pos;
339        p.ws();
340        if p.pos == before {
341            return Err("E_SYNTAX");
342        }
343        if state && !p.eat("$") {
344            return Err("E_SYNTAX");
345        }
346        if state
347            && p.engine
348                .source
349                .as_bytes()
350                .get(p.pos)
351                .is_some_and(u8::is_ascii_whitespace)
352        {
353            return Err("E_SYNTAX");
354        }
355        let name = p.identifier()?;
356        if name.as_bytes()[0].is_ascii_uppercase()
357            || matches!(name.as_str(), "data" | "input" | "view" | "now" | "zone")
358        {
359            return Err("E_RESERVED_NAME");
360        }
361        if !p.engine.names.insert(name.clone()) {
362            return Err("E_DUPLICATE_NAME");
363        }
364        if !p.eat("=") {
365            return Err("E_SYNTAX");
366        }
367        let child = p.expression(0, 0).map_err(|code| {
368            if code == "E_SYNTAX" {
369                "E_DECLARATION_INCOMPLETE"
370            } else {
371                code
372            }
373        })?;
374        p.ws();
375        if p.pos != stop {
376            return Err("E_SYNTAX");
377        }
378        if state && !literal(&child) {
379            return Err("E_LITERAL_REQUIRED");
380        }
381        Ok((
382            p.engine.node(
383                Span::new(start, stop),
384                Kind::Declaration { name, state },
385                vec![child],
386            )?,
387            stop,
388        ))
389    }
390    #[allow(clippy::too_many_arguments, clippy::type_complexity)]
391    pub fn body(
392        &mut self,
393        start: usize,
394        end: usize,
395        depth: usize,
396        inline: bool,
397        allow_tags: bool,
398        stop_at_close: bool,
399    ) -> Result<(Vec<Node>, usize, Option<(String, usize)>)> {
400        self.body_depth += 1;
401        if self.body_depth > 64 {
402            self.fatal = true;
403            return Err("E_NESTING");
404        }
405        self.work(end - start)?;
406        let contexts = markdown_contexts(self.source, start, end);
407        let code_matches = &contexts.code;
408        let mut specials = Vec::new();
409        let mut pos = start;
410        let mut close = None;
411        while pos < end {
412            self.work(1)?;
413            let rest = &self.source[pos..end];
414            if inline
415                && at_line_start(self.source, pos)
416                && self.source[pos..line_content_end(self.source, pos, end)]
417                    .trim()
418                    .is_empty()
419            {
420                break;
421            }
422            if let Some(stop) = thematic(self.source, pos, end) {
423                specials.push(self.node(
424                    Span::new(pos, stop),
425                    Kind::Markdown(Markdown::ThematicBreak),
426                    vec![],
427                )?);
428                pos = stop;
429                continue;
430            }
431            if let Some((language, fence_end, text)) =
432                contexts.fences.get(&pos).cloned().or_else(|| {
433                    fence(self.source, pos, end).map(|(language, stop)| {
434                        (language, stop, fence_content(self.source, pos, stop))
435                    })
436                })
437            {
438                let node = self.node(
439                    Span::new(pos, fence_end),
440                    Kind::Markdown(Markdown::CodeBlock { language, text }),
441                    vec![],
442                )?;
443                specials.push(node);
444                pos = fence_end;
445                continue;
446            }
447            if let Some(tail) = rest.strip_prefix('\\') {
448                if let Some(c) = tail.chars().next() {
449                    if c.is_ascii_punctuation() || c == '\n' || c == '\r' {
450                        pos += 1 + c.len_utf8();
451                        continue;
452                    }
453                }
454            }
455            if rest.starts_with('`') {
456                if let Some(&stop) = code_matches.get(&pos) {
457                    specials.push(self.node(
458                        Span::new(pos, stop),
459                        Kind::Markdown(Markdown::Code(code_text(&self.source[pos..stop]))),
460                        vec![],
461                    )?);
462                    pos = stop;
463                    continue;
464                }
465                pos += rest.bytes().take_while(|b| *b == b'`').count();
466                continue;
467            }
468            if stop_at_close && rest.starts_with("</") {
469                if let Some((name, stop)) = close_tag(self.source, pos, end) {
470                    close = Some((name, stop));
471                    break;
472                }
473            }
474            let link_start = if rest.starts_with("![") {
475                Some(pos + 1)
476            } else if rest.starts_with('[') {
477                Some(pos)
478            } else {
479                None
480            };
481            if let Some(bracket) = link_start.filter(|_| contexts.links.contains_key(&pos)) {
482                if let Some((label_end, stop, _, _)) = self.link(bracket, end, code_matches)? {
483                    let (children, _, _) =
484                        self.body(bracket + 1, label_end, depth, true, false, false)?;
485                    specials.push(self.node(
486                        Span::new(pos, stop),
487                        Kind::Markdown(Markdown::Link {
488                            destination: contexts.links[&pos].0.clone(),
489                            title: contexts.links[&pos].1.clone(),
490                            image: bracket != pos,
491                        }),
492                        children,
493                    )?);
494                    pos = stop;
495                    continue;
496                }
497            }
498            if rest.starts_with("<http://") || rest.starts_with("<https://") {
499                let found = rest.find('>');
500                self.work(found.unwrap_or(rest.len()))?;
501                if let Some(offset) = found {
502                    let stop = pos + offset + 1;
503                    specials.push(self.node(
504                        Span::new(pos, stop),
505                        Kind::Markdown(Markdown::Link {
506                            destination: rest[1..offset].to_owned(),
507                            title: String::new(),
508                            image: false,
509                        }),
510                        vec![],
511                    )?);
512                    pos = stop;
513                    continue;
514                }
515            }
516            if rest.starts_with("https://") || rest.starts_with("http://") {
517                let len = rest
518                    .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>'))
519                    .unwrap_or(rest.len());
520                let stop = pos + len;
521                specials.push(self.node(
522                    Span::new(pos, stop),
523                    Kind::Markdown(Markdown::Link {
524                        destination: self.source[pos..stop].to_owned(),
525                        title: String::new(),
526                        image: false,
527                    }),
528                    vec![],
529                )?);
530                pos = stop;
531                continue;
532            }
533            if allow_tags
534                && rest.starts_with('<')
535                && rest.as_bytes().get(1).is_some_and(u8::is_ascii_uppercase)
536            {
537                let line_start = self.source[..pos].rfind(['\r', '\n']).map_or(0, |i| i + 1);
538                self.work(pos - line_start)?;
539                let block = !inline && block_position(self.source, pos);
540                let (node, stop, _) = self.tag(pos, end, depth + 1, block)?;
541                specials.push(node);
542                pos = stop;
543                continue;
544            }
545            if at_line_start(self.source, pos) && declaration_start(rest) {
546                let stop = self.declaration_end(pos, end)?;
547                if self.mode == Mode::Reply {
548                    specials.push(self.opaque(pos, stop)?);
549                    pos = stop;
550                    continue;
551                }
552                match self.declaration(pos, stop, depth) {
553                    Ok((node, stop)) => {
554                        specials.push(node);
555                        pos = stop;
556                        continue;
557                    }
558                    Err(code) => {
559                        self.report(code, Span::new(pos, stop));
560                        specials.push(self.opaque(pos, stop)?);
561                        pos = stop;
562                        continue;
563                    }
564                }
565            }
566            if self.mode == Mode::Reply && rest.starts_with('{') {
567                let stop = self.literal_braces(pos, end)?;
568                specials.push(self.opaque(pos, stop)?);
569                pos = stop;
570                continue;
571            }
572            if self.mode == Mode::Program && rest.starts_with('{') {
573                match self.expr(pos + 1, end, depth) {
574                    Ok((node, next))
575                        if self.source[next..end].starts_with('}')
576                            && !has_blank_line(&self.source[pos..next]) =>
577                    {
578                        let stop = next + 1;
579                        let block = !inline
580                            && block_position(self.source, pos)
581                            && self.source[stop..line_content_end(self.source, stop, end)]
582                                .trim()
583                                .is_empty();
584                        specials.push(self.node(
585                            Span::new(pos, stop),
586                            Kind::Interpolation { block },
587                            vec![node],
588                        )?);
589                        pos = stop;
590                        continue;
591                    }
592                    Err(code) => self.report(code, Span::new(pos, pos + 1)),
593                    _ => self.report("E_SYNTAX", Span::new(pos, pos + 1)),
594                }
595                let stop = self.literal_braces(pos, end)?;
596                specials.push(self.opaque(pos, stop)?);
597                pos = stop;
598                continue;
599            }
600            if let Some(stop) = reference_end(&self.source[..end], pos) {
601                specials.push(self.node(
602                    Span::new(pos, stop),
603                    Kind::Reference {
604                        n: self.source[pos + 5..stop - 1].to_owned(),
605                    },
606                    vec![],
607                )?);
608                pos = stop;
609                continue;
610            }
611            pos += rest.chars().next().map_or(1, char::len_utf8);
612        }
613        let nodes = crate::markdown::assemble(self, start, pos, specials, inline)?;
614        self.body_depth -= 1;
615        Ok((nodes, pos, close))
616    }
617    fn literal_braces(&mut self, start: usize, end: usize) -> Result<usize> {
618        let mut depth = 1;
619        let mut pos = start + 1;
620        while pos < end {
621            self.work(1)?;
622            if at_line_start(self.source, pos)
623                && self.source[pos..line_content_end(self.source, pos, end)]
624                    .trim()
625                    .is_empty()
626            {
627                break;
628            }
629            match self.source.as_bytes()[pos] {
630                b'\\' => {
631                    pos = next_char(self.source, pos + 1, end);
632                    continue;
633                }
634                b'{' => depth += 1,
635                b'}' => {
636                    depth -= 1;
637                    if depth == 0 {
638                        return Ok(pos + 1);
639                    }
640                }
641                _ => {}
642            }
643            pos = next_char(self.source, pos, end);
644        }
645        Ok(pos)
646    }
647    fn link(
648        &mut self,
649        start: usize,
650        end: usize,
651        code: &HashMap<usize, usize>,
652    ) -> Result<Option<(usize, usize, String, String)>> {
653        let mut pos = start + 1;
654        let mut depth = 1;
655        while pos < end {
656            self.work(1)?;
657            if let Some(stop) = code.get(&pos) {
658                pos = *stop;
659                continue;
660            }
661            let c = self.source.as_bytes()[pos];
662            if c == b'\\' {
663                pos = next_char(self.source, pos + 1, end);
664                continue;
665            }
666            if c == b'[' {
667                depth += 1;
668            }
669            if c == b']' {
670                depth -= 1;
671                if depth == 0 {
672                    break;
673                }
674            }
675            pos = next_char(self.source, pos, end);
676        }
677        if pos >= end || !self.source[pos..end].starts_with("](") {
678            return Ok(None);
679        }
680        let label_end = pos;
681        pos += 2;
682        let dest_start = pos;
683        let mut nesting = 1;
684        let mut quote = None;
685        while pos < end {
686            self.work(1)?;
687            let c = self.source.as_bytes()[pos];
688            if c == b'\\' {
689                pos = next_char(self.source, pos + 1, end);
690                continue;
691            }
692            if let Some(q) = quote {
693                if c == q {
694                    quote = None;
695                }
696            } else if matches!(c, b'\'' | b'"') {
697                quote = Some(c);
698            } else if c == b'(' {
699                nesting += 1;
700            } else if c == b')' {
701                nesting -= 1;
702                if nesting == 0 {
703                    break;
704                }
705            }
706            pos = next_char(self.source, pos, end);
707        }
708        if pos == end || has_blank_line(&self.source[dest_start..pos]) {
709            return Ok(None);
710        }
711        let raw = self.source[dest_start..pos].trim();
712        let split = raw.find(char::is_whitespace).unwrap_or(raw.len());
713        Ok(Some((
714            label_end,
715            pos + 1,
716            raw[..split].trim_matches(['<', '>']).to_owned(),
717            raw[split..].trim().trim_matches(['\'', '"']).to_owned(),
718        )))
719    }
720}
721
722impl ExprParser<'_, '_> {
723    fn rest_is_string(&self) -> bool {
724        self.engine.source[self.pos..self.end].starts_with(['\'', '"'])
725    }
726}
727
728fn declaration_start(rest: &str) -> bool {
729    rest.strip_prefix("export")
730        .filter(|s| s.starts_with([' ', '\t']))
731        .is_some_and(|s| {
732            let s = s.trim_start_matches([' ', '\t']);
733            ["let", "const"].iter().any(|k| {
734                s.strip_prefix(k)
735                    .is_some_and(|tail| tail.starts_with([' ', '\t']))
736            })
737        })
738}
739fn declaration_continues(text: &str) -> bool {
740    let mut depth = 0_i32;
741    let mut tags = 0_i32;
742    let mut header = false;
743    let mut closing = false;
744    let mut header_braces = 0_i32;
745    let mut quote = None;
746    let mut escaped = false;
747    let mut operand = true;
748    let mut previous = '\0';
749    let mut chars = text.chars().peekable();
750    while let Some(c) = chars.next() {
751        if escaped {
752            escaped = false;
753            continue;
754        }
755        if c == '\\' {
756            escaped = true;
757            continue;
758        }
759        if let Some(q) = quote {
760            if c == q {
761                quote = None;
762                operand = false;
763            }
764            continue;
765        }
766        if (header || tags == 0) && matches!(c, '\'' | '"') {
767            quote = Some(c);
768            continue;
769        }
770        if c == '<'
771            && !header
772            && (tags > 0 || operand)
773            && chars
774                .peek()
775                .is_some_and(|c| c.is_ascii_uppercase() || *c == '/')
776        {
777            closing = chars.peek() == Some(&'/');
778            tags += if closing { -1 } else { 1 };
779            header = true;
780            header_braces = 0;
781        } else if header {
782            if c == '{' {
783                header_braces += 1;
784            }
785            if c == '}' {
786                header_braces -= 1;
787            }
788            if c == '>' && header_braces == 0 {
789                if !closing && previous == '/' {
790                    tags -= 1;
791                }
792                header = false;
793                operand = false;
794            }
795        } else if tags == 0 {
796            match c {
797                '(' | '[' | '{' => {
798                    depth += 1;
799                    operand = true;
800                }
801                ')' | ']' | '}' => {
802                    depth -= 1;
803                    operand = false;
804                }
805                '+' | '-' | '*' | '/' | '%' | '?' | ':' | ',' | '=' | '!' | '<' | '>' | '&'
806                | '|' => operand = true,
807                c if !c.is_whitespace() => operand = false,
808                _ => {}
809            }
810        }
811        if !c.is_whitespace() {
812            previous = c;
813        }
814    }
815    depth > 0 || tags > 0 || header || operand
816}
817pub(crate) fn line_content_end(source: &str, pos: usize, end: usize) -> usize {
818    source[pos..end].find(['\r', '\n']).map_or(end, |i| pos + i)
819}
820fn line_end(source: &str, pos: usize, end: usize) -> usize {
821    let stop = line_content_end(source, pos, end);
822    if stop == end {
823        end
824    } else if source[stop..end].starts_with("\r\n") {
825        stop + 2
826    } else {
827        stop + 1
828    }
829}
830fn at_line_start(source: &str, pos: usize) -> bool {
831    pos == 0
832        || (pos == 3 && source.starts_with('\u{feff}'))
833        || source
834            .as_bytes()
835            .get(pos - 1)
836            .is_some_and(|c| matches!(c, b'\n' | b'\r'))
837}
838fn block_position(source: &str, pos: usize) -> bool {
839    let start = source[..pos]
840        .rfind(['\r', '\n'])
841        .map_or(if source.starts_with('\u{feff}') { 3 } else { 0 }, |p| {
842            p + 1
843        });
844    let mut prefix = &source[start..pos];
845    for _ in 0..=MARKDOWN_DEPTH {
846        let trimmed = prefix.trim_start_matches(' ');
847        if prefix.len() - trimmed.len() > 3 {
848            return false;
849        }
850        prefix = trimmed;
851        if prefix.is_empty() {
852            return true;
853        }
854        if let Some(tail) = prefix.strip_prefix('>') {
855            prefix = tail.strip_prefix(' ').unwrap_or(tail);
856            continue;
857        }
858        let marker = if prefix.starts_with(['-', '+', '*']) {
859            1
860        } else {
861            let digits = prefix.bytes().take_while(u8::is_ascii_digit).count();
862            if digits == 0 || digits > 9 || !prefix[digits..].starts_with(['.', ')']) {
863                return false;
864            }
865            digits + 1
866        };
867        if !prefix[marker..].starts_with([' ', '\t']) {
868            return false;
869        }
870        prefix = prefix[marker..].trim_start_matches([' ', '\t']);
871    }
872    false
873}
874pub(crate) fn has_blank_line(text: &str) -> bool {
875    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
876    normalized
877        .split('\n')
878        .skip(1)
879        .take(normalized.split('\n').count().saturating_sub(2))
880        .any(|line| line.trim().is_empty())
881}
882fn next_char(source: &str, pos: usize, end: usize) -> usize {
883    pos + source[pos..end].chars().next().map_or(0, char::len_utf8)
884}
885fn close_tag(source: &str, start: usize, end: usize) -> Option<(String, usize)> {
886    let tail = source.get(start + 2..end)?;
887    if !tail.as_bytes().first()?.is_ascii_uppercase() {
888        return None;
889    }
890    let len = tail.bytes().take_while(u8::is_ascii_alphanumeric).count();
891    let rest = tail[len..].trim_start_matches(char::is_whitespace);
892    if !rest.starts_with('>') {
893        return None;
894    }
895    Some((tail[..len].to_owned(), end - rest.len() + 1))
896}
897fn fence(source: &str, pos: usize, end: usize) -> Option<(String, usize)> {
898    if !at_line_start(source, pos) {
899        return None;
900    }
901    let line_end_pos = line_content_end(source, pos, end);
902    let line = &source[pos..line_end_pos];
903    let trim = line.trim_start_matches(' ');
904    if line.len() - trim.len() > 3 {
905        return None;
906    }
907    let ch = *trim.as_bytes().first()?;
908    if !matches!(ch, b'`' | b'~') {
909        return None;
910    }
911    let count = trim.bytes().take_while(|b| *b == ch).count();
912    if count < 3 || (ch == b'`' && trim[count..].contains('`')) {
913        return None;
914    }
915    let language = trim[count..]
916        .split_whitespace()
917        .next()
918        .unwrap_or("")
919        .to_owned();
920    let mut next = line_end(source, pos, end);
921    while next < end {
922        let stop = line_content_end(source, next, end);
923        let line = &source[next..stop];
924        let trim = line.trim_start_matches(' ');
925        if line.len() - trim.len() <= 3 {
926            let run = trim.bytes().take_while(|b| *b == ch).count();
927            if run >= count && trim[run..].trim().is_empty() {
928                return Some((language, line_end(source, next, end)));
929            }
930        }
931        next = line_end(source, next, end);
932    }
933    Some((language, end))
934}
935fn code_text(source: &str) -> String {
936    let run = source.bytes().take_while(|b| *b == b'`').count();
937    let text = source[run..source.len() - run]
938        .replace("\r\n", " ")
939        .replace(['\r', '\n'], " ");
940    if text.starts_with(' ') && text.ends_with(' ') && text.chars().any(|c| c != ' ') {
941        text[1..text.len() - 1].to_owned()
942    } else {
943        text
944    }
945}
946fn thematic(source: &str, pos: usize, end: usize) -> Option<usize> {
947    if !at_line_start(source, pos) {
948        return None;
949    }
950    let stop = line_content_end(source, pos, end);
951    let line = &source[pos..stop];
952    let trim = line.trim_start_matches(' ');
953    if line.len() - trim.len() > 3 {
954        return None;
955    }
956    let mark = *trim.as_bytes().first()?;
957    if !matches!(mark, b'-' | b'*' | b'_')
958        || trim.bytes().filter(|b| *b == mark).count() < 3
959        || !trim.bytes().all(|b| b == mark || matches!(b, b' ' | b'\t'))
960    {
961        return None;
962    }
963    Some(stop)
964}
965#[derive(Default)]
966struct Contexts {
967    code: HashMap<usize, usize>,
968    fences: HashMap<usize, (String, usize, String)>,
969    links: HashMap<usize, (String, String)>,
970}
971fn markdown_contexts(source: &str, start: usize, end: usize) -> Contexts {
972    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
973    let masked = crate::markdown::context_source(&source[start..end]);
974    let mut contexts = Contexts::default();
975    let mut current: Option<(usize, String, usize, String)> = None;
976    for (event, range) in Parser::new_ext(
977        &masked,
978        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS,
979    )
980    .into_offset_iter()
981    {
982        match event {
983            Event::Code(_) => {
984                contexts.code.insert(start + range.start, start + range.end);
985            }
986            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
987                current = Some((
988                    start + range.start,
989                    info.split_whitespace().next().unwrap_or("").to_owned(),
990                    start + range.end,
991                    String::new(),
992                ));
993            }
994            Event::Text(text) if current.is_some() => {
995                if let Some((_, _, _, content)) = &mut current {
996                    content.push_str(&text);
997                }
998            }
999            Event::End(TagEnd::CodeBlock) => {
1000                if let Some((pos, language, stop, text)) = current.take() {
1001                    contexts.fences.insert(
1002                        pos,
1003                        (
1004                            language,
1005                            stop,
1006                            text.replace('\u{1}', "<")
1007                                .replace('\u{2}', " ")
1008                                .replace('\u{3}', "=")
1009                                .replace('\u{4}', "["),
1010                        ),
1011                    );
1012                }
1013            }
1014            Event::Start(
1015                Tag::Link {
1016                    dest_url, title, ..
1017                }
1018                | Tag::Image {
1019                    dest_url, title, ..
1020                },
1021            ) => {
1022                contexts.links.insert(
1023                    start + range.start,
1024                    (dest_url.into_string(), title.into_string()),
1025                );
1026            }
1027            _ => {}
1028        }
1029    }
1030    contexts
1031}
1032fn fence_content(source: &str, start: usize, end: usize) -> String {
1033    let first = line_end(source, start, end);
1034    let tail = source[first..end].trim_end_matches(['\r', '\n']);
1035    let last = tail.rfind(['\r', '\n']).map_or(first, |i| first + i + 1);
1036    let candidate = source[last..end].trim();
1037    let content_end = if candidate.bytes().all(|b| matches!(b, b'`' | b'~')) {
1038        last
1039    } else {
1040        end
1041    };
1042    source[first..content_end]
1043        .replace("\r\n", "\n")
1044        .replace('\r', "\n")
1045}
1046
1047pub fn parse(source: &str, mode: Mode) -> Document {
1048    let cap = if mode == Mode::Reply {
1049        REPLY_BYTES
1050    } else {
1051        PROGRAM_BYTES
1052    };
1053    let mut end = source.len().min(cap);
1054    while !source.is_char_boundary(end) {
1055        end -= 1;
1056    }
1057    let mut engine = Engine {
1058        source,
1059        mode,
1060        nodes: 0,
1061        blocks: 0,
1062        tags: 0,
1063        declarations: 0,
1064        names: HashSet::new(),
1065        remaining_work: end.saturating_mul(128).max(1024),
1066        diagnostics: vec![],
1067        suppressed: 0,
1068        fatal: false,
1069        body_depth: 0,
1070        expression_frames: 0,
1071        structural_failures: 0,
1072    };
1073    let mut fallback = mode == Mode::Program && source.len() > cap;
1074    if fallback {
1075        engine.report("E_SOURCE_TOO_LARGE", Span::new(0, end));
1076    }
1077    let start = if source.starts_with('\u{feff}') {
1078        3.min(end)
1079    } else {
1080        0
1081    };
1082    let mut nodes = if fallback {
1083        vec![]
1084    } else {
1085        match engine.body(start, end, 0, false, true, false) {
1086            Ok((nodes, _, _)) => nodes,
1087            Err(code) => {
1088                engine.report(code, Span::new(start, end));
1089                fallback = true;
1090                vec![]
1091            }
1092        }
1093    };
1094    if engine.fatal {
1095        fallback = true;
1096    }
1097    if mode == Mode::Reply && text_scalars(&nodes) > OUTPUT_SCALARS {
1098        engine.report("E_LIMIT_TEXT", Span::new(start, end));
1099        fallback = true;
1100    }
1101    if fallback {
1102        nodes = vec![Node {
1103            span: Span::new(start, end),
1104            kind: Kind::Literal(
1105                source[start..end]
1106                    .chars()
1107                    .take(OUTPUT_SCALARS)
1108                    .map(|c| if c == '\0' { '\u{fffd}' } else { c })
1109                    .collect(),
1110            ),
1111            children: vec![],
1112        }];
1113    }
1114    let mut doc = Document {
1115        mode,
1116        nodes,
1117        diagnostics: engine.diagnostics,
1118        suppressed_diagnostics: engine.suppressed,
1119        parsed_bytes: end,
1120        shortened: source.len() > cap,
1121        fallback,
1122    };
1123    if doc.references().len() > REFERENCES {
1124        add_diagnostic(&mut doc, "E_REFERENCE_LIMIT", Span::new(start, end));
1125    }
1126    if count_handles(&doc.nodes) > HANDLES {
1127        add_diagnostic(&mut doc, "E_REFERENCE_LIMIT", Span::new(start, end));
1128    }
1129    doc
1130}
1131fn add_diagnostic(doc: &mut Document, code: &'static str, span: Span) {
1132    if doc.diagnostics.len() < DIAGNOSTICS {
1133        doc.diagnostics.push(Diagnostic { code, span });
1134    } else {
1135        doc.suppressed_diagnostics += 1;
1136    }
1137}
1138fn count_handles(nodes: &[Node]) -> usize {
1139    nodes
1140        .iter()
1141        .map(|n| {
1142            usize::from(matches!(n.kind, Kind::Handle { .. }))
1143                + count_handles(&n.children)
1144                + match &n.kind {
1145                    Kind::Tag { props, .. } => props
1146                        .iter()
1147                        .map(|p| count_handles(std::slice::from_ref(&p.value)))
1148                        .sum(),
1149                    _ => 0,
1150                }
1151        })
1152        .sum()
1153}
1154fn text_scalars(nodes: &[Node]) -> usize {
1155    nodes.iter().map(|n| match &n.kind { Kind::Text(t) | Kind::Literal(t) => t.chars().count(), Kind::Markdown(Markdown::Code(t) | Markdown::CodeBlock { text: t, .. }) => t.chars().count(), _ => 0 } + text_scalars(&n.children)).sum()
1156}
1157
1158/// Invalid UTF-8 is replaced before parsing; offsets refer to the repaired text.
1159pub fn parse_bytes(source: &[u8], mode: Mode) -> Document {
1160    let cap = if mode == Mode::Reply {
1161        REPLY_BYTES
1162    } else {
1163        PROGRAM_BYTES
1164    };
1165    // A scalar can straddle the source cap. Retain at most three lookahead
1166    // bytes so truncation never manufactures an encoding error for valid UTF-8.
1167    let prefix = &source[..source.len().min(cap + 3)];
1168    let repaired = String::from_utf8_lossy(prefix);
1169    let mut doc = parse(&repaired, mode);
1170    if matches!(repaired, std::borrow::Cow::Owned(_)) {
1171        let end = doc.parsed_bytes;
1172        add_diagnostic(&mut doc, "W_ENCODING", Span::new(0, end));
1173    }
1174    if source.len() > cap {
1175        doc.shortened = true;
1176        if mode == Mode::Program && !doc.fallback {
1177            doc.fallback = true;
1178            doc.nodes.clear();
1179            let end = doc.parsed_bytes;
1180            add_diagnostic(&mut doc, "E_SOURCE_TOO_LARGE", Span::new(0, end));
1181        }
1182    }
1183    doc
1184}
1185
1186/// Collects a streamed source and parses it once, when `finish` is called.
1187/// Bytes beyond the mode's size limit (plus a three-byte margin) are counted
1188/// but not kept, and `finish` then marks the document shortened. No partial
1189/// parse is offered while bytes are still arriving.
1190pub struct StreamParser {
1191    mode: Mode,
1192    bytes: Vec<u8>,
1193    total: usize,
1194}
1195impl StreamParser {
1196    pub fn new(mode: Mode) -> Self {
1197        Self {
1198            mode,
1199            bytes: vec![],
1200            total: 0,
1201        }
1202    }
1203    pub fn push(&mut self, chunk: &[u8]) {
1204        let cap = if self.mode == Mode::Reply {
1205            REPLY_BYTES
1206        } else {
1207            PROGRAM_BYTES
1208        };
1209        self.total = self.total.saturating_add(chunk.len());
1210        let count = chunk.len().min(cap + 3 - self.bytes.len());
1211        self.bytes.extend_from_slice(&chunk[..count]);
1212    }
1213    pub fn finish(self) -> Document {
1214        let mut doc = parse_bytes(&self.bytes, self.mode);
1215        if self.total > self.bytes.len() {
1216            doc.shortened = true;
1217            if self.mode == Mode::Program && !doc.fallback {
1218                doc.fallback = true;
1219                doc.nodes.clear();
1220                let end = doc.parsed_bytes;
1221                add_diagnostic(&mut doc, "E_SOURCE_TOO_LARGE", Span::new(0, end));
1222            }
1223        }
1224        doc
1225    }
1226}