// lang_ordinal.mbt — ordinal ("second") and ordinal_num ("2nd") for the six
// non-English languages, ported to match upstream num2words 0.5.14 output
// exactly (including its quirks; see DIFF_REPORT.md).
//
// Upstream raises TypeError for negative ordinals in every language except
// Italian (which prefixes "meno "). Our string API has no exceptions, so the
// negative case returns "" for those languages instead of crashing.
// --- shared helpers ---------------------------------------------------------
///|
/// True when `s` ends with the exact code-unit suffix `suf`.
fn ends_with(s : String, suf : String) -> Bool {
if s.length() < suf.length() {
return false
}
s[s.length() - suf.length():s.length()].to_owned() == suf
}
///|
/// Drop the last code unit of `s` (all ordinals touched here are BMP-only).
fn drop_last_char(s : String) -> String {
s[0:s.length() - 1].to_owned()
}
///|
/// Trim leading/trailing spaces (upstream calls Python's `str.strip()`).
fn trim_spaces(s : String) -> String {
let mut a = 0
let mut b = s.length()
while a < b && s[a:a + 1].to_owned() == " " {
a = a + 1
}
while b > a && s[b - 1:b].to_owned() == " " {
b = b - 1
}
s[a:b].to_owned()
}
///|
/// Lowercase ASCII letters only (German umlauts are already lowercase in the
/// cardinal tables; only scale words start uppercase).
fn lower_ascii(s : String) -> String {
let buf = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
if c >= 65 && c <= 90 {
buf.write_char((c + 32).to_char().unwrap())
} else {
buf.write_char(c.to_char().unwrap())
}
}
buf.to_string()
}
// --- French -----------------------------------------------------------------
//
// Upstream: 1 -> "premier"; otherwise take the cardinal, replace a trailing
// "cinq"/"neuf", else drop a trailing "e" and append "ième".
// ordinal_num: "1er", everything else "me".
///|
pub fn ordinal_fr(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return ""
}
if n == BigInt::from_int(1) {
return "premier"
}
let mut word = lang_fr().cardinal_fr(n)
if ends_with(word, "cinq") {
word = word[0:word.length() - 4].to_owned() + "cinquième"
} else if ends_with(word, "neuf") {
word = word[0:word.length() - 4].to_owned() + "neuvième"
} else {
if ends_with(word, "e") {
word = drop_last_char(word)
}
word = word + "ième"
}
word
}
///|
pub fn ordinal_num_fr(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return ""
}
if n == BigInt::from_int(1) {
"1er"
} else {
n.to_string() + "me"
}
}
// --- German -----------------------------------------------------------------
//
// Upstream: lowercase the cardinal, apply the first matching suffix rewrite,
// append "te", then fix up "eintausendste"/"einhundertste" and million words.
///|
fn de_ord_rewrite(word : String) -> String {
// Insertion-ordered, first match wins (Python dict semantics).
if ends_with(word, "eins") {
return word[0:word.length() - 4].to_owned() + "ers"
}
if ends_with(word, "drei") {
return word[0:word.length() - 4].to_owned() + "drit"
}
if ends_with(word, "acht") {
return word[0:word.length() - 4].to_owned() + "ach"
}
if ends_with(word, "sieben") {
return word[0:word.length() - 6].to_owned() + "sieb"
}
if ends_with(word, "ig") {
return word[0:word.length() - 2].to_owned() + "igs"
}
if ends_with(word, "ert") {
return word[0:word.length() - 3].to_owned() + "erts"
}
if ends_with(word, "end") {
return word[0:word.length() - 3].to_owned() + "ends"
}
if ends_with(word, "ion") {
return word[0:word.length() - 3].to_owned() + "ions"
}
if ends_with(word, "nen") {
return word[0:word.length() - 3].to_owned() + "ns"
}
if ends_with(word, "rde") {
return word[0:word.length() - 3].to_owned() + "rds"
}
if ends_with(word, "rden") {
return word[0:word.length() - 4].to_owned() + "rds"
}
word
}
///|
pub fn ordinal_de(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return ""
}
let word = de_ord_rewrite(lower_ascii(lang_de().cardinal_de(n)))
let mut res = word + "te"
// "tausendste"/"hundertste" are preferred over "eintausendste"/...
if res == "eintausendste" || res == "einhundertste" {
res = res[3:res.length()].to_owned()
}
// Ordinals on -illion/-illiarde words: no "eine"/space before them.
if ends_with(res, "illionste") || ends_with(res, "illiardste") {
if res.length() > 5 && res[0:5].to_owned() == "eine " {
res = res[5:res.length()].to_owned()
} else {
// Remove the space directly before the final million word.
let mut sp = res.length() - 1
while sp >= 0 && res[sp:sp + 1].to_owned() != " " {
sp = sp - 1
}
if sp > 0 {
res = res[0:sp].to_owned() + res[sp + 1:res.length()].to_owned()
}
}
}
res
}
///|
pub fn ordinal_num_de(n : BigInt) -> String {
if n < BigInt::from_int(0) {
""
} else {
n.to_string() + "."
}
}
// --- Italian ----------------------------------------------------------------
//
// Upstream: <20 uses a word table; compounds ending in 3 drop the accent and
// take "eesimo", ending in 6 take "esimo", everything else drops the final
// vowel ("mil" gains an extra "l"). Negatives are supported ("meno terzo").
// ordinal_num falls through to upstream's plain str(value).
///|
let it_ordinal_words : Array[String] = [
"zero", "primo", "secondo", "terzo", "quarto", "quinto", "sesto", "settimo", "ottavo",
"nono", "decimo", "undicesimo", "dodicesimo", "tredicesimo", "quattordicesimo",
"quindicesimo", "sedicesimo", "diciassettesimo", "diciottesimo", "diciannovesimo",
]
///|
pub fn ordinal_it(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return "meno " + ordinal_it(-n)
}
let hundred = BigInt::from_int(100)
let tens = n % hundred
let ten = BigInt::from_int(10)
let twenty = BigInt::from_int(20)
let is_outside_teens = !(ten < tens && tens < twenty)
if n < twenty {
return it_ordinal_words[n.to_int()]
}
let three = BigInt::from_int(3)
let six = BigInt::from_int(6)
if is_outside_teens && tens % ten == three {
// tens % 10 == 3: drop the accented final vowel, add "eesimo".
return drop_last_char(lang_it().cardinal_it(n)) + "eesimo"
}
if is_outside_teens && tens % ten == six {
return lang_it().cardinal_it(n) + "esimo"
}
let mut string = drop_last_char(lang_it().cardinal_it(n))
if ends_with(string, "mil") {
string = string + "l"
}
string + "esimo"
}
///|
pub fn ordinal_num_it(n : BigInt) -> String {
n.to_string()
}
// --- Spanish ----------------------------------------------------------------
//
// Faithful port of upstream's recursion, quirks included:
// * 0 -> "" (empty string),
// * 11/12 concatenate without a space ("décimoprimero"),
// * >= 10^18 falls back to the cardinal.
// ordinal_num: "º".
///|
fn es_ord_stem_small(v : Int) -> String {
match v {
1 => "primer"
2 => "segund"
3 => "tercer"
4 => "cuart"
5 => "quint"
6 => "sext"
7 => "séptim"
8 => "octav"
9 => "noven"
_ => "décim"
}
}
///|
fn es_ord_stem_tens(d : Int) -> String {
match d {
2 => "vigésim"
3 => "trigésim"
4 => "quadragésim"
5 => "quincuagésim"
6 => "sexagésim"
7 => "septuagésim"
8 => "octogésim"
_ => "nonagésim"
}
}
///|
fn es_ord_stem_hundreds(d : Int) -> String {
match d {
1 => "centésim"
2 => "ducentésim"
3 => "tricentésim"
4 => "cuadrigentésim"
5 => "quingentésim"
6 => "sexcentésim"
7 => "septigentésim"
8 => "octigentésim"
_ => "noningentésim"
}
}
///|
/// Scale stems for 10^(3k), k = 1..5 (1e3 .. 1e15).
fn es_ord_stem_scale(k : Int) -> String {
match k {
1 => "milésim"
2 => "millonésim"
3 => "billonésim"
4 => "trillonésim"
_ => "cuadrillonésim"
}
}
///|
fn es_ord_rec(v : BigInt) -> String {
let b0 = BigInt::from_int(0)
let b10 = BigInt::from_int(10)
let b12 = BigInt::from_int(12)
let b100 = BigInt::from_int(100)
let b1000 = BigInt::from_int(1000)
let raw = if v == b0 {
""
} else if v <= b10 {
es_ord_stem_small(v.to_int()) + "o"
} else if v <= b12 {
// Upstream has no space here: "décimoprimero".
"décimo" + es_ord_rec(v - b10)
} else if v <= b100 {
let dec = v / b10 * b10
let stem = if dec == b10 {
"décim"
} else if dec == b100 {
"centésim"
} else {
es_ord_stem_tens((dec / b10).to_int())
}
stem + "o" + " " + es_ord_rec(v - dec)
} else if v <= b1000 {
let cen = v / b100 * b100
let stem = if cen == b1000 {
"milésim"
} else {
es_ord_stem_hundreds((cen / b100).to_int())
}
stem + "o" + " " + es_ord_rec(v - cen)
} else if v < BigInt::from_string("1000000000000000000") {
// Largest 1000^k <= v, k capped at 5 (1e15).
let mut dec = b1000
let mut k = 1
while k < 5 && v >= dec * b1000 {
dec = dec * b1000
k = k + 1
}
let high = v / dec
let low = v % dec
let card = if high == BigInt::from_int(1) {
""
} else {
lang_es().cardinal_es(high)
}
card + es_ord_stem_scale(k) + "o" + " " + es_ord_rec(low)
} else {
// >= 1e18: upstream falls back to the cardinal.
lang_es().cardinal_es(v)
}
trim_spaces(raw)
}
///|
pub fn ordinal_es(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return ""
}
es_ord_rec(n)
}
///|
pub fn ordinal_num_es(n : BigInt) -> String {
if n < BigInt::from_int(0) {
""
} else {
n.to_string() + "º"
}
}
// --- Portuguese (pt_BR / pt_PT share ordinals) ------------------------------
//
// Upstream walks the decimal digits right-to-left, appending per-position
// ordinal words and scale separators, then collapses whitespace and drops a
// leading "primeiro " (the "primeiro milésimo" quirk). 0 -> "". Digits at
// position >= 18 have no separator table upstream (it would raise), so we
// return "".
///|
fn pt_ord_digit(pos : Int, d : Int) -> String {
match pos {
0 =>
match d {
1 => "primeiro"
2 => "segundo"
3 => "terceiro"
4 => "quarto"
5 => "quinto"
6 => "sexto"
7 => "sétimo"
8 => "oitavo"
9 => "nono"
_ => ""
}
1 =>
match d {
1 => "décimo"
2 => "vigésimo"
3 => "trigésimo"
4 => "quadragésimo"
5 => "quinquagésimo"
6 => "sexagésimo"
7 => "septuagésimo"
8 => "octogésimo"
9 => "nonagésimo"
_ => ""
}
_ =>
match d {
1 => "centésimo"
2 => "ducentésimo"
3 => "tricentésimo"
4 => "quadrigentésimo"
5 => "quingentésimo"
6 => "seiscentésimo"
7 => "septigentésimo"
8 => "octigentésimo"
9 => "nongentésimo"
_ => ""
}
}
}
///|
fn pt_thousand_separator(idx : Int) -> String {
match idx {
3 => "milésimo"
6 => "milionésimo"
9 => "bilionésimo"
12 => "trilionésimo"
15 => "quadrilionésimo"
_ => ""
}
}
///|
/// Shared Portuguese ordinal walker. `separator_at` supplies the scale word at
/// positions 3, 6, 9, ...: the Brazilian variant uses "bilionésimo" at 10^9,
/// while European Portuguese composes long-scale forms such as
/// "milésimo milionésimo".
fn pt_ordinal_with(n : BigInt, separator_at : (Int) -> String) -> String {
let digits = n.to_string()
let result : Array[String] = []
let mut separator = ""
let mut idx = 0
let mut i = digits.length() - 1
while i >= 0 {
let d = digits[i].to_int() - 48
if idx > 0 && idx % 3 == 0 {
let sep = separator_at(idx)
if sep == "" {
// Upstream would raise KeyError; treat as unsupported.
return ""
}
separator = sep
}
if d != 0 && separator != "" {
result.push(separator)
separator = ""
}
result.push(pt_ord_digit(idx % 3, d))
idx = idx + 1
i = i - 1
}
let rev : Array[String] = []
for j = 0; j < result.length(); j = j + 1 {
rev.push(result[result.length() - 1 - j])
}
let parts : Array[String] = []
for j = 0; j < rev.length(); j = j + 1 {
if rev[j] != "" {
parts.push(rev[j])
}
}
let mut out = join_parts(parts, " ")
if parts.length() > 0 && parts[0] == "primeiro" && digits != "1" {
out = out[9:out.length()].to_owned()
}
out
}
///|
pub fn ordinal_pt(n : BigInt) -> String {
if n < BigInt::from_int(0) {
""
} else {
pt_ordinal_with(n, pt_thousand_separator)
}
}
///|
pub fn ordinal_num_pt(n : BigInt) -> String {
if n < BigInt::from_int(0) {
""
} else {
n.to_string() + "º"
}
}
// --- Russian ----------------------------------------------------------------
//
// Port of upstream lang_RU._int2word(cardinal=False) with the default
// grammar (nominative, singular, masculine, animate). Two modes:
//
// * normal chunk: hundreds/tens/ones ordinalised only when they are the
// last spoken part, otherwise cardinal; scale words stay nominative;
// * fused chunk (when the number ends in 000): the whole group is written
// with genitive forms and glued to the scale ordinal into ONE word
// ("двухтысячный", "двадцатиоднотысячный").
///|
fn ru_ones_n(o : Int) -> String {
match o {
1 => "один"
2 => "два"
3 => "три"
4 => "четыре"
5 => "пять"
6 => "шесть"
7 => "семь"
8 => "восемь"
_ => "девять"
}
}
///|
fn ru_ones_f(o : Int) -> String {
match o {
1 => "одна"
2 => "две"
3 => "три"
4 => "четыре"
5 => "пять"
6 => "шесть"
7 => "семь"
8 => "восемь"
_ => "девять"
}
}
///|
fn ru_ones_g(o : Int) -> String {
match o {
1 => "одного"
2 => "двух"
3 => "трёх"
4 => "четырёх"
5 => "пяти"
6 => "шести"
7 => "семи"
8 => "восьми"
_ => "девяти"
}
}
///|
fn ru_ones_ord(o : Int) -> String {
match o {
0 => "нулевой"
1 => "первый"
2 => "второй"
3 => "третий"
4 => "четвёртый"
5 => "пятый"
6 => "шестой"
7 => "седьмой"
8 => "восьмой"
_ => "девятый"
}
}
///|
fn ru_tens_g(o : Int) -> String {
match o {
0 => "десяти"
1 => "одиннадцати"
2 => "двенадцати"
3 => "тринадцати"
4 => "четырнадцати"
5 => "пятнадцати"
6 => "шестнадцати"
7 => "семнадцати"
8 => "восемнадцати"
_ => "девятнадцати"
}
}
///|
fn ru_tens_ord(o : Int) -> String {
match o {
0 => "десятый"
1 => "одиннадцатый"
2 => "двенадцатый"
3 => "тринадцатый"
4 => "четырнадцатый"
5 => "пятнадцатый"
6 => "шестнадцатый"
7 => "семнадцатый"
8 => "восемнадцатый"
_ => "девятнадцатый"
}
}
///|
fn ru_twenties_n(t : Int) -> String {
match t {
2 => "двадцать"
3 => "тридцать"
4 => "сорок"
5 => "пятьдесят"
6 => "шестьдесят"
7 => "семьдесят"
8 => "восемьдесят"
_ => "девяносто"
}
}
///|
fn ru_twenties_g(t : Int) -> String {
match t {
2 => "двадцати"
3 => "тридцати"
4 => "сорока"
5 => "пятидесяти"
6 => "шестидесяти"
7 => "семидесяти"
8 => "восьмидесяти"
_ => "девяноста"
}
}
///|
fn ru_twenties_ord(t : Int) -> String {
match t {
2 => "двадцатый"
3 => "тридцатый"
4 => "сороковой"
5 => "пятидесятый"
6 => "шестидесятый"
7 => "семидесятый"
8 => "восьмидесятый"
_ => "девяностый"
}
}
///|
fn ru_hundreds_n(h : Int) -> String {
match h {
1 => "сто"
2 => "двести"
3 => "триста"
4 => "четыреста"
5 => "пятьсот"
6 => "шестьсот"
7 => "семьсот"
8 => "восемьсот"
_ => "девятьсот"
}
}
///|
fn ru_hundreds_g(h : Int) -> String {
match h {
1 => "сто"
2 => "двухсот"
3 => "трёхсот"
4 => "четырёхсот"
5 => "пятисот"
6 => "шестисот"
7 => "семисот"
8 => "восьмисот"
_ => "девятисот"
}
}
///|
fn ru_hundreds_ord(h : Int) -> String {
match h {
1 => "сотый"
2 => "двухсотый"
3 => "трёхсотый"
4 => "четырёхсотый"
5 => "пятисотый"
6 => "шестисотый"
7 => "семисотый"
8 => "восьмисотый"
_ => "девятисотый"
}
}
///|
fn ru_thousands_ord(i : Int) -> String {
match i {
1 => "тысячный"
2 => "миллионный"
3 => "миллиардный"
4 => "триллионный"
5 => "квадриллионный"
6 => "квинтиллионный"
7 => "секстиллионный"
8 => "септиллионный"
9 => "октиллионный"
_ => "нониллионный"
}
}
///|
fn ru_chunk_ordinal(
h : Int,
t : Int,
o : Int,
chunk_num : Int,
) -> Array[String] {
let words : Array[String] = []
if h > 0 {
if t == 0 && o == 0 {
words.push(ru_hundreds_ord(h))
} else {
words.push(ru_hundreds_n(h))
}
}
if t > 1 {
if o == 0 {
words.push(ru_twenties_ord(t))
} else {
words.push(ru_twenties_n(t))
}
}
if t == 1 {
words.push(ru_tens_ord(o))
} else if o > 0 {
if chunk_num == 0 {
words.push(ru_ones_ord(o))
} else if chunk_num > 0 && o == 1 && h == 0 && t == 0 {
// "тысяча первый": the "одна" disappears.
} else if chunk_num == 1 {
// Thousands are feminine.
words.push(ru_ones_f(o))
} else {
words.push(ru_ones_n(o))
}
}
words
}
///|
fn ru_chunk_ordinal_join(
h : Int,
t : Int,
o : Int,
chunk_num : Int,
) -> Array[String] {
let words : Array[String] = []
if h > 1 {
words.push(ru_hundreds_g(h))
} else if h == 1 {
// "сто", not "ста".
words.push("сто")
}
if t == 9 {
// "девяносто", not "девяноста".
words.push("девяносто")
} else if t > 1 {
words.push(ru_twenties_g(t))
}
if t == 1 {
words.push(ru_tens_g(o))
} else if o > 0 {
if chunk_num == 0 {
words.push(ru_ones_ord(o))
} else if chunk_num > 0 && o == 1 && t != 1 {
if t == 0 && h == 0 {
// "тысячный", "миллионный": the "один" disappears.
} else {
words.push("одно")
}
} else {
words.push(ru_ones_g(o))
}
}
words
}
///|
pub fn ordinal_ru(n : BigInt) -> String {
if n < BigInt::from_int(0) {
return ""
}
if n == BigInt::from_int(0) {
return "нулевой"
}
// Split into groups of 3 from the right (most significant first).
let rev_chunks : Array[Int] = []
let b1000 = BigInt::from_int(1000)
let mut v = n
while v > BigInt::from_int(0) {
rev_chunks.push((v % b1000).to_int())
v = v / b1000
}
let chunks : Array[Int] = []
for idx = 0; idx < rev_chunks.length(); idx = idx + 1 {
chunks.push(rev_chunks[rev_chunks.length() - 1 - idx])
}
let count = chunks.length()
// Upstream's scale tables stop at 10^30; beyond that the ordinal would
// raise, so we report "".
if count - 1 > 10 {
return ""
}
let ord_join = chunks[count - 1] == 0
let mut max_nonzero = 0
for i = 0; i < count; i = i + 1 {
if chunks[i] != 0 {
max_nonzero = i
}
}
let rightest_nonzero_i = count - 1 - max_nonzero
let words : Array[String] = []
for idx = 0; idx < count; idx = idx + 1 {
let x = chunks[idx]
let i = count - 1 - idx
if x == 0 {
continue
}
let h = x / 100
let t = x / 10 % 10
let o = x % 10
if !(ord_join && rightest_nonzero_i == i) {
let chunk_words = ru_chunk_ordinal(h, t, o, i)
for w in chunk_words {
words.push(w)
}
if i > 0 {
words.push(ru_scale(i, ru_form(x)))
}
} else {
let chunk_words = ru_chunk_ordinal_join(h, t, o, i)
let mut joined = ""
for w in chunk_words {
joined = joined + w
}
if i > 0 {
joined = joined + ru_thousands_ord(i)
}
words.push(joined)
}
}
join_parts(words, " ")
}
///|
pub fn ordinal_num_ru(n : BigInt) -> String {
n.to_string()
}