Skip to content

Commit 0d1a4e8

Browse files
committed
Add decimalSeparator option
1 parent ad00dda commit 0d1a4e8

File tree

7 files changed

+116
-22
lines changed

7 files changed

+116
-22
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10-
- N/A
10+
### Changed
11+
12+
- Now requires ES2021+ (uses `String.prototype.replaceAll`).
13+
- In line with the rules of modern JavaScript syntax, repeated separators (e.g. `"1__0"` or `"1,,0"`) are considered invalid. The `allowTrailingInvalid` option will still permit evaluation of characters before any duplicate separators.
14+
15+
### Added
16+
17+
- Option `decimalSeparator`, accepting values `"."` (default) and `","`. When set to `","`, numbers will be evaluated with European-style decimal _comma_ (e.g. `1,0` is equivalent to `1`, not `10`).
1118

1219
## [v2.1.0] - 2025-06-09
1320

bunfig.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
coverage = true
33
coverageThreshold = 1
44
coverageReporter = ["lcov", "text"]
5+
coveragePathIgnorePatterns = "src/numericQuantityTests.ts"

src/constants.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const vulgarFractionToAsciiMap: Record<
3535
} as const;
3636

3737
/**
38-
* Captures the individual elements of a numeric string.
38+
* Captures the individual elements of a numeric string. Commas and underscores are allowed
39+
* as separators, as long as they appear between digits and are not consecutive.
3940
*
4041
* Capture groups:
4142
*
@@ -59,20 +60,17 @@ export const vulgarFractionToAsciiMap: Record<
5960
* ```
6061
*/
6162
export const numericRegex: RegExp =
62-
/^(?=-?\s*\.\d|-?\s*\d)(-)?\s*((?:\d(?:[\d,_]*\d)?)*)(([eE][+-]?\d(?:[\d,_]*\d)?)?|\.\d(?:[\d,_]*\d)?([eE][+-]?\d(?:[\d,_]*\d)?)?|(\s+\d(?:[\d,_]*\d)?\s*)?\s*\/\s*\d(?:[\d,_]*\d)?)?$/;
63+
/^(?=-?\s*\.\d|-?\s*\d)(-)?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?$/;
6364
/**
6465
* Same as {@link numericRegex}, but allows (and ignores) trailing invalid characters.
6566
*/
66-
export const numericRegexWithTrailingInvalid: RegExp = new RegExp(
67-
numericRegex.source.replace(/\$$/, '(?:\\s*[^\\.\\d\\/].*)?')
68-
);
67+
export const numericRegexWithTrailingInvalid: RegExp =
68+
/^(?=-?\s*\.\d|-?\s*\d)(-)?\s*((?:\d(?:[,_]\d|\d)*)*)(([eE][+-]?\d(?:[,_]\d|\d)*)?|\.\d(?:[,_]\d|\d)*([eE][+-]?\d(?:[,_]\d|\d)*)?|(\s+\d(?:[,_]\d|\d)*\s*)?\s*\/\s*\d(?:[,_]\d|\d)*)?(?:\s*[^.\d/].*)?/;
6969

7070
/**
7171
* Captures any Unicode vulgar fractions.
7272
*/
73-
export const vulgarFractionsRegex: RegExp = new RegExp(
74-
`(${Object.keys(vulgarFractionToAsciiMap).join('|')})`
75-
);
73+
export const vulgarFractionsRegex: RegExp = /([¼½¾}])/g;
7674
// #endregion
7775

7876
// #region Roman numerals
@@ -198,10 +196,8 @@ export const romanNumeralUnicodeToAsciiMap: Record<
198196
/**
199197
* Captures all Unicode Roman numeral code points.
200198
*/
201-
export const romanNumeralUnicodeRegex: RegExp = new RegExp(
202-
`(${Object.keys(romanNumeralUnicodeToAsciiMap).join('|')})`,
203-
'gi'
204-
);
199+
export const romanNumeralUnicodeRegex: RegExp =
200+
/([])/gi;
205201

206202
/**
207203
* Captures a valid Roman numeral sequence.
@@ -236,4 +232,5 @@ export const defaultOptions: Required<NumericQuantityOptions> = {
236232
allowTrailingInvalid: false,
237233
romanNumerals: false,
238234
bigIntOnOverflow: false,
235+
decimalSeparator: '.',
239236
} as const;

src/numericQuantity.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,55 @@ function numericQuantity(
5757
...options,
5858
};
5959

60+
let normalizedString = quantityAsString;
61+
62+
if (opts.decimalSeparator === ',') {
63+
const commaCount = (quantityAsString.match(/,/g) || []).length;
64+
if (commaCount === 1) {
65+
// Treat lone comma as decimal separator; remove all "." since they represent
66+
// thousands/whatever separators
67+
normalizedString = quantityAsString
68+
.replaceAll('.', '_')
69+
.replace(',', '.');
70+
} else if (commaCount > 1) {
71+
// The second comma and everything after is "trailing invalid"
72+
if (!opts.allowTrailingInvalid) {
73+
// Bail out if trailing invalid is not allowed
74+
return NaN;
75+
}
76+
77+
const firstCommaIndex = quantityAsString.indexOf(',');
78+
const secondCommaIndex = quantityAsString.indexOf(
79+
',',
80+
firstCommaIndex + 1
81+
);
82+
const beforeSecondComma = quantityAsString
83+
.substring(0, secondCommaIndex)
84+
.replaceAll('.', '_')
85+
.replace(',', '.');
86+
const afterSecondComma = quantityAsString.substring(secondCommaIndex + 1);
87+
normalizedString = opts.allowTrailingInvalid
88+
? beforeSecondComma + '&' + afterSecondComma
89+
: beforeSecondComma;
90+
} else {
91+
// No comma as decimal separator, so remove all "." since they represent
92+
// thousands/whatever separators
93+
normalizedString = quantityAsString.replaceAll('.', '_');
94+
}
95+
}
96+
6097
const regexResult = (
6198
opts.allowTrailingInvalid ? numericRegexWithTrailingInvalid : numericRegex
62-
).exec(quantityAsString);
99+
).exec(normalizedString);
63100

64101
// If the Arabic numeral regex fails, try Roman numerals
65102
if (!regexResult) {
66103
return opts.romanNumerals ? parseRomanNumerals(quantityAsString) : NaN;
67104
}
68105

69106
const [, dash, ng1temp, ng2temp] = regexResult;
70-
const numberGroup1 = ng1temp.replace(/[,_]/g, '');
71-
const numberGroup2 = ng2temp?.replace(/[,_]/g, '');
107+
const numberGroup1 = ng1temp.replaceAll(',', '').replaceAll('_', '');
108+
const numberGroup2 = ng2temp?.replaceAll(',', '').replaceAll('_', '');
72109

73110
// Numerify capture group 1
74111
if (!numberGroup1 && numberGroup2 && numberGroup2.startsWith('.')) {

src/numericQuantityTests.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ const allowTrailingInvalid = true;
55
const romanNumerals = true;
66

77
const noop = () => {};
8-
// This is only executed to meet test coverage requirements until
9-
// https://github.com/oven-sh/bun/issues/4021 is implemented.
10-
noop();
118

129
export const numericQuantityTests: Record<
1310
string,
@@ -81,7 +78,46 @@ export const numericQuantityTests: Record<
8178
['1 2/3,4', 1.059],
8279
['1 2/3_4', 1.059],
8380
],
84-
'Invalid/ignored separators': [
81+
// TODO: Add support for automatic decimal separator detection
82+
// 'Auto-detected decimal separator': [
83+
// ['1.0,00', 10, { decimalSeparator: 'auto' }],
84+
// ['1,00.0', 100, { decimalSeparator: 'auto' }],
85+
// ['10.0,00.1', NaN, { decimalSeparator: 'auto' }],
86+
// ['10,00.00,1', 1000.001, { decimalSeparator: 'auto' }],
87+
// ['100,100', 100.1, { decimalSeparator: 'auto' }],
88+
// ['100,1000', 100.1, { decimalSeparator: 'auto' }],
89+
// ['1000,100', 1000.1, { decimalSeparator: 'auto' }],
90+
// ['1000,1', 1000.1, { decimalSeparator: 'auto' }],
91+
// ],
92+
'Comma as decimal separator': [
93+
['1.0,00', 10, { decimalSeparator: ',' }],
94+
['1,00.0', 1, { decimalSeparator: ',' }],
95+
['1.00.0', 1000, { decimalSeparator: ',' }],
96+
['1,000,001', NaN, { decimalSeparator: ',' }],
97+
['1,000,001', 1, { decimalSeparator: ',', allowTrailingInvalid }],
98+
['1,00.1', 1.001, { decimalSeparator: ',', allowTrailingInvalid }],
99+
['10.0,00.0', 100, { decimalSeparator: ',' }],
100+
['10,00.00,0', NaN, { decimalSeparator: ',' }],
101+
['10,00.00,0', 10, { decimalSeparator: ',', allowTrailingInvalid }],
102+
['100,100', 100.1, { decimalSeparator: ',' }],
103+
['100,1000', 100.1, { decimalSeparator: ',' }],
104+
['1000,100', 1000.1, { decimalSeparator: ',' }],
105+
['1000,1', 1000.1, { decimalSeparator: ',' }],
106+
['1_.0,00', NaN, { decimalSeparator: ',' }],
107+
['1_,00.0', NaN, { decimalSeparator: ',' }],
108+
['1_.00.0', NaN, { decimalSeparator: ',' }],
109+
['1_,000,001', NaN, { decimalSeparator: ',' }],
110+
['1_,000,001', 1, { decimalSeparator: ',', allowTrailingInvalid }],
111+
['1_,00.1', 1, { decimalSeparator: ',', allowTrailingInvalid }],
112+
['1_0.0,00.0', 100, { decimalSeparator: ',' }],
113+
['1_0,00.00,0', NaN, { decimalSeparator: ',' }],
114+
['1_0,00.00,0', 10, { decimalSeparator: ',', allowTrailingInvalid }],
115+
['1_00,100', 100.1, { decimalSeparator: ',' }],
116+
['1_00,1000', 100.1, { decimalSeparator: ',' }],
117+
['1_000,100', 1000.1, { decimalSeparator: ',' }],
118+
['1_000,1', 1000.1, { decimalSeparator: ',' }],
119+
],
120+
'Invalid/repeated/ignored separators': [
85121
['_11 11/22', NaN],
86122
[',11 11/22', NaN],
87123
['11 _11/22', NaN],
@@ -94,6 +130,10 @@ export const numericQuantityTests: Record<
94130
['11 11,/22', NaN],
95131
['11 11/22_', NaN],
96132
['11 11/22,', NaN],
133+
['11__22', NaN],
134+
['11,,22', NaN],
135+
['11,_22', NaN],
136+
['11,_22', NaN],
97137
['11 _11/22', 11, { allowTrailingInvalid }],
98138
['11 ,11/22', 11, { allowTrailingInvalid }],
99139
['11 11/_22', 11, { allowTrailingInvalid }],
@@ -104,6 +144,10 @@ export const numericQuantityTests: Record<
104144
['11 11,/22', 11, { allowTrailingInvalid }],
105145
['11 11/22_', 11.5, { allowTrailingInvalid }],
106146
['11 11/22,', 11.5, { allowTrailingInvalid }],
147+
['11__22', 11, { allowTrailingInvalid }],
148+
['11,,22', 11, { allowTrailingInvalid }],
149+
['11,_22', 11, { allowTrailingInvalid }],
150+
['11,_22', 11, { allowTrailingInvalid }],
107151
],
108152
'Trailing invalid characters': [
109153
['1 2 3', 1, { allowTrailingInvalid }],

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export interface NumericQuantityOptions {
2323
* a valid integer too large for the `number` type.
2424
*/
2525
bigIntOnOverflow?: boolean;
26+
/**
27+
* Specifies which character ("." or ",") to treat as the decimal separator.
28+
*
29+
* @default "."
30+
*/
31+
// TODO: Add support for automatic decimal separator detection
32+
// decimalSeparator?: ',' | '.' | 'auto';
33+
decimalSeparator?: ',' | '.';
2634
}
2735

2836
/**

tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"esModuleInterop": true,
1212
"noEmit": true,
1313
"skipLibCheck": true,
14-
"lib": ["ES2015", "DOM"],
15-
"target": "es2020"
14+
"lib": ["ES2021", "DOM"],
15+
"target": "es2021"
1616
},
1717
"include": ["./*.ts", "./src"]
1818
}

0 commit comments

Comments
 (0)