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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
5.0.0 - Unreleased
=====================

* **Breaking:** Integers outside JavaScript's safe range (beyond ±`Number.MAX_SAFE_INTEGER`) now throw a parse error instead of silently returning a rounded value ([#28](https://github.com/BinaryMuse/toml-node/issues/28)). Opt in to lossless handling of the full 64-bit range with `toml.parse(input, { bigint: true })`, which returns all integer values as `BigInt`.
* **Breaking:** Integers outside TOML's 64-bit signed integer range now throw a parse error in either mode, as required by the spec. Previously they were silently rounded.

4.3.0 - Unreleased
=====================

Expand Down
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,17 @@ If you haven't heard of TOML, well you're just missing out. [Go check it out now
TOML Spec Support
-----------------

toml-node supports [TOML v1.1.0](https://toml.io/en/v1.1.0), scoring **673/680 (99.0%)** on the official [toml-test](https://github.com/toml-lang/toml-test) compliance suite:
toml-node supports [TOML v1.1.0](https://toml.io/en/v1.1.0), scoring **702/708 (99.2%)** on the official [toml-test](https://github.com/toml-lang/toml-test) compliance suite:

| | Pass | Total | Rate |
|---|---|---|---|
| Valid tests | 213 | 214 | 99.5% |
| Invalid tests | 460 | 466 | 98.7% |
| **Total** | **673** | **680** | **99.0%** |
| Valid tests | 218 | 218 | 100% |
| Invalid tests | 484 | 490 | 98.8% |
| **Total** | **702** | **708** | **99.2%** |

The 7 remaining failures are inherent JavaScript platform limitations shared by all JS TOML parsers:
The 6 remaining failures are inherent JavaScript platform limitations shared by all JS TOML parsers: they cover UTF-8 encoding validation, which Node.js handles at the engine level before the parser sees the data.

- 1 valid test: 64-bit integer precision (`Number` can't represent values beyond `Number.MAX_SAFE_INTEGER`)
- 6 invalid tests: UTF-8 encoding validation (Node.js handles UTF-8 decoding at the engine level before the parser sees the data)
Note that integers beyond `Number.MAX_SAFE_INTEGER` require the [`bigint` option](#integer-range-and-bigint) to parse losslessly; without it they throw a parse error rather than silently losing precision.

### Feature Support

Expand Down Expand Up @@ -68,6 +67,27 @@ To guard against stack overflow on maliciously deep input, arrays and inline tab
toml.parse(someTomlString, { maxDepth: 100 });
```

### Integer Range and BigInt

TOML requires parsers to handle the full range of 64-bit signed integers, but JavaScript's `number` type can only represent integers up to `Number.MAX_SAFE_INTEGER` (2⁵³ − 1) losslessly. By default, `toml.parse` returns integers as `number` and throws a parse error when a value falls outside the safe range, rather than silently returning a rounded value:

```javascript
toml.parse('id = 771752188537605140');
// Error: Integer 771752188537605140 cannot be represented losslessly
// as a JavaScript number. Use the `bigint` option to parse integers
// as BigInt values.
```

Pass `bigint: true` to instead parse **all** integers as `BigInt`, preserving the full 64-bit range:

```javascript
const data = toml.parse('id = 771752188537605140\ncount = 3', { bigint: true });
data.id // 771752188537605140n
data.count // 3n
```

Integers outside the 64-bit signed range always throw, in either mode, as required by the spec. Floats are unaffected by all of this: TOML floats are IEEE 754 binary64 values, which is exactly what a JavaScript `number` is, so every TOML float is represented as faithfully as the spec intends.

### Date/Time Values

Offset date-times are returned as JavaScript `Date` objects. Local date-times, local dates, and local times are returned as strings since they have no timezone information and can't be losslessly represented as `Date`:
Expand Down
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ declare module 'toml' {
*/
maxDepth?: number;

/**
* When true, all integer values are returned as `BigInt` instead of
* `number`, preserving the full 64-bit signed integer range required by
* TOML. When false (the default), integers outside
* `Number.MIN_SAFE_INTEGER`..`Number.MAX_SAFE_INTEGER` throw a parse
* error rather than silently losing precision.
*
* Floats are unaffected: TOML floats are IEEE 754 binary64, which is
* exactly what a JavaScript number is.
*/
bigint?: boolean;

/**
* When true, date/time values are returned as Temporal objects instead of
* the default representations:
Expand Down
33 changes: 32 additions & 1 deletion lib/compiler.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"use strict";

var INT64_MIN = -(2n ** 63n);
var INT64_MAX = 2n ** 63n - 1n;
var MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
var MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER);

function compile(nodes, inputText, options) {
options = options || {};
var temporal = null;
Expand Down Expand Up @@ -102,7 +108,9 @@ function compile(nodes, inputText, options) {


function reduceValueNode(node) {
if (node.type === "Array") {
if (node.type === "Integer") {
return reduceInteger(node);
} else if (node.type === "Array") {
return reduceArray(node.value);
} else if (node.type === "InlineTable") {
return reduceInlineTableNode(node.value);
Expand Down Expand Up @@ -130,6 +138,29 @@ function compile(nodes, inputText, options) {
return str.replace(/\.(\d{9})\d+/, ".$1");
}

// Integer nodes arrive from the parser as BigInt so the full 64-bit
// range is preserved exactly.
function reduceInteger(node) {
var value = node.value;
if (value < INT64_MIN || value > INT64_MAX) {
genError(
"Integer " + value + " is outside the 64-bit signed integer range required by TOML.",
node.offset
);
}
if (options.bigint) {
return value;
}
if (value < MIN_SAFE || value > MAX_SAFE) {
genError(
"Integer " + value + " cannot be represented losslessly as a JavaScript number. " +
"Use the `bigint` option to parse integers as BigInt values.",
node.offset
);
}
return Number(value);
}

function reduceInlineTableNode(values) {
var obj = createTable();
var definedKeys = new Set();
Expand Down
8 changes: 4 additions & 4 deletions lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,10 @@ function peg$parse(input, options) {
function peg$f39(sign, digits) { return (sign === '-' ? '-' : '') + digits }
function peg$f40() { return '0' }
function peg$f41(sign, digits) { return (sign || '') + digits }
function peg$f42(digits) { return node('Integer', parseInt(stripUnderscores(digits), 16), offset()) }
function peg$f43(digits) { return node('Integer', parseInt(stripUnderscores(digits), 8), offset()) }
function peg$f44(digits) { return node('Integer', parseInt(stripUnderscores(digits), 2), offset()) }
function peg$f45(text) { return node('Integer', parseInt(stripUnderscores(text), 10), offset()) }
function peg$f42(digits) { return node('Integer', BigInt('0x' + stripUnderscores(digits)), offset()) }
function peg$f43(digits) { return node('Integer', BigInt('0o' + stripUnderscores(digits)), offset()) }
function peg$f44(digits) { return node('Integer', BigInt('0b' + stripUnderscores(digits)), offset()) }
function peg$f45(text) { return node('Integer', BigInt(stripUnderscores(text)), offset()) }
function peg$f46(sign) { return (sign || '') + '0' }
function peg$f47(sign, digits) { return (sign || '') + digits }
Comment thread
BinaryMuse marked this conversation as resolved.
function peg$f48() { return node('Boolean', true, offset()) }
Expand Down
10 changes: 6 additions & 4 deletions src/toml.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,13 @@ FLOAT_DEC_INT
float_exp_text
= sign:[+-]? digits:$DEC_DIGIT_SEQ { return (sign || '') + digits }

// Integer values are BigInt so the full 64-bit range survives parsing;
// the compiler converts to Number (or errors) based on options.
integer
= '0x' digits:$HEX_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 16), offset()) }
/ '0o' digits:$OCT_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 8), offset()) }
/ '0b' digits:$BIN_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 2), offset()) }
/ text:dec_integer_text { return node('Integer', parseInt(stripUnderscores(text), 10), offset()) }
= '0x' digits:$HEX_DIGIT_SEQ { return node('Integer', BigInt('0x' + stripUnderscores(digits)), offset()) }
/ '0o' digits:$OCT_DIGIT_SEQ { return node('Integer', BigInt('0o' + stripUnderscores(digits)), offset()) }
/ '0b' digits:$BIN_DIGIT_SEQ { return node('Integer', BigInt('0b' + stripUnderscores(digits)), offset()) }
/ text:dec_integer_text { return node('Integer', BigInt(stripUnderscores(text)), offset()) }

dec_integer_text
= sign:[+-]? '0' !([0-9_]) !'.' { return (sign || '') + '0' }
Expand Down
15 changes: 12 additions & 3 deletions test/spec-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ var FILES_LIST = path.join(TESTS_DIR, "files-toml-1.1.0");
// Known failures due to JS platform limitations, not parser bugs.
// These are excluded from the pass/fail exit code.
var KNOWN_FAILURES = [
// Number can't represent 64-bit integers beyond Number.MAX_SAFE_INTEGER
"valid/integer/long",
// Node.js handles UTF-8 decoding at the engine level; invalid byte sequences
// are replaced before the parser sees the data, so we can't reject them.
"invalid/encoding/bad-codepoint",
Expand All @@ -40,6 +38,12 @@ var KNOWN_FAILURES = [
"invalid/encoding/bad-utf8-in-string-literal",
];

// Tests whose integers exceed Number.MAX_SAFE_INTEGER; the parser rejects
// them by default (a documented spec deviation), so run them in bigint mode.
var BIGINT_TESTS = [
"valid/integer/long",
];

// ---------------------------------------------------------------------------
// Tagged JSON conversion
//
Expand Down Expand Up @@ -137,6 +141,10 @@ function toTaggedJSON(value, typeMap, currentPath) {
return { type: "bool", value: String(value) };
}

if (typeof value === "bigint") {
return { type: "integer", value: String(value) };
}

if (typeof value === "number") {
if (Number.isNaN(value)) {
return { type: "float", value: "nan" };
Expand Down Expand Up @@ -342,7 +350,8 @@ function runValidTest(testPath) {
try {
var astNodes = parser.parse(tomlContent);
var typeMap = buildTypeMap(astNodes);
var parsed = toml.parse(tomlContent);
var options = BIGINT_TESTS.indexOf(testPath) !== -1 ? { bigint: true } : undefined;
var parsed = toml.parse(tomlContent, options);
var tagged = toTaggedJSON(parsed, typeMap, "");
var diff = deepEqual(tagged, expectedJSON);
if (diff) {
Expand Down
88 changes: 88 additions & 0 deletions test/test_toml.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,94 @@ describe("numbers", function () {
});
});

describe("integer range", function () {
it("parses integers at the edge of the safe range", function () {
parsesToml("a = 9007199254740991\nb = -9007199254740991", {
a: Number.MAX_SAFE_INTEGER,
b: Number.MIN_SAFE_INTEGER,
});
});

it("rejects integers beyond the safe range by default", function () {
assert.throws(function () {
toml.parse("a = 9007199254740993");
}, /cannot be represented losslessly.*bigint/s);
assert.throws(function () {
toml.parse("a = -9007199254740993");
}, /cannot be represented losslessly.*bigint/s);
});

it("reports the position of out-of-range integers", function () {
try {
toml.parse("a = 1\nb = 2\nc = 9223372036854775807");
assert.fail("expected parse error");
} catch (e) {
assert.strictEqual(e.line, 3);
assert.strictEqual(e.column, 5);
}
});

it("rejects integers outside the int64 range in both modes", function () {
assert.throws(function () {
toml.parse("a = 9223372036854775808");
}, /64-bit signed integer range/);
assert.throws(function () {
toml.parse("a = 9223372036854775808", { bigint: true });
}, /64-bit signed integer range/);
assert.throws(function () {
toml.parse("a = -9223372036854775809", { bigint: true });
}, /64-bit signed integer range/);
assert.throws(function () {
toml.parse("a = 0xffff_ffff_ffff_ffff", { bigint: true });
}, /64-bit signed integer range/);
});

it("large-magnitude floats are unaffected", function () {
parsesToml("a = 9007199254740993.0\nb = 1e300", {
a: 9007199254740992,
b: 1e300,
});
});
});

describe("bigint mode", function () {
it("returns all integers as BigInt", function () {
var result = toml.parse("a = 42\nb = -17\nc = 0x10\nd = 0o755\ne = 0b101", {
bigint: true,
});
assert.deepStrictEqual(normalize(result), {
a: 42n,
b: -17n,
c: 16n,
d: 493n,
e: 5n,
});
});

it("preserves the full int64 range exactly", function () {
var result = toml.parse(
"max = 9223372036854775807\nmin = -9223372036854775808",
{ bigint: true }
);
assert.strictEqual(result.max, 9223372036854775807n);
assert.strictEqual(result.min, -9223372036854775808n);
});

it("converts integers inside arrays and inline tables", function () {
var result = toml.parse("a = [1, 2]\nb = { c = 9223372036854775807 }", {
bigint: true,
});
assert.deepStrictEqual(normalize(result.a), [1n, 2n]);
assert.strictEqual(result.b.c, 9223372036854775807n);
});

it("leaves floats as numbers", function () {
var result = toml.parse("a = 3.0\nb = 5e22", { bigint: true });
assert.strictEqual(result.a, 3.0);
assert.strictEqual(result.b, 5e22);
});
});

describe("whitespace", function () {
it("handles whitespace", function () {
parsesToml("a = 1\n \n b = 2 ", { a: 1, b: 2 });
Expand Down
Loading