use crate::SyntaxKind;
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum HeaderDefect {
StringAfterTagOrLink,
TooManyStrings(usize),
UnexpectedToken,
}
const fn is_header_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT
| SyntaxKind::PERCENT_COMMENT
| SyntaxKind::SHEBANG
| SyntaxKind::EMACS_DIRECTIVE
| SyntaxKind::BOM
| SyntaxKind::PIPE
)
}
fn is_flag_position_token(kind: SyntaxKind, range: &Range<usize>) -> bool {
match kind {
SyntaxKind::DATE
| SyntaxKind::STAR
| SyntaxKind::PENDING_KW
| SyntaxKind::FLAG
| SyntaxKind::HASH
| SyntaxKind::TXN_KW => true,
SyntaxKind::CURRENCY => range.len() == 1,
_ => false,
}
}
pub(super) fn first_header_defect<I>(tokens: I) -> Option<(HeaderDefect, Range<usize>)>
where
I: IntoIterator<Item = (SyntaxKind, Range<usize>)>,
{
let mut strings: usize = 0;
let mut third_string: Option<Range<usize>> = None;
let mut seen_tag_or_link = false;
let mut seen_header_content = false;
for (kind, range) in tokens {
if is_header_trivia(kind) {
continue;
}
match kind {
SyntaxKind::STRING => {
if seen_tag_or_link {
return Some((HeaderDefect::StringAfterTagOrLink, range));
}
strings += 1;
if strings == 3 {
third_string = Some(range);
}
seen_header_content = true;
}
SyntaxKind::TAG | SyntaxKind::LINK => {
seen_tag_or_link = true;
seen_header_content = true;
}
_ if !seen_header_content && is_flag_position_token(kind, &range) => {}
_ => return Some((HeaderDefect::UnexpectedToken, range)),
}
}
third_string.map(|r| (HeaderDefect::TooManyStrings(strings), r))
}
pub(super) fn defect_message(defect: HeaderDefect, text: &str) -> String {
match defect {
HeaderDefect::StringAfterTagOrLink => format!(
"unexpected string {text} after a tag or link: \
payee and narration must come before any #tag or ^link"
),
HeaderDefect::TooManyStrings(n) => format!(
"too many strings on transaction description: found {n}, \
expected at most 2 (payee and narration)"
),
HeaderDefect::UnexpectedToken => {
format!("unexpected {text:?} in transaction header")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn toks(items: &[(SyntaxKind, &str)]) -> Vec<(SyntaxKind, Range<usize>)> {
let mut at = 0usize;
items
.iter()
.map(|(k, t)| {
let r = at..at + t.len();
at = r.end;
(*k, r)
})
.collect()
}
const WS: (SyntaxKind, &str) = (SyntaxKind::WHITESPACE, " ");
#[test]
fn accepts_the_canonical_shapes() {
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2014-04-20"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"payee\""),
WS,
(SyntaxKind::STRING, "\"narration\""),
WS,
(SyntaxKind::TAG, "#trip"),
WS,
(SyntaxKind::LINK, "^abc"),
])),
None
);
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2014-04-20"),
WS,
(SyntaxKind::STRING, "\"n\""),
])),
None
);
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2014-01-01"),
WS,
(SyntaxKind::STAR, "*")
])),
None
);
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2014-01-01"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::COMMENT, "; note"),
])),
None
);
}
#[test]
fn accepts_single_char_currency_as_a_flag_but_not_elsewhere() {
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2012-12-17"),
WS,
(SyntaxKind::CURRENCY, "P"),
WS,
(SyntaxKind::STRING, "\"Payee\""),
WS,
(SyntaxKind::STRING, "\"Narration\""),
])),
None
);
assert!(matches!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2013-05-02"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"Dinner\""),
WS,
(SyntaxKind::CURRENCY, "A"),
])),
Some((HeaderDefect::UnexpectedToken, _))
));
assert!(matches!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2013-05-02"),
WS,
(SyntaxKind::CURRENCY, "USD"),
])),
Some((HeaderDefect::UnexpectedToken, _))
));
}
#[test]
fn rejects_a_string_after_a_tag() {
let got = first_header_defect(toks(&[
(SyntaxKind::DATE, "2014-04-20"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::TAG, "#trip"),
WS,
(SyntaxKind::STRING, "\"Money from CC\""),
WS,
(SyntaxKind::LINK, "^610fa7f17e7a"),
]));
let (defect, range) = got.expect("string after tag must be reported");
assert_eq!(defect, HeaderDefect::StringAfterTagOrLink);
assert_eq!(range.len(), "\"Money from CC\"".len());
}
#[test]
fn rejects_more_than_two_strings() {
let three = toks(&[
(SyntaxKind::DATE, "2013-05-18"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"A\""),
WS,
(SyntaxKind::STRING, "\"B\""),
WS,
(SyntaxKind::STRING, "\"C\""),
]);
assert_eq!(
first_header_defect(three.clone()).map(|(d, _)| d),
Some(HeaderDefect::TooManyStrings(3))
);
let third = three
.iter()
.filter(|(k, _)| *k == SyntaxKind::STRING)
.nth(2)
.expect("fixture has three strings")
.1
.clone();
let (_, range) = first_header_defect(three).unwrap();
assert_eq!(range, third);
let mut five = vec![
(SyntaxKind::DATE, "2013-05-18"),
WS,
(SyntaxKind::STAR, "*"),
];
for _ in 0..5 {
five.push(WS);
five.push((SyntaxKind::STRING, "\"x\""));
}
assert_eq!(
first_header_defect(toks(&five)).map(|(d, _)| d),
Some(HeaderDefect::TooManyStrings(5))
);
}
#[test]
fn two_strings_is_not_too_many() {
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2013-05-18"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"A\""),
WS,
(SyntaxKind::STRING, "\"B\""),
])),
None
);
}
#[test]
fn ignores_the_deprecated_pipe() {
assert_eq!(
first_header_defect(toks(&[
(SyntaxKind::DATE, "2013-05-18"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"payee\""),
WS,
(SyntaxKind::PIPE, "|"),
WS,
(SyntaxKind::STRING, "\"narration\""),
])),
None
);
}
#[test]
fn reports_only_the_first_defect() {
let stream = toks(&[
(SyntaxKind::DATE, "2013-05-02"),
WS,
(SyntaxKind::STAR, "*"),
WS,
(SyntaxKind::STRING, "\"Dinner\""),
WS,
(SyntaxKind::CURRENCY, "A"),
(SyntaxKind::COLON, ":"),
(SyntaxKind::STAR, "*"),
(SyntaxKind::COLON, ":"),
(SyntaxKind::CURRENCY, "B"),
]);
let first_junk = stream[6].1.clone();
let (defect, range) = first_header_defect(stream).expect("junk must be reported");
assert_eq!(defect, HeaderDefect::UnexpectedToken);
assert_eq!(range, first_junk);
}
}