zopia 0.3.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.
- package/CHANGELOG.md +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zopia",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"api-docs",
|
|
7
|
+
"json-schema",
|
|
8
|
+
"km-api",
|
|
9
|
+
"openapi",
|
|
10
|
+
"swagger",
|
|
11
|
+
"typescript",
|
|
12
|
+
"zod"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/komeilm76/zopia#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/komeilm76/zopia/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/komeilm76/zopia.git"
|
|
21
|
+
},
|
|
22
|
+
"author": "komeilm76",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"src",
|
|
30
|
+
"docs",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"packageManager": "bun@1.2.21",
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.1",
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.20.4",
|
|
45
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
46
|
+
"km-api": "^0.4.1",
|
|
47
|
+
"typescript": "^5.9.2",
|
|
48
|
+
"vitest": "4.1.11",
|
|
49
|
+
"zod": "^4.6.5"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"km-api": "^0.4.1",
|
|
53
|
+
"zod": "^4.0.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"coverage": "vitest run --coverage && bun scripts/check-coverage.ts",
|
|
60
|
+
"package:check": "bun scripts/package-check.ts",
|
|
61
|
+
"release:check": "bun run bun:gate",
|
|
62
|
+
"prepublishOnly": "bun run release:check",
|
|
63
|
+
"bun:gate": "bun scripts/bun-gate.ts",
|
|
64
|
+
"golden:update": "bun scripts/update-golden.ts",
|
|
65
|
+
"build": "tsc --noEmit"
|
|
66
|
+
},
|
|
67
|
+
"bin": {
|
|
68
|
+
"zopia": "./bin/zopia.js"
|
|
69
|
+
},
|
|
70
|
+
"exports": {
|
|
71
|
+
".": {
|
|
72
|
+
"types": "./src/index.ts",
|
|
73
|
+
"import": "./src/index.ts",
|
|
74
|
+
"default": "./src/index.ts"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🧑💻 Spec ↔ generated-tree navigation (S-93).
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, manifest-driven index used by the CLI (`zopia navigate`),
|
|
5
|
+
* the VS Code extension, and any tooling that jumps between a Swagger/OpenAPI
|
|
6
|
+
* document and the generated api-docs tree. The index is built directly from
|
|
7
|
+
* the tree's `.zopia-manifest.json`, so every pointer the tree can answer is
|
|
8
|
+
* one generation produced — including custom companions (derived from the
|
|
9
|
+
* manifest's recorded `options.custom`, since companion files are not
|
|
10
|
+
* manifest-owned) and preset sub-trees (each bucket root carries its own
|
|
11
|
+
* manifest and therefore its own index).
|
|
12
|
+
*
|
|
13
|
+
* Everything here is pure data lookup besides {@link loadNavigationIndex},
|
|
14
|
+
* which is the single file-reading call.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ZOPIA_MANIFEST_FILE, type ZopiaManifest } from './conversions/manifest-writer';
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { ZopiaError } from './errors';
|
|
21
|
+
|
|
22
|
+
/** Category of a navigation target inside a generated tree. */
|
|
23
|
+
export type ZopiaNavigationKind = 'endpoint' | 'webhook' | 'component' | 'custom' | 'manifest';
|
|
24
|
+
|
|
25
|
+
/** One concrete navigation target in the tree. */
|
|
26
|
+
export interface ZopiaNavigationLocation {
|
|
27
|
+
/** Category of the target file. */
|
|
28
|
+
kind: ZopiaNavigationKind;
|
|
29
|
+
/** Tree-relative POSIX file path the target lives in. */
|
|
30
|
+
file: string;
|
|
31
|
+
/** JSON Pointer of the target in the source document (`#` for the manifest). */
|
|
32
|
+
pointer: string;
|
|
33
|
+
/** Human label, e.g. `get /pets (listPets)`. */
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Manifest-driven, deterministic navigation index of one generated tree. */
|
|
38
|
+
export interface ZopiaNavigationIndex {
|
|
39
|
+
/** The manifest the index was built from (verbatim, unparsed beyond shape checks). */
|
|
40
|
+
manifest: ZopiaManifest;
|
|
41
|
+
/**
|
|
42
|
+
* Map a source-document JSON pointer to every generated file implementing it.
|
|
43
|
+
* Supported shapes: `#` (manifest), `#/paths/<path>` (every method
|
|
44
|
+
* of the item), `#/paths/<path>/<method>`,
|
|
45
|
+
* `#/webhooks/<name>`(…/ method)`, `#/components/schemas/<name>`.
|
|
46
|
+
* Companion files follow their endpoint when `options.custom` was recorded.
|
|
47
|
+
* @param pointer Source-document JSON pointer (RFC 6901 fragment form).
|
|
48
|
+
* @returns Navigation locations in deterministic file order.
|
|
49
|
+
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` — pointer shape is unsupported, the target declares no generated module (e.g. components emission was disabled), or the pointer matches nothing the tree generated.
|
|
50
|
+
*/
|
|
51
|
+
specToLocations(pointer: string): ZopiaNavigationLocation[];
|
|
52
|
+
/**
|
|
53
|
+
* Map one tree-relative file back to its source-document pointer.
|
|
54
|
+
* Companion `custom.ts` files, component barrels, and the manifest resolve as expected.
|
|
55
|
+
* @param file Tree-relative path (`pets/get/index.ts`); `./` prefixes and backslash separators are normalized.
|
|
56
|
+
* @returns The single pointer-backed location owning that file.
|
|
57
|
+
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` — the file is not represented in this tree.
|
|
58
|
+
*/
|
|
59
|
+
treeToSpecLocation(file: string): ZopiaNavigationLocation;
|
|
60
|
+
/** Resolve one operationId to its pointer (stable for YAML specs that cannot be scanned for structure). @param operationId The declared or derived operationId visible in the spec. @returns The pointer of that operation, or `undefined` when the tree holds no such operation. */
|
|
61
|
+
pointerForOperationId(operationId: string): string | undefined;
|
|
62
|
+
/** All pointer-backed locations in deterministic file order (the jump table). @returns Location list suitable for building source maps. */
|
|
63
|
+
locations(): ZopiaNavigationLocation[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Escape one JSON Pointer segment per RFC 6901. */
|
|
67
|
+
const escapePointerSegment = (segment: string): string => segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
68
|
+
|
|
69
|
+
/** Decode one escaped JSON Pointer segment per RFC 6901. */
|
|
70
|
+
const decodePointerSegment = (segment: string): string => segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
71
|
+
|
|
72
|
+
/** %{method} %{path|name} (%{operationId}) label shared by both directions. */
|
|
73
|
+
const operationLabel = (section: 'paths' | 'webhooks', path: string, method: string, operationId: string | undefined): string =>
|
|
74
|
+
section === 'webhooks'
|
|
75
|
+
? `webhook ${path} ${method}${operationId ? ` (${operationId})` : ''}`
|
|
76
|
+
: `${method} ${path}${operationId ? ` (${operationId})` : ''}`;
|
|
77
|
+
|
|
78
|
+
/** Companion `custom.ts` file of one endpoint module (sibling of `index.ts`, both layouts). */
|
|
79
|
+
const companionFile = (file: string): string => file.slice(0, -'index.ts'.length) + 'custom.ts';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build a navigation index directly from a trusted manifest. **Pure** — no I/O.
|
|
83
|
+
*
|
|
84
|
+
* @param manifest The tree's `.zopia-manifest.json` as JSON (already validated manifests accepted as-is).
|
|
85
|
+
* @returns The navigation index.
|
|
86
|
+
* @throws {ZopiaError} `ZOPIA_MANIFEST_INVALID` — the manifest is not an object or lacks the `apis` array.
|
|
87
|
+
*/
|
|
88
|
+
export function navigationIndexFromManifest(manifest: ZopiaManifest): ZopiaNavigationIndex {
|
|
89
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !Array.isArray((manifest as { apis?: unknown }).apis)) {
|
|
90
|
+
throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'invalid manifest for navigation: expected an object with an apis array', { at: '#', hint: 'regenerate the tree to rebuild a valid manifest' });
|
|
91
|
+
}
|
|
92
|
+
const custom = (manifest.options as { custom?: unknown } | undefined)?.custom === true;
|
|
93
|
+
const byFile = new Map<string, ZopiaNavigationLocation>();
|
|
94
|
+
const byPointer = new Map<string, ZopiaNavigationLocation[]>();
|
|
95
|
+
const byOperationId = new Map<string, string>();
|
|
96
|
+
const rememberPointer = (pointer: string, location: ZopiaNavigationLocation): void => {
|
|
97
|
+
const existing = byPointer.get(pointer) ?? [];
|
|
98
|
+
existing.push(location); byPointer.set(pointer, existing);
|
|
99
|
+
};
|
|
100
|
+
const remember = (location: ZopiaNavigationLocation): void => {
|
|
101
|
+
byFile.set(location.file, location);
|
|
102
|
+
rememberPointer(location.pointer, location);
|
|
103
|
+
};
|
|
104
|
+
remember({ kind: 'manifest', file: ZOPIA_MANIFEST_FILE, pointer: '#', label: 'manifest' });
|
|
105
|
+
|
|
106
|
+
for (const api of manifest.apis) {
|
|
107
|
+
if (!api || typeof api !== 'object') continue;
|
|
108
|
+
const file = api.file;
|
|
109
|
+
if (file === undefined) continue;
|
|
110
|
+
const pointer = `#/paths/${escapePointerSegment(api.path)}/${api.method}`;
|
|
111
|
+
const location: ZopiaNavigationLocation = { kind: 'endpoint', file, pointer, label: operationLabel('paths', api.path, api.method, api.operationId) };
|
|
112
|
+
remember(location);
|
|
113
|
+
if (custom) remember({ kind: 'custom', file: companionFile(file), pointer, label: `${location.label} custom companion` });
|
|
114
|
+
if (api.operationId !== undefined && !byOperationId.has(api.operationId)) byOperationId.set(api.operationId, pointer);
|
|
115
|
+
}
|
|
116
|
+
for (const webhook of manifest.webhooks ?? []) {
|
|
117
|
+
if (!webhook || typeof webhook !== 'object') continue;
|
|
118
|
+
const file = webhook.file;
|
|
119
|
+
if (file === undefined) continue;
|
|
120
|
+
const pointer = `#/webhooks/${escapePointerSegment(webhook.name)}/${webhook.method}`;
|
|
121
|
+
const location: ZopiaNavigationLocation = { kind: 'webhook', file, pointer, label: operationLabel('webhooks', webhook.name, webhook.method, webhook.operationId) };
|
|
122
|
+
remember(location);
|
|
123
|
+
if (custom) remember({ kind: 'custom', file: companionFile(file), pointer, label: `${location.label} custom companion` });
|
|
124
|
+
if (webhook.operationId !== undefined && !byOperationId.has(webhook.operationId)) byOperationId.set(webhook.operationId, pointer);
|
|
125
|
+
}
|
|
126
|
+
const components = manifest.components ?? [];
|
|
127
|
+
let hasComponentFile = false;
|
|
128
|
+
for (const component of components) {
|
|
129
|
+
if (!component || typeof component !== 'object') continue;
|
|
130
|
+
if (typeof component.file !== 'string') continue;
|
|
131
|
+
hasComponentFile = true;
|
|
132
|
+
remember({ kind: 'component', file: component.file, pointer: `#/components/schemas/${escapePointerSegment(component.name)}`, label: `component ${component.name}` });
|
|
133
|
+
}
|
|
134
|
+
if (hasComponentFile) remember({ kind: 'component', file: 'components/index.ts', pointer: '#/components/schemas', label: 'component barrel' });
|
|
135
|
+
|
|
136
|
+
const locationsSorted = [...byFile.values()].sort((left, right) => left.file < right.file ? -1 : left.file > right.file ? 1 : 0);
|
|
137
|
+
const index: ZopiaNavigationIndex = {
|
|
138
|
+
manifest,
|
|
139
|
+
specToLocations(pointer) {
|
|
140
|
+
if (pointer === '#') return [{ kind: 'manifest', file: ZOPIA_MANIFEST_FILE, pointer: '#', label: 'manifest' }];
|
|
141
|
+
const segments = pointer.startsWith('#/') ? pointer.slice(2).split('/').map(decodePointerSegment) : undefined;
|
|
142
|
+
if (!segments || segments.length === 0) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported spec pointer for navigation: ${pointer}`, { at: pointer, hint: "use '#', '#/paths/<path>[/<method>]', '#/webhooks/<name>[/<method>]', or '#/components/schemas/<name>'" });
|
|
143
|
+
const [section, ...rest] = segments;
|
|
144
|
+
if (section === 'paths' || section === 'webhooks') {
|
|
145
|
+
const [name, method, ...extra] = rest;
|
|
146
|
+
if (name === undefined || extra.length > 0) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported spec pointer for navigation: ${pointer}`, { at: pointer, hint: `use '${section === 'paths' ? '#/paths/<path>/<method>' : '#/webhooks/<name>/<method>'}'` });
|
|
147
|
+
if (method === undefined) {
|
|
148
|
+
// Item-level pointer: every routed operation of the item (plus companions).
|
|
149
|
+
const collected: ZopiaNavigationLocation[] = [];
|
|
150
|
+
for (const location of locationsSorted) {
|
|
151
|
+
if (!location.pointer.startsWith(`${pointer}/`)) continue;
|
|
152
|
+
if (location.pointer.slice(pointer.length + 1).includes('/')) continue;
|
|
153
|
+
collected.push(location);
|
|
154
|
+
}
|
|
155
|
+
if (collected.length > 0) return collected;
|
|
156
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `spec pointer has no generated module: ${pointer}`, { at: pointer, hint: 'check the item name, or regenerate from a newer source document' });
|
|
157
|
+
}
|
|
158
|
+
const hits = byPointer.get(pointer);
|
|
159
|
+
if (hits && hits.length > 0) return hits;
|
|
160
|
+
// exact pointer has no implementation — distinguish unheard targets from disabled sections
|
|
161
|
+
if (section === 'webhooks') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `spec pointer has no generated module: ${pointer}`, { at: pointer, hint: 'check the webhook name and method, or regenerate from a newer source document' });
|
|
162
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `spec pointer has no generated module: ${pointer}`, { at: pointer, hint: 'check the path and method, or regenerate from a newer source document' });
|
|
163
|
+
}
|
|
164
|
+
if (section === 'components' && rest[0] === 'schemas') {
|
|
165
|
+
const name = rest[1];
|
|
166
|
+
if (name === undefined || rest.length > 2) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported spec pointer for navigation: ${pointer}`, { at: pointer, hint: "use '#/components/schemas/<name>'" });
|
|
167
|
+
const hits = byPointer.get(pointer);
|
|
168
|
+
if (hits && hits.length > 0) return hits;
|
|
169
|
+
if (components.some((component) => component?.name === name)) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `component ${name} declares no generated module: ${pointer}`, { at: pointer, hint: 'regenerate with insertComponents (--insert-components) to emit component modules' });
|
|
170
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `spec pointer has no generated module: ${pointer}`, { at: pointer, hint: 'check the component name, or regenerate from a newer source document' });
|
|
171
|
+
}
|
|
172
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported spec pointer for navigation: ${pointer}`, { at: pointer, hint: "use '#', '#/paths/<path>[/<method>]', '#/webhooks/<name>[/<method>]', or '#/components/schemas/<name>'" });
|
|
173
|
+
},
|
|
174
|
+
treeToSpecLocation(file) {
|
|
175
|
+
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
176
|
+
const hit = byFile.get(normalized);
|
|
177
|
+
if (hit) return hit;
|
|
178
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `file is not represented in this tree: ${file}`, { at: file, hint: 'pass a generated path relative to the tree root, e.g. pets/get/index.ts' });
|
|
179
|
+
},
|
|
180
|
+
pointerForOperationId(operationId) {
|
|
181
|
+
return byOperationId.get(operationId);
|
|
182
|
+
},
|
|
183
|
+
locations() {
|
|
184
|
+
return locationsSorted;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
return index;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Load the navigation index of one generated tree by reading its manifest.
|
|
192
|
+
* Each preset bucket root carries its own manifest and therefore its own index.
|
|
193
|
+
*
|
|
194
|
+
* @param outDir Directory holding a `.zopia-manifest.json` (tree root or preset bucket root).
|
|
195
|
+
* @returns The navigation index for that tree.
|
|
196
|
+
* @throws {ZopiaError} `ZOPIA_DOCS_MISSING_MANIFEST` — no readable manifest at `outDir` (generate first, with manifests enabled).
|
|
197
|
+
* @throws {ZopiaError} `ZOPIA_MANIFEST_INVALID` — the manifest is not valid JSON or lacks the `apis` array.
|
|
198
|
+
*/
|
|
199
|
+
export async function loadNavigationIndex(outDir: string): Promise<ZopiaNavigationIndex> {
|
|
200
|
+
const manifestPath = join(outDir, ZOPIA_MANIFEST_FILE);
|
|
201
|
+
let text: string;
|
|
202
|
+
try { text = await readFile(manifestPath, 'utf8'); }
|
|
203
|
+
catch (error) { throw new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `Unable to load the manifest: ${error instanceof Error ? error.message : String(error)}`, { at: manifestPath, hint: 'generate the tree first (regeneration keeps manifests enabled by default)' }); }
|
|
204
|
+
let manifest: ZopiaManifest;
|
|
205
|
+
try { manifest = JSON.parse(text) as ZopiaManifest; }
|
|
206
|
+
catch { throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'invalid manifest for navigation: not valid JSON', { at: manifestPath, hint: 'regenerate the tree to rebuild a valid manifest' }); }
|
|
207
|
+
return navigationIndexFromManifest(manifest);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Map JSON-pointers to their declaration line (1-based) in one JSON document
|
|
212
|
+
* **text** — a single-pass whitespace/token scanner, no parser dependency.
|
|
213
|
+
* Absent from the returned map = pointer not declared in this document;
|
|
214
|
+
* the first occurrence wins for duplicate JSON keys.
|
|
215
|
+
*
|
|
216
|
+
* @param jsonText Raw JSON source text (`.json` spec files).
|
|
217
|
+
* @param pointers Fragment pointers such as `#/paths/~1pets/get`.
|
|
218
|
+
* @returns Map of found pointer → 1-based declaration line.
|
|
219
|
+
* @throws {ZopiaError} `ZOPIA_SPEC_INVALID_JSON` — the text is not valid JSON.
|
|
220
|
+
*/
|
|
221
|
+
export function specPointersToLines(jsonText: string, pointers: string[]): Map<string, number> {
|
|
222
|
+
const targets = new Map<string, string[] | null>();
|
|
223
|
+
for (const pointer of pointers) {
|
|
224
|
+
targets.set(pointer, pointer.startsWith('#/') ? pointer.slice(2).split('/').map(decodePointerSegment) : null);
|
|
225
|
+
}
|
|
226
|
+
const found = new Map<string, number>();
|
|
227
|
+
let index = 0;
|
|
228
|
+
let line = 1;
|
|
229
|
+
const text = jsonText;
|
|
230
|
+
const advance = (): void => { while (index < text.length && ' \t\r\n\f\v'.includes(text[index])) { if (text[index] === '\n') line += 1; index += 1; } };
|
|
231
|
+
const scanString = (): string => {
|
|
232
|
+
// Caller positioned index at the opening quote; scanString returns the decoded value.
|
|
233
|
+
let depth = 0;
|
|
234
|
+
let end = index + 1;
|
|
235
|
+
while (end < text.length) {
|
|
236
|
+
const char = text[end];
|
|
237
|
+
if (char === '\\') { if (text[end + 1] === '\n') { throw invalidJson(); } end += 2; }
|
|
238
|
+
else if (char === '"') { depth = 1; end += 1; break; }
|
|
239
|
+
else { if (char === '\n') { // raw newline inside a string is invalid JSON
|
|
240
|
+
throw invalidJson();
|
|
241
|
+
} end += 1; }
|
|
242
|
+
}
|
|
243
|
+
if (end > text.length || depth !== 1) throw invalidJson();
|
|
244
|
+
let value: string;
|
|
245
|
+
try { value = JSON.parse(text.slice(index, end)) as string; }
|
|
246
|
+
catch { throw invalidJson(); }
|
|
247
|
+
const stringBody = text.slice(index + 1, end - 1);
|
|
248
|
+
const newlineCount = (stringBody.match(/\n/g) ?? []).length;
|
|
249
|
+
line += newlineCount;
|
|
250
|
+
index = end;
|
|
251
|
+
return value;
|
|
252
|
+
};
|
|
253
|
+
const invalidJson = (): ZopiaError => new ZopiaError('ZOPIA_SPEC_INVALID_JSON', 'Invalid JSON during pointer scan', { at: '#', hint: 'fix the JSON syntax' });
|
|
254
|
+
const scanValue = (): void => {
|
|
255
|
+
advance();
|
|
256
|
+
if (index >= text.length) throw invalidJson();
|
|
257
|
+
const char = text[index];
|
|
258
|
+
if (char === '{') { scanObject(); return; }
|
|
259
|
+
if (char === '[') { scanArray(); return; }
|
|
260
|
+
if (char === '"') { scanString(); return; }
|
|
261
|
+
// scalars: number/true/false/null — consume until a structural boundary
|
|
262
|
+
const scalars = new Set(['t', 'f', 'n', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
|
263
|
+
if (!scalars.has(char)) throw invalidJson();
|
|
264
|
+
while (index < text.length && !' \t\r\n,}]'.includes(text[index])) index += 1;
|
|
265
|
+
};
|
|
266
|
+
/** Stack of object-key segments along the current path ('' inside arrays). */
|
|
267
|
+
const path: string[] = [];
|
|
268
|
+
const scanObject = (): void => {
|
|
269
|
+
index += 1; // '{'
|
|
270
|
+
advance();
|
|
271
|
+
if (text[index] === '}') { index += 1; return; }
|
|
272
|
+
// empty object
|
|
273
|
+
while (true) {
|
|
274
|
+
advance();
|
|
275
|
+
if (text[index] !== '"') throw invalidJson();
|
|
276
|
+
const keyStartLine = line;
|
|
277
|
+
const key = scanString();
|
|
278
|
+
path.push(key);
|
|
279
|
+
for (const [pointer, segments] of targets) {
|
|
280
|
+
if (segments === null || found.has(pointer)) continue;
|
|
281
|
+
if (segments.length !== path.length) continue;
|
|
282
|
+
let match = true;
|
|
283
|
+
for (let depth = 0; depth < segments.length; depth += 1) { if (segments[depth] !== path[depth]) { match = false; break; } }
|
|
284
|
+
if (match) found.set(pointer, keyStartLine);
|
|
285
|
+
}
|
|
286
|
+
advance();
|
|
287
|
+
if (text[index] !== ':') throw invalidJson();
|
|
288
|
+
index += 1;
|
|
289
|
+
scanValue();
|
|
290
|
+
path.pop();
|
|
291
|
+
advance();
|
|
292
|
+
if (text[index] === ',') { index += 1; continue; }
|
|
293
|
+
if (text[index] === '}') { index += 1; return; }
|
|
294
|
+
throw invalidJson();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const scanArray = (): void => {
|
|
298
|
+
index += 1; // '['
|
|
299
|
+
advance();
|
|
300
|
+
if (text[index] === ']') { index += 1; return; }
|
|
301
|
+
// elements share the enclosing key path ('' placeholder keeps depth honest)
|
|
302
|
+
path.push('');
|
|
303
|
+
while (true) {
|
|
304
|
+
scanValue();
|
|
305
|
+
advance();
|
|
306
|
+
if (text[index] === ',') { index += 1; continue; }
|
|
307
|
+
if (text[index] === ']') { index += 1; path.pop(); return; }
|
|
308
|
+
throw invalidJson();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
scanValue();
|
|
312
|
+
advance();
|
|
313
|
+
if (index < text.length) throw invalidJson();
|
|
314
|
+
return found;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Map ONE pointer to its declaration line — convenience wrapper of
|
|
319
|
+
* {@link specPointersToLines}.
|
|
320
|
+
*
|
|
321
|
+
* @param jsonText Raw JSON source text.
|
|
322
|
+
* @param pointer Fragment pointer such as `#/paths/~1pets/get`.
|
|
323
|
+
* @returns The 1-based declaration line, or `undefined` when not declared.
|
|
324
|
+
* @throws {ZopiaError} `ZOPIA_SPEC_INVALID_JSON` — the text is not valid JSON.
|
|
325
|
+
*/
|
|
326
|
+
export function specPointerToLine(jsonText: string, pointer: string): number | undefined {
|
|
327
|
+
return specPointersToLines(jsonText, [pointer]).get(pointer);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Choose the pointer whose declaration line most closely precedes (or equals)
|
|
332
|
+
* a given line — the "which operation am I standing in" rule editors use.
|
|
333
|
+
* Ties prefer the longer (deeper) pointer, then lexical order for determinism.
|
|
334
|
+
*
|
|
335
|
+
* @param jsonText Raw JSON source text.
|
|
336
|
+
* @param line 1-based target line (e.g. the cursor line).
|
|
337
|
+
* @param pointers Candidate pointers (typically `index.locations()` pointers).
|
|
338
|
+
* @returns The winning pointer, or `undefined` when none is at-or-before the line.
|
|
339
|
+
* @throws {ZopiaError} `ZOPIA_SPEC_INVALID_JSON` — the text is not valid JSON.
|
|
340
|
+
*/
|
|
341
|
+
export function specPointerAtLine(jsonText: string, line: number, pointers: string[]): string | undefined {
|
|
342
|
+
const lines = specPointersToLines(jsonText, pointers);
|
|
343
|
+
let winner: string | undefined;
|
|
344
|
+
let winnerLine = -1;
|
|
345
|
+
for (const pointer of pointers) {
|
|
346
|
+
const found = lines.get(pointer);
|
|
347
|
+
if (found === undefined || found > line) continue;
|
|
348
|
+
if (found > winnerLine || (found === winnerLine && winner !== undefined && (pointer.length > winner.length || pointer.length === winner.length && pointer < winner))) {
|
|
349
|
+
winner = pointer; winnerLine = found;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return winner;
|
|
353
|
+
}
|