From 11e3a364d5c4af2276bc736d3136139b70aaab51 Mon Sep 17 00:00:00 2001 From: Cynthia Date: Mon, 22 May 2023 14:38:52 +0200 Subject: [PATCH] feat: add stringify method (#2) * chore: slight reorganization * feat: rough draft of a serializer * fix: make stringify spec-compliant * chore: code cleanup and benchmarks * chore: update readme * fix: reject null/undefined in arrays * chore: update readme * fix: reject invalid dates --- README.md | 96 ++++++- bench/stringifyLargeMixed.bench.ts | 49 ++++ bench/stringifySpecExample.bench.ts | 49 ++++ package.json | 7 +- src/date.ts | 1 + src/extract.ts | 105 ++++++++ src/index.ts | 151 +---------- src/parse.ts | 185 +++++++++---- src/primitive.ts | 3 +- src/stringify.ts | 167 ++++++++++++ src/struct.ts | 2 +- test/extract.test.ts | 46 ++++ test/parse.test.ts | 380 +++++++++++++++++++++++++- test/stringify.test.ts | 209 +++++++++++++++ test/toml.test.ts | 399 ---------------------------- toml-test-encode.js | 76 ++++++ 16 files changed, 1293 insertions(+), 632 deletions(-) create mode 100644 bench/stringifyLargeMixed.bench.ts create mode 100644 bench/stringifySpecExample.bench.ts create mode 100644 src/extract.ts create mode 100644 src/stringify.ts create mode 100644 test/extract.test.ts create mode 100644 test/stringify.test.ts delete mode 100644 test/toml.test.ts create mode 100644 toml-test-encode.js diff --git a/README.md b/README.md index e593f35..dae8c56 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License](https://img.shields.io/github/license/squirrelchat/smol-toml.svg?style=flat-square)](https://github.com/squirrelchat/smol-toml/blob/mistress/LICENSE) [![npm](https://img.shields.io/npm/v/smol-toml?style=flat-square)](https://npm.im/smol-toml) -A small, fast, and correct TOML parser. smol-toml is fully(ish) spec-compliant with TOML v1.0.0. +A small, fast, and correct TOML parser and serializer. smol-toml is fully(ish) spec-compliant with TOML v1.0.0. Why yet another TOML parser? Well, the ecosystem of TOML parsers in JavaScript is quite underwhelming, most likely due to a lack of interest. With most parsers being outdated, unmaintained, non-compliant, or a combination of these, a new @@ -23,12 +23,13 @@ smol-toml also passes all of the tests in https://github.com/iarna/toml-spec-tes
List of failed `toml-test` cases + These tests were done by modifying `primitive.ts` and make the implementation return bigints for integers. This allows verifying the parser correctly intents a number to be an integer or a float. *Ideally, this becomes an option of the library, but for now...* -The following tests are failing: +The following parse tests are failing: - invalid/encoding/bad-utf8-in-comment - invalid/encoding/bad-utf8-in-multiline-literal - invalid/encoding/bad-utf8-in-multiline @@ -44,11 +45,65 @@ The following tests are failing: ## Usage ```js -import { parse } from 'smol-toml' +import { parse, stringify } from 'smol-toml' const doc = '...' const parsed = parse(doc) console.log(parsed) + +const toml = stringify(parsed) +console.log(toml) +``` + +A few notes on the `stringify` function: +- `undefined` and `null` values on objects are ignored (does not produce a key/value). +- `undefined` and `null` values in arrays are **rejected**. +- Functions, classes and symbols are **rejected**. +- floats will be serialized as integers if they don't have a decimal part. + - `stringify(parse('a = 1.0')) === 'a = 1'` +- JS `Date` will be serialized as Offset Date Time + - Use the [`TomlDate` object](#dates) for representing other types. + +### Dates +`smol-toml` uses an extended `Date` object to represent all types of TOML Dates. In the future, `smol-toml` will use +objects from the Temporal proposal, but for now we're stuck with the legacy Date object. + +```js +import { TomlDate } from 'smol-toml' + +// Offset Date Time +const date = new TomlDate('1979-05-27T07:32:00.000-08:00') +console.log(date.isDateTime(), date.isDate(), date.isTime(), date.isLocal()) // ~> true, false, false, false +console.log(date.toISOString()) // ~> 1979-05-27T07:32:00.000-08:00 + +// Local Date Time +const date = new TomlDate('1979-05-27T07:32:00.000') +console.log(date.isDateTime(), date.isDate(), date.isTime(), date.isLocal()) // ~> true, false, false, true +console.log(date.toISOString()) // ~> 1979-05-27T07:32:00.000 + +// Local Date +const date = new TomlDate('1979-05-27') +console.log(date.isDateTime(), date.isDate(), date.isTime(), date.isLocal()) // ~> false, true, false, true +console.log(date.toISOString()) // ~> 1979-05-27 + +// Local Time +const date = new TomlDate('07:32:00') +console.log(date.isDateTime(), date.isDate(), date.isTime(), date.isLocal()) // ~> false, false, true, true +console.log(date.toISOString()) // ~> 07:32:00.000 +``` + +You can also wrap a native `Date` object and specify using different methods depending on the type of date you wish +to represent: + +```js +import { TomlDate } from 'smol-toml' + +const jsDate = new Date() + +const offsetDateTime = TomlDate.wrapAsOffsetDateTime(jsDate) +const localDateTime = TomlDate.wrapAsLocalDateTime(jsDate) +const localDate = TomlDate.wrapAsLocalDate(jsDate) +const localTime = TomlDate.wrapAsLocalTime(jsDate) ``` ## Performance @@ -61,15 +116,20 @@ idea is to have a file relatively close to a real-world application. The large TOML generator can be found [here](https://gist.github.com/cyyynthia/e77c744cb6494dabe37d0182506526b9) -| | smol-toml | @iarna/toml@3.0.0 | @ltd/j-toml | fast-toml | -|----------------|---------------------|-------------------|----------------|----------------| -| Spec example | **71,356.51 op/s** | 33,629.31 op/s | 16,433.86 op/s | 29,421.60 op/s | -| ~5MB test file | **3.8091 op/s** | *DNF* | 2.4369 op/s | 2.6078 op/s | +| **Parse** | smol-toml | @iarna/toml@3.0.0 | @ltd/j-toml | fast-toml | +|----------------|---------------------|-------------------|-----------------|-----------------| +| Spec example | **71,356.51 op/s** | 33,629.31 op/s | 16,433.86 op/s | 29,421.60 op/s | +| ~5MB test file | **3.8091 op/s** | *DNF* | 2.4369 op/s | 2.6078 op/s | + +| **Stringify** | smol-toml | @iarna/toml@3.0.0 | @ltd/j-toml | +|----------------|----------------------|-------------------|----------------| +| Spec example | **195,191.99 op/s** | 46,583.07 op/s | 5,670.12 op/s | +| ~5MB test file | **14.6709 op/s** | 3.5941 op/s | 0.7856 op/s |
Detailed benchmark data -Tests ran using Vitest v0.31.0 on commit 04d233e351f9ae719222154ee2217aea8b95dbab +Tests ran using Vitest v0.31.0 on commit f58cb6152e667e9cea09f31c93d90652e3b82bf5 CPU: Intel Core i7 7700K (4.2GHz) @@ -87,6 +147,16 @@ CPU: Intel Core i7 7700K (4.2GHz) · smol-toml 3.8091 239.60 287.30 262.53 274.17 287.30 287.30 287.30 ±3.66% 10 fastest · @ltd/j-toml 2.4369 376.73 493.49 410.35 442.58 493.49 493.49 493.49 ±7.08% 10 slowest · fast-toml 2.6078 373.88 412.79 383.47 388.62 412.79 412.79 412.79 ±2.72% 10 + ✓ bench/stringifySpecExample.bench.ts (3) 1886ms + name hz min max mean p75 p99 p995 p999 rme samples + · smol-toml 195,191.99 0.0047 0.2704 0.0051 0.0050 0.0099 0.0110 0.0152 ±0.41% 97596 fastest + · @iarna/toml 46,583.07 0.0197 0.2808 0.0215 0.0208 0.0448 0.0470 0.1704 ±0.47% 23292 + · @ltd/j-toml 5,670.12 0.1613 0.5768 0.1764 0.1726 0.3036 0.3129 0.4324 ±0.56% 2836 slowest + ✓ bench/stringifyLargeMixed.bench.ts (3) 24057ms + name hz min max mean p75 p99 p995 p999 rme samples + · smol-toml 14.6709 65.1071 79.2199 68.1623 67.1088 79.2199 79.2199 79.2199 ±5.25% 10 fastest + · @iarna/toml 3.5941 266.48 295.24 278.24 290.10 295.24 295.24 295.24 ±2.83% 10 + · @ltd/j-toml 0.7856 1,254.33 1,322.05 1,272.87 1,286.82 1,322.05 1,322.05 1,322.05 ±1.37% 10 slowest BENCH Summary @@ -99,6 +169,14 @@ CPU: Intel Core i7 7700K (4.2GHz) 2.12x faster than @iarna/toml 2.43x faster than fast-toml 4.34x faster than @ltd/j-toml + + smol-toml - bench/stringifyLargeMixed.bench.ts > + 4.00x faster than @iarna/toml + 18.33x faster than @ltd/j-toml + + smol-toml - bench/stringifySpecExample.bench.ts > + 4.19x faster than @iarna/toml + 34.42x faster than @ltd/j-toml ``` --- @@ -111,7 +189,7 @@ I initially reported this to the library author, but the author decided to - b) [delete the issue](https://github.com/huan231/toml-nodejs/issues/12) when pointed out links to the NodeJS documentation about the flag removal and standard resolution algorithm. -For the reference anyways, `toml-nodejs` (with proper imports) is ~8x slower on both benchmark with: +For the reference anyways, `toml-nodejs` (with proper imports) is ~8x slower on both parse benchmark with: - spec example: 7,543.47 op/s - 5mb mixed: 0.7006 op/s
diff --git a/bench/stringifyLargeMixed.bench.ts b/bench/stringifyLargeMixed.bench.ts new file mode 100644 index 0000000..094c27f --- /dev/null +++ b/bench/stringifyLargeMixed.bench.ts @@ -0,0 +1,49 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { bench } from 'vitest' +import { readFile } from 'fs/promises' +import { stringify as smolTomlStringify, parse } from '../src/index.js' +import { stringify as iarnaTomlStringify } from '@iarna/toml' +import { stringify as ltdJTomlStringify } from '@ltd/j-toml' + +let obj = parse( + await readFile(new URL('./testfiles/5mb-mixed.toml', import.meta.url), 'utf8') +) + +bench('smol-toml', () => { + smolTomlStringify(obj) +}) + +bench('@iarna/toml', () => { + iarnaTomlStringify(obj) +}) + +bench('@ltd/j-toml', () => { + ltdJTomlStringify(obj) +}) diff --git a/bench/stringifySpecExample.bench.ts b/bench/stringifySpecExample.bench.ts new file mode 100644 index 0000000..1c216af --- /dev/null +++ b/bench/stringifySpecExample.bench.ts @@ -0,0 +1,49 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { bench } from 'vitest' +import { readFile } from 'fs/promises' +import { stringify as smolTomlStringify, parse } from '../src/index.js' +import { stringify as iarnaTomlStringify } from '@iarna/toml' +import { stringify as ltdJTomlStringify } from '@ltd/j-toml' + +let obj = parse( + await readFile(new URL('./testfiles/toml-spec-example.toml', import.meta.url), 'utf8') +) + +bench('smol-toml', () => { + smolTomlStringify(obj) +}) + +bench('@iarna/toml', () => { + iarnaTomlStringify(obj) +}) + +bench('@ltd/j-toml', () => { + ltdJTomlStringify(obj) +}) diff --git a/package.json b/package.json index b604eb1..59d69a1 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "smol-toml", - "version": "1.0.1", + "version": "1.1.0", "keywords": [ "toml", - "parser" + "parser", + "serializer" ], - "description": "A small, fast, and correct TOML parser", + "description": "A small, fast, and correct TOML parser/serializer", "repository": "git@github.com:squirrelchat/smol-toml.git", "author": "Cynthia ", "license": "BSD-3-Clause", diff --git a/src/date.ts b/src/date.ts index 2c9f0f6..d69ac96 100644 --- a/src/date.ts +++ b/src/date.ts @@ -58,6 +58,7 @@ export default class TomlDate extends Date { } else { offset = match[3] || null date = date.toUpperCase() + if (!offset) date += 'Z' } } else { date = '' diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..d69c6de --- /dev/null +++ b/src/extract.ts @@ -0,0 +1,105 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { parseString, parseValue } from './primitive.js' +import { parseArray, parseInlineTable } from './struct.js' +import { type TomlPrimitive, indexOfNewline, skipVoid, skipUntil, skipComment, getStringEnd } from './util.js' +import TomlError from './error.js' + +function sliceAndTrimEndOf (str: string, startPtr: number, endPtr: number, allowNewLines?: boolean): [ string, number ] { + let value = str.slice(startPtr, endPtr) + + let commentIdx = value.indexOf('#') + if (commentIdx > -1) { + // The call to skipComment allows to "validate" the comment + // (absence of control characters) + skipComment(str, commentIdx) + value = value.slice(0, commentIdx) + } + + let trimmed = value.trimEnd() + + if (!allowNewLines) { + let newlineIdx = value.indexOf('\n', trimmed.length) + if (newlineIdx > -1) { + throw new TomlError('newlines are not allowed in inline tables', { + toml: str, + ptr: startPtr + newlineIdx + }) + } + } + + return [ trimmed, commentIdx ] +} + +export function extractValue (str: string, ptr: number, end?: string): [ TomlPrimitive, number ] { + let c = str[ptr] + if (c === '[' || c === '{') { + let [ value, endPtr ] = c === '[' + ? parseArray(str, ptr) + : parseInlineTable(str, ptr) + + let newPtr = skipUntil(str, endPtr, ',', end) + if (end === '}') { + let nextNewLine = indexOfNewline(str, endPtr, newPtr) + if (nextNewLine > -1) { + throw new TomlError('newlines are not allowed in inline tables', { + toml: str, + ptr: nextNewLine + }) + } + } + + return [ value, newPtr ] + } + + let endPtr + if (c === '"' || c === "'") { + endPtr = getStringEnd(str, ptr) + return [ parseString(str, ptr, endPtr), endPtr + +(!!end && str[endPtr] === ',') ] + } + + endPtr = skipUntil(str, ptr, ',', end) + let slice = sliceAndTrimEndOf(str, ptr, endPtr - (+(str[endPtr - 1] === ',')), end === ']') + if (!slice[0]) { + throw new TomlError('incomplete key-value declaration: no value specified', { + toml: str, + ptr: ptr + }) + } + + if (end && slice[1] > -1) { + endPtr = skipVoid(str, ptr + slice[1]) + endPtr += +(str[endPtr] === ',') + } + + return [ + parseValue(slice[0], str, ptr), + endPtr, + ] +} diff --git a/src/index.ts b/src/index.ts index 106a12d..c810412 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,153 +26,8 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { parseKey } from './struct.js' -import { extractValue } from './parse.js' -import { type TomlPrimitive, skipVoid } from './util.js' -import TomlError from './error.js' - +export { default as TomlError } from './error.js' export { default as TomlDate } from './date.js' -const enum Type { DOTTED, EXPLICIT, ARRAY } - -type MetaState = { t: Type, d: boolean, i: number, c: MetaRecord } -type MetaRecord = { [k: string]: MetaState } -type PeekResult = [ string, Record, MetaRecord ] | null - -function peekTable (key: string[], table: Record, meta: MetaRecord, type: Type): PeekResult { - let t: any = table - let m = meta - let k: string - let hasOwn = false - let state: MetaState - - for (let i = 0; i < key.length; i++) { - if (i) { - t = hasOwn! ? t[k!] : (t[k!] = {}) - m = (state = m[k!]!).c - - if (type === Type.DOTTED && state.t === Type.EXPLICIT) { - return null - } - - if (state.t === Type.ARRAY) { - let l = t.length - 1 - t = t[l] - m = m[l]!.c - } - } - - k = key[i]! - if ((hasOwn = Object.hasOwn(t, k)) && m[k]?.t === Type.DOTTED && m[k]?.d) { - return null - } - - if (!hasOwn) { - if (k === '__proto__') { - Object.defineProperty(t, k, { enumerable: true, configurable: true, writable: true }) - Object.defineProperty(m, k, { enumerable: true, configurable: true, writable: true }) - } - - m[k] = { - t: i < key.length - 1 && type === Type.ARRAY - ? Type.DOTTED - : type, - d: false, - i: 0, - c: {}, - } - } - } - - state = m[k!]! - if (state.t !== type) { - // Bad key type! - return null - } - - if (type === Type.ARRAY) { - if (!state.d) { - state.d = true - t[k!] = [] - } - - t[k!].push(t = {}) - state.c[state.i++] = (state = { t: Type.EXPLICIT, d: false, i: 0, c: {} }) - } - - if (state.d) { - // Redefining a table! - return null - } - - state.d = true - if (type === Type.EXPLICIT) { - t = hasOwn ? t[k!] : (t[k!] = {}) - } else if (type === Type.DOTTED && hasOwn) { - return null - } - - return [ k!, t, state.c ] -} - -export function parse (toml: string): Record { - let res = {} - let meta = {} - - let tbl = res - let m = meta - - for (let ptr = skipVoid(toml, 0); ptr < toml.length;) { - if (toml[ptr] === '[') { - let isTableArray = toml[++ptr] === '[' - let k = parseKey(toml, ptr += +isTableArray, ']') - - if (isTableArray) { - if (toml[k[1] - 1] !== ']') { - throw new TomlError('expected end of table declaration', { - toml: toml, - ptr: k[1] - 1, - }) - } - - k[1]++ - } - - let p = peekTable(k[0], res, meta, isTableArray ? Type.ARRAY : Type.EXPLICIT) - if (!p) { - throw new TomlError('trying to redefine an already defined table or value', { - toml: toml, - ptr: ptr, - }) - } - - m = p[2] - tbl = p[1] - ptr = k[1] - } else { - let k = parseKey(toml, ptr) - let p = peekTable(k[0], tbl, m, Type.DOTTED) - if (!p) { - throw new TomlError('trying to redefine an already defined table or value', { - toml: toml, - ptr: ptr, - }) - } - - let v = extractValue(toml, k[1]) - p[1][p[0]] = v[0] - ptr = v[1] - } - - ptr = skipVoid(toml, ptr, true) - if (toml[ptr] && toml[ptr] !== '\n' && toml[ptr] !== '\r') { - throw new TomlError('each key-value declaration must be followed by an end-of-line', { - toml: toml, - ptr: ptr - }) - } - ptr = skipVoid(toml, ptr) - } - - return res -} +export { parse } from './parse.js' +export { stringify } from './stringify.js' diff --git a/src/parse.ts b/src/parse.ts index d69c6de..6f1afa4 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -26,80 +26,151 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { parseString, parseValue } from './primitive.js' -import { parseArray, parseInlineTable } from './struct.js' -import { type TomlPrimitive, indexOfNewline, skipVoid, skipUntil, skipComment, getStringEnd } from './util.js' +import { parseKey } from './struct.js' +import { extractValue } from './extract.js' +import { type TomlPrimitive, skipVoid } from './util.js' import TomlError from './error.js' -function sliceAndTrimEndOf (str: string, startPtr: number, endPtr: number, allowNewLines?: boolean): [ string, number ] { - let value = str.slice(startPtr, endPtr) +const enum Type { DOTTED, EXPLICIT, ARRAY } - let commentIdx = value.indexOf('#') - if (commentIdx > -1) { - // The call to skipComment allows to "validate" the comment - // (absence of control characters) - skipComment(str, commentIdx) - value = value.slice(0, commentIdx) - } +type MetaState = { t: Type, d: boolean, i: number, c: MetaRecord } +type MetaRecord = { [k: string]: MetaState } +type PeekResult = [ string, Record, MetaRecord ] | null - let trimmed = value.trimEnd() +function peekTable (key: string[], table: Record, meta: MetaRecord, type: Type): PeekResult { + let t: any = table + let m = meta + let k: string + let hasOwn = false + let state: MetaState - if (!allowNewLines) { - let newlineIdx = value.indexOf('\n', trimmed.length) - if (newlineIdx > -1) { - throw new TomlError('newlines are not allowed in inline tables', { - toml: str, - ptr: startPtr + newlineIdx - }) + for (let i = 0; i < key.length; i++) { + if (i) { + t = hasOwn! ? t[k!] : (t[k!] = {}) + m = (state = m[k!]!).c + + if (type === Type.DOTTED && state.t === Type.EXPLICIT) { + return null + } + + if (state.t === Type.ARRAY) { + let l = t.length - 1 + t = t[l] + m = m[l]!.c + } } - } - return [ trimmed, commentIdx ] -} + k = key[i]! + if ((hasOwn = Object.hasOwn(t, k)) && m[k]?.t === Type.DOTTED && m[k]?.d) { + return null + } -export function extractValue (str: string, ptr: number, end?: string): [ TomlPrimitive, number ] { - let c = str[ptr] - if (c === '[' || c === '{') { - let [ value, endPtr ] = c === '[' - ? parseArray(str, ptr) - : parseInlineTable(str, ptr) - - let newPtr = skipUntil(str, endPtr, ',', end) - if (end === '}') { - let nextNewLine = indexOfNewline(str, endPtr, newPtr) - if (nextNewLine > -1) { - throw new TomlError('newlines are not allowed in inline tables', { - toml: str, - ptr: nextNewLine - }) + if (!hasOwn) { + if (k === '__proto__') { + Object.defineProperty(t, k, { enumerable: true, configurable: true, writable: true }) + Object.defineProperty(m, k, { enumerable: true, configurable: true, writable: true }) + } + + m[k] = { + t: i < key.length - 1 && type === Type.ARRAY + ? Type.DOTTED + : type, + d: false, + i: 0, + c: {}, } } + } - return [ value, newPtr ] + state = m[k!]! + if (state.t !== type) { + // Bad key type! + return null } - let endPtr - if (c === '"' || c === "'") { - endPtr = getStringEnd(str, ptr) - return [ parseString(str, ptr, endPtr), endPtr + +(!!end && str[endPtr] === ',') ] + if (type === Type.ARRAY) { + if (!state.d) { + state.d = true + t[k!] = [] + } + + t[k!].push(t = {}) + state.c[state.i++] = (state = { t: Type.EXPLICIT, d: false, i: 0, c: {} }) + } + + if (state.d) { + // Redefining a table! + return null } - endPtr = skipUntil(str, ptr, ',', end) - let slice = sliceAndTrimEndOf(str, ptr, endPtr - (+(str[endPtr - 1] === ',')), end === ']') - if (!slice[0]) { - throw new TomlError('incomplete key-value declaration: no value specified', { - toml: str, - ptr: ptr - }) + state.d = true + if (type === Type.EXPLICIT) { + t = hasOwn ? t[k!] : (t[k!] = {}) + } else if (type === Type.DOTTED && hasOwn) { + return null } - if (end && slice[1] > -1) { - endPtr = skipVoid(str, ptr + slice[1]) - endPtr += +(str[endPtr] === ',') + return [ k!, t, state.c ] +} + +export function parse (toml: string): Record { + let res = {} + let meta = {} + + let tbl = res + let m = meta + + for (let ptr = skipVoid(toml, 0); ptr < toml.length;) { + if (toml[ptr] === '[') { + let isTableArray = toml[++ptr] === '[' + let k = parseKey(toml, ptr += +isTableArray, ']') + + if (isTableArray) { + if (toml[k[1] - 1] !== ']') { + throw new TomlError('expected end of table declaration', { + toml: toml, + ptr: k[1] - 1, + }) + } + + k[1]++ + } + + let p = peekTable(k[0], res, meta, isTableArray ? Type.ARRAY : Type.EXPLICIT) + if (!p) { + throw new TomlError('trying to redefine an already defined table or value', { + toml: toml, + ptr: ptr, + }) + } + + m = p[2] + tbl = p[1] + ptr = k[1] + } else { + let k = parseKey(toml, ptr) + let p = peekTable(k[0], tbl, m, Type.DOTTED) + if (!p) { + throw new TomlError('trying to redefine an already defined table or value', { + toml: toml, + ptr: ptr, + }) + } + + let v = extractValue(toml, k[1]) + p[1][p[0]] = v[0] + ptr = v[1] + } + + ptr = skipVoid(toml, ptr, true) + if (toml[ptr] && toml[ptr] !== '\n' && toml[ptr] !== '\r') { + throw new TomlError('each key-value declaration must be followed by an end-of-line', { + toml: toml, + ptr: ptr + }) + } + ptr = skipVoid(toml, ptr) } - return [ - parseValue(slice[0], str, ptr), - endPtr, - ] + return res } diff --git a/src/primitive.ts b/src/primitive.ts index d060fbc..4ef7b1b 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -134,9 +134,10 @@ export function parseValue (value: string, toml: string, ptr: number): boolean | if (value === 'inf' || value === '+inf') return Infinity if (value === 'nan' || value === '+nan' || value === '-nan') return NaN + if (value === '-0') return 0 // Avoid FP representation of -0 + // Numbers let isInt - if (value === '-0') return 0 // Avoid FP representation of -0 if ((isInt = INT_REGEX.test(value)) || FLOAT_REGEX.test(value)) { if (LEADING_ZERO.test(value)) { throw new TomlError('leading zeroes are not allowed', { diff --git a/src/stringify.ts b/src/stringify.ts new file mode 100644 index 0000000..2987e1e --- /dev/null +++ b/src/stringify.ts @@ -0,0 +1,167 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +const BARE_KEY = /^[a-z0-9-_]+$/i + +function extendedTypeOf (obj: any) { + let type = typeof obj + if (type === 'object') { + if (Array.isArray(obj)) return 'array' + if (obj instanceof Date) return 'date' + } + + return type +} + +function isArrayOfTables (obj: any[]) { + for (let i = 0; i < obj.length; i++) { + if (extendedTypeOf(obj[i]) !== 'object') return false + } + + return true +} + +function formatString (s: string) { + return JSON.stringify(s).replace(/\x7f/g, '\\u007f') +} + +function stringifyValue (val: any, type = extendedTypeOf(val)) { + if (type === 'number') { + if (isNaN(val)) return 'nan' + if (val === Infinity) return 'inf' + if (val === -Infinity) return '-inf' + return val.toString() + } + + if (type === 'bigint' || type === 'boolean') { + return val.toString() + } + + if (type === 'string') { + return formatString(val) + } + + if (type === 'date') { + if (isNaN(val.getTime())) { + throw new TypeError('cannot serialize invalid date') + } + + return val.toISOString() + } + + if (type === 'object') { + return stringifyInlineTable(val) + } + + if (type === 'array') { + return stringifyArray(val) + } +} + +function stringifyInlineTable (obj: any) { + let res = '{ ' + + let keys = Object.keys(obj) + for (let i = 0; i < keys.length; i++) { + let k = keys[i]! + if (i) res += ', ' + + res += BARE_KEY.test(k) ? k : formatString(k) + res += ' = ' + res += stringifyValue(obj[k]) + } + + return res + ' }' +} + +function stringifyArray (array: any[]) { + let res = '[ ' + for (let i = 0; i < array.length; i++) { + if (i) res += ', ' + if (array[i] === null || array[i] === void 0) { + throw new TypeError('arrays cannot contain null or undefined values') + } + + res += stringifyValue(array[i]) + } + + return res + ' ]' +} + +function stringifyArrayTable (array: any[], key: string) { + let res = '' + for (let i = 0; i < array.length; i++) { + res += `[[${key}]]\n` + res += stringifyTable(array[i], key) + res += '\n\n' + } + + return res +} + +function stringifyTable (obj: any, prefix = '') { + let preamble = '' + let tables = '' + + let keys = Object.keys(obj) + for (let i = 0; i < keys.length; i++) { + let k = keys[i]! + if (obj[k] !== null && obj[k] !== void 0) { + let type = extendedTypeOf(obj[k]) + if (type === 'symbol' || type === 'function') { + throw new TypeError(`cannot serialize values of type '${type}'`) + } + + let key = BARE_KEY.test(k) ? k : formatString(k) + + if (type === 'array' && isArrayOfTables(obj[k])) { + tables += stringifyArrayTable(obj[k], prefix ? `${prefix}.${key}` : key) + } else if (type === 'object') { + let tblKey = prefix ? `${prefix}.${key}` : key + tables += `[${tblKey}]\n` + tables += stringifyTable(obj[k], tblKey) + tables += '\n\n' + } else { + preamble += key + preamble += ' = ' + preamble += stringifyValue(obj[k], type) + preamble += '\n' + } + } + } + + return `${preamble}\n${tables}`.trim() +} + +export function stringify (obj: any) { + if (extendedTypeOf(obj) !== 'object') { + throw new TypeError('stringify can only be called with an object') + } + + return stringifyTable(obj) +} diff --git a/src/struct.ts b/src/struct.ts index 4c89b6c..dd4e071 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -27,7 +27,7 @@ */ import { parseString } from './primitive.js' -import { extractValue } from './parse.js' +import { extractValue } from './extract.js' import { type TomlPrimitive, skipComment, indexOfNewline, getStringEnd, skipVoid } from './util.js' import TomlError from './error.js' diff --git a/test/extract.test.ts b/test/extract.test.ts new file mode 100644 index 0000000..e0de7cc --- /dev/null +++ b/test/extract.test.ts @@ -0,0 +1,46 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { it, expect } from 'vitest' +import { extractValue } from '../src/extract.js' + +it('extracts value of correct type', () => { + expect(extractValue('[ 1, 2 ]', 2, ']')).toStrictEqual([ 1, 4 ]) + expect(extractValue('[ "uwu", 2 ]', 2, ']')).toStrictEqual([ 'uwu', 8 ]) + expect(extractValue('[ {}, 2 ]', 2, ']')).toStrictEqual([ {}, 5 ]) + expect(extractValue('[ 2 ]', 2, ']')).toStrictEqual([ 2, 4 ]) + expect(extractValue('2\n', 0)).toStrictEqual([ 2, 1 ]) + + expect(extractValue('"""uwu"""\n', 0)).toStrictEqual([ 'uwu', 9 ]) + expect(extractValue('"""this is a "multiline string""""\n', 0)).toStrictEqual([ 'this is a "multiline string"', 34 ]) + expect(extractValue('"""this is a "multiline string"""""\n', 0)).toStrictEqual([ 'this is a "multiline string""', 35 ]) + expect(extractValue('"uwu""\n', 0)).toStrictEqual([ 'uwu', 5 ]) + + expect(extractValue('"\\\\"\n', 0)).toStrictEqual([ '\\', 4 ]) + expect(extractValue("'uwu\\'", 0)).toStrictEqual([ 'uwu\\', 6 ]) +}) diff --git a/test/parse.test.ts b/test/parse.test.ts index 71f6266..07713f9 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -26,22 +26,374 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { it, expect } from 'vitest' -import { extractValue } from '../src/parse.js' +import { describe, it, expect } from 'vitest' +import { parse } from '../src/parse.js' import TomlError from '../src/error.js' -it('extracts value of correct type', () => { - expect(extractValue('[ 1, 2 ]', 2, ']')).toStrictEqual([ 1, 4 ]) - expect(extractValue('[ "uwu", 2 ]', 2, ']')).toStrictEqual([ 'uwu', 8 ]) - expect(extractValue('[ {}, 2 ]', 2, ']')).toStrictEqual([ {}, 5 ]) - expect(extractValue('[ 2 ]', 2, ']')).toStrictEqual([ 2, 4 ]) - expect(extractValue('2\n', 0)).toStrictEqual([ 2, 1 ]) +it('parses a simple key-value', () => { + expect(parse('key = "value"')).toStrictEqual({ key: 'value' }) + expect(parse('key = "value"\nother = 1')).toStrictEqual({ key: 'value', other: 1 }) + expect(parse('key = "value"\r\nother = 1')).toStrictEqual({ key: 'value', other: 1 }) +}) + +it('parses dotted key-values', () => { + expect(parse('fruit.apple.color = "red"\nfruit.apple.taste.sweet = true')) + .toStrictEqual({ fruit: { apple: { color: 'red', taste: { sweet: true } } } }) +}) + +it('handles comments', () => { + const doc = ` +# This is a full-line comment +key = "value" # This is a comment at the end of a line +another = "# This is not a comment" +`.trim() + + expect(parse(doc)).toStrictEqual({ key: 'value', another: '# This is not a comment' }) +}) + +it('rejects unspecified values', () => { + expect(() => parse('key = # INVALID')).toThrowError(TomlError) +}) + +it('rejects invalid keys', () => { + expect(() => parse('key."uwu"owo = test')).toThrowError(TomlError) +}) + +it('rejects multiple key-values on a single line', () => { + expect(() => parse('first = "Tom" last = "Preston-Werner" # INVALID')).toThrowError(TomlError) +}) + +it('rejects invalid strings', () => { + expect(() => parse('first = "To\nm"')).toThrowError(TomlError) +}) + +it('parses docs with tables', () => { + const doc = ` +[table-1] +key1 = "some string" +key2 = 123 + +[table-2] +key1 = "another string" +key2 = 456 +`.trim() + + expect(parse(doc)).toStrictEqual({ + 'table-1': { key1: 'some string', key2: 123 }, + 'table-2': { key1: 'another string', key2: 456 }, + }) +}) + +it('rejects unfinished tables', () => { + expect(() => parse('[test\nuwu = test')).toThrowError(TomlError) +}) + +it('rejects invalid tables', () => { + expect(() => parse('[key."uwu"owo]')).toThrowError(TomlError) +}) + +it('parses docs with dotted table and dotted keys', () => { + const doc = ` +[dog."tater.man"] +type.name = "pug" +`.trim() + + expect(parse(doc)).toStrictEqual({ dog: { 'tater.man': { type: { name: 'pug' } } } }) +}) + +it('ignores spaces in keys', () => { + const doc = ` +[a.b.c] # this is best practice +uwu = "owo" + +[ d.e.f ] # same as [d.e.f] +uwu = "owo" + +[ g . h . i ] # same as [g.h.i] +uwu = "owo" + + [ j . "ʞ" . 'l' ] # same as [j."ʞ".'l'] + uwu = "owo" +`.trim() + + expect(parse(doc)).toStrictEqual({ + a: { b: { c: { uwu: 'owo' } } }, + d: { e: { f: { uwu: 'owo' } } }, + g: { h: { i: { uwu: 'owo' } } }, + j: { 'ʞ': { l: { uwu: 'owo' } } }, + }) +}) + +it('handles empty tables', () => { + expect(parse('[uwu]\n')).toStrictEqual({ uwu: {} }) +}) + +it('lets super table be defined afterwards', () => { + const doc = ` +[x.y.z.w] +a = 0 + +[x] +b = 0 +`.trim() + + expect(parse(doc)).toStrictEqual({ + x: { b: 0, y: { z: { w: { a: 0 } } } } + }) +}) + +it('allows adding sub-tables', () => { + const doc = `[fruit] +apple.color = "red" +apple.taste.sweet = true + +[fruit.apple.texture] # you can add sub-tables +smooth = true +`.trim() + + expect(parse(doc)).toStrictEqual({ + fruit: { apple: { color: 'red', taste: { sweet: true }, texture: { smooth: true } } } + }) +}) + +it('rejects tables overriding a defined value', () => { + const doc = ` +[fruit] +apple = "red" + +[fruit.apple] +texture = "smooth" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) +}) + +it('parses arrays of tables', () => { + const doc = ` +[[products]] +name = "Hammer" +sku = 738594937 + +[[products]] # empty table within the array + +[[products]] +name = "Nail" +sku = 284758393 + +color = "gray" +`.trim() + + expect(parse(doc)).toStrictEqual({ + products: [ + { name: 'Hammer', sku: 738594937 }, + {}, + { name: 'Nail', sku: 284758393, color: 'gray' }, + ] + }) +}) + +it('rejects invalid arrays of table', () => { + expect(() => parse('[[uwu] ]')).toThrowError(TomlError) +}) + +it('parses arrays of tables with subtables', () => { + const doc = ` +[[fruits]] +name = "apple" + +[fruits.physical] # subtable +color = "red" +shape = "round" + +[fruits.physical.cute] # subtable +uwu = true + +[[fruits.varieties]] # nested array of tables +name = "red delicious" + +[[fruits.varieties]] +name = "granny smith" + + +[[fruits]] +name = "banana" + +[[fruits.varieties]] +name = "plantain" +`.trim() + + expect(parse(doc)).toStrictEqual({ + fruits: [ + { + name: 'apple', + physical: { + color: 'red', + shape: 'round', + cute: { uwu: true }, + }, + varieties: [ + { name: 'red delicious' }, + { name: 'granny smith' }, + ] + }, + { + name: 'banana', + varieties: [ + { name: 'plantain' }, + ], + }, + ], + }) +}) + +it('rejects subtables of an array of tables if order is reversed', () => { + const doc = ` +[fruit.physical] +color = "red" +shape = "round" + +[[fruit]] +name = "apple" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) +}) + +it('does not allow redefining a statically defined array', () => { + const doc = ` +fruits = [] + +[[fruits]] +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) +}) + +it('rejects conflicts between arrays of tables and normal tables (array then simple)', () => { + const doc = ` +[[fruits]] +name = "apple" + +[[fruits.varieties]] +name = "red delicious" + +[fruits.varieties] +name = "granny smith" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) +}) + +it('rejects conflicts between arrays of tables and normal tables (simple then array)', () => { + const doc = ` +[[fruits]] +name = "apple" + +[fruits.physical] +color = "red" +shape = "round" + +[[fruits.physical]] +color = "green" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) +}) + +describe('table clashes', () => { + it('does not allow redefining a table', () => { + const doc = ` +[fruit] +apple = "red" + +[fruit] +orange = "orange" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('does not allow dotted keys to redefine tables', () => { + const doc = ` +[a.b.c] + z = 9 +[a] + b.c.t = 9 +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('does not allow redefining tables with [table]', () => { + const doc = ` +[fruit] +apple.color = "red" + +[fruit.apple] +kind = "granny smith" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('does not allow clashes between [[table]] and [table]', () => { + const doc = ` +[[uwu]] +[uwu] +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('does not allow clashes between [table] and [[table]]', () => { + const doc = ` +[uwu] +[[uwu]] +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('rejects tables overriding a defined value (inline table)', () => { + const doc = ` +[fruit] +apple = { uwu = "owo" } + +[fruit.apple] +texture = "smooth" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('rejects tables overriding a defined value (inline table inner)', () => { + const doc = ` +[fruit] +apple = { uwu = "owo" } + +[fruit.apple.hehe] +texture = "smooth" +`.trim() + + expect(() => parse(doc)).toThrowError(TomlError) + }) + + it('does NOT reject duplicate [tables] for arrays of tables', () => { + const doc = ` +[[uwu]] +[uwu.owo] +hehe = true - expect(extractValue('"""uwu"""\n', 0)).toStrictEqual([ 'uwu', 9 ]) - expect(extractValue('"""this is a "multiline string""""\n', 0)).toStrictEqual([ 'this is a "multiline string"', 34 ]) - expect(extractValue('"""this is a "multiline string"""""\n', 0)).toStrictEqual([ 'this is a "multiline string""', 35 ]) - expect(extractValue('"uwu""\n', 0)).toStrictEqual([ 'uwu', 5 ]) +[[uwu]] +[uwu.owo] +hehe = true +`.trim() - expect(extractValue('"\\\\"\n', 0)).toStrictEqual([ '\\', 4 ]) - expect(extractValue("'uwu\\'", 0)).toStrictEqual([ 'uwu\\', 6 ]) + expect(parse(doc)).toStrictEqual({ + uwu: [ + { owo: { hehe: true } }, + { owo: { hehe: true } }, + ] + }) + }) }) diff --git a/test/stringify.test.ts b/test/stringify.test.ts new file mode 100644 index 0000000..a8785df --- /dev/null +++ b/test/stringify.test.ts @@ -0,0 +1,209 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { describe, it, expect } from 'vitest' +import { stringify } from '../src/stringify.js' +import TomlDate from '../src/date.js' + +it('stringifies a basic object', () => { + const expected = ` +a = 1 +b = "test" +c = false +d = 1.1 +`.trim() + + expect(stringify({ a: 1, b: 'test', c: false, d: 1.1 }).trim()).toBe(expected) +}) + +it('stringifies bigints as integers', () => { + const expected = ` +a = 100 +`.trim() + + expect(stringify({ a: 100n }).trim()).toBe(expected) +}) + +it('stringifies special float values', () => { + const expected = ` +inf = inf +ninf = -inf +nan = nan +`.trim() + + const obj = { + inf: Infinity, + ninf: -Infinity, + nan: NaN, + } + + expect(stringify(obj).trim()).toBe(expected) +}) + +it('stringifies dates properly', () => { + const expected = ` +date1 = 1979-05-27T07:32:00.000-08:00 +date2 = 1979-05-27T07:32:00.000 +date3 = 1979-05-27 +date4 = 07:32:00.000 +date5 = 1979-05-27T15:32:00.000Z +`.trim() + + const obj = { + date1: new TomlDate('1979-05-27T07:32:00-08:00'), + date2: new TomlDate('1979-05-27T07:32:00'), + date3: new TomlDate('1979-05-27'), + date4: new TomlDate('07:32:00'), + date5: new Date('1979-05-27T07:32:00-08:00'), + } + + expect(stringify(obj).trim()).toBe(expected) +}) + +it('stringifies arrays', () => { + const expected = ` +a = [ 10, 20, "30", false ] +`.trim() + + expect(stringify({ a: [ 10, 20n, '30', false ] }).trim()).toBe(expected) +}) + +it('stringifies tables', () => { + const expected = ` +[a] +b = 1 +c = 2 +`.trim() + + expect(stringify({ a: { b: 1, c: 2 } }).trim()).toBe(expected) +}) + +it('stringifies tables and handles top-level keys', () => { + const expected = ` +d = 3 + +[a] +b = 1 +c = 2 +`.trim() + + expect(stringify({ a: { b: 1, c: 2 }, d: 3 }).trim()).toBe(expected) +}) + +it('stringifies tables contained in arrays', () => { + const expected = ` +a = [ 1, { b = 2, c = 3 }, 4 ] +`.trim() + + expect(stringify({ a: [ 1, { b: 2, c: 3 }, 4 ] }).trim()).toBe(expected) +}) + +it('stringifies arrays of tables', () => { + const expected = ` +[[a]] +b = 1 +c = 2 + +[[a]] +b = 3 +c = 4 +`.trim() + + expect(stringify({ a: [ { b: 1, c: 2 }, { b: 3, c: 4 } ] }).trim()).toBe(expected) +}) + +it('does not produce invalid keys', () => { + const expected = ` +test-key123_ = 1 +"test key 123" = 2 +"testkey@" = 3 +`.trim() + + expect(stringify({ 'test-key123_': 1, 'test key 123': 2, 'testkey@': 3 }).trim()).toBe(expected) +}) + +it('does not produce invalid keys (table keys)', () => { + const expected = ` +[test-key123_] +a = 1 + +["test key 123"] +a = 2 + +["testkey@"] +a = 3 +`.trim() + + expect(stringify({ 'test-key123_': { a: 1 }, 'test key 123': { a: 2 }, 'testkey@': { a: 3 } }).trim()).toBe(expected) +}) + +it('does not produce invalid strings', () => { + const testObj = { + str1: 'test\n', + str2: 'test\x00', + str3: 'test"', + str4: 'test\\', + str5: 'test\x7f', + } + + const stringified = stringify(testObj) + expect(stringified).not.toContain('\n"') + expect(stringified).not.toContain('\x00') + expect(stringified).toContain('\\"') + expect(stringified).toContain('\\\\') + expect(stringified).not.toContain('\x7f') +}) + +it('rejects invalid inputs', () => { + expect(() => stringify('test')).toThrow(TypeError) +}) + +it('ignores null and undefined on objects', () => { + const testObj = { + a: null, + b: void 0, + c: 1 + } + + expect(stringify(testObj).trim()).toBe('c = 1') +}) + + +it('rejects null and undefined in arrays', () => { + expect(() => stringify({ a: [ 1, null, 2 ]})).toThrow(TypeError) + expect(() => stringify({ a: [ 1, void 0, 2 ]})).toThrow(TypeError) +}) + +it('rejects functions and symbols', () => { + expect(() => stringify({ a: () => void 0 })).toThrow(TypeError) + expect(() => stringify({ a: Symbol() })).toThrow(TypeError) +}) + +it('rejects invalid dates', () => { + expect(() => stringify({ a: new Date('Invalid Date') })).toThrow(TypeError) +}) diff --git a/test/toml.test.ts b/test/toml.test.ts deleted file mode 100644 index e8a94a8..0000000 --- a/test/toml.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/*! - * Copyright (c) Squirrel Chat et al., All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * 3. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -import { describe, it, expect } from 'vitest' -import { parse } from '../src/index.js' -import TomlError from '../src/error.js' - -it('parses a simple key-value', () => { - expect(parse('key = "value"')).toStrictEqual({ key: 'value' }) - expect(parse('key = "value"\nother = 1')).toStrictEqual({ key: 'value', other: 1 }) - expect(parse('key = "value"\r\nother = 1')).toStrictEqual({ key: 'value', other: 1 }) -}) - -it('parses dotted key-values', () => { - expect(parse('fruit.apple.color = "red"\nfruit.apple.taste.sweet = true')) - .toStrictEqual({ fruit: { apple: { color: 'red', taste: { sweet: true } } } }) -}) - -it('handles comments', () => { - const doc = ` -# This is a full-line comment -key = "value" # This is a comment at the end of a line -another = "# This is not a comment" -`.trim() - - expect(parse(doc)).toStrictEqual({ key: 'value', another: '# This is not a comment' }) -}) - -it('rejects unspecified values', () => { - expect(() => parse('key = # INVALID')).toThrowError(TomlError) -}) - -it('rejects invalid keys', () => { - expect(() => parse('key."uwu"owo = test')).toThrowError(TomlError) -}) - -it('rejects multiple key-values on a single line', () => { - expect(() => parse('first = "Tom" last = "Preston-Werner" # INVALID')).toThrowError(TomlError) -}) - -it('rejects invalid strings', () => { - expect(() => parse('first = "To\nm"')).toThrowError(TomlError) -}) - -it('parses docs with tables', () => { - const doc = ` -[table-1] -key1 = "some string" -key2 = 123 - -[table-2] -key1 = "another string" -key2 = 456 -`.trim() - - expect(parse(doc)).toStrictEqual({ - 'table-1': { key1: 'some string', key2: 123 }, - 'table-2': { key1: 'another string', key2: 456 }, - }) -}) - -it('rejects unfinished tables', () => { - expect(() => parse('[test\nuwu = test')).toThrowError(TomlError) -}) - -it('rejects invalid tables', () => { - expect(() => parse('[key."uwu"owo]')).toThrowError(TomlError) -}) - -it('parses docs with dotted table and dotted keys', () => { - const doc = ` -[dog."tater.man"] -type.name = "pug" -`.trim() - - expect(parse(doc)).toStrictEqual({ dog: { 'tater.man': { type: { name: 'pug' } } } }) -}) - -it('ignores spaces in keys', () => { - const doc = ` -[a.b.c] # this is best practice -uwu = "owo" - -[ d.e.f ] # same as [d.e.f] -uwu = "owo" - -[ g . h . i ] # same as [g.h.i] -uwu = "owo" - - [ j . "ʞ" . 'l' ] # same as [j."ʞ".'l'] - uwu = "owo" -`.trim() - - expect(parse(doc)).toStrictEqual({ - a: { b: { c: { uwu: 'owo' } } }, - d: { e: { f: { uwu: 'owo' } } }, - g: { h: { i: { uwu: 'owo' } } }, - j: { 'ʞ': { l: { uwu: 'owo' } } }, - }) -}) - -it('handles empty tables', () => { - expect(parse('[uwu]\n')).toStrictEqual({ uwu: {} }) -}) - -it('lets super table be defined afterwards', () => { - const doc = ` -[x.y.z.w] -a = 0 - -[x] -b = 0 -`.trim() - - expect(parse(doc)).toStrictEqual({ - x: { b: 0, y: { z: { w: { a: 0 } } } } - }) -}) - -it('allows adding sub-tables', () => { - const doc = `[fruit] -apple.color = "red" -apple.taste.sweet = true - -[fruit.apple.texture] # you can add sub-tables -smooth = true -`.trim() - - expect(parse(doc)).toStrictEqual({ - fruit: { apple: { color: 'red', taste: { sweet: true }, texture: { smooth: true } } } - }) -}) - -it('rejects tables overriding a defined value', () => { - const doc = ` -[fruit] -apple = "red" - -[fruit.apple] -texture = "smooth" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) -}) - -it('parses arrays of tables', () => { - const doc = ` -[[products]] -name = "Hammer" -sku = 738594937 - -[[products]] # empty table within the array - -[[products]] -name = "Nail" -sku = 284758393 - -color = "gray" -`.trim() - - expect(parse(doc)).toStrictEqual({ - products: [ - { name: 'Hammer', sku: 738594937 }, - {}, - { name: 'Nail', sku: 284758393, color: 'gray' }, - ] - }) -}) - -it('rejects invalid arrays of table', () => { - expect(() => parse('[[uwu] ]')).toThrowError(TomlError) -}) - -it('parses arrays of tables with subtables', () => { - const doc = ` -[[fruits]] -name = "apple" - -[fruits.physical] # subtable -color = "red" -shape = "round" - -[fruits.physical.cute] # subtable -uwu = true - -[[fruits.varieties]] # nested array of tables -name = "red delicious" - -[[fruits.varieties]] -name = "granny smith" - - -[[fruits]] -name = "banana" - -[[fruits.varieties]] -name = "plantain" -`.trim() - - expect(parse(doc)).toStrictEqual({ - fruits: [ - { - name: 'apple', - physical: { - color: 'red', - shape: 'round', - cute: { uwu: true }, - }, - varieties: [ - { name: 'red delicious' }, - { name: 'granny smith' }, - ] - }, - { - name: 'banana', - varieties: [ - { name: 'plantain' }, - ], - }, - ], - }) -}) - -it('rejects subtables of an array of tables if order is reversed', () => { - const doc = ` -[fruit.physical] -color = "red" -shape = "round" - -[[fruit]] -name = "apple" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) -}) - -it('does not allow redefining a statically defined array', () => { - const doc = ` -fruits = [] - -[[fruits]] -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) -}) - -it('rejects conflicts between arrays of tables and normal tables (array then simple)', () => { - const doc = ` -[[fruits]] -name = "apple" - -[[fruits.varieties]] -name = "red delicious" - -[fruits.varieties] -name = "granny smith" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) -}) - -it('rejects conflicts between arrays of tables and normal tables (simple then array)', () => { - const doc = ` -[[fruits]] -name = "apple" - -[fruits.physical] -color = "red" -shape = "round" - -[[fruits.physical]] -color = "green" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) -}) - -describe('table clashes', () => { - it('does not allow redefining a table', () => { - const doc = ` -[fruit] -apple = "red" - -[fruit] -orange = "orange" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('does not allow dotted keys to redefine tables', () => { - const doc = ` -[a.b.c] - z = 9 -[a] - b.c.t = 9 -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('does not allow redefining tables with [table]', () => { - const doc = ` -[fruit] -apple.color = "red" - -[fruit.apple] -kind = "granny smith" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('does not allow clashes between [[table]] and [table]', () => { - const doc = ` -[[uwu]] -[uwu] -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('does not allow clashes between [table] and [[table]]', () => { - const doc = ` -[uwu] -[[uwu]] -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('rejects tables overriding a defined value (inline table)', () => { - const doc = ` -[fruit] -apple = { uwu = "owo" } - -[fruit.apple] -texture = "smooth" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('rejects tables overriding a defined value (inline table inner)', () => { - const doc = ` -[fruit] -apple = { uwu = "owo" } - -[fruit.apple.hehe] -texture = "smooth" -`.trim() - - expect(() => parse(doc)).toThrowError(TomlError) - }) - - it('does NOT reject duplicate [tables] for arrays of tables', () => { - const doc = ` -[[uwu]] -[uwu.owo] -hehe = true - -[[uwu]] -[uwu.owo] -hehe = true -`.trim() - - expect(parse(doc)).toStrictEqual({ - uwu: [ - { owo: { hehe: true } }, - { owo: { hehe: true } }, - ] - }) - }) -}) diff --git a/toml-test-encode.js b/toml-test-encode.js new file mode 100644 index 0000000..21e78d2 --- /dev/null +++ b/toml-test-encode.js @@ -0,0 +1,76 @@ +/*! + * Copyright (c) Squirrel Chat et al., All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +// Script for https://github.com/BurntSushi/toml-test + +import { TomlDate, stringify } from './dist/index.js' + +function untagObject (obj) { + if (Array.isArray(obj)) return obj.map((o) => untagObject(o)) + + const res = {} + if (Object.keys(obj).length === 2 && 'type' in obj && 'value' in obj) { + switch (obj.type) { + case 'string': + return obj.value + case 'bool': + return obj.value === 'true' + case 'integer': + return BigInt(obj.value) + case 'float': + if (obj.value === 'nan') return NaN + if (obj.value === '+nan') return NaN + if (obj.value === '-nan') return NaN + if (obj.value === 'inf') return Infinity + if (obj.value === '+inf') return Infinity + if (obj.value === '-inf') return -Infinity + return Number(obj.value) + case 'datetime': + case 'datetime-local': + case 'date-local': + case 'time-local': + return new TomlDate(obj.value) + } + + throw new Error('cannot untag object') + } + + for (const k in obj) { + res[k] = untagObject(obj[k]) + } + return res +} + +let json = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (j) => json += j) +process.stdin.on('end', () => { + const tagged = JSON.parse(json) + const obj = untagObject(tagged) + console.log(stringify(obj)) +})