bevy_simple_rich_text 0.6.0

A tiny rich text helper for Bevy
Documentation
use chumsky::{
    Parser,
    error::Cheap,
    primitive::{choice, just, none_of},
};

#[derive(Default)]
pub(crate) struct TextSection {
    pub(crate) value: String,
    pub(crate) tags: Vec<String>,
}

#[derive(Debug, PartialEq, Eq)]
enum TagsOrText {
    Tags(Vec<String>),
    Text(String),
}

fn escaped_bracket() -> impl Parser<char, String, Error = Cheap<char>> {
    just('[')
        .ignore_then(just('['))
        .or(just(']').ignore_then(just(']')))
        .map(|c| c.to_string())
}

fn tag_block() -> impl Parser<char, TagsOrText, Error = Cheap<char>> {
    tags()
        .delimited_by(just('['), just(']'))
        .map(TagsOrText::Tags)
}

fn tags() -> impl Parser<char, Vec<String>, Error = Cheap<char>> {
    not_end_bracket_or_comma()
        .separated_by(just(','))
        .collect::<Vec<_>>()
}

fn not_end_bracket_or_comma() -> impl Parser<char, String, Error = Cheap<char>> {
    none_of("],").repeated().at_least(1).collect::<String>()
}

fn not_any_bracket() -> impl Parser<char, String, Error = Cheap<char>> {
    none_of("[]").repeated().at_least(1).collect::<String>()
}

fn stray_end_bracket() -> impl Parser<char, String, Error = Cheap<char>> {
    just(']').map(|c| c.to_string())
}

fn text() -> impl Parser<char, TagsOrText, Error = Cheap<char>> {
    choice((escaped_bracket(), not_any_bracket(), stray_end_bracket()))
        .repeated()
        .at_least(1)
        .collect::<String>()
        .map(TagsOrText::Text)
}

fn tags_or_text() -> impl Parser<char, Vec<TagsOrText>, Error = Cheap<char>> {
    choice((text(), tag_block())).repeated().collect::<Vec<_>>()
}

pub fn parse_richtext(text: &str) -> Vec<TextSection> {
    let mut sections = vec![];
    let mut current_tags = vec![];

    let result = tags_or_text().parse(text);

    let tags_or_text = match result {
        Ok(tags_or_text) => tags_or_text,
        Err(errors) => {
            bevy::log::error!(
                "bevy_simple_rich_text failed to parse the input string. This should never happen."
            );
            bevy::log::error!("input: {}", text);
            for error in errors {
                bevy::log::error!(
                    "parsing failed at span {:?} with label {:?}",
                    error.span(),
                    error.label()
                );
            }

            sections.push(TextSection {
                value: "".to_string(),
                tags: current_tags,
            });

            return sections;
        }
    };

    for t in tags_or_text {
        match t {
            TagsOrText::Text(value) => sections.push(TextSection {
                value,
                tags: current_tags.clone(),
            }),
            TagsOrText::Tags(tags) => current_tags = tags,
        }
    }

    if sections.is_empty() {
        sections.push(TextSection {
            value: "".to_string(),
            tags: vec![],
        });
    }

    sections
}

#[test]
fn test_parser() {
    assert_eq!(
        tags_or_text().parse("[bold]"),
        Ok(vec![TagsOrText::Tags(vec!["bold".to_string()])])
    );
    assert_eq!(
        tags_or_text().parse("[[horse]]"),
        Ok(vec![TagsOrText::Text("[horse]".to_string())])
    );
    assert_eq!(
        tags_or_text().parse("[bold]Bold Text[italic]Italic Text"),
        Ok(vec![
            TagsOrText::Tags(vec!["bold".to_string()]),
            TagsOrText::Text("Bold Text".to_string()),
            TagsOrText::Tags(vec!["italic".to_string()]),
            TagsOrText::Text("Italic Text".to_string()),
        ])
    );
    assert_eq!(
        tags_or_text().parse("[]Text[]"),
        Ok(vec![
            TagsOrText::Tags(vec![]),
            TagsOrText::Text("Text".to_string()),
            TagsOrText::Tags(vec![]),
        ])
    );
    // escaping
    assert_eq!(
        tags_or_text().parse("[[]]][]"),
        Ok(vec![
            TagsOrText::Text("[]]".to_string()),
            TagsOrText::Tags(vec![]),
        ])
    );
    // multiple
    assert_eq!(
        tags_or_text().parse("[bold,italic]text"),
        Ok(vec![
            TagsOrText::Tags(vec!["bold".to_string(), "italic".to_string()]),
            TagsOrText::Text("text".to_string()),
        ])
    )
}

#[test]
fn test_empty() {
    let sections = parse_richtext("");

    assert_eq!(sections.len(), 1);
    assert_eq!(sections[0].value, "");
}

#[test]
fn test_sections() {
    let sections = parse_richtext("test1[red]test2[]test3[red,bold]test4");

    assert_eq!(sections.len(), 4);

    assert_eq!(sections[0].value, "test1");
    assert_eq!(sections[0].tags, Vec::<String>::new());
    assert_eq!(sections[1].value, "test2");
    assert_eq!(sections[1].tags, vec!["red".to_string()]);
    assert_eq!(sections[2].value, "test3");
    assert_eq!(sections[2].tags, Vec::<String>::new());
    assert_eq!(sections[3].value, "test4");
    assert_eq!(
        sections[3].tags,
        vec!["red".to_string(), "bold".to_string()]
    );
}