• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

forzagreen / n2words / 22077363497

16 Feb 2026 09:00PM UTC coverage: 95.998% (+0.005%) from 95.993%
22077363497

push

github

web-flow
fix(core): pre-v4 audit fixes for parsers and exports (#264)

5027 of 5482 branches covered (91.7%)

Branch coverage included in aggregate %.

138 of 142 new or added lines in 40 files covered. (97.18%)

28002 of 28924 relevant lines covered (96.81%)

27.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.13
/src/ro-RO.js
1
/**
1✔
2
 * Romanian (Romania) language converter
1✔
3
 *
1✔
4
 * CLDR: ro-RO | Romanian as used in Romania
1✔
5
 *
1✔
6
 * Key features:
1✔
7
 * - Gender agreement (unu/una, doi/două)
1✔
8
 * - "De" preposition insertion for groups >= 20
1✔
9
 * - Complex scale word handling (mie/mii, milion/milioane)
1✔
10
 * - Feminine units for thousands
1✔
11
 */
1✔
12

1✔
13
import { parseCardinalValue } from './utils/parse-cardinal.js'
1✔
14
import { parseCurrencyValue } from './utils/parse-currency.js'
1✔
15
import { parseOrdinalValue } from './utils/parse-ordinal.js'
1✔
16
import { validateOptions } from './utils/validate-options.js'
1✔
17

1✔
18
// ============================================================================
1✔
19
// Vocabulary (module-level constants)
1✔
20
// ============================================================================
1✔
21

1✔
22
const ONES_MASC = ['', 'unu', 'doi', 'trei', 'patru', 'cinci', 'șase', 'șapte', 'opt', 'nouă']
1✔
23
const ONES_FEM = ['', 'una', 'două', 'trei', 'patru', 'cinci', 'șase', 'șapte', 'opt', 'nouă']
1✔
24

1✔
25
const TEENS = ['zece', 'unsprezece', 'douăsprezece', 'treisprezece', 'paisprezece', 'cincisprezece', 'șaisprezece', 'șaptesprezece', 'optsprezece', 'nouăsprezece']
1✔
26
const TEENS_MASC = ['zece', 'unsprezece', 'doisprezece', 'treisprezece', 'paisprezece', 'cincisprezece', 'șaisprezece', 'șaptesprezece', 'optsprezece', 'nouăsprezece']
1✔
27

1✔
28
const TWENTIES = ['', '', 'douăzeci', 'treizeci', 'patruzeci', 'cincizeci', 'șaizeci', 'șaptezeci', 'optzeci', 'nouăzeci']
1✔
29

1✔
30
const HUNDREDS = ['', 'o sută', 'două sute', 'trei sute', 'patru sute', 'cinci sute', 'șase sute', 'șapte sute', 'opt sute', 'nouă sute']
1✔
31

1✔
32
const ZERO = 'zero'
1✔
33
const NEGATIVE = 'minus'
1✔
34
const DECIMAL_SEP = 'virgulă'
1✔
35

1✔
36
// Ordinal vocabulary (masculine forms)
1✔
37
const ORDINAL_ONES = ['', 'primul', 'al doilea', 'al treilea', 'al patrulea', 'al cincilea', 'al șaselea', 'al șaptelea', 'al optulea', 'al nouălea']
1✔
38
const ORDINAL_TEENS = ['al zecelea', 'al unsprezecelea', 'al doisprezecelea', 'al treisprezecelea', 'al paisprezecelea', 'al cincisprezecelea', 'al șaisprezecelea', 'al șaptesprezecelea', 'al optsprezecelea', 'al nouăsprezecelea']
1✔
39
const ORDINAL_TENS = ['', '', 'al douăzecilea', 'al treizecilea', 'al patruzecilea', 'al cincizecilea', 'al șaizecilea', 'al șaptezecilea', 'al optzecilea', 'al nouăzecilea']
1✔
40
const ORDINAL_HUNDRED = 'al sutălea'
1✔
41
const ORDINAL_THOUSAND = 'al miilea'
1✔
42
const ORDINAL_MILLION = 'al milionulea'
1✔
43

1✔
44
// Currency (Romanian Leu)
1✔
45
const LEU_SINGULAR = 'leu'
1✔
46
const LEU_PLURAL = 'lei'
1✔
47
const BAN_SINGULAR = 'ban'
1✔
48
const BAN_PLURAL = 'bani'
1✔
49

1✔
50
// Scale metadata: [singular, plural, article, feminine, needsDe]
1✔
51
// - singular: form for 1
1✔
52
// - plural: form for 2+
1✔
53
// - article: 'o' for feminine, 'un' for masculine
1✔
54
// - feminine: whether units should be feminine
1✔
55
// - needsDe: whether "de" is inserted for segment >= 20
1✔
56
const SCALE_META = [
1✔
57
  { singular: 'mie', plural: 'mii', article: 'o', feminine: true, needsDe: true },
1✔
58
  { singular: 'milion', plural: 'milioane', article: 'un', feminine: false, needsDe: true },
1✔
59
  { singular: 'miliard', plural: 'miliarde', article: 'un', feminine: false, needsDe: true },
1✔
60
  { singular: 'trilion', plural: 'trilioane', article: 'un', feminine: false, needsDe: true },
1✔
61
  { singular: 'cvadrilion', plural: 'cvadrilioane', article: 'un', feminine: false, needsDe: true },
1✔
62
  { singular: 'cvintilion', plural: 'cvintilioane', article: 'un', feminine: false, needsDe: true },
1✔
63
  { singular: 'sextilion', plural: 'sextilioane', article: 'un', feminine: false, needsDe: true },
1✔
64
  { singular: 'septilion', plural: 'septilioane', article: 'un', feminine: false, needsDe: true },
1✔
65
  { singular: 'octilion', plural: 'octilioane', article: 'un', feminine: false, needsDe: true }
1✔
66
]
1✔
67

1✔
68
// ============================================================================
1✔
69
// Helper Functions
1✔
70
// ============================================================================
1✔
71

1✔
72
/**
1✔
73
 * Spells number under 100.
1✔
74
 */
1✔
75
function spellUnder100 (n, feminine = false, masculineTeens = false) {
150✔
76
  if (n === 0) return ''
150!
77
  if (n < 10) {
150✔
78
    return feminine ? ONES_FEM[n] : ONES_MASC[n]
63✔
79
  }
63✔
80
  if (n < 20) {
150✔
81
    return masculineTeens ? TEENS_MASC[n - 10] : TEENS[n - 10]
19✔
82
  }
19✔
83
  const t = Math.trunc(n / 10)
68✔
84
  const u = n % 10
68✔
85
  if (u === 0) {
150✔
86
    return TWENTIES[t]
17✔
87
  }
17✔
88
  const onesWord = feminine ? ONES_FEM[u] : ONES_MASC[u]
150✔
89
  return TWENTIES[t] + ' și ' + onesWord
150✔
90
}
150✔
91

1✔
92
/**
1✔
93
 * Spells number under 1000.
1✔
94
 */
1✔
95
function spellUnder1000 (n, feminine = false, masculineTeens = false) {
150✔
96
  if (n === 0) return ''
150!
97
  if (n < 100) return spellUnder100(n, feminine, masculineTeens)
150✔
98

43✔
99
  const h = Math.trunc(n / 100)
43✔
100
  const r = n % 100
43✔
101
  const hundredWord = HUNDREDS[h]
43✔
102

43✔
103
  if (r === 0) return hundredWord
150✔
104
  return hundredWord + ' ' + spellUnder100(r, feminine, masculineTeens)
33✔
105
}
150✔
106

1✔
107
/**
1✔
108
 * Builds scale word with proper pluralization and "de" insertion.
1✔
109
 * Romanian always uses feminine forms (două, not doi) when counting scale words.
1✔
110
 */
1✔
111
function buildScalePhrase (segment, scaleIndex) {
54✔
112
  const meta = SCALE_META[scaleIndex - 1]
54✔
113
  if (!meta) return spellUnder1000(segment, true)
54!
114

54✔
115
  if (segment === 1) {
54✔
116
    return meta.article + ' ' + meta.singular
18✔
117
  }
18✔
118

36✔
119
  // Special case: 21 with scale words uses feminine "una"
36✔
120
  if (segment === 21 && meta.needsDe) {
54✔
121
    return 'douăzeci și una de ' + meta.plural
3✔
122
  }
3✔
123

33✔
124
  // Romanian always uses feminine when counting scale words (două milioane, not doi milioane)
33✔
125
  const words = spellUnder1000(segment, true)
33✔
126

33✔
127
  // "de" after >= 20
33✔
128
  const needsDe = meta.needsDe && segment >= 20
33✔
129
  const separator = needsDe ? ' de ' : ' '
54✔
130

54✔
131
  return words + separator + meta.plural
54✔
132
}
54✔
133

1✔
134
// ============================================================================
1✔
135
// Conversion Functions
1✔
136
// ============================================================================
1✔
137

1✔
138
/**
1✔
139
 * Converts a non-negative integer to Romanian words.
1✔
140
 *
1✔
141
 * @param {bigint} n - Non-negative integer to convert
1✔
142
 * @param {Object} options - Conversion options
1✔
143
 * @returns {string} Romanian words
1✔
144
 */
1✔
145
function integerToWords (n, gender) {
139✔
146
  if (n === 0n) return ZERO
139✔
147

135✔
148
  // Fast path: numbers < 1000
135✔
149
  if (n < 1000n) {
139✔
150
    const feminine = gender === 'feminine'
83✔
151
    return spellUnder1000(Number(n), feminine)
83✔
152
  }
83✔
153

52✔
154
  return buildLargeNumberWords(n, gender)
52✔
155
}
139✔
156

1✔
157
/**
1✔
158
 * Builds words for numbers >= 1000.
1✔
159
 * Uses BigInt division for faster segment extraction.
1✔
160
 *
1✔
161
 * @param {bigint} n - Number >= 1000
1✔
162
 * @param {Object} options - Conversion options
1✔
163
 * @returns {string} Romanian words
1✔
164
 */
1✔
165
function buildLargeNumberWords (n, gender) {
52✔
166
  // Extract segments using BigInt division (faster than string slicing)
52✔
167
  // Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.)
52✔
168
  const segmentValues = []
52✔
169
  let temp = n
52✔
170
  while (temp > 0n) {
52✔
171
    segmentValues.push(Number(temp % 1000n))
150✔
172
    temp = temp / 1000n
150✔
173
  }
150✔
174

52✔
175
  // Build result string directly (avoid regex cleanup)
52✔
176
  let result = ''
52✔
177

52✔
178
  for (let i = segmentValues.length - 1; i >= 0; i--) {
52✔
179
    const segment = segmentValues[i]
150✔
180
    if (segment === 0) continue
150✔
181

76✔
182
    let segmentWords
76✔
183
    if (i === 0) {
150✔
184
      // Units segment - use gender from options
24✔
185
      const feminine = gender === 'feminine'
24✔
186
      segmentWords = spellUnder1000(segment, feminine)
24✔
187
    } else {
150✔
188
      // Scale segment
52✔
189
      segmentWords = buildScalePhrase(segment, i)
52✔
190
    }
52✔
191

76✔
192
    if (result && segmentWords) {
150✔
193
      result += ' ' + segmentWords
24✔
194
    } else if (segmentWords) {
150✔
195
      result = segmentWords
52✔
196
    }
52✔
197
  }
150✔
198

52✔
199
  return result
52✔
200
}
52✔
201

1✔
202
/**
1✔
203
 * Converts decimal digits to Romanian words.
1✔
204
 * Decimals always use masculine forms.
1✔
205
 *
1✔
206
 * @param {string} decimalPart - Decimal digits (without the point)
1✔
207
 * @returns {string} Romanian words for decimal part
1✔
208
 */
1✔
209
function decimalPartToWords (decimalPart) {
10✔
210
  let result = ''
10✔
211
  let i = 0
10✔
212

10✔
213
  // Handle leading zeros
10✔
214
  while (i < decimalPart.length && decimalPart[i] === '0') {
10✔
215
    if (result) result += ' '
5✔
216
    result += ZERO
5✔
217
    i++
5✔
218
  }
5✔
219

10✔
220
  // Convert remainder as a single number (masculine, with masculine teens)
10✔
221
  const remainder = decimalPart.slice(i)
10✔
222
  if (remainder) {
10✔
223
    if (result) result += ' '
10✔
224
    const n = BigInt(remainder)
10✔
225
    if (n < 1000n) {
10✔
226
      result += spellUnder1000(Number(n), false, true)
10✔
227
    } else {
10!
228
      result += integerToWords(n, { gender: 'masculine' })
×
229
    }
×
230
  }
10✔
231

10✔
232
  return result
10✔
233
}
10✔
234

1✔
235
/**
1✔
236
 * Converts a numeric value to Romanian words.
1✔
237
 *
1✔
238
 * @param {number | string | bigint} value - The numeric value to convert
1✔
239
 * @param {Object} [options] - Conversion options
1✔
240
 * @param {string} [options.gender='masculine'] - Gender for numbers
1✔
241
 * @returns {string} The number in Romanian words
1✔
242
 * @throws {TypeError} If value is not a valid numeric type
1✔
243
 * @throws {Error} If value is not a valid number format
1✔
244
 *
1✔
245
 * @example
1✔
246
 * toCardinal(21)                        // 'douăzeci și unu'
1✔
247
 * toCardinal(1, { gender: 'feminine' }) // 'una'
1✔
248
 * toCardinal(1000)                      // 'o mie'
1✔
249
 */
1✔
250
function toCardinal (value, options) {
125✔
251
  options = validateOptions(options)
125✔
252
  const { isNegative, integerPart, decimalPart } = parseCardinalValue(value)
125✔
253

125✔
254
  // Apply option defaults
125✔
255
  const { gender = 'masculine' } = options
125✔
256

125✔
257
  let result = ''
125✔
258

125✔
259
  if (isNegative) {
125✔
260
    result = NEGATIVE + ' '
3✔
261
  }
3✔
262

125✔
263
  result += integerToWords(integerPart, gender)
125✔
264

125✔
265
  if (decimalPart) {
125✔
266
    result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart)
10✔
267
  }
10✔
268

125✔
269
  return result
125✔
270
}
125✔
271

1✔
272
// ============================================================================
1✔
273
// Ordinal Functions
1✔
274
// ============================================================================
1✔
275

1✔
276
/**
1✔
277
 * Builds ordinal for tens and ones (0-99).
1✔
278
 *
1✔
279
 * @param {number} n - Number 0-99
1✔
280
 * @returns {string} Ordinal word
1✔
281
 */
1✔
282
function buildOrdinalTensOnes (n) {
21✔
283
  if (n === 0) return ''
21!
284
  if (n < 10) return ORDINAL_ONES[n]
21✔
285
  if (n < 20) return ORDINAL_TEENS[n - 10]
21✔
286

6✔
287
  const ones = n % 10
6✔
288
  const tens = Math.trunc(n / 10)
6✔
289

6✔
290
  if (ones === 0) {
21✔
291
    return ORDINAL_TENS[tens]
2✔
292
  }
2✔
293
  // Compound: cardinal tens + ordinal ones (only last is ordinal)
4✔
294
  return TWENTIES[tens] + ' și ' + ORDINAL_ONES[ones]
4✔
295
}
21✔
296

1✔
297
/**
1✔
298
 * Converts a non-negative integer to Romanian ordinal words.
1✔
299
 *
1✔
300
 * @param {bigint} n - Non-negative integer to convert
1✔
301
 * @returns {string} Romanian ordinal words
1✔
302
 */
1✔
303
function integerToOrdinal (n) {
28✔
304
  if (n === 0n) return ''
28!
305
  if (n === 1n) return ORDINAL_ONES[1]
28✔
306

27✔
307
  // Numbers < 100
27✔
308
  if (n < 100n) {
28✔
309
    return buildOrdinalTensOnes(Number(n))
18✔
310
  }
18✔
311

9✔
312
  // Numbers < 1000
9✔
313
  if (n < 1000n) {
28✔
314
    const hundreds = Number(n / 100n)
4✔
315
    const remainder = Number(n % 100n)
4✔
316

4✔
317
    if (remainder === 0) {
4✔
318
      return ORDINAL_HUNDRED
2✔
319
    }
2✔
320
    return HUNDREDS[hundreds] + ' ' + buildOrdinalTensOnes(remainder)
2✔
321
  }
2✔
322

5✔
323
  // Numbers < 1,000,000
5✔
324
  if (n < 1_000_000n) {
28✔
325
    const thousands = Number(n / 1000n)
3✔
326
    const remainder = Number(n % 1000n)
3✔
327

3✔
328
    if (remainder === 0) {
3✔
329
      if (thousands === 1) {
2✔
330
        return ORDINAL_THOUSAND
1✔
331
      }
1✔
332
      return buildScalePhrase(thousands, 1) + ' ' + ORDINAL_THOUSAND
1✔
333
    }
1✔
334

1✔
335
    // Cardinal thousands + ordinal remainder
1✔
336
    let result
1✔
337
    if (thousands === 1) {
1✔
338
      result = 'o mie'
1✔
339
    } else {
3!
340
      result = buildScalePhrase(thousands, 1)
×
341
    }
×
342

1✔
343
    if (remainder < 100) {
1✔
344
      return result + ' ' + buildOrdinalTensOnes(remainder)
1✔
345
    }
1✔
346

×
NEW
347
    const remHundreds = Math.trunc(remainder / 100)
×
348
    const remTensOnes = remainder % 100
×
349

×
350
    if (remTensOnes === 0) {
×
351
      return result + ' ' + ORDINAL_HUNDRED
×
352
    }
×
353
    return result + ' ' + HUNDREDS[remHundreds] + ' ' + buildOrdinalTensOnes(remTensOnes)
×
354
  }
×
355

2✔
356
  // Numbers >= 1,000,000
2✔
357
  const millions = Number(n / 1_000_000n)
2✔
358
  const remainder = n % 1_000_000n
2✔
359

2✔
360
  if (remainder === 0n) {
2✔
361
    if (millions === 1) {
2✔
362
      return ORDINAL_MILLION
1✔
363
    }
1✔
364
    return buildScalePhrase(millions, 2) + ' ' + ORDINAL_MILLION
1✔
365
  }
1✔
366

×
367
  // Cardinal millions + ordinal remainder
×
368
  let result
×
369
  if (millions === 1) {
×
370
    result = 'un milion'
×
371
  } else {
×
372
    result = buildScalePhrase(millions, 2)
×
373
  }
×
374

×
375
  return result + ' ' + integerToOrdinal(remainder)
×
376
}
28✔
377

1✔
378
/**
1✔
379
 * Converts a numeric value to Romanian ordinal words.
1✔
380
 *
1✔
381
 * @param {number | string | bigint} value - The numeric value to convert
1✔
382
 * @returns {string} The ordinal in Romanian words
1✔
383
 * @throws {TypeError} If value is not a valid numeric type
1✔
384
 * @throws {Error} If value is not a positive integer
1✔
385
 *
1✔
386
 * @example
1✔
387
 * toOrdinal(1)   // 'primul'
1✔
388
 * toOrdinal(21)  // 'douăzeci și primul'
1✔
389
 */
1✔
390
function toOrdinal (value) {
31✔
391
  const n = parseOrdinalValue(value)
31✔
392
  return integerToOrdinal(n)
31✔
393
}
31✔
394

1✔
395
// ============================================================================
1✔
396
// Currency Functions
1✔
397
// ============================================================================
1✔
398

1✔
399
/**
1✔
400
 * Converts a numeric value to Romanian Leu currency words.
1✔
401
 *
1✔
402
 * @param {number | string | bigint} value - The numeric value to convert
1✔
403
 * @returns {string} The currency in Romanian words
1✔
404
 * @throws {TypeError} If value is not a valid numeric type
1✔
405
 * @throws {Error} If value is not a valid number format
1✔
406
 *
1✔
407
 * @example
1✔
408
 * toCurrency(1)     // 'un leu'
1✔
409
 * toCurrency(2.50)  // 'doi lei cincizeci de bani'
1✔
410
 */
1✔
411
function toCurrency (value) {
24✔
412
  const { isNegative, dollars, cents } = parseCurrencyValue(value)
24✔
413

24✔
414
  const parts = []
24✔
415

24✔
416
  if (isNegative) {
24✔
417
    parts.push(NEGATIVE)
2✔
418
  }
2✔
419

24✔
420
  // Lei (masculine)
24✔
421
  if (dollars > 0n || cents === 0n) {
24✔
422
    if (dollars === 1n) {
18✔
423
      parts.push('un ' + LEU_SINGULAR)
4✔
424
    } else {
18✔
425
      const leuWord = integerToWords(dollars, 'masculine')
14✔
426
      parts.push(leuWord + ' ' + LEU_PLURAL)
14✔
427
    }
14✔
428
  }
18✔
429

24✔
430
  // Bani (masculine)
24✔
431
  if (cents > 0n) {
24✔
432
    const centNum = Number(cents)
12✔
433
    if (centNum === 1) {
12✔
434
      parts.push('un ' + BAN_SINGULAR)
2✔
435
    } else if (centNum >= 20) {
12✔
436
      const banWord = spellUnder100(centNum, false)
7✔
437
      parts.push(banWord + ' de ' + BAN_PLURAL)
7✔
438
    } else {
10✔
439
      const banWord = spellUnder100(centNum, false)
3✔
440
      parts.push(banWord + ' ' + BAN_PLURAL)
3✔
441
    }
3✔
442
  }
12✔
443

24✔
444
  return parts.join(' ')
24✔
445
}
24✔
446

1✔
447
// ============================================================================
1✔
448
// Public API
1✔
449
// ============================================================================
1✔
450

1✔
451
export { toCardinal, toOrdinal, toCurrency }
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc