1use std::collections::HashSet;
50
51pub const SPEAKER_COLORS: u8 = 8;
53
54#[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#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChatMessage {
75 pub side: Side,
76 pub speaker: String,
80 pub continuation: bool,
83 pub body: String,
85 pub body_offset: usize,
89 pub subtext: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum ChatItem {
95 Message(ChatMessage),
96 Delimiter,
98 Comment {
100 text: String,
101 offset: usize,
102 },
103 Markdown {
106 text: String,
107 offset: usize,
108 },
109}
110
111pub fn is_chat_info(info: &str) -> bool {
117 info.split_whitespace().next() == Some("chat")
118}
119
120fn fmt(text: &str) -> String {
122 text.trim().replace("\\|", "|")
123}
124
125#[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
151fn 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
160fn 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
189struct 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
202pub 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 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 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 markdown = Some((line.start, line.end));
271 }
272 i += 1;
273 }
274 flush(&mut markdown, &mut raw);
275
276 resolve(raw, &right, ¢er)
277}
278
279fn 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 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
328fn 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 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
368pub 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 (hash % u32::from(SPEAKER_COLORS)) as u8
377}
378
379fn escape(text: &str) -> String {
380 let mut out = String::with_capacity(text.len());
381 let _ = pulldown_cmark_escape::escape_html(&mut out, text);
383 out
384}
385
386pub 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
404pub 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
425pub 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 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 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 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 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 ("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 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("<b>x</b>"), "{open}");
670 assert!(open.contains("mbr-chat-right"), "{open}");
671 let close = message_close_html(&message);
672 assert!(close.contains(""<i>""), "{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 #[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 #[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}