This commit is contained in:
Chris Wanstrath 2025-10-16 15:51:38 -07:00
parent b16351ac95
commit 1a18a713d7
5 changed files with 570 additions and 0 deletions

View File

@ -235,6 +235,9 @@ CALL
- `DICT_SET` - Pop value, key, dict; mutate dict - `DICT_SET` - Pop value, key, dict; mutate dict
- `DICT_HAS` - Pop key and dict, push boolean - `DICT_HAS` - Pop key and dict, push boolean
### Unified Access
- `DOT_GET` - Pop index/key and array/dict, push value (null if missing)
### Strings ### Strings
- `STR_CONCAT #N` - Pop N values, convert to strings, concatenate, push result - `STR_CONCAT #N` - Pop N values, convert to strings, concatenate, push result
@ -474,6 +477,56 @@ PUSH "!"
STR_CONCAT #2 ; → "Hello World!" 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 ## Key Concepts
### Truthiness ### Truthiness

45
SPEC.md
View File

@ -509,6 +509,51 @@ Key is coerced to string.
Key is coerced to string. Key is coerced to string.
**Errors**: Throws if not dict **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 ### String Operations
#### STR_CONCAT #### STR_CONCAT

View File

@ -59,6 +59,9 @@ export enum OpCode {
DICT_SET, // operand: none | stack: [dict, key, value] → [] | mutates dict DICT_SET, // operand: none | stack: [dict, key, value] → [] | mutates dict
DICT_HAS, // operand: none | stack: [dict, key] → [boolean] 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 // strings
STR_CONCAT, // operand: value count (number) | stack: [val1, ..., valN] → [string] | concatenate N values STR_CONCAT, // operand: value count (number) | stack: [val1, ..., valN] → [string] | concatenate N values

View File

@ -338,6 +338,22 @@ export class VM {
this.stack.push({ type: 'boolean', value: hasDict.value.has(toString(hasKey)) }) this.stack.push({ type: 'boolean', value: hasDict.value.has(toString(hasKey)) })
break 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: case OpCode.STR_CONCAT:
let count = instruction.operand as number let count = instruction.operand as number
let parts = [] let parts = []

View File

@ -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", () => { describe("STR_CONCAT", () => {
test("concats together strings", async () => { test("concats together strings", async () => {
const str = ` const str = `