diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86381d6..b010ef2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..37c650d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test/JSONTestSuite"] + path = test/JSONTestSuite + url = https://github.com/nst/JSONTestSuite.git diff --git a/README.md b/README.md index eb8085f..bc74a6d 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,26 @@ optionally enabled. | ---------------------------------------------------- | --------------------------------------------------------------------------------------- | -## Features +The documentation below is organized along the +[Diátaxis](https://diataxis.fr) quadrants: -- Single-line comments: `// comment` -- Block comments: `/* comment */` -- Optional trailing commas in objects and arrays -- Strict JSON value parsing (no unquoted strings or hex numbers) -- Available in both TypeScript/JavaScript and Go +- [Quick start](#quick-start) — tutorial +- [How-to guides](#how-to-guides) — task recipes +- [Reference](#reference) — API surface +- [JSONC format](#jsonc-format) — explanation -## TypeScript +## Quick start -### Install +### TypeScript + +Install: ```bash npm install @jsonic/jsonc @jsonic/jsonic-next ``` -### Quick Start +Parse: ```typescript import { Jsonic } from '@jsonic/jsonic-next' @@ -43,100 +45,198 @@ import { Jsonc } from '@jsonic/jsonc' const j = Jsonic.make().use(Jsonc) -// Parse JSONC with comments const result = j('{ "name": "app", /* version */ "version": "1.0" }') -// => { name: "app", version: "1.0" } - -// Enable trailing commas -const jc = Jsonic.make().use(Jsonc, { allowTrailingComma: true }) -const config = jc('{ "debug": true, "verbose": false, }') -// => { debug: true, verbose: false } +// => { name: 'app', version: '1.0' } ``` -### Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `allowTrailingComma` | `boolean` | `false` | Allow trailing commas in objects and arrays | -| `disallowComments` | `boolean` | `false` | Disable comment parsing (strict JSON mode) | +### Go - -## Go - -### Install +Install: ```bash go get github.com/jsonicjs/jsonc/go ``` -### Quick Start +Parse: ```go package main import ( "fmt" + jsonic "github.com/jsonicjs/jsonic/go" jsonc "github.com/jsonicjs/jsonc/go" ) func main() { - // Parse JSONC with comments - result, err := jsonc.Parse(`{ "name": "app", /* version */ "version": "1.0" }`) + j := jsonic.Make() + j.Use(jsonc.Jsonc) + + result, err := j.Parse(`{ "name": "app", /* version */ "version": "1.0" }`) if err != nil { panic(err) } fmt.Println(result) // => map[name:app version:1.0] +} +``` - // Enable trailing commas - result, err = jsonc.Parse( - `{ "debug": true, "verbose": false, }`, - jsonc.JsoncOptions{AllowTrailingComma: boolPtr(true)}, - ) - fmt.Println(result) - // => map[debug:true verbose:false] + +## How-to guides + +### Allow trailing commas + +TypeScript: + +```typescript +const j = Jsonic.make().use(Jsonc, { allowTrailingComma: true }) +j('{ "debug": true, "verbose": false, }') +// => { debug: true, verbose: false } +``` + +Go: + +```go +j := jsonic.Make() +j.Use(jsonc.Jsonc, map[string]any{"allowTrailingComma": true}) +result, _ := j.Parse(`{ "debug": true, "verbose": false, }`) +``` + +### Parse strict JSON (disable comments) + +TypeScript: + +```typescript +const j = Jsonic.make().use(Jsonc, { disallowComments: true }) +j('{ "foo": /* not allowed */ true }') // throws +``` + +Go: + +```go +j := jsonic.Make() +j.Use(jsonc.Jsonc, map[string]any{"disallowComments": true}) +``` + +### Handle parse errors + +TypeScript — parse errors throw: + +```typescript +try { + j('{ "bad": }') +} catch (err) { + console.error(err.message) +} +``` + +Go — errors are returned: + +```go +if _, err := j.Parse(`{ "bad": }`); err != nil { + fmt.Println(err) } +``` -func boolPtr(b bool) *bool { return &b } +### Parse a file + +TypeScript: + +```typescript +import { readFileSync } from 'node:fs' +const j = Jsonic.make().use(Jsonc, { allowTrailingComma: true }) +const config = j(readFileSync('tsconfig.json', 'utf8')) +``` + +Go: + +```go +src, _ := os.ReadFile("tsconfig.json") +j := jsonic.Make() +j.Use(jsonc.Jsonc, map[string]any{"allowTrailingComma": true}) +config, _ := j.Parse(string(src)) ``` -### API -#### `Parse(src string, opts ...JsoncOptions) (any, error)` +## Reference + +### TypeScript -Parse a JSONC string and return the result. Returns `map[string]any` for -objects, `[]any` for arrays, `float64` for numbers, `string`, `bool`, -or `nil`. +```typescript +function Jsonc(jsonic: Jsonic, options?: JsoncOptions): void + +type JsoncOptions = { + allowTrailingComma?: boolean // default: false + disallowComments?: boolean // default: false +} +``` -#### `MakeJsonic(opts ...JsoncOptions) *jsonic.Jsonic` +Register with `jsonic.use(Jsonc, options?)`. After registration, invoke +the jsonic instance as a function on a source string; it returns the +parsed value or throws on syntax errors. -Create a configured jsonic instance for JSONC parsing. Use this when you -need to parse multiple inputs with the same configuration. +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `allowTrailingComma` | `boolean` | `false` | Permit a trailing comma before `}` and `]` | +| `disallowComments` | `boolean` | `false` | Reject `//` and `/* */` comments (strict JSON) | + +### Go + +```go +func Jsonc(j *jsonic.Jsonic, pluginOpts map[string]any) error +``` -#### `JsoncOptions` +Register with `j.Use(jsonc.Jsonc)` or `j.Use(jsonc.Jsonc, opts)` where +`opts` is a `map[string]any`. `Parse` then returns `(any, error)` — +`map[string]any` for objects, `[]any` for arrays, `float64` for numbers, +`string`, `bool`, or `nil`. -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `AllowTrailingComma` | `*bool` | `false` | Allow trailing commas in objects and arrays | -| `DisallowComments` | `*bool` | `false` | Disable comment parsing | +| Key | Type | Default | Effect | +|-----|------|---------|--------| +| `allowTrailingComma` | `bool` | `false` | Permit a trailing comma before `}` and `]` | +| `disallowComments` | `bool` | `false` | Reject `//` and `/* */` comments (strict JSON) | -## JSONC Format +## JSONC format -JSONC follows [RFC 8259](https://tools.ietf.org/html/rfc8259) (JSON) with -these extensions: +JSONC follows [RFC 8259](https://tools.ietf.org/html/rfc8259) (JSON) +with these extensions: - **Line comments**: `//` to end of line - **Block comments**: `/* */` (non-nesting) - **Trailing commas**: optional, in objects and arrays -All other rules follow standard JSON: +All other JSON rules apply: + - Strings must be double-quoted -- Only standard escape sequences: `\"` `\\` `\/` `\b` `\f` `\n` `\r` `\t` `\uXXXX` -- Numbers: integer, decimal, scientific notation (no hex, octal, or binary) +- Standard escapes only: `\"` `\\` `\/` `\b` `\f` `\n` `\r` `\t` `\uXXXX` +- Numbers: integer, decimal, scientific notation (no hex, octal, binary) - Keywords: `true`, `false`, `null` (case-sensitive) - Property names must be double-quoted strings +### Conformance notes + +The plugin layers JSONC rules on top of jsonic, which is intentionally +lenient in some places vs. strict RFC 8259. The test suite runs the +[nst/JSONTestSuite](https://github.com/nst/JSONTestSuite) corpus in +strict mode (`disallowComments: true`) and pins the known-lenient +cases in `test/jsontestsuite.test.ts` (see `N_KNOWN_LENIENT`). Examples +of accepted-but-non-RFC input include numbers with leading zeros and +unquoted object keys. Use an RFC-strict parser if byte-perfect RFC 8259 +rejection is required. + + +## Acknowledgments + +Conformance testing uses third-party corpora under MIT License: + +- [nst/JSONTestSuite](https://github.com/nst/JSONTestSuite) by Nicolas + Seriot — vendored as a git submodule at `test/JSONTestSuite/`. +- [microsoft/node-jsonc-parser](https://github.com/microsoft/node-jsonc-parser) — + parse-level test cases ported into `test/jsonc.test.ts`. + +See [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) for details. + ## License diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..1963441 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,49 @@ +# Third-Party Notices + +This project incorporates material from the projects listed below. The +original copyright notices and license texts are preserved as required. + +## nst/JSONTestSuite + +Used as a git submodule at `test/JSONTestSuite/` for RFC 8259 conformance +testing via `test/jsontestsuite.test.ts`. The submodule's `LICENSE` file +is preserved in place. + +- Project: https://github.com/nst/JSONTestSuite +- License: MIT +- Copyright (c) 2016 Nicolas Seriot + +## microsoft/node-jsonc-parser + +Parse-level test cases in `test/jsonc.test.ts` were ported from +`src/test/json.test.ts` of `microsoft/node-jsonc-parser`. + +- Project: https://github.com/microsoft/node-jsonc-parser +- License: MIT +- Copyright (c) Microsoft Corporation + +--- + +Both projects are distributed under the MIT License. Full license text: + +``` +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/go/go.mod b/go/go.mod index fda0d02..b979f0c 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,6 +2,4 @@ module github.com/jsonicjs/jsonc/go go 1.24.7 -require github.com/jsonicjs/jsonic/go v0.1.18 - -replace github.com/jsonicjs/jsonic/go => ../../jsonic/go +require github.com/jsonicjs/jsonic/go v0.1.19 diff --git a/go/go.sum b/go/go.sum index 7142a59..4f66b30 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,2 +1,2 @@ -github.com/jsonicjs/jsonic/go v0.1.18 h1:OW15hjFisrw2n7HE6zDuQAikW8A5NUW8OyP4SCG2oFg= -github.com/jsonicjs/jsonic/go v0.1.18/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= +github.com/jsonicjs/jsonic/go v0.1.19 h1:jEP+GSxMGKV+eTJEjuU0qRMUQ8GAIl1SRigc+mbZzVo= +github.com/jsonicjs/jsonic/go v0.1.19/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= diff --git a/package.json b/package.json index d7c2e50..e09ecf7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "jsonic" ], "scripts": { - "test": "node --enable-source-maps --experimental-strip-types --test test/jsonc.test.ts", + "test": "node --enable-source-maps --experimental-strip-types --test test/jsonc.test.ts test/jsontestsuite.test.ts", "build": "node embed-grammar.js && tsc -p src", "watch": "tsc -p src --watch", "embed": "node embed-grammar.js", @@ -27,7 +27,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "jsonic": "file:../jsonic", + "jsonic": "github:jsonicjs/jsonic#main", "typescript": "^5.6.3" }, "files": [ diff --git a/test/JSONTestSuite b/test/JSONTestSuite new file mode 160000 index 0000000..1ef36fa --- /dev/null +++ b/test/JSONTestSuite @@ -0,0 +1 @@ +Subproject commit 1ef36fa01286573e846ac449e8683f8833c5b26a diff --git a/test/jsonc.test.ts b/test/jsonc.test.ts index d704e93..b99ce58 100644 --- a/test/jsonc.test.ts +++ b/test/jsonc.test.ts @@ -1,5 +1,9 @@ /* Copyright (c) 2021-2025 Richard Rodger and other contributors, MIT License */ +// Parse-level test cases in this file were ported from +// microsoft/node-jsonc-parser (src/test/json.test.ts), Copyright (c) +// Microsoft Corporation, MIT License. See THIRD_PARTY_NOTICES.md. + import { test, describe } from 'node:test' import assert from 'node:assert' diff --git a/test/jsontestsuite.test.ts b/test/jsontestsuite.test.ts new file mode 100644 index 0000000..8a8f458 --- /dev/null +++ b/test/jsontestsuite.test.ts @@ -0,0 +1,119 @@ +/* Copyright (c) 2021-2025 Richard Rodger and other contributors, MIT License */ + +// Uses the nst/JSONTestSuite corpus (Copyright (c) 2016 Nicolas Seriot, +// MIT License) vendored as a git submodule at test/JSONTestSuite/. Its +// LICENSE travels with the submodule; see also THIRD_PARTY_NOTICES.md. + +// Runs the nst/JSONTestSuite (RFC 8259) against the jsonc plugin in strict +// mode (disallowComments: true, no trailing commas). Each file in +// test_parsing/ is classified by prefix: +// y_* must parse successfully +// n_* must be rejected (known-lenient cases pinned in N_KNOWN_LENIENT) +// i_* implementation-defined (recorded only) +// +// Known-lenient n_* entries reflect intentional jsonic relaxations vs. strict +// RFC 8259 (e.g. leading zeros, unquoted keys). They are pinned so the set +// is caught if it grows or shrinks. + +import { test, describe } from 'node:test' +import assert from 'node:assert' +import { readdirSync, readFileSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Jsonic } from 'jsonic' +import { Jsonc } from '../dist/jsonc.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const SUITE_DIR = join(__dirname, 'JSONTestSuite', 'test_parsing') + +const j = Jsonic.make().use(Jsonc, { disallowComments: true }) + +const parse = (src: string) => j(src) + +const files = existsSync(SUITE_DIR) + ? readdirSync(SUITE_DIR).filter((f) => f.endsWith('.json')).sort() + : [] + +// Cases jsonic intentionally accepts that RFC 8259 requires rejecting. +const N_KNOWN_LENIENT = new Set([ + 'n_array_comma_after_close.json', + 'n_number_+1.json', + 'n_number_-01.json', + 'n_number_-2..json', + 'n_number_0.e1.json', + 'n_number_2.e+3.json', + 'n_number_2.e-3.json', + 'n_number_2.e3.json', + 'n_number_neg_int_starting_with_zero.json', + 'n_number_neg_real_without_int_part.json', + 'n_number_real_without_fractional_part.json', + 'n_number_with_leading_zero.json', + 'n_object_non_string_key.json', + 'n_object_non_string_key_but_huge_number_instead.json', + 'n_object_repeated_null_null.json', + 'n_object_single_quote.json', + 'n_single_space.json', + 'n_string_escape_x.json', + 'n_string_single_quote.json', + 'n_structure_object_with_trailing_garbage.json', +]) + +describe('JSONTestSuite (RFC 8259)', () => { + if (0 === files.length) { + test('suite unavailable', () => { + console.warn(`JSONTestSuite not found at ${SUITE_DIR} — skipping.`) + }) + return + } + + test('y_* accept', () => { + const fails: { file: string; err: string }[] = [] + for (const f of files.filter((x) => x.startsWith('y_'))) { + const src = readFileSync(join(SUITE_DIR, f), 'utf8') + try { + parse(src) + } catch (e: any) { + fails.push({ file: f, err: e?.code || e?.message || String(e) }) + } + } + assert.deepEqual(fails, [], `y_* files that failed to parse:\n${fails.map((x) => ` ${x.file}: ${x.err}`).join('\n')}`) + }) + + test('n_* reject', () => { + const unexpectedAccept: string[] = [] + const unexpectedReject: string[] = [] + for (const f of files.filter((x) => x.startsWith('n_'))) { + const src = readFileSync(join(SUITE_DIR, f), 'utf8') + let accepted = false + try { + parse(src) + accepted = true + } catch { + // expected + } + const isLenient = N_KNOWN_LENIENT.has(f) + if (accepted && !isLenient) unexpectedAccept.push(f) + if (!accepted && isLenient) unexpectedReject.push(f) + } + assert.deepEqual( + { unexpectedAccept, unexpectedReject }, + { unexpectedAccept: [], unexpectedReject: [] }, + 'n_* divergence from pinned allowlist', + ) + }) + + test('i_* implementation-defined', () => { + const results: { file: string; accepted: boolean }[] = [] + for (const f of files.filter((x) => x.startsWith('i_'))) { + const src = readFileSync(join(SUITE_DIR, f), 'utf8') + try { + parse(src) + results.push({ file: f, accepted: true }) + } catch { + results.push({ file: f, accepted: false }) + } + } + assert.ok(results.length > 0, 'expected at least one i_* file') + }) +})