use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Part {
Text {
text: String,
},
Emote {
id: String,
name: String,
count: u32,
},
}
pub fn parse(content: &str, combine: bool) -> Vec<Part> {
let mut parts: Vec<Part> = Vec::new();
let mut rest = content;
let mut between = String::new();
while let Some(start) = rest.find("[emote:") {
let (before, token) = rest.split_at(start);
let (id, name, len) = match emote_token(token) {
Token::Emote(id, name, len) => (id, name, len),
Token::Not => {
between.push_str(&rest[..start + 1]);
rest = &rest[start + 1..];
continue;
}
Token::Unclosed => break,
};
between.push_str(before);
let same_run = combine
&& between.trim().is_empty()
&& matches!(parts.last(), Some(Part::Emote { id: last, .. }) if *last == id);
if same_run {
if let Some(Part::Emote { count, .. }) = parts.last_mut() {
*count += 1;
}
} else {
if !between.is_empty() {
parts.push(Part::Text { text: std::mem::take(&mut between) });
}
parts.push(Part::Emote { id, name, count: 1 });
}
between.clear();
rest = &token[len..];
}
between.push_str(rest);
if !between.is_empty() {
parts.push(Part::Text { text: between });
}
parts
}
enum Token {
Emote(String, String, usize),
Not,
Unclosed,
}
fn emote_token(s: &str) -> Token {
let Some(body) = s.strip_prefix("[emote:") else { return Token::Not };
let digits = body.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 || body.as_bytes().get(digits) != Some(&b':') {
return Token::Not;
}
let after = &body[digits + 1..];
match after.find(['[', ']']) {
Some(close) if after.as_bytes()[close] == b']' => {
Token::Emote(body[..digits].to_owned(), after[..close].to_owned(), "[emote:".len() + digits + 1 + close + 1)
}
Some(_) => Token::Not,
None => Token::Unclosed,
}
}
pub fn emote_url(id: &str) -> Result<String, crate::Error> {
if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) {
return Err(crate::Error::Invalid(format!("emote id {id:?}: digits only")));
}
Ok(format!("https://files.kick.com/emotes/{id}/fullsize"))
}
#[cfg(test)]
mod tests {
use super::*;
fn emote(id: &str, name: &str, count: u32) -> Part {
Part::Emote { id: id.into(), name: name.into(), count }
}
fn text(t: &str) -> Part {
Part::Text { text: t.into() }
}
#[test]
fn splits_text_and_emotes() {
assert_eq!(
parse("hi [emote:37226:KEKW] there", false),
vec![text("hi "), emote("37226", "KEKW", 1), text(" there")]
);
assert_eq!(parse("", false), vec![]);
assert_eq!(parse("[emote:x:no] [emote:1:", false), vec![text("[emote:x:no] [emote:1:")]);
}
#[test]
fn combines_runs_of_one_emote() {
assert_eq!(
parse("[emote:1:A] [emote:1:A][emote:1:B] [emote:2:C] x [emote:2:C]", true),
vec![emote("1", "A", 3), text(" "), emote("2", "C", 1), text(" x "), emote("2", "C", 1)]
);
assert_eq!(parse("[emote:1:A] [emote:1:A]", false).len(), 3);
}
#[test]
fn broken_tokens_stay_text() {
assert_eq!(parse("[emote:1:a[emote:2:B]", false), vec![text("[emote:1:a"), emote("2", "B", 1)]);
assert_eq!(parse("[emote::x] [emote:1x:y]", false), vec![text("[emote::x] [emote:1x:y]")]);
assert_eq!(parse("[emote:1:A] [emote:2:", false), vec![emote("1", "A", 1), text(" [emote:2:")]);
}
#[test]
fn unclosed_tokens_take_linear_time() {
let evil = "[emote:1:".repeat(200_000);
let start = std::time::Instant::now();
assert_eq!(parse(&evil, true), vec![text(&evil)]);
let evil = "[emote:1:a".repeat(200_000) + "]";
let parts = parse(&evil, true);
assert_eq!(parts.len(), 2);
assert!(start.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn emote_urls_take_digits_only() {
assert_eq!(emote_url("37226").unwrap(), "https://files.kick.com/emotes/37226/fullsize");
for bad in ["", ".", "..", "1/2", "a"] {
assert!(emote_url(bad).is_err(), "{bad:?}");
}
}
}