use sup_xml_core::xpath::{NodeId, XPathNodeKind};
use sup_xml_core::xpath::eval::{case_transform, english_words, WordCase};
#[derive(Default, Clone, Debug)]
pub struct FormatOptions {
pub ordinal: bool,
pub lang: Option<String>,
pub ordinal_scheme: Option<String>,
}
pub fn format_one(n: i64, format: &str) -> String {
format_one_opts(n, format, &FormatOptions::default())
}
pub fn format_one_opts(n: i64, format: &str, opts: &FormatOptions) -> String {
if format.is_empty() {
return if n < 0 { n.to_string() } else { n.to_string() };
}
let lang = opts.lang.as_deref().unwrap_or("");
match format {
"W" | "w" | "Ww" => {
let case = match format {
"W" => WordCase::Upper,
"w" => WordCase::Lower,
"Ww" => WordCase::Title,
_ => unreachable!(),
};
return localised_words(n, opts, case, lang);
}
_ => {}
}
let first = format.chars().next().unwrap();
match first {
'0' | '1' if n >= 0 => {
let pad = format.chars().take_while(|c| *c == '0' || *c == '1').count();
let mut s = if pad > 1 { format!("{:0width$}", n, width = pad) }
else { n.to_string() };
if opts.ordinal { s.push_str(ordinal_suffix(n)); }
return s;
}
'A' if n > 0 => return alpha_format_base(n, 'A', 26),
'a' if n > 0 => return alpha_format_base(n, 'a', 26),
'I' if n > 0 => return roman_format(n, true),
'i' if n > 0 => return roman_format(n, false),
_ => {}
}
if n < 0 { return n.to_string(); }
if format.chars().count() == 1 && n > 0 {
if first == '\u{03B1}' { return alpha_format_base(n, '\u{03B1}', 25); }
if first == '\u{0391}' { return alpha_format_base(n, '\u{0391}', 25); }
}
if format.chars().count() == 1 {
if let Some(fam) = digit_family(first) {
return fam.render(n);
}
}
if n == 0 { "0".into() } else { n.to_string() }
}
struct DigitFamily {
segments: &'static [Segment],
zero: Option<char>,
}
struct Segment {
from: i64,
to: i64,
start: char, }
impl DigitFamily {
fn render(&self, n: i64) -> String {
if n == 0 {
if let Some(z) = self.zero { return z.to_string(); }
return "0".into();
}
for seg in self.segments {
if n >= seg.from && n <= seg.to {
let cp = seg.start as u32 + (n - seg.from) as u32;
if let Some(c) = char::from_u32(cp) {
return c.to_string();
}
}
}
n.to_string()
}
}
fn digit_family(token: char) -> Option<DigitFamily> {
static CIRCLED: &[Segment] = &[
Segment { from: 1, to: 20, start: '\u{2460}' },
Segment { from: 21, to: 35, start: '\u{3251}' },
Segment { from: 36, to: 50, start: '\u{32B1}' },
];
static PARENTHESIZED: &[Segment] = &[
Segment { from: 1, to: 20, start: '\u{2474}' },
];
static FULL_STOP: &[Segment] = &[
Segment { from: 1, to: 20, start: '\u{2488}' },
];
static DOUBLE_CIRCLED: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{24F5}' },
];
static NEG_CIRCLED: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{2776}' },
Segment { from: 11, to: 20, start: '\u{24EB}' },
];
static SANS_CIRCLED: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{2780}' },
];
static NEG_SANS_CIRCLED: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{278A}' },
];
static PAREN_IDEO: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{3220}' },
];
static CIRCLED_IDEO: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{3280}' },
];
static AEGEAN: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{10107}' },
];
static COPTIC_EPACT: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{102E1}' },
];
static RUMI: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{10E60}' },
];
static BRAHMI: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{11052}' },
];
static SINHALA: &[Segment] = &[
Segment { from: 1, to: 10, start: '\u{111E1}' },
];
static ROD_UNIT: &[Segment] = &[
Segment { from: 1, to: 9, start: '\u{1D360}' },
];
static MENDE: &[Segment] = &[
Segment { from: 1, to: 9, start: '\u{1E8C7}' },
];
static COMMA: &[Segment] = &[
Segment { from: 1, to: 9, start: '\u{1F102}' },
];
match token {
'\u{2460}' => Some(DigitFamily { segments: CIRCLED, zero: Some('\u{24EA}') }),
'\u{2474}' => Some(DigitFamily { segments: PARENTHESIZED, zero: None }),
'\u{2488}' => Some(DigitFamily { segments: FULL_STOP, zero: Some('\u{1F100}') }),
'\u{24F5}' => Some(DigitFamily { segments: DOUBLE_CIRCLED, zero: None }),
'\u{2776}' => Some(DigitFamily { segments: NEG_CIRCLED, zero: Some('\u{24FF}') }),
'\u{2780}' => Some(DigitFamily { segments: SANS_CIRCLED, zero: Some('\u{1F10B}') }),
'\u{278A}' => Some(DigitFamily { segments: NEG_SANS_CIRCLED, zero: Some('\u{1F10C}') }),
'\u{3220}' => Some(DigitFamily { segments: PAREN_IDEO, zero: None }),
'\u{3280}' => Some(DigitFamily { segments: CIRCLED_IDEO, zero: None }),
'\u{10107}' => Some(DigitFamily { segments: AEGEAN, zero: None }),
'\u{102E1}' => Some(DigitFamily { segments: COPTIC_EPACT, zero: None }),
'\u{10E60}' => Some(DigitFamily { segments: RUMI, zero: None }),
'\u{11052}' => Some(DigitFamily { segments: BRAHMI, zero: None }),
'\u{111E1}' => Some(DigitFamily { segments: SINHALA, zero: None }),
'\u{1D360}' => Some(DigitFamily { segments: ROD_UNIT, zero: None }),
'\u{1E8C7}' => Some(DigitFamily { segments: MENDE, zero: None }),
'\u{1F102}' => Some(DigitFamily { segments: COMMA, zero: Some('\u{1F101}') }),
_ => None,
}
}
fn ordinal_suffix(n: i64) -> &'static str {
match (n.abs() % 100, n.abs() % 10) {
(11..=13, _) => "th",
(_, 1) => "st",
(_, 2) => "nd",
(_, 3) => "rd",
_ => "th",
}
}
fn alpha_format_base(mut n: i64, base: char, size: u32) -> String {
let mut out = String::new();
let size_i = size as i64;
while n > 0 {
n -= 1;
out.insert(0, char::from_u32(base as u32 + (n % size_i) as u32).unwrap_or(base));
n /= size_i;
}
out
}
fn roman_format(n: i64, upper: bool) -> String {
if n > 3999 || n <= 0 { return n.to_string(); }
let pairs = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
(1, "I"),
];
let mut n = n;
let mut out = String::new();
for &(val, sym) in &pairs {
while n >= val { out.push_str(sym); n -= val; }
}
if upper { out } else { out.to_lowercase() }
}
pub fn count_single_default<I: sup_xml_core::xpath::DocIndexLike>(node: NodeId, idx: &I) -> Option<i64> {
let parent = idx.parent(node)?;
if !matches!(idx.kind(parent), XPathNodeKind::Document | XPathNodeKind::Element) {
return None;
}
let target_name = idx.local_name(node);
let target_uri = idx.namespace_uri(node);
let target_kind = idx.kind(node);
let mut count: i64 = 0;
for &sib in idx.children(parent) {
if idx.kind(sib) != target_kind { continue; }
if idx.local_name(sib) != target_name { continue; }
if idx.namespace_uri(sib) != target_uri { continue; }
count += 1;
if sib == node { return Some(count); }
}
None
}
#[derive(Debug, Clone)]
pub struct FormatTokens {
pub prefix: String,
pub tokens: Vec<String>,
pub separators: Vec<String>,
}
pub fn parse_format(s: &str) -> FormatTokens {
let chars: Vec<char> = s.chars().collect();
let alphanum = |c: char| c.is_alphanumeric();
let mut i = 0;
let mut prefix = String::new();
while i < chars.len() && !alphanum(chars[i]) {
prefix.push(chars[i]); i += 1;
}
let mut tokens = Vec::new();
let mut separators = Vec::new();
while i < chars.len() {
let mut tok = String::new();
while i < chars.len() && alphanum(chars[i]) {
tok.push(chars[i]); i += 1;
}
if tok.is_empty() { break; }
tokens.push(tok);
let mut sep = String::new();
while i < chars.len() && !alphanum(chars[i]) {
sep.push(chars[i]); i += 1;
}
separators.push(sep);
}
if tokens.is_empty() {
tokens.push("1".into());
separators.push(prefix.clone());
}
FormatTokens { prefix, tokens, separators }
}
pub fn apply_grouping(s: &str, separator: &str, size: usize) -> String {
if size == 0 || separator.is_empty() { return s.to_string(); }
let mut out = String::with_capacity(s.len() + separator.len() * (s.len() / size));
let mut buf = String::new();
for ch in s.chars() {
if ch.is_ascii_digit() {
buf.push(ch);
} else {
if !buf.is_empty() { out.push_str(&group_digits(&buf, separator, size)); buf.clear(); }
out.push(ch);
}
}
if !buf.is_empty() { out.push_str(&group_digits(&buf, separator, size)); }
out
}
fn group_digits(digits: &str, sep: &str, size: usize) -> String {
let chars: Vec<char> = digits.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut i = chars.len();
while i > size {
out.push(chars[i - size..i].iter().collect());
i -= size;
}
out.push(chars[..i].iter().collect());
out.reverse();
out.join(sep)
}
pub fn format_list(ns: &[i64], fmt: &FormatTokens) -> String {
format_list_opts(ns, fmt, &FormatOptions::default())
}
pub fn format_list_opts(ns: &[i64], fmt: &FormatTokens, opts: &FormatOptions) -> String {
if ns.is_empty() {
let mut out = String::with_capacity(fmt.prefix.len() + 4);
out.push_str(&fmt.prefix);
if fmt.separators.len() >= fmt.tokens.len() {
if let Some(suffix) = fmt.separators.last() {
out.push_str(suffix);
}
}
return out;
}
let mut out = String::new();
out.push_str(&fmt.prefix);
let token_count = fmt.tokens.len();
let inter_seps: &[String] = if !fmt.separators.is_empty() && fmt.separators.len() >= token_count {
&fmt.separators[..token_count.saturating_sub(1)]
} else {
&fmt.separators[..]
};
let pick_sep = |i: usize| -> &str {
if let Some(s) = inter_seps.get(i) {
if s.is_empty() { "." } else { s.as_str() }
} else if let Some(last) = inter_seps.last() {
if last.is_empty() { "." } else { last.as_str() }
} else {
"."
}
};
for (i, &n) in ns.iter().enumerate() {
let tok = fmt.tokens.get(i).unwrap_or_else(||
fmt.tokens.last().expect("parse_format guarantees ≥1 token")
);
out.push_str(&format_one_opts(n, tok, opts));
if i + 1 < ns.len() {
out.push_str(pick_sep(i));
}
}
if fmt.separators.len() >= token_count {
if let Some(s) = fmt.separators.last() {
out.push_str(s);
}
}
out
}
pub struct CountMatcher<'a> {
ctx_kind: XPathNodeKind,
ctx_local: String,
ctx_uri: String,
pattern: Option<&'a sup_xml_core::xpath::Expr>,
}
impl<'a> CountMatcher<'a> {
pub fn new<I: sup_xml_core::xpath::DocIndexLike>(
ctx_node: NodeId,
pattern: Option<&'a sup_xml_core::xpath::Expr>,
idx: &I,
) -> Self {
Self {
ctx_kind: idx.kind(ctx_node),
ctx_local: idx.local_name(ctx_node).to_string(),
ctx_uri: idx.namespace_uri(ctx_node).to_string(),
pattern,
}
}
pub fn matches<I, F>(&self, node: NodeId, idx: &I, eval: &mut F) -> bool
where
I: sup_xml_core::xpath::DocIndexLike,
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> bool,
{
match self.pattern {
Some(p) => eval(p, node),
None => idx.kind(node) == self.ctx_kind
&& idx.local_name(node) == self.ctx_local
&& idx.namespace_uri(node) == self.ctx_uri,
}
}
}
pub fn count_level_any<I, F>(
context: NodeId,
from_root: NodeId,
matcher: &CountMatcher<'_>,
from_pattern: Option<&sup_xml_core::xpath::Expr>,
idx: &I,
eval: &mut F,
) -> Option<i64>
where
I: sup_xml_core::xpath::DocIndexLike,
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> bool,
{
let ctx_kind = idx.kind(context);
let (stop_at, ctx_is_attr_or_ns) = match ctx_kind {
sup_xml_core::xpath::XPathNodeKind::Attribute
| sup_xml_core::xpath::XPathNodeKind::Namespace => {
match idx.parent(context) {
Some(p) => (p, true),
None => return Some(0),
}
}
_ => (context, false),
};
let mut count: i64 = 0;
let walk_root = if from_pattern.is_some() {
doc_root(from_root, idx)
} else { from_root };
for node in descendants_or_self_in_doc_order(walk_root, idx) {
if let Some(pat) = from_pattern {
if eval(pat, node) { count = 0; }
}
if matcher.matches(node, idx, eval) { count += 1; }
if node == stop_at { break; }
}
if ctx_is_attr_or_ns {
if let Some(pat) = from_pattern {
if eval(pat, context) { count = 0; }
}
if matcher.matches(context, idx, eval) { count += 1; }
}
Some(count)
}
pub fn count_level_multiple<I, F>(
context: NodeId,
from_root: NodeId,
matcher: &CountMatcher<'_>,
idx: &I,
eval: &mut F,
) -> Vec<i64>
where
I: sup_xml_core::xpath::DocIndexLike,
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> bool,
{
let mut chain = Vec::new();
let mut cur = Some(context);
while let Some(n) = cur {
chain.push(n);
if n == from_root { break; }
cur = idx.parent(n);
}
chain.reverse(); let kept: Vec<NodeId> = chain.into_iter()
.filter(|&n| matcher.matches(n, idx, eval))
.collect();
let mut out = Vec::with_capacity(kept.len());
for &n in &kept {
out.push(sibling_position(n, matcher, idx, eval));
}
out
}
fn sibling_position<I, F>(
node: NodeId,
matcher: &CountMatcher<'_>,
idx: &I,
eval: &mut F,
) -> i64
where
I: sup_xml_core::xpath::DocIndexLike,
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> bool,
{
let Some(parent) = idx.parent(node) else { return 1; };
let mut pos: i64 = 0;
for &sib in idx.children(parent) {
if matcher.matches(sib, idx, eval) { pos += 1; }
if sib == node { return pos.max(1); }
}
1
}
fn descendants_or_self_in_doc_order<I: sup_xml_core::xpath::DocIndexLike>(
root: NodeId, idx: &I,
) -> Vec<NodeId> {
let mut out = Vec::new();
fn walk<I: sup_xml_core::xpath::DocIndexLike>(
n: NodeId, idx: &I, out: &mut Vec<NodeId>,
) {
out.push(n);
for &c in idx.children(n) { walk(c, idx, out); }
}
walk(root, idx, &mut out);
out
}
pub fn resolve_from_root<I, F>(
context: NodeId,
from_pattern: Option<&sup_xml_core::xpath::Expr>,
idx: &I,
eval: &mut F,
) -> NodeId
where
I: sup_xml_core::xpath::DocIndexLike,
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> bool,
{
let pat = match from_pattern { Some(p) => p, None => return doc_root(context, idx) };
let mut cur = Some(context);
while let Some(n) = cur {
if eval(pat, n) { return n; }
cur = idx.parent(n);
}
doc_root(context, idx)
}
fn doc_root<I: sup_xml_core::xpath::DocIndexLike>(start: NodeId, idx: &I) -> NodeId {
let mut cur = start;
while let Some(p) = idx.parent(cur) { cur = p; }
cur
}
fn localised_words(n: i64, opts: &FormatOptions, case: WordCase, lang: &str) -> String {
let lang_lc = lang.to_ascii_lowercase();
let words = match lang_lc.as_str() {
"de" => {
if opts.ordinal { german_ordinal(n) }
else { german_cardinal(n) }
}
"it" => {
let ord = opts.ordinal_scheme.as_deref().unwrap_or("");
if ord.contains("feminine") { italian_ordinal(n, true) }
else if ord.contains("masculine") || opts.ordinal {
italian_ordinal(n, false)
} else {
italian_cardinal(n)
}
}
_ => return english_words(n, opts.ordinal, case),
};
case_transform(&words, case)
}
fn german_cardinal(n: i64) -> String {
if n < 0 { return format!("minus {}", german_cardinal(-n)); }
const UNDER_20: &[&str] = &[
"null", "eins", "zwei", "drei", "vier", "fünf", "sechs",
"sieben", "acht", "neun", "zehn", "elf", "zwölf",
"dreizehn", "vierzehn", "fünfzehn", "sechzehn",
"siebzehn", "achtzehn", "neunzehn",
];
const TENS: &[&str] = &[
"", "", "zwanzig", "dreißig", "vierzig", "fünfzig",
"sechzig", "siebzig", "achtzig", "neunzig",
];
if (0..20).contains(&n) {
return UNDER_20[n as usize].to_string();
}
if n < 100 {
let t = (n / 10) as usize;
let r = (n % 10) as usize;
if r == 0 { return TENS[t].to_string(); }
let unit = if r == 1 { "ein" } else { UNDER_20[r] };
return format!("{unit}und{}", TENS[t]);
}
if n < 1000 {
let h = (n / 100) as usize;
let r = n % 100;
let head = format!("{}hundert", if h == 1 { "ein" } else { UNDER_20[h] });
if r == 0 { head } else { format!("{head}{}", german_cardinal(r)) }
} else if n < 1_000_000 {
let th = n / 1000;
let r = n % 1000;
let head = if th == 1 { "eintausend".to_string() }
else { format!("{} tausend", german_cardinal(th)) };
if r == 0 { head } else { format!("{head}{}", german_cardinal(r)) }
} else if n < 1_000_000_000 {
let mil = n / 1_000_000;
let r = n % 1_000_000;
let mil_word = if mil == 1 { "Eine Million" } else { "Millionen" };
let head = if mil == 1 { mil_word.to_string() }
else { format!("{} {mil_word}", german_cardinal(mil)) };
if r == 0 { head } else { format!("{head} {}", german_cardinal(r)) }
} else {
n.to_string()
}
}
fn german_ordinal(n: i64) -> String {
let card = german_cardinal(n);
format!("{card}ter")
}
fn italian_cardinal(n: i64) -> String {
const UNDER_20: &[&str] = &[
"zero", "uno", "due", "tre", "quattro", "cinque", "sei",
"sette", "otto", "nove", "dieci", "undici", "dodici",
"tredici", "quattordici", "quindici", "sedici",
"diciassette", "diciotto", "diciannove",
];
if (0..20).contains(&n) {
return UNDER_20[n as usize].to_string();
}
n.to_string()
}
fn italian_ordinal(n: i64, feminine: bool) -> String {
let stem = match n {
1 => "prim", 2 => "second", 3 => "terz",
4 => "quart", 5 => "quint", 6 => "sest",
7 => "settim", 8 => "ottav", 9 => "non",
10 => "decim",
_ => return n.to_string(),
};
let suf = if feminine { "a" } else { "o" };
format!("{stem}{suf}")
}
#[cfg(test)]
mod tests {
use super::*;
use sup_xml_core::{parse_str, ParseOptions, XPathContext};
use sup_xml_core::xpath::eval::Value;
#[test]
fn format_arabic_default() {
assert_eq!(format_one(7, "1"), "7");
assert_eq!(format_one(42, "1"), "42");
}
#[test]
fn format_unicode_digit_families() {
assert_eq!(format_one(0, "\u{2780}"), "\u{1F10B}");
assert_eq!(format_one(1, "\u{2780}"), "\u{2780}");
assert_eq!(format_one(10, "\u{2780}"), "\u{2789}");
assert_eq!(format_one(11, "\u{2780}"), "11");
assert_eq!(format_one(0, "\u{10107}"), "0");
assert_eq!(format_one(10, "\u{10107}"), "\u{10110}");
assert_eq!(format_one(11, "\u{10107}"), "11");
assert_eq!(format_one(10, "\u{111E1}"), "\u{111EA}");
}
#[test]
fn format_zero_padded() {
assert_eq!(format_one(3, "001"), "003");
assert_eq!(format_one(100, "001"), "100");
}
#[test]
fn format_zero_value_honours_pad_width() {
assert_eq!(format_one(0, "01"), "00");
assert_eq!(format_one(0, "001"), "000");
assert_eq!(format_one(0, "1"), "0");
}
#[test]
fn format_alpha_upper() {
assert_eq!(format_one(1, "A"), "A");
assert_eq!(format_one(26, "A"), "Z");
assert_eq!(format_one(27, "A"), "AA");
assert_eq!(format_one(28, "A"), "AB");
}
#[test]
fn format_alpha_lower() {
assert_eq!(format_one(1, "a"), "a");
assert_eq!(format_one(2, "a"), "b");
}
#[test]
fn format_roman_upper() {
assert_eq!(format_one(1, "I"), "I");
assert_eq!(format_one(4, "I"), "IV");
assert_eq!(format_one(9, "I"), "IX");
assert_eq!(format_one(1994, "I"), "MCMXCIV");
}
#[test]
fn format_roman_lower() {
assert_eq!(format_one(4, "i"), "iv");
}
#[test]
fn count_single_default_works() {
let doc = parse_str("<r><i/><i/><i/></r>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let nodes = match ctx.eval("/r/i").unwrap() { Value::NodeSet(ns) => ns, _ => panic!() };
assert_eq!(count_single_default(nodes[0], &ctx.index), Some(1));
assert_eq!(count_single_default(nodes[1], &ctx.index), Some(2));
assert_eq!(count_single_default(nodes[2], &ctx.index), Some(3));
}
}