diff --git a/README.md b/README.md index 35cdc7b..9051f2c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,68 @@ for (const dish of myMenu) { } ``` +# Additional field descriptors + +## `pad(fieldOffset, byteLength)` — padding / reserved bytes + +Document reserved or padding regions without occupying a meaningful field name. +The property is **non-enumerable**, so it is excluded from `JSON.stringify` and +`structToObject`. + +```js +import { defineStruct, pad, u8, u32 } from "@rotu/structview" + +class Header extends defineStruct({ + version: u8(0), + _reserved: pad(1, 3), // 3 reserved bytes + length: u32(4), +}) { + static BYTE_LENGTH = 8 +} +``` + +## `enumField(underlying, values)` — integer-to-label mapping + +Map a raw integer field to human-readable string labels. When reading, the +integer is looked up in `values`; unknown integers are returned as-is. When +writing, you may pass a label string or the raw integer. + +Inspired by enum types in [kaitai-struct](https://kaitai.io/) and +[restructure](https://github.com/nicolo-ribaudo/restructure). + +```js +import { defineStruct, enumField, u8 } from "@rotu/structview" + +const STATUS = { 0: "idle", 1: "busy", 2: "error" } + +class Packet extends defineStruct({ + status: enumField(u8(0), STATUS), + data: u32(4), +}) { + static BYTE_LENGTH = 8 +} + +const p = Packet.alloc() +p.status = "busy" +console.log(p.status) // "busy" +``` + +## `structToObject(struct)` — plain-object conversion + +Convert any struct to a plain `Record` by iterating all +enumerable (prototype-defined) fields. Useful for cloning, diffing, or any +place that needs a plain JS object. + +```js +import { defineStruct, f32, structToObject } from "@rotu/structview" + +class Vec2 extends defineStruct({ x: f32(0), y: f32(4) }) {} +const v = Vec2.alloc({ byteLength: 8 }) +v.x = 3; v.y = 4 +const plain = structToObject(v) // { x: 3, y: 4 } +console.log(JSON.stringify(plain)) // '{"x":3,"y":4}' +``` + # Gotchas and rough edges 1. Resizable structs are not yet implemented. Resizable `Arraybuffer`s only @@ -101,5 +163,6 @@ for (const dish of myMenu) { 3. Be careful using `TypedArray`s. They have an alignment requirement relative to their underlying `ArrayBuffer`. 4. `Struct` classes define properties on the prototype, _not_ on the instance. - That means spread syntax (`x = {...s}`) and `JSON.stringify(s)` will _not_ - reflect inherited fields. + That means spread syntax (`x = {...s}`) will _not_ reflect inherited fields. + However, `JSON.stringify(s)` **does** now work because `Struct` implements + `toJSON()`. For spread-like use cases, use the `structToObject(s)` helper. diff --git a/core.ts b/core.ts index 1d135bc..998d421 100644 --- a/core.ts +++ b/core.ts @@ -78,6 +78,29 @@ export class Struct { return Struct.name } + /** + * Return a plain object containing all enumerable struct fields. + * + * Because struct fields are defined on the prototype (not the instance), + * `JSON.stringify(struct)` and spread (`{ ...struct }`) would normally + * produce an empty object. Implementing `toJSON` here means + * `JSON.stringify(struct)` works as expected. + * + * @remarks + * The `for...in` loop is intentional: struct fields are enumerable properties + * on the prototype, not own properties, so we must traverse the prototype + * chain to collect them. + */ + toJSON(): Record { + const result: Record = {} + // Intentionally iterates prototype-chain properties: struct fields are + // defined as enumerable descriptors on the prototype by defineStruct(). + for (const key in this) { + result[key] = (this as unknown as Record)[key] + } + return result + } + static toDataView(o: Struct): DataView { return o[dataViewSymbol] } @@ -138,6 +161,32 @@ export class Struct { } } +/** + * Convert a struct to a plain object containing all enumerable field values. + * + * Unlike `JSON.stringify(struct)`, which misses prototype-defined fields, or + * spread (`{ ...struct }`), which only copies own properties, this function + * walks the prototype chain and collects every enumerable field defined by + * `defineStruct`. + * + * @example + * ```ts + * class Point extends defineStruct({ x: f32(0), y: f32(4) }) {} + * const p = Point.alloc({ byteLength: 8 }) + * p.x = 1; p.y = 2 + * const plain = structToObject(p) // { x: 1, y: 2 } + * ``` + */ +export function structToObject(struct: AnyStruct): Record { + const result: Record = {} + // Intentionally iterates prototype-chain properties: struct fields are + // defined as enumerable descriptors on the prototype by defineStruct(). + for (const key in struct) { + result[key] = (struct as unknown as Record)[key] + } + return result +} + /** * Subclass a type by adding the given property descriptors * @param ctor constructor for the base class diff --git a/fields.ts b/fields.ts index 61df69f..81849d6 100644 --- a/fields.ts +++ b/fields.ts @@ -271,6 +271,93 @@ export function bool(fieldOffset: number): StructPropertyDescriptor { } } +/** + * Field representing reserved / padding bytes. + * + * Use this to document padding or reserved regions in a struct layout without + * consuming a meaningful field name. The property is non-enumerable so it is + * excluded from `JSON.stringify` and {@link structToObject}. + * + * @example + * ```ts + * class Header extends defineStruct({ + * version: u8(0), + * _pad: pad(1, 3), // 3 reserved bytes at offset 1 + * length: u32(4), + * }) {} + * ``` + */ +export function pad( + fieldOffset: number, + byteLength: number, +): StructPropertyDescriptor & ReadOnlyAccessorDescriptor { + return { + enumerable: false, + get() { + return structBytes(this, fieldOffset, fieldOffset + byteLength) + }, + } +} + +/** + * Field that maps an integer value to a string label from a provided enum map. + * + * When reading, the raw integer is looked up in `values`; if a label is found + * it is returned, otherwise the raw integer is returned. When writing, you may + * supply either a label string or the raw integer. + * + * Inspired by enum types in kaitai-struct and restructure. + * + * @param underlying - A numeric field descriptor (e.g. `u8(0)`, `u16(4)`) + * @param values - Map from integer value to label string + * + * @example + * ```ts + * const STATUS = { 0: "idle", 1: "busy", 2: "error" } as const + * class Packet extends defineStruct({ + * status: enumField(u8(0), STATUS), + * data: u32(4), + * }) {} + * const p = Packet.alloc({ byteLength: 8 }) + * p.status = "busy" + * console.log(p.status) // "busy" + * ``` + */ +export function enumField>( + underlying: StructPropertyDescriptor, + values: Values, +): StructPropertyDescriptor { + const reverseMap = new Map( + Object.entries(values).map(([k, v]) => [v as string, Number(k)]), + ) + const validLabels = [...reverseMap.keys()].map((k) => JSON.stringify(k)).join( + ", ", + ) + return { + get() { + const raw = underlying.get!.call(this) + return (values[raw] ?? raw) as Values[keyof Values] | number + }, + set(value) { + let numValue: number + if (typeof value === "string") { + const n = reverseMap.get(value) + if (n === undefined) { + throw new RangeError( + `Unknown enum label: ${ + JSON.stringify(value) + }. Valid labels: ${validLabels}`, + ) + } + numValue = n + } else { + numValue = value as number + } + underlying.set!.call(this, numValue) + }, + } +} + /** * Define a descriptor based on a dataview of the struct * @param fieldGetter function which, given a dataview, returns diff --git a/mod_test.ts b/mod_test.ts index b68ad09..6587877 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -2,6 +2,7 @@ import { bigintle, biguintle, bool, + enumField, f16, f32, f64, @@ -9,6 +10,7 @@ import { i32, i64, i8, + pad, string, substruct, typedArray, @@ -17,7 +19,13 @@ import { u64, u8, } from "./fields.ts" -import { defineArray, defineStruct, Struct, structDataView } from "./core.ts" +import { + defineArray, + defineStruct, + Struct, + structDataView, + structToObject, +} from "./core.ts" import { assert, @@ -449,3 +457,119 @@ Deno.test("alloc", () => { // ensure correct typing (that alloc doesn't return a bare Struct) const _zz: Sized = z }) + +Deno.test("toJSON", () => { + class Point extends defineStruct({ + x: f32(0), + y: f32(4), + }) {} + const p = Point.alloc({ byteLength: 8 }) + p.x = 1.5 + p.y = -2.5 + const json = JSON.stringify(p) + const parsed = JSON.parse(json) + assertEquals(parsed.x, Math.fround(1.5)) + assertEquals(parsed.y, Math.fround(-2.5)) +}) + +Deno.test("toJSON nested", () => { + class Inner extends defineStruct({ a: u8(0), b: u8(1) }) {} + class Outer extends defineStruct({ + n: u32(0), + inner: substruct(Inner, 4, 2), + }) {} + const o = Outer.alloc({ byteLength: 8 }) + o.n = 42 + o.inner.a = 7 + o.inner.b = 9 + const json = JSON.stringify(o) + const parsed = JSON.parse(json) + assertEquals(parsed.n, 42) + assertEquals(parsed.inner.a, 7) + assertEquals(parsed.inner.b, 9) +}) + +Deno.test("structToObject", () => { + class S extends defineStruct({ + x: u32(0), + y: f32(4), + label: string(8, 8), + }) {} + const s = S.alloc({ byteLength: 16 }) + s.x = 99 + s.y = 3.14 + s.label = "hi" + const obj = structToObject(s) + assertEquals(obj.x, 99) + assertEquals(obj.y, Math.fround(3.14)) + assertEquals(obj.label, "hi") + // result is a plain object, not a Struct + assertEquals(Object.getPrototypeOf(obj), Object.prototype) +}) + +Deno.test("pad field is non-enumerable and returns bytes", () => { + class Header extends defineStruct({ + version: u8(0), + _reserved: pad(1, 3), + length: u32(4), + }) { + static BYTE_LENGTH = 8 + } + const h = Header.alloc() + h.version = 5 + h.length = 100 + + // pad field is non-enumerable (absent from structToObject) + const obj = structToObject(h) + assertEquals(Object.keys(obj), ["version", "length"]) + + // pad field returns the underlying bytes as a Uint8Array + assertInstanceOf(h._reserved, Uint8Array) + assertEquals(h._reserved.byteLength, 3) + + // pad bytes are live (mutations are reflected) + h._reserved[0] = 0xab + assertEquals(h._reserved[0], 0xab) +}) + +Deno.test("enumField read and write by label", () => { + const STATUS = { 0: "idle", 1: "busy", 2: "error" } as const + class Packet extends defineStruct({ + status: enumField(u8(0), STATUS), + data: u32(4), + }) { + static BYTE_LENGTH = 8 + } + const p = Packet.alloc() + // default value should be 0 → "idle" + assertEquals(p.status, "idle") + + // write by label + p.status = "busy" + assertEquals(p.status, "busy") + + // write by label "error" + p.status = "error" + assertEquals(p.status, "error") + + // write by raw number + p.status = 0 + assertEquals(p.status, "idle") + + // unknown raw number returns the number itself + p.status = 99 + assertEquals(p.status, 99) +}) + +Deno.test("enumField throws on unknown label", () => { + const COLOR = { 0: "red", 1: "green", 2: "blue" } as const + class S extends defineStruct({ color: enumField(u8(0), COLOR) }) {} + const s = new S({ byteLength: 1 }) + assertThrows( + () => { + // deno-lint-ignore no-explicit-any + s.color = "purple" as any + }, + RangeError, + ) +})