From b91698e9e3710457ae6b89b7aa36a7a76f127200 Mon Sep 17 00:00:00 2001 From: Mathias Buus Date: Mon, 6 Jan 2025 21:03:24 +0100 Subject: [PATCH 1/2] support enums --- index.js | 52 ++++++++++++++++++++++++++++ lib/codegen.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++-- test/basic.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 687caef..5738e21 100644 --- a/index.js +++ b/index.js @@ -79,6 +79,56 @@ class Alias extends ResolvedType { } } +class Enum extends ResolvedType { + constructor (hyperschema, fqn, description, existing) { + super(hyperschema, fqn, description, existing) + + this.isEnum = true + this.enum = [] + this.offset = typeof description.offset === 'number' ? description.offset : 1 + this.default = description.strings ? null : 0 + this.strings = !!description.strings + + if (this.existing) { + if (!this.existing.enum) { + throw new Error('Previous declaration was not an enum') + } + + if (this.existing.enum.length > description.enum.length) { + throw new Error('An enum value was removed') + } + } + + for (let i = 0; i < description.enum.length; i++) { + const d = description.enum[i] + const key = typeof d === 'string' ? d : d.key + const prev = i < this.existing?.enum.length ? this.existing.enum[i] : null + + if (prev && prev.key !== key) { + throw new Error(`Enum ${i} in ${fqn} changed. Was "${prev.key}" but is now "${key}`) + } + + if (!prev) { + hyperschema.maybeBumpVersion() + } + + this.enum.push({ + key, + version: prev ? prev.version : hyperschema.version + }) + } + } + + toJSON () { + return { + name: this.description.name, + namespace: this.namespace, + offset: this.offset, + enum: this.enum + } + } +} + class StructField { constructor (hyperschema, struct, position, flag, description) { this.hyperschema = hyperschema @@ -256,6 +306,8 @@ module.exports = class Hyperschema { let type = null if (description.alias) { type = new Alias(this, fqn, description, existing) + } else if (description.enum) { + type = new Enum(this, fqn, description, existing) } else { type = new Struct(this, fqn, description, existing) } diff --git a/lib/codegen.js b/lib/codegen.js index 8a80fe8..e4bd357 100644 --- a/lib/codegen.js +++ b/lib/codegen.js @@ -11,12 +11,15 @@ module.exports = function generateSchema (hyperschema) { if (type.isStruct) { structs.push(type) } + if (type.isEnum) { + structs.push(type) + } } for (let i = 0; i < structs.length; i++) { const struct = structs[i] const id = 'encoding' + i - const encoder = generateStruct(id, struct) + const encoder = struct.enum ? generateEnum(id, struct) : generateStruct(id, struct) structsByName.set(struct.fqn, { encoder, id }) } @@ -57,6 +60,18 @@ module.exports = function generateSchema (hyperschema) { str += '}\n' str += '\n' + str += 'function getEnum (name) {\n' + str += ' switch (name) {\n' + for (let i = 0; i < structs.length; i++) { + const struct = structs[i] + if (struct.enum) str += ` case ${s(struct.fqn)}: return ${structsByName.get(struct.fqn).id}_enum\n` + } + str += ' default: throw new Error(\'Enum not found \' + name)\n' + str += ' }\n' + str += '}\n' + + str += '\n' + str += 'function getEncoding (name) {\n' str += ' switch (name) {\n' for (let i = 0; i < structs.length; i++) { @@ -87,10 +102,81 @@ module.exports = function generateSchema (hyperschema) { str += ' }\n' str += '}\n' str += '\n' - str += 'module.exports = { resolveStruct, getEncoding, encode, decode, setVersion, version }\n' + str += 'module.exports = { resolveStruct: getStruct, getStruct, getEnum, getEncoding, encode, decode, setVersion, version }\n' return str + function generateEnum (id, struct) { + let str = '' + + str += `const ${id}_enum = {\n` + + for (let i = 0; i < struct.enum.length; i++) { + const e = struct.enum[i] + const value = struct.strings ? s(e.key) : i + struct.offset + str += ` ${gen.property(e.key)}: ${value}${i < struct.enum.length - 1 ? ',' : ''}\n` + } + + str += '}\n\n' + + const preencode = generateEncode({ preencode: true }) + const encode = generateEncode() + const decode = generateDecode() + + str += `// ${struct.fqn} enum\n` + str += `const ${id} = {\n` + str += ' preencode (state, m) {\n' + str += `${preencode}` + str += ' },\n' + str += ' encode (state, m) {\n' + str += `${encode}` + str += ' },\n' + str += ' decode (state) {\n' + str += `${decode}` + str += ' }\n' + str += '}\n' + + return str + + function generateEncode ({ preencode = false } = {}) { + const max = struct.enum.length + struct.offset - 1 + const encode = preencode ? 'preencode' : 'encode' + + if (preencode && max <= 0xfc) { + return ` state.end++ // max enum is ${max} so always one byte\n` + } + + if (!struct.strings) { + let str = '' + str += ` if (m > ${max}) throw new Error('Unknown enum')\n` + str += ` c.uint.${encode}(state, m)\n` + return str + } + + let str = ' switch (m) {\n' + for (let i = 0; i < struct.enum.length; i++) { + str += ` case ${s(struct.enum[i].key)}:\n` + str += ` c.uint.${encode}(state, ${i + struct.offset})\n` + str += ' break\n' + } + str += ' default: throw new Error(\'Unknown enum\')\n' + str += ' }\n' + return str + } + + function generateDecode () { + if (!struct.strings) return ' return c.uint.decode(state)\n' + + let str = ' switch (c.uint.decode(state)) {\n' + for (let i = 0; i < struct.enum.length; i++) { + str += ` case ${i + struct.offset}: return ${s(struct.enum[i].key)}\n` + } + str += ' default: return null\n' + str += ' }\n' + return str + } + } + function generateStruct (id, struct) { let str = '' const fieldTypes = new Map() diff --git a/test/basic.js b/test/basic.js index ba9c0b7..79e2c11 100644 --- a/test/basic.js +++ b/test/basic.js @@ -382,3 +382,96 @@ test('basic struct array', async t => { t.alike(dec, [{ foo: 'bar' }, { foo: 'baz' }]) } }) + +test('basic enums', async t => { + const schema = await createTestSchema(t) + + await schema.rebuild(schema => { + const ns = schema.namespace('test') + ns.register({ + name: 'test-enum', + enum: [ + 'hello', + 'world' + ] + }) + + ns.register({ + name: 'test-struct', + fields: [ + { + name: 'foo', + type: '@test/test-enum', + required: true + } + ] + }) + }) + + { + const { hello } = schema.module.getEnum('@test/test-enum') + const enc = schema.module.resolveStruct('@test/test-struct') + const buf = c.encode(enc, { foo: hello }) + const dec = c.decode(enc, buf) + + t.alike(dec, { foo: hello }) + } + + { + const { world } = schema.module.getEnum('@test/test-enum') + const enc = schema.module.resolveStruct('@test/test-struct') + const buf = c.encode(enc, { foo: world }) + const dec = c.decode(enc, buf) + + t.alike(dec, { foo: world }) + } + + t.alike(schema.module.getEnum('@test/test-enum'), { hello: 1, world: 2 }) +}) + +test('basic enums (strings)', async t => { + const schema = await createTestSchema(t) + + await schema.rebuild(schema => { + const ns = schema.namespace('test') + ns.register({ + name: 'test-enum', + strings: true, + enum: [ + 'hello', + 'world' + ] + }) + + ns.register({ + name: 'test-struct', + fields: [ + { + name: 'foo', + type: '@test/test-enum', + required: true + } + ] + }) + }) + + { + const { hello } = schema.module.getEnum('@test/test-enum') + const enc = schema.module.resolveStruct('@test/test-struct') + const buf = c.encode(enc, { foo: hello }) + const dec = c.decode(enc, buf) + + t.alike(dec, { foo: hello }) + } + + { + const { world } = schema.module.getEnum('@test/test-enum') + const enc = schema.module.resolveStruct('@test/test-struct') + const buf = c.encode(enc, { foo: world }) + const dec = c.decode(enc, buf) + + t.alike(dec, { foo: world }) + } + + t.alike(schema.module.getEnum('@test/test-enum'), { hello: 'hello', world: 'world' }) +}) From f755613a5b9bbe7a6e51b1fe447252cf50722754 Mon Sep 17 00:00:00 2001 From: Mathias Buus Date: Mon, 6 Jan 2025 21:03:49 +0100 Subject: [PATCH 2/2] alias resolveStruct to getStruct for consistency and optimisations from enums to structs --- lib/codegen.js | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/codegen.js b/lib/codegen.js index e4bd357..4092f48 100644 --- a/lib/codegen.js +++ b/lib/codegen.js @@ -84,7 +84,7 @@ module.exports = function generateSchema (hyperschema) { str += '\n' - str += 'function resolveStruct (name, v = VERSION) {\n' + str += 'function getStruct (name, v = VERSION) {\n' str += ' const enc = getEncoding(name)\n' str += ' return {\n' str += ' preencode (state, m) {\n' @@ -205,9 +205,9 @@ module.exports = function generateSchema (hyperschema) { str += '\n' } - const preencode = generateEncode(struct, { preencode: true }) - const encode = generateEncode(struct) - const decode = generateDecode(struct) + const preencode = generateEncode({ preencode: true }) + const encode = generateEncode() + const decode = generateDecode() str += `// ${struct.fqn}\n` str += `const ${id} = ${struct.array ? 'c.array(' + (struct.compact ? '' : 'c.frame(') : ''}{\n` str += ' preencode (state, m) {\n' @@ -222,22 +222,27 @@ module.exports = function generateSchema (hyperschema) { str += `}${struct.array ? (struct.compact ? '' : ')') + ')' : ''}\n` return str - function generateEncode (struct, { preencode = false } = {}) { + function generateEncode ({ preencode = false } = {}) { const fn = preencode ? 'preencode' : 'encode' let str = '' + let maxFlag = 0 + let fastFlags = false const flags = [] for (let i = 0; i < struct.optionals.length; i++) { const field = struct.optionals[i] + maxFlag = Math.max(maxFlag, field.flag) if (field.external) continue flags.push('(' + vInlinePrefix(field.version, gen('m', field.name)) + ' ? ' + field.flag + ' : 0)') } if (flags.length) { - if (flags.length === 1) { - str += ' const flags = ' + flags[0].slice(1, -1) + '\n\n' + if (maxFlag < 128 && preencode) { + fastFlags = true + } else if (flags.length === 1) { + str += ` const flags = ${flags[0].slice(1, -1)}\n\n` } else { - str += ' const flags =\n ' + flags.join(' |\n ') + '\n\n' + str += ` const flags =\n ${flags.join(' |\n ')}\n\n` } } @@ -248,7 +253,11 @@ module.exports = function generateSchema (hyperschema) { if (field.external) continue if (i === struct.flagsPosition) { - str += ` c.uint.${fn}(state, flags)` + if (fastFlags) { + str += ` state.end++ // max flag is ${maxFlag} so always one byte` + } else { + str += ` c.uint.${fn}(state, flags)` + } bitfield = true } if (field.type.bool) continue @@ -284,7 +293,7 @@ module.exports = function generateSchema (hyperschema) { return str } - function generateDecode (struct) { + function generateDecode () { let str = '' let seen = 0