Skip to content

Commit 522dc02

Browse files
committed
checkpoint
1 parent 936117e commit 522dc02

8 files changed

Lines changed: 159 additions & 69 deletions

File tree

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"editor.insertSpaces": false,
88
"editor.tabSize": 4,
99
"json.schemaDownload.enable": true,
10-
"js/ts.tsdk.path": "node_modules/typescript/lib"
10+
"js/ts.tsdk.path": "node_modules/typescript/lib",
11+
"[typescript]": {
12+
"editor.defaultFormatter": "vscode.typescript-language-features"
13+
}
1114
}

biome.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"recommended": true,
2222
"complexity": {
2323
"noCommaOperator": "off",
24+
"noStaticOnlyClass": "off",
2425
"noThisInStatic": "off"
2526
},
2627
"style": {

src/compiler/compiler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,5 @@ const host = new CompilerHost({
117117
include: ["src/test/example/*.resolver.json"],
118118
exclude: ["node_modules"],
119119
});
120+
121+
void host;

src/loader/index.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,41 @@ import { readFileSync } from "node:fs";
22
import { pathToFileURL } from "node:url";
33

44
import type { Format } from "../types/format.js";
5-
import type { Resolver } from "../types/resolver.js";
65
import type { Set } from "../types/resolver/set.js";
7-
8-
import { getAtPath, parsePointer } from "./pointer.js";
6+
import type { Resolver } from "../types/resolver.js";
97
import { mergeFormats } from "./merge.js";
8+
import { getAtPath, parsePointer } from "./pointer.js";
109

1110
// ─── Public Types ─────────────────────────────────────────────────────────────
1211

12+
/**
13+
* The I/O interface consumed by {@link LoaderHost}. Swap this out to run in
14+
* any environment — browser, Deno, test sandbox, etc.
15+
*
16+
* @example Browser virtual filesystem
17+
* const browserSys: LoaderSys = {
18+
* readFile: (url) => fileMap.get(url.href) ?? (() => { throw new Error(`Not found: ${url}`) })(),
19+
* currentDirectory: () => new URL("./", location.href),
20+
* }
21+
*/
22+
export interface LoaderSys {
23+
/** Reads the contents of the file at `url` and returns it as a UTF-8 string. */
24+
readFile(url: URL): string;
25+
26+
/** Returns the base URL used when no explicit base is provided to {@link LoaderHost.load}. */
27+
currentDirectory(): URL;
28+
}
29+
30+
/** Default {@link LoaderSys} for Node.js — reads files with `readFileSync`. */
31+
export const nodeSys: LoaderSys = {
32+
readFile: (url) => readFileSync(url, "utf8"),
33+
currentDirectory: () => pathToFileURL(`${process.cwd()}/`),
34+
};
35+
1336
export interface LoadOptions {
1437
/**
1538
* Base URL (or absolute path) used to resolve relative file references inside
16-
* a resolver document. Defaults to `file://{process.cwd()}/` when omitted.
39+
* a resolver document. Defaults to {@link LoaderSys.currentDirectory} when omitted.
1740
*/
1841
base?: URL | string;
1942
}
@@ -32,17 +55,25 @@ export interface LoadResult {
3255
* A stateful DTCG loader that caches every JSON file it reads.
3356
* Reuse a single `LoaderHost` instance across multiple `load()` calls to share
3457
* the cache and avoid re-reading the same files.
58+
*
59+
* Pass a custom {@link LoaderSys} to run outside of Node.js.
3560
*/
3661
export class LoaderHost {
62+
readonly sys: LoaderSys;
63+
3764
/** Parsed JSON values, keyed by URL href. */
3865
#cache = new Map<string, unknown>();
3966

67+
constructor(sys: LoaderSys = nodeSys) {
68+
this.sys = sys;
69+
}
70+
4071
/** Reads and JSON-parses a file at `url`, caching the result by href. */
4172
readJSON<T>(url: URL): T {
4273
const { href } = url;
4374

4475
if (!this.#cache.has(href)) {
45-
this.#cache.set(href, JSON.parse(readFileSync(url, "utf8")));
76+
this.#cache.set(href, JSON.parse(this.sys.readFile(url)));
4677
}
4778

4879
return this.#cache.get(href) as T;
@@ -60,7 +91,7 @@ export class LoaderHost {
6091
* merged into the final token tree.
6192
*/
6293
load(input: string | URL | Resolver, options?: LoadOptions): LoadResult {
63-
const defaultBase = pathToFileURL(`${process.cwd()}/`);
94+
const defaultBase = this.sys.currentDirectory();
6495
let resolver: Resolver;
6596
let resolverBase: URL;
6697

@@ -109,7 +140,7 @@ export class LoaderHost {
109140
* For repeated loads, prefer {@link LoaderHost} to share the internal file cache.
110141
*/
111142
export const load = (input: string | URL | Resolver, options?: LoadOptions): LoadResult =>
112-
new LoaderHost().load(input, options);
143+
new LoaderHost(nodeSys).load(input, options);
113144

114145
// ─── Internal helpers ─────────────────────────────────────────────────────────
115146

src/loader/merge.ts

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,96 @@
1-
import type { Format } from "../types/format.js"
1+
import type { Format } from "../types/format.js";
22

33
/**
4-
* Deep-merges an ordered iterable of DTCG {@link Format} objects into a single
5-
* combined token tree. Sources are applied left-to-right; later entries override
6-
* earlier ones at leaf (non-object) positions, while nested groups are recursively
7-
* merged so siblings from different sources are preserved.
4+
* Lazily merges an ordered array of DTCG {@link Format} objects into a single
5+
* combined token tree via a {@link Proxy}. Sources are applied left-to-right;
6+
* later entries override earlier ones at leaf (non-object) positions, while
7+
* nested groups are recursively merged so siblings from different sources are
8+
* preserved. No allocation occurs until a property is actually accessed.
89
*
910
* @example
1011
* mergeFormats([
1112
* { color: { red: { $type: "color", $value: "…" } } },
1213
* { color: { blue: { $type: "color", $value: "…" } } },
1314
* ])
14-
* // → { color: { red: { … }, blue: { … } } }
15+
* // → proxy that lazily produces { color: { red: { … }, blue: { … } } }
1516
*/
16-
export const mergeFormats = (formats: Iterable<Format>): Format => {
17-
const merged: Record<string, unknown> = {}
17+
export const mergeFormats = (formats: Format[]): Format => {
18+
const handler: ProxyHandler<object> = {
19+
get(_target, key) {
20+
if (typeof key !== "string") return undefined;
1821

19-
for (const format of formats) {
20-
deepMergeInto(merged, format as Record<string, unknown>)
21-
}
22+
const subFormats: Format[] = [];
23+
let leafValue: unknown;
24+
let hasLeaf = false;
2225

23-
return merged as Format
24-
}
26+
for (const format of formats) {
27+
const val = (format as Record<string, unknown>)[key];
28+
if (val === undefined) continue;
2529

26-
// ─── Internal ─────────────────────────────────────────────────────────────────
30+
if (isPlainObject(val)) {
31+
subFormats.push(val as Format);
32+
} else {
33+
// A later leaf (primitive or array) wins; discard accumulated sub-objects.
34+
subFormats.length = 0;
35+
leafValue = val;
36+
hasLeaf = true;
37+
}
38+
}
39+
40+
if (subFormats.length > 0) return mergeFormats(subFormats);
41+
if (hasLeaf) return leafValue;
42+
return undefined;
43+
},
44+
45+
has(_target, key) {
46+
if (typeof key !== "string") return false;
47+
return formats.some((f) => key in (f as object));
48+
},
49+
50+
ownKeys(_target) {
51+
const keys = new Set<string>();
52+
for (const format of formats) {
53+
for (const key in format) keys.add(key);
54+
}
55+
return [...keys];
56+
},
57+
58+
getOwnPropertyDescriptor(_target, key) {
59+
if (typeof key !== "string") return undefined;
60+
const exists = formats.some((f) => Object.hasOwn(f as object, key));
61+
if (!exists) return undefined;
62+
return { configurable: true, enumerable: true, writable: false, value: undefined };
63+
},
64+
};
65+
66+
// Each call gets a fresh subclass so its prototype slot is independent.
67+
const MergedFormat = class extends NullProxy {};
68+
return MergedFormat.from(handler) as unknown as Format;
69+
};
70+
71+
// ─── NullProxy ────────────────────────────────────────────────────────────────
2772

2873
/**
29-
* Recursively merges `source` into `target` in-place.
30-
* Plain objects are merged deeply; all other values (arrays, primitives) replace.
74+
* A base class whose instances have no default own properties and whose
75+
* prototype chain routes all property access through a caller-supplied
76+
* {@link ProxyHandler}. Callers should subclass and call `.from(handler)` on
77+
* the subclass so each merged view gets an isolated prototype slot.
3178
*/
32-
const deepMergeInto = (
33-
target: Record<string, unknown>,
34-
source: Record<string, unknown>,
35-
): void => {
36-
for (const [key, value] of Object.entries(source)) {
37-
if (isPlainObject(value) && isPlainObject(target[key])) {
38-
deepMergeInto(target[key] as Record<string, unknown>, value)
39-
} else {
40-
target[key] = value
41-
}
79+
80+
class NullProxy {
81+
static {
82+
// @ts-expect-error to fully nullify the prototype
83+
delete NullProxy.prototype.constructor;
84+
}
85+
86+
static from(handler: ProxyHandler<object>): NullProxy {
87+
const proxy = new Proxy(Object.create(null) as object, handler);
88+
Object.setPrototypeOf(this.prototype as object, proxy);
89+
return Reflect.construct(this, []) as NullProxy;
4290
}
4391
}
4492

45-
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
46-
value !== null && typeof value === "object" && !Array.isArray(value)
93+
// ─── Internal ─────────────────────────────────────────────────────────────────
4794

95+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
96+
value !== null && typeof value === "object" && !Array.isArray(value);

src/loader/pointer.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@
66
* @example parsePointer("#") // []
77
*/
88
export const parsePointer = (pointer: string): string[] => {
9-
if (pointer === "#") return []
9+
if (pointer === "#") return [];
1010

1111
if (!pointer.startsWith("#/")) {
12-
throw new Error(
13-
`Expected a local JSON Pointer (e.g. "#/sets/core"), got: ${JSON.stringify(pointer)}`,
14-
)
12+
throw new Error(`Expected a local JSON Pointer (e.g. "#/sets/core"), got: ${JSON.stringify(pointer)}`);
1513
}
1614

17-
return pointer.slice(2).split("/").map(decodeSegment)
18-
}
15+
return pointer.slice(2).split("/").map(decodeSegment);
16+
};
1917

2018
/**
2119
* Traverses a document by an array of path segments, returning the value at
@@ -24,28 +22,26 @@ export const parsePointer = (pointer: string): string[] => {
2422
* @example getAtPath(doc, ["sets", "core"]) // doc.sets.core
2523
*/
2624
export const getAtPath = (root: unknown, path: readonly string[]): unknown => {
27-
let node: unknown = root
25+
let node: unknown = root;
2826

2927
for (const key of path) {
3028
if (Array.isArray(node)) {
31-
const index = Number(key)
32-
node = Number.isInteger(index) ? node[index] : undefined
29+
const index = Number(key);
30+
node = Number.isInteger(index) ? node[index] : undefined;
3331
} else if (isObject(node)) {
34-
node = node[key]
32+
node = node[key];
3533
} else {
36-
return undefined
34+
return undefined;
3735
}
3836
}
3937

40-
return node
41-
}
38+
return node;
39+
};
4240

4341
// ─── Internal ─────────────────────────────────────────────────────────────────
4442

4543
/** Decodes a single RFC 6901 pointer segment (`~1` → `/`, `~0` → `~`). */
46-
const decodeSegment = (segment: string): string =>
47-
segment.replaceAll("~1", "/").replaceAll("~0", "~")
44+
const decodeSegment = (segment: string): string => segment.replaceAll("~1", "/").replaceAll("~0", "~");
4845

4946
const isObject = (value: unknown): value is Record<string, unknown> =>
50-
value !== null && typeof value === "object" && !Array.isArray(value)
51-
47+
value !== null && typeof value === "object" && !Array.isArray(value);

src/tree/index.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Format } from "../types/format.js";
21
import type { TokenType, TokenValue } from "../types/format/tokenType.js";
2+
import type { Format } from "../types/format.js";
33

44
const nodeInspect = Symbol.for("nodejs.util.inspect.custom");
55

@@ -22,9 +22,8 @@ export class TokenLeaf {
2222
this.value = value;
2323
}
2424

25-
[nodeInspect](_depth: number, options: object) {
26-
const { inspect } = require("node:util");
27-
return `TokenLeaf ${inspect({ type: this.type, value: this.value }, options)}`;
25+
[nodeInspect]() {
26+
return Object.assign(new (class TokenLeaf {})(), { type: this.type, value: this.value });
2827
}
2928
}
3029

@@ -53,9 +52,8 @@ export class TokenGroup {
5352
return this.#children.entries();
5453
}
5554

56-
[nodeInspect](_depth: number, options: object) {
57-
const { inspect } = require("node:util");
58-
return `TokenGroup ${inspect(Object.fromEntries(this.#children), options)}`;
55+
[nodeInspect]() {
56+
return Object.assign(new (class TokenGroup {})(), Object.fromEntries(this.#children));
5957
}
6058
}
6159

@@ -82,7 +80,8 @@ const buildGroup = (raw: RawObject, parentType: string | undefined): TokenGroup
8280
const ownType = typeof raw.$type === "string" ? raw.$type : parentType;
8381
const children = new Map<string, TokenNode>();
8482

85-
for (const [key, val] of Object.entries(raw)) {
83+
for (const key in raw) {
84+
const val = raw[key];
8685
if (key.startsWith("$") || !isObject(val)) continue;
8786

8887
children.set(

0 commit comments

Comments
 (0)