layout-data 0.2.0 → 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/src/layout.js ADDED
@@ -0,0 +1,303 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseDocument } from "yaml";
3
+ import { CODECS } from "./codecs.js";
4
+ import { register, registered_of, set_compiled } from "./registry.js";
5
+ import { build_structure, validate_structure, TYPE_BITS } from "./structure.js";
6
+
7
+ /** @import { Layout_Source, Registered_Layouts, Layout } from "./typedefs.js" */
8
+ /** @import { Data_Struct } from "./internal_types.js" */
9
+
10
+ /**
11
+ * The canonical text of a value: object keys sorted, `comment` left out (it
12
+ * does not change how a layout works) and the type names in lower case.
13
+ */
14
+ function canonical(value, key) {
15
+ if (Array.isArray(value)) return `[${value.map((v) => canonical(v)).join(",")}]`;
16
+ if (value !== null && typeof value === "object") {
17
+ const fields = Object.keys(value).filter((k) => k !== "comment" && value[k] !== undefined).sort();
18
+ return `{${fields.map((k) => `${JSON.stringify(k)}:${canonical(value[k], k)}`).join(",")}}`;
19
+ }
20
+ if (key === "type" && typeof value === "string") return JSON.stringify(value.toLowerCase());
21
+ return JSON.stringify(value) ?? "null";
22
+ }
23
+
24
+ /**
25
+ * Compiles an expanded structure (the result of build_structure) into a Layout
26
+ * (doc/design.md §3) and registers it under its name, the one source of the
27
+ * layouts an Ldata is created from. Registering a structure whose canonical
28
+ * text equals the registered one of that name gives the registered Layout, so
29
+ * the same layout described twice is compiled once; another structure under a
30
+ * used name throws. Throws on an unknown type or a duplicated tag name; the
31
+ * other layout rules are checked by validate_structure. Internal.
32
+ *
33
+ * @param {Data_Struct} data_struct
34
+ * @returns {Layout}
35
+ * @throws {Error} When the name is used by another layout.
36
+ */
37
+ export function register_structure(data_struct) {
38
+ const { name } = data_struct;
39
+ if (typeof name !== "string" || name === "") throw new Error("a layout needs a name");
40
+ const key = key_of(data_struct);
41
+ const known = registered_of(name);
42
+ if (known) {
43
+ if (known.key !== key) throw new Error(`another layout is already named ${name}`);
44
+ return known.layout;
45
+ }
46
+ const layout = compile_structure(data_struct);
47
+ register(name, { key, layout });
48
+ return layout;
49
+ }
50
+
51
+ function key_of(data_struct) {
52
+ return canonical({ name: data_struct.name, length: data_struct.length, items: data_struct.items });
53
+ }
54
+
55
+ function compile_structure(data_struct) {
56
+ const size = data_struct.length >> 3;
57
+ const entries = new Map();
58
+ const tags_at_byte = Array.from({ length: size }, () => []);
59
+ for (const item of data_struct.items) {
60
+ const { name, offset, init_value } = item;
61
+ const type = item.type.toLowerCase();
62
+ const codec = CODECS[type];
63
+ const fail = (message) => {
64
+ throw new Error(`${data_struct.name}.${name}: ${message}`);
65
+ };
66
+ if (!codec) fail(`unknown tag type ${item.type}`);
67
+ if (entries.has(name)) fail("duplicated tag name");
68
+ // defensive checks of what an Ldata relies on; validate_structure reports them all
69
+ if (type !== "bool" && offset % 8 !== 0) fail(`bit offset ${offset % 8} on a ${item.type}`);
70
+ if (codec.size > 1 && (offset >> 3) % 2 !== 0) fail(`${item.type} at the odd byte ${offset >> 3}`);
71
+ if (!["number", "boolean"].includes(typeof init_value)) fail(`init_value ${init_value} is not a number or boolean`);
72
+ const byte = offset >> 3;
73
+ const entry = {
74
+ name, type, init_value, byte,
75
+ index: entries.size,
76
+ size: codec.size,
77
+ mask: type === "bool" ? 1 << (offset % 8) : 0xff,
78
+ codec,
79
+ };
80
+ entries.set(name, entry);
81
+ // entries keep the item order: a combined word comes before its bits
82
+ for (let i = byte; i < byte + codec.size && i < size; i++) tags_at_byte[i].push(entry);
83
+ }
84
+
85
+ // the public layout holds the name, the sizes and frozen copies of the items;
86
+ // the entries and the lookup table stay out of reach (doc/spec-data-api.md §6.6)
87
+ const layout = Object.freeze({
88
+ name: data_struct.name,
89
+ length: data_struct.length,
90
+ size,
91
+ items: Object.freeze(data_struct.items.map(public_tag)),
92
+ });
93
+ set_compiled(layout, { entries, tags_at_byte });
94
+ return layout;
95
+ }
96
+
97
+ /**
98
+ * The frozen description of a tag of the public layout: the documented fields
99
+ * only, the length filled in from the type when the item has none.
100
+ */
101
+ function public_tag(item) {
102
+ const tag = {
103
+ name: item.name,
104
+ type: item.type,
105
+ offset: item.offset,
106
+ length: item.length ?? TYPE_BITS[item.type.toLowerCase()],
107
+ init_value: item.init_value,
108
+ };
109
+ if (item.comment !== undefined) tag.comment = item.comment;
110
+ tag.coupling = Object.freeze([...(item.coupling ?? [])]);
111
+ return Object.freeze(tag);
112
+ }
113
+
114
+ /**
115
+ * Resolves the fragment references of a list of items: `coupling` and
116
+ * `includes` given as a fragment name are replaced by a copy of that
117
+ * fragment, recursively. Omitted lengths are filled in from the type.
118
+ */
119
+ function resolve_items(list, fragments, stack, where, errors) {
120
+ if (!Array.isArray(list)) {
121
+ errors.push(`${where}: expected a list of items`);
122
+ return [];
123
+ }
124
+ const resolve_ref = (ref, field) => {
125
+ if (Array.isArray(ref)) return resolve_items(ref, fragments, stack, where, errors);
126
+ if (typeof ref !== "string") {
127
+ errors.push(`${where}: ${field} must be a fragment name or a list of items`);
128
+ return [];
129
+ }
130
+ if (stack.includes(ref)) {
131
+ errors.push(`${where}: circular fragment reference ${[...stack, ref].join(" -> ")}`);
132
+ return [];
133
+ }
134
+ if (!Object.hasOwn(fragments, ref)) {
135
+ errors.push(`${where}: unknown fragment ${ref}`);
136
+ return [];
137
+ }
138
+ return resolve_items(fragments[ref], fragments, [...stack, ref], `fragment ${ref}`, errors);
139
+ };
140
+ return list.map((_item) => {
141
+ const item = { ..._item };
142
+ if (item.coupling !== undefined) item.coupling = resolve_ref(item.coupling, "coupling");
143
+ if (item.includes !== undefined) item.includes = resolve_ref(item.includes, "includes");
144
+ const bits = TYPE_BITS[item.type?.toLowerCase()];
145
+ if (item.length === undefined && bits !== undefined) item.length = bits;
146
+ return item;
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Reads the sources of the layouts (doc/design.md §3), merges their layouts
152
+ * and fragments into one description and registers it with
153
+ * register_layout_data. Each source is the path of a YAML layout file or a
154
+ * parsed description; a list of sources is merged, so a layout may name a
155
+ * fragment of any source whatever the order. The root key `layouts` of a
156
+ * source maps layout names to item lists; every other root key is a fragment
157
+ * referenced by name.
158
+ *
159
+ * @param {Layout_Source | Layout_Source[]} sources
160
+ * @returns {Registered_Layouts}
161
+ * @throws {Error} With `errors`, every violation found, when the sources are not valid.
162
+ */
163
+ export function register_sources(sources) {
164
+ const errors = [];
165
+ const list = Array.isArray(sources) ? sources : [sources];
166
+ if (list.length === 0) errors.push("no source is given");
167
+ const origins = [];
168
+ const layout_defs = {};
169
+ const fragment_defs = {};
170
+ // the origin of each layout and fragment, to name both in a duplicate
171
+ const owners = { layout: new Map(), fragment: new Map() };
172
+ const add = (origin, root) => {
173
+ origins.push(origin);
174
+ if (root === null || typeof root !== "object" || Array.isArray(root)) {
175
+ errors.push(`${origin}: the root must be a mapping`);
176
+ return;
177
+ }
178
+ const { layouts, ...fragments } = root;
179
+ if (layouts !== undefined && (layouts === null || typeof layouts !== "object" || Array.isArray(layouts))) {
180
+ errors.push(`${origin}: the root key \`layouts\` must map layout names to item lists`);
181
+ return;
182
+ }
183
+ const merge = (kind, defs, into) => {
184
+ for (const [name, value] of Object.entries(defs)) {
185
+ if (owners[kind].has(name)) {
186
+ errors.push(`${kind} ${name} is defined in ${owners[kind].get(name)} and ${origin}`);
187
+ continue;
188
+ }
189
+ owners[kind].set(name, origin);
190
+ into[name] = value;
191
+ }
192
+ };
193
+ merge("layout", layouts ?? {}, layout_defs);
194
+ merge("fragment", fragments, fragment_defs);
195
+ };
196
+ list.forEach((source, index) => {
197
+ const label = Array.isArray(sources) ? `layout description #${index + 1}` : "layout description";
198
+ if (Array.isArray(source)) {
199
+ errors.push(`${label}: a list of sources must not contain a list`);
200
+ } else if (typeof source === "string") {
201
+ let text;
202
+ try {
203
+ text = readFileSync(source, "utf8");
204
+ } catch (error) {
205
+ errors.push(error.message);
206
+ return;
207
+ }
208
+ const doc = parseDocument(text, { version: "1.2" });
209
+ if (doc.errors.length > 0) errors.push(...doc.errors.map((e) => `${source}: ${e.message}`));
210
+ else add(source, doc.toJS());
211
+ } else {
212
+ add(label, source);
213
+ }
214
+ });
215
+ if (errors.length === 0 && Object.keys(layout_defs).length === 0) errors.push("no layout is defined in the sources");
216
+ if (errors.length > 0) throw layout_error("layout sources", errors);
217
+ return register_layout_data({ layouts: layout_defs, ...fragment_defs }, origins.join(", "));
218
+ }
219
+
220
+ /** Set once a call of register_layout has registered the layouts. */
221
+ let registered_once = false;
222
+
223
+ /**
224
+ * Registers the layouts of the sources, once per process: a layout is
225
+ * described, registered under its name, and then used by that name
226
+ * (doc/spec-data-api.md §6.4). Nothing is registered when a source is not
227
+ * valid, and such a call does not use up the single call.
228
+ *
229
+ * @param {Layout_Source | Layout_Source[]} sources - The path of a YAML layout file, a parsed description, or a list of them.
230
+ * @returns {Registered_Layouts}
231
+ * @throws {Error} With `errors`, every violation found, when the sources are not valid.
232
+ * @throws {Error} When the layouts were registered by an earlier call.
233
+ */
234
+ export function register_layout(sources) {
235
+ if (registered_once) throw new Error("register_layout can be called only once");
236
+ const result = register_sources(sources);
237
+ registered_once = true;
238
+ return result;
239
+ }
240
+
241
+ function layout_error(subject, errors) {
242
+ const error = new Error(`invalid ${subject}:\n${errors.join("\n")}`);
243
+ error.errors = errors;
244
+ return error;
245
+ }
246
+
247
+ /**
248
+ * The conflicts of structures with the layouts registered under their names.
249
+ *
250
+ * @param {Record<string, Data_Struct>} structs - By layout name.
251
+ * @returns {string[]}
252
+ */
253
+ function find_conflicts(structs) {
254
+ const conflicts = [];
255
+ for (const [name, struct] of Object.entries(structs)) {
256
+ const known = registered_of(name);
257
+ if (known && known.key !== key_of(struct)) conflicts.push(`layout ${name}: another layout is already named ${name}`);
258
+ }
259
+ return conflicts;
260
+ }
261
+
262
+ /**
263
+ * Registers the layouts of one description that is parsed and complete: the
264
+ * root key `layouts` and the fragments the layouts name. Internal.
265
+ *
266
+ * @param {Record<string, any>} root - The parsed description: `layouts` and the fragments.
267
+ * @param {string} [origin] - Names the source in errors.
268
+ * @returns {Registered_Layouts}
269
+ * @throws {Error} With `errors`, every violation found, when the description is not valid.
270
+ */
271
+ export function register_layout_data(root, origin = "the given object") {
272
+ const fail = (errors) => {
273
+ throw layout_error(`layout description from ${origin}`, errors);
274
+ };
275
+ if (root === null || typeof root !== "object" || Array.isArray(root)) fail(["the root must be a mapping"]);
276
+ const { layouts: layout_defs, ...fragment_defs } = root;
277
+ if (layout_defs === null || typeof layout_defs !== "object" || Array.isArray(layout_defs)) {
278
+ fail(["the root key `layouts` must map layout names to item lists"]);
279
+ }
280
+
281
+ const errors = [];
282
+ const fragments = {};
283
+ for (const [name, list] of Object.entries(fragment_defs)) {
284
+ fragments[name] = resolve_items(list, fragment_defs, [name], `fragment ${name}`, errors);
285
+ }
286
+ const structs = {};
287
+ for (const [name, list] of Object.entries(layout_defs)) {
288
+ const items = resolve_items(list, fragment_defs, [], `layout ${name}`, errors);
289
+ structs[name] = build_structure({ name }, items);
290
+ }
291
+ if (errors.length === 0) {
292
+ for (const struct of Object.values(structs)) errors.push(...validate_structure(struct));
293
+ }
294
+ if (errors.length === 0) {
295
+ // all or none: a conflict is found before any of the layouts is registered
296
+ errors.push(...find_conflicts(structs));
297
+ }
298
+ if (errors.length > 0) fail(errors);
299
+
300
+ const layouts = {};
301
+ for (const [name, struct] of Object.entries(structs)) layouts[name] = register_structure(struct);
302
+ return { layouts, fragments };
303
+ }
@@ -0,0 +1,147 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ /** @import { Compiled_Layout, Layout_Entry } from "./internal_types.js" */
4
+
5
+ // a change made by a change handler is detected in a further round
6
+ const MAX_DIFF_ROUNDS = 16;
7
+
8
+ const same_value = (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b));
9
+
10
+ /**
11
+ * Detects the changes of an Ldata buffer by comparing it with a snapshot
12
+ * (doc/design.md §5.1) and reports each changed tag once.
13
+ *
14
+ * Every write path of the Ldata registers the byte range it wrote with
15
+ * touch(), then calls diff() (in a transaction: at its commit). Only the
16
+ * registered range is compared, the other bytes equal the snapshot by
17
+ * construction. A diff() called while the changes are being reported, by a
18
+ * handler writing a tag, is only recorded; the outer diff() runs another
19
+ * round once the current changes are reported.
20
+ */
21
+ export class Observer {
22
+ /** @type {Buffer} */
23
+ #buffer;
24
+ /** @type {string} */
25
+ #name;
26
+ /** @type {Compiled_Layout} */
27
+ #compiled;
28
+ /** @type {Buffer} */
29
+ #snapshot;
30
+ /** @type {(entry: Layout_Entry, old_value: any, new_value: any, errors: unknown[]) => void} */
31
+ #report;
32
+ #diffing = false;
33
+ #pending = false;
34
+ /** The byte range [#start, #end) written since the last collection. */
35
+ #start = Number.POSITIVE_INFINITY;
36
+ #end = 0;
37
+ /** @type {Uint32Array} */
38
+ #seen;
39
+ #mark = 0;
40
+
41
+ /**
42
+ * @param {Buffer} buffer - The buffer of the Ldata.
43
+ * @param {string} name - The layout name, for errors.
44
+ * @param {Compiled_Layout} compiled - The compiled data of the layout of the Ldata.
45
+ * @param {(entry: Layout_Entry, old_value: any, new_value: any, errors: unknown[]) => void} report - Called for each change; what a handler throws goes to `errors`.
46
+ */
47
+ constructor(buffer, name, compiled, report) {
48
+ this.#buffer = buffer;
49
+ this.#name = name;
50
+ this.#compiled = compiled;
51
+ this.#report = report;
52
+ this.#snapshot = Buffer.from(buffer);
53
+ this.#seen = new Uint32Array(compiled.entries.size);
54
+ }
55
+
56
+ /**
57
+ * Registers the byte range [start, end) as written.
58
+ *
59
+ * @param {number} start
60
+ * @param {number} end - Exclusive.
61
+ */
62
+ touch(start, end) {
63
+ if (start >= end) return;
64
+ if (start < this.#start) this.#start = start;
65
+ if (end > this.#end) this.#end = end;
66
+ }
67
+
68
+ /**
69
+ * Forgets the registered range: the buffer was restored to the snapshot.
70
+ */
71
+ discard() {
72
+ this.#start = Number.POSITIVE_INFINITY;
73
+ this.#end = 0;
74
+ }
75
+
76
+ /**
77
+ * Reports every tag changed since the last diff. Nothing is thrown: what
78
+ * the handlers throw, and the exception of changes that do not settle, are
79
+ * pushed to `errors` after every change has been reported (doc/spec-data-api.md §4.4).
80
+ *
81
+ * @param {unknown[]} errors
82
+ */
83
+ diff(errors) {
84
+ if (this.#diffing) {
85
+ this.#pending = true;
86
+ return;
87
+ }
88
+ this.#diffing = true;
89
+ try {
90
+ let rounds = 0;
91
+ do {
92
+ this.#pending = false;
93
+ if (++rounds > MAX_DIFF_ROUNDS) {
94
+ errors.push(new Error(`${this.#name}: changes do not settle after ${MAX_DIFF_ROUNDS} rounds`));
95
+ break;
96
+ }
97
+ for (const [entry, old_value, new_value] of this.#collect()) {
98
+ this.#report(entry, old_value, new_value, errors);
99
+ }
100
+ } while (this.#pending);
101
+ } finally {
102
+ this.#diffing = false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Collects the changed tags, in layout order, and updates the snapshot.
108
+ * Old values are decoded from the snapshot, so no value is cached.
109
+ */
110
+ #collect() {
111
+ const buffer = this.#buffer;
112
+ const snapshot = this.#snapshot;
113
+ const tags_at_byte = this.#compiled.tags_at_byte;
114
+ // entries already checked in this collection, by entry index
115
+ const seen = this.#seen;
116
+ if (this.#mark === 0xffffffff) {
117
+ // the marks wrap around: clear them
118
+ seen.fill(0);
119
+ this.#mark = 0;
120
+ }
121
+ const mark = ++this.#mark;
122
+ const changes = [];
123
+ let in_order = true;
124
+ let last_index = -1;
125
+ // a write during the reporting registers its range for the next round
126
+ const start = this.#start;
127
+ const end = Math.min(this.#end, buffer.length);
128
+ this.discard();
129
+ for (let i = start; i < end; i++) {
130
+ if (buffer[i] === snapshot[i]) continue;
131
+ for (const entry of tags_at_byte[i]) {
132
+ if (seen[entry.index] === mark) continue;
133
+ seen[entry.index] = mark;
134
+ const { codec, byte, mask } = entry;
135
+ const old_value = codec.get(snapshot, byte, mask);
136
+ const new_value = codec.get(buffer, byte, mask);
137
+ if (same_value(old_value, new_value)) continue;
138
+ changes.push([entry, old_value, new_value]);
139
+ if (entry.index < last_index) in_order = false;
140
+ last_index = entry.index;
141
+ }
142
+ }
143
+ if (start < end) buffer.copy(snapshot, start, start, end);
144
+ if (!in_order) changes.sort((a, b) => a[0].index - b[0].index);
145
+ return changes;
146
+ }
147
+ }
@@ -0,0 +1,83 @@
1
+ // The registry of the compiled layouts, by layout name (doc/design.md §3).
2
+ // A name identifies one layout: it is the only source of the layouts an Ldata
3
+ // is created from. Written by register_structure (layout.js), read by Ldata.
4
+
5
+ /** @import { Layout } from "./typedefs.js" */
6
+ /** @import { Compiled_Layout } from "./internal_types.js" */
7
+
8
+ /**
9
+ * @typedef {object} Registered
10
+ * @property {string} key - The canonical text of the structure, to tell the same layout from another one.
11
+ * @property {Layout} layout
12
+ */
13
+
14
+ /** @type {Map<string, Registered>} */
15
+ const registered = new Map();
16
+
17
+ /**
18
+ * The compiled data of the layouts (doc/design.md §3), kept here so that the
19
+ * public Layout holds none of it.
20
+ *
21
+ * @type {WeakMap<Layout, Compiled_Layout>}
22
+ */
23
+ const compiled = new WeakMap();
24
+
25
+ /**
26
+ * @param {Layout} layout
27
+ * @param {Compiled_Layout} data
28
+ */
29
+ export function set_compiled(layout, data) {
30
+ compiled.set(layout, data);
31
+ }
32
+
33
+ /**
34
+ * The compiled data of a layout made by this package.
35
+ *
36
+ * @param {Layout} layout
37
+ * @returns {Compiled_Layout}
38
+ * @throws {Error} For an object that is not a compiled layout.
39
+ */
40
+ export function compiled_of(layout) {
41
+ const data = compiled.get(layout);
42
+ if (!data) throw new Error(`${layout?.name} is not a registered layout`);
43
+ return data;
44
+ }
45
+
46
+ /**
47
+ * @param {string} name
48
+ * @returns {Registered | undefined}
49
+ */
50
+ export function registered_of(name) {
51
+ return registered.get(name);
52
+ }
53
+
54
+ /**
55
+ * @param {string} name
56
+ * @param {Registered} entry
57
+ */
58
+ export function register(name, entry) {
59
+ registered.set(name, entry);
60
+ }
61
+
62
+ /**
63
+ * The registered layout of a name, undefined when there is none.
64
+ *
65
+ * @param {string} name
66
+ * @returns {Layout | undefined}
67
+ */
68
+ export function find_layout(name) {
69
+ return registered.get(name)?.layout;
70
+ }
71
+
72
+ /**
73
+ * The registered layout of a name (doc/spec-data-api.md §6.5).
74
+ *
75
+ * @param {string} name
76
+ * @returns {Layout}
77
+ * @throws {Error} When no layout of that name is registered.
78
+ */
79
+ export function get_layout(name) {
80
+ const layout = find_layout(name);
81
+ if (!layout) throw new Error(`unknown layout ${name}`);
82
+ return layout;
83
+ }
@@ -0,0 +1,35 @@
1
+ // The reserved names (doc/spec-data-api.md §2.10, doc/design.md §4). Internal:
2
+ // index.js does not export this file.
3
+
4
+ /**
5
+ * Names a tag must not have: the members of Ldata, the method and field names
6
+ * of EventEmitter in Node.js 20.11 and the names of Object.prototype. A static
7
+ * list, so that it does not depend on the Node.js version that runs the program.
8
+ */
9
+ const RESERVED_NAMES = new Set([
10
+ // members of Ldata
11
+ "name", "layout", "size", "get", "transaction",
12
+ "read_bytes", "write_bytes", "read_bit", "write_bit", "load_tag", "store_tag",
13
+ // EventEmitter
14
+ "addListener", "emit", "eventNames", "getMaxListeners", "listenerCount", "listeners",
15
+ "off", "on", "once", "prependListener", "prependOnceListener", "rawListeners",
16
+ "removeAllListeners", "removeListener", "setMaxListeners",
17
+ "_events", "_eventsCount", "_maxListeners",
18
+ // Object
19
+ "constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
20
+ "toLocaleString", "toString", "valueOf", "__proto__",
21
+ "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__",
22
+ ]);
23
+
24
+ /** Event names of the library; a user does not emit them or use them for its own events. */
25
+ export const RESERVED_EVENTS = Object.freeze(["change", "write", "access", "read"]);
26
+
27
+ /**
28
+ * Whether a tag name would shadow a member of a data block.
29
+ *
30
+ * @param {string} name
31
+ * @returns {boolean}
32
+ */
33
+ export function is_reserved_name(name) {
34
+ return RESERVED_NAMES.has(name);
35
+ }