diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 71643dd..8a1cd48 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,9 @@ "name": "JSONText Dev Container", "image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie", "features": { - "ghcr.io/devcontainers-extra/features/deno:1": {} + "ghcr.io/devcontainers-extra/features/deno:1": { + "version": "2.9.5" + } }, "customizations": { "vscode": { @@ -15,8 +17,10 @@ "editor.codeActionsOnSave": { "source.organizeImports": "always" }, - "deno.enablePaths": ["./tests/"], - "deno.config": "./deno.json" + "json.schemaDownload.trustedDomains": { + "https://deno.land/x/deno/cli/schemas/lint-rules.v1.json": true, + "https://deno.land/x/deno/cli/schemas/lint-tags.v1.json": true + } } } } diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index c5db448..cff4e4b 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -1,12 +1,21 @@ -name: Bump +name: Bump Version on: workflow_dispatch: inputs: - version: - description: "new version(e.g., 1.2.3)" + increment: + description: "version increment" required: true - type: string + default: patch + type: choice + options: + - patch + - minor + - major + - prepatch + - preminor + - premajor + - prerelease permissions: contents: write @@ -19,24 +28,19 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Set up Deno + uses: denoland/setup-deno@v2 with: - node-version: lts/* - cache: npm - registry-url: https://registry.npmjs.org/ + deno-version: v2.9.5 - - name: Store Version - run: | - TARGET_VERSION="${{ github.event.inputs.version }}" - TARGET_VERSION="${TARGET_VERSION#v}" - echo "VERSION=$TARGET_VERSION" >> $GITHUB_ENV - - - name: Update Package Version + - name: Bump Package Versions + env: + INCREMENT: ${{ inputs.increment }} run: | - jq ".version = \"$VERSION\"" package.json > tmp.json && mv tmp.json package.json - jq ".version = \"$VERSION\"" deno.json > tmp.json && mv tmp.json deno.json - npm install --package-lock-only --ignore-scripts + deno bump-version "$INCREMENT" --config deno.json + deno bump-version "$INCREMENT" --config package.json + VERSION="$(deno bump-version --config deno.json)" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" - name: Setup Git run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ad695b..a1b99a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish +name: Publish Package on: release: @@ -14,29 +14,25 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v6 - name: Set up Node.js uses: actions/setup-node@v6 with: node-version: lts/* - cache: npm registry-url: https://registry.npmjs.org/ - - name: Update npm to latest - run: npm install -g npm@latest - - - name: Install dependencies - run: | - rm -rf node_modules package-lock.json - npm install + - name: Set up Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.9.5 - - name: Build - run: npm run build -- --mode lib + - name: Build Package + run: deno task build - name: Publish to npm run: npm publish --access public --provenance - name: Publish to JSR - run: npx jsr publish --allow-dirty + run: deno publish diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 23bed05..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,4 +0,0 @@ -# Changelog - -All notable changes are documented in -[GitHub Releases](https://github.com/lcweden/jsontext/releases). diff --git a/README.md b/README.md index 44d72d3..d5f6538 100644 --- a/README.md +++ b/README.md @@ -4,508 +4,37 @@ [![npm version](https://img.shields.io/npm/v/jsontext.svg)](https://www.npmjs.com/package/jsontext) [![jsr version](https://img.shields.io/jsr/v/@lcweden/jsontext)](https://jsr.io/@lcweden/jsontext) -A state machine for incremental JSON processing. - -## Quick Start - -The following example demonstrates how to use `JSONTextSelectorStream` to extract all `address` from -a JSON fetched from [DummyJSON](https://dummyjson.com/). - -```javascript -import { JSONTextSelectorStream } from "jsontext"; - -const response = await fetch("https://dummyjson.com/users"); -const addresses = response.body.pipeThrough(new JSONTextSelectorStream("$.users[*].address")); - -for await (const value of addresses) { - console.log(value.json()); -} -``` +JSONText is a low-level, incremental UTF-8 JSON decoder and encoder for token-level processing, +selective extraction, and Web Streams pipelines. ## Installation -`jsontext` is an ESM-only package available on both `NPM` and `JSR`. The core decoder and encoder -run in any modern JavaScript environment; the optional `*Stream` classes additionally require -`WHATWG` Streams support: - -### NPM - -Install via [npm](https://www.npmjs.com/package/jsontext): - -```bash -npm install jsontext -``` - -### Deno - -Install via [JSR](https://jsr.io/@lcweden/jsontext): +### JSR ```bash deno add jsr:@lcweden/jsontext ``` -## APIs - -See full reference on [JSR](https://jsr.io/@lcweden/jsontext/doc/). - -| Category | Exports | -| :--------- | :------------------------------------------------------------------------------------------------------- | -| Core | [`JSONTextDecoder`], [`JSONTextEncoder`] | -| Stream | [`JSONTextDecoderStream`], [`JSONTextEncoderStream`], [`JSONTextSelectorStream`], [`JSONTextLineStream`] | -| Components | [`Token`], [`Value`], [`Kind`] | -| Error | [`SyntacticError`] | - -[`JSONTextDecoder`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextDecoder -[`JSONTextEncoder`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextEncoder -[`JSONTextDecoderStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextDecoderStream -[`JSONTextEncoderStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextEncoderStream -[`JSONTextSelectorStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextSelectorStream -[`JSONTextLineStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextLineStream -[`Token`]: https://jsr.io/@lcweden/jsontext/doc/~/Token -[`Value`]: https://jsr.io/@lcweden/jsontext/doc/~/Value -[`Kind`]: https://jsr.io/@lcweden/jsontext/doc/~/KIND -[`SyntacticError`]: https://jsr.io/@lcweden/jsontext/doc/~/SyntacticError - -### Core - -The core APIs provide more control and flexibility. They are designed for scenarios where Web -Streams are not available or when you need granular control. - -#### JSONTextDecoder - -A low-level, stateful JSON decoder that processes bytes incrementally. It is suitable for developing -custom JSON processing logic and `TransformStreams`. - -Unlike `JSON.parse`, you need to `.push()` bytes into `JSONTextDecoder` as they arrive, and pull -`Tokens` or `Values`. - -##### Basic Usage - -The following example demonstrates how to `.push()` bytes into `JSONTextDecoder` and read tokens one -by one. The decoder automatically buffers incomplete tokens across bytes. - -```javascript -const decoder = new JSONTextDecoder(); - -decoder.push(new TextEncoder().encode(`{"name": "Al`)); -decoder.push(new TextEncoder().encode(`ice", "age": 18`)); -decoder.push(new TextEncoder().encode(`}`)); - -decoder.end(); // no more bytes are coming, signal the end of input - -decoder.readToken().kind; // KIND.OBJECT_BEGIN ('{') -decoder.readToken().asString(); // "name" -decoder.readToken().asString(); // "Alice" -decoder.readToken().asString(); // "age" -decoder.readToken().asNumber(); // 18 -decoder.readToken().kind; // KIND.OBJECT_END ('}') - -decoder.checkEOF(); -``` - -You may want to check the type before parsing a `Token`. `KIND` is a constant enum that can be used -like this: `token.kind === KIND.STRING` or `token.kind === KIND.BOOLEAN`. - -> [!TIP] -> `.end()` signals that no more bytes will be pushed. The decoder needs this signal to confirm that -> a number at the very end of the stream is complete and not just more digits still coming, since -> there is no delimiter after it. Always call `.end()` when you know the input is done. - -> [!TIP] -> `checkEOF()` asserts that the entire input was consumed and well-formed, no unclosed objects or -> trailing garbage bytes. - -##### Extracting and Skipping Values - -Other than reading tokens one by one, you can also read a `Value` with `.readValue()`, which can be -a scalar, an entire object, or an array. - -```javascript -const decoder = new JSONTextDecoder(new TextEncoder().encode(`{"id": 1, "metadata": { }}`)); -let token; - -while (true) { - token = decoder.readToken(); - - if (token === undefined) { - break; // need more bytes - } - - if (token.asString() === "metadata") { - const value = decoder.readValue(); - const metadata = value.json(); - } else { - decoder.skipValue(); // skip the value of this token without parsing it - } -} - -decoder.end(); -decoder.checkEOF(); -``` - -The example above follows the sequence: - -| step | action | -| :--- | :-------------------------- | -| 1 | read a token (`"id"`) | -| 2 | skip the value (`1`) | -| 3 | read a token (`"metadata"`) | -| 4 | read a value (`{ }`) | -| 5 | parse the value as JSON | - -> [!TIP] -> Use `.stackPointer()` to get the [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901), -> which is useful for targeting specific paths in the document like -> `decoder.stackPointer() === "/metadata"`. - -##### Web Streams - -The following example demonstrates how to use `JSONTextDecoder` with a `ReadableStream` from -`fetch`. - ```javascript -const response = await fetch("your.api/endpoint"); -const decoder = new JSONTextDecoder(); - -// Outer loop: wait for new chunks to arrive -for await (const chunk of response.body) { - decoder.push(chunk); - - // Inner loop: read all decodable tokens from the current buffer - for (let token; (token = decoder.readToken()) !== undefined;) { - // read token or value... - } -} - -decoder.end(); -decoder.checkEOF(); +import { JSONTextSelectorStream } from "@lcweden/jsontext"; ``` -Requires the user to manage backpressure and chunk boundaries, it gives you the most control and -flexibility. Check `JSONTextDecoderStream` to see how to wrap it in a `TransformStream` that handles -all the stream mechanics for you. - -#### JSONTextEncoder - -It is the exact counterpart to `JSONTextDecoder`, which allows you to construct a JSON document -token by token or value. - -##### Basic Usage - -You can feel free to write tokens and values in any order using `Token` and `Value` provided -methods. - -```javascript -import { Token, Value } from "jsontext"; - -const decoder = new TextDecoder(); -const encoder = new JSONTextEncoder(); - -encoder.writeToken(Token.ARRAY_BEGIN); -encoder.writeValue(Value.from({ id: 1, status: "active" })); -encoder.writeValue(Value.from({ id: 2, status: "pending" })); -encoder.writeToken(Token.ARRAY_END); - -const bytes = encoder.takeBytes(); -const text = decoder.decode(bytes); -// '[{"id":1,"status":"active"},{"id":2,"status":"pending"}]' -``` - -##### Round Trip - -A common use case is piping a decoder directly into an encoder to mutate a stream on the fly. In -this pattern, you drain tokens from the decoder, modify them if needed, and write them to the -encoder. - -```javascript -const decoder = new JSONTextDecoder(); -const encoder = new JSONTextEncoder(); - -const response = await fetch("your.api/endpoint"); - -for await (const chunk of response.body) { - decoder.push(chunk); - - for (let token; (token = decoder.readToken()) !== undefined;) { - encoder.writeToken(token); - } - - const bytes = encoder.takeBytes(); -} - -decoder.end(); -decoder.checkEOF(); -``` - -> [!IMPORTANT] -> `takeBytes()` only gives you the encoded bytes and clears the encoder's internal buffer. It does -> not write them anywhere. You must manually pipe these bytes to your destination, such as a file -> writer, network socket, or controller. - -### Stream - -These classes wrap the core decoder and encoder in `TransformStream` interfaces, making them easy to -handle some common use cases and compose with other Web Streams APIs. See the [Examples](#examples) -section for more details. - -#### JSONTextDecoderStream +### npm -Wraps a `JSONTextDecoder` and emits `Token`s as they are decoded. Ideal for token-level processing, -such as filtering or transforming tokens. If you need to work with `Value`, use `JSONTextDecoder` -directly. - -```javascript -const response = await fetch("your.api/endpoint"); -const tokens = response.body.pipeThrough(new JSONTextDecoderStream()); - -for await (const token of tokens) { - // ... -} -``` - -#### JSONTextEncoderStream - -Wraps a `JSONTextEncoder` and accepts `Token` only. While streams like `JSONTextSelectorStream` and -`JSONTextLineStream` emit `Value`, `Value` provides a `.tokens()` generator that can be used to feed -tokens into `JSONTextEncoderStream`. - -The following example demonstrates how to write a `TransformStream` that converts `Value` into -`Token` and pipe it into a `JSONTextEncoderStream`. - -```javascript -const encoder = new JSONTextEncoderStream(); -const transformer = new TransformStream({ - transform(value, controller) { - for (const token of value.tokens()) { - controller.enqueue(token); - } - }, -}); - -stream.pipeThrough(transformer).pipeThrough(encoder); -``` - -#### JSONTextSelectorStream - -`JSONTextSelectorStream` supports a subset of -[JSON Path](https://datatracker.ietf.org/doc/html/rfc9535) syntax for selecting specific values from -a JSON document. - -| Supported | Syntax | -| :---------------------------------------------------------------------------------------------- | :-------------------------------------- | -| [Root Identifier](https://datatracker.ietf.org/doc/html/rfc9535#name-root-identifier) | `$` | -| [Child Segment](https://datatracker.ietf.org/doc/html/rfc9535#name-child-segment) | `.`, `[]` | -| [Descendant Segment](https://datatracker.ietf.org/doc/html/rfc9535#name-descendant-segment) | `..` | -| [Name Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-name-selector) | `.name`, `['name']`, `['name', 'name']` | -| [Wildcard Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-wildcard-selector) | `.*` | -| [Index Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-index-selector) | `[0]` | -| [Array Slice Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-array-slice-selector) | `[start:end:step]` | - -> [!NOTE] -> Negative numbers in index and slice selectors are not supported. - -The following example extracts all `email` values from `{ "users": [ ... ] }`. - -```javascript -const response = await fetch("your.api/endpoint"); -const emails = response.body.pipeThrough(new JSONTextSelectorStream("$.users[*].email")); - -for await (const value of emails) { - console.log(value.json()); -} -``` - -> [!TIP] -> `Value` has an optional `.pointer` property that returns the -> [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) of where the value was located in -> the source document. `JSONTextSelectorStream` sets this automatically, so you can use it to get -> the exact location of each selected value. - -#### JSONTextLineStream - -`JSONTextLineStream` is designed for processing JSON Lines (JSONL) format, but it can also handle -concatenated JSON documents. - -```javascript -const response = await fetch("your.api/endpoint"); -const lines = response.body.pipeThrough(new JSONTextLineStream()); - -for await (const value of lines) { - console.log(value.json()); -} -``` - -### Components - -#### Token - -A `Token` represents the smallest lexical unit of JSON. It is either a scalar (like `"Alice"`, -`true`, `123`, `null`) or a structural symbol (like `{`, `}`, `[`, `]`), it **never** represents a -whole object or array. - -See JSR documentation for all available methods, such as `ARRAY_BEGIN`, `.asNumber()`, -`.isScalar()`, etc. - -> [!IMPORTANT] -> Tokens and Values returned from a decoder are views into its internal buffer. This buffer is -> overwritten the next time you `.push()` more bytes. -> -> If you need to keep a token or value around for later use, you must copy it using `.clone()`: -> -> ```javascript -> const collected = []; -> while ((token = decoder.readToken()) !== undefined) { -> collected.push(token); // ❌ UNSAFE: all entries will point to the mutated bytes -> collected.push(token.clone()); // ✅ SAFE: creates an independent copy -> } -> ``` - -#### Value - -A `Value` represents a complete JSON unit. It can be a simple scalar, or it can be an entire -`object` or `array` including everything nested inside it. - -Use `Value` when you need a specific subtree. You can call `value.json()` to materialize it into a -JavaScript object, or use `decoder.skipValue()` to cheaply discard massive branches you don't need -without ever parsing them. - -##### Create a Value instance `from` - -`.from()` is a static helper that creates a `Value` instance from any JSON-serializable value. - -```javascript -const value = Value.from("Hello, World!"); -``` - -##### Canonicalize - -`.canonicalize()` implements the -[JSON Canonicalization Scheme](https://datatracker.ietf.org/doc/html/rfc8785) by recursively sorting -object keys by UTF-16 code unit order and normalizing numbers. The result is deterministic and -idempotent, making it ideal for hashing or strict comparisons. - -```javascript -const value = Value.from({ b: 2, a: 1 }).canonicalize(); // {"a":1,"b":2} -``` - -##### Tokenize - -`.tokens()` is a generator method that yields each `Token` within this value in document order. This -allows you to process or transform the value token by token without materializing the whole thing in -memory. - -```javascript -const value = Value.from({ name: "Alice", tags: ["admin", "user"] }); - -for (const token of value.tokens()) { - if (token.kind === KIND.STRING) { - console.log(token.asString()); - } -} +```bash +npm install jsontext ``` -#### Kind - -`KIND` is a constant object containing string discriminants that identify the structural role of a -JSON token. Always use these constants for comparisons to avoid typos. - -| Kind | Value | -| :------------------ | :--------- | -| `KIND.NULL` | `"null"` | -| `KIND.FALSE` | `"false"` | -| `KIND.TRUE` | `"true"` | -| `KIND.STRING` | `"string"` | -| `KIND.NUMBER` | `"number"` | -| `KIND.OBJECT_BEGIN` | `"{"` | -| `KIND.OBJECT_END` | `"}"` | -| `KIND.ARRAY_BEGIN` | `"["` | -| `KIND.ARRAY_END` | `"]"` | - -You can check a token's kind with `token.kind === KIND.STRING` or use helper methods like -`token.isScalar()`, `token.isStructural()`, etc. - -### Error - -`jsontext` throws standard JavaScript errors (`TypeError`, `RangeError`, `SyntaxError`) for -programmer mistakes such as invalid arguments or type mismatches. For malformed JSON input, it -throws the custom `SyntacticError` described below. - -#### SyntacticError - -When input violates [The JavaScript Object Notation](https://datatracker.ietf.org/doc/html/rfc8259), -it throws a `SyntacticError` carrying both the byte `offset` and the JSON `pointer` to help pinpoint -the exact failure. - ```javascript -import { JSONTextDecoder, SyntacticError } from "jsontext"; - -try { - const encoder = new TextEncoder(); - const decoder = new JSONTextDecoder(encoder.encode(`{"a": 1, "b": }`)); - - decoder.end(); - - while (decoder.readToken() !== undefined) { - /* ... */ - } -} catch (error) { - if (error instanceof SyntacticError) { - console.error(error.offset); - console.error(error.pointer); - console.error(error.message); - } -} +import { JSONTextSelectorStream } from "jsontext"; ``` -## Performance - -`jsontext` is designed for flat memory usage regardless of input size. The following shows a -passthrough run on a 1 GB file — heap stays near baseline throughout: - -![Passthrough Result](https://github.com/user-attachments/assets/6d8d795b-ba11-41c1-8993-ac5e15088524) - -For full profiling results across passthrough, round-trip, and query scenarios, see -[docs/performance.md](docs/performance.md). - -## Examples +JSONText is ESM-only. The core APIs use `Uint8Array` chunks and work in modern JavaScript runtimes. +The `*Stream` APIs require WHATWG Streams support. -Below are some simple examples demonstrating how to use `jsontext` for common JSON processing tasks. -For more examples, see the [docs/](docs/). - -### Replace `null` with an empty string - -In this example, we read a JSON stream from an API endpoint, replace all `null` values with empty -strings, and write the modified JSON back out as a stream without ever materializing the whole -document in memory. - -```javascript -import { JSONTextDecoderStream, JSONTextEncoderStream, KIND, Token } from "jsontext"; - -const response = await fetch("your.api/endpoint"); - -if (!response.ok || !response.body) { - throw new Error("Failed to fetch data"); -} - -const decoder = new JSONTextDecoderStream(); -const encoder = new JSONTextEncoderStream(); -const replacer = new TransformStream({ - transform(token, controller) { - if (token.kind === KIND.NULL) { // Detect a `null` token - controller.enqueue(Token.fromString("")); // Emit an empty string token instead - } else { - controller.enqueue(token); - } - }, -}); - -const stream = response.body.pipeThrough(decoder).pipeThrough(replacer).pipeThrough(encoder); -const blob = await new Response(stream).blob(); -``` +## API -> [!TIP] -> `JSONTextDecoderStream` supports token-level processing only. If you need to replace values that -> may be nested inside objects or arrays, you will need to use `JSONTextDecoder` directly. +See the [JSR API reference](https://jsr.io/@lcweden/jsontext/doc/) for constructors, methods. ## License @@ -514,4 +43,6 @@ This project is licensed under the [MIT](LICENSE) License. ## Acknowledgements This project is inspired by Go's -[`encoding/json/jsontext`](https://pkg.go.dev/encoding/json/jsontext) standard library. +[`encoding/json/jsontext`](https://pkg.go.dev/encoding/json/jsontext) standard library package and +.NET's +[`System.Text.Json`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json?view=net-8.0). diff --git a/deno.json b/deno.json index 5a4d502..5aa27d8 100644 --- a/deno.json +++ b/deno.json @@ -4,10 +4,13 @@ "license": "MIT", "tasks": { "bench": "deno bench --allow-all", + "build": "deno task transpile && deno task bundle", + "bundle": "deno bundle src/index.ts --minify --outdir dist --platform browser --sourcemap", "check": "deno check src/index.ts", "format": "deno fmt", "lint": "deno lint", - "test": "deno test --coverage --allow-all" + "test": "deno test --allow-all --coverage", + "transpile": "cd src && deno transpile index.ts --outdir ../dist --declaration" }, "imports": { "#src/": "./src/", @@ -21,38 +24,15 @@ }, "lint": { "rules": { - "tags": [ - "recommended" - ], - "include": [ - "no-slow-types" - ], - "exclude": [ - "no-sloppy-imports" - ] + "tags": ["recommended"], + "include": ["no-slow-types"], + "exclude": ["no-sloppy-imports"] } }, - "unstable": [ - "sloppy-imports" - ], - "exclude": [ - "node_modules/", - "public/", - "dist/" - ], + "unstable": ["sloppy-imports"], + "exclude": ["dist/"], "publish": { - "include": [ - "src/", - "README.md", - "LICENSE", - "deno.json" - ], - "exclude": [ - "tests/", - ".github/", - ".vscode/", - "dist/", - "public/" - ] + "include": ["src/", "README.md", "LICENSE", "deno.json"], + "exclude": ["tests/", ".github/", ".vscode/", "dist/"] } } diff --git a/deno.lock b/deno.lock index 7a4b627..28036d2 100644 --- a/deno.lock +++ b/deno.lock @@ -18,14 +18,6 @@ "workspace": { "dependencies": [ "jsr:@std/assert@1" - ], - "packageJson": { - "dependencies": [ - "npm:@types/node@^25.9.1", - "npm:typescript@~6.0.2", - "npm:unplugin-dts@^1.0.1", - "npm:vite@^8.0.9" - ] - } + ] } } diff --git a/docs/performance.md b/docs/performance.md deleted file mode 100644 index 4c6f6f5..0000000 --- a/docs/performance.md +++ /dev/null @@ -1,111 +0,0 @@ -# Performance - -This section focuses on memory performance. When processing huge files, the goal is to keep the -memory baseline flat and GC pauses to an absolute minimum, entirely independent of the input size. - -> [!NOTE] -> The following examples are run on `Node.js` using a 1 GB JSON file. Performance profiling is -> generated via `clinic`. - -## Passthrough - -This scenario demonstrates the absolute base cost of parsing. We use the core `JSONTextDecoder` to -read chunks from a 1 GB file, tokenize them, and immediately discard the tokens. - -```javascript -import { createReadStream } from "node:fs"; -import { JSONTextDecoder } from "jsontext"; - -const decoder = new JSONTextDecoder(); -const stream = createReadStream("data.json"); - -for await (const chunk of stream) { - decoder.push(chunk); - - while (decoder.readToken() !== undefined) { - /** Drain */ - } -} - -decoder.end(); -decoder.checkEOF(); -``` - -![Passthrough Result](https://github.com/user-attachments/assets/6d8d795b-ba11-41c1-8993-ac5e15088524) - -## Round Trip - -This scenario represents a full I/O cycle. We stream bytes from the 1 GB file, decode them into -Tokens using the core `JSONTextDecoder`, immediately feed those tokens into `JSONTextEncoder`, and -write the re-encoded bytes to a destination `/dev/null`. - -```javascript -import { createReadStream, createWriteStream } from "node:fs"; -import { JSONTextDecoder, JSONTextEncoder } from "jsontext"; - -const input = createReadStream("data.json"); -const output = createWriteStream("/dev/null"); -const decoder = new JSONTextDecoder(); -const encoder = new JSONTextEncoder(); - -for await (const chunk of input) { - decoder.push(chunk); - - for (let token; (token = decoder.readToken()) !== undefined;) { - encoder.writeToken(token); - } - - const bytes = encoder.takeBytes(); - - if (bytes.length > 0) { - output.write(bytes); - } -} - -decoder.end(); -decoder.checkEOF(); -output.end(); -``` - -![Round Trip Result](https://github.com/user-attachments/assets/f8c6fc35-0227-40c3-98a2-c9503a366299) - -> [!IMPORTANT] -> Using `JSONTextDecoderStream` and `JSONTextEncoderStream` directly in Node.js requires `.toWeb()` -> to convert to Web Streams, which adds an extra buffering layer and can push Heap Used up to 300 MB -> before triggering GC in this scenario. - -## Query - -This scenario demonstrates a data querying use case. We use `JSONTextSelectorStream` with a -descendant JSON Path expression `$..id` to scan the entire 1 GB file. For each match, we call -`json()` to decode the value into a JavaScript object, and keep a count of the total matches. - -Since `JSONTextSelectorStream` is a Web Streams `TransformStream`, we use `.toWeb()` to bridge -`Node.js` streams. - -```javascript -import { JSONTextSelectorStream } from "jsontext"; -import { createReadStream } from "node:fs"; -import { Readable } from "node:stream"; - -const stream = createReadStream("data.json"); -const selector = new JSONTextSelectorStream("$..id"); -let count = 0; - -for await (const value of Readable.toWeb(stream).pipeThrough(selector)) { - value.json(); - count++; -} - -console.log(`Total values: ${count}`); -// Total values: 565255 for the 1 GB file used in this example -``` - -![Query Result](https://github.com/user-attachments/assets/2a4e679f-e76f-43f5-bece-487d9a925b91) - -> [!TIP] -> `JSONTextSelectorStream` only emits matched values, so the frequency of `.enqueue()` calls is low -> bounded by the number of matches, not the number of tokens. This makes the microtask overhead from -> Web Streams acceptable here. In the Round Trip scenario every token triggers an `.enqueue()`, -> which creates enough microtask pressure to push Heap Used to 300 MB and trigger GC. That is why we -> use the core APIs directly there instead. diff --git a/index.html b/index.html deleted file mode 100644 index 4b56218..0000000 --- a/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - jsontext - - - -
- - - diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f409f45..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1316 +0,0 @@ -{ - "name": "jsontext", - "version": "0.2.3", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "jsontext", - "version": "0.2.3", - "license": "MIT", - "devDependencies": { - "@types/node": "^25.9.1", - "typescript": "~6.0.2", - "unplugin-dts": "^1.0.1", - "vite": "^8.0.9" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/compare-versions": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", - "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", - "dev": true, - "license": "MIT" - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/local-pkg": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", - "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", - "pathe": "^2.0.3" - } - }, - "node_modules/postcss": { - "version": "8.5.17", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", - "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/unplugin-dts": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.1.tgz", - "integrity": "sha512-EdJZxdWP4Tm/xhe58/zAge3Tu0OKDYygm8rucRrcCZ4XzgA31jexUKhaJuEMddOPBDs9ONnq6vwigbjeBqkfuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.4", - "@volar/typescript": "^2.4.26", - "compare-versions": "^6.1.1", - "debug": "^4.4.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.1.1", - "magic-string": "^0.30.17", - "unplugin": "^2.3.2" - }, - "peerDependencies": { - "@microsoft/api-extractor": ">=7", - "@rspack/core": "^1", - "@vue/language-core": "~3.1.5", - "esbuild": "*", - "rolldown": "*", - "rollup": ">=3", - "typescript": ">=4", - "vite": ">=3", - "webpack": "^4 || ^5" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@rspack/core": { - "optional": true - }, - "@vue/language-core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "rolldown": { - "optional": true - }, - "rollup": { - "optional": true - }, - "vite": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 1abaec2..11f37d9 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,9 @@ { "name": "jsontext", "version": "0.2.3", + "description": "A state machine for incremental JSON processing with high-level APIs.", + "keywords": ["json", "streaming", "parser", "encoder"], "license": "MIT", - "description": "A state machine for incremental JSON processing.", - "keywords": [ - "json", - "json-path", - "json-pointer", - "jsonl", - "jsonlines", - "parser", - "encoder", - "decoder", - "stream", - "streaming", - "web-streams", - "ndjson", - "state-machine" - ], - "type": "module", - "types": "./dist/index.d.ts", - "imports": { - "#src/*": "./src/*" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist/**/*", - "!dist/**/*.map" - ], - "author": { - "name": "lcweden", - "url": "https://github.com/lcweden" - }, "homepage": "https://github.com/lcweden/jsontext", "repository": { "type": "git", @@ -45,23 +12,30 @@ "bugs": { "url": "https://github.com/lcweden/jsontext/issues" }, + "author": { + "name": "lcweden", + "url": "https://github.com/lcweden" + }, + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "module": "./dist/index.js", + "imports": { + "#src/*": ["./dist/*.js", "./dist/*.ts", "./dist/*.d.ts"] + }, + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } + }, + "files": ["dist"], "scripts": { - "build": "vite build", - "bench": "deno task bench", - "dev": "vite", - "format": "deno task format", - "lint": "deno task lint", - "preview": "vite preview", "test": "deno task test" }, - "sideEffects": false, + "directories": { + "doc": "docs", + "test": "tests" + }, "engines": { "node": ">=18" }, - "devDependencies": { - "@types/node": "^25.9.1", - "typescript": "~6.0.2", - "unplugin-dts": "^1.0.1", - "vite": "^8.0.9" - } + "sideEffects": false } diff --git a/public/example.com.har b/public/example.com.har deleted file mode 100644 index 8a23347..0000000 --- a/public/example.com.har +++ /dev/null @@ -1,192 +0,0 @@ -{ - "log": { - "version": "1.2", - "creator": { - "name": "WebInspector", - "version": "537.36" - }, - "pages": [ - { - "startedDateTime": "2026-05-07T13:34:01.622Z", - "id": "page_1", - "title": "https://example.com/", - "pageTimings": { - "onContentLoad": 41.40600000391714, - "onLoad": 41.72400000970811 - } - } - ], - "entries": [ - { - "_initiator": { - "type": "other" - }, - "_priority": "VeryHigh", - "_resourceType": "document", - "cache": {}, - "connection": "443", - "request": { - "method": "GET", - "url": "https://example.com/", - "httpVersion": "http/2.0", - "headers": [ - { - "name": ":authority", - "value": "example.com" - }, - { - "name": ":method", - "value": "GET" - }, - { - "name": ":path", - "value": "/" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "accept-language", - "value": "zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6" - }, - { - "name": "cache-control", - "value": "max-age=0" - }, - { - "name": "priority", - "value": "u=0, i" - }, - { - "name": "referer", - "value": "https://www.google.com/" - }, - { - "name": "sec-ch-ua", - "value": "\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"macOS\"" - }, - { - "name": "sec-fetch-dest", - "value": "document" - }, - { - "name": "sec-fetch-mode", - "value": "navigate" - }, - { - "name": "sec-fetch-site", - "value": "cross-site" - }, - { - "name": "sec-fetch-user", - "value": "?1" - }, - { - "name": "upgrade-insecure-requests", - "value": "1" - }, - { - "name": "user-agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" - } - ], - "queryString": [], - "cookies": [], - "headersSize": -1, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "", - "httpVersion": "http/2.0", - "headers": [ - { - "name": "age", - "value": "7877" - }, - { - "name": "allow", - "value": "GET, HEAD" - }, - { - "name": "cf-cache-status", - "value": "HIT" - }, - { - "name": "cf-ray", - "value": "9f80998c3aa8d8ec-HKG" - }, - { - "name": "content-encoding", - "value": "br" - }, - { - "name": "content-type", - "value": "text/html" - }, - { - "name": "date", - "value": "Thu, 07 May 2026 13:34:01 GMT" - }, - { - "name": "last-modified", - "value": "Wed, 06 May 2026 14:17:14 GMT" - }, - { - "name": "server", - "value": "cloudflare" - } - ], - "cookies": [], - "content": { - "size": 528, - "mimeType": "text/html", - "text": "Example Domain

Example Domain

This domain is for use in documentation examples without needing permission. Avoid use in operations.

Learn more

\n" - }, - "redirectURL": "", - "headersSize": -1, - "bodySize": -1, - "_transferSize": 411, - "_error": null, - "_fetchedViaServiceWorker": false - }, - "serverIPAddress": "172.66.147.243", - "startedDateTime": "2026-05-07T13:34:01.621Z", - "time": 33.278999995673075, - "timings": { - "blocked": 1.7909999983943998, - "dns": -1, - "ssl": -1, - "connect": -1, - "send": 0.14900000000000002, - "wait": 30.936000013643877, - "receive": 0.4029999836347997, - "_blocked_queueing": 0.7259999983943999, - "_workerStart": -1, - "_workerReady": -1, - "_workerFetchStart": -1, - "_workerRespondWithSettled": -1 - }, - "_connectionId": "2446955", - "pageref": "page_1" - } - ] - } -} diff --git a/src/api/decoder.ts b/src/api/decoder.ts index 9f8a9e2..7a94d38 100644 --- a/src/api/decoder.ts +++ b/src/api/decoder.ts @@ -1,161 +1,129 @@ +import type { Kind } from "#src/api/kind"; +import Token from "#src/api/token"; +import Value from "#src/api/value"; import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants"; -import Decoder from "#src/modules/decoder"; -import type Token from "#src/modules/token"; -import type Value from "#src/modules/value"; -import type { Kind } from "#src/types/kind"; -import type { DecoderOptions } from "#src/types/options"; +import Parser from "#src/modules/parser"; /** - * Options for {@link JSONTextDecoder}. + * Options for {@link JSONTextDecoder} and {@link JSONTextDecoderStream}. * * @public */ -type JSONTextDecoderOptions = DecoderOptions; +type JSONTextDecoderOptions = { + /** Allow duplicate object key names. Defaults to `false`. */ + allowDuplicateNames?: boolean; + /** Allow invalid UTF-8 byte sequences. Defaults to `false`. */ + allowInvalidUTF8?: boolean; +}; /** - * Low-level, stateful JSON decoder that processes input incrementally. - * - * Feed byte chunks via {@link push} then consume tokens with - * {@link readToken} / {@link readValue} / {@link skipValue}. - * Call {@link end} when the stream is exhausted to flush any buffered state. + * Low-level, stateful JSON decoder that processes bytes incrementally. * * @public */ class JSONTextDecoder { - #decoder: Decoder; + #parser: Parser; - /** - * @param bytes - Initial bytes to pre-load into the decoder. - * @param options - Decoding options. - */ - constructor(bytes: Uint8Array = new Uint8Array(), options?: JSONTextDecoderOptions) { - this.#decoder = new Decoder(bytes, { ...DEFAULT_DECODER_OPTIONS, ...options }); + /** Creates a decoder with the supplied options. */ + constructor(options?: JSONTextDecoderOptions) { + this.#parser = new Parser({ ...DEFAULT_DECODER_OPTIONS, ...options }); } - /** - * Asserts that the input has been fully consumed. - * - * @throws {SyntaxError} If the decoder is still inside a nested structure, - * or if non-whitespace characters remain after the value. - */ - checkEOF(): void { - this.#decoder.checkEOF(); + /** The current structural nesting depth. */ + get depth(): number { + return this.#parser.depth; } - /** - * Returns the current nesting depth of the structural state. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#decoder.depth(); + /** The absolute byte offset of the current input position. */ + get inputOffset(): number { + return this.#parser.inputOffset; } - /** - * Signals that no more input will be pushed. - * - * After calling this, number tokens no longer require a trailing byte to - * confirm their end. - */ - end(): void { - this.#decoder.end(); + /** The unconsumed bytes currently retained by the decoder. */ + get unreadBytes(): Uint8Array { + return this.#parser.unreadBytes; } - /** - * The byte offset of the end of the last consumed token within the total - * input seen so far. - * - * @returns The global byte offset from the start of the stream. - */ - inputOffset(): number { - return this.#decoder.inputOffset(); + /** Verifies that the input is complete and contains no trailing characters. */ + checkEOF(): void { + this.#parser.checkEOF(); } - /** - * Appends a chunk of bytes to the internal buffer. - * - * @param bytes - The next chunk of JSON-encoded bytes. - */ + /** Marks the input as complete so the decoder can validate its final token. */ + end(): void { + this.#parser.close(); + } + + /** Appends a chunk of JSON bytes to the decoder. */ push(bytes: Uint8Array): void { - this.#decoder.push(bytes); + this.#parser.push(bytes); } /** - * Returns the {@link Kind} of the next token without consuming it, - * or `undefined` if no complete token is available yet. + * Peeks at the next token kind without consuming it. * - * @returns The {@link Kind} of the next token, or `undefined` if no complete token is available yet. - * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered. + * @returns The next {@link Kind}, or `undefined` when more input is needed. + * @throws {SyntaxError} If the next bytes contain invalid JSON syntax. */ peekKind(): Kind | undefined { - return this.#decoder.peekKind(); + return this.#parser.peekKind(); } - /** - * Resets the decoder to its initial state, discarding all buffered input - * and state. - */ + /** Resets the decoder, clearing buffered input and structural state. */ reset(): void { - this.#decoder.reset(); + this.#parser.reset(); } /** - * Reads and returns the next {@link Token} from the buffer, or `undefined` - * if no complete token is available yet. + * Reads the next JSON token. * - * @returns The next token, or `undefined` if no complete token is available yet. - * @throws {SyntacticError} If invalid JSON syntax is encountered. + * @returns A {@link Token}, or `undefined` when more input is needed. + * @throws {SyntaxError} If the next token is invalid or violates the current structure. */ readToken(): Token | undefined { - return this.#decoder.readToken(); + const span = this.#parser.readToken(); + + if (span === undefined) { + return undefined; + } + + return new Token(span); } /** - * Reads and returns the next complete {@link Value} from the buffer, or - * `undefined` if there is not yet enough input to form a complete value. + * Reads the next complete JSON value. * - * @returns The next value, or `undefined` if no complete value is available yet. - * @throws {SyntacticError} If invalid JSON syntax is encountered. + * @returns A {@link Value}, or `undefined` when more input is needed. + * @throws {SyntaxError} If the next value is invalid or violates the current structure. */ readValue(): Value | undefined { - return this.#decoder.readValue(); + const span = this.#parser.readValue(); + + if (span === undefined) { + return undefined; + } + + return new Value(span); } /** - * Skips over the next complete value without returning it. + * Skips the next complete JSON value without allocating a {@link Value}. * - * @returns `true` if a value was skipped, `false` if no complete value was available yet. - * @throws {SyntacticError} If invalid JSON syntax is encountered. + * @returns `true` when a value was skipped, or `false` when more input is needed. + * @throws {SyntaxError} If the next value is invalid or violates the current structure. */ skipValue(): boolean { - return this.#decoder.skipValue(); + return this.#parser.skipValue(); } /** - * Returns a JSON Pointer string describing a position in the current - * nesting context. + * Returns a JSON Pointer for a position relative to the current decoder state. * - * | `where` | Meaning | - * |---------|----------------------------------------------------------| - * | `1` | The position of the **next** value to be read (default). | - * | `0` | The position of the **current** container. | - * | `-1` | The position of the **previously** read value. | - * - * @param where - Which position to return. Defaults to `1`. - * @returns A JSON Pointer string, e.g. `"/foo/0"`. + * @param where Relative position: `-1` previous, `0` current, or `1` next. + * @returns A JSON Pointer string for the selected position. */ stackPointer(where: 0 | 1 | -1 = 1): string { - return this.#decoder.stackPointer(where).toString(); - } - - /** - * Returns a view of the bytes in the internal buffer that have not yet been - * consumed. - * - * @returns A `Uint8Array` view of the unread bytes in the internal buffer. - */ - unreadBytes(): Uint8Array { - return this.#decoder.unreadBytes(); + return this.#parser.stackPointer(where).toString(); } } diff --git a/src/api/encoder.ts b/src/api/encoder.ts index f260c4b..ca9ebb1 100644 --- a/src/api/encoder.ts +++ b/src/api/encoder.ts @@ -1,61 +1,70 @@ +import type Token from "#src/api/token"; +import type Value from "#src/api/value"; import { DEFAULT_ENCODER_OPTIONS } from "#src/common/constants"; -import Encoder from "#src/modules/encoder"; -import type Token from "#src/modules/token"; -import type Value from "#src/modules/value"; -import type { EncoderOptions } from "#src/types/options"; +import Serializer from "#src/modules/serializer"; /** - * Options for {@link JSONTextEncoder}. + * Options for {@link JSONTextEncoder} and {@link JSONTextEncoderStream}. * * @public */ -type JSONTextEncoderOptions = EncoderOptions; +type JSONTextEncoderOptions = { + /** Allow duplicate object key names. Defaults to `false`. */ + allowDuplicateNames?: boolean; + /** Allow invalid UTF-8 byte sequences. Defaults to `false`. */ + allowInvalidUTF8?: boolean; + /** Normalize raw number tokens. Defaults to `false`. */ + canonicalizeRawNumbers?: boolean; + /** Escape `<`, `>`, and `&` for HTML embedding. Defaults to `false`. */ + escapeForHTML?: boolean; + /** Escape JavaScript line and paragraph separators. Defaults to `false`. */ + escapeForJS?: boolean; + /** Indentation string used when `multiline` is enabled. Defaults to a tab. */ + indent?: string; + /** Prefix added before each indented line. Defaults to an empty string. */ + indentPrefix?: string; + /** Emit each value on its own line. Defaults to `true`. */ + multiline?: boolean; + /** Emit a space after object `:` separators. Defaults to `true`. */ + spaceAfterColon?: boolean; + /** Emit a space after array and object `,` separators. Defaults to `false`. */ + spaceAfterComma?: boolean; +}; /** * Low-level, stateful JSON encoder that produces bytes incrementally. * * Write tokens or values via {@link writeToken} / {@link writeValue}, then - * retrieve the accumulated output with {@link bytes}. Call {@link reset} to + * retrieve the accumulated output with {@link takeBytes}. Call {@link reset} to * start a new document without creating a new instance. * * @public */ class JSONTextEncoder { - #encoder: Encoder; + #serializer: Serializer; /** - * @param options - Encoding options. + * Creates a new JSONTextEncoder instance with the given options. + * + * @param options Encoding options. */ constructor(options?: JSONTextEncoderOptions) { - this.#encoder = new Encoder({ ...DEFAULT_ENCODER_OPTIONS, ...options }); + this.#serializer = new Serializer({ ...DEFAULT_ENCODER_OPTIONS, ...options }); } - /** - * The current nesting depth — `1` at the top level, incremented by each - * open object or array. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#encoder.depth(); + /** The current structural nesting depth. */ + get depth(): number { + return this.#serializer.depth; } - /** - * The byte offset of the end of the last token written, equal to the total - * number of bytes produced so far. - * - * @returns The byte offset of the end of the last token written. - */ - outputOffset(): number { - return this.#encoder.outputOffset(); + /** The byte offset of the end of the last token written. */ + get outputOffset(): number { + return this.#serializer.outputOffset; } - /** - * Resets the encoder to its initial state, clearing the output buffer and - * all structural state. - */ + /** Resets the encoder, clearing its output buffer and structural state. */ reset(): void { - this.#encoder.reset(); + this.#serializer.reset(); } /** @@ -68,11 +77,11 @@ class JSONTextEncoder { * | `0` | The position of the **current** container. | * | `-1` | The position of the **previously** written value. | * - * @param where - Which position to return. Defaults to `1`. + * @param where Relative position: `-1` previous, `0` current, or `1` next. Defaults to `1`. * @returns A JSON Pointer string, e.g. `"/foo/0"`. */ stackPointer(where: 0 | 1 | -1 = 1): string { - return this.#encoder.stackPointer(where).toString(); + return this.#serializer.stackPointer(where).toString(); } /** @@ -83,29 +92,28 @@ class JSONTextEncoder { * @returns A copy of the bytes written since the last `takeBytes` call. */ takeBytes(): Uint8Array { - return this.#encoder.takeBytes(); + return this.#serializer.takeBytes(); } /** * Encodes a single {@link Token} and appends its bytes to the output buffer. * - * @param token - The token to encode. - * @throws {SyntacticError} If the token is not valid at the current position. + * @param token The token to encode. + * @throws {SyntaxError} If the token is not valid at the current position. */ writeToken(token: Token): void { - this.#encoder.writeToken(token); + this.#serializer.writeToken(token.kind, token.bytes); } /** * Encodes a complete {@link Value} and appends its bytes to the output * buffer. * - * @param value - The value to encode. - * @throws {SyntacticError} If the value is not valid at the current - * position. + * @param value The value to encode. + * @throws {SyntaxError} If the value is not valid at the current position. */ writeValue(value: Value): void { - this.#encoder.writeValue(value); + this.#serializer.writeValue(value.kind, value.bytes); } } diff --git a/src/api/kind.ts b/src/api/kind.ts new file mode 100644 index 0000000..21d6a2d --- /dev/null +++ b/src/api/kind.ts @@ -0,0 +1,25 @@ +import { KIND } from "#src/common/constants"; + +/** + * String discriminants identifying the kind of a JSON token. + * + * @public + */ +export const Kind: { + readonly NULL: "null"; + readonly FALSE: "false"; + readonly TRUE: "true"; + readonly STRING: "string"; + readonly NUMBER: "number"; + readonly OBJECT_BEGIN: "{"; + readonly OBJECT_END: "}"; + readonly ARRAY_BEGIN: "["; + readonly ARRAY_END: "]"; +} = KIND; + +/** + * A union of the string values used to identify JSON token kinds. + * + * @public + */ +export type Kind = typeof Kind[keyof typeof Kind]; diff --git a/src/modules/token.ts b/src/api/token.ts similarity index 86% rename from src/modules/token.ts rename to src/api/token.ts index ad830d1..567c354 100644 --- a/src/modules/token.ts +++ b/src/api/token.ts @@ -1,5 +1,4 @@ -import { KIND } from "#src/common/constants"; -import type { Kind } from "#src/types/kind"; +import { Kind } from "#src/api/kind"; import { normalize } from "#src/utils/kind"; import { decodeText, encodeText } from "#src/utils/text"; @@ -166,11 +165,11 @@ class Token { */ isScalar(): boolean { return ( - this.#kind === KIND.STRING || - this.#kind === KIND.NUMBER || - this.#kind === KIND.TRUE || - this.#kind === KIND.FALSE || - this.#kind === KIND.NULL + this.#kind === Kind.STRING || + this.#kind === Kind.NUMBER || + this.#kind === Kind.TRUE || + this.#kind === Kind.FALSE || + this.#kind === Kind.NULL ); } @@ -181,10 +180,10 @@ class Token { */ isStructural(): boolean { return ( - this.kind === KIND.OBJECT_BEGIN || - this.kind === KIND.OBJECT_END || - this.kind === KIND.ARRAY_BEGIN || - this.kind === KIND.ARRAY_END + this.kind === Kind.OBJECT_BEGIN || + this.kind === Kind.OBJECT_END || + this.kind === Kind.ARRAY_BEGIN || + this.kind === Kind.ARRAY_END ); } @@ -192,10 +191,10 @@ class Token { * Decodes this token as a JavaScript string. * * @returns The unescaped string value. - * @throws {TypeError} If this token is not of kind {@link KIND.STRING}. + * @throws {TypeError} If this token is not of kind {@link Kind.STRING}. */ asString(): string { - if (this.#kind !== KIND.STRING) { + if (this.#kind !== Kind.STRING) { throw new TypeError(`invalid JSON token kind: ${this.#kind}`); } @@ -209,10 +208,10 @@ class Token { * Decodes this token as a JavaScript number. * * @returns The numeric value. - * @throws {TypeError} If this token is not of kind {@link KIND.NUMBER}. + * @throws {TypeError} If this token is not of kind {@link Kind.NUMBER}. */ asNumber(): number { - if (this.#kind !== KIND.NUMBER) { + if (this.#kind !== Kind.NUMBER) { throw new TypeError(`invalid JSON token kind: ${this.#kind}`); } @@ -226,14 +225,14 @@ class Token { * Decodes this token as a JavaScript boolean. * * @returns `true` for `true` tokens, `false` for `false` tokens. - * @throws {TypeError} If this token is not of kind {@link KIND.TRUE} or {@link KIND.FALSE}. + * @throws {TypeError} If this token is not of kind {@link Kind.TRUE} or {@link Kind.FALSE}. */ asBoolean(): boolean { - if (this.#kind === KIND.TRUE) { + if (this.#kind === Kind.TRUE) { return true; } - if (this.#kind === KIND.FALSE) { + if (this.#kind === Kind.FALSE) { return false; } @@ -244,10 +243,10 @@ class Token { * Decodes this token as `null`. * * @returns `null`. - * @throws {TypeError} If this token is not of kind {@link KIND.NULL}. + * @throws {TypeError} If this token is not of kind {@link Kind.NULL}. */ asNull(): null { - if (this.#kind !== KIND.NULL) { + if (this.#kind !== Kind.NULL) { throw new TypeError(`invalid JSON token kind: ${this.#kind}`); } diff --git a/src/modules/value.ts b/src/api/value.ts similarity index 78% rename from src/modules/value.ts rename to src/api/value.ts index 6791751..5aae758 100644 --- a/src/modules/value.ts +++ b/src/api/value.ts @@ -1,7 +1,7 @@ +import { Kind } from "#src/api/kind"; +import Token from "#src/api/token"; import { ASCII, DEFAULT_DECODER_OPTIONS, KIND } from "#src/common/constants"; -import Decoder from "#src/modules/decoder"; -import type Token from "#src/modules/token"; -import type { Kind } from "#src/types/kind"; +import Parser from "#src/modules/parser"; import { normalize } from "#src/utils/kind"; import { decodeText, encodeText } from "#src/utils/text"; import { compareUTF16, consumeWhitespace } from "#src/utils/wire"; @@ -19,14 +19,14 @@ import { compareUTF16, consumeWhitespace } from "#src/utils/wire"; class Value { #bytes: Uint8Array; #kind: Kind; - #pointer?: string; + #pointer: string | undefined; /** * Creates a `Value` from raw UTF-8 bytes. * Leading whitespace is accepted and preserved in {@link bytes}. * - * @param bytes - Raw UTF-8 bytes of a complete JSON value. - * @param pointer - Optional JSON Pointer (RFC 6901) indicating where this valuewas located in the source document. Typically set by {@link JSONTextSelectorStream}. + * @param bytes Raw UTF-8 bytes of a complete JSON value. + * @param pointer Optional JSON Pointer indicating where this value was located in the source document. * @throws {RangeError} If `bytes` is empty. * @throws {SyntaxError} If no valid JSON token is found after skipping leading whitespace. */ @@ -92,7 +92,7 @@ class Value { * and normalizes numbers. The result is deterministic and idempotent. * * @returns A new `Value` in canonical form. - * @throws {SyntacticError} If the bytes do not represent valid JSON. + * @throws {SyntaxError} If the bytes do not represent valid JSON. * @example * ```javascript * Value.from({ b: 2, a: 1 }).canonicalize().text() // '{"a":1,"b":2}' @@ -100,10 +100,14 @@ class Value { * ``` */ canonicalize(): Value { - const decoder = new Decoder(this.#bytes, { allowDuplicateNames: true }); - decoder.end(); + const allowInvalidUTF8 = false; + const allowDuplicateNames = true; + const parser = new Parser({ allowInvalidUTF8, allowDuplicateNames }); + + parser.push(this.#bytes); + parser.close(); - return new Value(this.#processValue(decoder)); + return new Value(this.#processValue(parser)); } /** @@ -123,17 +127,20 @@ class Value { */ isValid(): boolean { try { - const decoder = new Decoder(this.#bytes, {}); + const allowInvalidUTF8 = false; + const allowDuplicateNames = false; + const parser = new Parser({ allowInvalidUTF8, allowDuplicateNames }); - decoder.end(); + parser.push(this.#bytes); + parser.close(); - const result = decoder.readValue(); + const result = parser.readValue(); if (result === undefined) { return false; } - decoder.checkEOF(); + parser.checkEOF(); return true; } catch { @@ -169,19 +176,21 @@ class Value { * tokens including structural delimiters, keys, and nested values. * * @yields {Token} Tokens in document order. - * @throws {SyntacticError} If the bytes do not represent valid JSON. + * @throws {SyntaxError} If the bytes do not represent valid JSON. */ *tokens(): Generator { - const decoder = new Decoder(this.#bytes, DEFAULT_DECODER_OPTIONS); + const parser = new Parser(DEFAULT_DECODER_OPTIONS); + + parser.push(this.#bytes); while (true) { - const token = decoder.readToken(); + const bytes = parser.readToken(); - if (token === undefined) { + if (bytes === undefined) { break; } - yield token; + yield new Token(bytes); } } @@ -193,29 +202,27 @@ class Value { * @param decoder The decoder positioned at the start of the value. * @returns The canonicalized UTF-8 bytes of the value. */ - #processValue(decoder: Decoder): Uint8Array { - const kind = decoder.peekKind(); + #processValue(parser: Parser): Uint8Array { + const kind = parser.peekKind(); if (kind === KIND.OBJECT_BEGIN) { - return this.#processObject(decoder); + return this.#processObject(parser); } if (kind === KIND.ARRAY_BEGIN) { - return this.#processArray(decoder); + return this.#processArray(parser); } if (kind === KIND.NUMBER) { - const token = decoder.readToken()!; - const decoded = decodeText(token.bytes, true); + const bytes = parser.readToken()!; + const decoded = decodeText(bytes, true); const parsed = JSON.parse(decoded); const encoded = encodeText(String(parsed)); return encoded; } - const token = decoder.readToken()!; - - return token.bytes; + return parser.readToken()!; } /** @@ -225,24 +232,24 @@ class Value { * @param decoder The decoder positioned at the opening `{`. * @returns The canonicalized UTF-8 bytes of the object. */ - #processObject(decoder: Decoder): Uint8Array { - decoder.readToken(); + #processObject(parser: Parser): Uint8Array { + parser.readToken(); const members: { name: string; key: Uint8Array; value: Uint8Array }[] = []; - while (decoder.peekKind() !== KIND.OBJECT_END) { - const token = decoder.readToken(); + while (parser.peekKind() !== KIND.OBJECT_END) { + const bytes = parser.readToken(); - if (token) { - const decoded = decodeText(token.bytes, true); + if (bytes) { + const decoded = decodeText(bytes, true); const parsed = JSON.parse(decoded); - const value = this.#processValue(decoder); + const value = this.#processValue(parser); - members.push({ name: parsed, key: token.bytes, value }); + members.push({ name: parsed, key: bytes, value }); } } - decoder.readToken(); + parser.readToken(); members.sort((a, b) => compareUTF16(a.name, b.name)); @@ -285,16 +292,16 @@ class Value { * @param decoder The decoder positioned at the opening `[`. * @returns The UTF-8 bytes of the re-serialized array. */ - #processArray(decoder: Decoder): Uint8Array { - decoder.readToken(); + #processArray(parser: Parser): Uint8Array { + parser.readToken(); const items: Uint8Array[] = []; - while (decoder.peekKind() !== KIND.ARRAY_END) { - items.push(this.#processValue(decoder)); + while (parser.peekKind() !== KIND.ARRAY_END) { + items.push(this.#processValue(parser)); } - decoder.readToken(); + parser.readToken(); const OPEN = new Uint8Array([ASCII.OPENING_BRACKET]); const CLOSE = new Uint8Array([ASCII.CLOSING_BRACKET]); diff --git a/src/common/constants.ts b/src/common/constants.ts index a090798..394e876 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,5 @@ -/** ASCII byte values used for JSON parsing and encoding. */ -const ASCII = { +/** ASCII byte values used to recognize and emit JSON syntax. */ +export const ASCII = { TAB: 0x09, LINE_FEED: 0x0A, CARRIAGE_RETURN: 0x0D, @@ -52,8 +52,8 @@ const ASCII = { DELETE: 0x7F, } as const; -/** Unicode escape sequences for safe HTML and JavaScript embedding. */ -const UNICODE = { +/** Unicode escape sequences used when embedding JSON in HTML or JavaScript. */ +export const UNICODE = { OPEN_ANGLED_BRACKET: "\\u003c", CLOSE_ANGLED_BRACKET: "\\u003e", AMPERSAND: "\\u0026", @@ -61,8 +61,8 @@ const UNICODE = { PARAGRAPH_SEPARATOR: "\\u2029", } as const; -/** String discriminants identifying the structural role of a JSON token. */ -const KIND = { +/** String discriminants identifying the kind of a JSON token. */ +export const KIND = { NULL: "null", FALSE: "false", TRUE: "true", @@ -74,17 +74,19 @@ const KIND = { ARRAY_END: "]", } as const; -/** Maximum JSON nesting depth supported by the decoder and encoder. */ -const MAX_NESTING_DEPTH = 10_000; +/** Maximum JSON nesting depth accepted by the decoder and encoder. */ +export const MAX_NESTING_DEPTH = 10_000; -/** Default option values for decoding. */ -const DEFAULT_DECODER_OPTIONS = { +/** Default options applied when constructing a decoder or decoder stream. */ +export const DEFAULT_DECODER_OPTIONS = { allowDuplicateNames: false, allowInvalidUTF8: false, } as const; -/** Default option values for encoding. */ -const DEFAULT_ENCODER_OPTIONS = { +/** Default options applied when constructing an encoder or encoder stream. */ +export const DEFAULT_ENCODER_OPTIONS = { + allowInvalidUTF8: false, + allowDuplicateNames: false, escapeForHTML: false, escapeForJS: false, canonicalizeRawNumbers: false, @@ -95,34 +97,22 @@ const DEFAULT_ENCODER_OPTIONS = { indentPrefix: "", } as const; -/** JSON Path identifier types. */ -const IDENTIFIER = { +/** Numeric identifiers used by the JSON Path parser. */ +export const IDENTIFIER = { ROOT: 0, CURRENT: 1, } as const; -/** JSON Path segment kinds. */ -const SEGMENT = { +/** Numeric segment kinds used by the JSON Path parser. */ +export const SEGMENT = { CHILD: 0, DESCENDANT: 1, } as const; -/** JSON Path selector kinds. */ -const SELECTOR = { +/** Numeric selector kinds used by the JSON Path parser. */ +export const SELECTOR = { NAME: 0, WILDCARD: 1, INDEX: 2, ARRAY_SLICE: 3, } as const; - -export { - ASCII, - DEFAULT_DECODER_OPTIONS, - DEFAULT_ENCODER_OPTIONS, - IDENTIFIER, - KIND, - MAX_NESTING_DEPTH, - SEGMENT, - SELECTOR, - UNICODE, -}; diff --git a/src/common/errors.ts b/src/common/errors.ts index 6e96ec6..f552fe9 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -6,13 +6,15 @@ * where `within ` is omitted when `pointer` is an empty string. */ class SyntacticError extends SyntaxError { - pointer: string; - offset: number; + #pointer: string; + #offset: number; /** - * @param message - A human-readable description of the syntax error. - * @param pointer - JSON Pointer to the location in the document. - * @param offset - Byte offset at which the error was detected. + * Creates a new `SyntacticError` instance. + * + * @param message A human-readable description of the syntax error. + * @param pointer JSON Pointer to the location in the document. + * @param offset Byte offset at which the error was detected. */ constructor(message: string, pointer: string, offset: number) { const within = pointer ? ` within ${pointer}` : ""; @@ -21,8 +23,18 @@ class SyntacticError extends SyntaxError { super(`${message}${within}${at}`); this.name = "SyntacticError"; - this.pointer = pointer; - this.offset = offset; + this.#pointer = pointer; + this.#offset = offset; + } + + /** JSON Pointer identifying the location of the syntax error. */ + get pointer(): string { + return this.#pointer; + } + + /** Byte offset at which the syntax error was detected. */ + get offset(): number { + return this.#offset; } } diff --git a/src/types/path.ts b/src/common/types.ts similarity index 75% rename from src/types/path.ts rename to src/common/types.ts index 80fc017..5819291 100644 --- a/src/types/path.ts +++ b/src/common/types.ts @@ -1,4 +1,7 @@ -import { IDENTIFIER, SEGMENT, SELECTOR } from "#src/common/constants"; +import { IDENTIFIER, KIND, SEGMENT, SELECTOR } from "#src/common/constants"; + +/** A union of the string values used to identify JSON token kinds. */ +export type Kind = typeof KIND[keyof typeof KIND]; export type NameSelector = { type: typeof SELECTOR.NAME; name: string }; diff --git a/src/index.ts b/src/index.ts index 842ff66..b1396f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,17 @@ export type { JSONTextDecoderOptions } from "#src/api/decoder"; export type { JSONTextEncoderOptions } from "#src/api/encoder"; -export type { JSONTextDecoderStreamOptions } from "#src/libs/stream-decoder"; -export type { JSONTextEncoderStreamOptions } from "#src/libs/stream-encoder"; -export type { JSONTextLineStreamOptions } from "#src/libs/stream-line"; -export type { JSONTextSelectorStreamOptions } from "#src/libs/stream-selector"; - -export type { Kind } from "#src/types/kind"; +export type { JSONTextDecoderStreamOptions } from "#src/lib/stream-decoder"; +export type { JSONTextEncoderStreamOptions } from "#src/lib/stream-encoder"; +export type { JSONTextLineStreamOptions } from "#src/lib/stream-line"; +export type { JSONTextSelectorStreamOptions } from "#src/lib/stream-selector"; export { default as JSONTextDecoder } from "#src/api/decoder"; export { default as JSONTextEncoder } from "#src/api/encoder"; -export { default as Token } from "#src/modules/token"; -export { default as Value } from "#src/modules/value"; - -export { default as JSONTextDecoderStream } from "#src/libs/stream-decoder"; -export { default as JSONTextEncoderStream } from "#src/libs/stream-encoder"; -export { default as JSONTextLineStream } from "#src/libs/stream-line"; -export { default as JSONTextSelectorStream } from "#src/libs/stream-selector"; +export { Kind } from "#src/api/kind"; +export { default as Token } from "#src/api/token"; +export { default as Value } from "#src/api/value"; -export { KIND } from "#src/common/constants"; -export { SyntacticError } from "#src/common/errors"; +export { default as JSONTextDecoderStream } from "#src/lib/stream-decoder"; +export { default as JSONTextEncoderStream } from "#src/lib/stream-encoder"; +export { default as JSONTextLineStream } from "#src/lib/stream-line"; +export { default as JSONTextSelectorStream } from "#src/lib/stream-selector"; diff --git a/src/libs/stream-decoder.ts b/src/lib/stream-decoder.ts similarity index 68% rename from src/libs/stream-decoder.ts rename to src/lib/stream-decoder.ts index b9e20f0..ad1c94e 100644 --- a/src/libs/stream-decoder.ts +++ b/src/lib/stream-decoder.ts @@ -1,14 +1,14 @@ +import type { JSONTextDecoderOptions } from "#src/api/decoder"; +import Token from "#src/api/token"; import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants"; -import Decoder from "#src/modules/decoder"; -import type Token from "#src/modules/token"; -import type { DecoderOptions } from "#src/types/options"; +import Parser from "#src/modules/parser"; /** * Options for {@link JSONTextDecoderStream}. * * @public */ -type JSONTextDecoderStreamOptions = DecoderOptions & { +type JSONTextDecoderStreamOptions = JSONTextDecoderOptions & { /** Queuing strategy for the writable side. */ writableStrategy?: QueuingStrategy; /** Queuing strategy for the readable side. */ @@ -31,10 +31,12 @@ type JSONTextDecoderStreamOptions = DecoderOptions & { * ``` */ class JSONTextDecoderStream extends TransformStream { - #decoder: Decoder; + #parser: Parser; /** - * @param options - Decoder and queuing strategy options. + * Creates a stream that decodes byte chunks into JSON tokens. + * + * @param options Decoder and queuing strategy options. */ constructor(options: JSONTextDecoderStreamOptions = {}) { const { writableStrategy, readableStrategy, ...rest } = options; @@ -43,28 +45,26 @@ class JSONTextDecoderStream extends TransformStream { super( { transform: (chunk, controller) => { - this.#decoder.push(chunk); + this.#parser.push(chunk); this.#drain(controller); }, flush: (controller) => { - this.#decoder.end(); + this.#parser.close(); this.#drain(controller); - this.#decoder.checkEOF(); + this.#parser.checkEOF(); }, }, writableStrategy, readableStrategy, ); - this.#decoder = new Decoder(new Uint8Array(), decoderOptions); + this.#parser = new Parser(decoderOptions); } - /** - * Drains all available tokens from the decoder into the readable side. - */ + /** Drains all available tokens from the decoder into the readable side. */ #drain(controller: TransformStreamDefaultController): void { - for (let token; (token = this.#decoder.readToken()) !== undefined;) { - controller.enqueue(token); + for (let bytes; (bytes = this.#parser.readToken()) !== undefined;) { + controller.enqueue(new Token(bytes)); } } } diff --git a/src/libs/stream-encoder.ts b/src/lib/stream-encoder.ts similarity index 73% rename from src/libs/stream-encoder.ts rename to src/lib/stream-encoder.ts index 8af0012..d1f04b3 100644 --- a/src/libs/stream-encoder.ts +++ b/src/lib/stream-encoder.ts @@ -1,14 +1,14 @@ +import type { JSONTextEncoderOptions } from "#src/api/encoder"; +import type Token from "#src/api/token"; import { DEFAULT_ENCODER_OPTIONS } from "#src/common/constants"; -import Encoder from "#src/modules/encoder"; -import type Token from "#src/modules/token"; -import type { EncoderOptions } from "#src/types/options"; +import Serializer from "#src/modules/serializer"; /** * Options for {@link JSONTextEncoderStream}. * * @public */ -type JSONTextEncoderStreamOptions = EncoderOptions & { +type JSONTextEncoderStreamOptions = JSONTextEncoderOptions & { /** Queuing strategy for the writable side. */ writableStrategy?: QueuingStrategy; /** Queuing strategy for the readable side. */ @@ -37,10 +37,12 @@ type JSONTextEncoderStreamOptions = EncoderOptions & { * ``` */ class JSONTextEncoderStream extends TransformStream { - #encoder: Encoder; + #serializer: Serializer; /** - * @param options - Encoder and queuing strategy options. + * Creates a stream that encodes JSON tokens into byte chunks. + * + * @param options Encoder and queuing strategy options. */ constructor(options: JSONTextEncoderStreamOptions = {}) { const { writableStrategy, readableStrategy, ...rest } = options; @@ -49,7 +51,7 @@ class JSONTextEncoderStream extends TransformStream { super( { transform: (token, controller) => { - this.#encoder.writeToken(token); + this.#serializer.writeToken(token.kind, token.bytes); this.#drain(controller); }, flush: (controller) => { @@ -60,14 +62,12 @@ class JSONTextEncoderStream extends TransformStream { readableStrategy, ); - this.#encoder = new Encoder(encoderOptions); + this.#serializer = new Serializer(encoderOptions); } - /** - * Flushes accumulated bytes from the encoder into the readable side. - */ + /** Flushes accumulated bytes from the encoder into the readable side. */ #drain(controller: TransformStreamDefaultController): void { - const bytes = this.#encoder.takeBytes(); + const bytes = this.#serializer.takeBytes(); if (bytes.length > 0) { controller.enqueue(bytes); diff --git a/src/libs/stream-line.ts b/src/lib/stream-line.ts similarity index 69% rename from src/libs/stream-line.ts rename to src/lib/stream-line.ts index e5ce67d..44b4a55 100644 --- a/src/libs/stream-line.ts +++ b/src/lib/stream-line.ts @@ -1,14 +1,14 @@ +import type { JSONTextDecoderOptions } from "#src/api/decoder"; +import Value from "#src/api/value"; import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants"; -import Decoder from "#src/modules/decoder"; -import type Value from "#src/modules/value"; -import type { DecoderOptions } from "#src/types/options"; +import Parser from "#src/modules/parser"; /** * Options for {@link JSONTextLineStream}. * * @public */ -type JSONTextLineStreamOptions = DecoderOptions & { +type JSONTextLineStreamOptions = JSONTextDecoderOptions & { /** Queuing strategy for the writable side. */ writableStrategy?: QueuingStrategy; /** Queuing strategy for the readable side. */ @@ -32,10 +32,12 @@ type JSONTextLineStreamOptions = DecoderOptions & { * ``` */ class JSONTextLineStream extends TransformStream { - #decoder: Decoder; + #parser: Parser; /** - * @param options - Decoder and queuing strategy options. + * Creates a stream that decodes byte chunks into complete JSON values. + * + * @param options Decoder and queuing strategy options. */ constructor(options: JSONTextLineStreamOptions = {}) { const { writableStrategy, readableStrategy, ...rest } = options; @@ -44,11 +46,11 @@ class JSONTextLineStream extends TransformStream { super( { transform: (chunk, controller) => { - this.#decoder.push(chunk); + this.#parser.push(chunk); this.#drain(controller); }, flush: (controller) => { - this.#decoder.end(); + this.#parser.close(); this.#drain(controller); }, }, @@ -56,15 +58,13 @@ class JSONTextLineStream extends TransformStream { readableStrategy, ); - this.#decoder = new Decoder(new Uint8Array(), decoderOptions); + this.#parser = new Parser(decoderOptions); } - /** - * Drains all available values from the decoder into the readable side. - */ + /** Drains all available values from the decoder into the readable side. */ #drain(controller: TransformStreamDefaultController): void { - for (let value; (value = this.#decoder.readValue()) !== undefined;) { - controller.enqueue(value); + for (let bytes; (bytes = this.#parser.readValue()) !== undefined;) { + controller.enqueue(new Value(bytes)); } } } diff --git a/src/libs/stream-selector.ts b/src/lib/stream-selector.ts similarity index 76% rename from src/libs/stream-selector.ts rename to src/lib/stream-selector.ts index c6fa064..01f1ca0 100644 --- a/src/libs/stream-selector.ts +++ b/src/lib/stream-selector.ts @@ -1,9 +1,9 @@ +import type { JSONTextDecoderOptions } from "#src/api/decoder"; +import Value from "#src/api/value"; import { DEFAULT_DECODER_OPTIONS, KIND, MAX_NESTING_DEPTH } from "#src/common/constants"; -import Decoder from "#src/modules/decoder"; +import Parser from "#src/modules/parser"; import type { Matcher } from "#src/modules/path"; import Path from "#src/modules/path"; -import Value from "#src/modules/value"; -import type { DecoderOptions } from "#src/types/options"; import { encodeText } from "#src/utils/text"; /** @@ -11,7 +11,7 @@ import { encodeText } from "#src/utils/text"; * * @public */ -type JSONTextSelectorStreamOptions = DecoderOptions & { +type JSONTextSelectorStreamOptions = JSONTextDecoderOptions & { /** Queuing strategy for the writable side. */ writableStrategy?: QueuingStrategy; /** Queuing strategy for the readable side. */ @@ -31,8 +31,8 @@ type JSONTextSelectorStreamOptions = DecoderOptions & { * - **Descendant Segment**: `..` * - **Name Selector**: `.name` or `['name']` * - **Wildcard Selector**: `.*` or `[*]` - * - **Index Selector (positive)**: `[1]` - * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]` + * - **Index Selector (non-negative)**: `[1]` + * - **Array Slice Selector (non-negative)**: `[0:5]` or `[::2]` * * @see https://www.rfc-editor.org/rfc/rfc9535 * @public @@ -44,7 +44,7 @@ type JSONTextSelectorStreamOptions = DecoderOptions & { * ``` */ class JSONTextSelectorStream extends TransformStream { - #decoder: Decoder; + #parser: Parser; #matcher: Matcher; #indexes: Uint32Array; #types: Uint8Array; @@ -52,8 +52,10 @@ class JSONTextSelectorStream extends TransformStream { #depth: number; /** - * @param input - A JSON Path expression string (subset of RFC 9535) selecting which values to emit. - * @param options - Decoder and queuing strategy options. + * Creates a stream that emits values selected by a JSON Path expression. + * + * @param input A JSON Path expression selecting which values to emit. + * @param options Decoder and queuing strategy options. * @throws {SyntaxError} If the path expression is invalid. */ constructor(input: string, options: JSONTextSelectorStreamOptions = {}) { @@ -63,20 +65,20 @@ class JSONTextSelectorStream extends TransformStream { super( { transform: (chunk, controller) => { - this.#decoder.push(chunk); + this.#parser.push(chunk); this.#drain(controller); }, flush: (controller) => { - this.#decoder.end(); + this.#parser.close(); this.#drain(controller); - this.#decoder.checkEOF(); + this.#parser.checkEOF(); }, }, writableStrategy, readableStrategy, ); - this.#decoder = new Decoder(new Uint8Array(), decoderOptions); + this.#parser = new Parser(decoderOptions); this.#matcher = new Path(encodeText(input)).createMatcher(); this.#indexes = new Uint32Array(MAX_NESTING_DEPTH); this.#types = new Uint8Array(MAX_NESTING_DEPTH); @@ -84,20 +86,17 @@ class JSONTextSelectorStream extends TransformStream { this.#depth = 0; } - /** - * Reads decoder output step by step, advancing the path matcher, and - * enqueues values at positions that satisfy the path. - */ + /** Reads decoder output and enqueues values at positions that satisfy the path. */ #drain(controller: TransformStreamDefaultController): void { while (true) { - const kind = this.#decoder.peekKind(); + const kind = this.#parser.peekKind(); if (kind === undefined) { break; } if (kind === KIND.OBJECT_END || kind === KIND.ARRAY_END) { - this.#decoder.readToken(); + this.#parser.readToken(); if (this.#depth > 0 && this.#pushs[this.#depth - 1] > 0) { this.#matcher.pop(); @@ -118,8 +117,8 @@ class JSONTextSelectorStream extends TransformStream { continue; } - if (this.#decoder.needObjectName()) { - if (this.#decoder.readToken() === undefined) { + if (this.#parser.needObjectName) { + if (this.#parser.readToken() === undefined) { return; } @@ -130,7 +129,7 @@ class JSONTextSelectorStream extends TransformStream { if (this.#depth > 0) { const isObject = this.#types[this.#depth - 1] === 1; - const step = isObject ? this.#decoder.lastObjectName() : this.#indexes[this.#depth - 1]; + const step = isObject ? this.#parser.lastObjectName : this.#indexes[this.#depth - 1]; this.#matcher.push(step); pushed = true; @@ -139,9 +138,9 @@ class JSONTextSelectorStream extends TransformStream { const isContainer = kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN; if (this.#matcher.isAccepting()) { - const value = this.#decoder.readValue(); + const bytes = this.#parser.readValue(); - if (value === undefined) { + if (bytes === undefined) { if (pushed) { this.#matcher.pop(); } @@ -149,7 +148,7 @@ class JSONTextSelectorStream extends TransformStream { return; } - controller.enqueue(new Value(value.bytes, this.#decoder.stackPointer(-1).toString())); + controller.enqueue(new Value(bytes, this.#parser.stackPointer(-1).toString())); if (pushed) { this.#matcher.pop(); @@ -167,7 +166,7 @@ class JSONTextSelectorStream extends TransformStream { } if (!isContainer || this.#matcher.isDead()) { - if (!this.#decoder.skipValue()) { + if (!this.#parser.skipValue()) { if (pushed) { this.#matcher.pop(); } @@ -190,7 +189,7 @@ class JSONTextSelectorStream extends TransformStream { continue; } - this.#decoder.readToken(); + this.#parser.readToken(); this.#types[this.#depth] = kind === KIND.OBJECT_BEGIN ? 1 : 0; this.#indexes[this.#depth] = 0; this.#pushs[this.#depth] = pushed ? 1 : 0; diff --git a/src/modules/automaton.ts b/src/modules/automaton.ts index 1831015..d48bd00 100644 --- a/src/modules/automaton.ts +++ b/src/modules/automaton.ts @@ -1,25 +1,23 @@ import { MAX_NESTING_DEPTH } from "#src/common/constants"; +import type { Kind } from "#src/common/types"; import Entry from "#src/modules/entry"; -import type { Kind } from "#src/types/kind"; - -/** - * A state machine that enforces JSON syntax rules and tracks the structural nesting depth. - * It ensures that the sequence of incoming tokens adheres strictly to `RFC 8259`. - * - * @internal - */ + +/** A state machine that enforces JSON syntax rules and tracks structural nesting depth. */ class Automaton { #last: Entry; #stack: Entry[]; - /** - * Creates a new Automaton instance. - */ + /** Creates a new Automaton instance. */ constructor() { this.#last = new Entry("array"); this.#stack = []; } + /** The current structural nesting depth. */ + get depth(): number { + return this.#stack.length + 1; + } + /** The current entry at the deepest active parsing context. */ get last(): Entry { return this.#last; @@ -36,19 +34,14 @@ class Automaton { * @throws {SyntaxError} If the current entry requires an object name. */ appendLiteral(): void { - if (this.#last.needObjectName()) { + if (this.#last.needsObjectName) { throw new SyntaxError("object name must be a string"); } this.#last.increment(); } - /** - * Appends a string value to the current context. - * - * Unlike {@link appendLiteral}, strings are valid in both object-name and - * value positions, so no structural guard is applied. - */ + /** Appends a string value without applying an object-name guard. */ appendString(): void { this.#last.increment(); } @@ -62,15 +55,6 @@ class Automaton { this.appendLiteral(); } - /** - * Returns the current nesting depth of the structural state. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#stack.length + 1; - } - /** * Retrieves the structural entry at the specified depth index. * @@ -92,7 +76,7 @@ class Automaton { * @throws {RangeError} If the maximum nesting depth is exceeded. */ pushObject(): void { - if (this.#last.needObjectName()) { + if (this.#last.needsObjectName) { throw new SyntaxError("object name must be a string"); } @@ -112,11 +96,11 @@ class Automaton { * @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value. */ popObject(): void { - if (!this.#last.isObject()) { + if (!this.#last.isObject) { throw new SyntaxError("mismatching } for object"); } - if (this.#last.needObjectValue()) { + if (this.#last.needsObjectValue) { throw new SyntaxError("missing value after object name"); } @@ -134,7 +118,7 @@ class Automaton { * @throws {RangeError} If the maximum nesting depth is exceeded. */ pushArray(): void { - if (this.#last.needObjectName()) { + if (this.#last.needsObjectName) { throw new SyntaxError("object name must be a string"); } @@ -154,7 +138,7 @@ class Automaton { * @throws {SyntaxError} If the current context is not an array, or if it's the implicit top-level array being closed. */ popArray(): void { - if (!this.#last.isArray() || this.#stack.length === 0) { + if (!this.#last.isArray || this.#stack.length === 0) { throw new SyntaxError("mismatching structural token for object or array"); } @@ -165,14 +149,20 @@ class Automaton { } } + /** Resets the automaton to its initial top-level array state. */ + reset(): void { + this.#last = new Entry("array"); + this.#stack.length = 0; + } + /** * Determines whether an implicit delimiter is required before the next token. * * @param kind The kind of the next incoming token. * @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected. */ - needDelimiter(kind: Kind): ":" | "," | null { - if (this.#last.needImplicitColon()) { + requiredDelimiter(kind: Kind): ":" | "," | null { + if (this.#last.needsImplicitColon) { return ":"; } diff --git a/src/modules/cursor.ts b/src/modules/cursor.ts index 8a1045e..741874a 100644 --- a/src/modules/cursor.ts +++ b/src/modules/cursor.ts @@ -1,217 +1,89 @@ -/** - * A sliding window cursor used by the Decoder to navigate through incoming stream chunks. - * - * @internal - */ +/** Maintains the retained bytes and absolute offset of an incremental input stream. */ class Cursor { - #baseOffset: number; #bytes: Uint8Array; #ended: boolean; #owned: boolean; - #previousStart: number; - #previousEnd: number; - #peekPosition: number; - #peekError: Error | null; + #offset: number; - /** - * Creates a new Cursor instance starting with an initial chunk of bytes. - * - * @param bytes The initial Uint8Array chunk to read from. - */ - constructor(bytes: Uint8Array) { - this.#baseOffset = 0; - this.#bytes = bytes; + /** Creates a new Cursor instance. */ + constructor() { + this.#bytes = new Uint8Array(); this.#ended = false; + this.#offset = 0; this.#owned = false; - this.#previousStart = 0; - this.#previousEnd = 0; - this.#peekPosition = 0; - this.#peekError = null; - } - - /** - * Indicates whether the underlying stream has completely ended (EOF). - */ - get ended(): boolean { - return this.#ended; } - /** - * The current active byte buffer. - */ + /** The current active byte buffer. */ get bytes(): Uint8Array { return this.#bytes; } - /** - * The start position of the previous chunk. - */ - get previousStart(): number { - return this.#previousStart; - } - - set previousStart(value: number) { - this.#previousStart = value; - } - - /** - * The end position of the previous chunk. - */ - get previousEnd(): number { - return this.#previousEnd; - } - - set previousEnd(value: number) { - this.#previousEnd = value; - } - - /** - * The current peek position within the byte buffer. - */ - get peekPosition(): number { - return this.#peekPosition; - } - - set peekPosition(value: number) { - this.#peekPosition = value; - } - - /** - * The current peek error, if any. - */ - get peekError(): Error | null { - return this.#peekError; - } - - set peekError(value: Error | null) { - this.#peekError = value; + /** Indicates whether the underlying stream has completely ended. */ + get ended(): boolean { + return this.#ended; } /** - * Appends a newly received chunk of bytes to the cursor. + * Appends a newly received chunk and discards previously consumed data. * - * @param bytes The new Uint8Array chunk arriving from the stream. + * @param chunk New bytes from the stream. + * @param start Number of bytes already consumed from the current buffer. */ - appendBytes(bytes: Uint8Array): void { - const unread = this.unreadBytes(); - const start = this.#previousEnd; - - if (unread.length > 0) { - const capacity = start + unread.length + bytes.length; - const length = unread.length + bytes.length; - - if (this.#owned && capacity <= this.#bytes.buffer.byteLength) { - const buffer = this.#bytes.buffer; - - this.#bytes = new Uint8Array(buffer, 0, capacity); - this.#bytes.set(bytes, start + unread.length); + append(chunk: Uint8Array, start: number): void { + const retainedLength = this.#bytes.length - start; + const newLength = retainedLength + chunk.length; - return; - } + this.#offset += start; - if (this.#owned && length <= this.#bytes.buffer.byteLength) { - const buffer = this.#bytes.buffer; - const view = new Uint8Array(buffer); + if (retainedLength === 0) { + this.#bytes = chunk; + this.#owned = false; - view.copyWithin(0, start, start + unread.length); + return; + } - this.#bytes = new Uint8Array(buffer, 0, length); - this.#bytes.set(bytes, unread.length); - } else { - const capacity = this.#owned ? this.#bytes.buffer.byteLength : 0; - const array = new Uint8Array(Math.max(length, capacity * 2)); + if (this.#owned && newLength <= this.#bytes.buffer.byteLength) { + const buffer = this.#bytes.buffer; + const current = new Uint8Array(buffer); - array.set(unread, 0); - array.set(bytes, unread.length); + current.copyWithin(0, start, start + retainedLength); - this.#bytes = new Uint8Array(array.buffer, 0, length); - this.#owned = true; - } + this.#bytes = new Uint8Array(buffer, 0, newLength); + this.#bytes.set(chunk, retainedLength); } else { - this.#bytes = bytes; - this.#owned = false; - } - if (this.#peekPosition > 0) { - this.#peekPosition -= this.#previousEnd; - } + const capacity = this.#owned ? this.#bytes.buffer.byteLength : 0; + const doubling = Math.max(newLength, capacity * 2); + const next = new Uint8Array(doubling); - this.#baseOffset += this.#previousEnd; - this.#previousStart = 0; - this.#previousEnd = 0; - } + next.set(this.#bytes.subarray(start), 0); + next.set(chunk, retainedLength); - /** - * Discards the previously processed token bytes by advancing the start pointer. - * This signals that the current memory region is no longer needed. - */ - discardPrevious(): void { - if (this.#previousStart < this.#previousEnd && this.#previousStart < this.#bytes.length) { - this.#previousStart = this.#previousEnd; + this.#bytes = new Uint8Array(next.buffer, 0, newLength); + this.#owned = true; } } /** - * Marks the cursor as ended, indicating that no more bytes will be received. - */ - end(): void { - this.#ended = true; - } - - /** - * Checks if it has reached the end of the current internal buffer - * and requires more data from the stream to continue. + * Calculates the absolute stream offset for a position in the retained buffer. * - * @param position The current index being processed. - * @returns `true` if more chunks need to be pulled from the stream, `false` otherwise. + * @param position The position within the current retained buffer. + * @returns The byte offset from the beginning of the stream. */ - needMore(position: number): boolean { - return position === this.#bytes.length; + at(position: number): number { + return this.#offset + position; } - /** - * Calculates the absolute offset in the entire stream for a given local buffer position. - * - * @param position The local index within the current chunk. - * @returns The global byte offset from the very beginning of the stream. - */ - offsetAt(position: number): number { - return this.#baseOffset + position; - } - - /** - * Extracts the bytes of the most recently processed token. - * - * @returns A subarray representing the previous token. - */ - previousBytes(): Uint8Array { - return this.#bytes.subarray(this.#previousStart, this.#previousEnd); - } - - /** - * Calculates the absolute start offset of the most recently processed token. - * - * @returns The global byte offset from the very beginning of the stream. - */ - previousOffsetStart(): number { - return this.#baseOffset + this.#previousStart; - } - - /** - * Calculates the absolute end offset of the most recently processed token. - * - * @returns The global byte offset from the very beginning of the stream. - */ - previousOffsetEnd(): number { - return this.#baseOffset + this.#previousEnd; + /** Marks the cursor as ended so no more bytes can be received. */ + close(): void { + this.#ended = true; } - /** - * Extracts the bytes that have been read but not yet discarded, representing the unread portion of the buffer. - * - * @returns A subarray of the current buffer containing all unread bytes. - */ - unreadBytes(): Uint8Array { - return this.#bytes.subarray(this.#previousEnd, this.#bytes.length); + /** Resets the cursor, clearing its buffer and offsets. */ + reset(): void { + this.#bytes = new Uint8Array(); + this.#ended = false; + this.#offset = 0; + this.#owned = false; } } diff --git a/src/modules/decoder.ts b/src/modules/decoder.ts deleted file mode 100644 index d496856..0000000 --- a/src/modules/decoder.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { ASCII, KIND } from "#src/common/constants"; -import { SyntacticError } from "#src/common/errors"; -import Cursor from "#src/modules/cursor"; -import type Pointer from "#src/modules/pointer"; -import State from "#src/modules/state"; -import Token from "#src/modules/token"; -import Value from "#src/modules/value"; -import type { Kind } from "#src/types/kind"; -import type { DecoderOptions } from "#src/types/options"; -import { normalize } from "#src/utils/kind"; -import { decodeText } from "#src/utils/text"; -import { - consumeFalse, - consumeNull, - consumeNumber, - consumeSimpleNumber, - consumeSimpleString, - consumeStringResumable, - consumeTrue, - consumeWhitespace, -} from "#src/utils/wire"; - -/** - * Low-level streaming JSON decoder that reads UTF-8 encoded bytes, - * navigates through them via a {@link Cursor}, and enforces RFC 8259 - * structural rules through a {@link State} machine. - * - * @internal - */ -class Decoder { - #offset: number; - #cursor: Cursor; - #state: State; - #options: DecoderOptions; - - /** - * Creates a new Decoder with an initial byte buffer and options. - * - * @param bytes Initial UTF-8 bytes to decode. - * @param options Decoder configuration options. - */ - constructor(bytes: Uint8Array, options: DecoderOptions) { - this.#offset = 0; - this.#cursor = new Cursor(bytes); - this.#state = new State(options); - this.#options = options; - } - - /** - * Asserts that exactly one complete JSON value has been consumed with no - * trailing content. - * - * @throws {SyntaxError} If the decoder is still inside a nested structure, - * or if non-whitespace characters remain after the value. - */ - checkEOF(): void { - if (this.#state.depth() > 1) { - throw new SyntaxError(`Unexpected end of input`); - } - - const position = consumeWhitespace(this.#cursor.bytes, this.#cursor.previousEnd); - - if (!this.#cursor.needMore(position)) { - throw new SyntaxError(`Unexpected trailing characters at position ${position}`); - } - } - - /** - * Returns the current nesting depth of the structural state. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#state.depth(); - } - - /** - * Signals that no more bytes will arrive (end of stream). - * - * After calling this, number tokens no longer require a trailing byte to - * confirm their end. - */ - end(): void { - this.#cursor.end(); - } - - /** - * Returns the absolute byte offset at the end of the last consumed token. - * - * @returns The global byte offset from the start of the stream. - */ - inputOffset(): number { - return this.#cursor.previousOffsetEnd(); - } - - /** - * Checks whether the current context expects an object key. - * - * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. - */ - needObjectName(): boolean { - return this.#state.needObjectName(); - } - - /** - * Returns the most recently consumed object key string. - * - * @returns The current object property name, or an empty string if not inside an object. - */ - lastObjectName(): string { - return this.#state.lastObjectName(); - } - - /** - * Appends the next byte chunk from the stream to the input buffer. - * - * @param bytes The incoming UTF-8 bytes. - */ - push(bytes: Uint8Array): void { - this.#cursor.appendBytes(bytes); - } - - /** - * Peeks at the kind of the next token without consuming it. - * - * Skips leading whitespace and validates any required structural delimiter - * (`","` or `":"`). - * The result is cached until the next call to {@link readToken}, - * {@link readValue}, or {@link skipValue}. - * - * @returns The {@link Kind} of the next token, or `undefined` if more bytes are needed to determine it. - * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered. - */ - peekKind(): Kind | undefined { - if (this.#cursor.peekPosition > 0) { - if (this.#cursor.peekError) { - throw this.#cursor.peekError; - } - - const byte = this.#cursor.bytes[this.#cursor.peekPosition]; - const kind = normalize(byte); - - return kind; - } - - this.#cursor.discardPrevious(); - - let position = consumeWhitespace(this.#cursor.bytes, this.#cursor.previousEnd); - let delimiter = null; - - if (this.#cursor.needMore(position)) { - return undefined; - } - - const byte = this.#cursor.bytes[position]; - - if (byte === ASCII.COLON || byte === ASCII.COMMA) { - delimiter = String.fromCharCode(byte); - position++; - position = consumeWhitespace(this.#cursor.bytes, position); - - if (this.#cursor.needMore(position)) { - return undefined; - } - } - - const kind = normalize(this.#cursor.bytes[position]); - - if (!kind) { - const message = `invalid character`; - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(position); - const error = new SyntacticError(message, pointer, offset); - - this.#cursor.peekError = error; - - throw error; - } - - const expected = this.#state.needDelimiter(kind); - - if (expected !== delimiter) { - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(position); - const error = new SyntacticError("invalid delimiter", pointer, offset); - - this.#cursor.peekError = error; - - throw error; - } - - this.#cursor.peekPosition = position; - - return kind; - } - - /** - * Resets the decoder to its initial state, discarding all buffered bytes - * and structural state. - */ - reset(): void { - this.#offset = 0; - this.#cursor = new Cursor(new Uint8Array()); - this.#state = new State(this.#options); - } - - /** - * Consumes and returns the next token from the input buffer. - * - * For structural tokens (`{`, `}`, `[`, `]`), the state machine is updated - * accordingly. For string tokens that serve as object names, the name is - * recorded in the state. - * - * @returns The next {@link Token}, or `undefined` if more bytes are needed. - * @throws {SyntacticError} If the token is malformed or structurally invalid. - */ - readToken(): Token | undefined { - const kind = this.peekKind(); - - if (kind === undefined) { - return undefined; - } - - const start = this.#cursor.peekPosition; - let size = 0; - - try { - switch (kind) { - case KIND.NULL: - size = this.#consumeNull(start); - break; - case KIND.TRUE: - size = this.#consumeTrue(start); - break; - case KIND.FALSE: - size = this.#consumeFalse(start); - break; - case KIND.STRING: - size = this.#consumeString(start); - break; - case KIND.NUMBER: - size = this.#consumeNumber(start); - break; - case KIND.OBJECT_BEGIN: - size = this.#consumeObjectBegin(); - break; - case KIND.OBJECT_END: - size = this.#consumeObjectEnd(); - break; - case KIND.ARRAY_BEGIN: - size = this.#consumeArrayBegin(); - break; - case KIND.ARRAY_END: - size = this.#consumeArrayEnd(); - break; - } - } catch (error) { - if (error instanceof SyntacticError) { - throw error; - } - - if (error instanceof SyntaxError) { - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(start); - - throw new SyntacticError(error.message, pointer, offset); - } - - throw error; - } - - if (size === 0) { - return undefined; - } - - this.#cursor.peekPosition = 0; - this.#cursor.previousStart = start; - this.#cursor.previousEnd = start + size; - - return new Token(this.#cursor.previousBytes()); - } - - /** - * Consumes and returns the next complete JSON value from the input buffer. - * - * For composite values (objects and arrays), the raw bytes are captured as - * a single unit without entering the nested structure. For string values that - * serve as object names, the name is recorded in the state. - * - * @returns The next {@link Value}, or `undefined` if more bytes are needed. - * @throws {SyntacticError} If the value is malformed or structurally invalid. - */ - readValue(): Value | undefined { - const kind = this.peekKind(); - - if (kind === undefined) { - return undefined; - } - - const start = this.#cursor.peekPosition; - const size = this.#consumeValue(start); - - if (size === 0) { - return undefined; - } - - const bytes = this.#cursor.bytes.subarray(start, start + size); - - if (kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN) { - try { - JSON.parse(decodeText(bytes, !this.#options.allowInvalidUTF8)); - } catch (error) { - if (error instanceof Error) { - this.#cursor.peekError = error; - } - - throw error; - } - } - - if (kind === KIND.STRING) { - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - - if (this.#state.needObjectName()) { - this.#state.setLast(parsed); - } - - this.#state.appendString(); - } else if (kind === KIND.NUMBER) { - this.#state.appendNumber(); - } else { - this.#state.appendLiteral(); - } - - this.#cursor.peekPosition = 0; - this.#cursor.previousStart = start; - this.#cursor.previousEnd = start + size; - - return new Value(bytes); - } - - /** - * Consumes the next complete JSON value without returning its bytes. - * - * @returns `true` if a value was consumed, `false` if more bytes are needed. - * @throws {SyntacticError} If the value is malformed or structurally invalid. - */ - skipValue(): boolean { - const kind = this.peekKind(); - - if (kind === undefined) { - return false; - } - - if (kind !== KIND.OBJECT_BEGIN && kind !== KIND.ARRAY_BEGIN) { - return this.readToken() !== undefined; - } - - const start = this.#cursor.peekPosition; - const size = this.#consumeValue(start); - - if (size === 0) { - return false; - } - - this.#cursor.peekPosition = 0; - this.#cursor.previousStart = start; - this.#cursor.previousEnd = start + size; - this.#state.appendLiteral(); - - return true; - } - - /** - * Generates a JSON Pointer representing a location relative to the current - * decoding position. - * - * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. - * @returns A {@link Pointer} representing the absolute path. - */ - stackPointer(where: 0 | 1 | -1 = 1): Pointer { - return this.#state.stackPointer(where); - } - - /** - * Returns the unconsumed portion of the input buffer. - * - * @returns A subarray of bytes that have not yet been processed. - */ - unreadBytes(): Uint8Array { - return this.#cursor.unreadBytes(); - } - - #consumeValue(start: number): number { - let position = start; - const bytes = this.#cursor.bytes; - const kind = normalize(bytes[position]); - - if (kind === KIND.NULL) { - return consumeNull(bytes, position); - } - - if (kind === KIND.TRUE) { - return consumeTrue(bytes, position); - } - - if (kind === KIND.FALSE) { - return consumeFalse(bytes, position); - } - - if (kind === KIND.STRING) { - if (this.#offset === 0) { - const size = consumeSimpleString(bytes, position); - if (size > 0) return size; - } - - const result = consumeStringResumable( - bytes, - position, - this.#offset, - !this.#options.allowInvalidUTF8, - ); - - if (!result.completed) { - this.#offset = result.consumed; - return 0; - } - - this.#offset = 0; - return result.consumed; - } - - if (kind === KIND.NUMBER) { - let size = consumeSimpleNumber(bytes, position); - - if (size === 0) { - size = consumeNumber(bytes, position); - } - - if (size > 0 && position + size === bytes.length && !this.#cursor.ended) { - return 0; - } - - return size; - } - - if (kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN) { - let depth = 0; - let inString = false; - let inEscape = false; - - while (position < bytes.length) { - const byte = bytes[position]; - - if (inString) { - if (inEscape) { - inEscape = false; - } else if (byte === ASCII.BACKSLASH) { - inEscape = true; - } else if (byte === ASCII.QUOTE) { - inString = false; - } - - position++; - - continue; - } - - if (byte === ASCII.QUOTE) { - inString = true; - } else if (byte === ASCII.OPENING_BRACE || byte === ASCII.OPENING_BRACKET) { - depth++; - } else if (byte === ASCII.CLOSING_BRACE || byte === ASCII.CLOSING_BRACKET) { - depth--; - - if (depth === 0) { - return position + 1 - start; - } - } - - position++; - } - - return 0; - } - - return 0; - } - - #consumeNull(start: number): number { - const size = consumeNull(this.#cursor.bytes, start); - - if (size === 0) { - if (this.#cursor.bytes.length - start < 4) { - return 0; - } - - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(start); - const error = new SyntacticError("invalid literal null", pointer, offset); - - throw error; - } - - this.#state.appendLiteral(); - - return size; - } - - #consumeTrue(start: number): number { - const size = consumeTrue(this.#cursor.bytes, start); - - if (size === 0) { - if (this.#cursor.bytes.length - start < 4) { - return 0; - } - - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(start); - const error = new SyntacticError("invalid literal true", pointer, offset); - - throw error; - } - - this.#state.appendLiteral(); - - return size; - } - - #consumeFalse(start: number): number { - const size = consumeFalse(this.#cursor.bytes, start); - - if (size === 0) { - if (this.#cursor.bytes.length - start < 5) { - return 0; - } - - const pointer = this.#state.stackPointer(0).toString(); - const offset = this.#cursor.offsetAt(start); - const error = new SyntacticError("invalid literal false", pointer, offset); - - throw error; - } - - this.#state.appendLiteral(); - - return size; - } - - #consumeString(start: number): number { - let size = 0; - - if (this.#offset === 0) { - size = consumeSimpleString(this.#cursor.bytes, start); - } - - if (size === 0) { - const result = consumeStringResumable( - this.#cursor.bytes, - start, - this.#offset, - !this.#options.allowInvalidUTF8, - ); - - if (!result.completed) { - this.#offset = result.consumed; - return 0; - } - - size = result.consumed; - this.#offset = 0; - } - - if (this.#state.needObjectName()) { - const bytes = this.#cursor.bytes.subarray(start, start + size); - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const string = JSON.parse(decoded); - - this.#state.setLast(string); - } - - this.#state.appendString(); - - return size; - } - - #consumeNumber(start: number): number { - let size = consumeSimpleNumber(this.#cursor.bytes, start); - - if (size === 0) { - size = consumeNumber(this.#cursor.bytes, start); - - if (size === 0) { - return 0; - } - } - - if (start + size === this.#cursor.bytes.length && !this.#cursor.ended) { - return 0; - } - - this.#state.appendNumber(); - - return size; - } - - #consumeObjectBegin(): number { - return (this.#state.pushObject(), 1); - } - - #consumeObjectEnd(): number { - return (this.#state.popObject(), 1); - } - - #consumeArrayBegin(): number { - return (this.#state.pushArray(), 1); - } - - #consumeArrayEnd(): number { - return (this.#state.popArray(), 1); - } -} - -export default Decoder; diff --git a/src/modules/encoder.ts b/src/modules/encoder.ts deleted file mode 100644 index fc5b704..0000000 --- a/src/modules/encoder.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { ASCII, KIND, UNICODE } from "#src/common/constants"; -import { SyntacticError } from "#src/common/errors"; -import type Pointer from "#src/modules/pointer"; -import State from "#src/modules/state"; -import Tape from "#src/modules/tape"; -import Token from "#src/modules/token"; -import Value from "#src/modules/value"; -import type { Kind } from "#src/types/kind"; -import type { EncoderOptions } from "#src/types/options"; -import { decodeText, encodeText } from "#src/utils/text"; - -/** - * Low-level JSON encoder that serializes tokens and values onto an internal - * {@link Tape}, enforcing RFC 8259 structural rules and applying optional - * formatting (indentation, HTML escaping, etc.). - * - * @internal - */ -class Encoder { - #tape: Tape; - #state: State; - #options: EncoderOptions; - #cache: Record; - - /** - * Creates a new Encoder with the given options. - * - * @param options Encoder configuration options. - */ - constructor(options: EncoderOptions) { - this.#tape = new Tape(); - this.#state = new State(options); - this.#options = options; - this.#cache = { - "null_bytes": encodeText("null"), - "true_bytes": encodeText("true"), - "false_bytes": encodeText("false"), - "indent_bytes": encodeText(options.indent ?? "\t"), - "indent_prefix_bytes": options.indentPrefix ? encodeText(options.indentPrefix) : null, - }; - } - - /** - * Returns a view of the bytes written so far without advancing the output offset. - * - * @returns A subarray of the internal tape buffer. - */ - bytes(): Uint8Array { - return this.#tape.bytes(); - } - - /** - * Returns the current structural nesting depth. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#state.depth(); - } - - /** - * Returns the absolute output byte offset, accumulating across all - * {@link takeBytes} calls since the last {@link reset}. - * - * @returns The absolute output byte offset. - */ - outputOffset(): number { - return this.#tape.outputOffset(); - } - - /** - * Clears the tape and reinitializes the structural state. - */ - reset(): void { - this.#tape.reset(); - this.#state = new State(this.#options); - } - - /** - * Generates a JSON Pointer representing a location relative to the current - * encoding position. - * - * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. - * @returns A {@link Pointer} representing the absolute path. - */ - stackPointer(where: 0 | 1 | -1): Pointer { - return this.#state.stackPointer(where); - } - - /** - * Extracts the written bytes as a slice and advances the base output offset, - * preparing the tape for the next chunk (streaming use). - * - * @returns A copy of the bytes written since the last `takeBytes` call. - */ - takeBytes(): Uint8Array { - return this.#tape.takeBytes(); - } - - /** - * Serializes a single token onto the tape. - * - * Automatically inserts structural delimiters (`:` or `,`) and any - * configured whitespace or indentation before the token. On error, the - * tape is rolled back to its pre-call length. - * - * @param token The {@link Token} to write. - * @throws {SyntacticError} If the token is structurally invalid at the - * current position. - */ - writeToken(token: Token): void { - const length = this.#tape.length; - const delimiter = this.#state.needDelimiter(token.kind); - - try { - if (delimiter === ":") { - this.#tape.appendByte(ASCII.COLON); - } else if (delimiter === ",") { - this.#tape.appendByte(ASCII.COMMA); - } - - this.#appendWhitespace(token.kind, delimiter); - - switch (token.kind) { - case KIND.NULL: - this.#tape.appendBytes(this.#cache.null_bytes!); - this.#state.appendLiteral(); - break; - case KIND.TRUE: - this.#tape.appendBytes(this.#cache.true_bytes!); - this.#state.appendLiteral(); - break; - case KIND.FALSE: - this.#tape.appendBytes(this.#cache.false_bytes!); - this.#state.appendLiteral(); - break; - case KIND.STRING: { - const bytes = this.#encodeText(token.bytes); - - this.#tape.appendBytes(bytes); - - if (this.#state.needObjectName()) { - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - - this.#state.setLast(parsed); - } - - this.#state.appendString(); - break; - } - case KIND.NUMBER: { - let bytes = token.bytes; - - if (this.#options.canonicalizeRawNumbers) { - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - const encoded = encodeText(String(parsed)); - - bytes = encoded; - } - - this.#tape.appendBytes(bytes); - this.#state.appendNumber(); - break; - } - case KIND.OBJECT_BEGIN: - this.#tape.appendByte(ASCII.OPENING_BRACE); - this.#state.pushObject(); - break; - case KIND.OBJECT_END: - this.#tape.appendByte(ASCII.CLOSING_BRACE); - this.#state.popObject(); - break; - case KIND.ARRAY_BEGIN: - this.#tape.appendByte(ASCII.OPENING_BRACKET); - this.#state.pushArray(); - break; - case KIND.ARRAY_END: - this.#tape.appendByte(ASCII.CLOSING_BRACKET); - this.#state.popArray(); - break; - } - } catch (error) { - this.#tape.truncate(length); - - if (error instanceof SyntacticError) { - throw error; - } - - if (error instanceof SyntaxError) { - const pointer = this.#state.stackPointer(1).toString(); - const offset = this.#tape.outputOffset(); - - throw new SyntacticError(error.message, pointer, offset); - } - - throw error; - } - } - - /** - * Serializes a complete pre-parsed value onto the tape. - * - * Automatically inserts structural delimiters and any configured whitespace - * before the value. On error, the tape is rolled back to its pre-call length. - * - * @param value The {@link Value} to write. - * @throws {SyntacticError} If the value is structurally invalid at the - * current position. - */ - writeValue(value: Value): void { - const length = this.#tape.length; - const delimiter = this.#state.needDelimiter(value.kind); - - try { - if (delimiter === ":") { - this.#tape.appendByte(ASCII.COLON); - } else if (delimiter === ",") { - this.#tape.appendByte(ASCII.COMMA); - } - - this.#appendWhitespace(value.kind, delimiter); - - let bytes = value.bytes; - - if (value.kind === KIND.STRING) { - bytes = this.#encodeText(bytes); - } else if (value.kind === KIND.NUMBER && this.#options.canonicalizeRawNumbers) { - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - const encoded = encodeText(String(parsed)); - - bytes = encoded; - } - - this.#tape.appendBytes(bytes); - - switch (value.kind) { - case KIND.NULL: - case KIND.TRUE: - case KIND.FALSE: - this.#state.appendLiteral(); - break; - case KIND.STRING: { - if (this.#state.needObjectName()) { - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - - this.#state.setLast(parsed); - } - this.#state.appendString(); - break; - } - case KIND.NUMBER: - this.#state.appendNumber(); - break; - case KIND.OBJECT_BEGIN: - this.#state.pushObject(); - this.#state.popObject(); - break; - case KIND.ARRAY_BEGIN: - this.#state.pushArray(); - this.#state.popArray(); - break; - } - } catch (error) { - this.#tape.truncate(length); - - if (error instanceof SyntacticError) { - throw error; - } - - if (error instanceof SyntaxError) { - const pointer = this.#state.stackPointer(1).toString(); - const offset = this.#tape.outputOffset(); - - throw new SyntacticError(error.message, pointer, offset); - } - - throw error; - } - } - - #appendWhitespace(kind: Kind, delimiter: ":" | "," | null): void { - if (delimiter === ":") { - if (this.#options.spaceAfterColon) { - this.#tape.appendByte(ASCII.SPACE); - } - - return; - } - - if (delimiter === "," && this.#options.spaceAfterComma) { - this.#tape.appendByte(ASCII.SPACE); - } - - if (this.#options.multiline) { - const depth = this.#state.depth(); - - if (depth === 1) { - return; - } - - const isClose = kind === KIND.OBJECT_END || kind === KIND.ARRAY_END; - const levels = isClose ? depth - 2 : depth - 1; - - this.#tape.appendByte(ASCII.LINE_FEED); - - if (this.#options.indentPrefix && this.#cache["indent_prefix_bytes"]) { - this.#tape.appendBytes(this.#cache["indent_prefix_bytes"]); - } - - for (let i = 0; i < levels; i++) { - if (this.#cache["indent_bytes"]) { - this.#tape.appendBytes(this.#cache["indent_bytes"]); - } - } - } - } - - #encodeText(bytes: Uint8Array): Uint8Array { - if (!this.#options.escapeForHTML && !this.#options.escapeForJS) { - if (!this.#options.allowInvalidUTF8) { - decodeText(bytes, true); - } - - return bytes; - } - - const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); - const parsed = JSON.parse(decoded); - let encoded = JSON.stringify(parsed); - - if (this.#options.escapeForHTML) { - encoded = encoded.replace( - new RegExp("[<>&]", "g"), - (substring) => { - return (substring === "<" - ? UNICODE.OPEN_ANGLED_BRACKET - : substring === ">" - ? UNICODE.CLOSE_ANGLED_BRACKET - : UNICODE.AMPERSAND); - }, - ); - } - - if (this.#options.escapeForJS) { - encoded = encoded - .replace(new RegExp("\\u2028", "g"), UNICODE.LINE_SEPARATOR) - .replace(new RegExp("\\u2029", "g"), UNICODE.PARAGRAPH_SEPARATOR); - } - - return encodeText(encoded); - } -} - -export default Encoder; diff --git a/src/modules/entry.ts b/src/modules/entry.ts index b4d39a8..ec0ecdb 100644 --- a/src/modules/entry.ts +++ b/src/modules/entry.ts @@ -1,11 +1,7 @@ import { KIND } from "#src/common/constants"; -import type { Kind } from "#src/types/kind"; +import type { Kind } from "#src/common/types"; -/** - * Represents a single depth level in the automaton stack, which can be either an object or an array. - * - * @internal - */ +/** Represents one object or array depth level in the automaton stack. */ class Entry { #type: "object" | "array"; #count: number; @@ -20,63 +16,34 @@ class Entry { this.#count = 0; } - /** - * Returns the current count of elements in the entry. - * - **Array**: the index of the next element. - * - **Object**: the number of key-value half-steps processed. - * - * @returns The current element count. - */ - count(): number { + /** The number of elements processed in this entry. */ + get count(): number { return this.#count; } - /** - * Checks if the entry represents a JSON object. - * - * @returns `true` if the entry is an object, `false` otherwise. - */ - isObject(): boolean { + /** Checks if the entry represents a JSON object. */ + get isObject(): boolean { return this.#type === "object"; } - /** - * Checks if the entry represents a JSON array. - * - * @returns `true` if the entry is an array, `false` otherwise. - */ - isArray(): boolean { + /** Checks if the entry represents a JSON array. */ + get isArray(): boolean { return this.#type === "array"; } - /** - * Checks if the entry expects an object name for the next token. - * This is true when the structure is an object and the current count is even. - * - * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. - */ - needObjectName(): boolean { - return this.isObject() && this.#count % 2 === 0; + /** Checks if the entry expects an implicit colon before the next element. */ + get needsImplicitColon(): boolean { + return this.needsObjectValue; } - /** - * Checks if the entry expects an object value for the next token. - * This is true when the structure is an object and a key has just been processed (count is odd). - * - * @returns `true` if an object value is needed, `false` otherwise. - */ - needObjectValue(): boolean { - return this.isObject() && this.#count % 2 === 1; + /** Checks if the entry expects an object name for the next token. */ + get needsObjectName(): boolean { + return this.isObject && this.#count % 2 === 0; } - /** - * Checks if the entry needs an implicit colon before the next element, - * if it's an object expecting a value, the next token must be a colon (`:`). - * - * @returns `true` if the entry needs an implicit colon, `false` otherwise. - */ - needImplicitColon(): boolean { - return this.needObjectValue(); + /** Checks if the entry expects an object value for the next token. */ + get needsObjectValue(): boolean { + return this.isObject && this.#count % 2 === 1; } /** @@ -92,22 +59,18 @@ class Entry { * @returns `true` if the entry needs an implicit comma, `false` otherwise. */ needImplicitComma(next: Kind): boolean { - const isObjectEnd = this.isObject() && next === KIND.OBJECT_END; - const isArrayEnd = this.isArray() && next === KIND.ARRAY_END; + const isObjectEnd = this.isObject && next === KIND.OBJECT_END; + const isArrayEnd = this.isArray && next === KIND.ARRAY_END; - return (!!this.count() && !this.needObjectValue() && !isObjectEnd && !isArrayEnd); + return (!!this.count && !this.needsObjectValue && !isObjectEnd && !isArrayEnd); } - /** - * Increases the count of elements in the entry. - */ + /** Increases the count of elements in the entry. */ increment(): void { this.#count++; } - /** - * Decreases the count of elements in the entry. - */ + /** Decreases the count of elements in the entry. */ decrement(): void { this.#count--; } diff --git a/src/modules/escaper.ts b/src/modules/escaper.ts new file mode 100644 index 0000000..f519430 --- /dev/null +++ b/src/modules/escaper.ts @@ -0,0 +1,96 @@ +import { UNICODE } from "#src/common/constants"; +import { decodeText, encodeText } from "#src/utils/text"; + +/** Options for {@link Escaper}. */ +type EscaperOptions = { + /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */ + allowInvalidUTF8: boolean; + /** Normalize number tokens to their canonical decimal form. */ + canonicalizeRawNumbers: boolean; + /** Escape `<`, `>`, and `&` for safe embedding in HTML. */ + escapeForHTML: boolean; + /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */ + escapeForJS: boolean; +}; + +/** Handles string escaping for HTML and JavaScript contexts and canonicalizes raw numbers for JSON serialization. */ +class Escaper { + #options: EscaperOptions; + #regexEscapeForHTML: RegExp; + #regexLineSeparator: RegExp; + #regexParagraphSeparator: RegExp; + + /** + * Creates a new Escaper instance with the given options. + * + * @param options Escaper configuration options. + */ + constructor(options: EscaperOptions) { + this.#options = options; + this.#regexEscapeForHTML = new RegExp("[<>&]", "g"); + this.#regexLineSeparator = new RegExp("\\u2028", "g"); + this.#regexParagraphSeparator = new RegExp("\\u2029", "g"); + } + + /** + * Escapes raw UTF-8 bytes for a JSON string according to the configured HTML and JavaScript options. + * + * @param bytes The raw UTF-8 bytes of a JSON string. + * @returns The escaped UTF-8 bytes. + */ + escapeString(bytes: Uint8Array): Uint8Array { + if (!this.#options.escapeForHTML && !this.#options.escapeForJS) { + if (!this.#options.allowInvalidUTF8) { + decodeText(bytes, true); + } + + return bytes; + } + + const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); + const parsed = JSON.parse(decoded); + let encoded = JSON.stringify(parsed); + + if (this.#options.escapeForHTML) { + encoded = encoded.replace(this.#regexEscapeForHTML, (substring) => { + if (substring === "<") { + return UNICODE.OPEN_ANGLED_BRACKET; + } + + if (substring === ">") { + return UNICODE.CLOSE_ANGLED_BRACKET; + } + + return UNICODE.AMPERSAND; + }); + } + + if (this.#options.escapeForJS) { + encoded = encoded + .replace(this.#regexLineSeparator, UNICODE.LINE_SEPARATOR) + .replace(this.#regexParagraphSeparator, UNICODE.PARAGRAPH_SEPARATOR); + } + + return encodeText(encoded); + } + + /** + * Canonicalizes raw UTF-8 bytes for a JSON number when canonicalization is enabled. + * + * @param bytes The raw UTF-8 bytes of a JSON number. + * @returns The canonicalized (or unchanged) UTF-8 bytes. + */ + canonicalizeNumber(bytes: Uint8Array): Uint8Array { + if (!this.#options.canonicalizeRawNumbers) { + return bytes; + } + + const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); + const parsed = JSON.parse(decoded); + + return encodeText(String(parsed)); + } +} + +export default Escaper; +export type { EscaperOptions }; diff --git a/src/modules/formatter.ts b/src/modules/formatter.ts new file mode 100644 index 0000000..0bf88f3 --- /dev/null +++ b/src/modules/formatter.ts @@ -0,0 +1,83 @@ +import { ASCII, KIND } from "#src/common/constants"; +import type { Kind } from "#src/common/types"; +import { encodeText } from "#src/utils/text"; + +/** Options for {@link Formatter}. */ +type FormatterOptions = { + /** Indentation string used per nesting level when multiline is enabled. Defaults to a tab. */ + indent: string; + /** Prefix prepended to every indented line when multiline is enabled. */ + indentPrefix: string; + /** Emit each value on its own line with indentation. */ + multiline: boolean; + /** Emit a space after each `:` separator in objects. */ + spaceAfterColon: boolean; + /** Emit a space after each `,` separator in arrays and objects. */ + spaceAfterComma: boolean; +}; + +/** Handles whitespace and indentation formatting for JSON serialization. */ +class Formatter { + #indentBytes: Uint8Array; + #indentPrefixBytes: Uint8Array; + #options: FormatterOptions; + + /** + * Creates a new Formatter instance with the given options. + * + * @param options Formatter configuration options. + */ + constructor(options: FormatterOptions) { + this.#indentBytes = encodeText(options.indent); + this.#indentPrefixBytes = encodeText(options.indentPrefix); + this.#options = options; + } + + /** + * Computes whitespace chunks for a token or value. + * + * @param kind The {@link Kind} of token or value being formatted. + * @param delimiter The required delimiter (`:`, `,`, or `null`). + * @param depth The current structural nesting depth. + * @returns Byte chunks to insert before the token or value. + */ + getWhitespace(kind: Kind, delimiter: ":" | "," | null, depth: number): Uint8Array[] { + const chunks: Uint8Array[] = []; + + if (delimiter === ":") { + if (this.#options.spaceAfterColon) { + chunks.push(new Uint8Array([ASCII.SPACE])); + } + + return chunks; + } + + if (delimiter === "," && this.#options.spaceAfterComma) { + chunks.push(new Uint8Array([ASCII.SPACE])); + } + + if (this.#options.multiline) { + if (depth === 1) { + return chunks; + } + + const closed = kind === KIND.OBJECT_END || kind === KIND.ARRAY_END; + const levels = closed ? depth - 2 : depth - 1; + + chunks.push(new Uint8Array([ASCII.LINE_FEED])); + + if (this.#options.indentPrefix) { + chunks.push(this.#indentPrefixBytes); + } + + for (let i = 0; i < levels; i++) { + chunks.push(this.#indentBytes); + } + } + + return chunks; + } +} + +export default Formatter; +export type { FormatterOptions }; diff --git a/src/modules/parser.ts b/src/modules/parser.ts new file mode 100644 index 0000000..b28d066 --- /dev/null +++ b/src/modules/parser.ts @@ -0,0 +1,282 @@ +import { KIND } from "#src/common/constants"; +import { SyntacticError } from "#src/common/errors"; +import type { Kind } from "#src/common/types"; +import type Pointer from "#src/modules/pointer"; +import Scanner from "#src/modules/scanner"; +import State from "#src/modules/state"; +import { decodeText } from "#src/utils/text"; + +/** Options for {@link Parser}. */ +type ParserOptions = { + /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */ + allowDuplicateNames: boolean; + /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */ + allowInvalidUTF8: boolean; +}; + +/** Coordinates incremental JSON byte scanning with structural state validation. */ +class Parser { + #options: ParserOptions; + #scanner: Scanner; + #state: State; + + /** Creates a new parser with the supplied decoding options. */ + constructor(options: ParserOptions) { + this.#options = options; + this.#scanner = new Scanner(); + this.#state = new State(options); + } + + /** The current structural nesting depth. */ + get depth(): number { + return this.#state.depth; + } + + /** The absolute byte offset of the current scanner position. */ + get inputOffset(): number { + return this.#scanner.offset; + } + + /** The most recently read object property name. */ + get lastObjectName(): string { + return this.#state.lastObjectName; + } + + /** Whether the current object context expects a property name. */ + get needObjectName(): boolean { + return this.#state.needsObjectName; + } + + /** The unconsumed bytes currently retained by the scanner. */ + get unreadBytes(): Uint8Array { + return this.#scanner.unreadBytes; + } + + /** + * Verifies that the input is complete and contains no trailing characters. + * + * @throws {SyntaxError} If input ends inside a structure or contains trailing non-whitespace bytes. + */ + checkEOF(): void { + if (this.#state.depth > 1) { + throw new SyntaxError("Unexpected end of input"); + } + + const offset = this.#scanner.findTrailingOffset(); + + if (offset !== undefined) { + throw new SyntaxError(`Unexpected trailing characters at position ${offset}`); + } + } + + /** Marks the input as complete so unterminated final tokens can be diagnosed. */ + close(): void { + this.#scanner.close(); + } + + /** + * Peeks at the next token kind without consuming it. + * + * @returns The next {@link Kind}, or `undefined` when more input is needed. + * @throws {SyntacticError} If the next bytes contain invalid JSON syntax or delimiters. + */ + peekKind(): Kind | undefined { + try { + if (!this.#scanner.peekNext()) { + return undefined; + } + } catch (error) { + if (error instanceof SyntaxError) { + const pointer = this.#state.stackPointer(0).toString(); + const offset = this.#scanner.offset; + + throw new SyntacticError(error.message, pointer, offset); + } + + throw error; + } + + const kind = this.#scanner.kind; + + if (!kind) { + const pointer = this.#state.stackPointer(0).toString(); + const offset = this.#scanner.offset; + + throw new SyntacticError("Invalid character", pointer, offset); + } + + const expected = this.#state.requiredDelimiter(kind); + const delimiter = this.#scanner.delimiter; + + if (expected !== delimiter) { + const pointer = this.#state.stackPointer(0).toString(); + const offset = this.#scanner.offset; + + throw new SyntacticError("Invalid delimiter", pointer, offset); + } + + return kind; + } + + /** Appends a chunk of JSON bytes to the input buffer. */ + push(bytes: Uint8Array): void { + this.#scanner.appendBytes(bytes); + } + + /** + * Consumes the next JSON token. + * + * @returns The raw token bytes, or `undefined` when more input is needed. + * @throws {SyntacticError} If the next token is invalid or violates the current structure. + */ + readToken(): Uint8Array | undefined { + const kind = this.peekKind(); + + if (!kind) { + return; + } + + try { + const consumed = this.#scanner.consumeToken(!this.#options.allowInvalidUTF8); + + if (!consumed) { + return; + } + } catch (error) { + if (error instanceof SyntaxError) { + const pointer = this.#state.stackPointer(0).toString(); + const offset = this.#scanner.offset; + + throw new SyntacticError(error.message, pointer, offset); + } + + throw error; + } + + switch (kind) { + case KIND.NULL: + case KIND.TRUE: + case KIND.FALSE: { + this.#state.appendLiteral(); + break; + } + case KIND.NUMBER: { + this.#state.appendNumber(); + break; + } + case KIND.STRING: { + if (this.#state.needsObjectName) { + const text = decodeText(this.#scanner.span, !this.#options.allowInvalidUTF8); + const json = JSON.parse(text); + + this.#state.setLast(json); + } + + this.#state.appendString(); + break; + } + case KIND.OBJECT_BEGIN: { + this.#state.pushObject(); + break; + } + case KIND.OBJECT_END: { + this.#state.popObject(); + break; + } + case KIND.ARRAY_BEGIN: { + this.#state.pushArray(); + break; + } + case KIND.ARRAY_END: { + this.#state.popArray(); + break; + } + } + + return this.#scanner.span; + } + + /** + * Consumes the next complete JSON value. + * + * @returns The raw value bytes, or `undefined` when more input is needed. + * @throws {SyntacticError} If the next value is invalid or violates the current structure. + */ + readValue(): Uint8Array | undefined { + if (!this.peekKind() || !this.skipValue()) { + return; + } + + return this.#scanner.span; + } + + /** Resets the scanner and structural state to the initial state. */ + reset(): void { + this.#scanner.reset(); + this.#state.reset(); + } + + /** + * Consumes the next complete JSON value without returning its bytes. + * + * @returns `true` when a value was consumed, or `false` when more input is needed. + * @throws {SyntacticError} If the next value is invalid or violates the current structure. + */ + skipValue(): boolean { + const kind = this.peekKind(); + + if (!kind) { + return false; + } + + try { + const isStructural = kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN; + const consumed = isStructural + ? this.#scanner.consumeValue() + : this.#scanner.consumeToken(!this.#options.allowInvalidUTF8); + + if (!consumed) { + return false; + } + } catch (error) { + if (error instanceof SyntaxError) { + const pointer = this.#state.stackPointer(0).toString(); + const offset = this.#scanner.offset; + + throw new SyntacticError(error.message, pointer, offset); + } + + throw error; + } + + if (kind === KIND.STRING) { + if (this.#state.needsObjectName) { + const decoded = decodeText(this.#scanner.span, !this.#options.allowInvalidUTF8); + const json = JSON.parse(decoded); + + this.#state.setLast(json); + } + + this.#state.appendString(); + } else if (kind === KIND.NUMBER) { + this.#state.appendNumber(); + } else { + this.#state.appendLiteral(); + } + + return true; + } + + /** + * Returns a JSON Pointer for a position relative to the current parser state. + * + * @param where Relative position: `-1` previous, `0` current, or `1` next. + * @returns A {@link Pointer} for the selected position. + */ + stackPointer(where: 0 | 1 | -1 = 1): Pointer { + return this.#state.stackPointer(where); + } +} + +export default Parser; +export type { ParserOptions }; diff --git a/src/modules/path.ts b/src/modules/path.ts index 63861cd..3feb6c4 100644 --- a/src/modules/path.ts +++ b/src/modules/path.ts @@ -6,7 +6,7 @@ import type { Segment, Selector, WildcardSelector, -} from "#src/types/path"; +} from "#src/common/types"; import { decodeText } from "#src/utils/text"; import { consumeNumber, consumeWhitespace } from "#src/utils/wire"; @@ -19,10 +19,9 @@ import { consumeNumber, consumeWhitespace } from "#src/utils/wire"; * - **Descendant Segment**: `..` * - **Name Selector**: `.name` or `['name']` * - **Wildcard Selector**: `.*` or `[*]` - * - **Index Selector (positive)**: `[1]` - * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]` + * - **Index Selector (non-negative)**: `[1]` + * - **Array Slice Selector (non-negative)**: `[0:5]` or `[::2]` * - * @internal * @see https://www.rfc-editor.org/rfc/rfc9535 * @example * ```javascript @@ -332,11 +331,7 @@ class Path { } } -/** - * NFA implementation for JSON Path matching. - * - * @internal - */ +/** NFA implementation for JSON Path matching. */ class Matcher { #segments: Segment[]; #stack: number[]; @@ -444,9 +439,7 @@ class Matcher { this.#stack.push(next); } - /** - * Pops the last step from the matcher state. - */ + /** Pops the last step from the matcher state. */ pop(): void { this.#stack.pop(); } diff --git a/src/modules/pointer.ts b/src/modules/pointer.ts index 2d7cd1c..94174a9 100644 --- a/src/modules/pointer.ts +++ b/src/modules/pointer.ts @@ -1,8 +1,4 @@ -/** - * Represents a JSON Pointer as defined in RFC 6901. - * - * @internal - */ +/** Represents a JSON Pointer and its unescaped reference tokens. */ class Pointer { #tokens: string[]; @@ -23,9 +19,9 @@ class Pointer { /** * Parses a JSON Pointer string into a `Pointer` instance. * - * @param value The RFC 6901 compliant pointer string (e.g., `""` or `"/foo/bar"`). + * @param value A JSON Pointer string such as `""` or `"/foo/bar"`. * @returns A parsed `Pointer` object. - * @throws {TypeError} If the string is not empty and does not start with a slash (`/`). + * @throws {TypeError} If the string is not empty and does not start with `/`. * @example * ```javascript * Pointer.parse("").tokens // [] @@ -72,7 +68,7 @@ class Pointer { } /** - * Serializes the Pointer instance back into an RFC 6901 compliant string. + * Serializes the pointer back into a JSON Pointer string. * * @returns The serialized pointer string, or `""` for the root pointer. * @example diff --git a/src/modules/scanner.ts b/src/modules/scanner.ts new file mode 100644 index 0000000..eaa91df --- /dev/null +++ b/src/modules/scanner.ts @@ -0,0 +1,298 @@ +import { ASCII, KIND } from "#src/common/constants"; +import type { Kind } from "#src/common/types"; +import Cursor from "#src/modules/cursor"; +import Skipper from "#src/modules/skipper"; +import { normalize } from "#src/utils/kind"; +import { + consumeFalse, + consumeNull, + consumeNumber, + consumeSimpleNumber, + consumeSimpleString, + consumeStringResumable, + consumeTrue, + consumeWhitespace, +} from "#src/utils/wire"; + +/** Scans JSON input incrementally and exposes the next token or complete value span. */ +class Scanner { + #cursor: Cursor; + #skipper: Skipper; + #position: number; + #checkpoint: number; + #start: number; + #length: number; + #kind: Kind | undefined; + #delimiter: ":" | "," | null; + + /** Creates a new scanner with an empty input buffer. */ + constructor() { + this.#cursor = new Cursor(); + this.#skipper = new Skipper(); + this.#position = 0; + this.#checkpoint = 0; + this.#start = 0; + this.#length = 0; + this.#kind = undefined; + this.#delimiter = null; + } + + /** The delimiter found immediately before the current token, if any. */ + get delimiter(): ":" | "," | null { + return this.#delimiter; + } + + /** The kind of the next token, if one is ready. */ + get kind(): Kind | undefined { + return this.#kind; + } + + /** The absolute byte offset of the current scan position. */ + get offset(): number { + return this.#cursor.at(this.#position); + } + + /** The raw bytes of the most recently consumed token or value. */ + get span(): Uint8Array { + return this.#cursor.bytes.subarray(this.#start, this.#start + this.#length); + } + + /** The bytes that have not yet been consumed. */ + get unreadBytes(): Uint8Array { + return this.#cursor.bytes.subarray(this.#position); + } + + /** Appends an input chunk while retaining any incomplete token. */ + appendBytes(chunk: Uint8Array): void { + this.#cursor.append(chunk, this.#start); + + if (this.#position >= this.#start) { + this.#position -= this.#start; + } else { + this.#position = 0; + } + + this.#start = 0; + } + + /** + * Consumes the currently prepared token. + * + * @param validateUTF8 Whether string tokens must contain valid UTF-8. + * @returns `true` when a complete token was consumed, or `false` when more input is needed. + * @throws {SyntaxError} If the token contains invalid literal or string syntax. + */ + consumeToken(validateUTF8: boolean): boolean { + if (!this.#kind) { + return false; + } + + let size = 0; + + switch (this.#kind) { + case KIND.NULL: { + const consumed = consumeNull(this.#cursor.bytes, this.#position); + const failed = consumed === 0; + const complete = this.#cursor.bytes.length - this.#position >= 4; + + if (failed && complete) { + throw new SyntaxError("Invalid literal null"); + } + + size = consumed; + break; + } + case KIND.TRUE: { + const consumed = consumeTrue(this.#cursor.bytes, this.#position); + const failed = consumed === 0; + const complete = this.#cursor.bytes.length - this.#position >= 4; + + if (failed && complete) { + throw new SyntaxError("Invalid literal true"); + } + + size = consumed; + break; + } + case KIND.FALSE: { + const consumed = consumeFalse(this.#cursor.bytes, this.#position); + const failed = consumed === 0; + const complete = this.#cursor.bytes.length - this.#position >= 5; + + if (failed && complete) { + throw new SyntaxError("Invalid literal false"); + } + + size = consumed; + break; + } + case KIND.NUMBER: { + let consumed = consumeSimpleNumber(this.#cursor.bytes, this.#position); + + if (consumed === 0) { + consumed = consumeNumber(this.#cursor.bytes, this.#position); + } + + if (consumed === 0) { + return false; + } + + const exhausted = this.#position + consumed === this.#cursor.bytes.length; + const active = !this.#cursor.ended; + + if (exhausted && active) { + return false; + } + + size = consumed; + break; + } + case KIND.STRING: { + const resuming = this.#checkpoint !== 0; + + if (!resuming) { + const consumed = consumeSimpleString(this.#cursor.bytes, this.#position); + + if (consumed !== 0) { + size = consumed; + break; + } + } + + const { completed, consumed } = consumeStringResumable( + this.#cursor.bytes, + this.#position, + this.#checkpoint, + validateUTF8, + ); + + if (!completed) { + return (this.#checkpoint = consumed, false); + } + + this.#checkpoint = 0; + size = consumed; + break; + } + case KIND.OBJECT_BEGIN: + case KIND.OBJECT_END: + case KIND.ARRAY_BEGIN: + case KIND.ARRAY_END: { + size = 1; + break; + } + } + + if (size === 0) { + return false; + } + + this.#start = this.#position; + this.#length = size; + this.#position += size; + this.#kind = undefined; + this.#delimiter = null; + this.#skipper.reset(); + + return true; + } + + /** + * Consumes the currently prepared complete value as one span. + * + * @returns `true` when a complete value was consumed, or `false` when more input is needed. + */ + consumeValue(): boolean { + if (!this.#kind) { + return false; + } + + const size = this.#skipper.skip(this.#cursor.bytes, this.#position); + + if (size === 0) { + return false; + } + + this.#start = this.#position; + this.#length = size; + this.#position += size; + this.#kind = undefined; + this.#delimiter = null; + this.#skipper.reset(); + + return true; + } + + /** Marks the input as complete. */ + close(): void { + this.#cursor.close(); + } + + /** + * Finds the first non-whitespace byte after the most recently consumed value. + * + * @returns The absolute offset of trailing content, or `undefined` when none exists. + */ + findTrailingOffset(): number | undefined { + const position = consumeWhitespace(this.#cursor.bytes, this.#start + this.#length); + + if (position < this.#cursor.bytes.length) { + return this.#cursor.at(position); + } + + return; + } + + /** + * Scans whitespace and prepares the next token kind without consuming it. + * + * @returns `true` when a token is ready, or `false` when more input is needed. + * @throws {SyntaxError} If the next non-whitespace byte cannot begin a JSON token. + */ + peekNext(): boolean { + if (this.#kind) { + return true; + } + + this.#position = consumeWhitespace(this.#cursor.bytes, this.#position); + + if (this.#position >= this.#cursor.bytes.length) { + return false; + } + + const current = this.#cursor.bytes[this.#position]; + + if (current === ASCII.COLON || current === ASCII.COMMA) { + this.#delimiter = String.fromCharCode(current) as ":" | ","; + this.#position++; + this.#position = consumeWhitespace(this.#cursor.bytes, this.#position); + + if (this.#position >= this.#cursor.bytes.length) { + return false; + } + } + + const next = this.#cursor.bytes[this.#position]; + const kind = normalize(next); + + if (!kind) { + throw new SyntaxError(`Invalid character '${String.fromCharCode(next)}'`); + } + + return (this.#kind = kind, true); + } + + /** Resets the scanner and clears all retained input state. */ + reset(): void { + this.#cursor.reset(); + this.#skipper.reset(); + this.#position = 0; + this.#checkpoint = 0; + this.#start = 0; + this.#length = 0; + this.#kind = undefined; + this.#delimiter = null; + } +} + +export default Scanner; diff --git a/src/modules/serializer.ts b/src/modules/serializer.ts new file mode 100644 index 0000000..09a8368 --- /dev/null +++ b/src/modules/serializer.ts @@ -0,0 +1,276 @@ +import { ASCII, KIND } from "#src/common/constants"; +import { SyntacticError } from "#src/common/errors"; +import type { Kind } from "#src/common/types"; +import Escaper from "#src/modules/escaper"; +import Formatter from "#src/modules/formatter"; +import type Pointer from "#src/modules/pointer"; +import State from "#src/modules/state"; +import Tape from "#src/modules/tape"; +import { decodeText, encodeText } from "#src/utils/text"; + +/** Options for {@link Serializer}. */ +type SerializerOptions = { + /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */ + allowDuplicateNames: boolean; + /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */ + allowInvalidUTF8: boolean; + /** Normalize number tokens to their canonical decimal form. */ + canonicalizeRawNumbers: boolean; + /** Escape `<`, `>`, and `&` for safe embedding in HTML. */ + escapeForHTML: boolean; + /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */ + escapeForJS: boolean; + /** Indentation string used per nesting level when multiline is enabled. Defaults to a tab. */ + indent: string; + /** Prefix prepended to every indented line when multiline is enabled. */ + indentPrefix: string; + /** Emit each value on its own line with indentation. */ + multiline: boolean; + /** Emit a space after each `:` separator in objects. */ + spaceAfterColon: boolean; + /** Emit a space after each `,` separator in arrays and objects. */ + spaceAfterComma: boolean; +}; + +/** Low-level JSON serializer that writes tokens and values onto an internal {@link Tape}, enforcing RFC 8259 structural rules and applying optional formatting and escaping. */ +class Serializer { + #options: SerializerOptions; + #escaper: Escaper; + #formatter: Formatter; + #state: State; + #tape: Tape; + #trueBytes: Uint8Array; + #falseBytes: Uint8Array; + #nullBytes: Uint8Array; + + /** + * Creates a new Serializer instance with the given options. + * + * @param options Serializer configuration options. + */ + constructor(options: SerializerOptions) { + this.#options = options; + this.#escaper = new Escaper(options); + this.#formatter = new Formatter(options); + this.#state = new State(options); + this.#tape = new Tape(); + this.#trueBytes = encodeText(KIND.TRUE); + this.#falseBytes = encodeText(KIND.FALSE); + this.#nullBytes = encodeText(KIND.NULL); + } + + /** The current structural nesting depth. */ + get depth(): number { + return this.#state.depth; + } + + /** The absolute output byte offset accumulated across all {@link takeBytes} calls since creation. */ + get outputOffset(): number { + return this.#tape.outputOffset; + } + + /** + * Generates a JSON Pointer representing a location relative to the current + * serialization position. + * + * @param where Relative position: `-1` previous, `0` current, or `1` next. + * @returns A {@link Pointer} for the selected position. + */ + stackPointer(where: 0 | 1 | -1): Pointer { + return this.#state.stackPointer(where); + } + + /** Resets the serializer to its initial state, clearing the output buffer and all structural state. */ + reset(): void { + this.#state.reset(); + this.#tape.reset(); + } + + /** + * Extracts the written bytes as a slice and advances the base output offset, + * preparing the tape for the next chunk (streaming use). + * + * @returns A copy of the bytes written since the last `takeBytes` call. + */ + takeBytes(): Uint8Array { + return this.#tape.takeBytes(); + } + + /** + * Serializes a single token onto the tape. + * + * Automatically inserts structural delimiters (`:` or `,`) and any + * configured whitespace or indentation before the token. On error, the + * tape is rolled back to its pre-call length. + * + * @param kind The {@link Kind} of token to write. + * @param bytes Raw UTF-8 bytes of the token. + * @throws {SyntacticError} If the token is invalid at the current position. + */ + writeToken(kind: Kind, bytes: Uint8Array): void { + const length = this.#tape.length; + const delimiter = this.#state.requiredDelimiter(kind); + + try { + if (delimiter === ":") { + this.#tape.appendByte(ASCII.COLON); + } else if (delimiter === ",") { + this.#tape.appendByte(ASCII.COMMA); + } + + for (const bytes of this.#formatter.getWhitespace(kind, delimiter, this.#state.depth)) { + this.#tape.appendBytes(bytes); + } + + switch (kind) { + case KIND.NULL: + this.#tape.appendBytes(this.#nullBytes); + this.#state.appendLiteral(); + break; + case KIND.TRUE: + this.#tape.appendBytes(this.#trueBytes); + this.#state.appendLiteral(); + break; + case KIND.FALSE: + this.#tape.appendBytes(this.#falseBytes); + this.#state.appendLiteral(); + break; + case KIND.STRING: { + const escaped = this.#escaper.escapeString(bytes); + + this.#tape.appendBytes(escaped); + + if (this.#state.needsObjectName) { + const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); + const parsed = JSON.parse(decoded); + + this.#state.setLast(parsed); + } + + this.#state.appendString(); + break; + } + case KIND.NUMBER: { + const escaped = this.#escaper.canonicalizeNumber(bytes); + + this.#tape.appendBytes(escaped); + this.#state.appendNumber(); + break; + } + case KIND.OBJECT_BEGIN: + this.#tape.appendByte(ASCII.OPENING_BRACE); + this.#state.pushObject(); + break; + case KIND.OBJECT_END: + this.#tape.appendByte(ASCII.CLOSING_BRACE); + this.#state.popObject(); + break; + case KIND.ARRAY_BEGIN: + this.#tape.appendByte(ASCII.OPENING_BRACKET); + this.#state.pushArray(); + break; + case KIND.ARRAY_END: + this.#tape.appendByte(ASCII.CLOSING_BRACKET); + this.#state.popArray(); + break; + } + } catch (error) { + this.#tape.truncate(length); + + if (error instanceof SyntacticError) { + throw error; + } + + if (error instanceof SyntaxError) { + const pointer = this.#state.stackPointer(1).toString(); + const offset = this.#tape.outputOffset; + + throw new SyntacticError(error.message, pointer, offset); + } + + throw error; + } + } + + /** + * Serializes a complete pre-parsed value onto the tape. + * + * Automatically inserts structural delimiters and any configured whitespace + * before the value. On error, the tape is rolled back to its pre-call length. + * + * @param kind The {@link Kind} of value to write. + * @param bytes Raw UTF-8 bytes of the complete value. + * @throws {SyntacticError} If the value is invalid at the current position. + */ + writeValue(kind: Kind, bytes: Uint8Array): void { + const length = this.#tape.length; + const delimiter = this.#state.requiredDelimiter(kind); + + try { + if (delimiter === ":") { + this.#tape.appendByte(ASCII.COLON); + } else if (delimiter === ",") { + this.#tape.appendByte(ASCII.COMMA); + } + + for (const bytes of this.#formatter.getWhitespace(kind, delimiter, this.#state.depth)) { + this.#tape.appendBytes(bytes); + } + + if (kind === KIND.STRING) { + bytes = this.#escaper.escapeString(bytes); + } else if (kind === KIND.NUMBER) { + bytes = this.#escaper.canonicalizeNumber(bytes); + } + + this.#tape.appendBytes(bytes); + + switch (kind) { + case KIND.NULL: + case KIND.TRUE: + case KIND.FALSE: + this.#state.appendLiteral(); + break; + case KIND.STRING: { + if (this.#state.needsObjectName) { + const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8); + const parsed = JSON.parse(decoded); + + this.#state.setLast(parsed); + } + this.#state.appendString(); + break; + } + case KIND.NUMBER: + this.#state.appendNumber(); + break; + case KIND.OBJECT_BEGIN: + this.#state.pushObject(); + this.#state.popObject(); + break; + case KIND.ARRAY_BEGIN: + this.#state.pushArray(); + this.#state.popArray(); + break; + } + } catch (error) { + this.#tape.truncate(length); + + if (error instanceof SyntacticError) { + throw error; + } + + if (error instanceof SyntaxError) { + const pointer = this.#state.stackPointer(1).toString(); + const offset = this.#tape.outputOffset; + + throw new SyntacticError(error.message, pointer, offset); + } + + throw error; + } + } +} + +export default Serializer; +export type { SerializerOptions }; diff --git a/src/modules/skipper.ts b/src/modules/skipper.ts new file mode 100644 index 0000000..1cc9410 --- /dev/null +++ b/src/modules/skipper.ts @@ -0,0 +1,80 @@ +import { ASCII } from "#src/common/constants"; + +/** Skips complete JSON values while preserving progress across input chunks. */ +class Skipper { + #depth: number; + #inString: boolean; + #inStringEscape: boolean; + #offset: number; + + /** Creates a new value skipper. */ + constructor() { + this.#depth = 0; + this.#inString = false; + this.#inStringEscape = false; + this.#offset = 0; + } + + /** Resets the skipper to its initial state. */ + reset(): void { + this.#depth = 0; + this.#inString = false; + this.#inStringEscape = false; + this.#offset = 0; + } + + /** + * Scans for the end of the JSON value beginning at `start`. + * + * @param bytes The input bytes to scan. + * @param start The position at which the value begins. + * @returns The value length, or `0` when more input is needed. + */ + skip(bytes: Uint8Array, start: number): number { + let current = start + this.#offset; + + while (current < bytes.length) { + const byte = bytes[current]; + + if (this.#inString) { + if (this.#inStringEscape) { + this.#inStringEscape = false; + } else if (byte === ASCII.BACKSLASH) { + this.#inStringEscape = true; + } else if (byte === ASCII.QUOTE) { + this.#inString = false; + } + + current++; + continue; + } + + switch (byte) { + case ASCII.QUOTE: { + this.#inString = true; + break; + } + case ASCII.OPENING_BRACE: + case ASCII.OPENING_BRACKET: { + this.#depth++; + break; + } + case ASCII.CLOSING_BRACE: + case ASCII.CLOSING_BRACKET: { + this.#depth--; + break; + } + } + + current++; + + if (this.#depth === 0) { + return (this.#offset = 0, current - start); + } + } + + return (this.#offset = current - start, 0); + } +} + +export default Skipper; diff --git a/src/modules/stack.ts b/src/modules/stack.ts index 262195a..7afbc65 100644 --- a/src/modules/stack.ts +++ b/src/modules/stack.ts @@ -1,14 +1,8 @@ -/** - * A specialized stack for tracking the current property names of nested JSON objects. - * - * @internal - */ +/** A specialized stack for tracking the current property names of nested JSON objects. */ class ObjectNameStack { #names: Array; - /** - * Creates a new ObjectNameStack instance. - */ + /** Creates a new ObjectNameStack instance. */ constructor() { this.#names = []; } @@ -29,7 +23,7 @@ class ObjectNameStack { } /** - * Retrieves the property name of the deepest (currently active) object context. + * Retrieves the property name of the deepest active object context. * * @returns The current property name, or an empty string if the stack is empty. */ @@ -37,20 +31,21 @@ class ObjectNameStack { return this.#names[this.#names.length - 1] ?? ""; } - /** - * Pushes a new, empty name context onto the stack when entering a new JSON object. - */ + /** Pushes a new, empty name context when entering a JSON object. */ pushObject(): void { this.#names.push(""); } - /** - * Pops the deepest name context from the stack when exiting a JSON object. - */ + /** Pops the deepest name context when exiting a JSON object. */ popObject(): void { this.#names.pop(); } + /** Resets the stack and clears all tracked names. */ + reset(): void { + this.#names.length = 0; + } + /** * Sets the active property name for the current object context. * @@ -61,35 +56,15 @@ class ObjectNameStack { } } -/** - * A specialized stack for tracking the uniqueness of property names within nested JSON objects. - * - * @internal - */ +/** A specialized stack for tracking property-name uniqueness within nested JSON objects. */ class ObjectNamespaceStack { #namespaces: Array>; - /** - * Creates a new ObjectNamespaceStack instance. - */ + /** Creates a new ObjectNamespaceStack instance. */ constructor() { this.#namespaces = []; } - /** - * Pushes a new, empty namespace Set when entering a new JSON object. - */ - pushObject(): void { - this.#namespaces.push(new Set()); - } - - /** - * Pops and discards the namespace Set when exiting a JSON object. - */ - popObject(): void { - this.#namespaces.pop(); - } - /** * Attempts to insert a property name into the current namespace Set. * @@ -107,6 +82,21 @@ class ObjectNamespaceStack { return true; } + + /** Pushes a new, empty namespace set when entering a JSON object. */ + pushObject(): void { + this.#namespaces.push(new Set()); + } + + /** Pops and discards the namespace set when exiting a JSON object. */ + popObject(): void { + this.#namespaces.pop(); + } + + /** Resets the stack and clears all tracked namespaces. */ + reset(): void { + this.#namespaces.length = 0; + } } export { ObjectNamespaceStack, ObjectNameStack }; diff --git a/src/modules/state.ts b/src/modules/state.ts index 1de2b43..b2cb63e 100644 --- a/src/modules/state.ts +++ b/src/modules/state.ts @@ -1,32 +1,53 @@ +import type { Kind } from "#src/common/types"; import Automaton from "#src/modules/automaton"; import Pointer from "#src/modules/pointer"; import { ObjectNamespaceStack, ObjectNameStack } from "#src/modules/stack"; -import type { Kind } from "#src/types/kind"; -import type { BaseOptions } from "#src/types/options"; - -/** - * The central coordinator for the decoding/encoding state. - * - * @internal - */ + +/** Options for {@link State}. */ +type StateOptions = { + /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */ + allowDuplicateNames: boolean; +}; + +/** Coordinates syntax validation and location tracking for decoding and encoding. */ class State { #automaton: Automaton; #names: ObjectNameStack; #namespaces: ObjectNamespaceStack; - #options: BaseOptions; + #options: StateOptions; /** * Creates a new State coordinator. * * @param options Decoder/encoder configuration options. */ - constructor(options: BaseOptions) { + constructor(options: StateOptions) { this.#automaton = new Automaton(); this.#names = new ObjectNameStack(); this.#namespaces = new ObjectNamespaceStack(); this.#options = options; } + /** The current structural nesting depth. */ + get depth(): number { + return this.#automaton.depth; + } + + /** The last object name processed in the current context. */ + get lastObjectName(): string { + return this.#names.getLast(); + } + + /** Checks if the current context expects an object name. */ + get needsObjectName(): boolean { + return this.#automaton.last.needsObjectName; + } + + /** Checks if the current context expects an object value. */ + get needsObjectValue(): boolean { + return this.#automaton.last.needsObjectValue; + } + /** * Asserts and appends a generic literal value to the current context. * @@ -36,9 +57,7 @@ class State { this.#automaton.appendLiteral(); } - /** - * Appends a string value to the current context. - */ + /** Appends a string value to the current context. */ appendString(): void { this.#automaton.appendString(); } @@ -52,57 +71,21 @@ class State { this.#automaton.appendNumber(); } - /** - * Returns the current nesting depth of the structural state. - * - * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. - */ - depth(): number { - return this.#automaton.depth(); - } - /** * Determines if a delimiter (`:` or `,`) is required before the next token. * - * @param kind The kind of the next incoming token. - * @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected. - */ - needDelimiter(kind: Kind): ":" | "," | null { - return this.#automaton.needDelimiter(kind); - } - - /** - * Checks if the current context expects an object key. - * - * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. - */ - needObjectName(): boolean { - return this.#automaton.last.needObjectName(); - } - - /** - * Checks if the current context expects an object value. - * - * @returns `true` if an object value is needed, `false` otherwise. + * @param kind The kind of the next token. + * @returns `":"` for a colon, `","` for a comma, or `null`. */ - needObjectValue(): boolean { - return this.#automaton.last.needObjectValue(); - } - - /** - * Retrieves the name of the currently active object property. - * - * @returns The current object property name, or an empty string if not inside an object. - */ - lastObjectName(): string { - return this.#names.getLast(); + requiredDelimiter(kind: Kind): ":" | "," | null { + return this.#automaton.requiredDelimiter(kind); } /** * Pushes a new array structure, synchronizing the underlying automaton. * - * @throws {SyntaxError} If the parent context requires an object name. - * @throws {RangeError} If the maximum nesting depth is exceeded. + * @throws {SyntaxError} If an object name is required. + * @throws {RangeError} If maximum nesting depth is exceeded. */ pushArray(): void { this.#automaton.pushArray(); @@ -111,7 +94,7 @@ class State { /** * Pops the current array structure. * - * @throws {SyntaxError} If the current context is not an array, or if it is prematurely closed. + * @throws {SyntaxError} If the context is not an array or closes prematurely. */ popArray(): void { this.#automaton.popArray(); @@ -121,8 +104,8 @@ class State { * Pushes a new object structure. * Synchronizes the syntax automaton, name stack, and namespace stack. * - * @throws {SyntaxError} If the parent context requires an object name. - * @throws {RangeError} If the maximum nesting depth is exceeded. + * @throws {SyntaxError} If an object name is required. + * @throws {RangeError} If maximum nesting depth is exceeded. */ pushObject(): void { this.#automaton.pushObject(); @@ -133,7 +116,7 @@ class State { /** * Pops the current object structure, cleaning up the associated name and namespace tracking. * - * @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value. + * @throws {SyntaxError} If the context is not an object or closes before a value. */ popObject(): void { this.#automaton.popObject(); @@ -141,11 +124,20 @@ class State { this.#namespaces.popObject(); } + /** + * Resets the entire state, clearing the automaton, name stack, and namespace stack. + */ + reset(): void { + this.#automaton.reset(); + this.#names.reset(); + this.#namespaces.reset(); + } + /** * Sets the name for the current object property and validates it against duplicates. * * @param name The object key being processed. - * @throws {SyntaxError} If the name already exists in the current object and `allowDuplicateNames` is false. + * @throws {SyntaxError} If the name is a duplicate and duplicates are disallowed. */ setLast(name: string): void { this.#names.setLast(name); @@ -163,8 +155,8 @@ class State { * Dynamically generates a JSON Pointer (RFC 6901) representing a specific location * in the JSON structure relative to the current state. * - * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. - * @returns A `Pointer` instance representing the absolute path. + * @param where Relative position: `-1` previous, `0` current, or `1` next. + * @returns A `Pointer` for the selected position. * @example * ```javascript * // Object { "a": [...] } — one element at "/a/0" has just been written. @@ -179,29 +171,29 @@ class State { const tokens: string[] = []; let depth = 0; - for (let index = 1; index < this.#automaton.depth(); index++) { + for (let index = 1; index < this.#automaton.depth; index++) { const entry = this.#automaton.getEntry(index); let delta = -1; - if (index === this.#automaton.depth() - 1) { - const isEmpty = where < 0 && entry.count() === 0; - const isNotInObject = where === 0 && !entry.needObjectValue(); - const isExpectingName = where > 0 && entry.needObjectName(); + if (index === this.#automaton.depth - 1) { + const isEmpty = where < 0 && entry.count === 0; + const isNotInObject = where === 0 && !entry.needsObjectValue; + const isExpectingName = where > 0 && entry.needsObjectName; if (isEmpty || isNotInObject || isExpectingName) { return new Pointer(tokens); } - if (where > 0 && entry.isArray()) { + if (where > 0 && entry.isArray) { delta = 0; } } - if (entry.isObject()) { + if (entry.isObject) { tokens.push(this.#names.getObjectName(depth)); depth++; } else { - tokens.push(String(entry.count() + delta)); + tokens.push(String(entry.count + delta)); } } @@ -210,3 +202,4 @@ class State { } export default State; +export type { StateOptions }; diff --git a/src/modules/tape.ts b/src/modules/tape.ts index 8ad2304..c383b80 100644 --- a/src/modules/tape.ts +++ b/src/modules/tape.ts @@ -1,16 +1,10 @@ -/** - * A dynamic, pre-allocated buffer used as the primary write destination for the Encoder. - * - * @internal - */ +/** A dynamic, pre-allocated buffer used as the primary write destination for the encoder. */ class Tape { #baseOffset: number; #length: number; #bytes: Uint8Array; - /** - * Creates a new Tape instance with an initial buffer size of 256 bytes. - */ + /** Creates a new Tape instance with an initial buffer size of 256 bytes. */ constructor() { this.#baseOffset = 0; this.#length = 0; @@ -22,6 +16,16 @@ class Tape { return this.#length; } + /** The underlying byte buffer, including any unwritten capacity. */ + get bytes(): Uint8Array { + return this.#bytes.subarray(0, this.#length); + } + + /** The absolute offset of the first byte in the buffer, relative to the start of the stream. */ + get outputOffset(): number { + return this.#baseOffset + this.#length; + } + /** * Appends a single byte (0-255) to the buffer. * Doubles the underlying capacity if the buffer is full. @@ -53,37 +57,16 @@ class Tape { this.#length += bytes.length; } - /** - * Returns a view of the currently written bytes without advancing the output offset. - * - * @returns A subarray of the written bytes. - */ - bytes(): Uint8Array { - return this.#bytes.subarray(0, this.#length); - } - - /** - * Returns the absolute output byte offset, accumulating across all - * {@link takeBytes} calls since the last {@link reset}. - * - * @returns The absolute output byte offset. - */ - outputOffset(): number { - return this.#baseOffset + this.#length; - } - - /** - * Resets the buffer, clearing all written bytes and resetting the base offset. - */ + /** Resets the buffer, clearing all written bytes and resetting the base offset. */ reset(): void { this.#length = 0; this.#baseOffset = 0; } /** - * Extracts the currently written bytes and prepares the Tape for the next chunk of data. - * This method advances the base offset and resets the length pointer to 0, - * allowing the internal buffer to be safely overwritten (Buffer Reuse). + * Extracts the currently written bytes and prepares the tape for the next chunk of data. + * This advances the base offset and resets the length pointer to `0`, allowing the internal + * buffer to be safely overwritten. * * @returns A copy (slice) of the bytes written so far. */ diff --git a/src/types/kind.ts b/src/types/kind.ts deleted file mode 100644 index bef6c5c..0000000 --- a/src/types/kind.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { KIND } from "#src/common/constants"; - -/** - * A union type of all possible JSON token kind values, corresponding to the - * string discriminants in {@link KIND}. - * - * @public - */ -export type Kind = typeof KIND[keyof typeof KIND]; diff --git a/src/types/options.ts b/src/types/options.ts deleted file mode 100644 index 0b77233..0000000 --- a/src/types/options.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Base options for {@link DecoderOptions} and {@link EncoderOptions}. - * - * @internal - */ -export type BaseOptions = { - /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */ - allowDuplicateNames?: boolean; - /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */ - allowInvalidUTF8?: boolean; -}; - -/** - * Options for {@link Decoder}. - * - * @internal - */ -export type DecoderOptions = BaseOptions; - -/** - * Options for {@link Encoder}. - * - * @internal - */ -export type EncoderOptions = { - /** Escape `<`, `>`, and `&` for safe embedding in HTML. */ - escapeForHTML?: boolean; - /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */ - escapeForJS?: boolean; - /** Normalize number tokens to their canonical decimal form. */ - canonicalizeRawNumbers?: boolean; - /** Emit a space after each `:` separator in objects. */ - spaceAfterColon?: boolean; - /** Emit a space after each `,` separator in arrays and objects. */ - spaceAfterComma?: boolean; - /** Emit each value on its own line with indentation. */ - multiline?: boolean; - /** Indentation string used per nesting level when multiline is enabled. Defaults to two spaces. */ - indent?: string; - /** Prefix prepended to every indented line when multiline is enabled. */ - indentPrefix?: string; -} & BaseOptions; diff --git a/src/utils/kind.ts b/src/utils/kind.ts index 7f0fedf..66c54b6 100644 --- a/src/utils/kind.ts +++ b/src/utils/kind.ts @@ -1,35 +1,45 @@ import { ASCII, KIND } from "#src/common/constants"; -import type { Kind } from "#src/types/kind"; - -const NORM_KIND: Record = { - [ASCII.LOWER_CASE_N]: KIND.NULL, - [ASCII.LOWER_CASE_F]: KIND.FALSE, - [ASCII.LOWER_CASE_T]: KIND.TRUE, - [ASCII.QUOTE]: KIND.STRING, - [ASCII.OPENING_BRACE]: KIND.OBJECT_BEGIN, - [ASCII.CLOSING_BRACE]: KIND.OBJECT_END, - [ASCII.OPENING_BRACKET]: KIND.ARRAY_BEGIN, - [ASCII.CLOSING_BRACKET]: KIND.ARRAY_END, - [ASCII.MINUS]: KIND.NUMBER, - [ASCII.DIGIT_0]: KIND.NUMBER, - [ASCII.DIGIT_1]: KIND.NUMBER, - [ASCII.DIGIT_2]: KIND.NUMBER, - [ASCII.DIGIT_3]: KIND.NUMBER, - [ASCII.DIGIT_4]: KIND.NUMBER, - [ASCII.DIGIT_5]: KIND.NUMBER, - [ASCII.DIGIT_6]: KIND.NUMBER, - [ASCII.DIGIT_7]: KIND.NUMBER, - [ASCII.DIGIT_8]: KIND.NUMBER, - [ASCII.DIGIT_9]: KIND.NUMBER, -}; +import type { Kind } from "#src/common/types"; /** - * Normalizes a byte to its corresponding kind. - * @param byte The byte to normalize. - * @returns The corresponding kind. + * Maps a leading JSON byte to its token kind. + * + * @param byte The byte to map. + * @returns The matching {@link Kind}, or `undefined` when the byte cannot begin a JSON token. */ function normalize(byte: number): Kind | undefined { - return NORM_KIND[byte]; + switch (byte) { + case ASCII.LOWER_CASE_N: + return KIND.NULL; + case ASCII.LOWER_CASE_F: + return KIND.FALSE; + case ASCII.LOWER_CASE_T: + return KIND.TRUE; + case ASCII.QUOTE: + return KIND.STRING; + case ASCII.OPENING_BRACE: + return KIND.OBJECT_BEGIN; + case ASCII.CLOSING_BRACE: + return KIND.OBJECT_END; + case ASCII.OPENING_BRACKET: + return KIND.ARRAY_BEGIN; + case ASCII.CLOSING_BRACKET: + return KIND.ARRAY_END; + case ASCII.MINUS: + case ASCII.DIGIT_0: + case ASCII.DIGIT_1: + case ASCII.DIGIT_2: + case ASCII.DIGIT_3: + case ASCII.DIGIT_4: + case ASCII.DIGIT_5: + case ASCII.DIGIT_6: + case ASCII.DIGIT_7: + case ASCII.DIGIT_8: + case ASCII.DIGIT_9: + return KIND.NUMBER; + default: + return undefined; + } } export { normalize }; diff --git a/src/utils/text.ts b/src/utils/text.ts index fa544ca..77d9241 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -3,19 +3,25 @@ const decoder = new TextDecoder("utf-8"); const fatalDecoder = new TextDecoder("utf-8", { fatal: true }); /** - * Utility functions for encoding and decoding text. - * @param input The input string to encode. - * @returns A Uint8Array containing the encoded text. + * Encodes a string as UTF-8 bytes. + * + * @param input The string to encode. + * @returns The encoded UTF-8 bytes. */ function encodeText(input: string): Uint8Array { return encoder.encode(input); } /** - * Utility functions for encoding and decoding text in UTF-8. - * @param input The input Uint8Array to decode. - * @param fatal Whether to use a fatal decoder. + * Decodes UTF-8 bytes into a string. + * + * By default, invalid byte sequences are replaced according to the platform + * `TextDecoder` behavior. Set `fatal` to `true` to reject invalid sequences. + * + * @param input The UTF-8 bytes to decode. + * @param fatal Whether invalid UTF-8 should throw instead of being replaced. * @returns The decoded string. + * @throws {TypeError} If `fatal` is `true` and `input` contains invalid UTF-8. */ function decodeText(input: Uint8Array, fatal?: boolean): string { return fatal ? fatalDecoder.decode(input) : decoder.decode(input); diff --git a/src/utils/wire.ts b/src/utils/wire.ts index 7e3d517..bfdac75 100644 --- a/src/utils/wire.ts +++ b/src/utils/wire.ts @@ -2,20 +2,24 @@ import { ASCII } from "#src/common/constants"; import { decodeText } from "#src/utils/text"; /** - * Compares two strings by UTF-16 code unit order, as required by RFC 8785 §3.2.3. + * Compares two strings by UTF-16 code unit order. + * * @param a The first string to compare. * @param b The second string to compare. - * @returns A negative number if a < b, positive if a > b, or 0 if equal. + * @returns A negative number if `a` sorts before `b`, a positive number if it sorts after `b`, or `0` when both strings are equal. */ function compareUTF16(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } /** - * Consumes whitespace characters from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume whitespace from. - * @param position The position in the bytes to start consuming. - * @returns The new position after consuming whitespace. + * Advances past JSON whitespace starting at a byte position. + * + * Only space, tab, line feed, and carriage return are treated as whitespace. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns The first position that does not contain JSON whitespace. */ function consumeWhitespace(bytes: Uint8Array, position: number): number { while (position < bytes.length) { @@ -41,10 +45,11 @@ function consumeWhitespace(bytes: Uint8Array, position: number): number { } /** - * Consumes the "null" literal from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if the "null" literal is found, otherwise 0. + * Matches the `null` literal at a byte position. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns `4` when `null` starts at `position`, otherwise `0`. */ function consumeNull(bytes: Uint8Array, position: number): number { if (bytes.length - position >= 4) { @@ -62,10 +67,11 @@ function consumeNull(bytes: Uint8Array, position: number): number { } /** - * Consumes the "true" literal from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if the "true" literal is found, otherwise 0. + * Matches the `true` literal at a byte position. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns `4` when `true` starts at `position`, otherwise `0`. */ function consumeTrue(bytes: Uint8Array, position: number): number { if (bytes.length - position >= 4) { @@ -83,10 +89,11 @@ function consumeTrue(bytes: Uint8Array, position: number): number { } /** - * Consumes the "false" literal from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if the "false" literal is found, otherwise 0. + * Matches the `false` literal at a byte position. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns `5` when `false` starts at `position`, otherwise `0`. */ function consumeFalse(bytes: Uint8Array, position: number): number { if (bytes.length - position >= 5) { @@ -105,11 +112,16 @@ function consumeFalse(bytes: Uint8Array, position: number): number { } /** - * Consumes a string literal from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @param validateUTF8 Whether to validate the string as UTF-8. - * @returns The number of bytes consumed if a string literal is found, otherwise 0. + * Consumes a complete JSON string literal from a byte position. + * + * Escapes, control characters, and optionally UTF-8 validity are checked by + * the underlying resumable scanner. An incomplete or invalid string returns + * `0`. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @param validateUTF8 Whether to reject invalid UTF-8 sequences. + * @returns The number of bytes in the string literal, or `0` when it is incomplete or invalid. */ function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true): number { const result = consumeStringResumable(bytes, position, 0, validateUTF8); @@ -118,12 +130,17 @@ function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true) } /** - * Consumes a string literal from the given position, supporting resumption from a previous offset. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @param offset The relative number of bytes already scanned in previous chunks. - * @param validateUTF8 Whether to validate the string as UTF-8. - * @returns An object containing the number of bytes consumed (or the scan offset if incomplete) and whether the string is complete. + * Scans a JSON string literal and supports resuming from a previous chunk. + * + * When the string is incomplete, `consumed` is the relative scan offset to + * use when continuing with the next chunk. When the string is complete, + * `consumed` is its total byte length from `position`. + * + * @param bytes The current input chunk to scan. + * @param position The position at which the string starts. + * @param offset The relative scan offset returned for a previous incomplete chunk. + * @param validateUTF8 Whether to reject invalid UTF-8 sequences. + * @returns The scan result, including the consumed byte count and completion status. An invalid start or control character returns `{ consumed: 0, completed: false }`. */ function consumeStringResumable( bytes: Uint8Array, @@ -177,10 +194,14 @@ function consumeStringResumable( } /** - * Consumes a number literal from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if a number literal is found, otherwise 0. + * Scans a JSON number literal from a byte position. + * + * The result is the length of the valid number prefix. Delimiter validation is + * performed by the parser after this helper returns. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns The length of the number literal, or `0` when the bytes do not begin a valid number. */ function consumeNumber(bytes: Uint8Array, position: number): number { if (position >= bytes.length) { @@ -279,10 +300,11 @@ function consumeNumber(bytes: Uint8Array, position: number): number { } /** - * Consumes a simple string literal (without escape sequences) from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if a simple string literal is found, otherwise 0. + * Scans a JSON string literal that contains no escapes. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns The length of the string literal, or `0` when it is incomplete or contains an escape, control character, or non-ASCII byte. */ function consumeSimpleString(bytes: Uint8Array, position: number): number { if (position >= bytes.length || bytes[position] !== ASCII.QUOTE) { @@ -309,10 +331,11 @@ function consumeSimpleString(bytes: Uint8Array, position: number): number { } /** - * Consumes a simple number literal (without fractional or exponential parts) from the given position in the Uint8Array bytes. - * @param bytes The Uint8Array bytes to consume from. - * @param position The position in the bytes to start consuming. - * @returns The number of bytes consumed if a simple number literal is found, otherwise 0. + * Scans an integer JSON number without a sign, fraction, or exponent. + * + * @param bytes The input bytes to scan. + * @param position The position at which to start scanning. + * @returns The length of the integer, or `0` when the bytes do not begin a simple number or are followed immediately by a fraction or exponent. */ function consumeSimpleNumber(bytes: Uint8Array, position: number): number { if (position >= bytes.length) { diff --git a/tests/integration/decoder.test.ts b/tests/integration/decoder.test.ts index 4ee2820..3144960 100644 --- a/tests/integration/decoder.test.ts +++ b/tests/integration/decoder.test.ts @@ -9,8 +9,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read null", () => { const json = JSON.stringify(null); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const token = decoder.readToken(); @@ -21,8 +22,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read true", () => { const json = JSON.stringify(true); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const token = decoder.readToken(); @@ -34,8 +36,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read false", () => { const json = JSON.stringify(false); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const token = decoder.readToken(); @@ -47,8 +50,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read a number", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const token = decoder.readToken(); @@ -60,8 +64,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read a string", () => { const json = JSON.stringify("hello"); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const token = decoder.readToken(); @@ -73,8 +78,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read array tokens in order", () => { const json = JSON.stringify([1, 2]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); assertEquals(decoder.readToken()?.kind, KIND.ARRAY_BEGIN); @@ -87,8 +93,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read object tokens in order", () => { const json = JSON.stringify({ a: 1 }); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); assertEquals(decoder.readToken()?.kind, KIND.OBJECT_BEGIN); @@ -103,8 +110,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read a scalar value", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const value = decoder.readValue(); @@ -116,8 +124,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read a nested array as one value", () => { const json = JSON.stringify([1, 2]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const value = decoder.readValue(); @@ -129,8 +138,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read a nested object as one value", () => { const json = JSON.stringify({ a: 1 }); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const value = decoder.readValue(); @@ -142,8 +152,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should read elements inside an outer array one at a time", () => { const json = JSON.stringify([1, "two", [3]]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); @@ -169,8 +180,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should not advance the read position", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); assertEquals(decoder.peekKind(), KIND.NUMBER); @@ -189,8 +201,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should skip a scalar and allow reading the next value", () => { const json = JSON.stringify([1, 2]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.skipValue(); @@ -201,8 +214,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should skip a nested structure and allow reading the next value", () => { const json = JSON.stringify([[1, 2], 3]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); @@ -320,8 +334,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("[scenario] options", async (test) => { await test.step("should reject duplicate names by default", () => { const encoded = encodeText('{"a":1,"a":2}'); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.readToken(); @@ -332,8 +347,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should allow duplicate names when configured", () => { const encoded = encodeText('{"a":1,"a":2}'); - const decoder = new JSONTextDecoder(encoded, { allowDuplicateNames: true }); + const decoder = new JSONTextDecoder({ allowDuplicateNames: true }); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.readToken(); @@ -349,8 +365,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should not throw when input is fully consumed", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.checkEOF(); @@ -358,8 +375,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should throw when trailing characters remain", () => { const encoded = encodeText("42 extra"); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); @@ -371,26 +389,28 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should return 0 before reading anything", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); - assertEquals(decoder.inputOffset(), 0); + assertEquals(decoder.inputOffset, 0); }); await test.step("should advance after each token is read", () => { const json = JSON.stringify([1, 2]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); - assertEquals(decoder.inputOffset(), 1); + assertEquals(decoder.inputOffset, 1); decoder.readToken(); - assertEquals(decoder.inputOffset(), 2); + assertEquals(decoder.inputOffset, 2); }); }); @@ -398,30 +418,33 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should return all bytes before any token is read", () => { const json = JSON.stringify(42); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); - assertEquals(decoder.unreadBytes(), encoded); + assertEquals(decoder.unreadBytes, encoded); }); await test.step("should return remaining bytes after reading a token", () => { const json = JSON.stringify([1, 2]); const encoded = encodeText(json); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); - assertEquals(decoder.unreadBytes(), encodeText("1,2]")); + assertEquals(decoder.unreadBytes, encodeText("1,2]")); }); }); await test.step("[scenario] malformed JSON", async (test) => { await test.step("should throw on an unterminated string", () => { const encoded = encodeText('"hello'); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); @@ -430,8 +453,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should throw SyntacticError on an invalid character", () => { const encoded = encodeText("@invalid"); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const error = assertThrows(() => decoder.readToken(), SyntacticError); @@ -442,21 +466,23 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should throw SyntacticError for a malformed null literal", () => { const encoded = encodeText("nulx"); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); const error = assertThrows(() => decoder.readToken(), SyntacticError); assertEquals(error.offset, 0); assertEquals(error.pointer.toString(), ""); - assertEquals(error.message, "invalid literal null at offset 0"); + assertEquals(error.message, "Invalid literal null at offset 0"); }); await test.step("should include pointer path in SyntacticError within an object", () => { const encoded = encodeText('{"a": @}'); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.readToken(); @@ -469,8 +495,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => { await test.step("should throw on mismatched closing bracket", () => { const encoded = encodeText("[1,2}"); - const decoder = new JSONTextDecoder(encoded); + const decoder = new JSONTextDecoder(); + decoder.push(encoded); decoder.end(); decoder.readToken(); decoder.readToken(); diff --git a/tests/integration/encoder.test.ts b/tests/integration/encoder.test.ts index cf02275..cd3bd15 100644 --- a/tests/integration/encoder.test.ts +++ b/tests/integration/encoder.test.ts @@ -1,7 +1,7 @@ +import Token from "#src/api/token"; +import Value from "#src/api/value"; import { SyntacticError } from "#src/common/errors"; import { JSONTextEncoder } from "#src/index"; -import Token from "#src/modules/token"; -import Value from "#src/modules/value"; import { decodeText, encodeText } from "#src/utils/text"; import { assertEquals, assertThrows } from "#std/assert"; @@ -122,7 +122,7 @@ Deno.test("[integration] JSONTextEncoder", async (test) => { await test.step("should return 0 before any writes", () => { const encoder = new JSONTextEncoder(); - assertEquals(encoder.outputOffset(), 0); + assertEquals(encoder.outputOffset, 0); }); await test.step("should advance after each token is written", () => { @@ -130,10 +130,10 @@ Deno.test("[integration] JSONTextEncoder", async (test) => { encoder.writeToken(Token.fromText("[")); - assertEquals(encoder.outputOffset(), 1); + assertEquals(encoder.outputOffset, 1); encoder.writeToken(Token.fromNumber(1)); - assertEquals(encoder.outputOffset(), 2); + assertEquals(encoder.outputOffset, 2); }); }); diff --git a/tests/integration/stream-decoder.test.ts b/tests/integration/stream-decoder.test.ts index e5df894..f0aaf5b 100644 --- a/tests/integration/stream-decoder.test.ts +++ b/tests/integration/stream-decoder.test.ts @@ -1,5 +1,5 @@ +import type Token from "#src/api/token"; import { JSONTextDecoderStream } from "#src/index"; -import type Token from "#src/modules/token"; import { encodeText } from "#src/utils/text"; import { assertEquals, assertRejects } from "#std/assert"; diff --git a/tests/integration/stream-encoder.test.ts b/tests/integration/stream-encoder.test.ts index 4595670..58a1e51 100644 --- a/tests/integration/stream-encoder.test.ts +++ b/tests/integration/stream-encoder.test.ts @@ -1,5 +1,5 @@ +import Token from "#src/api/token"; import { JSONTextEncoderStream } from "#src/index"; -import Token from "#src/modules/token"; import { decodeText } from "#src/utils/text"; import { assertEquals, assertRejects } from "#std/assert"; diff --git a/tests/state.test.ts b/tests/state.test.ts index a6848bd..cadaf31 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -5,40 +5,40 @@ import { assertEquals, assertExists, assertThrows } from "#std/assert"; Deno.test("[module] state", async (test) => { await test.step("[function] constructor", async (test) => { await test.step("should initialize state with default options", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); assertExists(state); }); await test.step("should start at depth 1", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should not need object name initially", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.needObjectName(), false); + assertEquals(state.needsObjectName, false); }); await test.step("should not need object value initially", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.needObjectValue(), false); + assertEquals(state.needsObjectValue, false); }); }); await test.step("[function] appendLiteral", async (test) => { await test.step("should not throw in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendLiteral(); }); await test.step("should not throw when appending object value", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); @@ -46,7 +46,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when object name is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); @@ -56,42 +56,42 @@ Deno.test("[module] state", async (test) => { await test.step("[function] appendString", async (test) => { await test.step("should not throw in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendString(); }); await test.step("should not throw when object name is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); - assertEquals(state.needObjectValue(), true); + assertEquals(state.needsObjectValue, true); }); await test.step("should not throw when object value is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); state.appendString(); - assertEquals(state.needObjectName(), true); + assertEquals(state.needsObjectName, true); }); }); await test.step("[function] appendNumber", async (test) => { await test.step("should not throw in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendNumber(); }); await test.step("should not throw when appending object value", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); @@ -99,7 +99,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when object name is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); @@ -109,204 +109,204 @@ Deno.test("[module] state", async (test) => { await test.step("[function] depth", async (test) => { await test.step("should return 1 initially", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should increase by 1 after pushArray", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); - assertEquals(state.depth(), 2); + assertEquals(state.depth, 2); }); await test.step("should increase by 1 after pushObject", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); - assertEquals(state.depth(), 2); + assertEquals(state.depth, 2); }); await test.step("should decrease by 1 after popArray", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.popArray(); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should decrease by 1 after popObject", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.popObject(); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should track multiple levels of nesting", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.pushObject(); - assertEquals(state.depth(), 3); + assertEquals(state.depth, 3); }); }); - await test.step("[function] needDelimiter", async (test) => { + await test.step("[function] requiredDelimiter", async (test) => { await test.step("should return null initially", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.needDelimiter(KIND.STRING), null); + assertEquals(state.requiredDelimiter(KIND.STRING), null); }); await test.step("should return null for empty nested array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); - assertEquals(state.needDelimiter(KIND.STRING), null); + assertEquals(state.requiredDelimiter(KIND.STRING), null); }); await test.step("should return comma after first item in nested array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendString(); - assertEquals(state.needDelimiter(KIND.STRING), ","); + assertEquals(state.requiredDelimiter(KIND.STRING), ","); }); await test.step("should return null before array end even with items", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendString(); - assertEquals(state.needDelimiter(KIND.ARRAY_END), null); + assertEquals(state.requiredDelimiter(KIND.ARRAY_END), null); }); await test.step("should return colon after object name", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); - assertEquals(state.needDelimiter(KIND.STRING), ":"); + assertEquals(state.requiredDelimiter(KIND.STRING), ":"); }); await test.step("should return comma before second object name", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); state.appendString(); - assertEquals(state.needDelimiter(KIND.STRING), ","); + assertEquals(state.requiredDelimiter(KIND.STRING), ","); }); await test.step("should return null before object end even with items", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); state.appendString(); - assertEquals(state.needDelimiter(KIND.OBJECT_END), null); + assertEquals(state.requiredDelimiter(KIND.OBJECT_END), null); }); await test.step("should return null at root level even with items", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.appendString(); - assertEquals(state.needDelimiter(KIND.STRING), null); + assertEquals(state.requiredDelimiter(KIND.STRING), null); }); }); await test.step("[function] needObjectName", async (test) => { await test.step("should return false in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.needObjectName(), false); + assertEquals(state.needsObjectName, false); }); await test.step("should return true immediately after pushObject", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); - assertEquals(state.needObjectName(), true); + assertEquals(state.needsObjectName, true); }); await test.step("should return false after appending object name", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); - assertEquals(state.needObjectName(), false); + assertEquals(state.needsObjectName, false); }); await test.step("should return true again after appending object value", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); state.appendString(); - assertEquals(state.needObjectName(), true); + assertEquals(state.needsObjectName, true); }); }); await test.step("[function] needObjectValue", async (test) => { await test.step("should return false in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); - assertEquals(state.needObjectValue(), false); + assertEquals(state.needsObjectValue, false); }); await test.step("should return false immediately after pushObject", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); - assertEquals(state.needObjectValue(), false); + assertEquals(state.needsObjectValue, false); }); await test.step("should return true after appending object name", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); - assertEquals(state.needObjectValue(), true); + assertEquals(state.needsObjectValue, true); }); await test.step("should return false after appending object value", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); state.appendString(); - assertEquals(state.needObjectValue(), false); + assertEquals(state.needsObjectValue, false); }); }); await test.step("[function] pushArray", async (test) => { await test.step("should not throw in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); }); await test.step("should not throw when object value is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); @@ -314,7 +314,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when object name is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); @@ -322,7 +322,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when max nesting depth is exceeded", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); for (let i = 0; i < MAX_NESTING_DEPTH; i++) { state.pushArray(); @@ -334,22 +334,22 @@ Deno.test("[module] state", async (test) => { await test.step("[function] popArray", async (test) => { await test.step("should restore depth after pushArray", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.popArray(); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should throw at root level", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); assertThrows(() => state.popArray(), SyntaxError); }); await test.step("should throw when inside an object", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); @@ -359,13 +359,13 @@ Deno.test("[module] state", async (test) => { await test.step("[function] pushObject", async (test) => { await test.step("should not throw in array context", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); }); await test.step("should not throw when object value is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); @@ -374,7 +374,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when object name is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); @@ -382,7 +382,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw when max nesting depth is exceeded", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); for (let i = 0; i < MAX_NESTING_DEPTH; i++) { state.pushArray(); @@ -394,16 +394,16 @@ Deno.test("[module] state", async (test) => { await test.step("[function] popObject", async (test) => { await test.step("should restore depth after pushObject", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.popObject(); - assertEquals(state.depth(), 1); + assertEquals(state.depth, 1); }); await test.step("should throw when inside an array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); @@ -411,13 +411,13 @@ Deno.test("[module] state", async (test) => { }); await test.step("should throw at root level", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); assertThrows(() => state.popObject(), SyntaxError, "mismatching } for object"); }); await test.step("should throw when object value is pending", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.appendString(); @@ -428,14 +428,14 @@ Deno.test("[module] state", async (test) => { await test.step("[function] setLast", async (test) => { await test.step("should not throw for a unique name", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("key"); }); await test.step("should throw for a duplicate name in the same object", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("key"); @@ -456,7 +456,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should allow same name in different nested objects", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("key"); @@ -470,7 +470,7 @@ Deno.test("[module] state", async (test) => { await test.step("[function] stackPointer", async (test) => { await test.step("should return empty pointer at root level", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); assertEquals(state.stackPointer(-1).toString(), ""); assertEquals(state.stackPointer(0).toString(), ""); @@ -478,7 +478,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should return empty pointer for empty nested array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); @@ -487,7 +487,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should return next index for empty nested array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); @@ -495,7 +495,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should point to last written index in array", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushArray(); state.appendString(); @@ -505,7 +505,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should point to key when object value is expected", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("key"); @@ -517,7 +517,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should return empty pointer after completing object pair", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("key"); @@ -529,7 +529,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should escape special characters in key names", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("a/b~c"); @@ -539,7 +539,7 @@ Deno.test("[module] state", async (test) => { }); await test.step("should build pointer across nested structures", () => { - const state = new State({}); + const state = new State({ allowDuplicateNames: false }); state.pushObject(); state.setLast("a"); diff --git a/tests/token.test.ts b/tests/token.test.ts index 517b2f8..a687379 100644 --- a/tests/token.test.ts +++ b/tests/token.test.ts @@ -1,5 +1,5 @@ +import Token from "#src/api/token"; import { KIND } from "#src/common/constants"; -import Token from "#src/modules/token"; import { encodeText as e } from "#src/utils/text"; import { assertEquals, assertThrows } from "#std/assert"; diff --git a/tests/value.test.ts b/tests/value.test.ts index 4023538..de19951 100644 --- a/tests/value.test.ts +++ b/tests/value.test.ts @@ -1,5 +1,5 @@ +import Value from "#src/api/value"; import { KIND } from "#src/common/constants"; -import Value from "#src/modules/value"; import { encodeText as e } from "#src/utils/text"; import { assertEquals, assertFalse, assertThrows } from "#std/assert"; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 623f472..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext", - "module": "esnext", - "lib": ["ESNext", "DOM"], - "paths": { "#src/*": ["./src/*"] }, - "moduleResolution": "bundler", - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "skipLibCheck": true, - "noEmit": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "erasableSyntaxOnly": true - }, - "include": ["src", "web"] -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 489fd95..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { resolve } from "node:path"; -import dts from "unplugin-dts/vite"; -import { defineConfig } from "vite"; - -export default defineConfig(({ mode }) => { - if (mode === "lib") { - return { - publicDir: false, - plugins: [dts({ include: ["src"], outDirs: "dist", entryRoot: "src" })], - build: { - lib: { - entry: resolve("./src/index.ts"), - fileName: "index", - formats: ["es"], - }, - sourcemap: "hidden", - }, - }; - } - - return { - server: { host: true }, - }; -}); diff --git a/web/main.ts b/web/main.ts deleted file mode 100644 index 91a329f..0000000 --- a/web/main.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello, world");