diff --git a/CHANGELOG.md b/CHANGELOG.md index 742ff00..becd1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Add `optional()` field wrapper for optional (possibly-absent) fields. + Supports sentinel-value and predicate-function presence strategies. + The writability of the returned descriptor matches the wrapped descriptor. +- Extend `string()` with a dynamic-length overload: pass + `{ length: propertyName | (dv) => number }` as the second argument to create + a read-only variable-length string field. +- Extend `typedArray()` `length` option to also accept a + `(dv: DataView) => number` function in addition to a number or property name. + ## 0.16.1 — 2026-04-02 - Align release publishing so npm and JSR stay version-synchronized. diff --git a/README.md b/README.md index f1f6a34..c4cd9a5 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,109 @@ for (const dish of myMenu) { 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. + +# Optional and variable-length fields + +## Optional fields — `optional(descriptor, presence)` + +Wrap any field descriptor with `optional()` to make it return `null` when the +field is absent. Two presence strategies are supported: + +### Sentinel value + +A field is absent when its stored value equals the sentinel (compared via +`Object.is`). Writing `null` stores the sentinel. + +```js +import { defineStruct, optional, u16, u32 } from "@rotu/structview" + +// 0xFFFF is the conventional "not present" marker for a u16 +const Msg = defineStruct({ + id: u16(0), + extra: optional(u32(4), { sentinel: 0xffffffff }), +}) + +const msg = Msg.alloc({ byteLength: 8 }) +msg.id = 1 +msg.extra = null // writes 0xffffffff into bytes 4-7 +console.log(msg.extra) // → null + +msg.extra = 99 +console.log(msg.extra) // → 99 +``` + +### Predicate function + +A field is absent when a `(dv: DataView) => boolean` function returns `false`. +Setting to `null` is a no-op; the presence flag must be managed separately. + +```js +const Packet = defineStruct({ + flags: u8(0), + payload: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0), +}) + +const pkt = Packet.alloc({ byteLength: 8 }) +console.log(pkt.payload) // → null (flag bit not set) + +pkt.flags = 1 +pkt.payload = 42 +console.log(pkt.payload) // → 42 +``` + +The returned descriptor is **writable when the wrapped descriptor is writable**, +and **read-only when the wrapped descriptor is read-only** (e.g. `substruct`, +`typedArray`, or `fromDataView` without a setter). + +## Variable-length strings — `string(offset, { length })` + +Pass an options object as the second argument to create a **read-only** +variable-length string field. The byte length can be a property name on the +struct or a function of the `DataView`. + +```js +import { defineStruct, string, u8 } from "@rotu/structview" + +const Frame = defineStruct({ + name_len: u8(0), + // byte length is read from the `name_len` field at access time + name: string(1, { length: "name_len" }), +}) +``` + +Or with a function: + +```js +const Frame = defineStruct({ + name: string(1, { length: (dv) => dv.getUint8(0) }), +}) +``` + +> **Note:** Variable-length string fields are read-only. To write a +> variable-length string, update the backing buffer directly (e.g. via a +> `typedArray` or `fromDataView` with a custom setter). + +## Variable-length typed arrays — `typedArray(offset, { species, length })` + +The existing `typedArray` helper already supports a numeric length and a +property-name length. It now also accepts a `(dv: DataView) => number` +function: + +```js +import { defineStruct, typedArray, u8 } from "@rotu/structview" + +const Blob = defineStruct({ + count: u8(0), + values: typedArray(4, { + species: Float32Array, + length: (dv) => dv.getUint8(0), + }), +}) + +const blob = Blob.alloc({ byteLength: 20 }) +blob.count = 3 +blob.values[0] = 1.5 +blob.values[1] = 2.5 +blob.values[2] = 3.5 +``` + diff --git a/fields.ts b/fields.ts index eaf0a40..b96e7bb 100644 --- a/fields.ts +++ b/fields.ts @@ -233,26 +233,61 @@ export function f64(fieldOffset: number): StructPropertyDescriptor { export function string( fieldOffset: number, byteLength: number, +): StructPropertyDescriptor +/** + * Field for a UTF-8 string whose byte length is determined at read time. + * + * @remarks The returned descriptor is read-only because writing a string of + * variable size requires external coordination (e.g. also updating the length + * field). Use `fromDataView` for a writable custom implementation. + * + * @param fieldOffset - Byte offset of the string within the struct. + * @param options.length - A property name on the struct whose value gives the + * byte length, or a function `(dv: DataView) => number` that computes it. + */ +export function string( + fieldOffset: number, + options: { readonly length: string | ((dv: DataView) => number) }, +): StructPropertyDescriptor & ReadOnlyAccessorDescriptor +export function string( + fieldOffset: number, + arg: number | { readonly length: string | ((dv: DataView) => number) }, ): StructPropertyDescriptor { const TEXT_DECODER = new TextDecoder() const TEXT_ENCODER = new TextEncoder() + if (typeof arg === "number") { + const byteLength = arg + return { + get() { + const str = TEXT_DECODER.decode( + structBytes(this, fieldOffset, fieldOffset + byteLength), + ) + // trim all trailing null characters + return str.replace(/\0+$/, "") + }, + set(value) { + const bytes = structBytes( + this, + fieldOffset, + fieldOffset + byteLength, + ) + bytes.fill(0) + TEXT_ENCODER.encodeInto(value, bytes) + }, + } + } + const { length } = arg return { get() { + const dv = structDataView(this) + const len: number = typeof length === "string" + ? (Reflect.get(this, length) as number) + : length(dv) const str = TEXT_DECODER.decode( - structBytes(this, fieldOffset, fieldOffset + byteLength), + structBytes(this, fieldOffset, fieldOffset + len), ) - // trim all trailing null characters return str.replace(/\0+$/, "") }, - set(value) { - const bytes = structBytes( - this, - fieldOffset, - fieldOffset + byteLength, - ) - bytes.fill(0) - TEXT_ENCODER.encodeInto(value, bytes) - }, } } @@ -272,11 +307,80 @@ export function bool(fieldOffset: number): StructPropertyDescriptor { } /** - * Define a descriptor based on a dataview of the struct - * @param fieldGetter function which, given a dataview, returns the field value - * @param fieldSetter optional function which, given a dataview and a value, sets the field value - * @returns an enumerable property descriptor; readonly if no setter is provided + * Wrap a field descriptor to make it optional, returning `null` when the field + * is considered absent. + * + * The returned descriptor inherits the writability of the wrapped descriptor: + * - If `descriptor` has a setter, the returned descriptor also has a setter. + * - If `descriptor` has no setter (read-only), the returned descriptor is + * read-only too. + * + * Two presence strategies are supported: + * + * **Sentinel value** — the field is absent when its binary value equals the + * sentinel (compared via `Object.is`). Setting the property to `null` writes + * the sentinel back into the buffer. + * + * ```ts + * const Cls = defineStruct({ + * value: optional(u16(0), { sentinel: 0xffff }), + * }) + * ``` + * + * **Predicate function** — the field is absent when the predicate returns + * `false`. Setting the field to a non-null value writes it normally; + * setting to `null` is a no-op. + * + * ```ts + * const Cls = defineStruct({ + * flags: u8(0), + * value: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0), + * }) + * ``` */ +export function optional( + descriptor: StructPropertyDescriptor & { set(t: T): undefined }, + presence: ((dv: DataView) => boolean) | { readonly sentinel: T }, +): StructPropertyDescriptor +export function optional( + descriptor: StructPropertyDescriptor, + presence: ((dv: DataView) => boolean) | { readonly sentinel: T }, +): StructPropertyDescriptor & ReadOnlyAccessorDescriptor +export function optional( + descriptor: StructPropertyDescriptor, + presence: ((dv: DataView) => boolean) | { readonly sentinel: T }, +): StructPropertyDescriptor { + if (typeof presence === "function") { + const result: StructPropertyDescriptor = { + get() { + if (!presence(structDataView(this))) return null + return descriptor.get!.call(this) as T + }, + } + if (typeof descriptor.set === "function") { + result.set = function (value: T | null) { + if (value !== null) { + descriptor.set!.call(this, value) + } + } + } + return result + } + const { sentinel } = presence + const result: StructPropertyDescriptor = { + get() { + const value = descriptor.get!.call(this) as T + return Object.is(value, sentinel) ? null : value + }, + } + if (typeof descriptor.set === "function") { + result.set = function (value: T | null) { + descriptor.set!.call(this, value === null ? sentinel : value) + } + } + return result +} + export function fromDataView( fieldGetter: (dv: DataView) => T, fieldSetter: (dv: DataView, value: T) => void, @@ -351,8 +455,12 @@ export function substruct< export function typedArray( fieldOffset: number, kwargs: { - /** length or property name for the length of the array */ - readonly length: number | string | undefined + /** length, property name, function, or undefined (fill remaining buffer) */ + readonly length: + | number + | string + | ((dv: DataView) => number) + | undefined /** TypedArray constructor */ readonly species: TypedArraySpecies }, @@ -370,6 +478,8 @@ export function typedArray( lengthValue = length } else if (typeof length === "string") { lengthValue = Reflect.get(this, length) + } else if (typeof length === "function") { + lengthValue = length(dv) } return new species( dv.buffer, diff --git a/mod_test.ts b/mod_test.ts index 44458a4..03b8c34 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -10,6 +10,7 @@ import { i32, i64, i8, + optional, string, substruct, typedArray, @@ -488,6 +489,176 @@ test("fromDataView with setter is writable and enumerable", () => { assert(keys.includes("val")) }) +test("optional with sentinel (writable)", () => { + class S extends defineStruct({ + value: optional(u16(0), { sentinel: 0xffff }), + }) {} + const buf = new Uint8Array(2) + const s = new S(buf) + + // default (zeros) is not the sentinel + deepStrictEqual(s.value, 0) + + // writing null encodes the sentinel + s.value = null + deepStrictEqual(buf[0], 0xff) + deepStrictEqual(buf[1], 0xff) + deepStrictEqual(s.value, null) + + // writing a real value round-trips + s.value = 42 + deepStrictEqual(s.value, 42) +}) + +test("optional with sentinel preserves readonly for substruct", () => { + const Inner = defineStruct({ x: u8(0) }) + class S extends defineStruct({ + // substruct returns ReadOnlyAccessorDescriptor, so optional is also readonly + inner: optional(substruct(Inner, 0, 1), { sentinel: new Inner({ byteLength: 1 }) }), + }) {} + const s = new S(new Uint8Array(1)) + // type test: the property is read-only + throws(() => { + // @ts-expect-error assigning to readonly property + s.inner = null + }) +}) + +test("optional with predicate (writable)", () => { + class S extends defineStruct({ + flags: u8(0), + value: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0), + }) {} + const buf = new Uint8Array(8) + const s = new S(buf) + + // flag not set → null + deepStrictEqual(s.value, null) + + // set the presence flag → reads real value + s.flags = 1 + deepStrictEqual(s.value, 0) + + // write the field + s.value = 99 + deepStrictEqual(s.value, 99) + + // setting null when present is a no-op (value unchanged) + s.value = null + deepStrictEqual(s.value, 99) + + // clear presence flag → null again + s.flags = 0 + deepStrictEqual(s.value, null) +}) + +test("optional with predicate (readonly when descriptor is readonly)", () => { + const Inner = defineStruct({ x: u8(0) }) + class S extends defineStruct({ + flags: u8(0), + inner: optional(substruct(Inner, 1, 1), (dv) => dv.getUint8(0) !== 0), + }) {} + const buf = new Uint8Array(2) + const s = new S(buf) + + deepStrictEqual(s.inner, null) + buf[0] = 1 + deepStrictEqual(s.inner?.x, 0) + + throws(() => { + // @ts-expect-error assigning to readonly property + s.inner = null + }) +}) + +test("optional bigint sentinel", () => { + class S extends defineStruct({ + value: optional(u64(0), { sentinel: 0xffffffffffffffffn }), + }) {} + const buf = new Uint8Array(8).fill(0xff) + const s = new S(buf) + + deepStrictEqual(s.value, null) + s.value = 42n + deepStrictEqual(s.value, 42n) + s.value = null + deepStrictEqual(s.value, null) +}) + +test("string with dynamic length via property name", () => { + class S extends defineStruct({ + name_length: u8(0), + name: string(1, { length: "name_length" }), + }) {} + const encoder = new TextEncoder() + const buf = new Uint8Array(16) + const encoded = encoder.encode("Hello") + buf[0] = encoded.length + buf.set(encoded, 1) + const s = new S(buf) + + deepStrictEqual(s.name, "Hello") + + // changing the length field trims the view + s.name_length = 3 + deepStrictEqual(s.name, "Hel") + + // length 0 → empty string + s.name_length = 0 + deepStrictEqual(s.name, "") + + // type test: dynamic-length string is read-only + throws(() => { + // @ts-expect-error assigning to readonly property + s.name = "x" + }) +}) + +test("string with dynamic length via function", () => { + class S extends defineStruct({ + len: u8(0), + data: string(1, { length: (dv) => dv.getUint8(0) }), + }) {} + const encoder = new TextEncoder() + const buf = new Uint8Array(16) + const encoded = encoder.encode("World") + buf[0] = encoded.length + buf.set(encoded, 1) + const s = new S(buf) + + deepStrictEqual(s.data, "World") + + // simulate shrinking length + buf[0] = 2 + deepStrictEqual(s.data, "Wo") +}) + +test("typedArray with function-based length", () => { + class S extends defineStruct({ + count: u8(0), + values: typedArray(4, { + species: Float32Array, + length: (dv) => dv.getUint8(0), + }), + }) {} + const buf = new Uint8Array(20) + const s = new S(buf) + + deepStrictEqual(s.values.length, 0) + + s.count = 3 + deepStrictEqual(s.values.length, 3) + + s.values[0] = 1.5 + s.values[1] = 2.5 + s.values[2] = 3.5 + + const view = new Float32Array(buf.buffer, 4, 3) + deepStrictEqual(view[0], 1.5) + deepStrictEqual(view[1], 2.5) + deepStrictEqual(view[2], 3.5) +}) + function hexToUint8Array(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new TypeError("Hex input must have an even length")