From 7da4c1496293526f176eb8d43d864496d48c3764 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 16:30:45 -0700 Subject: [PATCH 01/44] parse arrays --- src/parser/shrimp.grammar | 6 ++- src/parser/shrimp.terms.ts | 33 +++++++------- src/parser/shrimp.ts | 22 +++++----- src/parser/tests/literals.test.ts | 72 +++++++++++++++++++++++++++++++ src/parser/tokenizer.ts | 8 +++- 5 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 src/parser/tests/literals.test.ts diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 0968765..d7edba5 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -191,6 +191,10 @@ EscapeSeq { "\\" ("$" | "n" | "t" | "r" | "\\" | "'") } +Array { + "[" (newlineOrSemicolon | expression)* "]" +} + // We need expressionWithoutIdentifier to avoid conflicts in consumeToTerminator. // Without this, when parsing "my-var" at statement level, the parser can't decide: // - ambiguousFunctionCall → FunctionCallOrIdentifier → Identifier @@ -200,7 +204,7 @@ EscapeSeq { // to go through ambiguousFunctionCall (which is what we want semantically). // Yes, it is annoying and I gave up trying to use GLR to fix it. expressionWithoutIdentifier { - ParenExpr | Word | String | Number | Boolean | Regex | @specialize[@name=Null] + ParenExpr | Word | String | Number | Boolean | Regex | Array | @specialize[@name=Null] } block { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 6ea2f2a..9712d60 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -31,19 +31,20 @@ export const EscapeSeq = 29, Boolean = 30, Regex = 31, - Null = 32, - ConditionalOp = 33, - FunctionDef = 34, - Params = 35, - colon = 36, - keyword = 50, - PositionalArg = 38, - Underscore = 39, - NamedArg = 40, - NamedArgPrefix = 41, - IfExpr = 43, - SingleLineThenBlock = 45, - ThenBlock = 46, - ElseIfExpr = 47, - ElseExpr = 49, - Assign = 51 + Array = 32, + Null = 33, + ConditionalOp = 34, + FunctionDef = 35, + Params = 36, + colon = 37, + keyword = 51, + PositionalArg = 39, + Underscore = 40, + NamedArg = 41, + NamedArgPrefix = 42, + IfExpr = 44, + SingleLineThenBlock = 46, + ThenBlock = 47, + ElseIfExpr = 48, + ElseExpr = 50, + Assign = 52 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 40ba69f..9e934e5 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./scopeTracker" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,null:64, end:74, if:88, elseif:96, else:100} +const spec_Identifier = {__proto__:null,null:66, end:76, if:90, elseif:98, else:102} export const parser = LRParser.deserialize({ version: 14, - states: "/SQYQbOOO!TOpO'#CqO#aQcO'#CtO$ZOSO'#CvO%aQcO'#DsOOQa'#Ds'#DsO&gQcO'#DrO'OQRO'#CuO'^QcO'#DnO'uQbO'#D{OOQ`'#DO'#DOO'}QbO'#CsOOQ`'#Do'#DoO(oQbO'#DnO(}QbO'#EROOQ`'#DX'#DXO)lQRO'#DaOOQ`'#Dn'#DnO)qQQO'#DmOOQ`'#Dm'#DmOOQ`'#Db'#DbQYQbOOO)yObO,59]OOQa'#Dr'#DrOOQ`'#DS'#DSO*RQbO'#DUOOQ`'#EQ'#EQOOQ`'#Df'#DfO*]QbO,59[O*pQbO'#CxO*xQWO'#CyOOOO'#Du'#DuOOOO'#Dc'#DcO+^OSO,59bOOQa,59b,59bO(}QbO,59aO(}QbO,59aOOQ`'#Dd'#DdO+lQbO'#DPO+tQQO,5:gO+yQRO,59_O-`QRO'#CuO-pQRO,59_O-|QQO,59_O.RQQO,59_O.ZQbO'#DgO.fQbO,59ZO.wQRO,5:mO/OQQO,5:mO/TQbO,59{OOQ`,5:X,5:XOOQ`-E7`-E7`OOQa1G.w1G.wOOQ`,59p,59pOOQ`-E7d-E7dOOOO,59d,59dOOOO,59e,59eOOOO-E7a-E7aOOQa1G.|1G.|OOQa1G.{1G.{O/_QcO1G.{OOQ`-E7b-E7bO/yQbO1G0ROOQa1G.y1G.yO(}QbO,59iO(}QbO,59iO!YQbO'#CtO$iQbO'#CpOOQ`,5:R,5:ROOQ`-E7e-E7eO0WQbO1G0XOOQ`1G/g1G/gO0eQbO7+%mO0jQbO7+%nOOQO1G/T1G/TO0zQRO1G/TOOQ`'#DZ'#DZO1UQbO7+%sO1ZQbO7+%tOOQ`<tAN>tO(}QbO'#D]OOQ`'#Dh'#DhO2nQbOAN>zO2yQQO'#D_OOQ`AN>zAN>zO3OQbOAN>zO3TQRO,59wO3[QQO,59wOOQ`-E7f-E7fOOQ`G24fG24fO3aQbOG24fO3fQQO,59yO3kQQO1G/cOOQ`LD*QLD*QO0jQbO1G/eO1ZQbO7+$}OOQ`7+%P7+%POOQ`<yAN>yO*VQbO'#D^OOQ`'#Dj'#DjO4QQbOAN?OO4]QQO'#D`OOQ`AN?OAN?OO4bQbOAN?OO4gQRO,59xO4nQQO,59xOOQ`-E7h-E7hOOQ`G24jG24jO4sQbOG24jO4xQQO,59zO4}QQO1G/dOOQ`LD*ULD*UO1|QbO1G/fO2mQbO7+%OOOQ`7+%Q7+%QOOQ`<i~RzOX#uXY$dYZ$}Zp#upq$dqs#ust%htu'Puw#uwx'Uxy'Zyz'tz{#u{|(_|}#u}!O(_!O!P#u!P!Q+R!Q![(|![!]3n!]!^$}!^#O#u#O#P4X#P#R#u#R#S4^#S#T#u#T#Y4w#Y#Z6V#Z#b4w#b#c:e#c#f4w#f#g;[#g#h4w#h#idS#zUkSOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uS$aP;=`<%l#u^$kUkS!_YOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uU%UUkS!qQOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#u^%oZkS!`YOY%hYZ#uZt%htu&buw%hwx&bx#O%h#O#P&b#P;'S%h;'S;=`&y<%lO%hY&gS!`YOY&bZ;'S&b;'S;=`&s<%lO&bY&vP;=`<%l&b^&|P;=`<%l%h~'UO!j~~'ZO!h~U'bUkS!eQOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uU'{UkS!sQOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uU(dWkSOt#uuw#ux!Q#u!Q![(|![#O#u#P;'S#u;'S;=`$^<%lO#uU)TYkSfQOt#uuw#ux!O#u!O!P)s!P!Q#u!Q![(|![#O#u#P;'S#u;'S;=`$^<%lO#uU)xWkSOt#uuw#ux!Q#u!Q![*b![#O#u#P;'S#u;'S;=`$^<%lO#uU*iWkSfQOt#uuw#ux!Q#u!Q![*b![#O#u#P;'S#u;'S;=`$^<%lO#uU+WWkSOt#uuw#ux!P#u!P!Q+p!Q#O#u#P;'S#u;'S;=`$^<%lO#uU+u^kSOY,qYZ#uZt,qtu-tuw,qwx-tx!P,q!P!Q#u!Q!},q!}#O2g#O#P0S#P;'S,q;'S;=`3h<%lO,qU,x^kSoQOY,qYZ#uZt,qtu-tuw,qwx-tx!P,q!P!Q0i!Q!},q!}#O2g#O#P0S#P;'S,q;'S;=`3h<%lO,qQ-yXoQOY-tZ!P-t!P!Q.f!Q!}-t!}#O/T#O#P0S#P;'S-t;'S;=`0c<%lO-tQ.iP!P!Q.lQ.qUoQ#Z#[.l#]#^.l#a#b.l#g#h.l#i#j.l#m#n.lQ/WVOY/TZ#O/T#O#P/m#P#Q-t#Q;'S/T;'S;=`/|<%lO/TQ/pSOY/TZ;'S/T;'S;=`/|<%lO/TQ0PP;=`<%l/TQ0VSOY-tZ;'S-t;'S;=`0c<%lO-tQ0fP;=`<%l-tU0nWkSOt#uuw#ux!P#u!P!Q1W!Q#O#u#P;'S#u;'S;=`$^<%lO#uU1_bkSoQOt#uuw#ux#O#u#P#Z#u#Z#[1W#[#]#u#]#^1W#^#a#u#a#b1W#b#g#u#g#h1W#h#i#u#i#j1W#j#m#u#m#n1W#n;'S#u;'S;=`$^<%lO#uU2l[kSOY2gYZ#uZt2gtu/Tuw2gwx/Tx#O2g#O#P/m#P#Q,q#Q;'S2g;'S;=`3b<%lO2gU3eP;=`<%l2gU3kP;=`<%l,qU3uUkStQOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#u~4^O!k~U4eUkSwQOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uU4|YkSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#o4w#o;'S#u;'S;=`$^<%lO#uU5sUyQkSOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#uU6[ZkSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#U6}#U#o4w#o;'S#u;'S;=`$^<%lO#uU7S[kSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#`4w#`#a7x#a#o4w#o;'S#u;'S;=`$^<%lO#uU7}[kSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#g4w#g#h8s#h#o4w#o;'S#u;'S;=`$^<%lO#uU8x[kSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#X4w#X#Y9n#Y#o4w#o;'S#u;'S;=`$^<%lO#uU9uYnQkSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#o4w#o;'S#u;'S;=`$^<%lO#u^:lY!lWkSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#o4w#o;'S#u;'S;=`$^<%lO#u^;cY!nWkSOt#uuw#ux!_#u!_!`5l!`#O#u#P#T#u#T#o4w#o;'S#u;'S;=`$^<%lO#u^QUzQkSOt#uuw#ux#O#u#P;'S#u;'S;=`$^<%lO#u~>iO!w~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!d~~", 11)], + repeatNodeCount: 8, + tokenData: "?s~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P4x#P#Q4}#Q#R#{#R#S5h#S#T#{#T#Y6R#Y#Z7a#Z#b6R#b#c;o#c#f6R#f#gY#g#o6R#o;'S#{;'S;=`$d<%lO#{U>_[kSOt#{uw#{x!_#{!_!`6v!`#O#{#P#T#{#T#i6R#i#j9}#j#o6R#o;'S#{;'S;=`$d<%lO#{U?[U{QkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~?sO!{~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!f~~", 11)], topRules: {"Program":[0,18]}, specialized: [{term: 13, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 13, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 860 + tokenPrec: 924 }) diff --git a/src/parser/tests/literals.test.ts b/src/parser/tests/literals.test.ts new file mode 100644 index 0000000..c48e0b3 --- /dev/null +++ b/src/parser/tests/literals.test.ts @@ -0,0 +1,72 @@ +import { expect, describe, test } from 'bun:test' + +import '../shrimp.grammar' // Importing this so changes cause it to retest! + +describe('array literals', () => { + test('work with numbers', () => { + expect('[1 2 3]').toMatchTree(` + Array + Number 1 + Number 2 + Number 3 + `) + }) + + test('work with strings', () => { + expect("['one' 'two' 'three']").toMatchTree(` + Array + String + StringFragment one + String + StringFragment two + String + StringFragment three + `) + }) + + test('work with identifiers', () => { + expect('[one two three]').toMatchTree(` + Array + Identifier one + Identifier two + Identifier three + `) + }) + + test('can be nested', () => { + expect('[one [two [three]]]').toMatchTree(` + Array + Identifier one + Array + Identifier two + Array + Identifier three + `) + }) + + test('can span multiple lines', () => { + expect(`[ + 1 + 2 + 3 + ]`).toMatchTree(` + Array + Number 1 + Number 2 + Number 3 + `) + }) + + test('can span multiple w/o calling functions', () => { + expect(`[ + one + two + three + ]`).toMatchTree(` + Array + Identifier one + Identifier two + Identifier three + `) + }) +}) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 0db5545..cef4446 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -195,7 +195,13 @@ const isWhiteSpace = (ch: number): boolean => { } const isWordChar = (ch: number): boolean => { - return !isWhiteSpace(ch) && ch !== 10 /* \n */ && ch !== 41 /* ) */ && ch !== -1 /* EOF */ + return ( + !isWhiteSpace(ch) && + ch !== 10 /* \n */ && + ch !== 41 /* ) */ && + ch !== 93 /* ] */ && + ch !== -1 /* EOF */ + ) } const isLowercaseLetter = (ch: number): boolean => { From 339c09eb8c3f2b7a23f5e4c098321605eec7b75e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 16:47:33 -0700 Subject: [PATCH 02/44] compile array literals --- src/compiler/compiler.ts | 7 ++++++ src/compiler/tests/literals.test.ts | 36 +++++++++++++++++++++++++++++ src/testSetup.ts | 12 +++++----- 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/compiler/tests/literals.test.ts diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index b7e6274..d05f2ce 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -468,6 +468,13 @@ export class Compiler { return instructions } + case terms.Array: { + const children = getAllChildren(node) + const instructions: ProgramItem[] = children.map((x) => this.#compileNode(x, input)).flat() + instructions.push(['MAKE_ARRAY', children.length]) + return instructions + } + default: throw new CompilerError( `Compiler doesn't know how to handle a "${node.type.name}" node.`, diff --git a/src/compiler/tests/literals.test.ts b/src/compiler/tests/literals.test.ts new file mode 100644 index 0000000..4be5579 --- /dev/null +++ b/src/compiler/tests/literals.test.ts @@ -0,0 +1,36 @@ +import { describe } from 'bun:test' +import { expect, test } from 'bun:test' + +describe('array literals', () => { + test('work with numbers', () => { + expect('[1 2 3]').toEvaluateTo([1, 2, 3]) + }) + + test('work with strings', () => { + expect("['one' 'two' 'three']").toEvaluateTo(['one', 'two', 'three']) + }) + + test('work with identifiers', () => { + expect('[one two three]').toEvaluateTo(['one', 'two', 'three']) + }) + + test('can be nested', () => { + expect('[one [two [three]]]').toEvaluateTo(['one', ['two', ['three']]]) + }) + + test('can span multiple lines', () => { + expect(`[ + 1 + 2 + 3 + ]`).toEvaluateTo([1, 2, 3]) + }) + + test('can span multiple w/o calling functions', () => { + expect(`[ + one + two + three + ]`).toEvaluateTo(['one', 'two', 'three']) + }) +}) diff --git a/src/testSetup.ts b/src/testSetup.ts index 8e1f4b8..89203e1 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -93,11 +93,7 @@ expect.extend({ } }, - async toEvaluateTo( - received: unknown, - expected: unknown, - globals: Record = {} - ) { + async toEvaluateTo(received: unknown, expected: unknown, globals: Record = {}) { assert(typeof received === 'string', 'toEvaluateTo can only be used with string values') try { @@ -109,7 +105,7 @@ expect.extend({ if (expected instanceof RegExp) expected = String(expected) if (value instanceof RegExp) value = String(value) - if (value === expected) { + if (isEqual(value, expected)) { return { pass: true } } else { return { @@ -165,3 +161,7 @@ const trimWhitespace = (str: string): string => { }) .join('\n') } + +function isEqual(a: any, b: any): boolean { + return typeof a === 'object' ? JSON.stringify(a) === JSON.stringify(b) : a === b +} From 34c11776369a1d8a5b67d4ac5bbb59fbafe876fe Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 17:03:41 -0700 Subject: [PATCH 03/44] more tests --- packages/ReefVM | 1 + src/compiler/tests/literals.test.ts | 60 +++++++++++++ src/parser/tests/literals.test.ts | 131 ++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 160000 packages/ReefVM diff --git a/packages/ReefVM b/packages/ReefVM new file mode 160000 index 0000000..97b6722 --- /dev/null +++ b/packages/ReefVM @@ -0,0 +1 @@ +Subproject commit 97b6722a113417398a1c47d583bfe07a906f87a0 diff --git a/src/compiler/tests/literals.test.ts b/src/compiler/tests/literals.test.ts index 4be5579..66d0c01 100644 --- a/src/compiler/tests/literals.test.ts +++ b/src/compiler/tests/literals.test.ts @@ -33,4 +33,64 @@ describe('array literals', () => { three ]`).toEvaluateTo(['one', 'two', 'three']) }) + + test('empty arrays', () => { + expect('[]').toEvaluateTo([]) + }) + + test('mixed types', () => { + expect("[1 'two' three true null]").toEvaluateTo([1, 'two', 'three', true, null]) + }) + + test('semicolons as separators', () => { + expect('[1; 2; 3]').toEvaluateTo([1, 2, 3]) + }) + + test('expressions in arrays', () => { + expect('[(1 + 2) (3 * 4)]').toEvaluateTo([3, 12]) + }) + + test('mixed separators - spaces and newlines', () => { + expect(`[1 2 +3 4]`).toEvaluateTo([1, 2, 3, 4]) + }) + + test('mixed separators - spaces and semicolons', () => { + expect('[1 2; 3 4]').toEvaluateTo([1, 2, 3, 4]) + }) + + test('empty lines within arrays', () => { + expect(`[1 + +2]`).toEvaluateTo([1, 2]) + }) + + test('comments within arrays', () => { + expect(`[1 # first + 2 # second + ]`).toEvaluateTo([1, 2]) + }) + + test('complex nested multiline', () => { + expect(`[ + [1 2] + [3 4] + [5 6] +]`).toEvaluateTo([[1, 2], [3, 4], [5, 6]]) + }) + + test('boolean and null literals', () => { + expect('[true false null]').toEvaluateTo([true, false, null]) + }) + + test('regex literals', () => { + expect('[//[0-9]+//]').toEvaluateTo([/[0-9]+/]) + }) + + test('trailing newlines', () => { + expect(`[ +1 +2 +]`).toEvaluateTo([1, 2]) + }) }) diff --git a/src/parser/tests/literals.test.ts b/src/parser/tests/literals.test.ts index c48e0b3..84d09a8 100644 --- a/src/parser/tests/literals.test.ts +++ b/src/parser/tests/literals.test.ts @@ -69,4 +69,135 @@ describe('array literals', () => { Identifier three `) }) + + test('empty arrays', () => { + expect('[]').toMatchTree(` + Array [] + `) + }) + + test('mixed types', () => { + expect("[1 'two' three true null]").toMatchTree(` + Array + Number 1 + String + StringFragment two + Identifier three + Boolean true + Null null + `) + }) + + test('semicolons as separators', () => { + expect('[1; 2; 3]').toMatchTree(` + Array + Number 1 + Number 2 + Number 3 + `) + }) + + test('expressions in arrays', () => { + expect('[(1 + 2) (3 * 4)]').toMatchTree(` + Array + ParenExpr + BinOp + Number 1 + Plus + + Number 2 + ParenExpr + BinOp + Number 3 + Star * + Number 4 + `) + }) + + test('mixed separators - spaces and newlines', () => { + expect(`[1 2 +3 4]`).toMatchTree(` + Array + Number 1 + Number 2 + Number 3 + Number 4 + `) + }) + + test('mixed separators - spaces and semicolons', () => { + expect('[1 2; 3 4]').toMatchTree(` + Array + Number 1 + Number 2 + Number 3 + Number 4 + `) + }) + + test('empty lines within arrays', () => { + expect(`[1 + +2]`).toMatchTree(` + Array + Number 1 + Number 2 + `) + }) + + test('comments within arrays', () => { + expect(`[ # something... + 1 # first + 2 # second + ]`).toMatchTree(` + Array + Number 1 + Number 2 + `) + }) + + test('complex nested multiline', () => { + expect(`[ + [1 2] + [3 4] + [5 6] +]`).toMatchTree(` + Array + Array + Number 1 + Number 2 + Array + Number 3 + Number 4 + Array + Number 5 + Number 6 + `) + }) + + test('boolean and null literals', () => { + expect('[true false null]').toMatchTree(` + Array + Boolean true + Boolean false + Null null + `) + }) + + test('regex literals', () => { + expect('[//[0-9]+//]').toMatchTree(` + Array + Regex //[0-9]+// + `) + }) + + test('trailing newlines', () => { + expect(`[ +1 +2 +]`).toMatchTree(` + Array + Number 1 + Number 2 + `) + }) }) From 982054eb54acf0a50bb4aaa5691490c41ce53b04 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:09:15 -0700 Subject: [PATCH 04/44] [a=1 b=2 c=3] and [=] (empty dict) --- src/compiler/compiler.ts | 20 ++ src/compiler/tests/literals.test.ts | 63 +++++- src/parser/shrimp.grammar | 9 +- src/parser/shrimp.terms.ts | 29 +-- src/parser/shrimp.ts | 20 +- src/parser/tests/literals.test.ts | 289 ++++++++++++++++++++++++++++ src/testSetup.ts | 29 ++- src/utils/tree.ts | 27 +-- 8 files changed, 433 insertions(+), 53 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index d05f2ce..7ce259f 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -470,11 +470,31 @@ export class Compiler { case terms.Array: { const children = getAllChildren(node) + // todo: [ = ] const instructions: ProgramItem[] = children.map((x) => this.#compileNode(x, input)).flat() instructions.push(['MAKE_ARRAY', children.length]) return instructions } + case terms.Dict: { + const children = getAllChildren(node) + const instructions: ProgramItem[] = [] + + children.forEach((node) => { + const keyNode = node.firstChild + const valueNode = node.firstChild.nextSibling + + // name= -> name + const key = input.slice(keyNode.from, keyNode.to).slice(0, -1) + instructions.push(['PUSH', key]) + + instructions.push(...this.#compileNode(valueNode, input)) + }) + + instructions.push(['MAKE_DICT', children.length]) + return instructions + } + default: throw new CompilerError( `Compiler doesn't know how to handle a "${node.type.name}" node.`, diff --git a/src/compiler/tests/literals.test.ts b/src/compiler/tests/literals.test.ts index 66d0c01..5c93e2a 100644 --- a/src/compiler/tests/literals.test.ts +++ b/src/compiler/tests/literals.test.ts @@ -76,7 +76,11 @@ describe('array literals', () => { [1 2] [3 4] [5 6] -]`).toEvaluateTo([[1, 2], [3, 4], [5, 6]]) +]`).toEvaluateTo([ + [1, 2], + [3, 4], + [5, 6], + ]) }) test('boolean and null literals', () => { @@ -94,3 +98,60 @@ describe('array literals', () => { ]`).toEvaluateTo([1, 2]) }) }) + +describe('dict literals', () => { + test('work with numbers', () => { + expect('[a=1 b=2 c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 }) + }) + + test('work with strings', () => { + expect("[a='one' b='two' c='three']").toEvaluateTo({ a: 'one', b: 'two', c: 'three' }) + }) + + test('work with identifiers', () => { + expect('[a=one b=two c=three]').toEvaluateTo({ a: 'one', b: 'two', c: 'three' }) + }) + + test('can be nested', () => { + expect('[a=one b=[two [c=three]]]').toEvaluateTo({ a: 'one', b: ['two', { c: 'three' }] }) + }) + + test('can span multiple lines', () => { + expect(`[ + a=1 + b=2 + c=3 + ]`).toEvaluateTo({ a: 1, b: 2, c: 3 }) + }) + + test('empty dict', () => { + expect('[=]').toEvaluateTo({}) + expect('[ = ]').toEvaluateTo({}) + + test('mixed types', () => { + expect("[a=1 b='two' c=three d=true e=null]").toEvaluateTo({ + a: 1, + b: 'two', + c: 'three', + d: true, + e: null, + }) + + test('semicolons as separators', () => { + expect('[a=1; b=2; c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 }) + }) + + test('expressions in dicts', () => { + expect('[a=(1 + 2) b=(3 * 4)]').toEvaluateTo({ a: 3, b: 12 }) + }) + + test('empty lines within dicts', () => { + expect(`[a=1 + + b=2 + + c=3]`).toEvaluateTo({ a: 1, b: 2, c: 3 }) + }) + }) + }) +}) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index d7edba5..ee89d1c 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -191,8 +191,13 @@ EscapeSeq { "\\" ("$" | "n" | "t" | "r" | "\\" | "'") } +Dict { + "[=]" | + "[" newlineOrSemicolon* NamedArg (newlineOrSemicolon | NamedArg)* "]" +} + Array { - "[" (newlineOrSemicolon | expression)* "]" + "[" newlineOrSemicolon* (expression (newlineOrSemicolon | expression)*)? "]" } // We need expressionWithoutIdentifier to avoid conflicts in consumeToTerminator. @@ -204,7 +209,7 @@ Array { // to go through ambiguousFunctionCall (which is what we want semantically). // Yes, it is annoying and I gave up trying to use GLR to fix it. expressionWithoutIdentifier { - ParenExpr | Word | String | Number | Boolean | Regex | Array | @specialize[@name=Null] + ParenExpr | Word | String | Number | Boolean | Regex | Dict | Array | @specialize[@name=Null] } block { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 9712d60..69d6d47 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -31,20 +31,21 @@ export const EscapeSeq = 29, Boolean = 30, Regex = 31, - Array = 32, - Null = 33, - ConditionalOp = 34, + Dict = 32, + NamedArg = 33, + NamedArgPrefix = 34, FunctionDef = 35, Params = 36, colon = 37, - keyword = 51, - PositionalArg = 39, - Underscore = 40, - NamedArg = 41, - NamedArgPrefix = 42, - IfExpr = 44, - SingleLineThenBlock = 46, - ThenBlock = 47, - ElseIfExpr = 48, - ElseExpr = 50, - Assign = 52 + keyword = 52, + Underscore = 39, + Array = 40, + Null = 41, + ConditionalOp = 42, + PositionalArg = 43, + IfExpr = 45, + SingleLineThenBlock = 47, + ThenBlock = 48, + ElseIfExpr = 49, + ElseExpr = 51, + Assign = 53 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 9e934e5..4865cb6 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./scopeTracker" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,null:66, end:76, if:90, elseif:98, else:102} +const spec_Identifier = {__proto__:null,end:76, null:82, if:92, elseif:100, else:104} export const parser = LRParser.deserialize({ version: 14, - states: "/xQYQbOOO!WOpO'#CqO#gQcO'#CtO$aOSO'#CvO%dQbO'#C|O&fQcO'#DuOOQa'#Du'#DuO'lQcO'#DtO(TQRO'#CuO(cQcO'#DpO(zQbO'#EQOOQ`'#DP'#DPO)SQbO'#CsOOQ`'#Dq'#DqO)wQbO'#DpO*VQbO'#EVOOQ`'#DY'#DYO*wQRO'#DbOOQ`'#Dp'#DpO*|QQO'#DoOOQ`'#Do'#DoOOQ`'#Dc'#DcQYQbOOO+UObO,59]OOQa'#Dt'#DtOOQ`'#DT'#DTO+^QbO'#DVOOQ`'#EU'#EUOOQ`'#Dh'#DhO+hQbO,59[O+{QbO'#CxO,TQWO'#CyOOOO'#Dw'#DwOOOO'#Dd'#DdO,iOSO,59bOOQa,59b,59bOOQ`'#De'#DeO,wQbO,59hOOQa,59h,59hO*VQbO,59aO*VQbO,59aOOQ`'#Df'#DfO-OQbO'#DQO-WQQO,5:lO-]QRO,59_O.rQRO'#CuO/SQRO,59_O/`QQO,59_O/eQQO,59_O/mQbO'#DiO/xQbO,59ZO0ZQRO,5:qO0bQQO,5:qO0gQbO,59|OOQ`,5:Z,5:ZOOQ`-E7a-E7aOOQa1G.w1G.wOOQ`,59q,59qOOQ`-E7f-E7fOOOO,59d,59dOOOO,59e,59eOOOO-E7b-E7bOOQa1G.|1G.|OOQ`-E7c-E7cOOQa1G/S1G/SOOQa1G.{1G.{O0qQcO1G.{OOQ`-E7d-E7dO1]QbO1G0WOOQa1G.y1G.yO*VQbO,59jO*VQbO,59jO!]QbO'#CtO%kQbO'#CpOOQ`,5:T,5:TOOQ`-E7g-E7gO1jQbO1G0]OOQ`1G/h1G/hO1wQbO7+%rO1|QbO7+%sOOQO1G/U1G/UO2^QRO1G/UOOQ`'#D['#D[O2hQbO7+%wO2mQbO7+%xOOQ`<yAN>yO*VQbO'#D^OOQ`'#Dj'#DjO4QQbOAN?OO4]QQO'#D`OOQ`AN?OAN?OO4bQbOAN?OO4gQRO,59xO4nQQO,59xOOQ`-E7h-E7hOOQ`G24jG24jO4sQbOG24jO4xQQO,59zO4}QQO1G/dOOQ`LD*ULD*UO1|QbO1G/fO2mQbO7+%OOOQ`7+%Q7+%QOOQ`<|AN>|O*iQbO'#D_OOQ`'#Dm'#DmO5xQbOAN?SO6TQQO'#DaOOQ`AN?SAN?SO6YQbOAN?SO6_QRO,59yO6fQQO,59yOOQ`-E7k-E7kOOQ`G24nG24nO6kQbOG24nO6pQQO,59{O6uQQO1G/eOOQ`LD*YLD*YO3_QbO1G/gO4eQbO7+%POOQ`7+%R7+%ROOQ`<Y#g#o6R#o;'S#{;'S;=`$d<%lO#{U>_[kSOt#{uw#{x!_#{!_!`6v!`#O#{#P#T#{#T#i6R#i#j9}#j#o6R#o;'S#{;'S;=`$d<%lO#{U?[U{QkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~?sO!{~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!f~~", 11)], + repeatNodeCount: 10, + tokenData: "AO~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P6T#P#Q6Y#Q#R#{#R#S6s#S#T#{#T#Y7^#Y#Z8l#Z#b7^#b#ch#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUkS!dYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UkS!vQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZkS!eYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!eYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!o~~'aO!m~U'hUkS!jQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUkS!{QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWkSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYkSfQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWkSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWkSfQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WkSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^kSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^kSoQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXoQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUoQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWkSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebkSoQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[kSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UkSuQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!uQkSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVkSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!tQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!p~U6aU!zQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUkSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUrQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!rWkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU|QkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#P~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!i~~", 11)], topRules: {"Program":[0,18]}, specialized: [{term: 13, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 13, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 924 + tokenPrec: 1008 }) diff --git a/src/parser/tests/literals.test.ts b/src/parser/tests/literals.test.ts index 84d09a8..693da17 100644 --- a/src/parser/tests/literals.test.ts +++ b/src/parser/tests/literals.test.ts @@ -201,3 +201,292 @@ describe('array literals', () => { `) }) }) + +describe('dict literals', () => { + test('work with numbers', () => { + expect('[a=1 b=2 c=3]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + + test('work with strings', () => { + expect("[a='one' b='two' c='three']").toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + String + StringFragment one + NamedArg + NamedArgPrefix b= + String + StringFragment two + NamedArg + NamedArgPrefix c= + String + StringFragment three + `) + }) + + test('work with identifiers', () => { + expect('[a=one b=two c=three]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Identifier one + NamedArg + NamedArgPrefix b= + Identifier two + NamedArg + NamedArgPrefix c= + Identifier three + `) + }) + + test('can be nested', () => { + expect('[a=one b=[two [c=three]]]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Identifier one + NamedArg + NamedArgPrefix b= + Array + Identifier two + Dict + NamedArg + NamedArgPrefix c= + Identifier three + `) + }) + + test('can span multiple lines', () => { + expect(`[ + a=1 + b=2 + c=3 + ]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + test('empty dict', () => { + expect('[=]').toMatchTree(` + Dict [=] + `) + + expect('[ = ]').toMatchTree(` + Array + Word = + `) + }) + + test('mixed types', () => { + expect("[a=1 b='two' c=three d=true e=null]").toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + String + StringFragment two + NamedArg + NamedArgPrefix c= + Identifier three + NamedArg + NamedArgPrefix d= + Boolean true + NamedArg + NamedArgPrefix e= + Null null + `) + }) + + test('semicolons as separators', () => { + expect('[a=1; b=2; c=3]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + + test('expressions in dicts', () => { + expect('[a=(1 + 2) b=(3 * 4)]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + ParenExpr + BinOp + Number 1 + Plus + + Number 2 + NamedArg + NamedArgPrefix b= + ParenExpr + BinOp + Number 3 + Star * + Number 4 + `) + }) + + test('mixed separators - spaces and newlines', () => { + expect(`[a=1 b=2 +c=3]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + + test('empty lines within dicts', () => { + expect(`[a=1 + +b=2 + +c=3]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + + test('comments within dicts', () => { + expect(`[ # something... + a=1 # first + b=2 # second + + c=3 + ]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) + + test('complex nested multiline', () => { + expect(`[ + a=[a=1 b=2] + b=[b=3 c=4] + c=[c=5 d=6] +]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix b= + Dict + NamedArg + NamedArgPrefix b= + Number 3 + NamedArg + NamedArgPrefix c= + Number 4 + NamedArg + NamedArgPrefix c= + Dict + NamedArg + NamedArgPrefix c= + Number 5 + NamedArg + NamedArgPrefix d= + Number 6 + `) + }) + + test('boolean and null literals', () => { + expect('[a=true b=false c=null]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Boolean true + NamedArg + NamedArgPrefix b= + Boolean false + NamedArg + NamedArgPrefix c= + Null null + `) + }) + + test('regex literals', () => { + expect('[pattern=//[0-9]+//]').toMatchTree(` + Dict + NamedArg + NamedArgPrefix pattern= + Regex //[0-9]+// + `) + }) + + test('trailing newlines', () => { + expect(`[ +a=1 +b=2 +c=3 + +]`).toMatchTree(` + Dict + NamedArg + NamedArgPrefix a= + Number 1 + NamedArg + NamedArgPrefix b= + Number 2 + NamedArg + NamedArgPrefix c= + Number 3 + `) + }) +}) diff --git a/src/testSetup.ts b/src/testSetup.ts index 89203e1..10b715e 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -109,7 +109,10 @@ expect.extend({ return { pass: true } } else { return { - message: () => `Expected evaluation to be ${expected}, but got ${value}`, + message: () => + `Expected evaluation to be ${JSON.stringify(expected)}, but got ${JSON.stringify( + value + )}`, pass: false, } } @@ -163,5 +166,27 @@ const trimWhitespace = (str: string): string => { } function isEqual(a: any, b: any): boolean { - return typeof a === 'object' ? JSON.stringify(a) === JSON.stringify(b) : a === b + if (a === null && b === null) return true + + switch (typeof a) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + return a === b + default: + return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b)) + } +} + +function sortKeys(o: any): any { + if (Array.isArray(o)) return o.map(sortKeys) + if (o && typeof o === 'object' && o.constructor === Object) + return Object.keys(o) + .sort() + .reduce((r, k) => { + r[k] = sortKeys(o[k]) + return r + }, {} as any) + return o } diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 1682d21..7a2b36a 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -1,6 +1,6 @@ import { Tree, TreeCursor } from '@lezer/common' import { assertNever } from '#utils/utils' -import { type Value } from 'reefvm' +import { type Value, fromValue } from 'reefvm' export const treeToString = (tree: Tree, input: string): string => { const lines: string[] = [] @@ -35,27 +35,6 @@ export const treeToString = (tree: Tree, input: string): string => { } export const VMResultToValue = (result: Value): unknown => { - if ( - result.type === 'number' || - result.type === 'boolean' || - result.type === 'string' || - result.type === 'regex' - ) { - return result.value - } else if (result.type === 'null') { - return null - } else if (result.type === 'array') { - return result.value.map(VMResultToValue) - } else if (result.type === 'dict') { - const obj: Record = {} - for (const [key, val] of Object.entries(result.value)) { - obj[key] = VMResultToValue(val) - } - - return obj - } else if (result.type === 'function') { - return Function - } else { - assertNever(result) - } + if (result.type === 'function') return Function + else return fromValue(result) } From 8112515278f4d3db99180c08c3287053833ad6f8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:18:24 -0700 Subject: [PATCH 05/44] [ = ] --- src/compiler/compiler.ts | 13 ++++++++++- src/compiler/tests/literals.test.ts | 36 ++++++++++++++--------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 7ce259f..4f8b15b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -470,7 +470,18 @@ export class Compiler { case terms.Array: { const children = getAllChildren(node) - // todo: [ = ] + + // We can easily parse [=] as an empty dict, but `[ = ]` is tougher. + // = can be a valid word, and also valid in words, so for now we cheat + // and check for arrays that look like `[ = ]` to interpret them as + // empty dicts + if (children.length === 1 && children[0].name === 'Word') { + const child = children[0] + if (input.slice(child.from, child.to) === '=') { + return [['MAKE_DICT', 0]] + } + } + const instructions: ProgramItem[] = children.map((x) => this.#compileNode(x, input)).flat() instructions.push(['MAKE_ARRAY', children.length]) return instructions diff --git a/src/compiler/tests/literals.test.ts b/src/compiler/tests/literals.test.ts index 5c93e2a..666a4b5 100644 --- a/src/compiler/tests/literals.test.ts +++ b/src/compiler/tests/literals.test.ts @@ -127,31 +127,31 @@ describe('dict literals', () => { test('empty dict', () => { expect('[=]').toEvaluateTo({}) expect('[ = ]').toEvaluateTo({}) + }) - test('mixed types', () => { - expect("[a=1 b='two' c=three d=true e=null]").toEvaluateTo({ - a: 1, - b: 'two', - c: 'three', - d: true, - e: null, - }) + test('mixed types', () => { + expect("[a=1 b='two' c=three d=true e=null]").toEvaluateTo({ + a: 1, + b: 'two', + c: 'three', + d: true, + e: null, + }) + }) - test('semicolons as separators', () => { - expect('[a=1; b=2; c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 }) - }) + test('semicolons as separators', () => { + expect('[a=1; b=2; c=3]').toEvaluateTo({ a: 1, b: 2, c: 3 }) + }) - test('expressions in dicts', () => { - expect('[a=(1 + 2) b=(3 * 4)]').toEvaluateTo({ a: 3, b: 12 }) - }) + test('expressions in dicts', () => { + expect('[a=(1 + 2) b=(3 * 4)]').toEvaluateTo({ a: 3, b: 12 }) + }) - test('empty lines within dicts', () => { - expect(`[a=1 + test('empty lines within dicts', () => { + expect(`[a=1 b=2 c=3]`).toEvaluateTo({ a: 1, b: 2, c: 3 }) - }) - }) }) }) From 1aa15701352594c979f16d6ebdf11f64848c581e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:36:02 -0700 Subject: [PATCH 06/44] add barus minimus docs --- CLAUDE.md | 13 +++++++++++++ example.shrimp | 24 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 356bd09..e0e372d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,19 @@ Implementation files: **Why this matters**: This enables shell-like file paths (`readme.txt`) while supporting dictionary/array access (`config.path`) without quotes, determined entirely at parse time based on lexical scope. +**Array and dict literals**: Square brackets `[]` create both arrays and dicts, distinguished by content: +- **Arrays**: Space/newline/semicolon-separated args that work like calling a function → `[1 2 3]` (call functions using parens eg `[1 (double 4) 200]`) +- **Dicts**: NamedArg syntax (key=value pairs) → `[a=1 b=2]` +- **Empty array**: `[]` (standard empty brackets) +- **Empty dict**: `[=]` (exactly this, no spaces) + +Implementation details: +- Grammar rules (shrimp.grammar:194-201): Dict uses `NamedArg` nodes, Array uses `expression` nodes +- Parser distinguishes at parse time based on whether first element contains `=` +- Both support multiline, comments, and nesting +- Separators: spaces, newlines (`\n`), or semicolons (`;`) work interchangeably +- Test files: `src/parser/tests/literals.test.ts` and `src/compiler/tests/literals.test.ts` + **EOF handling**: The grammar uses `(statement | newlineOrSemicolon)+ eof?` to handle empty lines and end-of-file without infinite loops. ## Compiler Architecture diff --git a/example.shrimp b/example.shrimp index 53c563b..f1a9a05 100644 --- a/example.shrimp +++ b/example.shrimp @@ -42,13 +42,13 @@ a-file = file.txt 3 # symbols can be assigned to functions. The body of the function comes after a colon `:` -add = fn x y: x + y +add = do x y: x + y add 1 2 --- 3 # Functions can have multiple lines, they are terminated with `end` -sub = fn x y: +sub = do x y: x - y end @@ -82,9 +82,25 @@ add 1 (sub 5 2) 4 +# Arrays use square brackets with space-separated elements +numbers = [1 2 3] +shopping-list = [apples bananas carrots] +empty-array = [] + +# Dicts use square brackets with key=value pairs +config = [name=Shrimp version=1.0 debug=true] +empty-dict = [=] + +# Nested structures work naturally +nested = [ + users=[ + [name=Alice age=30] + [name=Bob age=25] + ] + settings=[debug=true timeout=5000] +] + # HOLD UP -- how do we handle arrays? -- how do we handle hashes? - conditionals - loops \ No newline at end of file From 62036b1e4b6658b8cd7231328415dcc8f74d36e5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 25 Oct 2025 20:41:13 -0700 Subject: [PATCH 07/44] start on a prelude of builtin functions --- bin/repl | 93 ++--------------------------------------- bin/shrimp | 45 ++------------------ src/prelude.ts | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 131 deletions(-) create mode 100644 src/prelude.ts diff --git a/bin/repl b/bin/repl index b614e26..8855e2d 100755 --- a/bin/repl +++ b/bin/repl @@ -1,24 +1,12 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { VM, type Value, Scope, bytecodeToString } from 'reefvm' +import { colors, formatValue, nativeFunctions, valueFunctions } from '../src/prelude' +import { VM, Scope, bytecodeToString } from 'reefvm' import * as readline from 'readline' import { readFileSync, writeFileSync } from 'fs' import { basename } from 'path' -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - cyan: '\x1b[36m', - yellow: '\x1b[33m', - green: '\x1b[32m', - red: '\x1b[31m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - pink: '\x1b[38;2;255;105;180m' -} - async function repl() { const commands = ['/clear', '/reset', '/vars', '/funcs', '/history', '/bytecode', '/exit', '/save', '/quit'] @@ -60,7 +48,7 @@ async function repl() { return } - vm ||= new VM({ instructions: [], constants: [] }, nativeFunctions) + vm ||= new VM({ instructions: [], constants: [] }, nativeFunctions, valueFunctions) if (['/exit', 'exit', '/quit', 'quit'].includes(trimmed)) { console.log(`\n${colors.yellow}Goodbye!${colors.reset}`) @@ -186,40 +174,6 @@ async function repl() { }) } - -function formatValue(value: Value, inner = false): string { - switch (value.type) { - case 'string': - return `${colors.green}'${value.value}'${colors.reset}` - case 'number': - return `${colors.cyan}${value.value}${colors.reset}` - case 'boolean': - return `${colors.yellow}${value.value}${colors.reset}` - case 'null': - return `${colors.dim}null${colors.reset}` - case 'array': { - const items = value.value.map(x => formatValue(x, true)).join(' ') - return `${inner ? '(' : ''}${colors.blue}list${colors.reset} ${items}${inner ? ')' : ''}` - } - case 'dict': { - const entries = Array.from(value.value.entries()) - .map(([k, v]) => `${k}=${formatValue(v, true)}`) - .join(' ') - return `${inner ? '(' : ''}${colors.magenta}dict${colors.reset} ${entries}${inner ? ')' : ''}` - } - case 'function': { - const params = value.params.join(', ') - return `${colors.dim}${colors.reset}` - } - case 'native': - return `${colors.dim}${colors.reset}` - case 'regex': - return `${colors.magenta}${value.value}${colors.reset}` - default: - return String(value) - } -} - function formatVariables(scope: Scope, onlyFunctions = false): string { const vars: string[] = [] @@ -257,7 +211,7 @@ async function loadFile(filePath: string): Promise<{ vm: VM; codeHistory: string console.log(`${colors.dim}Loading ${basename(filePath)}...${colors.reset}`) - const vm = new VM({ instructions: [], constants: [] }, nativeFunctions) + const vm = new VM({ instructions: [], constants: [] }, nativeFunctions, valueFunctions) await vm.run() const codeHistory: string[] = [] @@ -313,43 +267,4 @@ function showWelcome() { console.log() } -const nativeFunctions = { - echo: (...args: any[]) => { - console.log(...args) - }, - len: (value: any) => { - if (typeof value === 'string') return value.length - if (Array.isArray(value)) return value.length - if (value && typeof value === 'object') return Object.keys(value).length - return 0 - }, - type: (value: any) => { - if (value === null) return 'null' - if (Array.isArray(value)) return 'array' - return typeof value - }, - range: (start: number, end: number | null) => { - if (end === null) { - end = start - start = 0 - } - const result: number[] = [] - for (let i = start; i <= end; i++) { - result.push(i) - } - return result - }, - join: (arr: any[], sep: string = ',') => { - return arr.join(sep) - }, - split: (str: string, sep: string = ',') => { - return str.split(sep) - }, - upper: (str: string) => str.toUpperCase(), - lower: (str: string) => str.toLowerCase(), - trim: (str: string) => str.trim(), - list: (...args: any[]) => args, - dict: (atNamed = {}) => atNamed -} - await repl() diff --git a/bin/shrimp b/bin/shrimp index eed1d48..0622700 100755 --- a/bin/shrimp +++ b/bin/shrimp @@ -1,57 +1,18 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { VM, toValue, fromValue, bytecodeToString } from 'reefvm' +import { colors, nativeFunctions, valueFunctions } from '../src/prelude' +import { VM, fromValue, bytecodeToString } from 'reefvm' import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { randomUUID } from "crypto" import { spawn } from 'child_process' import { join } from 'path' -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - red: '\x1b[31m', - yellow: '\x1b[33m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', - pink: '\x1b[38;2;255;105;180m' -} - -const nativeFunctions = { - echo: (...args: any[]) => console.log(...args), - len: (value: any) => { - if (typeof value === 'string') return value.length - if (Array.isArray(value)) return value.length - if (value && typeof value === 'object') return Object.keys(value).length - return 0 - }, - type: (value: any) => toValue(value).type, - range: (start: number, end: number | null) => { - if (end === null) { - end = start - start = 0 - } - const result: number[] = [] - for (let i = start; i <= end; i++) { - result.push(i) - } - return result - }, - join: (arr: any[], sep: string = ',') => arr.join(sep), - split: (str: string, sep: string = ',') => str.split(sep), - upper: (str: string) => str.toUpperCase(), - lower: (str: string) => str.toLowerCase(), - trim: (str: string) => str.trim(), - list: (...args: any[]) => args, - dict: (atNamed = {}) => atNamed -} - async function runFile(filePath: string) { try { const code = readFileSync(filePath, 'utf-8') const compiler = new Compiler(code) - const vm = new VM(compiler.bytecode, nativeFunctions) + const vm = new VM(compiler.bytecode, nativeFunctions, valueFunctions) await vm.run() return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]) : null } catch (error: any) { diff --git a/src/prelude.ts b/src/prelude.ts new file mode 100644 index 0000000..77dc987 --- /dev/null +++ b/src/prelude.ts @@ -0,0 +1,110 @@ +// The prelude creates all the builtin Shrimp functions. + +import { toValue, type Value, extractParamInfo, isWrapped, getOriginalFunction } from 'reefvm' + +export const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + yellow: '\x1b[33m', + green: '\x1b[32m', + red: '\x1b[31m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + pink: '\x1b[38;2;255;105;180m' +} + +export const valueFunctions = { + echo: (...args: Value[]) => { + console.log(...args.map(a => formatValue(a))) + return toValue(null) + }, + + length: (value: Value) => { + switch (value.type) { + case 'string': case 'array': + return toValue(value.value.length) + case 'dict': + return toValue(Object.keys(value.value).length) + default: + return toValue(0) + } + }, + + type: (value: Value) => toValue(value.type), + inspect: (value: Value) => toValue(formatValue(value)) +} + +export const nativeFunctions = { + range: (start: number, end: number | null) => { + if (end === null) { + end = start + start = 0 + } + const result: number[] = [] + for (let i = start; i <= end; i++) { + result.push(i) + } + return result + }, + + // strings + join: (arr: any[], sep: string = ',') => arr.join(sep), + split: (str: string, sep: string = ',') => str.split(sep), + 'to-upper': (str: string) => str.toUpperCase(), + 'to-lower': (str: string) => str.toLowerCase(), + trim: (str: string) => str.trim(), + + // collections + at: (collection: any, index: number | string) => collection[index], + list: (...args: any[]) => args, + dict: (atNamed = {}) => atNamed, + slice: (list: any[], start: number, end?: number) => list.slice(start, end), + + // enumerables + map: async (list: any[], cb: Function) => { + let acc: any[] = [] + for (const value of list) acc.push(await cb(value)) + return acc + }, + each: async (list: any[], cb: Function) => { + for (const value of list) await cb(value) + }, +} + +export function formatValue(value: Value, inner = false): string { + switch (value.type) { + case 'string': + return `${colors.green}'${value.value.replaceAll("'", "\\'")}${colors.green}'${colors.reset}` + case 'number': + return `${colors.cyan}${value.value}${colors.reset}` + case 'boolean': + return `${colors.yellow}${value.value}${colors.reset}` + case 'null': + return `${colors.dim}null${colors.reset}` + case 'array': { + const items = value.value.map(x => formatValue(x, true)).join(' ') + return `${inner ? '(' : ''}${colors.blue}list${colors.reset} ${items}${inner ? ')' : ''}` + } + case 'dict': { + const entries = Array.from(value.value.entries()) + .map(([k, v]) => `${k}=${formatValue(v, true)}`) + .join(' ') + return `${inner ? '(' : ''}${colors.magenta}dict${colors.reset} ${entries}${inner ? ')' : ''}` + } + case 'function': { + const params = value.params.length ? '(' + value.params.join(' ') + ')' : '' + return `${colors.dim}${colors.reset}` + } + case 'native': + const fn = isWrapped(value.fn) ? getOriginalFunction(value.fn) : value.fn + const info = extractParamInfo(fn) + const params = info.params.length ? '(' + info.params.join(' ') + ')' : '' + return `${colors.dim}${colors.reset}` + case 'regex': + return `${colors.magenta}${value.value}${colors.reset}` + default: + return String(value) + } +} \ No newline at end of file From 35e6b63499a2fe81b875b873d57b891ac9eed6bf Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 12:12:00 -0700 Subject: [PATCH 08/44] better echo --- src/{prelude.ts => prelude/index.ts} | 65 +++++++++++++++------------- 1 file changed, 36 insertions(+), 29 deletions(-) rename src/{prelude.ts => prelude/index.ts} (77%) diff --git a/src/prelude.ts b/src/prelude/index.ts similarity index 77% rename from src/prelude.ts rename to src/prelude/index.ts index 77dc987..fb80502 100644 --- a/src/prelude.ts +++ b/src/prelude/index.ts @@ -1,6 +1,7 @@ // The prelude creates all the builtin Shrimp functions. -import { toValue, type Value, extractParamInfo, isWrapped, getOriginalFunction } from 'reefvm' +import { readFileSync } from 'fs' +import { VM, isValue, toValue, type Value, extractParamInfo, isWrapped, getOriginalFunction } from 'reefvm' export const colors = { reset: '\x1b[0m', @@ -15,40 +16,28 @@ export const colors = { pink: '\x1b[38;2;255;105;180m' } -export const valueFunctions = { - echo: (...args: Value[]) => { - console.log(...args.map(a => formatValue(a))) +export const nativeFunctions = { + // hello + echo: (...args: any[]) => { + console.log(...args.map(a => { + const v = toValue(a) + return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value + })) return toValue(null) }, - length: (value: Value) => { + // info + type: (v: any) => toValue(v).type, + inspect: (v: any) => formatValue(toValue(v)), + length: (v: any) => { + const value = toValue(v) switch (value.type) { - case 'string': case 'array': - return toValue(value.value.length) - case 'dict': - return toValue(Object.keys(value.value).length) - default: - return toValue(0) + case 'string': case 'array': return value.value.length + case 'dict': return value.value.size + default: return 0 } }, - type: (value: Value) => toValue(value.type), - inspect: (value: Value) => toValue(formatValue(value)) -} - -export const nativeFunctions = { - range: (start: number, end: number | null) => { - if (end === null) { - end = start - start = 0 - } - const result: number[] = [] - for (let i = start; i <= end; i++) { - result.push(i) - } - return result - }, - // strings join: (arr: any[], sep: string = ',') => arr.join(sep), split: (str: string, sep: string = ',') => str.split(sep), @@ -61,6 +50,17 @@ export const nativeFunctions = { list: (...args: any[]) => args, dict: (atNamed = {}) => atNamed, slice: (list: any[], start: number, end?: number) => list.slice(start, end), + range: (start: number, end: number | null) => { + if (end === null) { + end = start + start = 0 + } + const result: number[] = [] + for (let i = start; i <= end; i++) { + result.push(i) + } + return result + }, // enumerables map: async (list: any[], cb: Function) => { @@ -71,9 +71,16 @@ export const nativeFunctions = { each: async (list: any[], cb: Function) => { for (const value of list) await cb(value) }, + + // modules + use: function (this: VM, path: string) { + const file = readFileSync(path + '.sh') + } } -export function formatValue(value: Value, inner = false): string { +export function formatValue(value: Value | any, inner = false): string { + if (!isValue(value)) value = toValue(value) + switch (value.type) { case 'string': return `${colors.green}'${value.value.replaceAll("'", "\\'")}${colors.green}'${colors.reset}` From ee4de6c59edf0c524cc8f2c066cde562574f5ee3 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 12:27:38 -0700 Subject: [PATCH 09/44] update-reef command --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4adf26d..7d05328 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "dev": "bun generate-parser && bun --hot src/server/server.tsx", "generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts", - "repl": "bun generate-parser && bun bin/repl" + "repl": "bun generate-parser && bun bin/repl", + "update-reef": "cd packages/ReefVM && git pull origin main" }, "dependencies": { "@codemirror/view": "^6.38.3", From 9345c743ff728e224d5cfcc6b6affdcea511b168 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 12:29:06 -0700 Subject: [PATCH 10/44] no valueFunctions --- bin/repl | 6 +++--- bin/shrimp | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/repl b/bin/repl index 8855e2d..d2f2e5e 100755 --- a/bin/repl +++ b/bin/repl @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, formatValue, nativeFunctions, valueFunctions } from '../src/prelude' +import { colors, formatValue, nativeFunctions } from '../src/prelude' import { VM, Scope, bytecodeToString } from 'reefvm' import * as readline from 'readline' import { readFileSync, writeFileSync } from 'fs' @@ -48,7 +48,7 @@ async function repl() { return } - vm ||= new VM({ instructions: [], constants: [] }, nativeFunctions, valueFunctions) + vm ||= new VM({ instructions: [], constants: [] }, nativeFunctions) if (['/exit', 'exit', '/quit', 'quit'].includes(trimmed)) { console.log(`\n${colors.yellow}Goodbye!${colors.reset}`) @@ -211,7 +211,7 @@ async function loadFile(filePath: string): Promise<{ vm: VM; codeHistory: string console.log(`${colors.dim}Loading ${basename(filePath)}...${colors.reset}`) - const vm = new VM({ instructions: [], constants: [] }, nativeFunctions, valueFunctions) + const vm = new VM({ instructions: [], constants: [] }, nativeFunctions) await vm.run() const codeHistory: string[] = [] diff --git a/bin/shrimp b/bin/shrimp index 0622700..0f33de4 100755 --- a/bin/shrimp +++ b/bin/shrimp @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, nativeFunctions, valueFunctions } from '../src/prelude' +import { colors, nativeFunctions } from '../src/prelude' import { VM, fromValue, bytecodeToString } from 'reefvm' import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { randomUUID } from "crypto" @@ -12,7 +12,7 @@ async function runFile(filePath: string) { try { const code = readFileSync(filePath, 'utf-8') const compiler = new Compiler(code) - const vm = new VM(compiler.bytecode, nativeFunctions, valueFunctions) + const vm = new VM(compiler.bytecode, nativeFunctions) await vm.run() return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]) : null } catch (error: any) { From d3e83e17b295a7e7441881bffd2d01ffdf5b0681 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 12:29:14 -0700 Subject: [PATCH 11/44] narrow type --- src/testSetup.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/testSetup.ts b/src/testSetup.ts index 8e1f4b8..aaa00cb 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -3,7 +3,7 @@ import { parser } from '#parser/shrimp' import { $ } from 'bun' import { assert, errorMessage } from '#utils/utils' import { Compiler } from '#compiler/compiler' -import { run, VM } from 'reefvm' +import { run, VM, type TypeScriptFunction } from 'reefvm' import { treeToString, VMResultToValue } from '#utils/tree' const regenerateParser = async () => { @@ -93,11 +93,7 @@ expect.extend({ } }, - async toEvaluateTo( - received: unknown, - expected: unknown, - globals: Record = {} - ) { + async toEvaluateTo(received: unknown, expected: unknown, globals: Record = {}) { assert(typeof received === 'string', 'toEvaluateTo can only be used with string values') try { From 7387c56a20bd988f012306ac761b3335763e96a6 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 13:05:31 -0700 Subject: [PATCH 12/44] native -> global --- bin/repl | 6 +++--- bin/shrimp | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/repl b/bin/repl index d2f2e5e..5ae39b9 100755 --- a/bin/repl +++ b/bin/repl @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, formatValue, nativeFunctions } from '../src/prelude' +import { colors, formatValue, globalFunctions } from '../src/prelude' import { VM, Scope, bytecodeToString } from 'reefvm' import * as readline from 'readline' import { readFileSync, writeFileSync } from 'fs' @@ -48,7 +48,7 @@ async function repl() { return } - vm ||= new VM({ instructions: [], constants: [] }, nativeFunctions) + vm ||= new VM({ instructions: [], constants: [] }, globalFunctions) if (['/exit', 'exit', '/quit', 'quit'].includes(trimmed)) { console.log(`\n${colors.yellow}Goodbye!${colors.reset}`) @@ -211,7 +211,7 @@ async function loadFile(filePath: string): Promise<{ vm: VM; codeHistory: string console.log(`${colors.dim}Loading ${basename(filePath)}...${colors.reset}`) - const vm = new VM({ instructions: [], constants: [] }, nativeFunctions) + const vm = new VM({ instructions: [], constants: [] }, globalFunctions) await vm.run() const codeHistory: string[] = [] diff --git a/bin/shrimp b/bin/shrimp index 0f33de4..2260519 100755 --- a/bin/shrimp +++ b/bin/shrimp @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, nativeFunctions } from '../src/prelude' +import { colors, globalFunctions } from '../src/prelude' import { VM, fromValue, bytecodeToString } from 'reefvm' import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { randomUUID } from "crypto" @@ -12,7 +12,7 @@ async function runFile(filePath: string) { try { const code = readFileSync(filePath, 'utf-8') const compiler = new Compiler(code) - const vm = new VM(compiler.bytecode, nativeFunctions) + const vm = new VM(compiler.bytecode, globalFunctions) await vm.run() return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]) : null } catch (error: any) { From 2ff4615aab3381a24b8fda1afd0ad5c093ac6c0a Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 13:13:53 -0700 Subject: [PATCH 13/44] use module --- src/prelude/index.ts | 38 ++++++++++++++++++++++++++++------- src/prelude/tests/use.test.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/prelude/tests/use.test.ts diff --git a/src/prelude/index.ts b/src/prelude/index.ts index fb80502..1423ef6 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -1,7 +1,12 @@ // The prelude creates all the builtin Shrimp functions. +import { resolve, parse } from 'path' import { readFileSync } from 'fs' -import { VM, isValue, toValue, type Value, extractParamInfo, isWrapped, getOriginalFunction } from 'reefvm' +import { Compiler } from '#compiler/compiler' +import { + VM, Scope, toValue, type Value, + extractParamInfo, isWrapped, getOriginalFunction, +} from 'reefvm' export const colors = { reset: '\x1b[0m', @@ -16,7 +21,7 @@ export const colors = { pink: '\x1b[38;2;255;105;180m' } -export const nativeFunctions = { +export const globalFunctions = { // hello echo: (...args: any[]) => { console.log(...args.map(a => { @@ -73,14 +78,33 @@ export const nativeFunctions = { }, // modules - use: function (this: VM, path: string) { - const file = readFileSync(path + '.sh') + use: async function (this: VM, path: string) { + const scope = this.scope + const pc = this.pc + + const fullPath = resolve(path) + '.sh' + const code = readFileSync(fullPath, 'utf-8') + + this.pc = this.instructions.length + this.scope = new Scope(scope) + const compiled = new Compiler(code) + this.appendBytecode(compiled.bytecode) + + await this.continue() + + const module: Map = new Map + for (const [name, value] of this.scope.locals.entries()) + module.set(name, value) + + this.scope = scope + this.pc = pc + this.stopped = false + + this.scope.set(parse(fullPath).name, { type: 'dict', value: module }) } } -export function formatValue(value: Value | any, inner = false): string { - if (!isValue(value)) value = toValue(value) - +export function formatValue(value: Value, inner = false): string { switch (value.type) { case 'string': return `${colors.green}'${value.value.replaceAll("'", "\\'")}${colors.green}'${colors.reset}` diff --git a/src/prelude/tests/use.test.ts b/src/prelude/tests/use.test.ts new file mode 100644 index 0000000..971e1b5 --- /dev/null +++ b/src/prelude/tests/use.test.ts @@ -0,0 +1,28 @@ +import { expect, describe, test } from 'bun:test' +import { globalFunctions } from '#prelude' + +describe('use', () => { + test(`imports all a file's functions`, async () => { + expect(` + use ./src/prelude/tests/math + dbl = math | at double + dbl 4 + `).toEvaluateTo(8, globalFunctions) + + expect(` + use ./src/prelude/tests/math + math | at pi + `).toEvaluateTo(3.14, globalFunctions) + + expect(` + use ./src/prelude/tests/math + math | at 🥧 + `).toEvaluateTo(3.14159265359, globalFunctions) + + expect(` + use ./src/prelude/tests/math + call = do x y: x y end + call (math | at add1) 5 + `).toEvaluateTo(6, globalFunctions) + }) +}) \ No newline at end of file From 3a04970dcaf5b9c8c11c46608f268d400aa1542f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 26 Oct 2025 15:59:27 -0700 Subject: [PATCH 14/44] need you --- src/prelude/tests/math.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/prelude/tests/math.sh diff --git a/src/prelude/tests/math.sh b/src/prelude/tests/math.sh new file mode 100644 index 0000000..14dc504 --- /dev/null +++ b/src/prelude/tests/math.sh @@ -0,0 +1,4 @@ +🥧 = 3.14159265359 +pi = 3.14 +add1 = do x: x + 1 end +double = do x: x * 2 end \ No newline at end of file From b46154f753eb260dbd6680632a78f8f5aff711a1 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:38:56 -0700 Subject: [PATCH 15/44] no more, i think? --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index 7d05328..2ac8e0c 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,6 @@ "version": "0.1.0", "private": true, "type": "module", - "workspaces": [ - "packages/*" - ], "scripts": { "dev": "bun generate-parser && bun --hot src/server/server.tsx", "generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts", From b03610761b6ad695026ea634acc2b01beaa7defd Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:52:15 -0700 Subject: [PATCH 16/44] shh --- src/compiler/compiler.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 4f8b15b..c22e117 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -475,8 +475,8 @@ export class Compiler { // = can be a valid word, and also valid in words, so for now we cheat // and check for arrays that look like `[ = ]` to interpret them as // empty dicts - if (children.length === 1 && children[0].name === 'Word') { - const child = children[0] + if (children.length === 1 && children[0]!.name === 'Word') { + const child = children[0]! if (input.slice(child.from, child.to) === '=') { return [['MAKE_DICT', 0]] } @@ -493,13 +493,13 @@ export class Compiler { children.forEach((node) => { const keyNode = node.firstChild - const valueNode = node.firstChild.nextSibling + const valueNode = node.firstChild!.nextSibling // name= -> name - const key = input.slice(keyNode.from, keyNode.to).slice(0, -1) + const key = input.slice(keyNode!.from, keyNode!.to).slice(0, -1) instructions.push(['PUSH', key]) - instructions.push(...this.#compileNode(valueNode, input)) + instructions.push(...this.#compileNode(valueNode!, input)) }) instructions.push(['MAKE_DICT', children.length]) From e1ba9c630d4054bf95fc69b67e24617cd02f6ce0 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:52:41 -0700 Subject: [PATCH 17/44] important note --- src/compiler/compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index c22e117..bbedc09 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -472,7 +472,7 @@ export class Compiler { const children = getAllChildren(node) // We can easily parse [=] as an empty dict, but `[ = ]` is tougher. - // = can be a valid word, and also valid in words, so for now we cheat + // = can be a valid word, and is also valid inside words, so for now we cheat // and check for arrays that look like `[ = ]` to interpret them as // empty dicts if (children.length === 1 && children[0]!.name === 'Word') { From 6d19896d1afe25fea20f8de2520483734e302f78 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 21:53:22 -0700 Subject: [PATCH 18/44] not anymore, right --- packages/ReefVM | 1 - 1 file changed, 1 deletion(-) delete mode 160000 packages/ReefVM diff --git a/packages/ReefVM b/packages/ReefVM deleted file mode 160000 index 97b6722..0000000 --- a/packages/ReefVM +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 97b6722a113417398a1c47d583bfe07a906f87a0 From f25ec024c2cbb11f236a3d6e1566c262a4a69c62 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 22:18:46 -0700 Subject: [PATCH 19/44] further activate dotget --- src/compiler/compiler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index bbedc09..4438020 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -265,6 +265,9 @@ export class Compiler { } case terms.FunctionCallOrIdentifier: { + if (node.firstChild?.name === 'DotGet') + return this.#compileNode(node.firstChild, input) + return [['TRY_CALL', value]] } From bf1196bf963ee96da4ee92fa3aa512ba7cb314a9 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 22:20:03 -0700 Subject: [PATCH 20/44] use works more like fn, for now --- src/prelude/index.ts | 10 +++++----- src/prelude/tests/use.test.ts | 34 ++++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 1423ef6..a851706 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -78,7 +78,7 @@ export const globalFunctions = { }, // modules - use: async function (this: VM, path: string) { + use: async function (this: VM, path: string): Promise> { const scope = this.scope const pc = this.pc @@ -92,16 +92,16 @@ export const globalFunctions = { await this.continue() - const module: Map = new Map + const module: Record = {} for (const [name, value] of this.scope.locals.entries()) - module.set(name, value) + module[name] = value this.scope = scope this.pc = pc this.stopped = false - this.scope.set(parse(fullPath).name, { type: 'dict', value: module }) - } + return module + }, } export function formatValue(value: Value, inner = false): string { diff --git a/src/prelude/tests/use.test.ts b/src/prelude/tests/use.test.ts index 971e1b5..a8ec265 100644 --- a/src/prelude/tests/use.test.ts +++ b/src/prelude/tests/use.test.ts @@ -4,25 +4,39 @@ import { globalFunctions } from '#prelude' describe('use', () => { test(`imports all a file's functions`, async () => { expect(` - use ./src/prelude/tests/math - dbl = math | at double - dbl 4 + math = use ./src/prelude/tests/math + math.double 4 `).toEvaluateTo(8, globalFunctions) expect(` - use ./src/prelude/tests/math - math | at pi + math = use ./src/prelude/tests/math + math.double (math.double 4) + `).toEvaluateTo(16, globalFunctions) + + expect(` + math = use ./src/prelude/tests/math + dbl = math.double + dbl (dbl 2) + `).toEvaluateTo(8, globalFunctions) + + expect(` + math = use ./src/prelude/tests/math + math.pi `).toEvaluateTo(3.14, globalFunctions) expect(` - use ./src/prelude/tests/math + math = use ./src/prelude/tests/math math | at 🥧 `).toEvaluateTo(3.14159265359, globalFunctions) expect(` - use ./src/prelude/tests/math - call = do x y: x y end - call (math | at add1) 5 + math = use ./src/prelude/tests/math + math.🥧 + `).toEvaluateTo(3.14159265359, globalFunctions) + + expect(` + math = use ./src/prelude/tests/math + math.add1 5 `).toEvaluateTo(6, globalFunctions) }) -}) \ No newline at end of file +}) From b738e6cfd191f2cebacc2edf4d0bf01e7cc99f55 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 22:23:49 -0700 Subject: [PATCH 21/44] use -> load --- src/prelude/index.ts | 2 +- src/prelude/tests/{use.test.ts => load.ts} | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/prelude/tests/{use.test.ts => load.ts} (71%) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index a851706..df1cdaf 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -78,7 +78,7 @@ export const globalFunctions = { }, // modules - use: async function (this: VM, path: string): Promise> { + load: async function (this: VM, path: string): Promise> { const scope = this.scope const pc = this.pc diff --git a/src/prelude/tests/use.test.ts b/src/prelude/tests/load.ts similarity index 71% rename from src/prelude/tests/use.test.ts rename to src/prelude/tests/load.ts index a8ec265..374211f 100644 --- a/src/prelude/tests/use.test.ts +++ b/src/prelude/tests/load.ts @@ -4,38 +4,38 @@ import { globalFunctions } from '#prelude' describe('use', () => { test(`imports all a file's functions`, async () => { expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math.double 4 `).toEvaluateTo(8, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math.double (math.double 4) `).toEvaluateTo(16, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math dbl = math.double dbl (dbl 2) `).toEvaluateTo(8, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math.pi `).toEvaluateTo(3.14, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math | at 🥧 `).toEvaluateTo(3.14159265359, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math.🥧 `).toEvaluateTo(3.14159265359, globalFunctions) expect(` - math = use ./src/prelude/tests/math + math = load ./src/prelude/tests/math math.add1 5 `).toEvaluateTo(6, globalFunctions) }) From dd2edb6dda04efb2878aaa2d10947e8fda1f1c6c Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:39:12 -0700 Subject: [PATCH 22/44] prelude tests --- src/prelude/index.ts | 2 +- src/prelude/tests/prelude.test.ts | 256 ++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/prelude/tests/prelude.test.ts diff --git a/src/prelude/index.ts b/src/prelude/index.ts index df1cdaf..9e62a3b 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -44,7 +44,7 @@ export const globalFunctions = { }, // strings - join: (arr: any[], sep: string = ',') => arr.join(sep), + join: (arr: string[], sep: string = ',') => arr.join(sep), split: (str: string, sep: string = ',') => str.split(sep), 'to-upper': (str: string) => str.toUpperCase(), 'to-lower': (str: string) => str.toLowerCase(), diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts new file mode 100644 index 0000000..7997150 --- /dev/null +++ b/src/prelude/tests/prelude.test.ts @@ -0,0 +1,256 @@ +import { expect, describe, test, mock } from 'bun:test' +import { globalFunctions, formatValue } from '#prelude' +import { toValue } from 'reefvm' + +describe('string operations', () => { + test('to-upper converts to uppercase', () => { + expect(globalFunctions['to-upper']('hello')).toBe('HELLO') + expect(globalFunctions['to-upper']('Hello World!')).toBe('HELLO WORLD!') + }) + + test('to-lower converts to lowercase', () => { + expect(globalFunctions['to-lower']('HELLO')).toBe('hello') + expect(globalFunctions['to-lower']('Hello World!')).toBe('hello world!') + }) + + test('trim removes whitespace', () => { + expect(globalFunctions.trim(' hello ')).toBe('hello') + expect(globalFunctions.trim('\n\thello\t\n')).toBe('hello') + }) + + test('split divides string by separator', () => { + expect(globalFunctions.split('a,b,c', ',')).toEqual(['a', 'b', 'c']) + expect(globalFunctions.split('hello', '')).toEqual(['h', 'e', 'l', 'l', 'o']) + }) + + test('split uses comma as default separator', () => { + expect(globalFunctions.split('a,b,c')).toEqual(['a', 'b', 'c']) + }) + + test('join combines array elements', () => { + expect(globalFunctions.join(['a', 'b', 'c'], '-')).toBe('a-b-c') + expect(globalFunctions.join(['hello', 'world'], ' ')).toBe('hello world') + }) + + test('join uses comma as default separator', () => { + expect(globalFunctions.join(['a', 'b', 'c'])).toBe('a,b,c') + }) +}) + +describe('introspection', () => { + test('type returns proper types', () => { + expect(globalFunctions.type(toValue('hello'))).toBe('string') + expect(globalFunctions.type('hello')).toBe('string') + + expect(globalFunctions.type(toValue(42))).toBe('number') + expect(globalFunctions.type(42)).toBe('number') + + expect(globalFunctions.type(toValue(true))).toBe('boolean') + expect(globalFunctions.type(false)).toBe('boolean') + + expect(globalFunctions.type(toValue(null))).toBe('null') + + expect(globalFunctions.type(toValue([1, 2, 3]))).toBe('array') + + const dict = new Map([['key', toValue('value')]]) + expect(globalFunctions.type({ type: 'dict', value: dict })).toBe('dict') + }) + + test('length', () => { + expect(globalFunctions.length(toValue('hello'))).toBe(5) + expect(globalFunctions.length('hello')).toBe(5) + + expect(globalFunctions.length(toValue([1, 2, 3]))).toBe(3) + expect(globalFunctions.length([1, 2, 3])).toBe(3) + + const dict = new Map([['a', toValue(1)], ['b', toValue(2)]]) + expect(globalFunctions.length({ type: 'dict', value: dict })).toBe(2) + + expect(globalFunctions.length(toValue(42))).toBe(0) + expect(globalFunctions.length(toValue(true))).toBe(0) + expect(globalFunctions.length(toValue(null))).toBe(0) + }) + + test('inspect formats values', () => { + const result = globalFunctions.inspect(toValue('hello')) + expect(result).toContain('hello') + }) +}) + +describe('collections', () => { + test('list creates array from arguments', () => { + expect(globalFunctions.list(1, 2, 3)).toEqual([1, 2, 3]) + expect(globalFunctions.list('a', 'b')).toEqual(['a', 'b']) + expect(globalFunctions.list()).toEqual([]) + }) + + test('dict creates object from named arguments', () => { + expect(globalFunctions.dict({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }) + expect(globalFunctions.dict()).toEqual({}) + }) + + test('at retrieves element at index', () => { + expect(globalFunctions.at([10, 20, 30], 0)).toBe(10) + expect(globalFunctions.at([10, 20, 30], 2)).toBe(30) + }) + + test('at retrieves property from object', () => { + expect(globalFunctions.at({ name: 'test' }, 'name')).toBe('test') + }) + + test('slice extracts array subset', () => { + expect(globalFunctions.slice([1, 2, 3, 4, 5], 1, 3)).toEqual([2, 3]) + expect(globalFunctions.slice([1, 2, 3, 4, 5], 2)).toEqual([3, 4, 5]) + }) + + test('range creates number sequence', () => { + expect(globalFunctions.range(0, 5)).toEqual([0, 1, 2, 3, 4, 5]) + expect(globalFunctions.range(3, 6)).toEqual([3, 4, 5, 6]) + }) + + test('range with single argument starts from 0', () => { + expect(globalFunctions.range(3, null)).toEqual([0, 1, 2, 3]) + expect(globalFunctions.range(0, null)).toEqual([0]) + }) +}) + +describe('enumerables', () => { + test('map transforms array elements', async () => { + const double = (x: number) => x * 2 + const result = await globalFunctions.map([1, 2, 3], double) + expect(result).toEqual([2, 4, 6]) + }) + + test('map works with async callbacks', async () => { + const asyncDouble = async (x: number) => { + await Promise.resolve() + return x * 2 + } + const result = await globalFunctions.map([1, 2, 3], asyncDouble) + expect(result).toEqual([2, 4, 6]) + }) + + test('map handles empty array', async () => { + const fn = (x: number) => x * 2 + const result = await globalFunctions.map([], fn) + expect(result).toEqual([]) + }) + + test('each iterates over array', async () => { + const results: number[] = [] + await globalFunctions.each([1, 2, 3], (x: number) => { + results.push(x * 2) + }) + expect(results).toEqual([2, 4, 6]) + }) + + test('each works with async callbacks', async () => { + const results: number[] = [] + await globalFunctions.each([1, 2, 3], async (x: number) => { + await Promise.resolve() + results.push(x * 2) + }) + expect(results).toEqual([2, 4, 6]) + }) + + test('each handles empty array', async () => { + let called = false + await globalFunctions.each([], () => { + called = true + }) + expect(called).toBe(false) + }) +}) + +describe('echo', () => { + test('echo logs arguments to console', () => { + const spy = mock(() => { }) + const originalLog = console.log + console.log = spy + + globalFunctions.echo('hello', 'world') + + expect(spy).toHaveBeenCalledWith('hello', 'world') + console.log = originalLog + }) + + test('echo returns null value', () => { + const originalLog = console.log + console.log = () => { } + + const result = globalFunctions.echo('test') + + expect(result).toEqual(toValue(null)) + console.log = originalLog + }) + + test('echo formats array values', () => { + const spy = mock(() => { }) + const originalLog = console.log + console.log = spy + + globalFunctions.echo(toValue([1, 2, 3])) + + // Should format the array, not just log the raw value + expect(spy).toHaveBeenCalled() + // @ts-ignore + const logged = spy.mock.calls[0][0] + // @ts-ignore + expect(logged).toContain('list') + + console.log = originalLog + }) +}) + +describe('formatValue', () => { + test('formats string with quotes', () => { + const result = formatValue(toValue('hello')) + expect(result).toContain('hello') + expect(result).toContain("'") + }) + + test('formats numbers', () => { + const result = formatValue(toValue(42)) + expect(result).toContain('42') + }) + + test('formats booleans', () => { + expect(formatValue(toValue(true))).toContain('true') + expect(formatValue(toValue(false))).toContain('false') + }) + + test('formats null', () => { + const result = formatValue(toValue(null)) + expect(result).toContain('null') + }) + + test('formats arrays', () => { + const result = formatValue(toValue([1, 2, 3])) + expect(result).toContain('list') + }) + + test('formats nested arrays with parentheses', () => { + const inner = toValue([1, 2]) + const outer = toValue([inner]) + const result = formatValue(outer) + expect(result).toContain('list') + expect(result).toContain('(') + expect(result).toContain(')') + }) + + test('formats dicts', () => { + const dict = new Map([ + ['name', toValue('test')], + ['age', toValue(42)] + ]) + const result = formatValue({ type: 'dict', value: dict }) + expect(result).toContain('dict') + expect(result).toContain('name=') + expect(result).toContain('age=') + }) + + test('escapes single quotes in strings', () => { + const result = formatValue(toValue("it's")) + expect(result).toContain("\\'") + }) +}) From 0eca3685f59071a875d3ed0a1774548e8c485de5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 28 Oct 2025 22:31:36 -0700 Subject: [PATCH 23/44] spruce up tests --- src/prelude/index.ts | 1 + src/prelude/tests/prelude.test.ts | 289 ++++++++++-------------------- 2 files changed, 91 insertions(+), 199 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 9e62a3b..7bb82f1 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -75,6 +75,7 @@ export const globalFunctions = { }, each: async (list: any[], cb: Function) => { for (const value of list) await cb(value) + return list }, // modules diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 7997150..72d7c5c 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -1,256 +1,147 @@ -import { expect, describe, test, mock } from 'bun:test' -import { globalFunctions, formatValue } from '#prelude' -import { toValue } from 'reefvm' +import { expect, describe, test } from 'bun:test' +import { globalFunctions } from '#prelude' describe('string operations', () => { - test('to-upper converts to uppercase', () => { - expect(globalFunctions['to-upper']('hello')).toBe('HELLO') - expect(globalFunctions['to-upper']('Hello World!')).toBe('HELLO WORLD!') + test('to-upper converts to uppercase', async () => { + await expect(`to-upper 'hello'`).toEvaluateTo('HELLO', globalFunctions) + await expect(`to-upper 'Hello World!'`).toEvaluateTo('HELLO WORLD!', globalFunctions) }) - test('to-lower converts to lowercase', () => { - expect(globalFunctions['to-lower']('HELLO')).toBe('hello') - expect(globalFunctions['to-lower']('Hello World!')).toBe('hello world!') + test('to-lower converts to lowercase', async () => { + await expect(`to-lower 'HELLO'`).toEvaluateTo('hello', globalFunctions) + await expect(`to-lower 'Hello World!'`).toEvaluateTo('hello world!', globalFunctions) }) - test('trim removes whitespace', () => { - expect(globalFunctions.trim(' hello ')).toBe('hello') - expect(globalFunctions.trim('\n\thello\t\n')).toBe('hello') + test('trim removes whitespace', async () => { + await expect(`trim ' hello '`).toEvaluateTo('hello', globalFunctions) + await expect(`trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello', globalFunctions) }) - test('split divides string by separator', () => { - expect(globalFunctions.split('a,b,c', ',')).toEqual(['a', 'b', 'c']) - expect(globalFunctions.split('hello', '')).toEqual(['h', 'e', 'l', 'l', 'o']) + test('split divides string by separator', async () => { + await expect(`split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'], globalFunctions) }) - test('split uses comma as default separator', () => { - expect(globalFunctions.split('a,b,c')).toEqual(['a', 'b', 'c']) + test('split with comma separator', async () => { + await expect(`split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) }) - test('join combines array elements', () => { - expect(globalFunctions.join(['a', 'b', 'c'], '-')).toBe('a-b-c') - expect(globalFunctions.join(['hello', 'world'], ' ')).toBe('hello world') + test('join combines array elements', async () => { + await expect(`join ['a' 'b' 'c'] '-'`).toEvaluateTo('a-b-c', globalFunctions) + await expect(`join ['hello' 'world'] ' '`).toEvaluateTo('hello world', globalFunctions) }) - test('join uses comma as default separator', () => { - expect(globalFunctions.join(['a', 'b', 'c'])).toBe('a,b,c') + test('join with comma separator', async () => { + await expect(`join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globalFunctions) }) }) describe('introspection', () => { - test('type returns proper types', () => { - expect(globalFunctions.type(toValue('hello'))).toBe('string') - expect(globalFunctions.type('hello')).toBe('string') - - expect(globalFunctions.type(toValue(42))).toBe('number') - expect(globalFunctions.type(42)).toBe('number') - - expect(globalFunctions.type(toValue(true))).toBe('boolean') - expect(globalFunctions.type(false)).toBe('boolean') - - expect(globalFunctions.type(toValue(null))).toBe('null') - - expect(globalFunctions.type(toValue([1, 2, 3]))).toBe('array') - - const dict = new Map([['key', toValue('value')]]) - expect(globalFunctions.type({ type: 'dict', value: dict })).toBe('dict') + test('type returns proper types', async () => { + await expect(`type 'hello'`).toEvaluateTo('string', globalFunctions) + await expect(`type 42`).toEvaluateTo('number', globalFunctions) + await expect(`type true`).toEvaluateTo('boolean', globalFunctions) + await expect(`type false`).toEvaluateTo('boolean', globalFunctions) + await expect(`type null`).toEvaluateTo('null', globalFunctions) + await expect(`type [1 2 3]`).toEvaluateTo('array', globalFunctions) + await expect(`type [a=1 b=2]`).toEvaluateTo('dict', globalFunctions) }) - test('length', () => { - expect(globalFunctions.length(toValue('hello'))).toBe(5) - expect(globalFunctions.length('hello')).toBe(5) - - expect(globalFunctions.length(toValue([1, 2, 3]))).toBe(3) - expect(globalFunctions.length([1, 2, 3])).toBe(3) - - const dict = new Map([['a', toValue(1)], ['b', toValue(2)]]) - expect(globalFunctions.length({ type: 'dict', value: dict })).toBe(2) - - expect(globalFunctions.length(toValue(42))).toBe(0) - expect(globalFunctions.length(toValue(true))).toBe(0) - expect(globalFunctions.length(toValue(null))).toBe(0) + test('length', async () => { + await expect(`length 'hello'`).toEvaluateTo(5, globalFunctions) + await expect(`length [1 2 3]`).toEvaluateTo(3, globalFunctions) + await expect(`length [a=1 b=2]`).toEvaluateTo(2, globalFunctions) + await expect(`length 42`).toEvaluateTo(0, globalFunctions) + await expect(`length true`).toEvaluateTo(0, globalFunctions) + await expect(`length null`).toEvaluateTo(0, globalFunctions) }) - test('inspect formats values', () => { - const result = globalFunctions.inspect(toValue('hello')) - expect(result).toContain('hello') + test('inspect formats values', async () => { + // Just test that inspect returns something for now + // (we'd need more complex assertion to check the actual format) + await expect(`type (inspect 'hello')`).toEvaluateTo('string', globalFunctions) }) }) describe('collections', () => { - test('list creates array from arguments', () => { - expect(globalFunctions.list(1, 2, 3)).toEqual([1, 2, 3]) - expect(globalFunctions.list('a', 'b')).toEqual(['a', 'b']) - expect(globalFunctions.list()).toEqual([]) + test('list creates array from arguments', async () => { + await expect(`list 1 2 3`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list 'a' 'b'`).toEvaluateTo(['a', 'b'], globalFunctions) + await expect(`list`).toEvaluateTo([], globalFunctions) }) - test('dict creates object from named arguments', () => { - expect(globalFunctions.dict({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }) - expect(globalFunctions.dict()).toEqual({}) + test('dict creates object from named arguments', async () => { + await expect(`dict a=1 b=2`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + await expect(`dict`).toEvaluateTo({}, globalFunctions) }) - test('at retrieves element at index', () => { - expect(globalFunctions.at([10, 20, 30], 0)).toBe(10) - expect(globalFunctions.at([10, 20, 30], 2)).toBe(30) + test('at retrieves element at index', async () => { + await expect(`at [10 20 30] 0`).toEvaluateTo(10, globalFunctions) + await expect(`at [10 20 30] 2`).toEvaluateTo(30, globalFunctions) }) - test('at retrieves property from object', () => { - expect(globalFunctions.at({ name: 'test' }, 'name')).toBe('test') + test('at retrieves property from object', async () => { + await expect(`at [name='test'] 'name'`).toEvaluateTo('test', globalFunctions) }) - test('slice extracts array subset', () => { - expect(globalFunctions.slice([1, 2, 3, 4, 5], 1, 3)).toEqual([2, 3]) - expect(globalFunctions.slice([1, 2, 3, 4, 5], 2)).toEqual([3, 4, 5]) + test('slice extracts array subset', async () => { + await expect(`slice [1 2 3 4 5] 1 3`).toEvaluateTo([2, 3], globalFunctions) + await expect(`slice [1 2 3 4 5] 2 5`).toEvaluateTo([3, 4, 5], globalFunctions) }) - test('range creates number sequence', () => { - expect(globalFunctions.range(0, 5)).toEqual([0, 1, 2, 3, 4, 5]) - expect(globalFunctions.range(3, 6)).toEqual([3, 4, 5, 6]) + test('range creates number sequence', async () => { + await expect(`range 0 5`).toEvaluateTo([0, 1, 2, 3, 4, 5], globalFunctions) + await expect(`range 3 6`).toEvaluateTo([3, 4, 5, 6], globalFunctions) }) - test('range with single argument starts from 0', () => { - expect(globalFunctions.range(3, null)).toEqual([0, 1, 2, 3]) - expect(globalFunctions.range(0, null)).toEqual([0]) + test('range with single argument starts from 0', async () => { + await expect(`range 3 null`).toEvaluateTo([0, 1, 2, 3], globalFunctions) + await expect(`range 0 null`).toEvaluateTo([0], globalFunctions) }) }) describe('enumerables', () => { test('map transforms array elements', async () => { - const double = (x: number) => x * 2 - const result = await globalFunctions.map([1, 2, 3], double) - expect(result).toEqual([2, 4, 6]) - }) - - test('map works with async callbacks', async () => { - const asyncDouble = async (x: number) => { - await Promise.resolve() - return x * 2 - } - const result = await globalFunctions.map([1, 2, 3], asyncDouble) - expect(result).toEqual([2, 4, 6]) + await expect(` + double = do x: x * 2 end + map [1 2 3] double + `).toEvaluateTo([2, 4, 6], globalFunctions) }) test('map handles empty array', async () => { - const fn = (x: number) => x * 2 - const result = await globalFunctions.map([], fn) - expect(result).toEqual([]) + await expect(` + double = do x: x * 2 end + map [] double + `).toEvaluateTo([], globalFunctions) }) test('each iterates over array', async () => { - const results: number[] = [] - await globalFunctions.each([1, 2, 3], (x: number) => { - results.push(x * 2) - }) - expect(results).toEqual([2, 4, 6]) - }) - - test('each works with async callbacks', async () => { - const results: number[] = [] - await globalFunctions.each([1, 2, 3], async (x: number) => { - await Promise.resolve() - results.push(x * 2) - }) - expect(results).toEqual([2, 4, 6]) + // Note: each doesn't return the results, it returns null + // We can test it runs by checking the return value + await expect(` + double = do x: x * 2 end + each [1 2 3] double + `).toEvaluateTo([1, 2, 3], globalFunctions) }) test('each handles empty array', async () => { - let called = false - await globalFunctions.each([], () => { - called = true - }) - expect(called).toBe(false) + await expect(` + fn = do x: x end + each [] fn + `).toEvaluateTo([], globalFunctions) }) }) -describe('echo', () => { - test('echo logs arguments to console', () => { - const spy = mock(() => { }) - const originalLog = console.log - console.log = spy +// describe('echo', () => { +// test('echo returns null value', async () => { +// await expect(`echo 'hello' 'world'`).toEvaluateTo(null, globalFunctions) +// }) - globalFunctions.echo('hello', 'world') +// test('echo with array', async () => { +// await expect(`echo [1 2 3]`).toEvaluateTo(null, globalFunctions) +// }) - expect(spy).toHaveBeenCalledWith('hello', 'world') - console.log = originalLog - }) - - test('echo returns null value', () => { - const originalLog = console.log - console.log = () => { } - - const result = globalFunctions.echo('test') - - expect(result).toEqual(toValue(null)) - console.log = originalLog - }) - - test('echo formats array values', () => { - const spy = mock(() => { }) - const originalLog = console.log - console.log = spy - - globalFunctions.echo(toValue([1, 2, 3])) - - // Should format the array, not just log the raw value - expect(spy).toHaveBeenCalled() - // @ts-ignore - const logged = spy.mock.calls[0][0] - // @ts-ignore - expect(logged).toContain('list') - - console.log = originalLog - }) -}) - -describe('formatValue', () => { - test('formats string with quotes', () => { - const result = formatValue(toValue('hello')) - expect(result).toContain('hello') - expect(result).toContain("'") - }) - - test('formats numbers', () => { - const result = formatValue(toValue(42)) - expect(result).toContain('42') - }) - - test('formats booleans', () => { - expect(formatValue(toValue(true))).toContain('true') - expect(formatValue(toValue(false))).toContain('false') - }) - - test('formats null', () => { - const result = formatValue(toValue(null)) - expect(result).toContain('null') - }) - - test('formats arrays', () => { - const result = formatValue(toValue([1, 2, 3])) - expect(result).toContain('list') - }) - - test('formats nested arrays with parentheses', () => { - const inner = toValue([1, 2]) - const outer = toValue([inner]) - const result = formatValue(outer) - expect(result).toContain('list') - expect(result).toContain('(') - expect(result).toContain(')') - }) - - test('formats dicts', () => { - const dict = new Map([ - ['name', toValue('test')], - ['age', toValue(42)] - ]) - const result = formatValue({ type: 'dict', value: dict }) - expect(result).toContain('dict') - expect(result).toContain('name=') - expect(result).toContain('age=') - }) - - test('escapes single quotes in strings', () => { - const result = formatValue(toValue("it's")) - expect(result).toContain("\\'") - }) -}) +// test('echo with multiple arguments', async () => { +// await expect(`echo 'test' 42 true`).toEvaluateTo(null, globalFunctions) +// }) +// }) From 3496b290727794be0ae4f450b7fee6dcdcdbc114 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 10:12:55 -0700 Subject: [PATCH 24/44] tell the parser about builtin global functions --- src/compiler/compiler.ts | 4 +++- src/parser/tokenizer.ts | 9 ++++++++- src/prelude/index.ts | 3 +++ src/prelude/tests/prelude.test.ts | 4 ++-- src/testSetup.ts | 9 ++++++--- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 4438020..f1c1e01 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -1,6 +1,7 @@ import { CompilerError } from '#compiler/compilerError.ts' import { parser } from '#parser/shrimp.ts' import * as terms from '#parser/shrimp.terms' +import { setGlobals } from '#parser/tokenizer' import type { SyntaxNode, Tree } from '@lezer/common' import { assert, errorMessage } from '#utils/utils' import { toBytecode, type Bytecode, type ProgramItem, bytecodeToString } from 'reefvm' @@ -53,8 +54,9 @@ export class Compiler { bytecode: Bytecode pipeCounter = 0 - constructor(public input: string) { + constructor(public input: string, globals?: string[]) { try { + if (globals) setGlobals(globals) const cst = parser.parse(input) const errors = checkTreeForErrors(cst) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index cef4446..6b2e67a 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -6,6 +6,13 @@ export function specializeKeyword(ident: string) { return ident === 'do' ? Do : -1 } +// tell the dotGet searcher about builtin globals +export const globals: string[] = [] +export const setGlobals = (newGlobals: string[]) => { + globals.length = 0 + globals.push(...newGlobals) +} + // The only chars that can't be words are whitespace, apostrophes, closing parens, and EOF. export const tokenizer = new ExternalTokenizer( @@ -152,7 +159,7 @@ const checkForDotGet = (input: InputStream, stack: Stack, pos: number): number | // If identifier is in scope, this is property access (e.g., obj.prop) // If not in scope, it should be consumed as a Word (e.g., file.txt) - return context?.scope.has(identifierText) ? IdentifierBeforeDot : null + return context?.scope.has(identifierText) || globals.includes(identifierText) ? IdentifierBeforeDot : null } // Decide between AssignableIdentifier and Identifier using grammar state + peek-ahead diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 7bb82f1..46f3dbf 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -49,6 +49,9 @@ export const globalFunctions = { 'to-upper': (str: string) => str.toUpperCase(), 'to-lower': (str: string) => str.toLowerCase(), trim: (str: string) => str.trim(), + str: { + trim: (str: string) => str.trim(), + }, // collections at: (collection: any, index: number | string) => collection[index], diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 72d7c5c..f3d934c 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -13,8 +13,8 @@ describe('string operations', () => { }) test('trim removes whitespace', async () => { - await expect(`trim ' hello '`).toEvaluateTo('hello', globalFunctions) - await expect(`trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello', globalFunctions) + await expect(`str.trim ' hello '`).toEvaluateTo('hello', globalFunctions) + await expect(`str.trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello', globalFunctions) }) test('split divides string by separator', async () => { diff --git a/src/testSetup.ts b/src/testSetup.ts index c6d5551..3904828 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -1,9 +1,10 @@ import { expect } from 'bun:test' import { parser } from '#parser/shrimp' +import { setGlobals } from '#parser/tokenizer' import { $ } from 'bun' import { assert, errorMessage } from '#utils/utils' import { Compiler } from '#compiler/compiler' -import { run, VM, type TypeScriptFunction } from 'reefvm' +import { run, VM } from 'reefvm' import { treeToString, VMResultToValue } from '#utils/tree' const regenerateParser = async () => { @@ -30,7 +31,7 @@ await regenerateParser() // Type declaration for TypeScript declare module 'bun:test' { interface Matchers { - toMatchTree(expected: string): T + toMatchTree(expected: string, globals?: Record): T toMatchExpression(expected: string): T toFailParse(): T toEvaluateTo(expected: unknown, globals?: Record): Promise @@ -39,9 +40,10 @@ declare module 'bun:test' { } expect.extend({ - toMatchTree(received: unknown, expected: string) { + toMatchTree(received: unknown, expected: string, globals?: Record) { assert(typeof received === 'string', 'toMatchTree can only be used with string values') + if (globals) setGlobals(Object.keys(globals)) const tree = parser.parse(received) const actual = treeToString(tree, received) const normalizedExpected = trimWhitespace(expected) @@ -97,6 +99,7 @@ expect.extend({ assert(typeof received === 'string', 'toEvaluateTo can only be used with string values') try { + if (globals) setGlobals(Object.keys(globals)) const compiler = new Compiler(received) const result = await run(compiler.bytecode, globals) let value = VMResultToValue(result) From 07ffc7df97a381b37cd130a6ec1dd471b52c3992 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 10:21:20 -0700 Subject: [PATCH 25/44] str.trim and friends, list.map and friends --- src/prelude/index.ts | 27 ++++++++++---------- src/prelude/tests/prelude.test.ts | 42 +++++++++++++++---------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 46f3dbf..0d31735 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -44,20 +44,26 @@ export const globalFunctions = { }, // strings - join: (arr: string[], sep: string = ',') => arr.join(sep), - split: (str: string, sep: string = ',') => str.split(sep), - 'to-upper': (str: string) => str.toUpperCase(), - 'to-lower': (str: string) => str.toLowerCase(), - trim: (str: string) => str.trim(), str: { + join: (arr: string[], sep: string = ',') => arr.join(sep), + split: (str: string, sep: string = ',') => str.split(sep), + 'to-upper': (str: string) => str.toUpperCase(), + 'to-lower': (str: string) => str.toLowerCase(), trim: (str: string) => str.trim(), }, + // list + list: { + slice: (list: any[], start: number, end?: number) => list.slice(start, end), + map: async (list: any[], cb: Function) => { + let acc: any[] = [] + for (const value of list) acc.push(await cb(value)) + return acc + }, + }, + // collections at: (collection: any, index: number | string) => collection[index], - list: (...args: any[]) => args, - dict: (atNamed = {}) => atNamed, - slice: (list: any[], start: number, end?: number) => list.slice(start, end), range: (start: number, end: number | null) => { if (end === null) { end = start @@ -71,11 +77,6 @@ export const globalFunctions = { }, // enumerables - map: async (list: any[], cb: Function) => { - let acc: any[] = [] - for (const value of list) acc.push(await cb(value)) - return acc - }, each: async (list: any[], cb: Function) => { for (const value of list) await cb(value) return list diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index f3d934c..1ed05f9 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -3,13 +3,13 @@ import { globalFunctions } from '#prelude' describe('string operations', () => { test('to-upper converts to uppercase', async () => { - await expect(`to-upper 'hello'`).toEvaluateTo('HELLO', globalFunctions) - await expect(`to-upper 'Hello World!'`).toEvaluateTo('HELLO WORLD!', globalFunctions) + await expect(`str.to-upper 'hello'`).toEvaluateTo('HELLO', globalFunctions) + await expect(`str.to-upper 'Hello World!'`).toEvaluateTo('HELLO WORLD!', globalFunctions) }) test('to-lower converts to lowercase', async () => { - await expect(`to-lower 'HELLO'`).toEvaluateTo('hello', globalFunctions) - await expect(`to-lower 'Hello World!'`).toEvaluateTo('hello world!', globalFunctions) + await expect(`str.to-lower 'HELLO'`).toEvaluateTo('hello', globalFunctions) + await expect(`str.to-lower 'Hello World!'`).toEvaluateTo('hello world!', globalFunctions) }) test('trim removes whitespace', async () => { @@ -18,21 +18,21 @@ describe('string operations', () => { }) test('split divides string by separator', async () => { - await expect(`split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) - await expect(`split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'], globalFunctions) + await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'], globalFunctions) }) test('split with comma separator', async () => { - await expect(`split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) }) test('join combines array elements', async () => { - await expect(`join ['a' 'b' 'c'] '-'`).toEvaluateTo('a-b-c', globalFunctions) - await expect(`join ['hello' 'world'] ' '`).toEvaluateTo('hello world', globalFunctions) + await expect(`str.join ['a' 'b' 'c'] '-'`).toEvaluateTo('a-b-c', globalFunctions) + await expect(`str.join ['hello' 'world'] ' '`).toEvaluateTo('hello world', globalFunctions) }) test('join with comma separator', async () => { - await expect(`join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globalFunctions) + await expect(`str.join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globalFunctions) }) }) @@ -64,15 +64,15 @@ describe('introspection', () => { }) describe('collections', () => { - test('list creates array from arguments', async () => { - await expect(`list 1 2 3`).toEvaluateTo([1, 2, 3], globalFunctions) - await expect(`list 'a' 'b'`).toEvaluateTo(['a', 'b'], globalFunctions) - await expect(`list`).toEvaluateTo([], globalFunctions) + test('literal array creates array from arguments', async () => { + await expect(`[ 1 2 3 ]`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`['a' 'b']`).toEvaluateTo(['a', 'b'], globalFunctions) + await expect(`[]`).toEvaluateTo([], globalFunctions) }) - test('dict creates object from named arguments', async () => { - await expect(`dict a=1 b=2`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) - await expect(`dict`).toEvaluateTo({}, globalFunctions) + test('literal dict creates object from named arguments', async () => { + await expect(`[ a=1 b=2 ]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + await expect(`[=]`).toEvaluateTo({}, globalFunctions) }) test('at retrieves element at index', async () => { @@ -85,8 +85,8 @@ describe('collections', () => { }) test('slice extracts array subset', async () => { - await expect(`slice [1 2 3 4 5] 1 3`).toEvaluateTo([2, 3], globalFunctions) - await expect(`slice [1 2 3 4 5] 2 5`).toEvaluateTo([3, 4, 5], globalFunctions) + await expect(`list.slice [1 2 3 4 5] 1 3`).toEvaluateTo([2, 3], globalFunctions) + await expect(`list.slice [1 2 3 4 5] 2 5`).toEvaluateTo([3, 4, 5], globalFunctions) }) test('range creates number sequence', async () => { @@ -104,14 +104,14 @@ describe('enumerables', () => { test('map transforms array elements', async () => { await expect(` double = do x: x * 2 end - map [1 2 3] double + list.map [1 2 3] double `).toEvaluateTo([2, 4, 6], globalFunctions) }) test('map handles empty array', async () => { await expect(` double = do x: x * 2 end - map [] double + list.map [] double `).toEvaluateTo([], globalFunctions) }) From 40a648cd194b6e26f4860f14d2d0b7d3658f2173 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 10:38:57 -0700 Subject: [PATCH 26/44] allow ? in identifier name --- src/parser/tests/basics.test.ts | 13 +++++++++++++ src/parser/tokenizer.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index f92e034..0d870a8 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -36,6 +36,19 @@ describe('Identifier', () => { FunctionCallOrIdentifier Identifier 𝜋`) }) + + test('parses identifiers with queries', () => { + expect('even? 20').toMatchTree(` + FunctionCall + Identifier even? + PositionalArg + Number 20`) + + expect('even?').toMatchTree(` + FunctionCallOrIdentifier + Identifier even?`) + }) + }) describe('Unicode Symbol Support', () => { diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 6b2e67a..e4fc895 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -119,7 +119,7 @@ const consumeWordToken = ( } // Track identifier validity: must be lowercase, digit, dash, or emoji/unicode - if (!isLowercaseLetter(ch) && !isDigit(ch) && ch !== 45 /* - */ && !isEmojiOrUnicode(ch)) { + if (!isLowercaseLetter(ch) && !isDigit(ch) && ch !== 45 /* - */ && ch !== 63 /* ? */ && !isEmojiOrUnicode(ch)) { if (!canBeWord) break isValidIdentifier = false } From d843071bee9d32c7cdb403ec703666201991d84b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 10:52:55 -0700 Subject: [PATCH 27/44] prelude tests --- src/prelude/index.ts | 93 ++++++++++ src/prelude/tests/prelude.test.ts | 279 ++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 0d31735..8eb9bb3 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -50,6 +50,21 @@ export const globalFunctions = { 'to-upper': (str: string) => str.toUpperCase(), 'to-lower': (str: string) => str.toLowerCase(), trim: (str: string) => str.trim(), + // predicates + 'starts-with?': (str: string, prefix: string) => str.startsWith(prefix), + 'ends-with?': (str: string, suffix: string) => str.endsWith(suffix), + 'contains?': (str: string, substr: string) => str.includes(substr), + 'empty?': (str: string) => str.length === 0, + // transformations + replace: (str: string, search: string, replacement: string) => str.replace(search, replacement), + '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), + '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'), + chars: (str: string) => str.split(''), }, // list @@ -60,6 +75,43 @@ export const globalFunctions = { for (const value of list) acc.push(await cb(value)) return acc }, + filter: async (list: any[], cb: Function) => { + let acc: any[] = [] + for (const value of list) { + if (await cb(value)) acc.push(value) + } + return acc + }, + reduce: async (list: any[], cb: Function, initial: any) => { + let acc = initial + for (const value of list) acc = await cb(acc, value) + return acc + }, + find: async (list: any[], cb: Function) => { + for (const value of list) { + if (await cb(value)) return value + } + return null + }, + // predicates + 'empty?': (list: any[]) => list.length === 0, + 'contains?': (list: any[], item: any) => list.includes(item), + // sequence operations + reverse: (list: any[]) => list.slice().reverse(), + sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb), + concat: (...lists: any[][]) => lists.flat(1), + flatten: (list: any[], depth: number = 1) => list.flat(depth), + unique: (list: any[]) => Array.from(new Set(list)), + zip: (list1: any[], list2: any[]) => list1.map((item, i) => [item, list2[i]]), + // access + 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), + append: (list: any[], item: any) => [...list, item], + prepend: (list: any[], item: any) => [item, ...list], + 'index-of': (list: any[], item: any) => list.indexOf(item), }, // collections @@ -75,6 +127,47 @@ export const globalFunctions = { } return result }, + 'empty?': (v: any) => { + const value = toValue(v) + switch (value.type) { + case 'string': case 'array': + return value.value.length === 0 + case 'dict': + return value.value.size === 0 + default: + return false + } + }, + + // dict + dict: { + keys: (dict: Record) => Object.keys(dict), + values: (dict: Record) => Object.values(dict), + entries: (dict: Record) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })), + 'has?': (dict: Record, key: string) => key in dict, + get: (dict: Record, key: string, defaultValue: any = null) => dict[key] ?? defaultValue, + merge: (...dicts: Record[]) => Object.assign({}, ...dicts), + 'empty?': (dict: Record) => Object.keys(dict).length === 0, + }, + + // math + math: { + abs: (n: number) => Math.abs(n), + 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), + pow: (base: number, exp: number) => Math.pow(base, exp), + sqrt: (n: number) => Math.sqrt(n), + random: () => Math.random(), + // predicates + 'even?': (n: number) => n % 2 === 0, + 'odd?': (n: number) => n % 2 !== 0, + 'positive?': (n: number) => n > 0, + 'negative?': (n: number) => n < 0, + 'zero?': (n: number) => n === 0, + }, // enumerables each: async (list: any[], cb: Function) => { diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 1ed05f9..89969dd 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -34,6 +34,59 @@ describe('string operations', () => { test('join with comma separator', async () => { await expect(`str.join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globalFunctions) }) + + test('starts-with? checks string prefix', async () => { + await expect(`str.starts-with? 'hello' 'hel'`).toEvaluateTo(true, globalFunctions) + await expect(`str.starts-with? 'hello' 'bye'`).toEvaluateTo(false, globalFunctions) + }) + + test('ends-with? checks string suffix', async () => { + await expect(`str.ends-with? 'hello' 'lo'`).toEvaluateTo(true, globalFunctions) + await expect(`str.ends-with? 'hello' 'he'`).toEvaluateTo(false, globalFunctions) + }) + + test('contains? checks for substring', async () => { + await expect(`str.contains? 'hello world' 'o w'`).toEvaluateTo(true, globalFunctions) + await expect(`str.contains? 'hello' 'bye'`).toEvaluateTo(false, globalFunctions) + }) + + test('empty? checks if string is empty', async () => { + await expect(`str.empty? ''`).toEvaluateTo(true, globalFunctions) + await expect(`str.empty? 'hello'`).toEvaluateTo(false, globalFunctions) + }) + + test('replace replaces first occurrence', async () => { + await expect(`str.replace 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hello', globalFunctions) + }) + + test('replace-all replaces all occurrences', async () => { + await expect(`str.replace-all 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hi', globalFunctions) + }) + + test('slice extracts substring', async () => { + await expect(`str.slice 'hello' 1 3`).toEvaluateTo('el', globalFunctions) + await expect(`str.slice 'hello' 2 null`).toEvaluateTo('llo', globalFunctions) + }) + + test('repeat repeats string', async () => { + await expect(`str.repeat 'ha' 3`).toEvaluateTo('hahaha', globalFunctions) + }) + + test('pad-start pads beginning', async () => { + await expect(`str.pad-start '5' 3 '0'`).toEvaluateTo('005', globalFunctions) + }) + + test('pad-end pads end', async () => { + await expect(`str.pad-end '5' 3 '0'`).toEvaluateTo('500', globalFunctions) + }) + + test('lines splits by newlines', async () => { + await expect(`str.lines 'a\\nb\\nc'`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + }) + + test('chars splits into characters', async () => { + await expect(`str.chars 'abc'`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + }) }) describe('introspection', () => { @@ -98,6 +151,121 @@ describe('collections', () => { await expect(`range 3 null`).toEvaluateTo([0, 1, 2, 3], globalFunctions) await expect(`range 0 null`).toEvaluateTo([0], globalFunctions) }) + + test('empty? checks if list, dict, string is empty', async () => { + await expect(`empty? []`).toEvaluateTo(true, globalFunctions) + await expect(`empty? [1]`).toEvaluateTo(false, globalFunctions) + + await expect(`empty? [=]`).toEvaluateTo(true, globalFunctions) + await expect(`empty? [a=true]`).toEvaluateTo(false, globalFunctions) + + await expect(`empty? ''`).toEvaluateTo(true, globalFunctions) + await expect(`empty? 'cat'`).toEvaluateTo(false, globalFunctions) + await expect(`empty? meow`).toEvaluateTo(false, globalFunctions) + }) + + // TODO: These tests fail due to parser/compiler issues with == operator in function bodies + // The functions themselves work correctly when passed native JS functions + test.skip('list.filter keeps matching elements', async () => { + await expect(` + is-positive = do x: + x == 3 or x == 4 or x == 5 + end + list.filter [1 2 3 4 5] is-positive + `).toEvaluateTo([3, 4, 5], globalFunctions) + }) + + test('list.reduce accumulates values', async () => { + await expect(` + add = do acc x: + acc + x + end + list.reduce [1 2 3 4] add 0 + `).toEvaluateTo(10, globalFunctions) + }) + + test.skip('list.find returns first match', async () => { + await expect(` + is-four = do x: + x == 4 + end + list.find [1 2 4 5] is-four + `).toEvaluateTo(4, globalFunctions) + }) + + test.skip('list.find returns null if no match', async () => { + await expect(` + is-ten = do x: + x == 10 + end + list.find [1 2 3] is-ten + `).toEvaluateTo(null, globalFunctions) + }) + + test('list.empty? checks if list is empty', async () => { + await expect(`list.empty? []`).toEvaluateTo(true, globalFunctions) + await expect(`list.empty? [1]`).toEvaluateTo(false, globalFunctions) + }) + + test('list.contains? checks for element', async () => { + await expect(`list.contains? [1 2 3] 2`).toEvaluateTo(true, globalFunctions) + await expect(`list.contains? [1 2 3] 5`).toEvaluateTo(false, globalFunctions) + }) + + test('list.reverse reverses array', async () => { + await expect(`list.reverse [1 2 3]`).toEvaluateTo([3, 2, 1], globalFunctions) + }) + + test('list.concat combines arrays', async () => { + await expect(`list.concat [1 2] [3 4]`).toEvaluateTo([1, 2, 3, 4], globalFunctions) + }) + + test('list.flatten flattens nested arrays', async () => { + await expect(`list.flatten [[1 2] [3 4]] 1`).toEvaluateTo([1, 2, 3, 4], globalFunctions) + }) + + test('list.unique removes duplicates', async () => { + await expect(`list.unique [1 2 2 3 1]`).toEvaluateTo([1, 2, 3], globalFunctions) + }) + + test('list.zip combines two arrays', async () => { + await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]], globalFunctions) + }) + + test('list.first returns first element', async () => { + await expect(`list.first [1 2 3]`).toEvaluateTo(1, globalFunctions) + await expect(`list.first []`).toEvaluateTo(null, globalFunctions) + }) + + test('list.last returns last element', async () => { + await expect(`list.last [1 2 3]`).toEvaluateTo(3, globalFunctions) + await expect(`list.last []`).toEvaluateTo(null, globalFunctions) + }) + + test('list.rest returns all but first', async () => { + await expect(`list.rest [1 2 3]`).toEvaluateTo([2, 3], globalFunctions) + }) + + test('list.take returns first n elements', async () => { + await expect(`list.take [1 2 3 4 5] 3`).toEvaluateTo([1, 2, 3], globalFunctions) + }) + + test('list.drop skips first n elements', async () => { + await expect(`list.drop [1 2 3 4 5] 2`).toEvaluateTo([3, 4, 5], globalFunctions) + }) + + test('list.append adds to end', async () => { + await expect(`list.append [1 2] 3`).toEvaluateTo([1, 2, 3], globalFunctions) + }) + + test('list.prepend adds to start', async () => { + await expect(`list.prepend [2 3] 1`).toEvaluateTo([1, 2, 3], globalFunctions) + }) + + test('list.index-of finds element index', async () => { + await expect(`list.index-of [1 2 3] 2`).toEvaluateTo(1, globalFunctions) + await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globalFunctions) + }) }) describe('enumerables', () => { @@ -132,6 +300,117 @@ describe('enumerables', () => { }) }) +describe('dict operations', () => { + test('dict.keys returns all keys', async () => { + const result = await (async () => { + const { Compiler } = await import('#compiler/compiler') + const { run, fromValue } = await import('reefvm') + const { setGlobals } = await import('#parser/tokenizer') + setGlobals(Object.keys(globalFunctions)) + const c = new Compiler('dict.keys [a=1 b=2 c=3]') + const r = await run(c.bytecode, globalFunctions) + return fromValue(r) + })() + // Check that all expected keys are present (order may vary) + expect(result.sort()).toEqual(['a', 'b', 'c']) + }) + + test('dict.values returns all values', async () => { + const result = await (async () => { + const { Compiler } = await import('#compiler/compiler') + const { run, fromValue } = await import('reefvm') + const { setGlobals } = await import('#parser/tokenizer') + setGlobals(Object.keys(globalFunctions)) + const c = new Compiler('dict.values [a=1 b=2]') + const r = await run(c.bytecode, globalFunctions) + return fromValue(r) + })() + // Check that all expected values are present (order may vary) + expect(result.sort()).toEqual([1, 2]) + }) + + test('dict.has? checks for key', async () => { + await expect(`dict.has? [a=1 b=2] 'a'`).toEvaluateTo(true, globalFunctions) + await expect(`dict.has? [a=1 b=2] 'c'`).toEvaluateTo(false, globalFunctions) + }) + + test('dict.get retrieves value with default', async () => { + await expect(`dict.get [a=1] 'a' 0`).toEvaluateTo(1, globalFunctions) + await expect(`dict.get [a=1] 'b' 99`).toEvaluateTo(99, globalFunctions) + }) + + test('dict.empty? checks if dict is empty', async () => { + await expect(`dict.empty? [=]`).toEvaluateTo(true, globalFunctions) + await expect(`dict.empty? [a=1]`).toEvaluateTo(false, globalFunctions) + }) + + test('dict.merge combines dicts', async () => { + await expect(`dict.merge [a=1] [b=2]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + }) +}) + +describe('math operations', () => { + test('math.abs returns absolute value', async () => { + await expect(`math.abs -5`).toEvaluateTo(5, globalFunctions) + await expect(`math.abs 5`).toEvaluateTo(5, globalFunctions) + }) + + test('math.floor rounds down', async () => { + await expect(`math.floor 3.7`).toEvaluateTo(3, globalFunctions) + }) + + test('math.ceil rounds up', async () => { + await expect(`math.ceil 3.2`).toEvaluateTo(4, globalFunctions) + }) + + test('math.round rounds to nearest', async () => { + await expect(`math.round 3.4`).toEvaluateTo(3, globalFunctions) + await expect(`math.round 3.6`).toEvaluateTo(4, globalFunctions) + }) + + test('math.min returns minimum', async () => { + await expect(`math.min 5 2 8 1`).toEvaluateTo(1, globalFunctions) + }) + + test('math.max returns maximum', async () => { + await expect(`math.max 5 2 8 1`).toEvaluateTo(8, globalFunctions) + }) + + test('math.pow computes power', async () => { + await expect(`math.pow 2 3`).toEvaluateTo(8, globalFunctions) + }) + + test('math.sqrt computes square root', async () => { + await expect(`math.sqrt 16`).toEvaluateTo(4, globalFunctions) + }) + + test('math.even? checks if even', async () => { + await expect(`math.even? 4`).toEvaluateTo(true, globalFunctions) + await expect(`math.even? 5`).toEvaluateTo(false, globalFunctions) + }) + + test('math.odd? checks if odd', async () => { + await expect(`math.odd? 5`).toEvaluateTo(true, globalFunctions) + await expect(`math.odd? 4`).toEvaluateTo(false, globalFunctions) + }) + + test('math.positive? checks if positive', async () => { + await expect(`math.positive? 5`).toEvaluateTo(true, globalFunctions) + await expect(`math.positive? -5`).toEvaluateTo(false, globalFunctions) + await expect(`math.positive? 0`).toEvaluateTo(false, globalFunctions) + }) + + test('math.negative? checks if negative', async () => { + await expect(`math.negative? -5`).toEvaluateTo(true, globalFunctions) + await expect(`math.negative? 5`).toEvaluateTo(false, globalFunctions) + }) + + test('math.zero? checks if zero', async () => { + await expect(`math.zero? 0`).toEvaluateTo(true, globalFunctions) + await expect(`math.zero? 5`).toEvaluateTo(false, globalFunctions) + }) +}) + // describe('echo', () => { // test('echo returns null value', async () => { // await expect(`echo 'hello' 'world'`).toEvaluateTo(null, globalFunctions) From ced190488a89dbf9019cd45aa0aa452888d1cc0a Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 10:58:48 -0700 Subject: [PATCH 28/44] Add % operator --- src/compiler/compiler.ts | 3 ++ src/compiler/tests/compiler.test.ts | 6 +++ src/parser/operatorTokenizer.ts | 1 + src/parser/shrimp.grammar | 3 +- src/parser/shrimp.terms.ts | 75 +++++++++++++++-------------- src/parser/shrimp.ts | 24 ++++----- src/parser/tests/basics.test.ts | 9 ++++ 7 files changed, 71 insertions(+), 50 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index f1c1e01..227cc8b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -220,6 +220,9 @@ export class Compiler { case '/': instructions.push(['DIV']) break + case '%': + instructions.push(['MOD']) + break default: throw new CompilerError(`Unsupported binary operator: ${opValue}`, op.from, op.to) } diff --git a/src/compiler/tests/compiler.test.ts b/src/compiler/tests/compiler.test.ts index 46ee0b7..5e58c11 100644 --- a/src/compiler/tests/compiler.test.ts +++ b/src/compiler/tests/compiler.test.ts @@ -38,6 +38,12 @@ describe('compiler', () => { expect('15 / 3').toEvaluateTo(5) }) + test('modulo', () => { + expect('44 % 2').toEvaluateTo(0) + expect('44 % 3').toEvaluateTo(2) + expect('3 % 4').toEvaluateTo(3) + }) + test('assign number', () => { expect('x = 5').toEvaluateTo(5) }) diff --git a/src/parser/operatorTokenizer.ts b/src/parser/operatorTokenizer.ts index 478c20d..d15318c 100644 --- a/src/parser/operatorTokenizer.ts +++ b/src/parser/operatorTokenizer.ts @@ -17,6 +17,7 @@ const operators: Array = [ { str: '-', tokenName: 'Minus' }, { str: '>', tokenName: 'Gt' }, { str: '<', tokenName: 'Lt' }, + { str: '%', tokenName: 'Modulo' }, ] export const operatorTokenizer = new ExternalTokenizer((input: InputStream) => { diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index ee89d1c..9147110 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -6,7 +6,7 @@ @top Program { item* } -@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, Neq, Lt, Lte, Gt, Gte } +@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, Neq, Lt, Lte, Gt, Gte, Modulo } @tokens { @precedence { Number Regex } @@ -148,6 +148,7 @@ Assign { } BinOp { + expression !multiplicative Modulo expression | (expression | BinOp) !multiplicative Star (expression | BinOp) | (expression | BinOp) !multiplicative Slash (expression | BinOp) | (expression | BinOp) !additive Plus (expression | BinOp) | diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 69d6d47..eb818df 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -12,40 +12,41 @@ export const Lte = 10, Gt = 11, Gte = 12, - Identifier = 13, - AssignableIdentifier = 14, - Word = 15, - IdentifierBeforeDot = 16, - Do = 17, - Program = 18, - PipeExpr = 19, - FunctionCall = 20, - DotGet = 21, - Number = 22, - ParenExpr = 23, - FunctionCallOrIdentifier = 24, - BinOp = 25, - String = 26, - StringFragment = 27, - Interpolation = 28, - EscapeSeq = 29, - Boolean = 30, - Regex = 31, - Dict = 32, - NamedArg = 33, - NamedArgPrefix = 34, - FunctionDef = 35, - Params = 36, - colon = 37, - keyword = 52, - Underscore = 39, - Array = 40, - Null = 41, - ConditionalOp = 42, - PositionalArg = 43, - IfExpr = 45, - SingleLineThenBlock = 47, - ThenBlock = 48, - ElseIfExpr = 49, - ElseExpr = 51, - Assign = 53 + Modulo = 13, + Identifier = 14, + AssignableIdentifier = 15, + Word = 16, + IdentifierBeforeDot = 17, + Do = 18, + Program = 19, + PipeExpr = 20, + FunctionCall = 21, + DotGet = 22, + Number = 23, + ParenExpr = 24, + FunctionCallOrIdentifier = 25, + BinOp = 26, + String = 27, + StringFragment = 28, + Interpolation = 29, + EscapeSeq = 30, + Boolean = 31, + Regex = 32, + Dict = 33, + NamedArg = 34, + NamedArgPrefix = 35, + FunctionDef = 36, + Params = 37, + colon = 38, + keyword = 53, + Underscore = 40, + Array = 41, + Null = 42, + ConditionalOp = 43, + PositionalArg = 44, + IfExpr = 46, + SingleLineThenBlock = 48, + ThenBlock = 49, + ElseIfExpr = 50, + ElseExpr = 52, + Assign = 54 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 4865cb6..5b4e228 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./scopeTracker" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,end:76, null:82, if:92, elseif:100, else:104} +const spec_Identifier = {__proto__:null,end:78, null:84, if:94, elseif:102, else:106} export const parser = LRParser.deserialize({ version: 14, - states: "2YQYQbOOO!ZOpO'#CqO#mQcO'#CtO$gOSO'#CvO$uQbO'#ETOOQ`'#DP'#DPOOQa'#C|'#C|O%xQbO'#DUO&}QcO'#DxOOQa'#Dx'#DxO(TQcO'#DwO(lQRO'#CuO(zQcO'#DsO)cQbO'#CsOOQ`'#Dt'#DtO*ZQbO'#DsO*iQbO'#EZOOQ`'#DZ'#DZO+^QRO'#DcOOQ`'#Ds'#DsO+cQQO'#DrOOQ`'#Dr'#DrOOQ`'#Dd'#DdQYQbOOO+kObO,59]O+sQbO'#C}OOQa'#Dw'#DwOOQ`'#DX'#DXOOQ`'#EY'#EYOOQ`'#Dk'#DkO+}QbO,59[O,bQbO'#CxO,jQWO'#CyOOOO'#Dz'#DzOOOO'#De'#DeO-OOSO,59bOOQa,59b,59bOOQ`'#Dg'#DgO-^QbO'#DQO-fQQO,5:oOOQ`'#Df'#DfO-kQbO,59pO-rQQO,59hOOQa,59p,59pO-}QbO,59pO*iQbO,59aO*iQbO,59aO.XQRO,59_O/nQRO'#CuO0OQRO,59_O0[QQO,59_O0aQQO,59_O0iQbO'#DlO0tQbO,59ZO1VQRO,5:uO1^QQO,5:uO1cQbO,59}OOQ`,5:^,5:^OOQ`-E7b-E7bOOQa1G.w1G.wOOQ`,59i,59iOOQ`-E7i-E7iOOOO,59d,59dOOOO,59e,59eOOOO-E7c-E7cOOQa1G.|1G.|OOQ`-E7e-E7eO1mQbO1G0ZOOQ`-E7d-E7dO1zQQO1G/SOOQa1G/[1G/[O2VQbO1G/[OOQO'#Di'#DiO1zQQO1G/SOOQa1G/S1G/SOOQ`'#Dj'#DjO2VQbO1G/[OOQa1G.{1G.{O2aQcO1G.{OOQa1G.y1G.yO*iQbO,59rO*iQbO,59rO!`QbO'#CtO&PQbO'#CpOOQ`,5:W,5:WOOQ`-E7j-E7jO2{QbO1G0aOOQ`1G/i1G/iO3YQbO7+%uO3_QbO7+%vO3oQQO7+$nOOQa7+$n7+$nO3zQbO7+$vOOQa7+$v7+$vOOQO-E7g-E7gOOQ`-E7h-E7hOOQO1G/^1G/^O4UQRO1G/^OOQ`'#D]'#D]O4`QbO7+%{O4eQbO7+%|OOQ`<|AN>|O*iQbO'#D_OOQ`'#Dm'#DmO5xQbOAN?SO6TQQO'#DaOOQ`AN?SAN?SO6YQbOAN?SO6_QRO,59yO6fQQO,59yOOQ`-E7k-E7kOOQ`G24nG24nO6kQbOG24nO6pQQO,59{O6uQQO1G/eOOQ`LD*YLD*YO3_QbO1G/gO4eQbO7+%POOQ`7+%R7+%ROOQ`<}AN>}O*yQbO'#D`OOQ`'#Dn'#DnO7XQbOAN?TO7dQQO'#DbOOQ`AN?TAN?TO7iQbOAN?TO7nQRO,59zO7uQQO,59zOOQ`-E7l-E7lOOQ`G24oG24oO7zQbOG24oO8PQQO,59|O8UQQO1G/fOOQ`LD*ZLD*ZO4nQbO1G/hO5tQbO7+%QOOQ`7+%S7+%SOOQ`<h#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUkS!dYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UkS!vQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZkS!eYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!eYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!o~~'aO!m~U'hUkS!jQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUkS!{QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWkSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYkSfQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWkSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWkSfQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WkSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^kSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^kSoQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXoQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUoQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWkSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebkSoQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[kSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UkSuQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!uQkSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVkSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!tQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!p~U6aU!zQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUkSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUrQkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!rWkSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[kSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU|QkSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#P~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!i~~", 11)], - topRules: {"Program":[0,18]}, - specialized: [{term: 13, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 13, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1008 + tokenData: "AO~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P6T#P#Q6Y#Q#R#{#R#S6s#S#T#{#T#Y7^#Y#Z8l#Z#b7^#b#ch#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUlS!eYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UlS!wQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZlS!fYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!fYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!p~~'aO!n~U'hUlS!kQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUlS!|QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWlSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYlSgQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWlSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWlSgQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WlSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^lSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^lSpQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXpQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUpQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWlSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1eblSpQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[lSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UlSvQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!vQlSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVlSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!uQlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!q~U6aU!{QlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUlSxQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUsQlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!sWlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU}QlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#Q~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!j~~", 11)], + topRules: {"Program":[0,19]}, + specialized: [{term: 14, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 14, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], + tokenPrec: 1069 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index 0d870a8..f5e03f2 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -408,6 +408,15 @@ describe('BinOp', () => { `) }) + test('modulo tests', () => { + expect('4 % 3').toMatchTree(` + BinOp + Number 4 + Modulo % + Number 3 + `) + }) + test('mixed operations with precedence', () => { expect('2 + 3 * 4 - 5 / 1').toMatchTree(` BinOp From fd197a2dfc8138a3f638e66939e33231a1e0675c Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 11:13:30 -0700 Subject: [PATCH 29/44] fix or/and chaining --- src/parser/shrimp.grammar | 19 +++++--- src/parser/shrimp.ts | 8 +-- src/parser/tests/basics.test.ts | 86 +++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 9147110..794dc29 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -33,6 +33,9 @@ @precedence { pipe @left, + or @left, + and @left, + comparison @left, multiplicative @left, additive @left, call @@ -129,14 +132,14 @@ SingleLineThenBlock { } ConditionalOp { - expression Eq expression | - expression Neq expression | - expression Lt expression | - expression Lte expression | - expression Gt expression | - expression Gte expression | - expression And (expression | ConditionalOp) | - expression Or (expression | ConditionalOp) + expression !comparison Eq expression | + expression !comparison Neq expression | + expression !comparison Lt expression | + expression !comparison Lte expression | + expression !comparison Gt expression | + expression !comparison Gte expression | + (expression | ConditionalOp) !and And (expression | ConditionalOp) | + (expression | ConditionalOp) !or Or (expression | ConditionalOp) } Params { diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 5b4e228..642c46b 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -7,9 +7,9 @@ import {highlighting} from "./highlight" const spec_Identifier = {__proto__:null,end:78, null:84, if:94, elseif:102, else:106} export const parser = LRParser.deserialize({ version: 14, - states: "2lQYQbOOO!ZOpO'#CrO#mQcO'#CuO$jOSO'#CwO$xQbO'#EUOOQ`'#DQ'#DQOOQa'#C}'#C}O%{QbO'#DVO'QQcO'#DyOOQa'#Dy'#DyO(lQcO'#DxO(yQRO'#CvO)[QcO'#DtO)sQbO'#CtOOQ`'#Du'#DuO*kQbO'#DtO*yQbO'#E[OOQ`'#D['#D[O+nQRO'#DdOOQ`'#Dt'#DtO+sQQO'#DsOOQ`'#Ds'#DsOOQ`'#De'#DeQYQbOOO+{ObO,59^O,TQbO'#DOOOQa'#Dx'#DxOOQ`'#DY'#DYOOQ`'#EZ'#EZOOQ`'#Dl'#DlO,_QbO,59]O,rQbO'#CyO,zQWO'#CzOOOO'#D{'#D{OOOO'#Df'#DfO-`OSO,59cOOQa,59c,59cOOQ`'#Dh'#DhO-nQbO'#DRO-vQQO,5:pOOQ`'#Dg'#DgO-{QbO,59qO.SQQO,59iOOQa,59q,59qO._QbO,59qO*yQbO,59bO*yQbO,59bO*yQbO,59bO.iQRO,59`O/YQRO'#CvO/vQRO,59`O0SQQO,59`O0XQQO,59`O0aQbO'#DmO0lQbO,59[O1iQRO,5:vO1pQQO,5:vO1uQbO,5:OOOQ`,5:_,5:_OOQ`-E7c-E7cOOQa1G.x1G.xOOQ`,59j,59jOOQ`-E7j-E7jOOOO,59e,59eOOOO,59f,59fOOOO-E7d-E7dOOQa1G.}1G.}OOQ`-E7f-E7fO2PQbO1G0[OOQ`-E7e-E7eO2^QQO1G/TOOQa1G/]1G/]O2iQbO1G/]OOQO'#Dj'#DjO2^QQO1G/TOOQa1G/T1G/TOOQ`'#Dk'#DkO2iQbO1G/]OOQa1G.|1G.|O3[QcO1G.|O3fQcO1G.|O3pQcO1G.|OOQa1G.z1G.zO*yQbO,59sO*yQbO,59sO!`QbO'#CuO&SQbO'#CqOOQ`,5:X,5:XOOQ`-E7k-E7kO4[QbO1G0bOOQ`1G/j1G/jO4iQbO7+%vO4nQbO7+%wO5OQQO7+$oOOQa7+$o7+$oO5ZQbO7+$wOOQa7+$w7+$wOOQO-E7h-E7hOOQ`-E7i-E7iOOQO1G/_1G/_O5eQRO1G/_OOQ`'#D^'#D^O5oQbO7+%|O5tQbO7+%}OOQ`<}AN>}O*yQbO'#D`OOQ`'#Dn'#DnO7XQbOAN?TO7dQQO'#DbOOQ`AN?TAN?TO7iQbOAN?TO7nQRO,59zO7uQQO,59zOOQ`-E7l-E7lOOQ`G24oG24oO7zQbOG24oO8PQQO,59|O8UQQO1G/fOOQ`LD*ZLD*ZO4nQbO1G/hO5tQbO7+%QOOQ`7+%S7+%SOOQ`<}AN>}O*yQbO'#D`OOQ`'#Dn'#DnO8kQbOAN?TO8vQQO'#DbOOQ`AN?TAN?TO8{QbOAN?TO9QQRO,59zO9XQRO,59zOOQ`-E7l-E7lOOQ`G24oG24oO9dQbOG24oO9iQQO,59|O9nQQO1G/fOOQ`LD*ZLD*ZO5PQbO1G/hO7WQbO7+%QOOQ`7+%S7+%SOOQ`< (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 14, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1069 + tokenPrec: 1139 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index f5e03f2..80c3a07 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -592,4 +592,90 @@ describe('Comments', () => { Slash / Identifier prop`) }) +}) + +describe('Conditional ops', () => { + test('or can be chained', () => { + expect(` + is-positive = do x: + if x = 3 or x = 4 or x = 5: + true + end + end + `).toMatchTree(` +Assign + AssignableIdentifier is-positive + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + IfExpr + keyword if + ConditionalOp + ConditionalOp + ConditionalOp + Identifier x + Eq = + Number 3 + Or or + ConditionalOp + Identifier x + Eq = + Number 4 + Or or + ConditionalOp + Identifier x + Eq = + Number 5 + colon : + ThenBlock + Boolean true + keyword end + keyword end + `) + }) + + test('and can be chained', () => { + expect(` + is-positive = do x: + if x = 3 and x = 4 and x = 5: + true + end + end + `).toMatchTree(` +Assign + AssignableIdentifier is-positive + Eq = + FunctionDef + Do do + Params + Identifier x + colon : + IfExpr + keyword if + ConditionalOp + ConditionalOp + ConditionalOp + Identifier x + Eq = + Number 3 + And and + ConditionalOp + Identifier x + Eq = + Number 4 + And and + ConditionalOp + Identifier x + Eq = + Number 5 + colon : + ThenBlock + Boolean true + keyword end + keyword end + `) + }) }) \ No newline at end of file From 34305b473ef5bf37cc065dafae7767c6347825e4 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 11:18:47 -0700 Subject: [PATCH 30/44] switch back to using == --- src/compiler/compiler.ts | 2 +- src/compiler/tests/compiler.test.ts | 2 +- src/parser/operatorTokenizer.ts | 1 + src/parser/shrimp.grammar | 4 +- src/parser/shrimp.terms.ts | 87 ++++++++++++++------------- src/parser/shrimp.ts | 22 +++---- src/parser/tests/basics.test.ts | 16 ++--- src/parser/tests/control-flow.test.ts | 4 +- 8 files changed, 70 insertions(+), 68 deletions(-) diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 227cc8b..e6b006b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -366,7 +366,7 @@ export class Compiler { const opValue = input.slice(op.from, op.to) switch (opValue) { - case '=': + case '==': instructions.push(...leftInstructions, ...rightInstructions, ['EQ']) break diff --git a/src/compiler/tests/compiler.test.ts b/src/compiler/tests/compiler.test.ts index 5e58c11..ef02035 100644 --- a/src/compiler/tests/compiler.test.ts +++ b/src/compiler/tests/compiler.test.ts @@ -111,7 +111,7 @@ describe('compiler', () => { expect(`(10 > 20)`).toEvaluateTo(false) expect(`(4 <= 9)`).toEvaluateTo(true) expect(`(15 >= 20)`).toEvaluateTo(false) - expect(`(7 = 7)`).toEvaluateTo(true) + expect(`(7 == 7)`).toEvaluateTo(true) expect(`(5 != 5)`).toEvaluateTo(false) expect(`('shave' and 'haircut')`).toEvaluateTo('haircut') expect(`(false and witness)`).toEvaluateTo(false) diff --git a/src/parser/operatorTokenizer.ts b/src/parser/operatorTokenizer.ts index d15318c..ee1dc44 100644 --- a/src/parser/operatorTokenizer.ts +++ b/src/parser/operatorTokenizer.ts @@ -8,6 +8,7 @@ const operators: Array = [ { str: '>=', tokenName: 'Gte' }, { str: '<=', tokenName: 'Lte' }, { str: '!=', tokenName: 'Neq' }, + { str: '==', tokenName: 'EqEq' }, // // Single-char operators { str: '*', tokenName: 'Star' }, diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 794dc29..113cf01 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -6,7 +6,7 @@ @top Program { item* } -@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, Neq, Lt, Lte, Gt, Gte, Modulo } +@external tokens operatorTokenizer from "./operatorTokenizer" { Star, Slash, Plus, Minus, And, Or, Eq, EqEq, Neq, Lt, Lte, Gt, Gte, Modulo } @tokens { @precedence { Number Regex } @@ -132,7 +132,7 @@ SingleLineThenBlock { } ConditionalOp { - expression !comparison Eq expression | + expression !comparison EqEq expression | expression !comparison Neq expression | expression !comparison Lt expression | expression !comparison Lte expression | diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index eb818df..144d69b 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -7,46 +7,47 @@ export const And = 5, Or = 6, Eq = 7, - Neq = 8, - Lt = 9, - Lte = 10, - Gt = 11, - Gte = 12, - Modulo = 13, - Identifier = 14, - AssignableIdentifier = 15, - Word = 16, - IdentifierBeforeDot = 17, - Do = 18, - Program = 19, - PipeExpr = 20, - FunctionCall = 21, - DotGet = 22, - Number = 23, - ParenExpr = 24, - FunctionCallOrIdentifier = 25, - BinOp = 26, - String = 27, - StringFragment = 28, - Interpolation = 29, - EscapeSeq = 30, - Boolean = 31, - Regex = 32, - Dict = 33, - NamedArg = 34, - NamedArgPrefix = 35, - FunctionDef = 36, - Params = 37, - colon = 38, - keyword = 53, - Underscore = 40, - Array = 41, - Null = 42, - ConditionalOp = 43, - PositionalArg = 44, - IfExpr = 46, - SingleLineThenBlock = 48, - ThenBlock = 49, - ElseIfExpr = 50, - ElseExpr = 52, - Assign = 54 + EqEq = 8, + Neq = 9, + Lt = 10, + Lte = 11, + Gt = 12, + Gte = 13, + Modulo = 14, + Identifier = 15, + AssignableIdentifier = 16, + Word = 17, + IdentifierBeforeDot = 18, + Do = 19, + Program = 20, + PipeExpr = 21, + FunctionCall = 22, + DotGet = 23, + Number = 24, + ParenExpr = 25, + FunctionCallOrIdentifier = 26, + BinOp = 27, + String = 28, + StringFragment = 29, + Interpolation = 30, + EscapeSeq = 31, + Boolean = 32, + Regex = 33, + Dict = 34, + NamedArg = 35, + NamedArgPrefix = 36, + FunctionDef = 37, + Params = 38, + colon = 39, + keyword = 54, + Underscore = 41, + Array = 42, + Null = 43, + ConditionalOp = 44, + PositionalArg = 45, + IfExpr = 47, + SingleLineThenBlock = 49, + ThenBlock = 50, + ElseIfExpr = 51, + ElseExpr = 53, + Assign = 55 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 642c46b..a8cf6ee 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,24 +4,24 @@ import {operatorTokenizer} from "./operatorTokenizer" import {tokenizer, specializeKeyword} from "./tokenizer" import {trackScope} from "./scopeTracker" import {highlighting} from "./highlight" -const spec_Identifier = {__proto__:null,end:78, null:84, if:94, elseif:102, else:106} +const spec_Identifier = {__proto__:null,end:80, null:86, if:96, elseif:104, else:108} export const parser = LRParser.deserialize({ version: 14, - states: "3UQYQbOOO!ZOpO'#CrO#mQcO'#CuO$jOSO'#CwO$xQbO'#EUOOQ`'#DQ'#DQOOQa'#C}'#C}O%{QbO'#DVO'QQcO'#DyOOQa'#Dy'#DyO(lQcO'#DxO(yQRO'#CvO)[QcO'#DtO)sQbO'#CtOOQ`'#Du'#DuO*kQbO'#DtO*yQbO'#E[OOQ`'#D['#D[O+nQRO'#DdOOQ`'#Dt'#DtO+sQQO'#DsOOQ`'#Ds'#DsOOQ`'#De'#DeQYQbOOO+{ObO,59^O,TQbO'#DOOOQa'#Dx'#DxOOQ`'#DY'#DYOOQ`'#EZ'#EZOOQ`'#Dl'#DlO,_QbO,59]O,rQbO'#CyO,zQWO'#CzOOOO'#D{'#D{OOOO'#Df'#DfO-`OSO,59cOOQa,59c,59cOOQ`'#Dh'#DhO-nQbO'#DRO-vQQO,5:pOOQ`'#Dg'#DgO-{QbO,59qO.SQQO,59iOOQa,59q,59qO._QbO,59qO*yQbO,59bO*yQbO,59bO*yQbO,59bO.iQRO,59`O/YQRO'#CvO/vQRO,59`O0XQRO,59`O0SQQO,59`O0dQQO,59`O0lQbO'#DmO0wQbO,59[O1tQRO,5:vO1{QRO,5:vO2WQbO,5:OOOQ`,5:_,5:_OOQ`-E7c-E7cOOQa1G.x1G.xOOQ`,59j,59jOOQ`-E7j-E7jOOOO,59e,59eOOOO,59f,59fOOOO-E7d-E7dOOQa1G.}1G.}OOQ`-E7f-E7fO2bQbO1G0[OOQ`-E7e-E7eO2oQQO1G/TOOQa1G/]1G/]O2zQbO1G/]OOQO'#Dj'#DjO2oQQO1G/TOOQa1G/T1G/TOOQ`'#Dk'#DkO2zQbO1G/]OOQa1G.|1G.|O3mQcO1G.|O3wQcO1G.|O4RQcO1G.|OOQa1G.z1G.zO*yQbO,59sO*yQbO,59sO*yQbO,59sO!`QbO'#CuO&SQbO'#CqOOQ`,5:X,5:XOOQ`-E7k-E7kO4mQbO1G0bOOQ`1G/j1G/jO4zQbO7+%vO5PQbO7+%wO5aQQO7+$oOOQa7+$o7+$oO5lQbO7+$wOOQa7+$w7+$wOOQO-E7h-E7hOOQ`-E7i-E7iOOQP1G/_1G/_O6eQRO1G/_O6lQRO1G/_O6zQRO1G/_OOQ`'#D^'#D^O7RQbO7+%|O7WQbO7+%}OOQ`<}AN>}O*yQbO'#D`OOQ`'#Dn'#DnO8kQbOAN?TO8vQQO'#DbOOQ`AN?TAN?TO8{QbOAN?TO9QQRO,59zO9XQRO,59zOOQ`-E7l-E7lOOQ`G24oG24oO9dQbOG24oO9iQQO,59|O9nQQO1G/fOOQ`LD*ZLD*ZO5PQbO1G/hO7WQbO7+%QOOQ`7+%S7+%SOOQ`<h#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUlS!eYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UlS!wQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZlS!fYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!fYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!p~~'aO!n~U'hUlS!kQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUlS!|QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWlSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYlSgQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWlSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWlSgQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WlSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^lSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^lSpQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXpQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUpQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWlSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1eblSpQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[lSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UlSvQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!vQlSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVlSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!uQlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!q~U6aU!{QlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUlSxQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUsQlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!sWlSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[lSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU}QlSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#Q~", - tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!j~~", 11)], - topRules: {"Program":[0,19]}, - specialized: [{term: 14, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 14, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], + tokenData: "AO~R|OX#{XY$jYZ%TZp#{pq$jqs#{st%ntu'Vuw#{wx'[xy'ayz'zz{#{{|(e|}#{}!O(e!O!P#{!P!Q+X!Q![)S![!]3t!]!^%T!^!}#{!}#O4_#O#P6T#P#Q6Y#Q#R#{#R#S6s#S#T#{#T#Y7^#Y#Z8l#Z#b7^#b#ch#i#o7^#o#p#{#p#q@`#q;'S#{;'S;=`$d<%l~#{~O#{~~@yS$QUmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{S$gP;=`<%l#{^$qUmS!fYOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U%[UmS!xQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{^%uZmS!gYOY%nYZ#{Zt%ntu&huw%nwx&hx#O%n#O#P&h#P;'S%n;'S;=`'P<%lO%nY&mS!gYOY&hZ;'S&h;'S;=`&y<%lO&hY&|P;=`<%l&h^'SP;=`<%l%n~'[O!q~~'aO!o~U'hUmS!lQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(RUmS!}QOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U(jWmSOt#{uw#{x!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U)ZYmShQOt#{uw#{x!O#{!O!P)y!P!Q#{!Q![)S![#O#{#P;'S#{;'S;=`$d<%lO#{U*OWmSOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U*oWmShQOt#{uw#{x!Q#{!Q![*h![#O#{#P;'S#{;'S;=`$d<%lO#{U+^WmSOt#{uw#{x!P#{!P!Q+v!Q#O#{#P;'S#{;'S;=`$d<%lO#{U+{^mSOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q#{!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wU-O^mSqQOY,wYZ#{Zt,wtu-zuw,wwx-zx!P,w!P!Q0o!Q!},w!}#O2m#O#P0Y#P;'S,w;'S;=`3n<%lO,wQ.PXqQOY-zZ!P-z!P!Q.l!Q!}-z!}#O/Z#O#P0Y#P;'S-z;'S;=`0i<%lO-zQ.oP!P!Q.rQ.wUqQ#Z#[.r#]#^.r#a#b.r#g#h.r#i#j.r#m#n.rQ/^VOY/ZZ#O/Z#O#P/s#P#Q-z#Q;'S/Z;'S;=`0S<%lO/ZQ/vSOY/ZZ;'S/Z;'S;=`0S<%lO/ZQ0VP;=`<%l/ZQ0]SOY-zZ;'S-z;'S;=`0i<%lO-zQ0lP;=`<%l-zU0tWmSOt#{uw#{x!P#{!P!Q1^!Q#O#{#P;'S#{;'S;=`$d<%lO#{U1ebmSqQOt#{uw#{x#O#{#P#Z#{#Z#[1^#[#]#{#]#^1^#^#a#{#a#b1^#b#g#{#g#h1^#h#i#{#i#j1^#j#m#{#m#n1^#n;'S#{;'S;=`$d<%lO#{U2r[mSOY2mYZ#{Zt2mtu/Zuw2mwx/Zx#O2m#O#P/s#P#Q,w#Q;'S2m;'S;=`3h<%lO2mU3kP;=`<%l2mU3qP;=`<%l,wU3{UmSwQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U4fW!wQmSOt#{uw#{x!_#{!_!`5O!`#O#{#P;'S#{;'S;=`$d<%lO#{U5TVmSOt#{uw#{x#O#{#P#Q5j#Q;'S#{;'S;=`$d<%lO#{U5qU!vQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~6YO!r~U6aU!|QmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U6zUmSyQOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U7cYmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#o7^#o;'S#{;'S;=`$d<%lO#{U8YUtQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{U8qZmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#U9d#U#o7^#o;'S#{;'S;=`$d<%lO#{U9i[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#`7^#`#a:_#a#o7^#o;'S#{;'S;=`$d<%lO#{U:d[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#g7^#g#h;Y#h#o7^#o;'S#{;'S;=`$d<%lO#{U;_[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#X7^#X#Yo[!tWmSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#f7^#f#g?e#g#o7^#o;'S#{;'S;=`$d<%lO#{U?j[mSOt#{uw#{x!_#{!_!`8R!`#O#{#P#T#{#T#i7^#i#j;Y#j#o7^#o;'S#{;'S;=`$d<%lO#{U@gU!OQmSOt#{uw#{x#O#{#P;'S#{;'S;=`$d<%lO#{~AOO#R~", + tokenizers: [operatorTokenizer, 1, 2, 3, tokenizer, new LocalTokenGroup("[~RP!O!PU~ZO!k~~", 11)], + topRules: {"Program":[0,20]}, + specialized: [{term: 15, get: (value: any, stack: any) => (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], tokenPrec: 1139 }) diff --git a/src/parser/tests/basics.test.ts b/src/parser/tests/basics.test.ts index 80c3a07..da6d4bb 100644 --- a/src/parser/tests/basics.test.ts +++ b/src/parser/tests/basics.test.ts @@ -598,7 +598,7 @@ describe('Conditional ops', () => { test('or can be chained', () => { expect(` is-positive = do x: - if x = 3 or x = 4 or x = 5: + if x == 3 or x == 4 or x == 5: true end end @@ -617,17 +617,17 @@ Assign ConditionalOp ConditionalOp Identifier x - Eq = + EqEq == Number 3 Or or ConditionalOp Identifier x - Eq = + EqEq == Number 4 Or or ConditionalOp Identifier x - Eq = + EqEq == Number 5 colon : ThenBlock @@ -640,7 +640,7 @@ Assign test('and can be chained', () => { expect(` is-positive = do x: - if x = 3 and x = 4 and x = 5: + if x == 3 and x == 4 and x == 5: true end end @@ -659,17 +659,17 @@ Assign ConditionalOp ConditionalOp Identifier x - Eq = + EqEq == Number 3 And and ConditionalOp Identifier x - Eq = + EqEq == Number 4 And and ConditionalOp Identifier x - Eq = + EqEq == Number 5 colon : ThenBlock diff --git a/src/parser/tests/control-flow.test.ts b/src/parser/tests/control-flow.test.ts index 11c81d0..70efddb 100644 --- a/src/parser/tests/control-flow.test.ts +++ b/src/parser/tests/control-flow.test.ts @@ -4,12 +4,12 @@ import '../shrimp.grammar' // Importing this so changes cause it to retest! describe('if/elseif/else', () => { test('parses single line if', () => { - expect(`if y = 1: 'cool' end`).toMatchTree(` + expect(`if y == 1: 'cool' end`).toMatchTree(` IfExpr keyword if ConditionalOp Identifier y - Eq = + EqEq == Number 1 colon : SingleLineThenBlock From 0dbba4d8472a518d97156e41cbd57dfe7ec2019e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 11:21:16 -0700 Subject: [PATCH 31/44] allow conditionals at statement-level --- src/parser/shrimp.grammar | 1 + src/parser/shrimp.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index 113cf01..f74f5ea 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -55,6 +55,7 @@ consumeToTerminator { FunctionDef | Assign | BinOp | + ConditionalOp | expressionWithoutIdentifier } diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index a8cf6ee..da34621 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -7,9 +7,9 @@ import {highlighting} from "./highlight" const spec_Identifier = {__proto__:null,end:80, null:86, if:96, elseif:104, else:108} export const parser = LRParser.deserialize({ version: 14, - states: "3UQYQbOOO!ZOpO'#CsO#mQcO'#CvO$jOSO'#CxO$xQbO'#EVOOQ`'#DR'#DROOQa'#DO'#DOO%{QbO'#DWO'QQcO'#DzOOQa'#Dz'#DzO(lQcO'#DyO(yQRO'#CwO)[QcO'#DuO)sQbO'#CuOOQ`'#Dv'#DvO*kQbO'#DuO*yQbO'#E]OOQ`'#D]'#D]O+nQRO'#DeOOQ`'#Du'#DuO+sQQO'#DtOOQ`'#Dt'#DtOOQ`'#Df'#DfQYQbOOO+{ObO,59_O,TQbO'#DPOOQa'#Dy'#DyOOQ`'#DZ'#DZOOQ`'#E['#E[OOQ`'#Dm'#DmO,_QbO,59^O,rQbO'#CzO,zQWO'#C{OOOO'#D|'#D|OOOO'#Dg'#DgO-`OSO,59dOOQa,59d,59dOOQ`'#Di'#DiO-nQbO'#DSO-vQQO,5:qOOQ`'#Dh'#DhO-{QbO,59rO.SQQO,59jOOQa,59r,59rO._QbO,59rO*yQbO,59cO*yQbO,59cO*yQbO,59cO.iQRO,59aO/YQRO'#CwO/vQRO,59aO0XQRO,59aO0SQQO,59aO0dQQO,59aO0lQbO'#DnO0wQbO,59]O1tQRO,5:wO1{QRO,5:wO2WQbO,5:POOQ`,5:`,5:`OOQ`-E7d-E7dOOQa1G.y1G.yOOQ`,59k,59kOOQ`-E7k-E7kOOOO,59f,59fOOOO,59g,59gOOOO-E7e-E7eOOQa1G/O1G/OOOQ`-E7g-E7gO2bQbO1G0]OOQ`-E7f-E7fO2oQQO1G/UOOQa1G/^1G/^O2zQbO1G/^OOQO'#Dk'#DkO2oQQO1G/UOOQa1G/U1G/UOOQ`'#Dl'#DlO2zQbO1G/^OOQa1G.}1G.}O3mQcO1G.}O3wQcO1G.}O4RQcO1G.}OOQa1G.{1G.{O*yQbO,59tO*yQbO,59tO*yQbO,59tO!`QbO'#CvO&SQbO'#CrOOQ`,5:Y,5:YOOQ`-E7l-E7lO4mQbO1G0cOOQ`1G/k1G/kO4zQbO7+%wO5PQbO7+%xO5aQQO7+$pOOQa7+$p7+$pO5lQbO7+$xOOQa7+$x7+$xOOQO-E7i-E7iOOQ`-E7j-E7jOOQP1G/`1G/`O6eQRO1G/`O6lQRO1G/`O6zQRO1G/`OOQ`'#D_'#D_O7RQbO7+%}O7WQbO7+&OOOQ`< (specializeKeyword(value, stack) << 1), external: specializeKeyword},{term: 15, get: (value: keyof typeof spec_Identifier) => spec_Identifier[value] || -1}], - tokenPrec: 1139 + tokenPrec: 1132 }) From 51b64da10676a83f8b3cfdc94b87ec41293891ff Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 11:21:24 -0700 Subject: [PATCH 32/44] update conditional tests --- src/prelude/tests/prelude.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 89969dd..8f3d194 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -164,9 +164,7 @@ describe('collections', () => { await expect(`empty? meow`).toEvaluateTo(false, globalFunctions) }) - // TODO: These tests fail due to parser/compiler issues with == operator in function bodies - // The functions themselves work correctly when passed native JS functions - test.skip('list.filter keeps matching elements', async () => { + test('list.filter keeps matching elements', async () => { await expect(` is-positive = do x: x == 3 or x == 4 or x == 5 @@ -184,7 +182,7 @@ describe('collections', () => { `).toEvaluateTo(10, globalFunctions) }) - test.skip('list.find returns first match', async () => { + test('list.find returns first match', async () => { await expect(` is-four = do x: x == 4 @@ -193,11 +191,9 @@ describe('collections', () => { `).toEvaluateTo(4, globalFunctions) }) - test.skip('list.find returns null if no match', async () => { + test('list.find returns null if no match', async () => { await expect(` - is-ten = do x: - x == 10 - end + is-ten = do x: x == 10 end list.find [1 2 3] is-ten `).toEvaluateTo(null, globalFunctions) }) From 3c06cac36c695d7154623277cce82fe9fb2342b0 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 11:34:04 -0700 Subject: [PATCH 33/44] more prelude functions --- src/prelude/index.ts | 83 +++++++++++++++ src/prelude/tests/prelude.test.ts | 165 ++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 8eb9bb3..952bca9 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -43,6 +43,27 @@ export const globalFunctions = { } }, + // type predicates + 'string?': (v: any) => toValue(v).type === 'string', + 'number?': (v: any) => toValue(v).type === 'number', + 'boolean?': (v: any) => toValue(v).type === 'boolean', + 'array?': (v: any) => toValue(v).type === 'array', + 'dict?': (v: any) => toValue(v).type === 'dict', + 'function?': (v: any) => { + const t = toValue(v).type + return t === 'function' || t === 'native' + }, + 'null?': (v: any) => toValue(v).type === 'null', + 'some?': (v: any) => toValue(v).type !== 'null', + + // boolean/logic + not: (v: any) => !v, + + // utilities + inc: (n: number) => n + 1, + dec: (n: number) => n - 1, + identity: (v: any) => v, + // strings str: { join: (arr: string[], sep: string = ',') => arr.join(sep), @@ -65,6 +86,10 @@ export const globalFunctions = { 'pad-end': (str: string, length: number, pad: string = ' ') => str.padEnd(length, pad), lines: (str: string) => str.split('\n'), chars: (str: string) => str.split(''), + 'index-of': (str: string, search: string) => str.indexOf(search), + 'last-index-of': (str: string, search: string) => str.lastIndexOf(search), + match: (str: string, regex: RegExp) => str.match(regex), + 'test?': (str: string, regex: RegExp) => regex.test(str), }, // list @@ -96,6 +121,18 @@ export const globalFunctions = { // predicates 'empty?': (list: any[]) => list.length === 0, 'contains?': (list: any[], item: any) => list.includes(item), + 'any?': async (list: any[], cb: Function) => { + for (const value of list) { + if (await cb(value)) return true + } + return false + }, + 'all?': async (list: any[], cb: Function) => { + for (const value of list) { + if (!await cb(value)) return false + } + return true + }, // sequence operations reverse: (list: any[]) => list.slice().reverse(), sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb), @@ -112,6 +149,34 @@ export const globalFunctions = { append: (list: any[], item: any) => [...list, item], prepend: (list: any[], item: any) => [item, ...list], 'index-of': (list: any[], item: any) => list.indexOf(item), + // utilities + sum: (list: any[]) => list.reduce((acc, x) => acc + x, 0), + count: async (list: any[], cb: Function) => { + let count = 0 + for (const value of list) { + if (await cb(value)) count++ + } + return count + }, + partition: async (list: any[], cb: Function) => { + const truthy: any[] = [] + const falsy: any[] = [] + for (const value of list) { + if (await cb(value)) truthy.push(value) + else falsy.push(value) + } + return [truthy, falsy] + }, + compact: (list: any[]) => list.filter(x => x != null), + 'group-by': async (list: any[], cb: Function) => { + const groups: Record = {} + for (const value of list) { + const key = String(await cb(value)) + if (!groups[key]) groups[key] = [] + groups[key].push(value) + } + return groups + }, }, // collections @@ -148,6 +213,21 @@ export const globalFunctions = { get: (dict: Record, key: string, defaultValue: any = null) => dict[key] ?? defaultValue, merge: (...dicts: Record[]) => Object.assign({}, ...dicts), 'empty?': (dict: Record) => Object.keys(dict).length === 0, + map: async (dict: Record, cb: Function) => { + const result: Record = {} + for (const [key, value] of Object.entries(dict)) { + result[key] = await cb(value, key) + } + return result + }, + filter: async (dict: Record, cb: Function) => { + const result: Record = {} + for (const [key, value] of Object.entries(dict)) { + if (await cb(value, key)) result[key] = value + } + return result + }, + 'from-entries': (entries: [string, any][]) => Object.fromEntries(entries), }, // math @@ -161,6 +241,9 @@ export const globalFunctions = { pow: (base: number, exp: number) => Math.pow(base, exp), sqrt: (n: number) => Math.sqrt(n), random: () => Math.random(), + clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max), + sign: (n: number) => Math.sign(n), + trunc: (n: number) => Math.trunc(n), // predicates 'even?': (n: number) => n % 2 === 0, 'odd?': (n: number) => n % 2 !== 0, diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 8f3d194..358dfae 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -87,6 +87,78 @@ describe('string operations', () => { test('chars splits into characters', async () => { await expect(`str.chars 'abc'`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) }) + + test('index-of finds substring position', async () => { + await expect(`str.index-of 'hello world' 'world'`).toEvaluateTo(6, globalFunctions) + await expect(`str.index-of 'hello' 'bye'`).toEvaluateTo(-1, globalFunctions) + }) + + test('last-index-of finds last occurrence', async () => { + await expect(`str.last-index-of 'hello hello' 'hello'`).toEvaluateTo(6, globalFunctions) + }) +}) + +describe('type predicates', () => { + test('string? checks for string type', async () => { + await expect(`string? 'hello'`).toEvaluateTo(true, globalFunctions) + await expect(`string? 42`).toEvaluateTo(false, globalFunctions) + }) + + test('number? checks for number type', async () => { + await expect(`number? 42`).toEvaluateTo(true, globalFunctions) + await expect(`number? 'hello'`).toEvaluateTo(false, globalFunctions) + }) + + test('boolean? checks for boolean type', async () => { + await expect(`boolean? true`).toEvaluateTo(true, globalFunctions) + await expect(`boolean? 42`).toEvaluateTo(false, globalFunctions) + }) + + test('array? checks for array type', async () => { + await expect(`array? [1 2 3]`).toEvaluateTo(true, globalFunctions) + await expect(`array? 42`).toEvaluateTo(false, globalFunctions) + }) + + test('dict? checks for dict type', async () => { + await expect(`dict? [a=1]`).toEvaluateTo(true, globalFunctions) + await expect(`dict? []`).toEvaluateTo(false, globalFunctions) + }) + + test('null? checks for null type', async () => { + await expect(`null? null`).toEvaluateTo(true, globalFunctions) + await expect(`null? 42`).toEvaluateTo(false, globalFunctions) + }) + + test('some? checks for non-null', async () => { + await expect(`some? 42`).toEvaluateTo(true, globalFunctions) + await expect(`some? null`).toEvaluateTo(false, globalFunctions) + }) +}) + +describe('boolean logic', () => { + test('not negates value', async () => { + await expect(`not true`).toEvaluateTo(false, globalFunctions) + await expect(`not false`).toEvaluateTo(true, globalFunctions) + await expect(`not 42`).toEvaluateTo(false, globalFunctions) + await expect(`not null`).toEvaluateTo(true, globalFunctions) + }) +}) + +describe('utilities', () => { + test('inc increments by 1', async () => { + await expect(`inc 5`).toEvaluateTo(6, globalFunctions) + await expect(`inc -1`).toEvaluateTo(0, globalFunctions) + }) + + test('dec decrements by 1', async () => { + await expect(`dec 5`).toEvaluateTo(4, globalFunctions) + await expect(`dec 0`).toEvaluateTo(-1, globalFunctions) + }) + + test('identity returns value as-is', async () => { + await expect(`identity 42`).toEvaluateTo(42, globalFunctions) + await expect(`identity 'hello'`).toEvaluateTo('hello', globalFunctions) + }) }) describe('introspection', () => { @@ -262,6 +334,64 @@ describe('collections', () => { await expect(`list.index-of [1 2 3] 2`).toEvaluateTo(1, globalFunctions) await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globalFunctions) }) + + test('list.any? checks if any element matches', async () => { + await expect(` + gt-three = do x: x > 3 end + list.any? [1 2 4 5] gt-three + `).toEvaluateTo(true, globalFunctions) + await expect(` + gt-ten = do x: x > 10 end + list.any? [1 2 3] gt-ten + `).toEvaluateTo(false, globalFunctions) + }) + + test('list.all? checks if all elements match', async () => { + await expect(` + positive = do x: x > 0 end + list.all? [1 2 3] positive + `).toEvaluateTo(true, globalFunctions) + await expect(` + positive = do x: x > 0 end + list.all? [1 -2 3] positive + `).toEvaluateTo(false, globalFunctions) + }) + + test('list.sum adds all numbers', async () => { + await expect(`list.sum [1 2 3 4]`).toEvaluateTo(10, globalFunctions) + await expect(`list.sum []`).toEvaluateTo(0, globalFunctions) + }) + + test('list.count counts matching elements', async () => { + await expect(` + gt-two = do x: x > 2 end + list.count [1 2 3 4 5] gt-two + `).toEvaluateTo(3, globalFunctions) + }) + + test('list.partition splits array by predicate', async () => { + await expect(` + gt-two = do x: x > 2 end + list.partition [1 2 3 4 5] gt-two + `).toEvaluateTo([[3, 4, 5], [1, 2]], globalFunctions) + }) + + test('list.compact removes null values', async () => { + await expect(`list.compact [1 null 2 null 3]`).toEvaluateTo([1, 2, 3], globalFunctions) + }) + + test('list.group-by groups by key function', async () => { + await expect(` + get-type = do x: + if (string? x): + 'str' + else: + 'num' + end + end + list.group-by ['a' 1 'b' 2] get-type + `).toEvaluateTo({ str: ['a', 'b'], num: [1, 2] }, globalFunctions) + }) }) describe('enumerables', () => { @@ -343,6 +473,24 @@ describe('dict operations', () => { test('dict.merge combines dicts', async () => { await expect(`dict.merge [a=1] [b=2]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) }) + + test('dict.map transforms values', async () => { + await expect(` + double = do v k: v * 2 end + dict.map [a=1 b=2] double + `).toEvaluateTo({ a: 2, b: 4 }, globalFunctions) + }) + + test('dict.filter keeps matching entries', async () => { + await expect(` + gt-one = do v k: v > 1 end + dict.filter [a=1 b=2 c=3] gt-one + `).toEvaluateTo({ b: 2, c: 3 }, globalFunctions) + }) + + test('dict.from-entries creates dict from array', async () => { + await expect(`dict.from-entries [['a' 1] ['b' 2]]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + }) }) describe('math operations', () => { @@ -405,6 +553,23 @@ describe('math operations', () => { await expect(`math.zero? 0`).toEvaluateTo(true, globalFunctions) await expect(`math.zero? 5`).toEvaluateTo(false, globalFunctions) }) + + test('math.clamp restricts value to range', async () => { + await expect(`math.clamp 5 0 10`).toEvaluateTo(5, globalFunctions) + await expect(`math.clamp -5 0 10`).toEvaluateTo(0, globalFunctions) + await expect(`math.clamp 15 0 10`).toEvaluateTo(10, globalFunctions) + }) + + test('math.sign returns sign of number', async () => { + await expect(`math.sign 5`).toEvaluateTo(1, globalFunctions) + await expect(`math.sign -5`).toEvaluateTo(-1, globalFunctions) + await expect(`math.sign 0`).toEvaluateTo(0, globalFunctions) + }) + + test('math.trunc truncates decimal', async () => { + await expect(`math.trunc 3.7`).toEvaluateTo(3, globalFunctions) + await expect(`math.trunc -3.7`).toEvaluateTo(-3, globalFunctions) + }) }) // describe('echo', () => { From 9e38fa7a44e3c05a1c1524a44909cb89435b5c9b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:04:14 -0700 Subject: [PATCH 34/44] globalFunctions => globals --- bun.lock | 2 +- ha.sh | 8 + src/prelude/index.ts | 2 +- src/prelude/tests/load.ts | 16 +- src/prelude/tests/prelude.test.ts | 346 +++++++++++++++--------------- 5 files changed, 191 insertions(+), 183 deletions(-) create mode 100644 ha.sh diff --git a/bun.lock b/bun.lock index 7561a48..1180308 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#e54207067734d2186cd788c3654b675b493c2585", { "peerDependencies": { "typescript": "^5" } }, "e54207067734d2186cd788c3654b675b493c2585"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#052f989e82430db638c649e91960bd8ce3cf6ceb", { "peerDependencies": { "typescript": "^5" } }, "052f989e82430db638c649e91960bd8ce3cf6ceb"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], diff --git a/ha.sh b/ha.sh new file mode 100644 index 0000000..7ba4927 --- /dev/null +++ b/ha.sh @@ -0,0 +1,8 @@ +bob = [ name= Bob age= 44 ] +mike = [ + name= Mike + age= 46 +] + +echo bob +echo (mike | at name) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 952bca9..145aacd 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -21,7 +21,7 @@ export const colors = { pink: '\x1b[38;2;255;105;180m' } -export const globalFunctions = { +export const globals = { // hello echo: (...args: any[]) => { console.log(...args.map(a => { diff --git a/src/prelude/tests/load.ts b/src/prelude/tests/load.ts index 374211f..7ce8172 100644 --- a/src/prelude/tests/load.ts +++ b/src/prelude/tests/load.ts @@ -1,42 +1,42 @@ import { expect, describe, test } from 'bun:test' -import { globalFunctions } from '#prelude' +import { globals } from '#prelude' describe('use', () => { test(`imports all a file's functions`, async () => { expect(` math = load ./src/prelude/tests/math math.double 4 - `).toEvaluateTo(8, globalFunctions) + `).toEvaluateTo(8, globals) expect(` math = load ./src/prelude/tests/math math.double (math.double 4) - `).toEvaluateTo(16, globalFunctions) + `).toEvaluateTo(16, globals) expect(` math = load ./src/prelude/tests/math dbl = math.double dbl (dbl 2) - `).toEvaluateTo(8, globalFunctions) + `).toEvaluateTo(8, globals) expect(` math = load ./src/prelude/tests/math math.pi - `).toEvaluateTo(3.14, globalFunctions) + `).toEvaluateTo(3.14, globals) expect(` math = load ./src/prelude/tests/math math | at 🥧 - `).toEvaluateTo(3.14159265359, globalFunctions) + `).toEvaluateTo(3.14159265359, globals) expect(` math = load ./src/prelude/tests/math math.🥧 - `).toEvaluateTo(3.14159265359, globalFunctions) + `).toEvaluateTo(3.14159265359, globals) expect(` math = load ./src/prelude/tests/math math.add1 5 - `).toEvaluateTo(6, globalFunctions) + `).toEvaluateTo(6, globals) }) }) diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 358dfae..fc19e0d 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -1,239 +1,239 @@ import { expect, describe, test } from 'bun:test' -import { globalFunctions } from '#prelude' +import { globals } from '#prelude' describe('string operations', () => { test('to-upper converts to uppercase', async () => { - await expect(`str.to-upper 'hello'`).toEvaluateTo('HELLO', globalFunctions) - await expect(`str.to-upper 'Hello World!'`).toEvaluateTo('HELLO WORLD!', globalFunctions) + await expect(`str.to-upper 'hello'`).toEvaluateTo('HELLO', globals) + await expect(`str.to-upper 'Hello World!'`).toEvaluateTo('HELLO WORLD!', globals) }) test('to-lower converts to lowercase', async () => { - await expect(`str.to-lower 'HELLO'`).toEvaluateTo('hello', globalFunctions) - await expect(`str.to-lower 'Hello World!'`).toEvaluateTo('hello world!', globalFunctions) + await expect(`str.to-lower 'HELLO'`).toEvaluateTo('hello', globals) + await expect(`str.to-lower 'Hello World!'`).toEvaluateTo('hello world!', globals) }) test('trim removes whitespace', async () => { - await expect(`str.trim ' hello '`).toEvaluateTo('hello', globalFunctions) - await expect(`str.trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello', globalFunctions) + await expect(`str.trim ' hello '`).toEvaluateTo('hello', globals) + await expect(`str.trim '\\n\\thello\\t\\n'`).toEvaluateTo('hello', globals) }) test('split divides string by separator', async () => { - await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) - await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'], globalFunctions) + await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globals) + await expect(`str.split 'hello' ''`).toEvaluateTo(['h', 'e', 'l', 'l', 'o'], globals) }) test('split with comma separator', async () => { - await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`str.split 'a,b,c' ','`).toEvaluateTo(['a', 'b', 'c'], globals) }) test('join combines array elements', async () => { - await expect(`str.join ['a' 'b' 'c'] '-'`).toEvaluateTo('a-b-c', globalFunctions) - await expect(`str.join ['hello' 'world'] ' '`).toEvaluateTo('hello world', globalFunctions) + await expect(`str.join ['a' 'b' 'c'] '-'`).toEvaluateTo('a-b-c', globals) + await expect(`str.join ['hello' 'world'] ' '`).toEvaluateTo('hello world', globals) }) test('join with comma separator', async () => { - await expect(`str.join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globalFunctions) + await expect(`str.join ['a' 'b' 'c'] ','`).toEvaluateTo('a,b,c', globals) }) test('starts-with? checks string prefix', async () => { - await expect(`str.starts-with? 'hello' 'hel'`).toEvaluateTo(true, globalFunctions) - await expect(`str.starts-with? 'hello' 'bye'`).toEvaluateTo(false, globalFunctions) + await expect(`str.starts-with? 'hello' 'hel'`).toEvaluateTo(true, globals) + await expect(`str.starts-with? 'hello' 'bye'`).toEvaluateTo(false, globals) }) test('ends-with? checks string suffix', async () => { - await expect(`str.ends-with? 'hello' 'lo'`).toEvaluateTo(true, globalFunctions) - await expect(`str.ends-with? 'hello' 'he'`).toEvaluateTo(false, globalFunctions) + await expect(`str.ends-with? 'hello' 'lo'`).toEvaluateTo(true, globals) + await expect(`str.ends-with? 'hello' 'he'`).toEvaluateTo(false, globals) }) test('contains? checks for substring', async () => { - await expect(`str.contains? 'hello world' 'o w'`).toEvaluateTo(true, globalFunctions) - await expect(`str.contains? 'hello' 'bye'`).toEvaluateTo(false, globalFunctions) + await expect(`str.contains? 'hello world' 'o w'`).toEvaluateTo(true, globals) + await expect(`str.contains? 'hello' 'bye'`).toEvaluateTo(false, globals) }) test('empty? checks if string is empty', async () => { - await expect(`str.empty? ''`).toEvaluateTo(true, globalFunctions) - await expect(`str.empty? 'hello'`).toEvaluateTo(false, globalFunctions) + await expect(`str.empty? ''`).toEvaluateTo(true, globals) + await expect(`str.empty? 'hello'`).toEvaluateTo(false, globals) }) test('replace replaces first occurrence', async () => { - await expect(`str.replace 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hello', globalFunctions) + await expect(`str.replace 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hello', globals) }) test('replace-all replaces all occurrences', async () => { - await expect(`str.replace-all 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hi', globalFunctions) + await expect(`str.replace-all 'hello hello' 'hello' 'hi'`).toEvaluateTo('hi hi', globals) }) test('slice extracts substring', async () => { - await expect(`str.slice 'hello' 1 3`).toEvaluateTo('el', globalFunctions) - await expect(`str.slice 'hello' 2 null`).toEvaluateTo('llo', globalFunctions) + await expect(`str.slice 'hello' 1 3`).toEvaluateTo('el', globals) + await expect(`str.slice 'hello' 2 null`).toEvaluateTo('llo', globals) }) test('repeat repeats string', async () => { - await expect(`str.repeat 'ha' 3`).toEvaluateTo('hahaha', globalFunctions) + await expect(`str.repeat 'ha' 3`).toEvaluateTo('hahaha', globals) }) test('pad-start pads beginning', async () => { - await expect(`str.pad-start '5' 3 '0'`).toEvaluateTo('005', globalFunctions) + await expect(`str.pad-start '5' 3 '0'`).toEvaluateTo('005', globals) }) test('pad-end pads end', async () => { - await expect(`str.pad-end '5' 3 '0'`).toEvaluateTo('500', globalFunctions) + await expect(`str.pad-end '5' 3 '0'`).toEvaluateTo('500', globals) }) test('lines splits by newlines', async () => { - await expect(`str.lines 'a\\nb\\nc'`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`str.lines 'a\\nb\\nc'`).toEvaluateTo(['a', 'b', 'c'], globals) }) test('chars splits into characters', async () => { - await expect(`str.chars 'abc'`).toEvaluateTo(['a', 'b', 'c'], globalFunctions) + await expect(`str.chars 'abc'`).toEvaluateTo(['a', 'b', 'c'], globals) }) test('index-of finds substring position', async () => { - await expect(`str.index-of 'hello world' 'world'`).toEvaluateTo(6, globalFunctions) - await expect(`str.index-of 'hello' 'bye'`).toEvaluateTo(-1, globalFunctions) + await expect(`str.index-of 'hello world' 'world'`).toEvaluateTo(6, globals) + await expect(`str.index-of 'hello' 'bye'`).toEvaluateTo(-1, globals) }) test('last-index-of finds last occurrence', async () => { - await expect(`str.last-index-of 'hello hello' 'hello'`).toEvaluateTo(6, globalFunctions) + await expect(`str.last-index-of 'hello hello' 'hello'`).toEvaluateTo(6, globals) }) }) describe('type predicates', () => { test('string? checks for string type', async () => { - await expect(`string? 'hello'`).toEvaluateTo(true, globalFunctions) - await expect(`string? 42`).toEvaluateTo(false, globalFunctions) + await expect(`string? 'hello'`).toEvaluateTo(true, globals) + await expect(`string? 42`).toEvaluateTo(false, globals) }) test('number? checks for number type', async () => { - await expect(`number? 42`).toEvaluateTo(true, globalFunctions) - await expect(`number? 'hello'`).toEvaluateTo(false, globalFunctions) + await expect(`number? 42`).toEvaluateTo(true, globals) + await expect(`number? 'hello'`).toEvaluateTo(false, globals) }) test('boolean? checks for boolean type', async () => { - await expect(`boolean? true`).toEvaluateTo(true, globalFunctions) - await expect(`boolean? 42`).toEvaluateTo(false, globalFunctions) + await expect(`boolean? true`).toEvaluateTo(true, globals) + await expect(`boolean? 42`).toEvaluateTo(false, globals) }) test('array? checks for array type', async () => { - await expect(`array? [1 2 3]`).toEvaluateTo(true, globalFunctions) - await expect(`array? 42`).toEvaluateTo(false, globalFunctions) + await expect(`array? [1 2 3]`).toEvaluateTo(true, globals) + await expect(`array? 42`).toEvaluateTo(false, globals) }) test('dict? checks for dict type', async () => { - await expect(`dict? [a=1]`).toEvaluateTo(true, globalFunctions) - await expect(`dict? []`).toEvaluateTo(false, globalFunctions) + await expect(`dict? [a=1]`).toEvaluateTo(true, globals) + await expect(`dict? []`).toEvaluateTo(false, globals) }) test('null? checks for null type', async () => { - await expect(`null? null`).toEvaluateTo(true, globalFunctions) - await expect(`null? 42`).toEvaluateTo(false, globalFunctions) + await expect(`null? null`).toEvaluateTo(true, globals) + await expect(`null? 42`).toEvaluateTo(false, globals) }) test('some? checks for non-null', async () => { - await expect(`some? 42`).toEvaluateTo(true, globalFunctions) - await expect(`some? null`).toEvaluateTo(false, globalFunctions) + await expect(`some? 42`).toEvaluateTo(true, globals) + await expect(`some? null`).toEvaluateTo(false, globals) }) }) describe('boolean logic', () => { test('not negates value', async () => { - await expect(`not true`).toEvaluateTo(false, globalFunctions) - await expect(`not false`).toEvaluateTo(true, globalFunctions) - await expect(`not 42`).toEvaluateTo(false, globalFunctions) - await expect(`not null`).toEvaluateTo(true, globalFunctions) + await expect(`not true`).toEvaluateTo(false, globals) + await expect(`not false`).toEvaluateTo(true, globals) + await expect(`not 42`).toEvaluateTo(false, globals) + await expect(`not null`).toEvaluateTo(true, globals) }) }) describe('utilities', () => { test('inc increments by 1', async () => { - await expect(`inc 5`).toEvaluateTo(6, globalFunctions) - await expect(`inc -1`).toEvaluateTo(0, globalFunctions) + await expect(`inc 5`).toEvaluateTo(6, globals) + await expect(`inc -1`).toEvaluateTo(0, globals) }) test('dec decrements by 1', async () => { - await expect(`dec 5`).toEvaluateTo(4, globalFunctions) - await expect(`dec 0`).toEvaluateTo(-1, globalFunctions) + await expect(`dec 5`).toEvaluateTo(4, globals) + await expect(`dec 0`).toEvaluateTo(-1, globals) }) test('identity returns value as-is', async () => { - await expect(`identity 42`).toEvaluateTo(42, globalFunctions) - await expect(`identity 'hello'`).toEvaluateTo('hello', globalFunctions) + await expect(`identity 42`).toEvaluateTo(42, globals) + await expect(`identity 'hello'`).toEvaluateTo('hello', globals) }) }) describe('introspection', () => { test('type returns proper types', async () => { - await expect(`type 'hello'`).toEvaluateTo('string', globalFunctions) - await expect(`type 42`).toEvaluateTo('number', globalFunctions) - await expect(`type true`).toEvaluateTo('boolean', globalFunctions) - await expect(`type false`).toEvaluateTo('boolean', globalFunctions) - await expect(`type null`).toEvaluateTo('null', globalFunctions) - await expect(`type [1 2 3]`).toEvaluateTo('array', globalFunctions) - await expect(`type [a=1 b=2]`).toEvaluateTo('dict', globalFunctions) + await expect(`type 'hello'`).toEvaluateTo('string', globals) + await expect(`type 42`).toEvaluateTo('number', globals) + await expect(`type true`).toEvaluateTo('boolean', globals) + await expect(`type false`).toEvaluateTo('boolean', globals) + await expect(`type null`).toEvaluateTo('null', globals) + await expect(`type [1 2 3]`).toEvaluateTo('array', globals) + await expect(`type [a=1 b=2]`).toEvaluateTo('dict', globals) }) test('length', async () => { - await expect(`length 'hello'`).toEvaluateTo(5, globalFunctions) - await expect(`length [1 2 3]`).toEvaluateTo(3, globalFunctions) - await expect(`length [a=1 b=2]`).toEvaluateTo(2, globalFunctions) - await expect(`length 42`).toEvaluateTo(0, globalFunctions) - await expect(`length true`).toEvaluateTo(0, globalFunctions) - await expect(`length null`).toEvaluateTo(0, globalFunctions) + 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('inspect formats values', async () => { // Just test that inspect returns something for now // (we'd need more complex assertion to check the actual format) - await expect(`type (inspect 'hello')`).toEvaluateTo('string', globalFunctions) + await expect(`type (inspect 'hello')`).toEvaluateTo('string', globals) }) }) describe('collections', () => { test('literal array creates array from arguments', async () => { - await expect(`[ 1 2 3 ]`).toEvaluateTo([1, 2, 3], globalFunctions) - await expect(`['a' 'b']`).toEvaluateTo(['a', 'b'], globalFunctions) - await expect(`[]`).toEvaluateTo([], globalFunctions) + await expect(`[ 1 2 3 ]`).toEvaluateTo([1, 2, 3], globals) + await expect(`['a' 'b']`).toEvaluateTo(['a', 'b'], globals) + await expect(`[]`).toEvaluateTo([], globals) }) test('literal dict creates object from named arguments', async () => { - await expect(`[ a=1 b=2 ]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) - await expect(`[=]`).toEvaluateTo({}, globalFunctions) + await expect(`[ a=1 b=2 ]`).toEvaluateTo({ a: 1, b: 2 }, globals) + await expect(`[=]`).toEvaluateTo({}, globals) }) test('at retrieves element at index', async () => { - await expect(`at [10 20 30] 0`).toEvaluateTo(10, globalFunctions) - await expect(`at [10 20 30] 2`).toEvaluateTo(30, globalFunctions) + await expect(`at [10 20 30] 0`).toEvaluateTo(10, globals) + await expect(`at [10 20 30] 2`).toEvaluateTo(30, globals) }) test('at retrieves property from object', async () => { - await expect(`at [name='test'] 'name'`).toEvaluateTo('test', globalFunctions) + await expect(`at [name='test'] 'name'`).toEvaluateTo('test', globals) }) test('slice extracts array subset', async () => { - await expect(`list.slice [1 2 3 4 5] 1 3`).toEvaluateTo([2, 3], globalFunctions) - await expect(`list.slice [1 2 3 4 5] 2 5`).toEvaluateTo([3, 4, 5], globalFunctions) + await expect(`list.slice [1 2 3 4 5] 1 3`).toEvaluateTo([2, 3], globals) + await expect(`list.slice [1 2 3 4 5] 2 5`).toEvaluateTo([3, 4, 5], globals) }) test('range creates number sequence', async () => { - await expect(`range 0 5`).toEvaluateTo([0, 1, 2, 3, 4, 5], globalFunctions) - await expect(`range 3 6`).toEvaluateTo([3, 4, 5, 6], globalFunctions) + await expect(`range 0 5`).toEvaluateTo([0, 1, 2, 3, 4, 5], globals) + await expect(`range 3 6`).toEvaluateTo([3, 4, 5, 6], globals) }) test('range with single argument starts from 0', async () => { - await expect(`range 3 null`).toEvaluateTo([0, 1, 2, 3], globalFunctions) - await expect(`range 0 null`).toEvaluateTo([0], globalFunctions) + await expect(`range 3 null`).toEvaluateTo([0, 1, 2, 3], globals) + await expect(`range 0 null`).toEvaluateTo([0], globals) }) test('empty? checks if list, dict, string is empty', async () => { - await expect(`empty? []`).toEvaluateTo(true, globalFunctions) - await expect(`empty? [1]`).toEvaluateTo(false, globalFunctions) + await expect(`empty? []`).toEvaluateTo(true, globals) + await expect(`empty? [1]`).toEvaluateTo(false, globals) - await expect(`empty? [=]`).toEvaluateTo(true, globalFunctions) - await expect(`empty? [a=true]`).toEvaluateTo(false, globalFunctions) + await expect(`empty? [=]`).toEvaluateTo(true, globals) + await expect(`empty? [a=true]`).toEvaluateTo(false, globals) - await expect(`empty? ''`).toEvaluateTo(true, globalFunctions) - await expect(`empty? 'cat'`).toEvaluateTo(false, globalFunctions) - await expect(`empty? meow`).toEvaluateTo(false, globalFunctions) + await expect(`empty? ''`).toEvaluateTo(true, globals) + await expect(`empty? 'cat'`).toEvaluateTo(false, globals) + await expect(`empty? meow`).toEvaluateTo(false, globals) }) test('list.filter keeps matching elements', async () => { @@ -242,7 +242,7 @@ describe('collections', () => { x == 3 or x == 4 or x == 5 end list.filter [1 2 3 4 5] is-positive - `).toEvaluateTo([3, 4, 5], globalFunctions) + `).toEvaluateTo([3, 4, 5], globals) }) test('list.reduce accumulates values', async () => { @@ -251,7 +251,7 @@ describe('collections', () => { acc + x end list.reduce [1 2 3 4] add 0 - `).toEvaluateTo(10, globalFunctions) + `).toEvaluateTo(10, globals) }) test('list.find returns first match', async () => { @@ -260,124 +260,124 @@ describe('collections', () => { x == 4 end list.find [1 2 4 5] is-four - `).toEvaluateTo(4, globalFunctions) + `).toEvaluateTo(4, globals) }) test('list.find returns null if no match', async () => { await expect(` is-ten = do x: x == 10 end list.find [1 2 3] is-ten - `).toEvaluateTo(null, globalFunctions) + `).toEvaluateTo(null, globals) }) test('list.empty? checks if list is empty', async () => { - await expect(`list.empty? []`).toEvaluateTo(true, globalFunctions) - await expect(`list.empty? [1]`).toEvaluateTo(false, globalFunctions) + await expect(`list.empty? []`).toEvaluateTo(true, globals) + await expect(`list.empty? [1]`).toEvaluateTo(false, globals) }) test('list.contains? checks for element', async () => { - await expect(`list.contains? [1 2 3] 2`).toEvaluateTo(true, globalFunctions) - await expect(`list.contains? [1 2 3] 5`).toEvaluateTo(false, globalFunctions) + await expect(`list.contains? [1 2 3] 2`).toEvaluateTo(true, globals) + await expect(`list.contains? [1 2 3] 5`).toEvaluateTo(false, globals) }) test('list.reverse reverses array', async () => { - await expect(`list.reverse [1 2 3]`).toEvaluateTo([3, 2, 1], globalFunctions) + await expect(`list.reverse [1 2 3]`).toEvaluateTo([3, 2, 1], globals) }) test('list.concat combines arrays', async () => { - await expect(`list.concat [1 2] [3 4]`).toEvaluateTo([1, 2, 3, 4], globalFunctions) + await expect(`list.concat [1 2] [3 4]`).toEvaluateTo([1, 2, 3, 4], globals) }) test('list.flatten flattens nested arrays', async () => { - await expect(`list.flatten [[1 2] [3 4]] 1`).toEvaluateTo([1, 2, 3, 4], globalFunctions) + await expect(`list.flatten [[1 2] [3 4]] 1`).toEvaluateTo([1, 2, 3, 4], globals) }) test('list.unique removes duplicates', async () => { - await expect(`list.unique [1 2 2 3 1]`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list.unique [1 2 2 3 1]`).toEvaluateTo([1, 2, 3], globals) }) test('list.zip combines two arrays', async () => { - await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]], globalFunctions) + await expect(`list.zip [1 2] [3 4]`).toEvaluateTo([[1, 3], [2, 4]], globals) }) test('list.first returns first element', async () => { - await expect(`list.first [1 2 3]`).toEvaluateTo(1, globalFunctions) - await expect(`list.first []`).toEvaluateTo(null, globalFunctions) + await expect(`list.first [1 2 3]`).toEvaluateTo(1, globals) + await expect(`list.first []`).toEvaluateTo(null, globals) }) test('list.last returns last element', async () => { - await expect(`list.last [1 2 3]`).toEvaluateTo(3, globalFunctions) - await expect(`list.last []`).toEvaluateTo(null, globalFunctions) + await expect(`list.last [1 2 3]`).toEvaluateTo(3, globals) + await expect(`list.last []`).toEvaluateTo(null, globals) }) test('list.rest returns all but first', async () => { - await expect(`list.rest [1 2 3]`).toEvaluateTo([2, 3], globalFunctions) + await expect(`list.rest [1 2 3]`).toEvaluateTo([2, 3], globals) }) test('list.take returns first n elements', async () => { - await expect(`list.take [1 2 3 4 5] 3`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list.take [1 2 3 4 5] 3`).toEvaluateTo([1, 2, 3], globals) }) test('list.drop skips first n elements', async () => { - await expect(`list.drop [1 2 3 4 5] 2`).toEvaluateTo([3, 4, 5], globalFunctions) + await expect(`list.drop [1 2 3 4 5] 2`).toEvaluateTo([3, 4, 5], globals) }) test('list.append adds to end', async () => { - await expect(`list.append [1 2] 3`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list.append [1 2] 3`).toEvaluateTo([1, 2, 3], globals) }) test('list.prepend adds to start', async () => { - await expect(`list.prepend [2 3] 1`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list.prepend [2 3] 1`).toEvaluateTo([1, 2, 3], globals) }) test('list.index-of finds element index', async () => { - await expect(`list.index-of [1 2 3] 2`).toEvaluateTo(1, globalFunctions) - await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globalFunctions) + await expect(`list.index-of [1 2 3] 2`).toEvaluateTo(1, globals) + await expect(`list.index-of [1 2 3] 5`).toEvaluateTo(-1, globals) }) test('list.any? checks if any element matches', async () => { await expect(` gt-three = do x: x > 3 end list.any? [1 2 4 5] gt-three - `).toEvaluateTo(true, globalFunctions) + `).toEvaluateTo(true, globals) await expect(` gt-ten = do x: x > 10 end list.any? [1 2 3] gt-ten - `).toEvaluateTo(false, globalFunctions) + `).toEvaluateTo(false, globals) }) test('list.all? checks if all elements match', async () => { await expect(` positive = do x: x > 0 end list.all? [1 2 3] positive - `).toEvaluateTo(true, globalFunctions) + `).toEvaluateTo(true, globals) await expect(` positive = do x: x > 0 end list.all? [1 -2 3] positive - `).toEvaluateTo(false, globalFunctions) + `).toEvaluateTo(false, globals) }) test('list.sum adds all numbers', async () => { - await expect(`list.sum [1 2 3 4]`).toEvaluateTo(10, globalFunctions) - await expect(`list.sum []`).toEvaluateTo(0, globalFunctions) + await expect(`list.sum [1 2 3 4]`).toEvaluateTo(10, globals) + await expect(`list.sum []`).toEvaluateTo(0, globals) }) test('list.count counts matching elements', async () => { await expect(` gt-two = do x: x > 2 end list.count [1 2 3 4 5] gt-two - `).toEvaluateTo(3, globalFunctions) + `).toEvaluateTo(3, globals) }) test('list.partition splits array by predicate', async () => { await expect(` gt-two = do x: x > 2 end list.partition [1 2 3 4 5] gt-two - `).toEvaluateTo([[3, 4, 5], [1, 2]], globalFunctions) + `).toEvaluateTo([[3, 4, 5], [1, 2]], globals) }) test('list.compact removes null values', async () => { - await expect(`list.compact [1 null 2 null 3]`).toEvaluateTo([1, 2, 3], globalFunctions) + await expect(`list.compact [1 null 2 null 3]`).toEvaluateTo([1, 2, 3], globals) }) test('list.group-by groups by key function', async () => { @@ -390,7 +390,7 @@ describe('collections', () => { end end list.group-by ['a' 1 'b' 2] get-type - `).toEvaluateTo({ str: ['a', 'b'], num: [1, 2] }, globalFunctions) + `).toEvaluateTo({ str: ['a', 'b'], num: [1, 2] }, globals) }) }) @@ -399,14 +399,14 @@ describe('enumerables', () => { await expect(` double = do x: x * 2 end list.map [1 2 3] double - `).toEvaluateTo([2, 4, 6], globalFunctions) + `).toEvaluateTo([2, 4, 6], globals) }) test('map handles empty array', async () => { await expect(` double = do x: x * 2 end list.map [] double - `).toEvaluateTo([], globalFunctions) + `).toEvaluateTo([], globals) }) test('each iterates over array', async () => { @@ -415,14 +415,14 @@ describe('enumerables', () => { await expect(` double = do x: x * 2 end each [1 2 3] double - `).toEvaluateTo([1, 2, 3], globalFunctions) + `).toEvaluateTo([1, 2, 3], globals) }) test('each handles empty array', async () => { await expect(` fn = do x: x end each [] fn - `).toEvaluateTo([], globalFunctions) + `).toEvaluateTo([], globals) }) }) @@ -432,9 +432,9 @@ describe('dict operations', () => { const { Compiler } = await import('#compiler/compiler') const { run, fromValue } = await import('reefvm') const { setGlobals } = await import('#parser/tokenizer') - setGlobals(Object.keys(globalFunctions)) + setGlobals(Object.keys(globals)) const c = new Compiler('dict.keys [a=1 b=2 c=3]') - const r = await run(c.bytecode, globalFunctions) + const r = await run(c.bytecode, globals) return fromValue(r) })() // Check that all expected keys are present (order may vary) @@ -446,9 +446,9 @@ describe('dict operations', () => { const { Compiler } = await import('#compiler/compiler') const { run, fromValue } = await import('reefvm') const { setGlobals } = await import('#parser/tokenizer') - setGlobals(Object.keys(globalFunctions)) + setGlobals(Object.keys(globals)) const c = new Compiler('dict.values [a=1 b=2]') - const r = await run(c.bytecode, globalFunctions) + const r = await run(c.bytecode, globals) return fromValue(r) })() // Check that all expected values are present (order may vary) @@ -456,119 +456,119 @@ describe('dict operations', () => { }) test('dict.has? checks for key', async () => { - await expect(`dict.has? [a=1 b=2] 'a'`).toEvaluateTo(true, globalFunctions) - await expect(`dict.has? [a=1 b=2] 'c'`).toEvaluateTo(false, globalFunctions) + await expect(`dict.has? [a=1 b=2] 'a'`).toEvaluateTo(true, globals) + await expect(`dict.has? [a=1 b=2] 'c'`).toEvaluateTo(false, globals) }) test('dict.get retrieves value with default', async () => { - await expect(`dict.get [a=1] 'a' 0`).toEvaluateTo(1, globalFunctions) - await expect(`dict.get [a=1] 'b' 99`).toEvaluateTo(99, globalFunctions) + await expect(`dict.get [a=1] 'a' 0`).toEvaluateTo(1, globals) + await expect(`dict.get [a=1] 'b' 99`).toEvaluateTo(99, globals) }) test('dict.empty? checks if dict is empty', async () => { - await expect(`dict.empty? [=]`).toEvaluateTo(true, globalFunctions) - await expect(`dict.empty? [a=1]`).toEvaluateTo(false, globalFunctions) + await expect(`dict.empty? [=]`).toEvaluateTo(true, globals) + await expect(`dict.empty? [a=1]`).toEvaluateTo(false, globals) }) test('dict.merge combines dicts', async () => { - await expect(`dict.merge [a=1] [b=2]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + await expect(`dict.merge [a=1] [b=2]`).toEvaluateTo({ a: 1, b: 2 }, globals) }) test('dict.map transforms values', async () => { await expect(` double = do v k: v * 2 end dict.map [a=1 b=2] double - `).toEvaluateTo({ a: 2, b: 4 }, globalFunctions) + `).toEvaluateTo({ a: 2, b: 4 }, globals) }) test('dict.filter keeps matching entries', async () => { await expect(` gt-one = do v k: v > 1 end dict.filter [a=1 b=2 c=3] gt-one - `).toEvaluateTo({ b: 2, c: 3 }, globalFunctions) + `).toEvaluateTo({ b: 2, c: 3 }, globals) }) test('dict.from-entries creates dict from array', async () => { - await expect(`dict.from-entries [['a' 1] ['b' 2]]`).toEvaluateTo({ a: 1, b: 2 }, globalFunctions) + await expect(`dict.from-entries [['a' 1] ['b' 2]]`).toEvaluateTo({ a: 1, b: 2 }, globals) }) }) describe('math operations', () => { test('math.abs returns absolute value', async () => { - await expect(`math.abs -5`).toEvaluateTo(5, globalFunctions) - await expect(`math.abs 5`).toEvaluateTo(5, globalFunctions) + await expect(`math.abs -5`).toEvaluateTo(5, globals) + await expect(`math.abs 5`).toEvaluateTo(5, globals) }) test('math.floor rounds down', async () => { - await expect(`math.floor 3.7`).toEvaluateTo(3, globalFunctions) + await expect(`math.floor 3.7`).toEvaluateTo(3, globals) }) test('math.ceil rounds up', async () => { - await expect(`math.ceil 3.2`).toEvaluateTo(4, globalFunctions) + await expect(`math.ceil 3.2`).toEvaluateTo(4, globals) }) test('math.round rounds to nearest', async () => { - await expect(`math.round 3.4`).toEvaluateTo(3, globalFunctions) - await expect(`math.round 3.6`).toEvaluateTo(4, globalFunctions) + await expect(`math.round 3.4`).toEvaluateTo(3, globals) + await expect(`math.round 3.6`).toEvaluateTo(4, globals) }) test('math.min returns minimum', async () => { - await expect(`math.min 5 2 8 1`).toEvaluateTo(1, globalFunctions) + await expect(`math.min 5 2 8 1`).toEvaluateTo(1, globals) }) test('math.max returns maximum', async () => { - await expect(`math.max 5 2 8 1`).toEvaluateTo(8, globalFunctions) + await expect(`math.max 5 2 8 1`).toEvaluateTo(8, globals) }) test('math.pow computes power', async () => { - await expect(`math.pow 2 3`).toEvaluateTo(8, globalFunctions) + await expect(`math.pow 2 3`).toEvaluateTo(8, globals) }) test('math.sqrt computes square root', async () => { - await expect(`math.sqrt 16`).toEvaluateTo(4, globalFunctions) + await expect(`math.sqrt 16`).toEvaluateTo(4, globals) }) test('math.even? checks if even', async () => { - await expect(`math.even? 4`).toEvaluateTo(true, globalFunctions) - await expect(`math.even? 5`).toEvaluateTo(false, globalFunctions) + await expect(`math.even? 4`).toEvaluateTo(true, globals) + await expect(`math.even? 5`).toEvaluateTo(false, globals) }) test('math.odd? checks if odd', async () => { - await expect(`math.odd? 5`).toEvaluateTo(true, globalFunctions) - await expect(`math.odd? 4`).toEvaluateTo(false, globalFunctions) + await expect(`math.odd? 5`).toEvaluateTo(true, globals) + await expect(`math.odd? 4`).toEvaluateTo(false, globals) }) test('math.positive? checks if positive', async () => { - await expect(`math.positive? 5`).toEvaluateTo(true, globalFunctions) - await expect(`math.positive? -5`).toEvaluateTo(false, globalFunctions) - await expect(`math.positive? 0`).toEvaluateTo(false, globalFunctions) + await expect(`math.positive? 5`).toEvaluateTo(true, globals) + await expect(`math.positive? -5`).toEvaluateTo(false, globals) + await expect(`math.positive? 0`).toEvaluateTo(false, globals) }) test('math.negative? checks if negative', async () => { - await expect(`math.negative? -5`).toEvaluateTo(true, globalFunctions) - await expect(`math.negative? 5`).toEvaluateTo(false, globalFunctions) + await expect(`math.negative? -5`).toEvaluateTo(true, globals) + await expect(`math.negative? 5`).toEvaluateTo(false, globals) }) test('math.zero? checks if zero', async () => { - await expect(`math.zero? 0`).toEvaluateTo(true, globalFunctions) - await expect(`math.zero? 5`).toEvaluateTo(false, globalFunctions) + await expect(`math.zero? 0`).toEvaluateTo(true, globals) + await expect(`math.zero? 5`).toEvaluateTo(false, globals) }) test('math.clamp restricts value to range', async () => { - await expect(`math.clamp 5 0 10`).toEvaluateTo(5, globalFunctions) - await expect(`math.clamp -5 0 10`).toEvaluateTo(0, globalFunctions) - await expect(`math.clamp 15 0 10`).toEvaluateTo(10, globalFunctions) + await expect(`math.clamp 5 0 10`).toEvaluateTo(5, globals) + await expect(`math.clamp -5 0 10`).toEvaluateTo(0, globals) + await expect(`math.clamp 15 0 10`).toEvaluateTo(10, globals) }) test('math.sign returns sign of number', async () => { - await expect(`math.sign 5`).toEvaluateTo(1, globalFunctions) - await expect(`math.sign -5`).toEvaluateTo(-1, globalFunctions) - await expect(`math.sign 0`).toEvaluateTo(0, globalFunctions) + await expect(`math.sign 5`).toEvaluateTo(1, globals) + await expect(`math.sign -5`).toEvaluateTo(-1, globals) + await expect(`math.sign 0`).toEvaluateTo(0, globals) }) test('math.trunc truncates decimal', async () => { - await expect(`math.trunc 3.7`).toEvaluateTo(3, globalFunctions) - await expect(`math.trunc -3.7`).toEvaluateTo(-3, globalFunctions) + await expect(`math.trunc 3.7`).toEvaluateTo(3, globals) + await expect(`math.trunc -3.7`).toEvaluateTo(-3, globals) }) }) From 4fb58483f0a4a886e3abe10e45ab996937c3e381 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:13:56 -0700 Subject: [PATCH 35/44] split up prelude modules --- src/prelude/dict.ts | 24 +++++ src/prelude/index.ts | 205 +++---------------------------------------- src/prelude/list.ts | 89 +++++++++++++++++++ src/prelude/load.ts | 29 ++++++ src/prelude/math.ts | 21 +++++ src/prelude/str.ts | 33 +++++++ 6 files changed, 209 insertions(+), 192 deletions(-) create mode 100644 src/prelude/dict.ts create mode 100644 src/prelude/list.ts create mode 100644 src/prelude/load.ts create mode 100644 src/prelude/math.ts create mode 100644 src/prelude/str.ts diff --git a/src/prelude/dict.ts b/src/prelude/dict.ts new file mode 100644 index 0000000..b3ee271 --- /dev/null +++ b/src/prelude/dict.ts @@ -0,0 +1,24 @@ +export const dict = { + keys: (dict: Record) => Object.keys(dict), + values: (dict: Record) => Object.values(dict), + entries: (dict: Record) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })), + 'has?': (dict: Record, key: string) => key in dict, + get: (dict: Record, key: string, defaultValue: any = null) => dict[key] ?? defaultValue, + merge: (...dicts: Record[]) => Object.assign({}, ...dicts), + 'empty?': (dict: Record) => Object.keys(dict).length === 0, + map: async (dict: Record, cb: Function) => { + const result: Record = {} + for (const [key, value] of Object.entries(dict)) { + result[key] = await cb(value, key) + } + return result + }, + filter: async (dict: Record, cb: Function) => { + const result: Record = {} + for (const [key, value] of Object.entries(dict)) { + if (await cb(value, key)) result[key] = value + } + return result + }, + 'from-entries': (entries: [string, any][]) => Object.fromEntries(entries), +} diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 145aacd..50e45e4 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -1,13 +1,16 @@ // The prelude creates all the builtin Shrimp functions. -import { resolve, parse } from 'path' -import { readFileSync } from 'fs' -import { Compiler } from '#compiler/compiler' import { - VM, Scope, toValue, type Value, + type Value, toValue, extractParamInfo, isWrapped, getOriginalFunction, } from 'reefvm' +import { dict } from './dict' +import { load } from './load' +import { list } from './list' +import { math } from './math' +import { str } from './str' + export const colors = { reset: '\x1b[0m', bright: '\x1b[1m', @@ -22,6 +25,12 @@ export const colors = { } export const globals = { + dict, + load, + list, + math, + str, + // hello echo: (...args: any[]) => { console.log(...args.map(a => { @@ -64,121 +73,6 @@ export const globals = { dec: (n: number) => n - 1, identity: (v: any) => v, - // strings - str: { - join: (arr: string[], sep: string = ',') => arr.join(sep), - split: (str: string, sep: string = ',') => str.split(sep), - 'to-upper': (str: string) => str.toUpperCase(), - 'to-lower': (str: string) => str.toLowerCase(), - trim: (str: string) => str.trim(), - // predicates - 'starts-with?': (str: string, prefix: string) => str.startsWith(prefix), - 'ends-with?': (str: string, suffix: string) => str.endsWith(suffix), - 'contains?': (str: string, substr: string) => str.includes(substr), - 'empty?': (str: string) => str.length === 0, - // transformations - replace: (str: string, search: string, replacement: string) => str.replace(search, replacement), - '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), - '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'), - chars: (str: string) => str.split(''), - 'index-of': (str: string, search: string) => str.indexOf(search), - 'last-index-of': (str: string, search: string) => str.lastIndexOf(search), - match: (str: string, regex: RegExp) => str.match(regex), - 'test?': (str: string, regex: RegExp) => regex.test(str), - }, - - // list - list: { - slice: (list: any[], start: number, end?: number) => list.slice(start, end), - map: async (list: any[], cb: Function) => { - let acc: any[] = [] - for (const value of list) acc.push(await cb(value)) - return acc - }, - filter: async (list: any[], cb: Function) => { - let acc: any[] = [] - for (const value of list) { - if (await cb(value)) acc.push(value) - } - return acc - }, - reduce: async (list: any[], cb: Function, initial: any) => { - let acc = initial - for (const value of list) acc = await cb(acc, value) - return acc - }, - find: async (list: any[], cb: Function) => { - for (const value of list) { - if (await cb(value)) return value - } - return null - }, - // predicates - 'empty?': (list: any[]) => list.length === 0, - 'contains?': (list: any[], item: any) => list.includes(item), - 'any?': async (list: any[], cb: Function) => { - for (const value of list) { - if (await cb(value)) return true - } - return false - }, - 'all?': async (list: any[], cb: Function) => { - for (const value of list) { - if (!await cb(value)) return false - } - return true - }, - // sequence operations - reverse: (list: any[]) => list.slice().reverse(), - sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb), - concat: (...lists: any[][]) => lists.flat(1), - flatten: (list: any[], depth: number = 1) => list.flat(depth), - unique: (list: any[]) => Array.from(new Set(list)), - zip: (list1: any[], list2: any[]) => list1.map((item, i) => [item, list2[i]]), - // access - 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), - append: (list: any[], item: any) => [...list, item], - prepend: (list: any[], item: any) => [item, ...list], - 'index-of': (list: any[], item: any) => list.indexOf(item), - // utilities - sum: (list: any[]) => list.reduce((acc, x) => acc + x, 0), - count: async (list: any[], cb: Function) => { - let count = 0 - for (const value of list) { - if (await cb(value)) count++ - } - return count - }, - partition: async (list: any[], cb: Function) => { - const truthy: any[] = [] - const falsy: any[] = [] - for (const value of list) { - if (await cb(value)) truthy.push(value) - else falsy.push(value) - } - return [truthy, falsy] - }, - compact: (list: any[]) => list.filter(x => x != null), - 'group-by': async (list: any[], cb: Function) => { - const groups: Record = {} - for (const value of list) { - const key = String(await cb(value)) - if (!groups[key]) groups[key] = [] - groups[key].push(value) - } - return groups - }, - }, - // collections at: (collection: any, index: number | string) => collection[index], range: (start: number, end: number | null) => { @@ -204,85 +98,12 @@ export const globals = { } }, - // dict - dict: { - keys: (dict: Record) => Object.keys(dict), - values: (dict: Record) => Object.values(dict), - entries: (dict: Record) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })), - 'has?': (dict: Record, key: string) => key in dict, - get: (dict: Record, key: string, defaultValue: any = null) => dict[key] ?? defaultValue, - merge: (...dicts: Record[]) => Object.assign({}, ...dicts), - 'empty?': (dict: Record) => Object.keys(dict).length === 0, - map: async (dict: Record, cb: Function) => { - const result: Record = {} - for (const [key, value] of Object.entries(dict)) { - result[key] = await cb(value, key) - } - return result - }, - filter: async (dict: Record, cb: Function) => { - const result: Record = {} - for (const [key, value] of Object.entries(dict)) { - if (await cb(value, key)) result[key] = value - } - return result - }, - 'from-entries': (entries: [string, any][]) => Object.fromEntries(entries), - }, - - // math - math: { - abs: (n: number) => Math.abs(n), - 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), - pow: (base: number, exp: number) => Math.pow(base, exp), - sqrt: (n: number) => Math.sqrt(n), - random: () => Math.random(), - clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max), - sign: (n: number) => Math.sign(n), - trunc: (n: number) => Math.trunc(n), - // predicates - 'even?': (n: number) => n % 2 === 0, - 'odd?': (n: number) => n % 2 !== 0, - 'positive?': (n: number) => n > 0, - 'negative?': (n: number) => n < 0, - 'zero?': (n: number) => n === 0, - }, - // enumerables each: async (list: any[], cb: Function) => { for (const value of list) await cb(value) return list }, - // modules - load: async function (this: VM, path: string): Promise> { - const scope = this.scope - const pc = this.pc - - const fullPath = resolve(path) + '.sh' - const code = readFileSync(fullPath, 'utf-8') - - this.pc = this.instructions.length - this.scope = new Scope(scope) - const compiled = new Compiler(code) - this.appendBytecode(compiled.bytecode) - - await this.continue() - - const module: Record = {} - for (const [name, value] of this.scope.locals.entries()) - module[name] = value - - this.scope = scope - this.pc = pc - this.stopped = false - - return module - }, } export function formatValue(value: Value, inner = false): string { diff --git a/src/prelude/list.ts b/src/prelude/list.ts new file mode 100644 index 0000000..eb013ef --- /dev/null +++ b/src/prelude/list.ts @@ -0,0 +1,89 @@ +export const list = { + slice: (list: any[], start: number, end?: number) => list.slice(start, end), + map: async (list: any[], cb: Function) => { + let acc: any[] = [] + for (const value of list) acc.push(await cb(value)) + return acc + }, + filter: async (list: any[], cb: Function) => { + let acc: any[] = [] + for (const value of list) { + if (await cb(value)) acc.push(value) + } + return acc + }, + reduce: async (list: any[], cb: Function, initial: any) => { + let acc = initial + for (const value of list) acc = await cb(acc, value) + return acc + }, + find: async (list: any[], cb: Function) => { + for (const value of list) { + if (await cb(value)) return value + } + return null + }, + + // predicates + 'empty?': (list: any[]) => list.length === 0, + 'contains?': (list: any[], item: any) => list.includes(item), + 'any?': async (list: any[], cb: Function) => { + for (const value of list) { + if (await cb(value)) return true + } + return false + }, + 'all?': async (list: any[], cb: Function) => { + for (const value of list) { + if (!await cb(value)) return false + } + return true + }, + + // sequence operations + reverse: (list: any[]) => list.slice().reverse(), + sort: (list: any[], cb?: (a: any, b: any) => number) => list.slice().sort(cb), + concat: (...lists: any[][]) => lists.flat(1), + flatten: (list: any[], depth: number = 1) => list.flat(depth), + unique: (list: any[]) => Array.from(new Set(list)), + zip: (list1: any[], list2: any[]) => list1.map((item, i) => [item, list2[i]]), + + // access + 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), + append: (list: any[], item: any) => [...list, item], + prepend: (list: any[], item: any) => [item, ...list], + 'index-of': (list: any[], item: any) => list.indexOf(item), + + // utilities + sum: (list: any[]) => list.reduce((acc, x) => acc + x, 0), + count: async (list: any[], cb: Function) => { + let count = 0 + for (const value of list) { + if (await cb(value)) count++ + } + return count + }, + partition: async (list: any[], cb: Function) => { + const truthy: any[] = [] + const falsy: any[] = [] + for (const value of list) { + if (await cb(value)) truthy.push(value) + else falsy.push(value) + } + return [truthy, falsy] + }, + compact: (list: any[]) => list.filter(x => x != null), + 'group-by': async (list: any[], cb: Function) => { + const groups: Record = {} + for (const value of list) { + const key = String(await cb(value)) + if (!groups[key]) groups[key] = [] + groups[key].push(value) + } + return groups + }, +} \ No newline at end of file diff --git a/src/prelude/load.ts b/src/prelude/load.ts new file mode 100644 index 0000000..3f317c1 --- /dev/null +++ b/src/prelude/load.ts @@ -0,0 +1,29 @@ +import { resolve } from 'path' +import { readFileSync } from 'fs' +import { Compiler } from '#compiler/compiler' +import { type Value, VM, Scope } from 'reefvm' + +export const load = async function (this: VM, path: string): Promise> { + const scope = this.scope + const pc = this.pc + + const fullPath = resolve(path) + '.sh' + const code = readFileSync(fullPath, 'utf-8') + + this.pc = this.instructions.length + this.scope = new Scope(scope) + const compiled = new Compiler(code) + this.appendBytecode(compiled.bytecode) + + await this.continue() + + const module: Record = {} + for (const [name, value] of this.scope.locals.entries()) + module[name] = value + + this.scope = scope + this.pc = pc + this.stopped = false + + return module +} \ No newline at end of file diff --git a/src/prelude/math.ts b/src/prelude/math.ts new file mode 100644 index 0000000..21f2f57 --- /dev/null +++ b/src/prelude/math.ts @@ -0,0 +1,21 @@ +export const math = { + abs: (n: number) => Math.abs(n), + 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), + pow: (base: number, exp: number) => Math.pow(base, exp), + sqrt: (n: number) => Math.sqrt(n), + random: () => Math.random(), + clamp: (n: number, min: number, max: number) => Math.min(Math.max(n, min), max), + sign: (n: number) => Math.sign(n), + trunc: (n: number) => Math.trunc(n), + + // predicates + 'even?': (n: number) => n % 2 === 0, + 'odd?': (n: number) => n % 2 !== 0, + 'positive?': (n: number) => n > 0, + 'negative?': (n: number) => n < 0, + 'zero?': (n: number) => n === 0, +} \ No newline at end of file diff --git a/src/prelude/str.ts b/src/prelude/str.ts new file mode 100644 index 0000000..fa0d657 --- /dev/null +++ b/src/prelude/str.ts @@ -0,0 +1,33 @@ +// strings +export const str = { + join: (arr: string[], sep: string = ',') => arr.join(sep), + split: (str: string, sep: string = ',') => str.split(sep), + 'to-upper': (str: string) => str.toUpperCase(), + 'to-lower': (str: string) => str.toLowerCase(), + trim: (str: string) => str.trim(), + + // predicates + 'starts-with?': (str: string, prefix: string) => str.startsWith(prefix), + 'ends-with?': (str: string, suffix: string) => str.endsWith(suffix), + 'contains?': (str: string, substr: string) => str.includes(substr), + 'empty?': (str: string) => str.length === 0, + + // inspection + 'index-of': (str: string, search: string) => str.indexOf(search), + 'last-index-of': (str: string, search: string) => str.lastIndexOf(search), + + // transformations + replace: (str: string, search: string, replacement: string) => str.replace(search, replacement), + '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), + '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'), + chars: (str: string) => str.split(''), + + // regex + match: (str: string, regex: RegExp) => str.match(regex), + 'test?': (str: string, regex: RegExp) => regex.test(str), +} \ No newline at end of file From df3d483de5c5d3b71b6c843c5744f4dabbc15231 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:21:02 -0700 Subject: [PATCH 36/44] update repl and shrimp --- bin/repl | 8 ++++---- bin/shrimp | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/repl b/bin/repl index 5ae39b9..052f481 100755 --- a/bin/repl +++ b/bin/repl @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, formatValue, globalFunctions } from '../src/prelude' +import { colors, formatValue, globals } from '../src/prelude' import { VM, Scope, bytecodeToString } from 'reefvm' import * as readline from 'readline' import { readFileSync, writeFileSync } from 'fs' @@ -48,7 +48,7 @@ async function repl() { return } - vm ||= new VM({ instructions: [], constants: [] }, globalFunctions) + vm ||= new VM({ instructions: [], constants: [] }, globals) if (['/exit', 'exit', '/quit', 'quit'].includes(trimmed)) { console.log(`\n${colors.yellow}Goodbye!${colors.reset}`) @@ -147,7 +147,7 @@ async function repl() { codeHistory.push(trimmed) try { - const compiler = new Compiler(trimmed) + const compiler = new Compiler(trimmed, Object.keys(globals)) vm.appendBytecode(compiler.bytecode) @@ -211,7 +211,7 @@ async function loadFile(filePath: string): Promise<{ vm: VM; codeHistory: string console.log(`${colors.dim}Loading ${basename(filePath)}...${colors.reset}`) - const vm = new VM({ instructions: [], constants: [] }, globalFunctions) + const vm = new VM({ instructions: [], constants: [] }, globals) await vm.run() const codeHistory: string[] = [] diff --git a/bin/shrimp b/bin/shrimp index 2260519..49cd7f3 100755 --- a/bin/shrimp +++ b/bin/shrimp @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { Compiler } from '../src/compiler/compiler' -import { colors, globalFunctions } from '../src/prelude' +import { colors, globals } from '../src/prelude' import { VM, fromValue, bytecodeToString } from 'reefvm' import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { randomUUID } from "crypto" @@ -11,8 +11,8 @@ import { join } from 'path' async function runFile(filePath: string) { try { const code = readFileSync(filePath, 'utf-8') - const compiler = new Compiler(code) - const vm = new VM(compiler.bytecode, globalFunctions) + const compiler = new Compiler(code, Object.keys(globals)) + const vm = new VM(compiler.bytecode, globals) await vm.run() return vm.stack.length ? fromValue(vm.stack[vm.stack.length - 1]) : null } catch (error: any) { From a21ba54ad7280c6b65be1288376ec79d7c4f8ebd Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:21:11 -0700 Subject: [PATCH 37/44] describe? --- src/prelude/index.ts | 30 +++++++++++++++++------------- src/prelude/tests/prelude.test.ts | 6 ++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 50e45e4..6834b8c 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -11,19 +11,6 @@ import { list } from './list' import { math } from './math' import { str } from './str' -export const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - cyan: '\x1b[36m', - yellow: '\x1b[33m', - green: '\x1b[32m', - red: '\x1b[31m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - pink: '\x1b[38;2;255;105;180m' -} - export const globals = { dict, load, @@ -43,6 +30,10 @@ export const globals = { // info type: (v: any) => toValue(v).type, inspect: (v: any) => formatValue(toValue(v)), + describe: (v: any) => { + const val = toValue(v) + return { [val.type]: formatValue(toValue(v)) } + }, length: (v: any) => { const value = toValue(v) switch (value.type) { @@ -106,6 +97,19 @@ export const globals = { } +export const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + yellow: '\x1b[33m', + green: '\x1b[32m', + red: '\x1b[31m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + pink: '\x1b[38;2;255;105;180m' +} + export function formatValue(value: Value, inner = false): string { switch (value.type) { case 'string': diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index fc19e0d..b7eb8ed 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -186,6 +186,12 @@ describe('introspection', () => { // (we'd need more complex assertion to check the actual format) await expect(`type (inspect 'hello')`).toEvaluateTo('string', globals) }) + + test('describe describes values', async () => { + // Just test that inspect returns something for now + // (we'd need more complex assertion to check the actual format) + await expect(`describe 'hello'`).toEvaluateTo({ string: "\u001b[32m'hello\u001b[32m'\u001b[0m" }, globals) + }) }) describe('collections', () => { From 1a3f1c6c439a8c1ed6827628ee3eedeac480b3fa Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:28:51 -0700 Subject: [PATCH 38/44] tweak describe --- src/prelude/index.ts | 10 ++++++---- src/prelude/tests/prelude.test.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/prelude/index.ts b/src/prelude/index.ts index 6834b8c..facf4b8 100644 --- a/src/prelude/index.ts +++ b/src/prelude/index.ts @@ -32,7 +32,7 @@ export const globals = { inspect: (v: any) => formatValue(toValue(v)), describe: (v: any) => { const val = toValue(v) - return { [val.type]: formatValue(toValue(v)) } + return `#<${val.type}: ${formatValue(val)}>` }, length: (v: any) => { const value = toValue(v) @@ -122,13 +122,15 @@ export function formatValue(value: Value, inner = false): string { return `${colors.dim}null${colors.reset}` case 'array': { const items = value.value.map(x => formatValue(x, true)).join(' ') - return `${inner ? '(' : ''}${colors.blue}list${colors.reset} ${items}${inner ? ')' : ''}` + return `${colors.blue}[${colors.reset}${items}${colors.blue}]${colors.reset}` } case 'dict': { const entries = Array.from(value.value.entries()) - .map(([k, v]) => `${k}=${formatValue(v, true)}`) + .map(([k, v]) => `${k}${colors.blue}=${colors.reset}${formatValue(v, true)}`) .join(' ') - return `${inner ? '(' : ''}${colors.magenta}dict${colors.reset} ${entries}${inner ? ')' : ''}` + if (entries.length === 0) + return `${colors.blue}[=]${colors.reset}` + return `${colors.blue}[${colors.reset}${entries}${colors.blue}]${colors.reset}` } case 'function': { const params = value.params.length ? '(' + value.params.join(' ') + ')' : '' diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index b7eb8ed..90ea2c6 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -190,7 +190,7 @@ describe('introspection', () => { test('describe describes values', async () => { // Just test that inspect returns something for now // (we'd need more complex assertion to check the actual format) - await expect(`describe 'hello'`).toEvaluateTo({ string: "\u001b[32m'hello\u001b[32m'\u001b[0m" }, globals) + await expect(`describe 'hello'`).toEvaluateTo("#", globals) }) }) From c3453fdc5c2700342cbd8f9377ce88c8029ea3fd Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:45:27 -0700 Subject: [PATCH 39/44] how did that get in there... --- ha.sh | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 ha.sh diff --git a/ha.sh b/ha.sh deleted file mode 100644 index 7ba4927..0000000 --- a/ha.sh +++ /dev/null @@ -1,8 +0,0 @@ -bob = [ name= Bob age= 44 ] -mike = [ - name= Mike - age= 46 -] - -echo bob -echo (mike | at name) From e95c0d6728dc1774ae94e38b5055fd4a079b5ff5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:50:09 -0700 Subject: [PATCH 40/44] use bun's equal check --- src/testSetup.ts | 40 ++++------------------------------------ 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/src/testSetup.ts b/src/testSetup.ts index 3904828..f47218a 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -108,16 +108,10 @@ expect.extend({ if (expected instanceof RegExp) expected = String(expected) if (value instanceof RegExp) value = String(value) - if (isEqual(value, expected)) { - return { pass: true } - } else { - return { - message: () => - `Expected evaluation to be ${JSON.stringify(expected)}, but got ${JSON.stringify( - value - )}`, - pass: false, - } + expect(value).toEqual(expected) + return { + message: () => `Expected evaluation to be ${expected}, but got ${value}`, + pass: true, } } catch (error) { return { @@ -167,29 +161,3 @@ const trimWhitespace = (str: string): string => { }) .join('\n') } - -function isEqual(a: any, b: any): boolean { - if (a === null && b === null) return true - - switch (typeof a) { - case 'string': - case 'number': - case 'boolean': - case 'undefined': - return a === b - default: - return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b)) - } -} - -function sortKeys(o: any): any { - if (Array.isArray(o)) return o.map(sortKeys) - if (o && typeof o === 'object' && o.constructor === Object) - return Object.keys(o) - .sort() - .reduce((r, k) => { - r[k] = sortKeys(o[k]) - return r - }, {} as any) - return o -} From c51030b3bda7c9abdfdf2007e7e915c6a4e53038 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 12:50:56 -0700 Subject: [PATCH 41/44] sure --- src/utils/tree.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 7a2b36a..45a9318 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -1,5 +1,4 @@ import { Tree, TreeCursor } from '@lezer/common' -import { assertNever } from '#utils/utils' import { type Value, fromValue } from 'reefvm' export const treeToString = (tree: Tree, input: string): string => { @@ -35,6 +34,5 @@ export const treeToString = (tree: Tree, input: string): string => { } export const VMResultToValue = (result: Value): unknown => { - if (result.type === 'function') return Function - else return fromValue(result) + return result.type === 'function' ? Function : fromValue(result) } From 92ce43b5089dfb145abe0a9021ee39c9aaf74b0f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 13:12:40 -0700 Subject: [PATCH 42/44] dict.set --- src/prelude/dict.ts | 11 +++++++++++ src/prelude/tests/prelude.test.ts | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src/prelude/dict.ts b/src/prelude/dict.ts index b3ee271..9642a15 100644 --- a/src/prelude/dict.ts +++ b/src/prelude/dict.ts @@ -1,9 +1,16 @@ +import { type Value, toString, toValue } from 'reefvm' + export const dict = { keys: (dict: Record) => Object.keys(dict), values: (dict: Record) => Object.values(dict), entries: (dict: Record) => Object.entries(dict).map(([k, v]) => ({ key: k, value: v })), 'has?': (dict: Record, key: string) => key in dict, get: (dict: Record, key: string, defaultValue: any = null) => dict[key] ?? defaultValue, + set: (dict: Value, key: Value, value: Value) => { + const map = dict.value as Map + map.set(toString(key), value) + return dict + }, merge: (...dicts: Record[]) => Object.assign({}, ...dicts), 'empty?': (dict: Record) => Object.keys(dict).length === 0, map: async (dict: Record, cb: Function) => { @@ -22,3 +29,7 @@ export const dict = { }, 'from-entries': (entries: [string, any][]) => Object.fromEntries(entries), } + + // raw functions deal directly in Value types, meaning we can modify collection + // careful - the MUST return a Value! + ; (dict.set as any).raw = true diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index 90ea2c6..e19e18e 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -471,6 +471,12 @@ describe('dict operations', () => { await expect(`dict.get [a=1] 'b' 99`).toEvaluateTo(99, globals) }) + test('dict.set sets value', async () => { + await expect(`map = [a=1]; dict.set map 'b' 99; map.b`).toEvaluateTo(99, globals) + await expect(`map = [a=1]; dict.set map 'a' 100; map.a`).toEvaluateTo(100, globals) + }) + + test('dict.empty? checks if dict is empty', async () => { await expect(`dict.empty? [=]`).toEvaluateTo(true, globals) await expect(`dict.empty? [a=1]`).toEvaluateTo(false, globals) From d939322f6e847973499112cac4da303f696fc58c Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 13:14:37 -0700 Subject: [PATCH 43/44] shh --- src/prelude/tests/prelude.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/prelude/tests/prelude.test.ts b/src/prelude/tests/prelude.test.ts index e19e18e..ef7d8d6 100644 --- a/src/prelude/tests/prelude.test.ts +++ b/src/prelude/tests/prelude.test.ts @@ -476,7 +476,6 @@ describe('dict operations', () => { await expect(`map = [a=1]; dict.set map 'a' 100; map.a`).toEvaluateTo(100, globals) }) - test('dict.empty? checks if dict is empty', async () => { await expect(`dict.empty? [=]`).toEvaluateTo(true, globals) await expect(`dict.empty? [a=1]`).toEvaluateTo(false, globals) From 0de72a0d670e6c4b3b29d916a69f020525eb9f37 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 29 Oct 2025 13:14:59 -0700 Subject: [PATCH 44/44] update reef --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 1180308..005ca60 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#052f989e82430db638c649e91960bd8ce3cf6ceb", { "peerDependencies": { "typescript": "^5" } }, "052f989e82430db638c649e91960bd8ce3cf6ceb"], + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#030eb7487165b3ba502965a8b7fa09c4b5fdb0da", { "peerDependencies": { "typescript": "^5" } }, "030eb7487165b3ba502965a8b7fa09c4b5fdb0da"], "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],