Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

steps:
- name: Setup
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup Deno
uses: denoland/setup-deno@v2
Expand All @@ -28,3 +28,5 @@ jobs:

- name: Test
run: deno task test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 changes: 14 additions & 5 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{
"tasks": {
"test": "deno test --coverage --allow-all",
"bench": "deno bench --allow-all",
"check": "deno check && deno fmt --check && deno lint",
"format": "deno fmt",
"lint": "deno lint"
"lint": "deno lint",
"test": "deno test --coverage --allow-all"
},
"imports": {
"#src/*": "./src/*.ts",
Expand All @@ -14,9 +15,17 @@
},
"lint": {
"rules": {
"tags": ["recommended"],
"exclude": ["no-sloppy-imports"]
"tags": [
"recommended"
],
"exclude": [
"no-sloppy-imports"
]
}
},
"exclude": ["node_modules/", "public/", "dist/"]
"exclude": [
"node_modules/",
"public/",
"dist/"
]
}
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
"#src/*": "./src/*.ts"
},
"scripts": {
"dev": "vite",
"test": "deno task test",
"build": "tsc && vite build",
"preview": "vite preview",
"bench": "deno task bench",
"dev": "vite",
"format": "deno task format",
"lint": "deno task lint"
"lint": "deno task lint",
"preview": "vite preview",
"test": "deno task test"
},
"devDependencies": {
"@types/node": "^25.6.0",
Expand Down
153 changes: 153 additions & 0 deletions src/api/decoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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";

type JSONTextDecoderOptions = DecoderOptions;

/**
* 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.
*/
class JSONTextDecoder {
#decoder: Decoder;

/**
* @param bytes - Initial bytes to pre-load into the decoder.
* @param options - Decoding options.
*/
constructor(bytes = new Uint8Array(), options?: JSONTextDecoderOptions) {
this.#decoder = new Decoder(bytes, { ...DEFAULT_DECODER_OPTIONS, ...options });
}

/**
* Asserts that the input has been fully consumed.
*
* @throws {SyntacticError} If there are unread bytes remaining.
*/
checkEOF(): void {
this.#decoder.checkEOF();
}

/**
* The current nesting depth — `0` at top level, incremented inside each
* object or array.
*/
depth(): number {
return this.#decoder.depth();
}

/**
* Signals that no more input will be pushed.
*
* Validates that any incomplete value is properly terminated.
*
* @throws {SyntacticError} If the input ends in the middle of a value.
*/
end(): void {
this.#decoder.end();
}

/**
* The byte offset of the next unread byte within the total input seen so far.
*
* @returns The byte offset of the next unread byte, or the total length of all
*/
inputOffset(): number {
return this.#decoder.inputOffset();
}

/**
* Appends a chunk of bytes to the internal buffer.
*
* @param bytes - The next chunk of JSON-encoded bytes.
*/
push(bytes: Uint8Array): void {
this.#decoder.push(bytes);
}

/**
* Returns the {@link Kind} of the next token without consuming it,
* or `undefined` if no complete token is available yet.
*
* @returns The {@link Kind} of the next token, or `undefined` if no complete token is available yet.
*/
peekKind(): Kind | undefined {
return this.#decoder.peekKind();
}

/**
* Resets the decoder to its initial state, discarding all buffered input
* and state.
*/
reset(): void {
this.#decoder.reset();
}

/**
* Reads and returns the next {@link Token} from the buffer, or `undefined`
* if no complete token is available yet.
*
* @returns The next token, or `undefined` if no complete token is available yet.
* @throws {SyntacticError} If invalid JSON syntax is encountered.
*/
readToken(): Token | undefined {
return this.#decoder.readToken();
}

/**
* 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.
*
* @returns The next value, or `undefined` if no complete value is available yet.
* @throws {SyntacticError} If invalid JSON syntax is encountered.
*/
readValue(): Value | undefined {
return this.#decoder.readValue();
}

/**
* Skips over the next complete value without returning it.
*
* @returns `true` if a value was skipped, `false` if no complete value was available yet.
* @throws {SyntacticError} If invalid JSON syntax is encountered.
*/
skipValue(): boolean {
return this.#decoder.skipValue();
}

/**
* Returns a JSON Pointer string describing a position in the current
* nesting context.
*
* | `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"`.
*/
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();
}
}

export default JSONTextDecoder;
export type { JSONTextDecoderOptions };
106 changes: 106 additions & 0 deletions src/api/encoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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";

type JSONTextEncoderOptions = EncoderOptions;

/**
* 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
* start a new document without creating a new instance.
*/
class JSONTextEncoder {
#encoder: Encoder;

/**
* @param options - Encoding options.
*/
constructor(options?: JSONTextEncoderOptions) {
this.#encoder = new Encoder({ ...DEFAULT_ENCODER_OPTIONS, ...options });
}

/**
* Returns the cumulative encoded bytes produced so far.
*
* The buffer is never cleared between calls; use {@link reset} to start fresh.
*
* @returns The cumulative encoded bytes produced so far.
*/
bytes(): Uint8Array {
return this.#encoder.bytes();
}

/**
* The current nesting depth — `0` at top level, incremented inside each
* object or array.
*
* @returns The current nesting depth.
*/
depth(): number {
return this.#encoder.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();
}

/**
* Resets the encoder to its initial state, clearing the output buffer and
* all structural state.
*/
reset(): void {
this.#encoder.reset();
}

/**
* Returns a JSON Pointer string describing a position in the current
* nesting context.
*
* | `where` | Meaning |
* |---------|---------------------------------------------------------------|
* | `1` | The position of the **next** value to be written (default). |
* | `0` | The position of the **current** container. |
* | `-1` | The position of the **previously** written value. |
*
* @param where - Which position to return. 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();
}

/**
* 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.
*/
writeToken(token: Token): void {
this.#encoder.writeToken(token);
}

/**
* 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.
*/
writeValue(value: Value): void {
this.#encoder.writeValue(value);
}
}

export default JSONTextEncoder;
export type { JSONTextEncoderOptions };
20 changes: 20 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const ASCII = {
SPACE: 0x20,
QUOTE: 0x22,
NUMBER_SIGN: 0x23,
DOLLAR_SIGN: 0x24,
AMPERSAND: 0x26,
SINGLE_QUOTE: 0x27,
ASTERISK: 0x2A,
PLUS: 0x2B,
COMMA: 0x2C,
MINUS: 0x2D,
Expand All @@ -23,7 +26,9 @@ const ASCII = {
COLON: 0x3A,
OPEN_ANGLED_BRACKET: 0x3C,
CLOSE_ANGLED_BRACKET: 0x3E,
UPPER_CASE_A: 0x41,
UPPER_CASE_E: 0x45,
UPPER_CASE_Z: 0x5A,
OPENING_BRACKET: 0x5B,
BACKSLASH: 0x5C,
CLOSING_BRACKET: 0x5D,
Expand All @@ -38,6 +43,7 @@ const ASCII = {
LOWER_CASE_S: 0x73,
LOWER_CASE_T: 0x74,
LOWER_CASE_U: 0x75,
LOWER_CASE_Z: 0x7A,
OPENING_BRACE: 0x7B,
CLOSING_BRACE: 0x7D,
DELETE: 0x7F,
Expand Down Expand Up @@ -81,11 +87,25 @@ const DEFAULT_ENCODER_OPTIONS = {
indentPrefix: "",
} as const;

const SELECTOR = {
NAME: "NAME",
WILDCARD: "WILDCARD",
INDEX: "INDEX",
ARRAY_SLICE: "ARRAY_SLICE",
} as const;

const SEGMENT = {
CHILD: "CHILD",
DESCENDANT: "DESCENDANT",
} as const;

export {
ASCII,
DEFAULT_DECODER_OPTIONS,
DEFAULT_ENCODER_OPTIONS,
KIND,
MAX_NESTING_DEPTH,
SEGMENT,
SELECTOR,
UNICODE,
};
Loading