From 1a18a713d7ae86b03a6bef38cc53d12ecfbf9627 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 16 Oct 2025 15:51:38 -0700 Subject: [PATCH] DOT_GET --- GUIDE.md | 53 +++++ SPEC.md | 45 +++++ src/opcode.ts | 3 + src/vm.ts | 16 ++ tests/opcodes.test.ts | 453 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 570 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index 9ff5021..b732d55 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -235,6 +235,9 @@ CALL - `DICT_SET` - Pop value, key, dict; mutate dict - `DICT_HAS` - Pop key and dict, push boolean +### Unified Access +- `DOT_GET` - Pop index/key and array/dict, push value (null if missing) + ### Strings - `STR_CONCAT #N` - Pop N values, convert to strings, concatenate, push result @@ -474,6 +477,56 @@ PUSH "!" STR_CONCAT #2 ; → "Hello World!" ``` +### Unified Access (DOT_GET) +DOT_GET provides a single opcode for accessing both arrays and dicts: + +``` +; Array access +PUSH 10 +PUSH 20 +PUSH 30 +MAKE_ARRAY #3 +PUSH 1 +DOT_GET ; → 20 + +; Dict access +PUSH 'name' +PUSH 'Alice' +MAKE_DICT #1 +PUSH 'name' +DOT_GET ; → 'Alice' +``` + +**Chained access**: +``` +; Access dict['users'][0]['name'] +LOAD dict +PUSH 'users' +DOT_GET ; Get users array +PUSH 0 +DOT_GET ; Get first user +PUSH 'name' +DOT_GET ; Get name field +``` + +**With variables**: +``` +LOAD data +LOAD key ; Key can be string or number +DOT_GET ; Works for both array and dict +``` + +**Null safety**: Returns null for missing keys or out-of-bounds indices +``` +MAKE_ARRAY #0 +PUSH 0 +DOT_GET ; → null (empty array) + +MAKE_DICT #0 +PUSH 'key' +DOT_GET ; → null (missing key) +``` + ## Key Concepts ### Truthiness diff --git a/SPEC.md b/SPEC.md index a0c0efa..e67c114 100644 --- a/SPEC.md +++ b/SPEC.md @@ -509,6 +509,51 @@ Key is coerced to string. Key is coerced to string. **Errors**: Throws if not dict +### Unified Access + +#### DOT_GET +**Operand**: None +**Effect**: Get value from array or dict +**Stack**: [array|dict, index|key] → [value] + +**Behavior**: +- If target is array: coerce index to number and access `array[index]` +- If target is dict: coerce key to string and access `dict.get(key)` +- Returns null if index out of bounds or key not found + +**Errors**: Throws if target is not array or dict + +**Use Cases**: +- Unified syntax for accessing both arrays and dicts +- Chaining access operations: `obj.users.0.name` +- Generic accessor that works with any indexable type + +**Example**: +``` +; Array access +PUSH 10 +PUSH 20 +PUSH 30 +MAKE_ARRAY #3 +PUSH 1 +DOT_GET ; → 20 + +; Dict access +PUSH 'name' +PUSH 'Alice' +MAKE_DICT #1 +PUSH 'name' +DOT_GET ; → 'Alice' + +; Chained access +; dict['users'][0] +LOAD dict +PUSH 'users' +DOT_GET +PUSH 0 +DOT_GET +``` + ### String Operations #### STR_CONCAT diff --git a/src/opcode.ts b/src/opcode.ts index 22c97d2..063da52 100644 --- a/src/opcode.ts +++ b/src/opcode.ts @@ -59,6 +59,9 @@ export enum OpCode { DICT_SET, // operand: none | stack: [dict, key, value] → [] | mutates dict DICT_HAS, // operand: none | stack: [dict, key] → [boolean] + // arrays and dicts + DOT_GET, // operand: none | stack: [array|dict, index|key] → [value] | unified accessor, returns null if missing + // strings STR_CONCAT, // operand: value count (number) | stack: [val1, ..., valN] → [string] | concatenate N values diff --git a/src/vm.ts b/src/vm.ts index 98fa816..de679fb 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -338,6 +338,22 @@ export class VM { this.stack.push({ type: 'boolean', value: hasDict.value.has(toString(hasKey)) }) break + case OpCode.DOT_GET: { + const index = this.stack.pop()! + const target = this.stack.pop()! + + if (target.type === 'array') + this.stack.push(toValue(target.value?.[Number(index.value)])) + + else if (target.type === 'dict') + this.stack.push(toValue(target.value?.get(String(index.value)))) + + else + throw new Error(`DOT_GET: ${target.type} not supported`) + + break + } + case OpCode.STR_CONCAT: let count = instruction.operand as number let parts = [] diff --git a/tests/opcodes.test.ts b/tests/opcodes.test.ts index ffd4106..0336752 100644 --- a/tests/opcodes.test.ts +++ b/tests/opcodes.test.ts @@ -786,6 +786,459 @@ describe("DICT_HAS", () => { }) }) +describe("DOT_GET", () => { + test("gets an ARRAY index", async () => { + const bytecode = toBytecode(` + PUSH 100 + PUSH 200 + PUSH 300 + PUSH 400 + MAKE_ARRAY #4 + PUSH 2 + DOT_GET +`) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 300 }) + }) + + test("gets a DICT value", async () => { + const bytecode = toBytecode(` + PUSH 'name' + PUSH 'Bob' + PUSH 'age' + PUSH 106 + MAKE_DICT #2 + PUSH 'name' + DOT_GET +`) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'Bob' }) + }) + + test("fails to work on NUMBER", async () => { + const bytecode = toBytecode(` + PUSH 500 + PUSH 0 + DOT_GET + `) + + try { + await run(bytecode) + expect(true).toBe(false) // Should not reach here + } catch (e: any) { + expect(e.message).toContain('DOT_GET: number not supported') + } + }) + + test("fails to work on STRING", async () => { + const bytecode = toBytecode(` + PUSH 'ShrimpVM?' + PUSH 1 + DOT_GET + `) + + try { + await run(bytecode) + expect(true).toBe(false) // Should not reach here + } catch (e: any) { + expect(e.message).toContain('DOT_GET: string not supported') + } + }) + + test("array - returns null for out of bounds", async () => { + const bytecode = toBytecode(` + PUSH 10 + PUSH 20 + MAKE_ARRAY #2 + PUSH 5 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("array - negative index", async () => { + const bytecode = toBytecode(` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY #3 + PUSH -1 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("array - first and last element", async () => { + const bytecode = toBytecode(` + PUSH 100 + PUSH 200 + PUSH 300 + MAKE_ARRAY #3 + DUP + PUSH 0 + DOT_GET + STORE first + PUSH 2 + DOT_GET + STORE last + LOAD first + LOAD last + ADD + `) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 400 }) + }) + + test("dict - returns null for missing key", async () => { + const bytecode = toBytecode(` + PUSH 'name' + PUSH 'Alice' + MAKE_DICT #1 + PUSH 'age' + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("dict - with numeric key", async () => { + const bytecode = toBytecode(` + PUSH '123' + PUSH 'value' + MAKE_DICT #1 + PUSH 123 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'value' }) + }) + + test("array - with string value", async () => { + const bytecode = toBytecode(` + PUSH 'foo' + PUSH 'bar' + PUSH 'baz' + MAKE_ARRAY #3 + PUSH 1 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'bar' }) + }) + + test("array - with boolean values", async () => { + const bytecode = toBytecode(` + PUSH true + PUSH false + PUSH true + MAKE_ARRAY #3 + PUSH 1 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'boolean', value: false }) + }) + + test("dict - with boolean value", async () => { + const bytecode = toBytecode(` + PUSH 'active' + PUSH true + PUSH 'visible' + PUSH false + MAKE_DICT #2 + PUSH 'active' + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'boolean', value: true }) + }) + + test("array - nested arrays", async () => { + const bytecode = toBytecode(` + PUSH 1 + PUSH 2 + MAKE_ARRAY #2 + PUSH 3 + PUSH 4 + MAKE_ARRAY #2 + MAKE_ARRAY #2 + PUSH 1 + DOT_GET + `) + + const result = await run(bytecode) + expect(result.type).toBe('array') + if (result.type === 'array') { + expect(result.value).toHaveLength(2) + expect(result.value[0]).toEqual({ type: 'number', value: 3 }) + expect(result.value[1]).toEqual({ type: 'number', value: 4 }) + } + }) + + test("dict - nested dicts", async () => { + const bytecode = toBytecode(` + PUSH 'user' + PUSH 'name' + PUSH 'Alice' + MAKE_DICT #1 + MAKE_DICT #1 + PUSH 'user' + DOT_GET + `) + + const result = await run(bytecode) + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.get('name')).toEqual({ type: 'string', value: 'Alice' }) + } + }) + + test("array format", async () => { + const bytecode = toBytecode([ + ["PUSH", "apple"], + ["PUSH", "banana"], + ["PUSH", "cherry"], + ["MAKE_ARRAY", 3], + ["PUSH", 1], + ["DOT_GET"], + ["HALT"] + ]) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'banana' }) + }) + + test("with variables - array", async () => { + const bytecode = toBytecode(` + PUSH 10 + PUSH 20 + PUSH 30 + MAKE_ARRAY #3 + STORE arr + PUSH 1 + STORE idx + LOAD arr + LOAD idx + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 20 }) + }) + + test("with variables - dict", async () => { + const bytecode = toBytecode(` + PUSH 'x' + PUSH 100 + PUSH 'y' + PUSH 200 + MAKE_DICT #2 + STORE point + PUSH 'y' + STORE key + LOAD point + LOAD key + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 200 }) + }) + + test("with expression as index", async () => { + const bytecode = toBytecode(` + PUSH 10 + PUSH 20 + PUSH 30 + PUSH 40 + MAKE_ARRAY #4 + PUSH 1 + PUSH 2 + ADD + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 40 }) + }) + + test("chained access - array of dicts", async () => { + const bytecode = toBytecode(` + PUSH 'name' + PUSH 'Alice' + MAKE_DICT #1 + PUSH 'name' + PUSH 'Bob' + MAKE_DICT #1 + MAKE_ARRAY #2 + PUSH 1 + DOT_GET + PUSH 'name' + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'Bob' }) + }) + + test("chained access - dict of arrays", async () => { + const bytecode = toBytecode(` + PUSH 'nums' + PUSH 1 + PUSH 2 + PUSH 3 + MAKE_ARRAY #3 + MAKE_DICT #1 + PUSH 'nums' + DOT_GET + PUSH 1 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'number', value: 2 }) + }) + + test("with null value in array", async () => { + const bytecode = toBytecode(` + PUSH 10 + PUSH null + PUSH 30 + MAKE_ARRAY #3 + PUSH 1 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("with null value in dict", async () => { + const bytecode = toBytecode(` + PUSH 'name' + PUSH 'Alice' + PUSH 'middle' + PUSH null + MAKE_DICT #2 + PUSH 'middle' + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("array with regex values", async () => { + const bytecode = toBytecode(` + PUSH /foo/ + PUSH /bar/i + PUSH /baz/g + MAKE_ARRAY #3 + PUSH 1 + DOT_GET + `) + + const result = await run(bytecode) + expect(result.type).toBe('regex') + if (result.type === 'regex') { + expect(result.value.source).toBe('bar') + expect(result.value.ignoreCase).toBe(true) + } + }) + + test("dict with regex values", async () => { + const bytecode = toBytecode(` + PUSH 'email' + PUSH /^[a-z@.]+$/i + PUSH 'url' + PUSH /^https?:\\/\\// + MAKE_DICT #2 + PUSH 'email' + DOT_GET + `) + + const result = await run(bytecode) + expect(result.type).toBe('regex') + if (result.type === 'regex') { + expect(result.value.source).toBe('^[a-z@.]+$') + expect(result.value.ignoreCase).toBe(true) + } + }) + + test("dict - key coercion from number", async () => { + const bytecode = toBytecode(` + PUSH '0' + PUSH 'zero' + PUSH '1' + PUSH 'one' + MAKE_DICT #2 + PUSH 0 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'string', value: 'zero' }) + }) + + test("fails on boolean", async () => { + const bytecode = toBytecode(` + PUSH true + PUSH 0 + DOT_GET + `) + + try { + await run(bytecode) + expect(true).toBe(false) + } catch (e: any) { + expect(e.message).toContain('DOT_GET: boolean not supported') + } + }) + + test("fails on null", async () => { + const bytecode = toBytecode(` + PUSH null + PUSH 0 + DOT_GET + `) + + try { + await run(bytecode) + expect(true).toBe(false) + } catch (e: any) { + expect(e.message).toContain('DOT_GET: null not supported') + } + }) + + test("fails on regex", async () => { + const bytecode = toBytecode(` + PUSH /test/ + PUSH 0 + DOT_GET + `) + + try { + await run(bytecode) + expect(true).toBe(false) + } catch (e: any) { + expect(e.message).toContain('DOT_GET: regex not supported') + } + }) + + test("empty array access", async () => { + const bytecode = toBytecode(` + MAKE_ARRAY #0 + PUSH 0 + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) + + test("empty dict access", async () => { + const bytecode = toBytecode(` + MAKE_DICT #0 + PUSH 'key' + DOT_GET + `) + + expect(await run(bytecode)).toEqual({ type: 'null', value: null }) + }) +}) + describe("STR_CONCAT", () => { test("concats together strings", async () => { const str = `