Skip to main content

mbr_core/
chat.rs

1//! ```` ```chat ```` code blocks: a conversation rendered as speech bubbles.
2//!
3//! Compatible with the `chat` block of the Obsidian
4//! [Chat View](https://github.com/adifyr/obsidian-chat-view) plugin (v2.0,
5//! `src/processors/chat.ts`), so notes written for it render the same here.
6//! Other renderers show the raw code block, which still reads as a transcript.
7//!
8//! This module is **pure**: [`parse`] turns the block's text into
9//! [`ChatItem`]s and the `*_html` helpers produce the wrapper markup. It knows
10//! nothing about markdown — bodies are handed back as text, and
11//! `markdown::collect_events_and_headings` parses them with the page's own
12//! pipeline so links, wikilinks and media inside a bubble resolve exactly as
13//! they would anywhere else on the page.
14//!
15//! # Grammar
16//!
17//! Every construct starts in column 0 of a line, as in the plugin:
18//!
19//! | Line | Meaning |
20//! |------|---------|
21//! | `{{header\|body\|subtext}}` | A message. May span lines; ends at the first line ending in `\|subtext}}` |
22//! | `...` | A centred divider |
23//! | `# text` | A centred note outside the bubbles |
24//! | `> Name, Name` | These speakers are drawn on the right |
25//! | `^ Name, Name` | These speakers are drawn in the centre |
26//! | `[Name=color]` / `{key=value}` | Chat View 1.x configuration — silently ignored |
27//! | anything else | Markdown, rendered between the bubbles (an mbr extension) |
28//!
29//! Two deliberate departures from the plugin:
30//!
31//! * The plugin collects `>`/`^` alignment lines with a whole-block regex, so a
32//!   blockquote written *inside* a message body also re-aligns speakers. Here
33//!   only lines outside messages count.
34//! * The plugin drops lines that match nothing. They are rendered as markdown
35//!   here, because a transcript interleaved with commentary is the point.
36//!
37//! # Matching the plugin's message regex
38//!
39//! The plugin's pattern is
40//! `^\{\{([^|\n]*)\|([\s\S]*?)\|([^|\n]*)\}\}\s*$` (multiline). The header is
41//! the text up to the first `|` on the opening line; the body is lazy, so it
42//! ends at the **first** `|` that is followed by pipe-free text, `}}` and the
43//! end of a line. Only the last `|` on a line can be followed by pipe-free text,
44//! so each line has at most one candidate closer, and [`parse`] precomputes
45//! them once — an unclosed `{{` then costs O(1) to reject instead of a rescan of
46//! the rest of the block, which would make a block of a thousand broken
47//! messages quadratic.
48
49use std::collections::HashSet;
50
51/// Number of speaker colour slots in `theme.css` (`.mbr-chat-c0` …).
52pub const SPEAKER_COLORS: u8 = 8;
53
54/// Which side of the log a message is drawn on.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Side {
57    Left,
58    Right,
59    Center,
60}
61
62impl Side {
63    fn class(self) -> &'static str {
64        match self {
65            Side::Left => "mbr-chat-left",
66            Side::Right => "mbr-chat-right",
67            Side::Center => "mbr-chat-center",
68        }
69    }
70}
71
72/// One `{{header|body|subtext}}` message.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChatMessage {
75    pub side: Side,
76    /// The speaker. For a message with an empty header this is the previous
77    /// speaker's name (empty if there was none), so the bubble keeps their
78    /// colour.
79    pub speaker: String,
80    /// True when the speaker label is not repeated: an empty header, or the
81    /// same name as the message immediately before.
82    pub continuation: bool,
83    /// Markdown source of the body, trimmed, with `\|` unescaped.
84    pub body: String,
85    /// Byte offset of `body`'s first character in the block text. Lets the
86    /// caller map the body's own lines back to source lines; `\|` unescaping
87    /// never removes a newline, so line arithmetic survives it.
88    pub body_offset: usize,
89    pub subtext: String,
90}
91
92/// One element of a chat block, in document order.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum ChatItem {
95    Message(ChatMessage),
96    /// `...`
97    Delimiter,
98    /// `# text` — rendered as inline-ish markdown in a muted, centred note.
99    Comment {
100        text: String,
101        offset: usize,
102    },
103    /// Lines outside any message that are none of the above, kept together
104    /// until the next construct. Blank lines inside stay, so paragraphs work.
105    Markdown {
106        text: String,
107        offset: usize,
108    },
109}
110
111/// Whether a fenced code block's info string makes it a chat block.
112///
113/// Only the first word counts, so ```` ```chat title ```` still qualifies, and
114/// `chat-old`, `chat-webvtt` & co. (other Chat View formats mbr does not
115/// support) do not.
116pub fn is_chat_info(info: &str) -> bool {
117    info.split_whitespace().next() == Some("chat")
118}
119
120/// Trims and unescapes `\|`, as the plugin's `fmt` does.
121fn fmt(text: &str) -> String {
122    text.trim().replace("\\|", "|")
123}
124
125/// A line with its byte span in the block (newline excluded).
126#[derive(Debug, Clone, Copy)]
127struct Line {
128    start: usize,
129    end: usize,
130}
131
132fn split_lines(src: &str) -> Vec<Line> {
133    let mut lines = Vec::new();
134    let mut start = 0;
135    for (newline, _) in src.match_indices('\n') {
136        lines.push(Line {
137            start,
138            end: newline,
139        });
140        start = newline + 1;
141    }
142    if start < src.len() {
143        lines.push(Line {
144            start,
145            end: src.len(),
146        });
147    }
148    lines
149}
150
151/// Absolute offset of the `|` that could close a message on this line: the
152/// line, right-trimmed, must end in `}}`, and the closer is the last `|` before
153/// that. `None` when the line cannot close a message.
154fn closer(src: &str, line: Line) -> Option<usize> {
155    let text = src[line.start..line.end].trim_end();
156    let inner = text.strip_suffix("}}")?;
157    inner.rfind('|').map(|pipe| line.start + pipe)
158}
159
160/// Chat View 1.x configuration lines (`[Albus=red, Minerva=green]`,
161/// `{mw=60, mode=minimal}`): every comma-separated entry is `key=value` with a
162/// non-empty key and a single-word value. Strict on purpose, so a markdown link
163/// or a stray brace in prose is never swallowed.
164fn is_legacy_config(line: &str) -> bool {
165    let line = line.trim();
166    let inner = line
167        .strip_prefix('[')
168        .and_then(|rest| rest.strip_suffix(']'))
169        .or_else(|| {
170            line.strip_prefix('{')
171                .filter(|rest| !rest.starts_with('{'))
172                .and_then(|rest| rest.strip_suffix('}'))
173        });
174    let Some(inner) = inner else {
175        return false;
176    };
177    !inner.contains(['[', ']', '{', '}', '(', ')'])
178        && inner.split(',').all(|entry| {
179            entry.split_once('=').is_some_and(|(key, value)| {
180                let (key, value) = (key.trim(), value.trim());
181                !key.is_empty()
182                    && !value.is_empty()
183                    && !value.contains('=')
184                    && !value.contains(char::is_whitespace)
185            })
186        })
187}
188
189/// A message header before its side and continuation have been resolved.
190struct RawMessage {
191    header: String,
192    body: String,
193    body_offset: usize,
194    subtext: String,
195}
196
197enum RawItem {
198    Message(RawMessage),
199    Resolved(ChatItem),
200}
201
202/// Parses the text of a chat block. Never panics; never drops content other
203/// than syntax, alignment lines and 1.x configuration lines.
204pub fn parse(src: &str) -> Vec<ChatItem> {
205    let lines = split_lines(src);
206    let closers: Vec<Option<usize>> = lines.iter().map(|&line| closer(src, line)).collect();
207    // next_closer[i]: the first line at or after `i` that can close a message.
208    let mut next_closer = vec![None; lines.len() + 1];
209    for i in (0..lines.len()).rev() {
210        next_closer[i] = if closers[i].is_some() {
211            Some(i)
212        } else {
213            next_closer[i + 1]
214        };
215    }
216
217    let mut raw: Vec<RawItem> = Vec::new();
218    let mut right: HashSet<String> = HashSet::new();
219    let mut center: HashSet<String> = HashSet::new();
220    // Byte span of the markdown run being accumulated.
221    let mut markdown: Option<(usize, usize)> = None;
222
223    let flush = |markdown: &mut Option<(usize, usize)>, raw: &mut Vec<RawItem>| {
224        if let Some((start, end)) = markdown.take() {
225            let text = src[start..end].trim_end();
226            if !text.is_empty() {
227                raw.push(RawItem::Resolved(ChatItem::Markdown {
228                    text: text.to_string(),
229                    offset: start,
230                }));
231            }
232        }
233    };
234
235    let mut i = 0;
236    while i < lines.len() {
237        let line = lines[i];
238        let text = &src[line.start..line.end];
239
240        if let Some(message) = try_message(src, &lines, &closers, &next_closer, i) {
241            flush(&mut markdown, &mut raw);
242            raw.push(RawItem::Message(message.0));
243            i = message.1 + 1;
244            continue;
245        }
246
247        if text.trim_end() == "..." {
248            flush(&mut markdown, &mut raw);
249            raw.push(RawItem::Resolved(ChatItem::Delimiter));
250        } else if let Some(comment) = text.strip_prefix('#') {
251            flush(&mut markdown, &mut raw);
252            let leading = comment.len() - comment.trim_start().len();
253            raw.push(RawItem::Resolved(ChatItem::Comment {
254                text: fmt(comment),
255                offset: line.start + 1 + leading,
256            }));
257        } else if let Some(names) = text.strip_prefix('>') {
258            flush(&mut markdown, &mut raw);
259            collect_names(names, &mut right);
260        } else if let Some(names) = text.strip_prefix('^') {
261            flush(&mut markdown, &mut raw);
262            collect_names(names, &mut center);
263        } else if is_legacy_config(text) {
264            flush(&mut markdown, &mut raw);
265        } else if let Some((_, end)) = markdown.as_mut() {
266            *end = line.end;
267        } else if !text.trim().is_empty() {
268            // A run never starts on a blank line, so its offset is the line its
269            // first visible text is on.
270            markdown = Some((line.start, line.end));
271        }
272        i += 1;
273    }
274    flush(&mut markdown, &mut raw);
275
276    resolve(raw, &right, &center)
277}
278
279/// Tries to read a message opening on line `i`. Returns it with the index of
280/// the line it closed on.
281fn try_message(
282    src: &str,
283    lines: &[Line],
284    closers: &[Option<usize>],
285    next_closer: &[Option<usize>],
286    i: usize,
287) -> Option<(RawMessage, usize)> {
288    let line = lines[i];
289    let text = &src[line.start..line.end];
290    let after_open = text.strip_prefix("{{")?;
291    let header_len = after_open.find('|')?;
292    let header = &after_open[..header_len];
293    // `{{` + header + `|`
294    let body_start = line.start + 2 + header_len + 1;
295
296    let (close_line, pipe) = match closers[i] {
297        Some(pipe) if pipe >= body_start => (i, pipe),
298        _ => {
299            let j = next_closer[i + 1]?;
300            (j, closers[j]?)
301        }
302    };
303    let close = lines[close_line];
304    let subtext_end = close.start + src[close.start..close.end].trim_end().len() - 2;
305
306    let raw_body = &src[body_start..pipe];
307    let leading = raw_body.len() - raw_body.trim_start().len();
308    Some((
309        RawMessage {
310            header: fmt(header),
311            body: fmt(raw_body),
312            body_offset: body_start + leading,
313            subtext: fmt(&src[pipe + 1..subtext_end]),
314        },
315        close_line,
316    ))
317}
318
319fn collect_names(list: &str, into: &mut HashSet<String>) {
320    into.extend(
321        list.split(',')
322            .map(str::trim)
323            .filter(|name| !name.is_empty())
324            .map(str::to_string),
325    );
326}
327
328/// Assigns sides and continuation flags. Alignment lines may come anywhere in
329/// the block, even after the messages they affect, hence a second pass.
330fn resolve(raw: Vec<RawItem>, right: &HashSet<String>, center: &HashSet<String>) -> Vec<ChatItem> {
331    let mut side = Side::Left;
332    let mut speaker = String::new();
333    let mut previous_was_message = false;
334    raw.into_iter()
335        .map(|item| match item {
336            RawItem::Resolved(item) => {
337                previous_was_message = false;
338                item
339            }
340            RawItem::Message(message) => {
341                let continuation = message.header.is_empty()
342                    || (previous_was_message && message.header == speaker);
343                if !message.header.is_empty() {
344                    // As in the plugin, an empty header keeps the last side.
345                    side = if right.contains(&message.header) {
346                        Side::Right
347                    } else if center.contains(&message.header) {
348                        Side::Center
349                    } else {
350                        Side::Left
351                    };
352                    speaker = message.header;
353                }
354                previous_was_message = true;
355                ChatItem::Message(ChatMessage {
356                    side,
357                    speaker: speaker.clone(),
358                    continuation,
359                    body: message.body,
360                    body_offset: message.body_offset,
361                    subtext: message.subtext,
362                })
363            }
364        })
365        .collect()
366}
367
368/// Stable colour slot for a speaker: FNV-1a over the name's bytes. Stable
369/// across runs and platforms, so a speaker keeps their colour from page to page
370/// and between a server render and a static build.
371pub fn speaker_color(name: &str) -> u8 {
372    let hash = name.bytes().fold(0x811c_9dc5_u32, |hash, byte| {
373        (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193)
374    });
375    // The modulus is < 256, so the narrowing cannot truncate.
376    (hash % u32::from(SPEAKER_COLORS)) as u8
377}
378
379fn escape(text: &str) -> String {
380    let mut out = String::with_capacity(text.len());
381    // Writing to a String cannot fail.
382    let _ = pulldown_cmark_escape::escape_html(&mut out, text);
383    out
384}
385
386/// Opens the whole block. `line` is the fence's source line, emitted as
387/// `data-mbr-line` so a review note on a bubble can name the block it is in.
388pub fn open_html(line: Option<u32>) -> String {
389    match line {
390        Some(line) => {
391            format!(
392                "<div class=\"mbr-chat\" role=\"log\" aria-label=\"Chat\" data-mbr-line=\"{line}\">\n"
393            )
394        }
395        None => "<div class=\"mbr-chat\" role=\"log\" aria-label=\"Chat\">\n".to_string(),
396    }
397}
398
399pub const CLOSE_HTML: &str = "</div>\n";
400pub const DELIMITER_HTML: &str = "<div class=\"mbr-chat-delim\" role=\"separator\"></div>\n";
401pub const COMMENT_OPEN_HTML: &str = "<div class=\"mbr-chat-comment\">\n";
402pub const MARKDOWN_OPEN_HTML: &str = "<div class=\"mbr-chat-md\">\n";
403
404/// Everything before a message's body: the bubble and its speaker label.
405pub fn message_open_html(message: &ChatMessage) -> String {
406    let mut html = format!(
407        "<div class=\"mbr-chat-msg {} mbr-chat-c{}{}\">\n",
408        message.side.class(),
409        speaker_color(&message.speaker),
410        if message.continuation {
411            " mbr-chat-cont"
412        } else {
413            ""
414        }
415    );
416    if !message.continuation && !message.speaker.is_empty() {
417        html.push_str("<div class=\"mbr-chat-name\">");
418        html.push_str(&escape(&message.speaker));
419        html.push_str("</div>\n");
420    }
421    html.push_str("<div class=\"mbr-chat-body\">\n");
422    html
423}
424
425/// Everything after a message's body: the subtext line and the bubble's close.
426pub fn message_close_html(message: &ChatMessage) -> String {
427    if message.subtext.is_empty() {
428        "</div>\n</div>\n".to_string()
429    } else {
430        format!(
431            "</div>\n<div class=\"mbr-chat-meta\">{}</div>\n</div>\n",
432            escape(&message.subtext)
433        )
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use proptest::prelude::*;
441
442    fn messages(items: &[ChatItem]) -> Vec<&ChatMessage> {
443        items
444            .iter()
445            .filter_map(|item| match item {
446                ChatItem::Message(m) => Some(m),
447                _ => None,
448            })
449            .collect()
450    }
451
452    #[test]
453    fn info_string_first_word_must_be_chat() {
454        assert!(is_chat_info("chat"));
455        assert!(is_chat_info(" chat title"));
456        assert!(!is_chat_info("chat-old"));
457        assert!(!is_chat_info("chat-webvtt"));
458        assert!(!is_chat_info("rust"));
459        assert!(!is_chat_info(""));
460    }
461
462    #[test]
463    fn single_line_message() {
464        let items = parse("{{Alice|Hello there|5:42 PM}}\n");
465        assert_eq!(
466            items,
467            vec![ChatItem::Message(ChatMessage {
468                side: Side::Left,
469                speaker: "Alice".into(),
470                continuation: false,
471                body: "Hello there".into(),
472                body_offset: 8,
473                subtext: "5:42 PM".into(),
474            })]
475        );
476    }
477
478    #[test]
479    fn multi_line_body_ends_at_first_closing_line() {
480        let src = "{{Bob|line one\n\nline two|now}}\n{{Alice|reply|}}";
481        let items = parse(src);
482        let ms = messages(&items);
483        assert_eq!(ms.len(), 2);
484        assert_eq!(ms[0].body, "line one\n\nline two");
485        assert_eq!(ms[0].subtext, "now");
486        assert_eq!(ms[1].body, "reply");
487        assert_eq!(&src[ms[1].body_offset..ms[1].body_offset + 5], "reply");
488    }
489
490    #[test]
491    fn parts_may_be_empty() {
492        let items = parse("{{||}}");
493        let ms = messages(&items);
494        assert_eq!(ms.len(), 1);
495        assert_eq!(ms[0].body, "");
496        assert_eq!(ms[0].subtext, "");
497        assert!(ms[0].continuation, "an empty header is a continuation");
498    }
499
500    #[test]
501    fn body_may_contain_pipes_and_escaped_pipes() {
502        // Lazy body + pipe-free subtext: only the last `|` closes.
503        let items = parse("{{A|see [[Page|alias]] and a \\| b|5pm}}");
504        let ms = messages(&items);
505        assert_eq!(ms[0].body, "see [[Page|alias]] and a | b");
506        assert_eq!(ms[0].subtext, "5pm");
507    }
508
509    #[test]
510    fn trailing_whitespace_after_close_is_allowed() {
511        assert_eq!(messages(&parse("{{A|b|c}}   \t")).len(), 1);
512        // But not text.
513        assert!(messages(&parse("{{A|b|c}} trailing")).is_empty());
514    }
515
516    #[test]
517    fn message_must_start_in_column_zero() {
518        let items = parse("  {{A|b|c}}");
519        assert!(messages(&items).is_empty());
520        assert!(matches!(&items[0], ChatItem::Markdown { .. }));
521    }
522
523    #[test]
524    fn unclosed_message_degrades_to_markdown() {
525        let items = parse("{{A|never closed\nmore text");
526        assert_eq!(
527            items,
528            vec![ChatItem::Markdown {
529                text: "{{A|never closed\nmore text".into(),
530                offset: 0
531            }]
532        );
533    }
534
535    #[test]
536    fn header_pipe_cannot_close_its_own_message() {
537        // `{{A|}}` has no second `|`, so the body runs on to the next closer.
538        let items = parse("{{A|}}\nstill body|t}}");
539        let ms = messages(&items);
540        assert_eq!(ms.len(), 1);
541        assert_eq!(ms[0].body, "}}\nstill body");
542        assert_eq!(ms[0].subtext, "t");
543    }
544
545    #[test]
546    fn delimiter_and_comment() {
547        let items = parse("...\n#  Monday  \n...   ");
548        assert_eq!(
549            items,
550            vec![
551                ChatItem::Delimiter,
552                ChatItem::Comment {
553                    text: "Monday".into(),
554                    offset: 7
555                },
556                ChatItem::Delimiter
557            ]
558        );
559    }
560
561    #[test]
562    fn alignment_lines_set_sides_even_when_they_come_later() {
563        let src = "{{Alice|hi|}}\n{{Bob|yo|}}\n{{Mod|rules|}}\n> Bob, Carol\n^ Mod";
564        let ms_items = parse(src);
565        let ms = messages(&ms_items);
566        assert_eq!(ms[0].side, Side::Left);
567        assert_eq!(ms[1].side, Side::Right);
568        assert_eq!(ms[2].side, Side::Center);
569    }
570
571    #[test]
572    fn blockquote_inside_a_body_is_not_an_alignment_line() {
573        // The plugin would put Alice on the right here.
574        let items = parse("{{Alice|quoting:\n> Alice\n|}}");
575        let ms = messages(&items);
576        assert_eq!(ms[0].side, Side::Left);
577        assert_eq!(ms[0].body, "quoting:\n> Alice");
578    }
579
580    #[test]
581    fn empty_header_and_repeated_name_continue_the_speaker() {
582        let src = "> Bob\n{{Bob|a|}}\n{{|b|}}\n{{Bob|c|}}\n...\n{{Bob|d|}}";
583        let items = parse(src);
584        let ms = messages(&items);
585        assert_eq!(
586            ms.iter()
587                .map(|m| (m.speaker.as_str(), m.side, m.continuation))
588                .collect::<Vec<_>>(),
589            vec![
590                ("Bob", Side::Right, false),
591                ("Bob", Side::Right, true),
592                ("Bob", Side::Right, true),
593                // A delimiter breaks the run, so the name is shown again.
594                ("Bob", Side::Right, false),
595            ]
596        );
597    }
598
599    #[test]
600    fn other_lines_become_markdown_runs() {
601        let src = "Intro paragraph.\n\nSecond **para**.\n{{A|b|}}\n\n\nAfter [link](x.md)\n";
602        let items = parse(src);
603        assert_eq!(
604            items[0],
605            ChatItem::Markdown {
606                text: "Intro paragraph.\n\nSecond **para**.".into(),
607                offset: 0
608            }
609        );
610        assert!(matches!(items[1], ChatItem::Message(_)));
611        let ChatItem::Markdown { text, offset } = &items[2] else {
612            panic!("expected markdown, got {:?}", items[2]);
613        };
614        assert_eq!(text, "After [link](x.md)");
615        assert_eq!(&src[*offset..*offset + 5], "After");
616    }
617
618    #[test]
619    fn legacy_config_lines_are_ignored() {
620        let items =
621            parse("[Albus Dumbledore=orange, Minerva=red]\n{mw=60, mode=minimal}\n{{A|b|}}");
622        assert_eq!(messages(&items).len(), 1);
623        assert_eq!(items.len(), 1);
624    }
625
626    #[test]
627    fn markdown_that_looks_bracketed_is_kept() {
628        for line in [
629            "[a link](x.md)",
630            "[x=1](y)",
631            "[just text]",
632            "{not=config with spaces}",
633            "{{A=b}}",
634        ] {
635            assert!(!is_legacy_config(line), "{line}");
636            assert_eq!(parse(line).len(), 1, "{line}");
637        }
638    }
639
640    #[test]
641    fn crlf_line_endings() {
642        let items = parse("{{A|b|c}}\r\n...\r\n");
643        assert_eq!(messages(&items)[0].subtext, "c");
644        assert_eq!(items[1], ChatItem::Delimiter);
645    }
646
647    #[test]
648    fn speaker_color_is_stable_and_in_range() {
649        assert_eq!(speaker_color("Alice"), speaker_color("Alice"));
650        for name in ["", "Alice", "Bob", "Ж", "a very long name indeed"] {
651            assert!(speaker_color(name) < SPEAKER_COLORS);
652        }
653        // Pinned so a change to the hash (which would recolour every speaker in
654        // every existing note) is a deliberate act.
655        assert_eq!(speaker_color("Alice"), 7);
656    }
657
658    #[test]
659    fn html_escapes_header_and_subtext() {
660        let message = ChatMessage {
661            side: Side::Right,
662            speaker: "<b>x</b>".into(),
663            continuation: false,
664            body: String::new(),
665            body_offset: 0,
666            subtext: "\"<i>\"".into(),
667        };
668        let open = message_open_html(&message);
669        assert!(open.contains("&lt;b&gt;x&lt;/b&gt;"), "{open}");
670        assert!(open.contains("mbr-chat-right"), "{open}");
671        let close = message_close_html(&message);
672        assert!(close.contains("&quot;&lt;i&gt;&quot;"), "{close}");
673    }
674
675    #[test]
676    fn continuation_hides_the_name_label() {
677        let message = ChatMessage {
678            side: Side::Left,
679            speaker: "Bob".into(),
680            continuation: true,
681            body: String::new(),
682            body_offset: 0,
683            subtext: String::new(),
684        };
685        let open = message_open_html(&message);
686        assert!(!open.contains("mbr-chat-name"), "{open}");
687        assert!(open.contains("mbr-chat-cont"), "{open}");
688    }
689
690    fn alnum_counts(text: &str) -> std::collections::BTreeMap<char, usize> {
691        let mut counts = std::collections::BTreeMap::new();
692        for c in text.chars().filter(|c| c.is_alphanumeric()) {
693            *counts.entry(c).or_insert(0) += 1;
694        }
695        counts
696    }
697
698    fn item_text(items: &[ChatItem]) -> String {
699        items
700            .iter()
701            .map(|item| match item {
702                ChatItem::Message(m) => format!("{} {}", m.body, m.subtext),
703                ChatItem::Delimiter => String::new(),
704                ChatItem::Comment { text, .. } | ChatItem::Markdown { text, .. } => text.clone(),
705            })
706            .collect::<Vec<_>>()
707            .join("\n")
708    }
709
710    proptest! {
711        #[test]
712        fn never_panics(src in "\\PC*") {
713            let _ = parse(&src);
714        }
715
716        #[test]
717        fn never_panics_on_syntax_soup(src in "[{}|#>^.\\[\\]=\\\\ a\n\r]{0,80}") {
718            let _ = parse(&src);
719        }
720
721        /// No content is lost. Headers are excluded from the comparison
722        /// (they carry no `|`/newline and go to `speaker`, which is inherited
723        /// for continuations), so the generator keeps letters out of them by
724        /// putting digits only in bodies/subtexts/prose and letters only in
725        /// headers — and alignment/config lines can't be generated at all,
726        /// since no line can start with `>`, `^`, `[` or a lone `{`.
727        #[test]
728        fn every_body_and_prose_character_survives(
729            lines in proptest::collection::vec(
730                prop_oneof![
731                    "[0-9 ]{0,12}",
732                    "\\{\\{[a-z]{0,4}\\|[0-9 \\|]{0,12}",
733                    "[0-9 ]{0,8}\\|[0-9 ]{0,4}\\}\\}",
734                    "\\{\\{[a-z]{0,4}\\|[0-9 ]{0,8}\\|[0-9 ]{0,4}\\}\\}",
735                    Just("...".to_string()),
736                    "#[0-9 ]{0,8}",
737                ],
738                0..12,
739            )
740        ) {
741            let src = lines.join("\n");
742            let items = parse(&src);
743            let digits = |text: &str| -> std::collections::BTreeMap<char, usize> {
744                alnum_counts(text).into_iter().filter(|(c, _)| c.is_ascii_digit()).collect()
745            };
746            prop_assert_eq!(digits(&item_text(&items)), digits(&src));
747        }
748
749        /// Every offset the parser hands out is a char boundary inside the
750        /// block, and the body it points at starts there.
751        #[test]
752        fn offsets_point_at_their_text(src in "[{}|#a\n ]{0,60}") {
753            for item in parse(&src) {
754                match item {
755                    ChatItem::Message(m) if !m.body.is_empty() => {
756                        prop_assert!(src.is_char_boundary(m.body_offset));
757                        prop_assert!(src[m.body_offset..].starts_with(m.body.chars().next().unwrap_or(' ')));
758                    }
759                    ChatItem::Comment { text, offset } | ChatItem::Markdown { text, offset } if !text.is_empty() => {
760                        prop_assert!(src[offset..].starts_with(&text[..1]));
761                    }
762                    _ => {}
763                }
764            }
765        }
766    }
767}