diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b5881e..ab9e04e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,14 +19,14 @@ jobs: timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: denoland/setup-deno@v2 with: deno-version: canary - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: "22.x" + node-version: "24.x" registry-url: "https://registry.npmjs.org" - name: build @@ -53,8 +53,6 @@ jobs: run: deno run -A jsr:@david/publish-on-tag@0.2.0 - name: npm publish if: startsWith(github.ref, 'refs/tags/') - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | cd npm - npm publish --provenance --access public + npm publish --access public diff --git a/Cargo.lock b/Cargo.lock index b3e4e55..2536db0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "jsonc-parser" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01958dcb05b69d9612853b47df8f7881810e4f61b5cedd8894be04291f28ccb9" +checksum = "9eb0774546269185d38da823d8583e94448ba0e158e3f50759d9c79aba946ca5" dependencies = [ "indexmap", "serde_json", diff --git a/README.md b/README.md index 0c1a037..5da28bc 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,19 @@ npm install jsonc-morph ## Example ```ts -import { parse } from "@david/jsonc-morph"; +import { parse, parseToValue } from "@david/jsonc-morph"; +// parse directly to a plain JavaScript value +const config = parseToValue(`{ + // database settings + "host": "localhost", + "port": 5432 +}`); + +console.log(config.host); // "localhost" +console.log(config.port); // 5432 + +// parse to a CST for programmatic editing const root = parse(`{ // 1 "data" /* 2 */: 123 // 3 @@ -56,3 +67,43 @@ assertEquals( } // 4`, ); ``` + +## Options and strict parsing + +By default, `parse` and `parseToValue` allow more than just JSONC (comments, +trailing commas, single-quoted strings, hexadecimal numbers, etc.). You can +customize this behaviour by passing options: + +```ts +import { parse, parseToValue } from "@david/jsonc-morph"; + +// Disable specific extensions +const root = parse(text, { + allowComments: false, // Reject // and /* */ comments + allowTrailingCommas: false, // Reject trailing commas in arrays/objects + allowSingleQuotedStrings: false, // Reject 'single quoted' strings + allowHexadecimalNumbers: false, // Reject 0xFF style numbers + allowUnaryPlusNumbers: false, // Reject +42 style numbers + allowMissingCommas: false, // Reject missing commas between elements + allowLooseObjectPropertyNames: false, // Reject unquoted property names +}); + +// parseToValue accepts the same options +const value = parseToValue(text, { allowComments: false }); +``` + +For strict JSON parsing (only allow JSON), use `parseStrict` or +`parseToValueStrict`: + +```ts +import { parseStrict, parseToValueStrict } from "@david/jsonc-morph"; + +// All extensions disabled by default +const root = parseStrict('{"name": "test"}'); + +// Selectively enable specific extensions +const rootWithComments = parseStrict(text, { allowComments: true }); + +// Same for parseToValueStrict +const value = parseToValueStrict('{"name": "test"}'); +``` diff --git a/mod.ts b/mod.ts index 1611cd4..ce1b710 100644 --- a/mod.ts +++ b/mod.ts @@ -14,3 +14,69 @@ export { StringLit, WordLit, } from "./lib/rs_lib.js"; + +import { + type JsonValue, + parse, + parseToValue, + type RootNode, +} from "./lib/rs_lib.js"; + +/** Options for strict JSON parsing (all JSONC extensions disabled by default). */ +export interface ParseStrictOptions { + /** Allow comments (defaults to `false` in strict mode). */ + allowComments?: boolean; + /** Allow trailing commas (defaults to `false` in strict mode). */ + allowTrailingCommas?: boolean; + /** Allow loose object property names (defaults to `false` in strict mode). */ + allowLooseObjectPropertyNames?: boolean; + /** Allow missing commas (defaults to `false` in strict mode). */ + allowMissingCommas?: boolean; + /** Allow single-quoted strings (defaults to `false` in strict mode). */ + allowSingleQuotedStrings?: boolean; + /** Allow hexadecimal numbers (defaults to `false` in strict mode). */ + allowHexadecimalNumbers?: boolean; + /** Allow unary plus on numbers (defaults to `false` in strict mode). */ + allowUnaryPlusNumbers?: boolean; +} + +const STRICT_DEFAULTS: Required = { + allowComments: false, + allowTrailingCommas: false, + allowLooseObjectPropertyNames: false, + allowMissingCommas: false, + allowSingleQuotedStrings: false, + allowHexadecimalNumbers: false, + allowUnaryPlusNumbers: false, +}; + +/** + * Parses a strict JSON string into a concrete syntax tree. + * By default, all JSONC extensions are disabled (no comments, no trailing commas, etc.). + * You can selectively enable extensions by setting options to `true`. + * @param text - The JSON text to parse + * @param options - Optional parsing options (all default to `false`) + * @returns The root node of the parsed CST + */ +export function parseStrict( + text: string, + options?: ParseStrictOptions, +): RootNode { + return parse(text, { ...STRICT_DEFAULTS, ...options }); +} + +/** + * Parses a strict JSON string directly to a JavaScript value. + * By default, all JSONC extensions are disabled (no comments, no trailing commas, etc.). + * You can selectively enable extensions by setting options to `true`. + * @param text - The JSON text to parse + * @param options - Optional parsing options (all default to `false`) + * @returns The plain JavaScript value (object, array, string, number, boolean, or null) + * @throws If the text cannot be parsed or converted + */ +export function parseToValueStrict( + text: string, + options?: ParseStrictOptions, +): JsonValue { + return parseToValue(text, { ...STRICT_DEFAULTS, ...options }); +} diff --git a/mod_test.ts b/mod_test.ts index 264a12f..72970c5 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -1,5 +1,5 @@ import { assertEquals, assertExists } from "@std/assert"; -import { parse, parseToValue } from "./mod.ts"; +import { parse, parseStrict, parseToValue, parseToValueStrict } from "./mod.ts"; Deno.test("RootNode - parse simple object", () => { const text = '{"name": "test", "value": 42}'; @@ -1704,3 +1704,288 @@ Deno.test("parseToValue - preserve key order", () => { assertEquals(Object.keys(result as object), ["zebra", "apple", "sauce"]); }); + +// Parse options tests for new properties + +Deno.test("parse - allowMissingCommas option (enabled)", () => { + const text = `{ + "a": 1 + "b": 2 + "c": 3 + }`; + const root = parse(text, { allowMissingCommas: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 3); +}); + +Deno.test("parse - allowMissingCommas option (disabled)", () => { + const text = `{ + "a": 1 + "b": 2 + }`; + try { + parse(text, { allowMissingCommas: false }); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parse - allowSingleQuotedStrings option (enabled)", () => { + const text = "{'name': 'test', 'value': 42}"; + const root = parse(text, { allowSingleQuotedStrings: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 2); +}); + +Deno.test("parse - allowSingleQuotedStrings option (disabled)", () => { + const text = "{'name': 'test'}"; + try { + parse(text, { allowSingleQuotedStrings: false }); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parse - allowHexadecimalNumbers option (enabled)", () => { + const text = '{"hex": 0xFF, "dec": 255}'; + const root = parse(text, { allowHexadecimalNumbers: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 2); +}); + +Deno.test("parse - allowHexadecimalNumbers option (disabled)", () => { + const text = '{"hex": 0xFF}'; + try { + parse(text, { allowHexadecimalNumbers: false }); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parse - allowUnaryPlusNumbers option (enabled)", () => { + const text = '{"positive": +42, "negative": -42}'; + const root = parse(text, { allowUnaryPlusNumbers: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 2); +}); + +Deno.test("parse - allowUnaryPlusNumbers option (disabled)", () => { + const text = '{"positive": +42}'; + try { + parse(text, { allowUnaryPlusNumbers: false }); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseToValue - allowMissingCommas option", () => { + const text = `{ + "a": 1 + "b": 2 + }`; + // deno-lint-ignore no-explicit-any + const result = parseToValue(text, { allowMissingCommas: true }) as any; + assertEquals(result.a, 1); + assertEquals(result.b, 2); +}); + +Deno.test("parseToValue - allowSingleQuotedStrings option", () => { + const text = "{'name': 'John'}"; + // deno-lint-ignore no-explicit-any + const result = parseToValue(text, { allowSingleQuotedStrings: true }) as any; + assertEquals(result.name, "John"); +}); + +Deno.test("parseToValue - allowHexadecimalNumbers option", () => { + const text = '{"value": 0xAB}'; + // deno-lint-ignore no-explicit-any + const result = parseToValue(text, { allowHexadecimalNumbers: true }) as any; + assertEquals(result.value, 171); // 0xAB = 171 +}); + +Deno.test("parseToValue - allowUnaryPlusNumbers option", () => { + const text = '{"value": +100}'; + // deno-lint-ignore no-explicit-any + const result = parseToValue(text, { allowUnaryPlusNumbers: true }) as any; + assertEquals(result.value, 100); +}); + +Deno.test("parse - all new options combined", () => { + const text = `{ + 'name': 'test' + "hex": 0xFF + "plus": +42 + }`; + const root = parse(text, { + allowMissingCommas: true, + allowSingleQuotedStrings: true, + allowHexadecimalNumbers: true, + allowUnaryPlusNumbers: true, + }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 3); +}); + +Deno.test("parse - strict mode (all options disabled)", () => { + // Standard JSON should still work with all options disabled + const text = '{"name": "test", "value": 42}'; + const root = parse(text, { + allowComments: false, + allowTrailingCommas: false, + allowLooseObjectPropertyNames: false, + allowMissingCommas: false, + allowSingleQuotedStrings: false, + allowHexadecimalNumbers: false, + allowUnaryPlusNumbers: false, + }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 2); +}); + +// parseStrict and parseToValueStrict tests + +Deno.test("parseStrict - parses valid JSON", () => { + const text = '{"name": "test", "value": 42}'; + const root = parseStrict(text); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 2); +}); + +Deno.test("parseStrict - rejects comments by default", () => { + const text = `{ + // comment + "name": "test" + }`; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - rejects trailing commas by default", () => { + const text = '{"name": "test",}'; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - rejects single-quoted strings by default", () => { + const text = "{'name': 'test'}"; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - rejects hexadecimal numbers by default", () => { + const text = '{"value": 0xFF}'; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - rejects unary plus by default", () => { + const text = '{"value": +42}'; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - rejects missing commas by default", () => { + const text = `{ + "a": 1 + "b": 2 + }`; + try { + parseStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseStrict - can selectively enable comments", () => { + const text = `{ + // comment + "name": "test" + }`; + const root = parseStrict(text, { allowComments: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 1); +}); + +Deno.test("parseStrict - can selectively enable trailing commas", () => { + const text = '{"name": "test",}'; + const root = parseStrict(text, { allowTrailingCommas: true }); + assertExists(root); + const obj = root.asObjectOrThrow(); + assertEquals(obj.properties().length, 1); +}); + +Deno.test("parseToValueStrict - parses valid JSON", () => { + const text = '{"name": "test", "value": 42}'; + // deno-lint-ignore no-explicit-any + const result = parseToValueStrict(text) as any; + assertEquals(result.name, "test"); + assertEquals(result.value, 42); +}); + +Deno.test("parseToValueStrict - rejects comments by default", () => { + const text = `{ + // comment + "name": "test" + }`; + try { + parseToValueStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseToValueStrict - rejects trailing commas by default", () => { + const text = '{"name": "test",}'; + try { + parseToValueStrict(text); + throw new Error("Should have thrown parse error"); + } catch (error) { + assertExists(error); + } +}); + +Deno.test("parseToValueStrict - can selectively enable extensions", () => { + const text = `{ + // comment + "items": [1, 2, 3,] + }`; + const result = parseToValueStrict(text, { + allowComments: true, + allowTrailingCommas: true, + }) as { items: number[] }; + assertEquals(result.items, [1, 2, 3]); +}); diff --git a/rs_lib/Cargo.toml b/rs_lib/Cargo.toml index 9181330..e04c363 100644 --- a/rs_lib/Cargo.toml +++ b/rs_lib/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -jsonc-parser = { version = "0.27.1", features = ["cst", "serde", "preserve_order", "error_unicode_width"] } +jsonc-parser = { version = "0.29.0", features = ["cst", "serde", "preserve_order", "error_unicode_width"] } wasm-bindgen = "=0.2.102" js-sys = "0.3" serde = "1.0" diff --git a/rs_lib/src/lib.rs b/rs_lib/src/lib.rs index e719777..6edb87d 100644 --- a/rs_lib/src/lib.rs +++ b/rs_lib/src/lib.rs @@ -15,7 +15,7 @@ fn throw_error(msg: &str) -> JsValue { #[wasm_bindgen] extern "C" { #[wasm_bindgen( - typescript_type = "{ allowComments?: boolean; allowTrailingCommas?: boolean; allowLooseObjectPropertyNames?: boolean; }" + typescript_type = "{ allowComments?: boolean; allowTrailingCommas?: boolean; allowLooseObjectPropertyNames?: boolean; allowMissingCommas?: boolean; allowSingleQuotedStrings?: boolean; allowHexadecimalNumbers?: boolean; allowUnaryPlusNumbers?: boolean; }" )] pub type JsoncParseOptionsObject; @@ -100,10 +100,38 @@ fn parse_options_from_js(obj: &JsValue) -> ParseOptions { .and_then(|v| v.as_bool()) .unwrap_or(defaults.allow_loose_object_property_names); + let allow_missing_commas = + js_sys::Reflect::get(obj, &"allowMissingCommas".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.allow_missing_commas); + + let allow_single_quoted_strings = + js_sys::Reflect::get(obj, &"allowSingleQuotedStrings".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.allow_single_quoted_strings); + + let allow_hexadecimal_numbers = + js_sys::Reflect::get(obj, &"allowHexadecimalNumbers".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.allow_hexadecimal_numbers); + + let allow_unary_plus_numbers = + js_sys::Reflect::get(obj, &"allowUnaryPlusNumbers".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.allow_unary_plus_numbers); + ParseOptions { allow_comments, allow_trailing_commas, allow_loose_object_property_names, + allow_missing_commas, + allow_single_quoted_strings, + allow_hexadecimal_numbers, + allow_unary_plus_numbers, } }