use logos::Logos;
use std::fmt;
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl From<Range<usize>> for Span {
fn from(range: Range<usize>) -> Self {
Self {
start: range.start,
end: range.end,
}
}
}
impl From<Span> for Range<usize> {
fn from(span: Span) -> Self {
span.start..span.end
}
}
#[derive(Logos, Debug, Clone, PartialEq, Eq)]
pub enum Token<'src> {
#[regex(r"[ \t]+")]
Whitespace(&'src str),
#[regex(r"\d{4}[-/]\d{1,2}[-/]\d{1,2}")]
Date(&'src str),
#[regex(r"(\d{1,3}(,\d{3})*|\d+)(\.\d*)?")]
Number(&'src str),
#[regex(r#""([^"\\]|\\.)*""#)]
String(&'src str),
#[regex(r"[\p{Lu}\p{Lo}\p{Lt}][\p{L}0-9-]*(:([\p{Lu}\p{Lo}\p{Lt}0-9][\p{L}0-9-]*)+)+")]
Account(&'src str),
#[regex(r"/[A-Z][A-Z0-9'._-]*|[A-Z][A-Z0-9'._-]*", priority = 3)]
Currency(&'src str),
#[regex(r"#[a-zA-Z0-9-_/.]+")]
Tag(&'src str),
#[regex(r"\^[a-zA-Z0-9-_/.]+")]
Link(&'src str),
#[token("txn")]
Txn,
#[token("balance")]
Balance,
#[token("open")]
Open,
#[token("close")]
Close,
#[token("commodity")]
Commodity,
#[token("pad")]
Pad,
#[token("event")]
Event,
#[token("query")]
Query,
#[token("note")]
Note,
#[token("document")]
Document,
#[token("price")]
Price,
#[token("custom")]
Custom,
#[token("option")]
Option_,
#[token("include")]
Include,
#[token("plugin")]
Plugin,
#[token("pushtag")]
Pushtag,
#[token("poptag")]
Poptag,
#[token("pushmeta")]
Pushmeta,
#[token("popmeta")]
Popmeta,
#[token("TRUE")]
#[token("True")]
#[token("true")]
True,
#[token("FALSE")]
#[token("False")]
#[token("false")]
False,
#[token("NULL")]
Null,
#[token("{{")]
LDoubleBrace,
#[token("}}")]
RDoubleBrace,
#[token("{#")]
LBraceHash,
#[token("{")]
LBrace,
#[token("}")]
RBrace,
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token("@@")]
AtAt,
#[token("@")]
At,
#[token(":")]
Colon,
#[token(",")]
Comma,
#[token("~")]
Tilde,
#[token("|")]
Pipe,
#[token("+")]
Plus,
#[token("-")]
Minus,
#[token("*")]
Star,
#[token("/")]
Slash,
#[token("!")]
Pending,
#[regex(r"[PSTCURM?&]")]
Flag(&'src str),
#[regex(r"\r?\n")]
Newline,
#[regex(r";[^\n\r]*", allow_greedy = true)]
Comment(&'src str),
#[token("#")]
Hash,
#[regex(r"%[^\n\r]*", allow_greedy = true)]
PercentComment(&'src str),
#[regex(r"#![^\n\r]*", allow_greedy = true)]
Shebang(&'src str),
#[regex(r"#\+[^\n\r]*", allow_greedy = true)]
EmacsDirective(&'src str),
#[regex(r"[a-z][a-zA-Z0-9_-]*:")]
MetaKey(&'src str),
Indent(usize),
DeepIndent(usize),
Error(&'src str),
}
impl Token<'_> {
pub const fn is_txn_flag(&self) -> bool {
match self {
Self::Star | Self::Pending | Self::Flag(_) | Self::Hash => true,
Self::Currency(s) => s.len() == 1,
_ => false,
}
}
pub const fn is_directive_keyword(&self) -> bool {
matches!(
self,
Self::Txn
| Self::Balance
| Self::Open
| Self::Close
| Self::Commodity
| Self::Pad
| Self::Event
| Self::Query
| Self::Note
| Self::Document
| Self::Price
| Self::Custom
| Self::Option_
| Self::Include
| Self::Plugin
| Self::Pushtag
| Self::Poptag
| Self::Pushmeta
| Self::Popmeta
)
}
}
impl fmt::Display for Token<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Date(s) => write!(f, "{s}"),
Self::Number(s) => write!(f, "{s}"),
Self::String(s) => write!(f, "{s}"),
Self::Account(s) => write!(f, "{s}"),
Self::Currency(s) => write!(f, "{s}"),
Self::Tag(s) => write!(f, "{s}"),
Self::Link(s) => write!(f, "{s}"),
Self::Txn => write!(f, "txn"),
Self::Balance => write!(f, "balance"),
Self::Open => write!(f, "open"),
Self::Close => write!(f, "close"),
Self::Commodity => write!(f, "commodity"),
Self::Pad => write!(f, "pad"),
Self::Event => write!(f, "event"),
Self::Query => write!(f, "query"),
Self::Note => write!(f, "note"),
Self::Document => write!(f, "document"),
Self::Price => write!(f, "price"),
Self::Custom => write!(f, "custom"),
Self::Option_ => write!(f, "option"),
Self::Include => write!(f, "include"),
Self::Plugin => write!(f, "plugin"),
Self::Pushtag => write!(f, "pushtag"),
Self::Poptag => write!(f, "poptag"),
Self::Pushmeta => write!(f, "pushmeta"),
Self::Popmeta => write!(f, "popmeta"),
Self::True => write!(f, "TRUE"),
Self::False => write!(f, "FALSE"),
Self::Null => write!(f, "NULL"),
Self::LDoubleBrace => write!(f, "{{{{"),
Self::RDoubleBrace => write!(f, "}}}}"),
Self::LBraceHash => write!(f, "{{#"),
Self::LBrace => write!(f, "{{"),
Self::RBrace => write!(f, "}}"),
Self::LParen => write!(f, "("),
Self::RParen => write!(f, ")"),
Self::AtAt => write!(f, "@@"),
Self::At => write!(f, "@"),
Self::Colon => write!(f, ":"),
Self::Comma => write!(f, ","),
Self::Tilde => write!(f, "~"),
Self::Pipe => write!(f, "|"),
Self::Plus => write!(f, "+"),
Self::Minus => write!(f, "-"),
Self::Star => write!(f, "*"),
Self::Slash => write!(f, "/"),
Self::Pending => write!(f, "!"),
Self::Flag(s) => write!(f, "{s}"),
Self::Whitespace(s) => write!(f, "{s}"),
Self::Newline => write!(f, "\\n"),
Self::Comment(s) => write!(f, "{s}"),
Self::Hash => write!(f, "#"),
Self::PercentComment(s) => write!(f, "{s}"),
Self::Shebang(s) => write!(f, "{s}"),
Self::EmacsDirective(s) => write!(f, "{s}"),
Self::MetaKey(s) => write!(f, "{s}"),
Self::Indent(n) => write!(f, "<indent:{n}>"),
Self::DeepIndent(n) => write!(f, "<deep-indent:{n}>"),
Self::Error(s) => {
if s.contains(crate::bom::BOM_CHAR) {
let mut chunks = s.split(crate::bom::BOM_CHAR);
if let Some(first) = chunks.next() {
f.write_str(first)?;
}
for chunk in chunks {
f.write_str("<BOM>")?;
f.write_str(chunk)?;
}
Ok(())
} else {
f.write_str(s)
}
}
}
}
}
fn apply_err_layout_transparency(
invalid_text: &str,
span_start: usize,
at_line_start: &mut bool,
last_newline_end: &mut usize,
) {
let after_leading_bom = invalid_text.trim_start_matches(crate::bom::BOM_CHAR);
let leading_bom_bytes = invalid_text.len() - after_leading_bom.len();
if leading_bom_bytes > 0 && *at_line_start && span_start == *last_newline_end {
*last_newline_end = span_start + leading_bom_bytes;
}
let has_non_bom_byte = invalid_text.chars().any(|c| c != crate::bom::BOM_CHAR);
if has_non_bom_byte {
*at_line_start = false;
}
}
pub fn tokenize(source: &str) -> Vec<(Token<'_>, Span)> {
tokenize_inner(source, false)
}
pub fn tokenize_lossless(source: &str) -> Vec<(Token<'_>, Span)> {
tokenize_inner(source, true)
}
fn tokenize_inner(source: &str, keep_whitespace: bool) -> Vec<(Token<'_>, Span)> {
let mut tokens = Vec::new();
let mut lexer = Token::lexer(source);
let mut at_line_start = true;
let mut last_newline_end = 0usize;
while let Some(result) = lexer.next() {
let span = lexer.span();
if !keep_whitespace && matches!(result, Ok(Token::Whitespace(_))) {
continue;
}
match result {
Ok(Token::Newline) => {
tokens.push((Token::Newline, span.clone().into()));
at_line_start = true;
last_newline_end = span.end;
}
Ok(Token::Hash) if at_line_start && span.start == last_newline_end => {
let comment_start = span.start;
let line_end = source[span.end..]
.find('\n')
.map_or(source.len(), |i| span.end + i);
let comment_text = &source[comment_start..line_end];
tokens.push((
Token::Comment(comment_text),
Span {
start: comment_start,
end: line_end,
},
));
while let Some(peek_result) = lexer.next() {
let peek_span = lexer.span();
let peek_end = peek_span.end;
if peek_result == Ok(Token::Newline) {
tokens.push((Token::Newline, peek_span.into()));
at_line_start = true;
last_newline_end = peek_end;
break;
}
}
}
Ok(token) => {
if at_line_start && span.start > last_newline_end {
let leading = &source[last_newline_end..span.start];
let mut space_count = 0;
let mut char_count = 0;
for c in leading.chars() {
match c {
' ' => {
space_count += 1;
char_count += 1;
}
'\t' => {
space_count += 4; char_count += 1;
}
_ => break,
}
}
if space_count >= 1 {
let indent_start = last_newline_end;
let indent_end = last_newline_end + char_count;
let indent_token = if space_count >= 3 {
Token::DeepIndent(space_count)
} else {
Token::Indent(space_count)
};
tokens.push((
indent_token,
Span {
start: indent_start,
end: indent_end,
},
));
}
}
at_line_start = false;
tokens.push((token, span.into()));
}
Err(()) => {
let invalid_text = &source[span.clone()];
apply_err_layout_transparency(
invalid_text,
span.start,
&mut at_line_start,
&mut last_newline_end,
);
tokens.push((Token::Error(invalid_text), span.into()));
}
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize_date() {
let tokens = tokenize("2024-01-15");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Date("2024-01-15")));
}
#[test]
fn test_tokenize_date_single_digit_month() {
let tokens = tokenize("2024-1-15");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Date("2024-1-15")));
}
#[test]
fn test_tokenize_date_single_digit_day() {
let tokens = tokenize("2024-01-5");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Date("2024-01-5")));
}
#[test]
fn test_tokenize_date_single_digit_month_and_day() {
let tokens = tokenize("2024-1-1");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Date("2024-1-1")));
}
#[test]
fn test_tokenize_date_slash_separator_single_digit() {
let tokens = tokenize("2024/1/5");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Date("2024/1/5")));
}
#[test]
fn test_tokenize_number() {
let tokens = tokenize("1234.56");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Number("1234.56")));
let tokens = tokenize("-1,234.56");
assert_eq!(tokens.len(), 2);
assert!(matches!(tokens[0].0, Token::Minus));
assert!(matches!(tokens[1].0, Token::Number("1,234.56")));
}
#[test]
fn test_tokenize_account() {
let tokens = tokenize("Assets:Bank:Checking");
assert_eq!(tokens.len(), 1);
assert!(matches!(
tokens[0].0,
Token::Account("Assets:Bank:Checking")
));
}
#[test]
fn test_tokenize_account_unicode() {
let tokens = tokenize("Assets:CORP✨");
assert!(
!matches!(tokens[0].0, Token::Account("Assets:CORP✨")),
"Unicode emoji in account name should not tokenize as a valid Account"
);
assert!(
tokens.iter().any(|(t, _)| matches!(t, Token::Error(_))),
"Unicode emoji should produce at least one Error token"
);
let tokens = tokenize("Assets:沪深300");
assert!(
matches!(tokens[0].0, Token::Account("Assets:沪深300")),
"CJK characters at the start of a sub-component should tokenize as Account"
);
let tokens = tokenize("Assets:日本銀行");
assert!(
matches!(tokens[0].0, Token::Account("Assets:日本銀行")),
"CJK sub-component should tokenize as Account"
);
let tokens = tokenize("Капитал:Retained");
assert!(
matches!(tokens[0].0, Token::Account("Капитал:Retained")),
"Cyrillic-starting account should tokenize as Account"
);
let tokens = tokenize("资产:银行:支票");
assert!(
matches!(tokens[0].0, Token::Account("资产:银行:支票")),
"Fully CJK account should tokenize as Account"
);
}
#[test]
fn test_tokenize_account_unicode_letters_after_ascii_start() {
let tokens = tokenize("Assets:Banque-Épargne");
assert!(
matches!(tokens[0].0, Token::Account("Assets:Banque-Épargne")),
"accented Latin letter after ASCII start should tokenize as Account, got: {tokens:?}"
);
let tokens = tokenize("Assets:Müller");
assert!(
matches!(tokens[0].0, Token::Account("Assets:Müller")),
"German umlaut after ASCII start should tokenize as Account, got: {tokens:?}"
);
let tokens = tokenize("Assets:CorpJP日本");
assert!(
matches!(tokens[0].0, Token::Account("Assets:CorpJP日本")),
"CJK letters after ASCII start should tokenize as Account, got: {tokens:?}"
);
}
#[test]
fn test_tokenize_currency() {
let tokens = tokenize("USD");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Currency("USD")));
}
#[test]
fn test_tokenize_single_char_currency() {
let tokens = tokenize("T");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Currency("T")));
let tokens = tokenize("V");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Currency("V")));
let tokens = tokenize("F");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Currency("F")));
}
#[test]
fn test_single_char_currency_is_txn_flag() {
let token = Token::Currency("T");
assert!(token.is_txn_flag());
let token = Token::Currency("USD");
assert!(!token.is_txn_flag());
}
#[test]
fn test_tokenize_string() {
let tokens = tokenize(r#""Hello, World!""#);
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::String(r#""Hello, World!""#)));
}
#[test]
fn test_tokenize_keywords() {
let tokens = tokenize("txn balance open close");
assert_eq!(tokens.len(), 4);
assert!(matches!(tokens[0].0, Token::Txn));
assert!(matches!(tokens[1].0, Token::Balance));
assert!(matches!(tokens[2].0, Token::Open));
assert!(matches!(tokens[3].0, Token::Close));
}
#[test]
fn test_tokenize_tag_and_link() {
let tokens = tokenize("#my-tag ^my-link");
assert_eq!(tokens.len(), 2);
assert!(matches!(tokens[0].0, Token::Tag("#my-tag")));
assert!(matches!(tokens[1].0, Token::Link("^my-link")));
}
#[test]
fn test_tokenize_comment() {
let tokens = tokenize("; This is a comment");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::Comment("; This is a comment")));
}
#[test]
fn test_tokenize_indentation() {
let tokens = tokenize("txn\n Assets:Bank 100 USD");
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Indent(_))));
}
#[test]
fn test_display_token_error_strips_embedded_bom() {
let payload = "foo\u{FEFF}bar";
let s = format!("{}", Token::Error(payload));
assert_eq!(s, "foo<BOM>bar");
assert!(!s.contains(crate::bom::BOM_CHAR));
}
#[test]
fn test_tokenize_mid_file_bom_falls_into_error_path() {
let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF}";
let tokens = tokenize(source);
let has_bom_in_error = tokens.iter().any(|(t, _)| {
if let Token::Error(s) = t {
s.contains(crate::bom::BOM_CHAR)
} else {
false
}
});
assert!(
has_bom_in_error,
"mid-file BOM should fall into `Token::Error`, got: {tokens:?}"
);
}
#[test]
fn test_mid_file_bom_at_line_start_preserves_following_indent() {
let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF} meta-key: \"v\"\n";
let tokens = tokenize(source);
let has_bom_error = tokens.iter().any(|(t, _)| {
if let Token::Error(s) = t {
*s == crate::bom::BOM
} else {
false
}
});
assert!(
has_bom_error,
"expected Token::Error(\"\\u{{FEFF}}\") in stream, got: {tokens:?}"
);
let has_indent_2 = tokens.iter().any(|(t, _)| matches!(t, Token::Indent(2)));
assert!(
has_indent_2,
"mid-file BOM at line start must not swallow the following Indent; got: {tokens:?}"
);
assert!(
tokens
.iter()
.any(|(t, _)| matches!(t, Token::MetaKey("meta-key:"))),
"expected MetaKey after BOM-prefixed indent, got: {tokens:?}"
);
}
#[test]
fn test_consecutive_mid_file_boms_preserve_layout() {
let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF}\u{FEFF} meta-key: \"v\"\n";
let tokens = tokenize(source);
let bom_error_count = tokens
.iter()
.filter(|(t, _)| matches!(t, Token::Error(s) if *s == crate::bom::BOM))
.count();
assert_eq!(
bom_error_count, 2,
"expected 2 Token::Error(BOM) tokens, got: {tokens:?}"
);
let has_indent_2 = tokens.iter().any(|(t, _)| matches!(t, Token::Indent(2)));
assert!(
has_indent_2,
"consecutive mid-file BOMs at line start must not swallow following indent; \
got: {tokens:?}"
);
assert!(
tokens
.iter()
.any(|(t, _)| matches!(t, Token::MetaKey("meta-key:"))),
"expected MetaKey after consecutive-BOM-prefixed indent, got: {tokens:?}"
);
}
#[test]
fn err_layout_transparency_coalesced_double_bom_at_line_start() {
let invalid_text = "\u{FEFF}\u{FEFF}";
let span_start = 10;
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency(
invalid_text,
span_start,
&mut at_line_start,
&mut last_newline_end,
);
assert!(
at_line_start,
"all-BOM error span must preserve at_line_start"
);
assert_eq!(
last_newline_end,
10 + 2 * crate::bom::BOM_LEN,
"last_newline_end must advance past BOTH BOMs, not just the first"
);
}
#[test]
fn err_layout_transparency_coalesced_bom_with_trailing_content() {
let invalid_text = "\u{FEFF}\u{FEFF}xyz";
let span_start = 10;
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency(
invalid_text,
span_start,
&mut at_line_start,
&mut last_newline_end,
);
assert!(
!at_line_start,
"trailing non-BOM content must clobber at_line_start"
);
assert_eq!(
last_newline_end,
10 + 2 * crate::bom::BOM_LEN,
"last_newline_end advances past leading BOMs, NOT past trailing content"
);
}
#[test]
fn err_layout_transparency_non_bom_clobbers() {
let invalid_text = "garbage";
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency(invalid_text, 10, &mut at_line_start, &mut last_newline_end);
assert!(!at_line_start);
assert_eq!(last_newline_end, 10, "non-BOM error must not advance");
}
#[test]
fn err_layout_transparency_all_bom_not_at_line_start_is_noop() {
let invalid_text = "\u{FEFF}\u{FEFF}";
let span_start = 20;
let mut at_line_start = false; let mut last_newline_end = 10;
apply_err_layout_transparency(
invalid_text,
span_start,
&mut at_line_start,
&mut last_newline_end,
);
assert!(!at_line_start);
assert_eq!(last_newline_end, 10, "guard prevents stale advance");
}
#[test]
fn err_layout_transparency_all_bom_span_mismatch_is_noop() {
let invalid_text = "\u{FEFF}\u{FEFF}";
let mut at_line_start = true;
let span_start = 20;
let mut last_newline_end = 10;
apply_err_layout_transparency(
invalid_text,
span_start,
&mut at_line_start,
&mut last_newline_end,
);
assert!(
at_line_start,
"all-BOM error span must preserve at_line_start regardless of span-vs-last-newline match"
);
assert_eq!(
last_newline_end, 10,
"span_start != last_newline_end must prevent stale advance"
);
}
#[test]
fn err_layout_transparency_bom_only_in_any_arrangement_preserves() {
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency(
"\u{FEFF}\u{FEFF}",
10, &mut at_line_start,
&mut last_newline_end,
);
assert!(at_line_start, "all-BOM span preserves at_line_start");
assert_eq!(
last_newline_end, 16,
"leading BOM run advances last_newline_end past both BOM bytes \
(each BOM is 3 UTF-8 bytes)"
);
}
#[test]
fn err_layout_transparency_non_bom_head_clobbers() {
let mut at_line_start = true;
let mut last_newline_end = 0;
apply_err_layout_transparency("@@\u{FEFF}", 10, &mut at_line_start, &mut last_newline_end);
assert!(
!at_line_start,
"non-BOM head ('@@') clobbers at_line_start regardless of trailing BOM"
);
}
#[test]
fn err_layout_transparency_bom_head_non_bom_tail_clobbers() {
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency("\u{FEFF}@@", 10, &mut at_line_start, &mut last_newline_end);
assert!(
!at_line_start,
"non-BOM tail ('@@') clobbers at_line_start even though span starts with BOM"
);
assert_eq!(
last_newline_end, 13,
"leading BOM run STILL advances last_newline_end past the BOM"
);
}
#[test]
fn err_layout_transparency_bom_flanking_non_bom_clobbers() {
let mut at_line_start = true;
let mut last_newline_end = 10;
apply_err_layout_transparency(
"\u{FEFF}@@\u{FEFF}",
10,
&mut at_line_start,
&mut last_newline_end,
);
assert!(
!at_line_start,
"non-BOM middle ('@@') clobbers at_line_start"
);
assert_eq!(
last_newline_end, 13,
"leading BOM run advances last_newline_end past the leading BOM only"
);
}
#[test]
fn test_tokenize_transaction_line() {
let source = "2024-01-15 * \"Grocery Store\" #food\n Expenses:Food 50.00 USD";
let tokens = tokenize(source);
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Date(_))));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Star)));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::String(_))));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Tag(_))));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Newline)));
assert!(
tokens
.iter()
.any(|(t, _)| matches!(t, Token::Indent(_) | Token::DeepIndent(_)))
);
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Account(_))));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Number(_))));
assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Currency(_))));
}
#[test]
fn test_tokenize_metadata_key() {
let tokens = tokenize("filename:");
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].0, Token::MetaKey("filename:")));
}
#[test]
fn test_tokenize_punctuation() {
let tokens = tokenize("{ } @ @@ , ~");
let token_types: Vec<_> = tokens.iter().map(|(t, _)| t.clone()).collect();
assert!(token_types.contains(&Token::LBrace));
assert!(token_types.contains(&Token::RBrace));
assert!(token_types.contains(&Token::At));
assert!(token_types.contains(&Token::AtAt));
assert!(token_types.contains(&Token::Comma));
assert!(token_types.contains(&Token::Tilde));
}
}