Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- N/A
### Changed

- Now requires ES2021+ (uses `String.prototype.replaceAll`).
- 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.

### Added

- 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`).

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

Expand Down
92 changes: 48 additions & 44 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
coverage = true
coverageThreshold = 1
coverageReporter = ["lcov", "text"]
coveragePathIgnorePatterns = "src/numericQuantityTests.ts"
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@
},
"devDependencies": {
"@jakeboone02/generate-dts": "0.1.2",
"@types/bun": "^1.3.3",
"@types/bun": "^1.3.6",
"@types/node": "^24.10.1",
"@types/web": "^0.0.294",
"@typescript/native-preview": "^7.0.0-dev.20251201.1",
"np": "^10.2.0",
"oxlint": "^1.31.0",
"oxlint-tsgolint": "^0.8.3",
"prettier": "3.7.3",
"@types/web": "^0.0.319",
"@typescript/native-preview": "^7.0.0-dev.20260120.1",
"np": "^10.3.0",
"oxlint": "^1.41.0",
"oxlint-tsgolint": "^0.11.1",
"prettier": "3.8.0",
"prettier-plugin-organize-imports": "4.3.0",
"tsdown": "^0.14.2",
"typedoc": "^0.28.15",
"typedoc": "^0.28.16",
"typescript": "^5.9.3"
},
"engines": {
Expand Down
21 changes: 9 additions & 12 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export const vulgarFractionToAsciiMap: Record<
} as const;

/**
* Captures the individual elements of a numeric string.
* Captures the individual elements of a numeric string. Commas and underscores are allowed
* as separators, as long as they appear between digits and are not consecutive.
*
* Capture groups:
*
Expand All @@ -59,20 +60,17 @@ export const vulgarFractionToAsciiMap: Record<
* ```
*/
export const numericRegex: RegExp =
/^(?=-?\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|-?\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)*)?$/;
/**
* Same as {@link numericRegex}, but allows (and ignores) trailing invalid characters.
*/
export const numericRegexWithTrailingInvalid: RegExp = new RegExp(
numericRegex.source.replace(/\$$/, '(?:\\s*[^\\.\\d\\/].*)?')
);
export const numericRegexWithTrailingInvalid: RegExp =
/^(?=-?\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/].*)?/;

/**
* Captures any Unicode vulgar fractions.
*/
export const vulgarFractionsRegex: RegExp = new RegExp(
`(${Object.keys(vulgarFractionToAsciiMap).join('|')})`
);
export const vulgarFractionsRegex: RegExp = /([¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞⅟}])/g;
// #endregion

// #region Roman numerals
Expand Down Expand Up @@ -198,10 +196,8 @@ export const romanNumeralUnicodeToAsciiMap: Record<
/**
* Captures all Unicode Roman numeral code points.
*/
export const romanNumeralUnicodeRegex: RegExp = new RegExp(
`(${Object.keys(romanNumeralUnicodeToAsciiMap).join('|')})`,
'gi'
);
export const romanNumeralUnicodeRegex: RegExp =
/([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿ])/gi;

/**
* Captures a valid Roman numeral sequence.
Expand Down Expand Up @@ -236,4 +232,5 @@ export const defaultOptions: Required<NumericQuantityOptions> = {
allowTrailingInvalid: false,
romanNumerals: false,
bigIntOnOverflow: false,
decimalSeparator: '.',
} as const;
43 changes: 40 additions & 3 deletions src/numericQuantity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,55 @@ function numericQuantity(
...options,
};

let normalizedString = quantityAsString;

if (opts.decimalSeparator === ',') {
const commaCount = (quantityAsString.match(/,/g) || []).length;
if (commaCount === 1) {
// Treat lone comma as decimal separator; remove all "." since they represent
// thousands/whatever separators
normalizedString = quantityAsString
.replaceAll('.', '_')
.replace(',', '.');
} else if (commaCount > 1) {
// The second comma and everything after is "trailing invalid"
if (!opts.allowTrailingInvalid) {
// Bail out if trailing invalid is not allowed
return NaN;
}

const firstCommaIndex = quantityAsString.indexOf(',');
const secondCommaIndex = quantityAsString.indexOf(
',',
firstCommaIndex + 1
);
const beforeSecondComma = quantityAsString
.substring(0, secondCommaIndex)
.replaceAll('.', '_')
.replace(',', '.');
const afterSecondComma = quantityAsString.substring(secondCommaIndex + 1);
normalizedString = opts.allowTrailingInvalid
? beforeSecondComma + '&' + afterSecondComma
: beforeSecondComma;
} else {
// No comma as decimal separator, so remove all "." since they represent
// thousands/whatever separators
normalizedString = quantityAsString.replaceAll('.', '_');
}
}

const regexResult = (
opts.allowTrailingInvalid ? numericRegexWithTrailingInvalid : numericRegex
).exec(quantityAsString);
).exec(normalizedString);

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

const [, dash, ng1temp, ng2temp] = regexResult;
const numberGroup1 = ng1temp.replace(/[,_]/g, '');
const numberGroup2 = ng2temp?.replace(/[,_]/g, '');
const numberGroup1 = ng1temp.replaceAll(',', '').replaceAll('_', '');
const numberGroup2 = ng2temp?.replaceAll(',', '').replaceAll('_', '');

// Numerify capture group 1
if (!numberGroup1 && numberGroup2 && numberGroup2.startsWith('.')) {
Expand Down
52 changes: 48 additions & 4 deletions src/numericQuantityTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ const allowTrailingInvalid = true;
const romanNumerals = true;

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

export const numericQuantityTests: Record<
string,
Expand Down Expand Up @@ -81,7 +78,46 @@ export const numericQuantityTests: Record<
['1 2/3,4', 1.059],
['1 2/3_4', 1.059],
],
'Invalid/ignored separators': [
// TODO: Add support for automatic decimal separator detection
// 'Auto-detected decimal separator': [
// ['1.0,00', 10, { decimalSeparator: 'auto' }],
// ['1,00.0', 100, { decimalSeparator: 'auto' }],
// ['10.0,00.1', NaN, { decimalSeparator: 'auto' }],
// ['10,00.00,1', 1000.001, { decimalSeparator: 'auto' }],
// ['100,100', 100.1, { decimalSeparator: 'auto' }],
// ['100,1000', 100.1, { decimalSeparator: 'auto' }],
// ['1000,100', 1000.1, { decimalSeparator: 'auto' }],
// ['1000,1', 1000.1, { decimalSeparator: 'auto' }],
// ],
'Comma as decimal separator': [
['1.0,00', 10, { decimalSeparator: ',' }],
['1,00.0', 1, { decimalSeparator: ',' }],
['1.00.0', 1000, { decimalSeparator: ',' }],
['1,000,001', NaN, { decimalSeparator: ',' }],
['1,000,001', 1, { decimalSeparator: ',', allowTrailingInvalid }],
['1,00.1', 1.001, { decimalSeparator: ',', allowTrailingInvalid }],
['10.0,00.0', 100, { decimalSeparator: ',' }],
['10,00.00,0', NaN, { decimalSeparator: ',' }],
['10,00.00,0', 10, { decimalSeparator: ',', allowTrailingInvalid }],
['100,100', 100.1, { decimalSeparator: ',' }],
['100,1000', 100.1, { decimalSeparator: ',' }],
['1000,100', 1000.1, { decimalSeparator: ',' }],
['1000,1', 1000.1, { decimalSeparator: ',' }],
['1_.0,00', NaN, { decimalSeparator: ',' }],
['1_,00.0', NaN, { decimalSeparator: ',' }],
['1_.00.0', NaN, { decimalSeparator: ',' }],
['1_,000,001', NaN, { decimalSeparator: ',' }],
['1_,000,001', 1, { decimalSeparator: ',', allowTrailingInvalid }],
['1_,00.1', 1, { decimalSeparator: ',', allowTrailingInvalid }],
['1_0.0,00.0', 100, { decimalSeparator: ',' }],
['1_0,00.00,0', NaN, { decimalSeparator: ',' }],
['1_0,00.00,0', 10, { decimalSeparator: ',', allowTrailingInvalid }],
['1_00,100', 100.1, { decimalSeparator: ',' }],
['1_00,1000', 100.1, { decimalSeparator: ',' }],
['1_000,100', 1000.1, { decimalSeparator: ',' }],
['1_000,1', 1000.1, { decimalSeparator: ',' }],
],
'Invalid/repeated/ignored separators': [
['_11 11/22', NaN],
[',11 11/22', NaN],
['11 _11/22', NaN],
Expand All @@ -94,6 +130,10 @@ export const numericQuantityTests: Record<
['11 11,/22', NaN],
['11 11/22_', NaN],
['11 11/22,', NaN],
['11__22', NaN],
['11,,22', NaN],
['11,_22', NaN],
['11,_22', NaN],
['11 _11/22', 11, { allowTrailingInvalid }],
['11 ,11/22', 11, { allowTrailingInvalid }],
['11 11/_22', 11, { allowTrailingInvalid }],
Expand All @@ -104,6 +144,10 @@ export const numericQuantityTests: Record<
['11 11,/22', 11, { allowTrailingInvalid }],
['11 11/22_', 11.5, { allowTrailingInvalid }],
['11 11/22,', 11.5, { allowTrailingInvalid }],
['11__22', 11, { allowTrailingInvalid }],
['11,,22', 11, { allowTrailingInvalid }],
['11,_22', 11, { allowTrailingInvalid }],
['11,_22', 11, { allowTrailingInvalid }],
],
'Trailing invalid characters': [
['1 2 3', 1, { allowTrailingInvalid }],
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface NumericQuantityOptions {
* a valid integer too large for the `number` type.
*/
bigIntOnOverflow?: boolean;
/**
* Specifies which character ("." or ",") to treat as the decimal separator.
*
* @default "."
*/
// TODO: Add support for automatic decimal separator detection
// decimalSeparator?: ',' | '.' | 'auto';
decimalSeparator?: ',' | '.';
}

/**
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"esModuleInterop": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2015", "DOM"],
"target": "es2020"
"lib": ["ES2021", "DOM"],
"target": "es2021"
},
"include": ["./*.ts", "./src"]
}