1#![doc = include_str!("../README.md")]
26#![warn(missing_docs)]
27
28use std::sync::LazyLock;
29
30use mxblocks::{Button, MAX_LABEL, Rich, Style, norm_key};
31use regex::Regex;
32
33const EMO: &str = r"(?:\p{Extended_Pictographic}|[#*0-9]\x{FE0F}?\x{20E3})(?:\x{FE0F}|\p{Emoji_Modifier})*(?:\x{200D}\p{Extended_Pictographic}(?:\x{FE0F}|\p{Emoji_Modifier})*)*";
36
37fn re(p: &str) -> Regex {
38 Regex::new(&p.replace("EMO", EMO)).unwrap_or_else(|e| panic!("reaction_asks regex: {e}"))
39}
40
41static EMO_RE: LazyLock<Regex> = LazyLock::new(|| re("EMO"));
42static FENCE_RE: LazyLock<Regex> = LazyLock::new(|| re(r"^\s{0,3}(`{3,}|~{3,})(.*)$"));
44static MARKER_RE: LazyLock<Regex> = LazyLock::new(|| re(r"\[ask:\s*([^\]\n]+)\]"));
45static OPTION_RE: LazyLock<Regex> = LazyLock::new(|| re(r"^(EMO)\s*(.*)$"));
46static TO_RE: LazyLock<Regex> = LazyLock::new(|| {
49 let item = r"EMO\s+to\s+[^,;·|/\n\p{Extended_Pictographic}]{1,60}?";
50 let sep = r"(?:\s*[,;·|/]\s*(?:or\s+)?|\s+or\s+)";
51 re(&format!(r"{item}{sep}{item}(?:{sep}{item})?[\s.!?)]*$"))
52});
53static RENDERED_RE: LazyLock<Regex> = LazyLock::new(|| {
56 let item = r"EMO\s*[^·|\n\p{Extended_Pictographic}]{1,80}?";
57 re(&format!(r"^\s*{item}(?:\s+·\s+{item}){{1,3}}\s*$"))
58});
59static REACT_RE: LazyLock<Regex> =
61 LazyLock::new(|| re(r"(?i)\breact(?:\s+with)?\s+(EMO(?:\s*(?:/|,|or)\s*EMO){0,3})[\s.!?)]*$"));
62static PAIR_RE: LazyLock<Regex> = LazyLock::new(|| {
64 re(r"(?:^|[\s(:—–-])(EMO\s*(?:/|or)\s*EMO(?:\s*(?:/|or)\s*EMO)?)\s*\??[\s.!)]*$")
65});
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Opt {
70 pub key: String,
72 pub label: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Ask {
80 pub opts: Vec<Opt>,
82 pub buttons: bool,
84}
85
86impl Ask {
87 pub fn seeds(&self) -> Vec<String> {
89 self.opts.iter().map(|o| o.key.clone()).collect()
90 }
91
92 pub fn rich(&self) -> Rich {
96 Rich::buttons(
97 self.opts
98 .iter()
99 .map(|o| {
100 let label = if o.label.is_empty() {
101 o.key.clone()
102 } else {
103 short(&o.label)
104 };
105 Button::react(&label, Style::Default, &o.key)
106 })
107 .collect(),
108 )
109 }
110
111 pub fn shown(&self) -> Option<Rich> {
113 self.buttons.then(|| self.rich())
114 }
115
116 pub fn marker(&self) -> String {
118 let opts: Vec<String> = self
119 .opts
120 .iter()
121 .map(|o| format!("{} {}", o.key, o.label).trim().to_owned())
122 .collect();
123 format!("[ask: {}]", opts.join(" | "))
124 }
125
126 pub fn quoted(&self, text: &str) -> String {
129 text.replacen(&self.fallback(), &self.marker(), 1)
130 }
131
132 pub fn fallback(&self) -> String {
134 self.opts
135 .iter()
136 .map(|o| format!("{} {}", o.key, o.label).trim().to_owned())
137 .collect::<Vec<_>>()
138 .join(" · ")
139 }
140}
141
142fn short(label: &str) -> String {
145 if label.chars().count() <= MAX_LABEL {
146 return label.to_owned();
147 }
148 let head: String = label.chars().take(MAX_LABEL - 1).collect();
149 let cut = match head.rfind(char::is_whitespace) {
150 Some(i) if head[..i].trim_end().chars().count() >= MAX_LABEL / 2 => &head[..i],
151 _ => &head,
152 };
153 let cut = cut.trim_end_matches(|c: char| c.is_whitespace() || ",;:·-–—|/".contains(c));
154 format!("{cut}…")
155}
156
157fn ask_of(opts: Vec<Opt>, min: usize, buttons: bool) -> Option<Ask> {
159 if opts.len() < min || opts.len() > 4 {
160 return None;
161 }
162 let mut keys: Vec<String> = opts.iter().map(|o| norm_key(&o.key)).collect();
163 keys.sort();
164 keys.dedup();
165 (keys.len() == opts.len()).then_some(Ask { opts, buttons })
166}
167
168fn parse_marker(inner: &str) -> Option<Ask> {
170 let opts = inner
171 .split('|')
172 .map(|o| {
173 let c = OPTION_RE.captures(o.trim())?;
174 let label = c[2].trim();
175 (!label.is_empty()).then(|| Opt {
176 key: c[1].to_owned(),
177 label: label.to_owned(),
178 })
179 })
180 .collect::<Option<Vec<_>>>()?;
181 ask_of(opts, 2, true)
182}
183
184fn prose_lines(text: &str) -> Vec<(usize, &str)> {
186 let mut out = Vec::new();
187 let mut fence = false;
188 let mut at = 0;
189 for line in text.split('\n') {
190 let start = at;
191 at += line.len() + 1;
192 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
193 fence = !fence;
194 continue;
195 }
196 if !fence {
197 out.push((start, line));
198 }
199 }
200 out
201}
202
203pub fn take_marker(para: &str) -> Option<(String, Ask)> {
206 for (start, line) in prose_lines(para) {
207 for m in MARKER_RE.captures_iter(line) {
208 let all = m.get(0)?;
209 if line[..all.start()].matches('`').count() % 2 == 1 {
211 continue;
212 }
213 let Some(ask) = parse_marker(&m[1]) else {
214 continue;
215 };
216 let (a, b) = (start + all.start(), start + all.end());
217 let text = format!("{}{}{}", ¶[..a], ask.fallback(), ¶[b..]);
218 return Some((text.trim().to_owned(), ask));
219 }
220 }
221 None
222}
223
224pub fn plain_markers(text: &str) -> String {
228 let mut out = String::with_capacity(text.len());
229 let mut at = 0;
230 for (start, line) in prose_lines(text) {
231 for m in MARKER_RE.captures_iter(line) {
232 let Some(all) = m.get(0) else { continue };
233 if line[..all.start()].matches('`').count() % 2 == 1 {
234 continue;
235 }
236 let plain = m[1]
237 .split('|')
238 .map(str::trim)
239 .filter(|o| !o.is_empty())
240 .collect::<Vec<_>>()
241 .join(" · ");
242 out.push_str(&text[at..start + all.start()]);
243 out.push_str(&plain);
244 at = start + all.end();
245 }
246 }
247 out.push_str(&text[at..]);
248 out
249}
250
251fn label_end(s: &str) -> String {
253 let trim = |s: &str| {
254 s.trim_end_matches(|c: char| c.is_whitespace() || ",;·|/.!?)".contains(c))
255 .to_owned()
256 };
257 let mut s = trim(s);
258 if let Some(r) = s.strip_suffix(" or") {
259 s = trim(r);
260 }
261 s
262}
263
264pub fn detect(text: &str) -> Option<Ask> {
267 let text = text.trim_end();
268 let (_, line) = *prose_lines(text).last()?;
269 if !text.ends_with(line) || line.contains('`') {
271 return None;
272 }
273 if let Some(m) = TO_RE.find(line) {
274 let span = m.as_str();
275 let emos: Vec<_> = EMO_RE.find_iter(span).collect();
276 let opts = emos
277 .iter()
278 .enumerate()
279 .map(|(i, e)| {
280 let end = emos.get(i + 1).map_or(span.len(), regex::Match::start);
281 let rest = span[e.end()..end].trim_start();
282 let label = label_end(rest.strip_prefix("to")?.trim_start());
283 (!label.is_empty()).then(|| Opt {
284 key: e.as_str().to_owned(),
285 label,
286 })
287 })
288 .collect::<Option<Vec<_>>>()?;
289 return ask_of(opts, 2, true);
290 }
291 let keys = |s: &str| -> Vec<Opt> {
292 EMO_RE
293 .find_iter(s)
294 .map(|e| Opt {
295 key: e.as_str().to_owned(),
296 label: String::new(),
297 })
298 .collect()
299 };
300 if let Some(c) = REACT_RE.captures(line) {
301 return ask_of(keys(&c[1]), 1, false);
302 }
303 if let Some(c) = PAIR_RE.captures(line) {
304 let g = c.get(1)?;
305 if line.contains('?') || line[..g.start()].trim().is_empty() {
307 return ask_of(keys(g.as_str()), 2, false);
308 }
309 }
310 None
311}
312
313pub fn rendered_only(text: &str) -> bool {
317 let text = text.trim_end();
318 let Some(&(_, line)) = prose_lines(text).last() else {
319 return false;
320 };
321 let before = text[..text.len() - line.len()].trim_end_matches([' ', '\t']);
322 (before.is_empty() || before.ends_with("\n\n"))
323 && text.ends_with(line)
324 && RENDERED_RE.is_match(line)
325 && parse_marker(&line.replace('·', "|")).is_some()
326}
327
328pub fn paragraphs(text: &str) -> Vec<String> {
331 let mut blocks: Vec<Vec<&str>> = Vec::new();
332 let mut cur: Vec<&str> = Vec::new();
333 let mut fence: Option<String> = None;
334 for line in text.split('\n') {
335 if fence.is_none() && line.trim().is_empty() {
336 if !cur.is_empty() {
337 blocks.push(core::mem::take(&mut cur));
338 }
339 continue;
340 }
341 cur.push(line);
342 if let Some(m) = FENCE_RE.captures(line) {
343 match &fence {
344 None => fence = Some(m[1].to_owned()),
345 Some(f) if m[1].starts_with(f.as_str()) && m[2].trim().is_empty() => fence = None,
346 _ => {}
347 }
348 }
349 }
350 if !cur.is_empty() {
351 blocks.push(cur);
352 }
353 blocks.into_iter().map(|b| b.join("\n")).collect()
354}
355
356pub fn plan<M: Default>(parts: Vec<(M, String)>) -> Vec<(M, String, Option<Ask>)> {
363 plan_with(parts, paragraphs)
364}
365
366pub fn plan_with<M: Default>(
368 parts: Vec<(M, String)>,
369 split: impl Fn(&str) -> Vec<String>,
370) -> Vec<(M, String, Option<Ask>)> {
371 let mut out: Vec<(M, String, Option<Ask>)> = Vec::new();
372 for (marks, part) in parts {
373 let blocks = split(&part);
374 if blocks.len() < 2 || !blocks.iter().any(|b| take_marker(b).is_some()) {
375 let (text, ask) = take_marker(&part).map_or((part, None), |(t, a)| (t, Some(a)));
376 out.push((marks, text, ask));
377 continue;
378 }
379 let mut marks = Some(marks);
380 let mut run: Vec<String> = Vec::new();
381 let flush = |run: &mut Vec<String>, out: &mut Vec<_>, marks: &mut Option<M>| {
382 if !run.is_empty() {
383 out.push((marks.take().unwrap_or_default(), run.join("\n\n"), None));
384 run.clear();
385 }
386 };
387 for b in blocks {
388 match take_marker(&b) {
389 Some((text, ask)) => {
390 flush(&mut run, &mut out, &mut marks);
391 out.push((marks.take().unwrap_or_default(), text, Some(ask)));
392 }
393 None => run.push(b),
394 }
395 }
396 flush(&mut run, &mut out, &mut marks);
397 }
398 for (_, text, _) in &mut out {
399 if text.contains("[ask:") {
400 *text = plain_markers(text);
401 }
402 }
403 if out.iter().all(|(_, _, a)| a.is_none())
404 && let Some(last) = out.last_mut()
405 {
406 last.2 = detect(&last.1);
407 }
408 out
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[derive(Debug, Clone, Default, PartialEq, Eq)]
417 struct Marks {
418 reply: Option<String>,
419 }
420
421 fn keys(a: &Ask) -> Vec<(&str, &str)> {
422 a.opts
423 .iter()
424 .map(|o| (o.key.as_str(), o.label.as_str()))
425 .collect()
426 }
427
428 #[test]
429 fn markers() {
430 let (t, a) = take_marker("v2 is staged. Deploy? [ask: ✅ deploy | ❌ skip]").unwrap();
431 assert_eq!(t, "v2 is staged. Deploy? ✅ deploy · ❌ skip");
432 assert_eq!(keys(&a), [("✅", "deploy"), ("❌", "skip")]);
433 assert!(a.buttons);
434 assert_eq!(a.seeds(), ["✅", "❌"]);
435 let r = serde_json::to_value(a.rich()).unwrap();
436 assert_eq!(r["blocks"][0]["buttons"][1]["label"], "skip");
437 assert_eq!(r["blocks"][0]["buttons"][1]["action"]["key"], "❌");
438 let (_, a) =
440 take_marker("[ask:👍🏽 yes|❤\u{fe0f} love it | 🧑💻 later | 1\u{fe0f}\u{20e3} one]")
441 .unwrap();
442 assert_eq!(a.opts.len(), 4);
443 assert_eq!(a.opts[2].key, "🧑💻");
444 for bad in [
446 "[ask: ✅ deploy]",
447 "[ask: ✅ a | ❌ b | 👍 c | 👎 d | 🕐 e]",
448 "[ask: ✅ | ❌ skip]",
449 "[ask: ✅ a | ✅\u{fe0f} b]",
450 "[ask: yes deploy | no skip]",
451 "`[ask: ✅ deploy | ❌ skip]` is the marker",
452 "```\n[ask: ✅ deploy | ❌ skip]\n```",
453 ] {
454 assert!(take_marker(bad).is_none(), "{bad}");
455 }
456 }
457
458 #[test]
459 fn long_labels() {
460 let labels = |a: &Ask| -> Vec<String> {
461 let r = serde_json::to_value(a.rich()).unwrap();
462 r["blocks"][0]["buttons"]
463 .as_array()
464 .unwrap()
465 .iter()
466 .map(|b| b["label"].as_str().unwrap().to_owned())
467 .collect()
468 };
469 let msg = "[ask: ✅ keep the old schema, migrate the rows next week | 🔀 do the full schema migration right now | ❌ hold]";
471 let (t, a) = take_marker(msg).unwrap();
472 assert_eq!(
473 t,
474 "✅ keep the old schema, migrate the rows next week · 🔀 do the full schema migration right now · ❌ hold"
475 );
476 assert_eq!(a.seeds(), ["✅", "🔀", "❌"]);
477 assert!(a.buttons);
478 assert_eq!(
479 labels(&a),
480 [
481 "keep the old schema, migrate the rows next week",
482 "do the full schema migration right now",
483 "hold"
484 ]
485 );
486 a.rich().validate().unwrap();
487 let long = "ship the whole thing now, ".repeat(5) + "then tell the team";
489 assert!(long.chars().count() > MAX_LABEL);
490 let (t, a) = take_marker(&format!("Go? [ask: ✅ {long} | ❌ hold]")).unwrap();
491 assert_eq!(t, format!("Go? ✅ {long} · ❌ hold"));
492 let b = &labels(&a)[0];
493 assert!(b.ends_with(" whole…"), "{b}");
494 assert!(b.chars().count() <= MAX_LABEL);
495 assert!(long.starts_with(b.trim_end_matches('…')));
496 a.rich().validate().unwrap();
497 assert_eq!(
499 short(&"x".repeat(MAX_LABEL + 10)),
500 format!("{}…", "x".repeat(MAX_LABEL - 1))
501 );
502 assert_eq!(short("fits"), "fits");
503 }
504
505 #[test]
506 fn unparsed_markers_are_plain() {
507 let p = |t: &str| plan(vec![(Marks::default(), t.to_owned())]);
508 let five = p("Pick: [ask: ✅ a | ❌ b | 👍 c | 👎 d | 🕐 e]");
509 assert_eq!(five[0].1, "Pick: ✅ a · ❌ b · 👍 c · 👎 d · 🕐 e");
510 assert!(five[0].2.is_none());
511 let bare = p("Ship? [ask: yes deploy | no skip]");
512 assert_eq!(bare[0].1, "Ship? yes deploy · no skip");
513 assert_eq!(p("[ask: ✅ deploy]")[0].1, "✅ deploy");
514 let two = p("Now? [ask: ✅ go | ❌ wait] Later? [ask: 👍 yes | 👎 no]");
516 assert_eq!(two[0].1, "Now? ✅ go · ❌ wait Later? 👍 yes · 👎 no");
517 assert!(two[0].2.is_some());
518 for code in [
520 "`[ask: ✅ deploy | ❌ skip]` is the marker",
521 "```\n[ask: yes | no]\n```",
522 ] {
523 assert_eq!(p(code)[0].1, code);
524 }
525 }
526
527 #[test]
528 fn safety_net() {
529 let d = |s: &str| {
530 detect(s).map(|a| {
531 (
532 keys(&a)
533 .iter()
534 .map(|(k, l)| format!("{k}={l}"))
535 .collect::<Vec<_>>()
536 .join(" "),
537 a.buttons,
538 )
539 })
540 };
541 assert_eq!(
542 d("Staged v2.\n\n✅ to deploy, ❌ to skip"),
543 Some(("✅=deploy ❌=skip".into(), true))
544 );
545 assert_eq!(
546 d("Shall I send it? (✅ to send it now or ❌ to drop it.)"),
547 Some(("✅=send it now ❌=drop it".into(), true))
548 );
549 assert_eq!(
550 d("👍 to merge / 👎 to hold / 🕐 to wait a day"),
551 Some(("👍=merge 👎=hold 🕐=wait a day".into(), true))
552 );
553 assert_eq!(d("Want it? React ✅."), Some(("✅=".into(), false)));
554 assert_eq!(d("react with 👍 or 👎"), Some(("👍= 👎=".into(), false)));
555 assert_eq!(d("Ship it? 👍/👎"), Some(("👍= 👎=".into(), false)));
556 assert_eq!(d("Good to go 👍/👎?"), Some(("👍= 👎=".into(), false)));
557 assert_eq!(
558 d("Notes are up.\n👍 or 👎"),
559 Some(("👍= 👎=".into(), false))
560 );
561 for s in [
563 "Approve the ✅ ones and I'll do the rest.",
564 "✅ to deploy, ❌ to skip.\n\nAnyway, the build is green.",
565 "I marked ✅ to deploy and ❌ to skip in the doc, then left",
566 "```\n✅ to deploy, ❌ to skip\n```",
567 "Use `👍/👎?` in the template",
568 "I had 🍕 or 🍔 for lunch",
569 "✅ to deploy, ✅ to skip",
570 "Done ✅",
571 "#5366 is ready.\n\n✅ merge #5366 · ⏸ hold",
573 "✅ CI green · ❌ e2e red",
574 "",
575 ] {
576 assert!(detect(s).is_none(), "{s}");
577 }
578 }
579
580 #[test]
581 fn rendered_forms() {
582 for s in [
583 "#5366 is ready. CI is green.\n\n✅ merge #5366 · ⏸ hold",
584 "✅ merge both · 👀 merge #5297 only · ⏸ hold",
585 ] {
586 assert!(rendered_only(s), "{s}");
587 }
588 for s in [
589 "CI:\n✅ merge #5366 · ⏸ hold",
590 "Shipped ✅ merge #5366 · ⏸ hold",
591 "✅ lint · ✅ tests",
592 "✅ a · ❌ b · 👍 c · 👎 d · 🕐 e",
593 "Deploy? [ask: ✅ deploy | ❌ skip]",
594 "Done ✅",
595 "",
596 ] {
597 assert!(!rendered_only(s), "{s}");
598 }
599 let (t, a) = take_marker("Close #5293?\n[ask: ✅ close #5293 | ⏸ leave it]").unwrap();
601 assert_eq!(t, "Close #5293?\n✅ close #5293 · ⏸ leave it");
602 assert_eq!(a.marker(), "[ask: ✅ close #5293 | ⏸ leave it]");
603 assert_eq!(
604 a.quoted(&t),
605 "Close #5293?\n[ask: ✅ close #5293 | ⏸ leave it]"
606 );
607 let a = detect("Ship it? 👍/👎").unwrap();
609 assert_eq!(a.quoted("Ship it? 👍/👎"), "Ship it? 👍/👎");
610 }
611
612 #[test]
613 fn plans() {
614 let m = |t: &str| (Marks::default(), t.to_owned());
615 let first = Marks {
617 reply: Some("$r".into()),
618 };
619 let p = plan(vec![
620 (
621 first.clone(),
622 "Built.\n\nDeploy? [ask: ✅ deploy | ❌ skip]\n\nMore after.".into(),
623 ),
624 m("Last one ✅ to a, ❌ to b"),
625 ]);
626 let texts: Vec<_> = p
627 .iter()
628 .map(|(_, t, a)| (t.as_str(), a.is_some()))
629 .collect();
630 assert_eq!(
631 texts,
632 [
633 ("Built.", false),
634 ("Deploy? ✅ deploy · ❌ skip", true),
635 ("More after.", false),
636 ("Last one ✅ to a, ❌ to b", false),
638 ]
639 );
640 assert_eq!(p[0].0, first);
641 assert_eq!(p[1].0, Marks::default());
642 let p = plan(vec![m("✅ to a, ❌ to b"), m("Ship it? 👍/👎")]);
644 assert!(p[0].2.is_none());
645 assert_eq!(p[1].2.as_ref().unwrap().seeds(), ["👍", "👎"]);
646 assert!(plan(vec![m("Plain.")])[0].2.is_none());
647 let p = plan(vec![m(
649 "```\n[ask: ✅ a | ❌ b]\n\nx\n```\n\nGo? [ask: ✅ go | ❌ no]",
650 )]);
651 assert_eq!(p.len(), 2);
652 assert!(p[0].2.is_none() && p[1].2.is_some());
653 assert_eq!(p[0].1, "```\n[ask: ✅ a | ❌ b]\n\nx\n```");
654 }
655
656 #[test]
657 fn splits_paragraphs() {
658 assert_eq!(paragraphs("a\nb\n\n\nc"), ["a\nb", "c"]);
659 assert_eq!(
660 paragraphs("x\n~~~~\n\n```\n~~~\n\n~~~~\ny"),
661 ["x\n~~~~\n\n```\n~~~\n\n~~~~\ny"]
662 );
663 assert!(paragraphs("\n\n").is_empty());
664 }
665}