use super::token::EnglishToken;
pub fn is_standing_alone(prev: Option<&EnglishToken>, next: Option<&EnglishToken>) -> bool {
is_standing_alone_impl(prev, next, None)
}
pub fn is_standing_alone_at(tokens: &[EnglishToken], i: usize) -> bool {
let prev = i.checked_sub(1).map(|p| &tokens[p]);
let next = tokens.get(i + 1);
let before_apostrophe = if matches!(prev, Some(EnglishToken::Symbol('\''))) {
Some(i.checked_sub(2).map(|p| &tokens[p]))
} else {
None
};
let stripped_next = {
let mut r = i + 1;
while matches!(
tokens.get(r),
Some(EnglishToken::Symbol(s)) if is_strippable_following_run_symbol(*s)
) {
r += 1;
}
tokens.get(r)
};
let stripped_ok = match stripped_next {
None | Some(EnglishToken::Space | EnglishToken::LineBreak) => true,
Some(
EnglishToken::Word(_) | EnglishToken::WordDivision { .. } | EnglishToken::Number(_),
) => {
std::ptr::eq(
stripped_next.unwrap(),
next.unwrap_or(stripped_next.unwrap()),
)
}
Some(EnglishToken::Symbol(_)) => std::ptr::eq(
stripped_next.unwrap(),
next.unwrap_or(stripped_next.unwrap()),
),
_ => true,
};
stripped_ok && is_standing_alone_impl(prev, next, before_apostrophe)
}
fn is_standing_alone_impl(
prev: Option<&EnglishToken>,
next: Option<&EnglishToken>,
before_apostrophe: Option<Option<&EnglishToken>>,
) -> bool {
let prev_ok = match prev {
None | Some(EnglishToken::Space | EnglishToken::Symbol('\t')) => true,
Some(EnglishToken::Symbol('-' | '–' | '—')) => true,
Some(EnglishToken::Symbol('(' | '[' | '{' | '"' | '“' | '‘')) => true,
Some(EnglishToken::Symbol('\'')) => matches!(
before_apostrophe,
Some(
None | Some(EnglishToken::Space)
| Some(EnglishToken::Symbol(
'-' | '–' | '—' | '(' | '[' | '{' | '"' | '“'
))
)
),
Some(EnglishToken::Symbol(_)) => false,
Some(
EnglishToken::Word(_)
| EnglishToken::WordDivision { .. }
| EnglishToken::Number(_)
| EnglishToken::Styled(..)
| EnglishToken::Technical(_),
) => false,
Some(EnglishToken::LineBreak) => true,
};
let next_ok = match next {
None | Some(EnglishToken::Space | EnglishToken::Symbol('\t')) => true,
Some(EnglishToken::Symbol(s)) => is_following_transparent_symbol(*s),
Some(
EnglishToken::Word(_)
| EnglishToken::WordDivision { .. }
| EnglishToken::Number(_)
| EnglishToken::Styled(..)
| EnglishToken::Technical(_),
) => false,
Some(EnglishToken::LineBreak) => true,
};
prev_ok && next_ok
}
fn is_following_transparent_symbol(s: char) -> bool {
matches!(
s,
',' | ';'
| ':'
| '.'
| '\u{2026}'
| '!'
| '?'
| ')'
| ']'
| '}'
| '"'
| '”'
| '’'
| '\''
| '-'
| '–'
| '—'
)
}
fn is_strippable_following_run_symbol(s: char) -> bool {
matches!(
s,
',' | ';' | ':' | '.' | '\u{2026}' | '!' | '?' | ')' | ']' | '}' | '"' | '”' | '’'
)
}
pub fn lower_wordsign_usable(prev: Option<&EnglishToken>, next: Option<&EnglishToken>) -> bool {
is_lower_anchor(prev) && is_lower_anchor(next)
}
fn is_lower_anchor(boundary: Option<&EnglishToken>) -> bool {
matches!(
boundary,
None | Some(EnglishToken::Space | EnglishToken::Symbol('\t'))
| Some(EnglishToken::Symbol('(' | ')' | '[' | ']' | '{' | '}'))
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounded_by_spaces_stands_alone() {
assert!(is_standing_alone(
Some(&EnglishToken::Space),
Some(&EnglishToken::Space)
));
}
#[test]
fn text_boundaries_stand_alone() {
assert!(is_standing_alone(None, None));
}
#[test]
fn following_apostrophe_s_still_stands_alone() {
assert!(is_standing_alone(
Some(&EnglishToken::Space),
Some(&EnglishToken::Symbol('\''))
));
}
#[test]
fn after_apostrophe_does_not_stand_alone() {
assert!(!is_standing_alone(Some(&EnglishToken::Symbol('\'')), None));
}
#[rstest::rstest]
#[case::dollar(EnglishToken::Symbol('$'), false)]
#[case::pound(EnglishToken::Symbol('£'), false)]
#[case::yen(EnglishToken::Symbol('¥'), false)]
#[case::euro(EnglishToken::Symbol('€'), false)]
#[case::period(EnglishToken::Symbol('.'), true)]
#[case::question(EnglishToken::Symbol('?'), true)]
fn following_currency_breaks_standing_alone(
#[case] next: EnglishToken,
#[case] expected: bool,
) {
assert_eq!(is_standing_alone(None, Some(&next)), expected);
}
#[test]
fn adjacent_number_breaks_standing_alone() {
assert!(!is_standing_alone(
None,
Some(&EnglishToken::Number(vec!['1']))
));
}
#[rstest::rstest]
#[case::open_paren(Some(EnglishToken::Symbol('(')), true)]
#[case::open_quote(Some(EnglishToken::Symbol('"')), true)]
#[case::hyphen(Some(EnglishToken::Symbol('-')), true)]
#[case::en_dash(Some(EnglishToken::Symbol('–')), true)]
#[case::leading_period(Some(EnglishToken::Symbol('.')), false)]
#[case::leading_comma(Some(EnglishToken::Symbol(',')), false)]
#[case::leading_slash(Some(EnglishToken::Symbol('/')), false)]
fn leading_punctuation_asymmetry(#[case] prev: Option<EnglishToken>, #[case] expected: bool) {
assert_eq!(
is_standing_alone(prev.as_ref(), Some(&EnglishToken::Space)),
expected
);
}
/// §10.5 lower-sign rule: anchored by space/edge/bracket → usable; any other
/// lower-sign neighbour (quote, hyphen, dash, `?`, `.`) → spell out.
#[rstest::rstest]
#[case::spaces(Some(EnglishToken::Space), Some(EnglishToken::Space), true)]
#[case::text_edges(None, None, true)]
#[case::parens(Some(EnglishToken::Symbol('(')), Some(EnglishToken::Symbol(')')), true)]
#[case::brackets(Some(EnglishToken::Symbol('[')), Some(EnglishToken::Symbol(']')), true)]
#[case::paren_then_space(Some(EnglishToken::Symbol('(')), Some(EnglishToken::Space), true)]
#[case::open_quote_before(Some(EnglishToken::Symbol('"')), Some(EnglishToken::Space), false)]
#[case::question_after(Some(EnglishToken::Space), Some(EnglishToken::Symbol('?')), false)]
#[case::hyphen_after(Some(EnglishToken::Space), Some(EnglishToken::Symbol('-')), false)]
#[case::period_after(Some(EnglishToken::Space), Some(EnglishToken::Symbol('.')), false)]
#[case::apostrophe_after(Some(EnglishToken::Space), Some(EnglishToken::Symbol('\'')), false)]
fn lower_wordsign_anchoring(
#[case] prev: Option<EnglishToken>,
#[case] next: Option<EnglishToken>,
#[case] expected: bool,
) {
assert_eq!(
lower_wordsign_usable(prev.as_ref(), next.as_ref()),
expected
);
}
#[test]
fn strips_following_punctuation_run_before_deciding_attachment() {
let tokens = vec![
EnglishToken::Word(vec!['b', 'u', 't']),
EnglishToken::Symbol('.'),
EnglishToken::Symbol('?'),
EnglishToken::Space,
];
assert!(is_standing_alone_at(&tokens, 0));
let attached = vec![
EnglishToken::Word(vec!['b', 'u', 't']),
EnglishToken::Symbol('.'),
EnglishToken::Symbol('?'),
EnglishToken::Word(vec!['n', 'o', 't']),
];
assert!(!is_standing_alone_at(&attached, 0));
}
#[test]
fn standing_alone_at_handles_first_token_and_opening_apostrophe() {
let first = vec![EnglishToken::Word(vec!['b', 'u', 't']), EnglishToken::Space];
assert!(is_standing_alone_at(&first, std::hint::black_box(0)));
let elision = vec![
EnglishToken::Symbol('\''),
EnglishToken::Word(vec!['a', 's']),
EnglishToken::Space,
];
assert!(is_standing_alone_at(&elision, std::hint::black_box(1)));
}
#[test]
fn standing_alone_at_reads_neighbors_and_strips_run() {
let tokens = vec![
EnglishToken::Space,
EnglishToken::Word(vec!['i', 't']),
EnglishToken::Symbol(','),
EnglishToken::Symbol('”'),
EnglishToken::LineBreak,
];
assert!(is_standing_alone_at(&tokens, std::hint::black_box(1)));
}
}