@kubohiroya/turbowarp-structured-data 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ {
2
+ "extensionName": "Structured Data",
3
+ "blocks": [
4
+ {
5
+ "opcode": "parseJson", "blockType": "COMMAND", "text": "parse JSON [TEXT] as [NAME]", "description": "Parse JSON text into the target-local structured data namespace.",
6
+ "arguments": {"TEXT": {"type": "STRING", "defaultValue": "{\"name\":\"TurboWarp\"}"}, "NAME": {"type": "STRING", "defaultValue": "data"}},
7
+ "resultType": "void", "effect": "state", "immutable": true, "errors": ["INVALID_JSON", "INVALID_NAME", "PARSE_LIMIT_EXCEEDED"],
8
+ "server": {"supported": true, "irOperation": "structuredData.parseJson"}
9
+ },
10
+ {
11
+ "opcode": "parseYaml", "blockType": "COMMAND", "text": "parse YAML [TEXT] as [NAME]", "description": "Parse safe YAML text into the target-local structured data namespace.",
12
+ "arguments": {"TEXT": {"type": "STRING", "defaultValue": "name: TurboWarp"}, "NAME": {"type": "STRING", "defaultValue": "data"}},
13
+ "resultType": "void", "effect": "state", "immutable": true, "errors": ["INVALID_YAML", "INVALID_NAME", "PARSE_LIMIT_EXCEEDED"],
14
+ "server": {"supported": true, "irOperation": "structuredData.parseYaml"}
15
+ },
16
+ {
17
+ "opcode": "toJson", "blockType": "REPORTER", "text": "to JSON [NAME]", "description": "Render named structured data as canonical JSON.",
18
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}},
19
+ "resultType": "jsonText", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND"],
20
+ "server": {"supported": true, "irOperation": "structuredData.toJson"}
21
+ },
22
+ {
23
+ "opcode": "toYaml", "blockType": "REPORTER", "text": "to YAML [NAME]", "description": "Render named structured data as deterministic YAML.",
24
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}},
25
+ "resultType": "yamlText", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND"],
26
+ "server": {"supported": true, "irOperation": "structuredData.toYaml"}
27
+ },
28
+ {
29
+ "opcode": "hasStructuredData", "blockType": "BOOLEAN", "text": "structured data [NAME] exists?", "description": "Report whether the current target has named structured data.",
30
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}},
31
+ "resultType": "boolean", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME"],
32
+ "server": {"supported": true, "irOperation": "structuredData.hasData"}
33
+ },
34
+ {
35
+ "opcode": "deleteStructuredData", "blockType": "COMMAND", "text": "delete structured data [NAME]", "description": "Delete named structured data from the current target.",
36
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}},
37
+ "resultType": "void", "effect": "state", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND"],
38
+ "server": {"supported": true, "irOperation": "structuredData.deleteData"}
39
+ },
40
+ {
41
+ "opcode": "getJsonAtPath", "blockType": "REPORTER", "text": "get JSON from [NAME] at path [PATH]", "description": "Return a named value at a path as JSON.",
42
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$.name", "normalizesTo": "pathSegments"}},
43
+ "resultType": "jsonText", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE"],
44
+ "server": {"supported": true, "irOperation": "structuredData.get"}
45
+ },
46
+ {
47
+ "opcode": "hasPath", "blockType": "BOOLEAN", "text": "structured data [NAME] has path [PATH]?", "description": "Report whether a path exists in named structured data.",
48
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$.name", "normalizesTo": "pathSegments"}},
49
+ "resultType": "boolean", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "TYPE_MISMATCH"],
50
+ "server": {"supported": true, "irOperation": "structuredData.has"}
51
+ },
52
+ {
53
+ "opcode": "setJsonAtPath", "blockType": "COMMAND", "text": "set [NAME] at path [PATH] to JSON [VALUE]", "description": "Immutably update a named value and replace its binding.",
54
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$.name", "normalizesTo": "pathSegments"}, "VALUE": {"type": "STRING", "defaultValue": "\"Scratch\""}},
55
+ "resultType": "void", "effect": "state", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_JSON", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE"],
56
+ "server": {"supported": true, "irOperation": "structuredData.set"}
57
+ },
58
+ {
59
+ "opcode": "deleteAtPath", "blockType": "COMMAND", "text": "delete path [PATH] from [NAME]", "description": "Immutably delete a path and replace its named binding.",
60
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$.name", "normalizesTo": "pathSegments"}},
61
+ "resultType": "void", "effect": "state", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE"],
62
+ "server": {"supported": true, "irOperation": "structuredData.delete"}
63
+ },
64
+ {
65
+ "opcode": "keysAtPath", "blockType": "REPORTER", "text": "keys in [NAME] at path [PATH]", "description": "Return sorted object keys as JSON.",
66
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$", "normalizesTo": "pathSegments"}},
67
+ "resultType": "jsonText", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE"],
68
+ "server": {"supported": true, "irOperation": "structuredData.keys"}
69
+ },
70
+ {
71
+ "opcode": "lengthAtPath", "blockType": "REPORTER", "text": "length of array in [NAME] at path [PATH]", "description": "Return the array length.",
72
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$", "normalizesTo": "pathSegments"}},
73
+ "resultType": "number", "effect": "pure", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE"],
74
+ "server": {"supported": true, "irOperation": "structuredData.length"}
75
+ },
76
+ {
77
+ "opcode": "forEachAtPath", "blockType": "LOOP", "text": "for each in [NAME] at path [PATH] max [MAX]", "description": "Iterate named data deterministically with a static maximum.",
78
+ "arguments": {"NAME": {"type": "STRING", "defaultValue": "data"}, "PATH": {"type": "STRING", "defaultValue": "$", "normalizesTo": "pathSegments"}, "MAX": {"type": "NUMBER", "defaultValue": 100, "staticLiteral": true, "minimum": 1, "maximum": 1000}},
79
+ "resultType": "void", "effect": "control", "immutable": true, "errors": ["INVALID_NAME", "DATA_NOT_FOUND", "INVALID_PATH", "PATH_NOT_FOUND", "TYPE_MISMATCH", "INDEX_OUT_OF_RANGE", "ITERATION_LIMIT_EXCEEDED"],
80
+ "server": {"supported": true, "irOperation": "structuredData.forEach"}
81
+ },
82
+ {
83
+ "opcode": "currentKey", "blockType": "REPORTER", "text": "current key", "description": "Return the current object key or array index string.",
84
+ "arguments": {}, "resultType": "string", "effect": "control", "immutable": true, "errors": ["ITERATION_CONTEXT_REQUIRED"],
85
+ "server": {"supported": true, "irOperation": "structuredData.currentKey"}
86
+ },
87
+ {
88
+ "opcode": "currentIndex", "blockType": "REPORTER", "text": "current index", "description": "Return the zero-based iteration number.",
89
+ "arguments": {}, "resultType": "number", "effect": "control", "immutable": true, "errors": ["ITERATION_CONTEXT_REQUIRED"],
90
+ "server": {"supported": true, "irOperation": "structuredData.currentIndex"}
91
+ },
92
+ {
93
+ "opcode": "currentValueJson", "blockType": "REPORTER", "text": "current value JSON", "description": "Return the current iteration value as JSON.",
94
+ "arguments": {}, "resultType": "jsonText", "effect": "control", "immutable": true, "errors": ["ITERATION_CONTEXT_REQUIRED"],
95
+ "server": {"supported": true, "irOperation": "structuredData.currentValue"}
96
+ }
97
+ ]
98
+ }
package/src/codecs.ts ADDED
@@ -0,0 +1,204 @@
1
+ import {
2
+ isAlias,
3
+ isMap,
4
+ isScalar,
5
+ isSeq,
6
+ LineCounter,
7
+ parseDocument,
8
+ type Node as YamlNode,
9
+ type Pair as YamlPair
10
+ } from 'yaml';
11
+ import {
12
+ compareUnicodeCodePoints,
13
+ parseJson,
14
+ stringifyJson,
15
+ StructuredDataError,
16
+ type JsonValue
17
+ } from './core.js';
18
+
19
+ export const PARSE_LIMITS = {
20
+ maxInputBytes: 256 * 1024,
21
+ maxDepth: 64,
22
+ maxNodes: 50_000
23
+ } as const;
24
+
25
+ interface ParseBudget {
26
+ nodes: number;
27
+ }
28
+
29
+ const SIMPLE_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/u;
30
+
31
+ export function parseJsonDocument(source: string): JsonValue {
32
+ validateInputSize(source);
33
+ const value = parseJson(source);
34
+ validateValueBudget(value, {nodes: 0}, 0);
35
+ return value;
36
+ }
37
+
38
+ export function parseYamlDocument(source: string): JsonValue {
39
+ validateInputSize(source);
40
+ const lineCounter = new LineCounter();
41
+ const document = parseDocument(source, {
42
+ lineCounter,
43
+ prettyErrors: false,
44
+ schema: 'core',
45
+ uniqueKeys: true
46
+ });
47
+ const problem = document.errors[0] ?? document.warnings[0];
48
+ if (problem) {
49
+ const {line, column} = yamlPosition(lineCounter, problem.pos[0]);
50
+ throw new StructuredDataError(
51
+ 'INVALID_YAML',
52
+ `${problem.code} at line ${line}, column ${column}: ${problem.message}`
53
+ );
54
+ }
55
+ try {
56
+ return valueFromYamlNode(document.contents, {nodes: 0}, 0, lineCounter);
57
+ } catch (error) {
58
+ if (error instanceof StructuredDataError) throw error;
59
+ throw new StructuredDataError(
60
+ 'INVALID_YAML',
61
+ error instanceof Error ? error.message : String(error)
62
+ );
63
+ }
64
+ }
65
+
66
+ export function renderJsonDocument(value: JsonValue): string {
67
+ return stringifyJson(value);
68
+ }
69
+
70
+ export function renderYamlDocument(value: JsonValue): string {
71
+ return renderYamlValue(value, 0);
72
+ }
73
+
74
+ function valueFromYamlNode(
75
+ node: YamlNode<unknown> | null | unknown,
76
+ budget: ParseBudget,
77
+ depth: number,
78
+ lineCounter: LineCounter
79
+ ): JsonValue {
80
+ consumeBudget(budget, depth);
81
+ if (node === null) return null;
82
+ if (isAlias(node)) {
83
+ throw new StructuredDataError('INVALID_YAML', 'YAML aliases are not allowed.');
84
+ }
85
+ if (isScalar(node)) {
86
+ const value = node.value;
87
+ if (
88
+ value === null ||
89
+ typeof value === 'string' ||
90
+ typeof value === 'boolean' ||
91
+ (typeof value === 'number' && Number.isFinite(value))
92
+ ) {
93
+ return value;
94
+ }
95
+ throw new StructuredDataError('INVALID_YAML', 'YAML contains an unsupported scalar value.');
96
+ }
97
+ if (isSeq(node)) {
98
+ return node.items.map((item) => valueFromYamlNode(item, budget, depth + 1, lineCounter));
99
+ }
100
+ if (isMap(node)) {
101
+ return Object.fromEntries(
102
+ node.items.map((item) => yamlPairToEntry(item, budget, depth, lineCounter))
103
+ );
104
+ }
105
+ throw new StructuredDataError('INVALID_YAML', 'YAML contains an unsupported node type.');
106
+ }
107
+
108
+ function yamlPairToEntry(
109
+ pair: YamlPair,
110
+ budget: ParseBudget,
111
+ depth: number,
112
+ lineCounter: LineCounter
113
+ ): [string, JsonValue] {
114
+ if (!isScalar(pair.key) || typeof pair.key.value !== 'string') {
115
+ throw new StructuredDataError('INVALID_YAML', 'YAML map keys must be strings.');
116
+ }
117
+ return [pair.key.value, valueFromYamlNode(pair.value, budget, depth + 1, lineCounter)];
118
+ }
119
+
120
+ function validateValueBudget(value: JsonValue, budget: ParseBudget, depth: number): void {
121
+ consumeBudget(budget, depth);
122
+ if (Array.isArray(value)) {
123
+ for (const item of value) validateValueBudget(item, budget, depth + 1);
124
+ } else if (typeof value === 'object' && value !== null) {
125
+ for (const item of Object.values(value)) validateValueBudget(item, budget, depth + 1);
126
+ }
127
+ }
128
+
129
+ function consumeBudget(budget: ParseBudget, depth: number): void {
130
+ if (depth > PARSE_LIMITS.maxDepth) {
131
+ throw new StructuredDataError(
132
+ 'PARSE_LIMIT_EXCEEDED',
133
+ `Data exceeds maximum nesting depth ${PARSE_LIMITS.maxDepth}.`
134
+ );
135
+ }
136
+ budget.nodes += 1;
137
+ if (budget.nodes > PARSE_LIMITS.maxNodes) {
138
+ throw new StructuredDataError(
139
+ 'PARSE_LIMIT_EXCEEDED',
140
+ `Data exceeds maximum node count ${PARSE_LIMITS.maxNodes}.`
141
+ );
142
+ }
143
+ }
144
+
145
+ function validateInputSize(source: string): void {
146
+ const bytes = new TextEncoder().encode(source).byteLength;
147
+ if (bytes > PARSE_LIMITS.maxInputBytes) {
148
+ throw new StructuredDataError(
149
+ 'PARSE_LIMIT_EXCEEDED',
150
+ `Input is ${bytes} bytes; maximum is ${PARSE_LIMITS.maxInputBytes}.`
151
+ );
152
+ }
153
+ }
154
+
155
+ function renderYamlValue(value: JsonValue, indent: number): string {
156
+ if (isObject(value)) {
157
+ const entries = Object.keys(value).sort(compareUnicodeCodePoints);
158
+ if (entries.length === 0) return `${spaces(indent)}{}`;
159
+ return entries
160
+ .map((key) => renderValuePair(key, value[key] as JsonValue, indent))
161
+ .join('\n');
162
+ }
163
+ if (Array.isArray(value)) {
164
+ if (value.length === 0) return `${spaces(indent)}[]`;
165
+ return value.map((item) => renderSequenceItem(item, indent)).join('\n');
166
+ }
167
+ return `${spaces(indent)}${renderScalar(value)}`;
168
+ }
169
+
170
+ function renderValuePair(key: string, value: JsonValue, indent: number): string {
171
+ const prefix = `${spaces(indent)}${renderKey(key)}:`;
172
+ if (!isObject(value) && !Array.isArray(value)) return `${prefix} ${renderScalar(value)}`;
173
+ return `${prefix}\n${renderYamlValue(value, indent + 2)}`;
174
+ }
175
+
176
+ function renderSequenceItem(value: JsonValue, indent: number): string {
177
+ const prefix = `${spaces(indent)}-`;
178
+ if (!isObject(value) && !Array.isArray(value)) return `${prefix} ${renderScalar(value)}`;
179
+ return `${prefix}\n${renderYamlValue(value, indent + 2)}`;
180
+ }
181
+
182
+ function renderKey(key: string): string {
183
+ return SIMPLE_KEY_PATTERN.test(key) ? key : JSON.stringify(key);
184
+ }
185
+
186
+ function renderScalar(value: null | boolean | number | string): string {
187
+ if (value === null) return 'null';
188
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
189
+ if (typeof value === 'number') return String(value);
190
+ return JSON.stringify(value);
191
+ }
192
+
193
+ function isObject(value: JsonValue): value is {[key: string]: JsonValue} {
194
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
195
+ }
196
+
197
+ function yamlPosition(counter: LineCounter, offset: number): {line: number; column: number} {
198
+ const position = counter.linePos(offset);
199
+ return {line: position.line, column: position.col};
200
+ }
201
+
202
+ function spaces(count: number): string {
203
+ return ' '.repeat(count);
204
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ export const STRUCTURED_DATA_MVP_DEFAULT = false;
2
+ export const NAMED_DATA_REGISTRY_MVP_DEFAULT = false;
3
+
4
+ export function isStructuredDataMvpEnabled(
5
+ source: {STRUCTURED_DATA_MVP?: unknown} = globalThis as {STRUCTURED_DATA_MVP?: unknown}
6
+ ): boolean {
7
+ const value = source.STRUCTURED_DATA_MVP;
8
+ return value === undefined ? STRUCTURED_DATA_MVP_DEFAULT : value === true || value === 'true';
9
+ }
10
+
11
+ export function isNamedDataRegistryMvpEnabled(
12
+ source: {NAMED_DATA_REGISTRY_MVP?: unknown} = globalThis as {
13
+ NAMED_DATA_REGISTRY_MVP?: unknown;
14
+ }
15
+ ): boolean {
16
+ const value = source.NAMED_DATA_REGISTRY_MVP;
17
+ return value === undefined ? NAMED_DATA_REGISTRY_MVP_DEFAULT : value === true || value === 'true';
18
+ }
19
+
20
+ export const extensionConfig = {
21
+ id: 'kubohiroyastructureddata',
22
+ slug: 'structured-data',
23
+ name: 'Structured Data',
24
+ description: 'Named structured data with JSON/YAML codecs for TurboWarp and server compilation.',
25
+ author: 'Hiroya Kubo',
26
+ license: 'MPL-2.0',
27
+ unsandboxed: true,
28
+ docsURI: 'https://kubohiroya.github.io/turbowarp-structured-data/',
29
+ blockIconURI:
30
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCI+PHJlY3QgeD0iNCIgeT0iOCIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE0IiByeD0iMyIgZmlsbD0iIzRDOTdGRiIvPjxyZWN0IHg9IjI2IiB5PSI4IiB3aWR0aD0iMTgiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjNTlDMDU5Ii8+PHJlY3QgeD0iMTUiIHk9IjI2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjRkZBQjE5Ii8+PC9zdmc+'
31
+ } as const;
package/src/core.ts ADDED
@@ -0,0 +1,309 @@
1
+ export const STRUCTURED_DATA_ERROR_CODES = [
2
+ 'INVALID_JSON',
3
+ 'INVALID_YAML',
4
+ 'INVALID_NAME',
5
+ 'DATA_NOT_FOUND',
6
+ 'INVALID_PATH',
7
+ 'PATH_NOT_FOUND',
8
+ 'TYPE_MISMATCH',
9
+ 'INDEX_OUT_OF_RANGE',
10
+ 'ITERATION_LIMIT_EXCEEDED',
11
+ 'ITERATION_CONTEXT_REQUIRED',
12
+ 'PARSE_LIMIT_EXCEEDED'
13
+ ] as const;
14
+
15
+ export type StructuredDataErrorCode = (typeof STRUCTURED_DATA_ERROR_CODES)[number];
16
+ export type JsonPrimitive = null | boolean | number | string;
17
+ export type JsonValue = JsonPrimitive | JsonValue[] | {[key: string]: JsonValue};
18
+ export type PathSegment = {kind: 'key'; value: string} | {kind: 'index'; value: number};
19
+
20
+ export class StructuredDataError extends Error {
21
+ public readonly code: StructuredDataErrorCode;
22
+
23
+ public constructor(code: StructuredDataErrorCode, message: string) {
24
+ super(`${code}: ${message}`);
25
+ this.name = 'StructuredDataError';
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ export function parseJson(text: unknown): JsonValue {
31
+ try {
32
+ const value: unknown = JSON.parse(String(text));
33
+ assertJsonValue(value);
34
+ return value;
35
+ } catch (error) {
36
+ if (error instanceof StructuredDataError) throw error;
37
+ throw new StructuredDataError('INVALID_JSON', 'Input must be valid JSON.');
38
+ }
39
+ }
40
+
41
+ export function isValidJson(text: unknown): boolean {
42
+ try {
43
+ parseJson(text);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function normalizeJson(text: unknown): string {
51
+ return stringifyJson(parseJson(text));
52
+ }
53
+
54
+ export function stringifyJson(value: JsonValue): string {
55
+ if (Array.isArray(value)) return `[${value.map(stringifyJson).join(',')}]`;
56
+ if (isJsonObject(value)) {
57
+ return `{${Object.keys(value)
58
+ .sort(compareUnicodeCodePoints)
59
+ .map((key) => `${JSON.stringify(key)}:${stringifyJson(value[key] as JsonValue)}`)
60
+ .join(',')}}`;
61
+ }
62
+ return JSON.stringify(value);
63
+ }
64
+
65
+ export function parsePath(input: unknown): PathSegment[] {
66
+ const path = String(input);
67
+ if (!path.startsWith('$')) invalidPath();
68
+ const segments: PathSegment[] = [];
69
+ let cursor = 1;
70
+
71
+ while (cursor < path.length) {
72
+ if (path[cursor] === '.') {
73
+ const match = /^[A-Za-z_][A-Za-z0-9_]*/u.exec(path.slice(cursor + 1));
74
+ if (!match) invalidPath();
75
+ segments.push({kind: 'key', value: match[0]});
76
+ cursor += match[0].length + 1;
77
+ continue;
78
+ }
79
+
80
+ if (path[cursor] !== '[') invalidPath();
81
+ cursor += 1;
82
+ if (path[cursor] === '"') {
83
+ const end = scanJsonString(path, cursor);
84
+ let key: unknown;
85
+ try {
86
+ key = JSON.parse(path.slice(cursor, end));
87
+ } catch {
88
+ invalidPath();
89
+ }
90
+ if (typeof key !== 'string' || path[end] !== ']') invalidPath();
91
+ segments.push({kind: 'key', value: key});
92
+ cursor = end + 1;
93
+ continue;
94
+ }
95
+
96
+ const match = /^(0|[1-9][0-9]*)\]/u.exec(path.slice(cursor));
97
+ if (!match) invalidPath();
98
+ const index = Number(match[1]);
99
+ if (!Number.isSafeInteger(index)) invalidPath();
100
+ segments.push({kind: 'index', value: index});
101
+ cursor += match[0].length;
102
+ }
103
+
104
+ return segments;
105
+ }
106
+
107
+ export function getAtPath(root: JsonValue, segments: readonly PathSegment[]): JsonValue {
108
+ let current = root;
109
+ for (const segment of segments) current = readSegment(current, segment);
110
+ return current;
111
+ }
112
+
113
+ export function hasAtPath(root: JsonValue, segments: readonly PathSegment[]): boolean {
114
+ try {
115
+ getAtPath(root, segments);
116
+ return true;
117
+ } catch (error) {
118
+ if (
119
+ error instanceof StructuredDataError &&
120
+ (error.code === 'PATH_NOT_FOUND' || error.code === 'INDEX_OUT_OF_RANGE')
121
+ ) {
122
+ return false;
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ export function setAtPath(
129
+ root: JsonValue,
130
+ segments: readonly PathSegment[],
131
+ replacement: JsonValue
132
+ ): JsonValue {
133
+ if (segments.length === 0) return cloneJson(replacement);
134
+ return updateParent(root, segments, (parent, segment) => {
135
+ if (segment.kind === 'key') {
136
+ if (!isJsonObject(parent)) typeMismatch('Object path segment requires an object.');
137
+ return {...parent, [segment.value]: cloneJson(replacement)};
138
+ }
139
+ if (!Array.isArray(parent)) typeMismatch('Index path segment requires an array.');
140
+ if (segment.value >= parent.length) indexOutOfRange();
141
+ const result = parent.slice();
142
+ result[segment.value] = cloneJson(replacement);
143
+ return result;
144
+ });
145
+ }
146
+
147
+ export function deleteAtPath(root: JsonValue, segments: readonly PathSegment[]): JsonValue {
148
+ if (segments.length === 0) {
149
+ throw new StructuredDataError('INVALID_PATH', 'The root value cannot be deleted.');
150
+ }
151
+ return updateParent(root, segments, (parent, segment) => {
152
+ if (segment.kind === 'key') {
153
+ if (!isJsonObject(parent)) typeMismatch('Object path segment requires an object.');
154
+ if (!Object.hasOwn(parent, segment.value)) pathNotFound();
155
+ const result = {...parent};
156
+ delete result[segment.value];
157
+ return result;
158
+ }
159
+ if (!Array.isArray(parent)) typeMismatch('Index path segment requires an array.');
160
+ if (segment.value >= parent.length) indexOutOfRange();
161
+ const result = parent.slice();
162
+ result.splice(segment.value, 1);
163
+ return result;
164
+ });
165
+ }
166
+
167
+ export function keysAtPath(root: JsonValue, segments: readonly PathSegment[]): string[] {
168
+ const value = getAtPath(root, segments);
169
+ if (!isJsonObject(value)) typeMismatch('Keys require an object.');
170
+ return Object.keys(value).sort(compareUnicodeCodePoints);
171
+ }
172
+
173
+ export function lengthAtPath(root: JsonValue, segments: readonly PathSegment[]): number {
174
+ const value = getAtPath(root, segments);
175
+ if (!Array.isArray(value)) typeMismatch('Length requires an array.');
176
+ return value.length;
177
+ }
178
+
179
+ export interface IterationEntry {
180
+ key: string;
181
+ value: JsonValue;
182
+ }
183
+
184
+ export function iterationEntries(
185
+ root: JsonValue,
186
+ segments: readonly PathSegment[],
187
+ maximum: number
188
+ ): IterationEntry[] {
189
+ if (!Number.isInteger(maximum) || maximum < 1 || maximum > 1000) {
190
+ throw new StructuredDataError('ITERATION_LIMIT_EXCEEDED', 'Maximum must be from 1 to 1000.');
191
+ }
192
+ const value = getAtPath(root, segments);
193
+ const entries = Array.isArray(value)
194
+ ? value.map((item, index) => ({key: String(index), value: item}))
195
+ : isJsonObject(value)
196
+ ? Object.keys(value)
197
+ .sort(compareUnicodeCodePoints)
198
+ .map((key) => ({key, value: value[key] as JsonValue}))
199
+ : typeMismatch('Iteration requires an array or object.');
200
+ if (entries.length > maximum) {
201
+ throw new StructuredDataError(
202
+ 'ITERATION_LIMIT_EXCEEDED',
203
+ `Collection contains ${entries.length} items, above maximum ${maximum}.`
204
+ );
205
+ }
206
+ return entries;
207
+ }
208
+
209
+ function updateParent(
210
+ root: JsonValue,
211
+ segments: readonly PathSegment[],
212
+ update: (parent: JsonValue, segment: PathSegment) => JsonValue
213
+ ): JsonValue {
214
+ const [segment, ...remaining] = segments;
215
+ if (!segment) return root;
216
+ if (remaining.length === 0) return update(root, segment);
217
+ const child = readSegment(root, segment);
218
+ const next = updateParent(child, remaining, update);
219
+ if (segment.kind === 'key') {
220
+ if (!isJsonObject(root)) typeMismatch('Object path segment requires an object.');
221
+ return {...root, [segment.value]: next};
222
+ }
223
+ if (!Array.isArray(root)) typeMismatch('Index path segment requires an array.');
224
+ const result = root.slice();
225
+ result[segment.value] = next;
226
+ return result;
227
+ }
228
+
229
+ function readSegment(value: JsonValue, segment: PathSegment): JsonValue {
230
+ if (segment.kind === 'key') {
231
+ if (!isJsonObject(value)) typeMismatch('Object path segment requires an object.');
232
+ if (!Object.hasOwn(value, segment.value)) pathNotFound();
233
+ return value[segment.value] as JsonValue;
234
+ }
235
+ if (!Array.isArray(value)) typeMismatch('Index path segment requires an array.');
236
+ if (segment.value >= value.length) indexOutOfRange();
237
+ return value[segment.value] as JsonValue;
238
+ }
239
+
240
+ function cloneJson(value: JsonValue): JsonValue {
241
+ if (Array.isArray(value)) return value.map(cloneJson);
242
+ if (isJsonObject(value)) {
243
+ return Object.fromEntries(
244
+ Object.keys(value)
245
+ .map((key) => [key, cloneJson(value[key] as JsonValue)])
246
+ );
247
+ }
248
+ return value;
249
+ }
250
+
251
+ function assertJsonValue(value: unknown): asserts value is JsonValue {
252
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
253
+ if (typeof value === 'number' && Number.isFinite(value)) return;
254
+ if (Array.isArray(value)) {
255
+ for (const item of value) assertJsonValue(item);
256
+ return;
257
+ }
258
+ if (typeof value === 'object') {
259
+ for (const item of Object.values(value)) assertJsonValue(item);
260
+ return;
261
+ }
262
+ throw new StructuredDataError('INVALID_JSON', 'JSON contains an unsupported value.');
263
+ }
264
+
265
+ function isJsonObject(value: JsonValue): value is {[key: string]: JsonValue} {
266
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
267
+ }
268
+
269
+ function scanJsonString(path: string, start: number): number {
270
+ let escaped = false;
271
+ for (let index = start + 1; index < path.length; index += 1) {
272
+ const character = path[index];
273
+ if (escaped) {
274
+ escaped = false;
275
+ } else if (character === '\\') {
276
+ escaped = true;
277
+ } else if (character === '"') {
278
+ return index + 1;
279
+ }
280
+ }
281
+ invalidPath();
282
+ }
283
+
284
+ export function compareUnicodeCodePoints(left: string, right: string): number {
285
+ const leftPoints = Array.from(left, (character) => character.codePointAt(0) as number);
286
+ const rightPoints = Array.from(right, (character) => character.codePointAt(0) as number);
287
+ const length = Math.min(leftPoints.length, rightPoints.length);
288
+ for (let index = 0; index < length; index += 1) {
289
+ const difference = (leftPoints[index] as number) - (rightPoints[index] as number);
290
+ if (difference !== 0) return difference;
291
+ }
292
+ return leftPoints.length - rightPoints.length;
293
+ }
294
+
295
+ function invalidPath(): never {
296
+ throw new StructuredDataError('INVALID_PATH', 'Path does not match the supported syntax.');
297
+ }
298
+
299
+ function pathNotFound(): never {
300
+ throw new StructuredDataError('PATH_NOT_FOUND', 'Path does not exist.');
301
+ }
302
+
303
+ function typeMismatch(message: string): never {
304
+ throw new StructuredDataError('TYPE_MISMATCH', message);
305
+ }
306
+
307
+ function indexOutOfRange(): never {
308
+ throw new StructuredDataError('INDEX_OUT_OF_RANGE', 'Array index is out of range.');
309
+ }