diff --git a/SPEC.md b/SPEC.md index 26e70ef..430314e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -183,6 +183,7 @@ All arithmetic operations pop two values, perform operation, push result as numb Performs different operations depending on operand types: - If either operand is a string, converts both to strings and concatenates - Else if both operands are arrays, concatenates the arrays +- Else if both operands are dicts, merges them (b's keys overwrite a's keys on conflict) - Otherwise, converts both to numbers and performs numeric addition **Examples**: @@ -192,6 +193,8 @@ Performs different operations depending on operand types: - `100 + " items"` → `"100 items"` (string concatenation) - `[1, 2, 3] + [4]` → `[1, 2, 3, 4]` (array concatenation) - `[1, 2] + [3, 4]` → `[1, 2, 3, 4]` (array concatenation) +- `{a: 1} + {b: 2}` → `{a: 1, b: 2}` (dict merge) +- `{a: 1, b: 2} + {b: 99}` → `{a: 1, b: 99}` (dict merge, b overwrites) #### SUB **Stack**: [a, b] → [a - b] diff --git a/examples/add-with-dicts.ts b/examples/add-with-dicts.ts new file mode 100644 index 0000000..8c31f1f --- /dev/null +++ b/examples/add-with-dicts.ts @@ -0,0 +1,158 @@ +/** + * Demonstrates the ADD opcode working with dicts + * + * ADD now handles dict merging: + * - {a: 1} + {b: 2} === {a: 1, b: 2} + * - If both operands are dicts, they are merged + * - Keys from the second dict overwrite keys from the first on conflict + */ + +import { toBytecode, run } from "#reef" + +// Basic dict merge +const basicMerge = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["MAKE_DICT", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] +]) + +console.log('Basic dict merge ({a: 1} + {b: 2}):') +const result1 = await run(basicMerge) +console.log(result1) +// Output: { type: 'dict', value: Map { a: 1, b: 2 } } + +// Merge with overlapping keys +const overlapMerge = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 2], + ["PUSH", "b"], + ["PUSH", 99], + ["PUSH", "c"], + ["PUSH", 3], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] +]) + +console.log('\nMerge with overlapping keys ({a: 1, b: 2} + {b: 99, c: 3}):') +const result2 = await run(overlapMerge) +console.log(result2) +console.log('Note: b is overwritten from 2 to 99') +// Output: { type: 'dict', value: Map { a: 1, b: 99, c: 3 } } + +// Merge multiple dicts in sequence +const multipleMerge = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["MAKE_DICT", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 1], + ["ADD"], + ["PUSH", "c"], + ["PUSH", 3], + ["MAKE_DICT", 1], + ["ADD"], + ["PUSH", "d"], + ["PUSH", 4], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] +]) + +console.log('\nMultiple merges ({a: 1} + {b: 2} + {c: 3} + {d: 4}):') +const result3 = await run(multipleMerge) +console.log(result3) +// Output: { type: 'dict', value: Map { a: 1, b: 2, c: 3, d: 4 } } + +// Merge dicts with different value types +const mixedTypes = toBytecode([ + ["PUSH", "num"], + ["PUSH", 42], + ["PUSH", "str"], + ["PUSH", "hello"], + ["MAKE_DICT", 2], + ["PUSH", "bool"], + ["PUSH", true], + ["PUSH", "null"], + ["PUSH", null], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] +]) + +console.log('\nMerge dicts with different types ({num: 42, str: "hello"} + {bool: true, null: null}):') +const result4 = await run(mixedTypes) +console.log(result4) +// Output: { type: 'dict', value: Map { num: 42, str: 'hello', bool: true, null: null } } + +// Merge empty dict with non-empty +const emptyMerge = toBytecode([ + ["MAKE_DICT", 0], + ["PUSH", "x"], + ["PUSH", 100], + ["PUSH", "y"], + ["PUSH", 200], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] +]) + +console.log('\nMerge empty dict with {x: 100, y: 200} ({} + {x: 100, y: 200}):') +const result5 = await run(emptyMerge) +console.log(result5) +// Output: { type: 'dict', value: Map { x: 100, y: 200 } } + +// Merge dicts with nested structures +const nestedMerge = toBytecode([ + ["PUSH", "data"], + ["PUSH", 1], + ["PUSH", 2], + ["MAKE_ARRAY", 2], + ["MAKE_DICT", 1], + ["PUSH", "config"], + ["PUSH", "debug"], + ["PUSH", true], + ["MAKE_DICT", 1], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] +]) + +console.log('\nMerge dicts with nested structures:') +const result6 = await run(nestedMerge) +console.log(result6) +// Output: { type: 'dict', value: Map { data: [1, 2], config: { debug: true } } } + +// Building configuration objects +const configBuild = toBytecode([ + // Default config + ["PUSH", "debug"], + ["PUSH", false], + ["PUSH", "port"], + ["PUSH", 3000], + ["PUSH", "host"], + ["PUSH", "localhost"], + ["MAKE_DICT", 3], + // Override with user config + ["PUSH", "debug"], + ["PUSH", true], + ["PUSH", "port"], + ["PUSH", 8080], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] +]) + +console.log('\nBuilding config (defaults + overrides):') +const result7 = await run(configBuild) +console.log(result7) +// Output: { type: 'dict', value: Map { debug: true, port: 8080, host: 'localhost' } } diff --git a/src/vm.ts b/src/vm.ts index 30fa3a9..b2dec44 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -154,6 +154,13 @@ export class VM { } else if (a.type === 'array' && b.type === 'array') { // two arrays, concatenate them this.stack.push({ type: 'array', value: [...a.value, ...b.value] }) + } else if (a.type === 'dict' && b.type === 'dict') { + // two dicts, merge them (b's keys overwrite a's keys) + const merged = new Map(a.value) + for (const [key, value] of b.value) { + merged.set(key, value) + } + this.stack.push({ type: 'dict', value: merged }) } else { // Otherwise do numeric addition this.stack.push(toValue(toNumber(a) + toNumber(b))) diff --git a/tests/opcodes.test.ts b/tests/opcodes.test.ts index a66b698..7cc1322 100644 --- a/tests/opcodes.test.ts +++ b/tests/opcodes.test.ts @@ -215,6 +215,164 @@ describe("ADD", () => { expect(result.value[1]).toEqual({ type: 'number', value: 3 }) } }) + + test("merge two dicts", async () => { + const bytecode = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["MAKE_DICT", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(2) + expect(result.value.get('a')).toEqual({ type: 'number', value: 1 }) + expect(result.value.get('b')).toEqual({ type: 'number', value: 2 }) + } + }) + + test("merge dicts with overlapping keys (second overwrites)", async () => { + const bytecode = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 2], + ["PUSH", "b"], + ["PUSH", 99], + ["PUSH", "c"], + ["PUSH", 3], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(3) + expect(result.value.get('a')).toEqual({ type: 'number', value: 1 }) + expect(result.value.get('b')).toEqual({ type: 'number', value: 99 }) // overwritten + expect(result.value.get('c')).toEqual({ type: 'number', value: 3 }) + } + }) + + test("merge empty dicts", async () => { + const bytecode = toBytecode([ + ["MAKE_DICT", 0], + ["PUSH", "x"], + ["PUSH", 42], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(1) + expect(result.value.get('x')).toEqual({ type: 'number', value: 42 }) + } + }) + + test("merge multiple dicts in sequence", async () => { + const bytecode = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["MAKE_DICT", 1], + ["PUSH", "b"], + ["PUSH", 2], + ["MAKE_DICT", 1], + ["ADD"], + ["PUSH", "c"], + ["PUSH", 3], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(3) + expect(result.value.get('a')).toEqual({ type: 'number', value: 1 }) + expect(result.value.get('b')).toEqual({ type: 'number', value: 2 }) + expect(result.value.get('c')).toEqual({ type: 'number', value: 3 }) + } + }) + + test("merge dicts with different value types", async () => { + const bytecode = toBytecode([ + ["PUSH", "num"], + ["PUSH", 42], + ["PUSH", "str"], + ["PUSH", "hello"], + ["MAKE_DICT", 2], + ["PUSH", "bool"], + ["PUSH", true], + ["PUSH", "null"], + ["PUSH", null], + ["MAKE_DICT", 2], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(4) + expect(result.value.get('num')).toEqual({ type: 'number', value: 42 }) + expect(result.value.get('str')).toEqual({ type: 'string', value: 'hello' }) + expect(result.value.get('bool')).toEqual({ type: 'boolean', value: true }) + expect(result.value.get('null')).toEqual({ type: 'null', value: null }) + } + }) + + test("merge dicts with nested structures", async () => { + const bytecode = toBytecode([ + ["PUSH", "a"], + ["PUSH", 1], + ["PUSH", 2], + ["MAKE_ARRAY", 2], + ["MAKE_DICT", 1], + ["PUSH", "b"], + ["PUSH", "x"], + ["PUSH", 99], + ["MAKE_DICT", 1], + ["MAKE_DICT", 1], + ["ADD"], + ["HALT"] + ]) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.size).toBe(2) + + // a contains an array [1, 2] + const aValue = result.value.get('a') + expect(aValue?.type).toBe('array') + if (aValue?.type === 'array') { + expect(aValue.value.length).toBe(2) + expect(aValue.value[0]).toEqual({ type: 'number', value: 1 }) + expect(aValue.value[1]).toEqual({ type: 'number', value: 2 }) + } + + // b contains a nested dict {x: 99} + const bValue = result.value.get('b') + expect(bValue?.type).toBe('dict') + if (bValue?.type === 'dict') { + expect(bValue.value.size).toBe(1) + expect(bValue.value.get('x')).toEqual({ type: 'number', value: 99 }) + } + } + }) }) describe("SUB", () => {