From d957675ac81d24aa918847f317b94a89aaede11d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:15:35 -0700 Subject: [PATCH 1/5] there are always StringFragments --- src/compiler/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 7fd9cf5..c5fb786 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -236,7 +236,12 @@ export const getStringParts = (node: SyntaxNode, input: string) => { } }) - return { parts, hasInterpolation: parts.length > 0 } + // hasInterpolation means the string has interpolation ($var) or escape sequences (\n) + // A simple string like 'hello' has one StringFragment but no interpolation + const hasInterpolation = parts.some( + (p) => p.type.id === terms.Interpolation || p.type.id === terms.EscapeSeq + ) + return { parts, hasInterpolation } } export const getDotGetParts = (node: SyntaxNode, input: string) => { From 4f961d3039f20cb79bc568d9c2ea08367326f614 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:29:07 -0700 Subject: [PATCH 2/5] test native exceptions --- bun.lock | 2 +- src/compiler/tests/native-exceptions.test.ts | 292 +++++++++++++++++++ src/parser/shrimp.grammar | 2 +- src/parser/shrimp.ts | 8 +- 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/compiler/tests/native-exceptions.test.ts diff --git a/bun.lock b/bun.lock index 005ca60..e8b8c92 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ "hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="], - "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#030eb7487165b3ba502965a8b7fa09c4b5fdb0da", { "peerDependencies": { "typescript": "^5" } }, "030eb7487165b3ba502965a8b7fa09c4b5fdb0da"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d", { "peerDependencies": { "typescript": "^5" } }, "9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], diff --git a/src/compiler/tests/native-exceptions.test.ts b/src/compiler/tests/native-exceptions.test.ts new file mode 100644 index 0000000..a301fae --- /dev/null +++ b/src/compiler/tests/native-exceptions.test.ts @@ -0,0 +1,292 @@ +import { describe, test, expect } from 'bun:test' +import { Compiler } from '#compiler/compiler' +import { VM } from 'reefvm' + +describe('Native Function Exceptions', () => { + test('native function error caught by try/catch', async () => { + const code = ` + result = try: + failing-fn + catch e: + 'caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('native function failed') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught: native function failed' }) + }) + + test('async native function error caught by try/catch', async () => { + const code = ` + result = try: + async-fail + catch e: + 'async caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('async-fail', async () => { + await new Promise(resolve => setTimeout(resolve, 1)) + throw new Error('async error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'async caught: async error' }) + }) + + test('native function with arguments throwing error', async () => { + const code = ` + result = try: + read-file missing.txt + catch e: + 'default content' + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('read-file', (path: string) => { + if (path === 'missing.txt') { + throw new Error('file not found') + } + return 'file contents' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'default content' }) + }) + + test('native function error with finally block', async () => { + const code = ` + cleanup-count = 0 + + result = try: + failing-fn + catch e: + 'error handled' + finally: + cleanup-count = cleanup-count + 1 + end + + cleanup-count + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('native error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 1 }) + }) + + test('native function error without catch propagates', async () => { + const code = ` + failing-fn + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('uncaught error') + }) + + await expect(vm.run()).rejects.toThrow('Uncaught exception in native function: uncaught error') + }) + + test('native function in function-level catch', async () => { + const code = ` + safe-read = do path: + read-file path + catch e: + 'default: ' + e + end + + result = safe-read missing.txt + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('read-file', (path: string) => { + throw new Error('file not found: ' + path) + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'default: file not found: missing.txt' }) + }) + + test('nested native function errors', async () => { + const code = ` + result = try: + try: + inner-fail + catch e: + throw 'wrapped: ' + e + end + catch e: + 'outer caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('inner-fail', () => { + throw new Error('inner error') + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'outer caught: wrapped: inner error' }) + }) + + test('native function error with multiple named args', async () => { + const code = ` + result = try: + process-file path=missing.txt mode=strict + catch e: + 'error: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('process-file', (path: string, mode: string = 'lenient') => { + if (mode === 'strict' && path === 'missing.txt') { + throw new Error('strict mode: file required') + } + return 'processed' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'error: strict mode: file required' }) + }) + + test('native function returning normally after other functions threw', async () => { + const code = ` + result1 = try: + failing-fn + catch e: + 'caught' + end + + result2 = success-fn + + result1 + ' then ' + result2 + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('failing-fn', () => { + throw new Error('error') + }) + + vm.set('success-fn', () => { + return 'success' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught then success' }) + }) + + test('native function error message preserved', async () => { + const code = ` + result = try: + throw-custom-message + catch e: + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('throw-custom-message', () => { + throw new Error('This is a very specific error message with details') + }) + + const result = await vm.run() + expect(result).toEqual({ + type: 'string', + value: 'This is a very specific error message with details' + }) + }) + + test('native function throwing non-Error value', async () => { + const code = ` + result = try: + throw-string + catch e: + 'caught: ' + e + end + + result + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('throw-string', () => { + throw 'plain string error' + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'caught: plain string error' }) + }) + + test('multiple native function calls with mixed success/failure', async () => { + const code = ` + r1 = try: success-fn catch e: 'error' end + r2 = try: failing-fn catch e: 'caught' end + r3 = try: success-fn catch e: 'error' end + + results = [r1 r2 r3] + results + ` + + const compiler = new Compiler(code) + const vm = new VM(compiler.bytecode) + + vm.set('success-fn', () => 'ok') + vm.set('failing-fn', () => { + throw new Error('failed') + }) + + const result = await vm.run() + expect(result.type).toBe('array') + const arr = result.value as any[] + expect(arr.length).toBe(3) + expect(arr[0]).toEqual({ type: 'string', value: 'ok' }) + expect(arr[1]).toEqual({ type: 'string', value: 'caught' }) + expect(arr[2]).toEqual({ type: 'string', value: 'ok' }) + }) +}) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 455a9b2..80cdef5 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -159,7 +159,7 @@ TryBlock { } Throw { - @specialize[@name=keyword] expression + @specialize[@name=keyword] (BinOp | ConditionalOp | expression) } ConditionalOp { diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index f1f7a33..ec84f41 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -7,9 +7,9 @@ import {highlighting} from "./highlight" const spec_Identifier = {__proto__:null,catch:82, finally:88, end:90, null:96, try:106, throw:110, if:114, elseif:122, else:126} export const parser = LRParser.deserialize({ version: 14, - states: "8lQYQbOOO#tQcO'#CvO$qOSO'#CxO%PQbO'#E`OOQ`'#DR'#DROOQa'#DO'#DOO&SQbO'#D]O'XQcO'#ETOOQa'#ET'#ETO)cQcO'#ESO*bQRO'#CwO+WQcO'#EOO+hQcO'#EOO+rQbO'#CuO,jOpO'#CsOOQ`'#EP'#EPO,oQbO'#EOO,vQQO'#EfOOQ`'#Db'#DbO,{QbO'#DdO,{QbO'#EhOOQ`'#Df'#DfO-pQRO'#DnOOQ`'#EO'#EOO-uQQO'#D}OOQ`'#D}'#D}OOQ`'#Do'#DoQYQbOOO-}QbO'#DPOOQa'#ES'#ESOOQ`'#D`'#D`OOQ`'#Ee'#EeOOQ`'#Dv'#DvO.XQbO,59^O.rQbO'#CzO.zQWO'#C{OOOO'#EV'#EVOOOO'#Dp'#DpO/`OSO,59dOOQa,59d,59dOOQ`'#Dr'#DrO/nQbO'#DSO/vQQO,5:zOOQ`'#Dq'#DqO/{QbO,59wO0SQQO,59jOOQa,59w,59wO0_QbO,59wO,{QbO,59cO,{QbO,59cO,{QbO,59cO,{QbO,59yO,{QbO,59yO,{QbO,59yO0iQRO,59aO0pQRO,59aO1RQRO,59aO0|QQO,59aO1^QQO,59aO1fObO,59_O1qQbO'#DwO1|QbO,59]O2eQbO,5;QOOQ`,5:O,5:OO2xQRO,5;SO3PQRO,5;SO3[QbO,5:YOOQ`,5:i,5:iOOQ`-E7m-E7mOOQ`,59k,59kOOQ`-E7t-E7tOOOO,59f,59fOOOO,59g,59gOOOO-E7n-E7nOOQa1G/O1G/OOOQ`-E7p-E7pO3lQbO1G0fOOQ`-E7o-E7oO4PQQO1G/UOOQa1G/c1G/cO4[QbO1G/cOOQO'#Dt'#DtO4PQQO1G/UOOQa1G/U1G/UOOQ`'#Du'#DuO4[QbO1G/cOOQa1G.}1G.}O5TQcO1G.}O5_QcO1G.}O5iQcO1G.}OOQa1G/e1G/eO7XQcO1G/eO7`QcO1G/eO7gQcO1G/eOOQa1G.{1G.{OOQa1G.y1G.yO!aQbO'#CvO&ZQbO'#CrOOQ`,5:c,5:cOOQ`-E7u-E7uO7nQbO1G0lO7yQbO1G0mO8gQbO1G0nOOQ`1G/t1G/tO8zQbO7+&QO7yQbO7+&SO9VQQO7+$pOOQa7+$p7+$pO9bQbO7+$}OOQa7+$}7+$}OOQO-E7r-E7rOOQ`-E7s-E7sO9lQbO'#DUO9qQQO'#DXOOQ`7+&W7+&WO9vQbO7+&WO9{QbO7+&WOOQ`'#Ds'#DsO:TQQO'#DsO:YQbO'#EaOOQ`'#DW'#DWO:|QbO7+&XOOQ`'#Dh'#DhO;XQbO7+&YO;^QbO7+&ZOOQ`<bQbO1G/_OOQ`1G/_1G/_OOQ`AN?^AN?^OOQ`AN?_AN?_O>xQbOAN?_O,{QbO'#DjOOQ`'#Dx'#DxO>}QbOAN?aO?YQQO'#DlOOQ`AN?aAN?aO?_QbOAN?aOOQ`G24rG24rOOQ`G24tG24tO?dQbOG24tO?iQbO7+$vOOQ`7+$v7+$vOOQ`7+$y7+$yOOQ`G24yG24yO@SQRO,5:UO@ZQRO,5:UOOQ`-E7v-E7vOOQ`G24{G24{O@fQbOG24{O@kQQO,5:WOOQ`LD*`LD*`OOQ`<bQbO1G/rO;^QbO7+%[OOQ`7+%^7+%^OOQ`<PQbO<[QQO,59pO>aQbO,59sOOQ`<tQbO<yQbO< (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1463 + tokenPrec: 1547 }) From f8d2236292deeb14bddbe33e8d96024b276eac64 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:36:18 -0700 Subject: [PATCH 3/5] add exceptions to prelude functions --- src/prelude/index.ts | 21 +++++++++++++++++++-- src/prelude/list.ts | 10 ++++++++-- src/prelude/math.ts | 20 ++++++++++++++++---- src/prelude/str.ts | 6 +++++- src/prelude/tests/prelude.test.ts | 9 ++++++--- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index facf4b8..cc46ead 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -39,7 +39,7 @@ export const globals = { switch (value.type) { case 'string': case 'array': return value.value.length case 'dict': return value.value.size - default: return 0 + default: throw new Error(`length: expected string, array, or dict, got ${value.type}`) } }, @@ -65,7 +65,24 @@ export const globals = { identity: (v: any) => v, // collections - at: (collection: any, index: number | string) => collection[index], + at: (collection: any, index: number | string) => { + const value = toValue(collection) + if (value.type === 'string' || value.type === 'array') { + const idx = typeof index === 'number' ? index : parseInt(index as string) + if (idx < 0 || idx >= value.value.length) { + throw new Error(`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`) + } + return value.value[idx] + } else if (value.type === 'dict') { + const key = String(index) + if (!value.value.has(key)) { + throw new Error(`at: key '${key}' not found in dict`) + } + return value.value.get(key) + } else { + throw new Error(`at: expected string, array, or dict, got ${value.type}`) + } + }, range: (start: number, end: number | null) => { if (end === null) { end = start diff --git a/src/prelude/list.ts b/src/prelude/list.ts index eb013ef..8c0452d 100644 --- a/src/prelude/list.ts +++ b/src/prelude/list.ts @@ -52,8 +52,14 @@ export const list = { first: (list: any[]) => list[0] ?? null, last: (list: any[]) => list[list.length - 1] ?? null, rest: (list: any[]) => list.slice(1), - take: (list: any[], n: number) => list.slice(0, n), - drop: (list: any[], n: number) => list.slice(n), + take: (list: any[], n: number) => { + if (n < 0) throw new Error(`take: count must be non-negative, got ${n}`) + return list.slice(0, n) + }, + drop: (list: any[], n: number) => { + if (n < 0) throw new Error(`drop: count must be non-negative, got ${n}`) + return list.slice(n) + }, append: (list: any[], item: any) => [...list, item], prepend: (list: any[], item: any) => [item, ...list], 'index-of': (list: any[], item: any) => list.indexOf(item), diff --git a/src/prelude/math.ts b/src/prelude/math.ts index 21f2f57..148cde9 100644 --- a/src/prelude/math.ts +++ b/src/prelude/math.ts @@ -3,12 +3,24 @@ export const math = { floor: (n: number) => Math.floor(n), ceil: (n: number) => Math.ceil(n), round: (n: number) => Math.round(n), - min: (...nums: number[]) => Math.min(...nums), - max: (...nums: number[]) => Math.max(...nums), + min: (...nums: number[]) => { + if (nums.length === 0) throw new Error('min: expected at least one argument') + return Math.min(...nums) + }, + max: (...nums: number[]) => { + if (nums.length === 0) throw new Error('max: expected at least one argument') + return Math.max(...nums) + }, pow: (base: number, exp: number) => Math.pow(base, exp), - sqrt: (n: number) => Math.sqrt(n), + sqrt: (n: number) => { + if (n < 0) throw new Error(`sqrt: cannot take square root of negative number ${n}`) + return Math.sqrt(n) + }, random: () => Math.random(), - clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max), + clamp: (n: number, min: number, max: number) => { + if (min > max) throw new Error(`clamp: min (${min}) must be less than or equal to max (${max})`) + return Math.min(Math.max(n, min), max) + }, sign: (n: number) => Math.sign(n), trunc: (n: number) => Math.trunc(n), diff --git a/src/prelude/str.ts b/src/prelude/str.ts index fa0d657..5aede56 100644 --- a/src/prelude/str.ts +++ b/src/prelude/str.ts @@ -21,7 +21,11 @@ export const str = { 'replace-all': (str: string, search: string, replacement: string) => str.replaceAll(search, replacement), slice: (str: string, start: number, end?: number | null) => str.slice(start, end ?? undefined), substring: (str: string, start: number, end?: number | null) => str.substring(start, end ?? undefined), - repeat: (str: string, count: number) => str.repeat(count), + repeat: (str: string, count: number) => { + if (count < 0) throw new Error(`repeat: count must be non-negative, got ${count}`) + if (!Number.isInteger(count)) throw new Error(`repeat: count must be an integer, got ${count}`) + return str.repeat(count) + }, 'pad-start': (str: string, length: number, pad: string = ' ') => str.padStart(length, pad), 'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad), lines: (str: string) => str.split('\n'), diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index ef7d8d6..a073d86 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -176,9 +176,12 @@ describe('introspection', () => { await expect(`length 'hello'`).toEvaluateTo(5, globals) await expect(`length [1 2 3]`).toEvaluateTo(3, globals) await expect(`length [a=1 b=2]`).toEvaluateTo(2, globals) - await expect(`length 42`).toEvaluateTo(0, globals) - await expect(`length true`).toEvaluateTo(0, globals) - await expect(`length null`).toEvaluateTo(0, globals) + }) + + test('length throws on invalid types', async () => { + await expect(`try: length 42 catch e: 'error' end`).toEvaluateTo('error', globals) + await expect(`try: length true catch e: 'error' end`).toEvaluateTo('error', globals) + await expect(`try: length null catch e: 'error' end`).toEvaluateTo('error', globals) }) test('inspect formats values', async () => { From e60e3184faa46f384e5f0e4647e390330a843775 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 15:37:39 -0700 Subject: [PATCH 4/5] less chatty --- src/compiler/tests/native-exceptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/tests/native-exceptions.test.ts b/src/compiler/tests/native-exceptions.test.ts index a301fae..f7e2e37 100644 --- a/src/compiler/tests/native-exceptions.test.ts +++ b/src/compiler/tests/native-exceptions.test.ts @@ -111,7 +111,7 @@ describe('Native Function Exceptions', () => { throw new Error('uncaught error') }) - await expect(vm.run()).rejects.toThrow('Uncaught exception in native function: uncaught error') + await expect(vm.run()).rejects.toThrow('uncaught error') }) test('native function in function-level catch', async () => { From 2329a2ebb685c80796a04cc35c56bf1ab1430270 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Fri, 31 Oct 2025 10:04:43 -0700 Subject: [PATCH 5/5] Update bun.lock --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index e8b8c92..afb8aaa 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ "hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="], - "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d", { "peerDependencies": { "typescript": "^5" } }, "9618dd64148ccf6f0cdfd8a80a0f58efe3e0819d"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#c69b172c78853756ec8acba5bc33d93eb6a571c6", { "peerDependencies": { "typescript": "^5" } }, "c69b172c78853756ec8acba5bc33d93eb6a571c6"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],