teacat_lib 0.4.1

Tools for working with TeaCat files
Documentation
use std::collections::HashMap;

use crate::{
	CatResult, Moo,
	error::TeaCatErr,
	parser::{
		escape_codes::proc_str,
		markup::{parse_header, parse_markup},
		tags::parse_tag,
	},
};
use grammar::{Rule, TeaCatGrammar};
use pest::{
	Parser,
	iterators::{Pair, Pairs},
};

mod escape_codes;
mod markup;
mod tags;

type TeaPair<'i> = Pair<'i, Rule>;
type TeaPairs<'i> = Pairs<'i, Rule>;

#[derive(Debug, PartialEq, Clone)]
pub struct TeaCatAst<'input>(pub Vec<AstNode<'input>>);

#[derive(Debug, PartialEq, Clone)]
pub enum AstNode<'input> {
	Space,
	Word(Moo<'input>),
	Tag(Tag<'input>),
	Markup {
		of: MarkupType,
		inner: TeaCatAst<'input>,
	},
}

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum MarkupType {
	Bold,
	Italic,
	Strikethrough,
	Underline,
	Header(u8),
}

#[derive(Debug, PartialEq, Clone, Default)]
pub struct Attributes<'input>(pub HashMap<Moo<'input>, Moo<'input>>);

#[derive(Debug, PartialEq, Clone)]
pub struct Tag<'input> {
	pub name: Moo<'input>,
	pub attrs: Attributes<'input>,
	pub content: TeaCatAst<'input>,
}

impl<'input> TryFrom<&'input str> for TeaCatAst<'input> {
	type Error = TeaCatErr;

	fn try_from(src: &'input str) -> CatResult<Self> {
		let pair = TeaCatGrammar::parse(Rule::teacat, src)?
			.next()
			.expect("Parsing should output something")
			.into_inner()
			.next()
			.expect("Rule teacat must not be empty!");

		assert_eq!(pair.as_rule(), Rule::line_sequence);
		Ok(parse_line_sequence(pair))
	}
}

fn parse_line_sequence(pair: TeaPair) -> TeaCatAst {
	assert_eq!(pair.as_rule(), Rule::line_sequence);

	let mut inner = pair
		.into_inner()
		.map(parse_line)
		.filter(|line| !matches!(line[..], [] | [AstNode::Space]))
		.flat_map(|mut line| {
			if line.last().is_some_and(|item| *item != AstNode::Space) {
				line.push(AstNode::Space);
			}
			line
		})
		.collect::<Vec<_>>();

	// Remove trailing space
	inner.pop();

	TeaCatAst(inner)
}

fn parse_line(pair: TeaPair) -> Vec<AstNode> {
	assert_eq!(pair.as_rule(), Rule::line);

	let pair = pair.into_inner().next().expect("Line must contain rule!");

	match pair.as_rule() {
		Rule::tag_raw | Rule::tag_sameline | Rule::tag_multiline => {
			vec![parse_tag(pair.into_inner())]
		}

		Rule::header => vec![parse_header(pair)],
		Rule::line_content => parse_line_content(pair),
		other => panic!("Unexpected rule in line: {other:?}"),
	}
}

fn parse_line_content(pair: TeaPair) -> Vec<AstNode> {
	assert_eq!(pair.as_rule(), Rule::line_content);

	pair.into_inner()
		.map(|pair| match pair.as_rule() {
			Rule::tag_inline => parse_tag(pair.into_inner()),
			Rule::markup => parse_markup(pair),
			Rule::word => AstNode::Word(proc_str(pair)),
			Rule::space => AstNode::Space,
			other => panic!("Unexpected rule in line_content: {other:?}"),
		})
		.collect()
}

pub(crate) mod grammar {
	use pest_derive::Parser;

	#[derive(Parser)]
	#[grammar = "teacat.pest"]
	pub struct TeaCatGrammar;
}

#[cfg(test)]
mod tests {
	use std::collections::HashMap;

	use crate::parser::{Attributes, MarkupType, Tag};

	use super::{AstNode, TeaCatAst, grammar::*};
	use pest::Parser;

	/// Shorthand for making an AST
	macro_rules! tc_ast {
		($($elem:expr),*) => { TeaCatAst(vec![$($elem),*]) };
	}

	/// Shorthand for making a tag
	macro_rules! tc_tag {
		($tagname:ident ( $($argname:ident $content:literal),* ) $(,$item:expr)* $(,)?) => {
			AstNode::Tag(Tag {
				name: stringify!($tagname).into(),
				attrs: Attributes(HashMap::from([
					$((
						stringify!($argname).into(),
						$content.into()
					)),*
				])),
				content: tc_ast!($($item),*),
			})
		};
	}

	#[test]
	fn text() {
		parse("hello chat").unwrap();
	}

	#[test]
	fn single_line_tag() {
		parse(":test test\n").unwrap();
	}

	#[test]
	fn inline_tag() {
		parse(":test[test]").unwrap();
	}

	#[test]
	fn multi_line_tag() {
		parse(":test [\ntest\n]\n").unwrap();
	}

	#[test]
	fn pain() {
		assert!(parse(":a :a[\n]testing").is_err());
	}

	#[test]
	fn escape_codes() {
		parse(r":test \t\n\s\:3\u[1F431]").unwrap();
	}

	#[test]
	fn markup() {
		let ast =
			TeaCatAst::try_from("+bold+ and *italic* and ~strikethrough~ and _underline_").unwrap();
		let and = AstNode::Word("and".into());
		assert_eq!(
			ast,
			tc_ast![
				AstNode::Markup {
					of: MarkupType::Bold,
					inner: tc_ast![AstNode::Word("bold".into())]
				},
				AstNode::Space,
				and.clone(),
				AstNode::Space,
				AstNode::Markup {
					of: MarkupType::Italic,
					inner: tc_ast![AstNode::Word("italic".into())]
				},
				AstNode::Space,
				and.clone(),
				AstNode::Space,
				AstNode::Markup {
					of: MarkupType::Strikethrough,
					inner: tc_ast![AstNode::Word("strikethrough".into())]
				},
				AstNode::Space,
				and.clone(),
				AstNode::Space,
				AstNode::Markup {
					of: MarkupType::Underline,
					inner: tc_ast![AstNode::Word("underline".into())]
				}
			]
		);
	}

	#[test]
	fn markup_precedence() {
		let ast = TeaCatAst::try_from("+a*b+c*").unwrap();
		assert_eq!(
			ast,
			tc_ast![
				AstNode::Word("+a".into()),
				AstNode::Markup {
					of: MarkupType::Italic,
					inner: tc_ast![AstNode::Word("b+c".into())]
				}
			]
		);
	}

	#[test]
	fn escape_codes2() {
		let ast = TeaCatAst::try_from(r"\t\[\u[1f431]").unwrap();
		assert_eq!(ast, tc_ast![AstNode::Word("\t[🐱".into())]);
	}

	#[test]
	fn args() {
		let ast = TeaCatAst::try_from(r#":t(a "haii", b "chat")[]"#).unwrap();
		assert_eq!(ast, tc_ast![tc_tag![t(a "haii", b "chat")]]);
	}

	/// Direct wrapper around `TeaCatGrammar::parse`
	fn parse(input: &str) -> Result<pest::iterators::Pairs<'_, Rule>, pest::error::Error<Rule>> {
		TeaCatGrammar::parse(Rule::teacat, input)
	}
}