diff --git a/src/compiler/compiler.test.ts b/src/compiler/compiler.test.ts index f4468b9..3bd44b8 100644 --- a/src/compiler/compiler.test.ts +++ b/src/compiler/compiler.test.ts @@ -70,6 +70,17 @@ describe('compiler', () => { expect(`add = fn a b: a + b end; add 2 9`).toEvaluateTo(11) }) + test('function call with named args', () => { + expect(`minus = fn a b: a - b end; minus b=2 a=9`).toEvaluateTo(7) + }) + + test('function call with named and positional args', () => { + expect(`minus = fn a b: a - b end; minus b=2 9`).toEvaluateTo(7) + expect(`minus = fn c d: a - b end; minus 90 b=20`).toEvaluateTo(70) + expect(`minus = fn e f: a - b end; minus a=900 200`).toEvaluateTo(700) + expect(`minus = fn g h: a - b end; minus 2000 a=9000`).toEvaluateTo(7000) + }) + test('function call with no args', () => { expect(`bloop = fn: 'bloop' end; bloop`).toEvaluateTo('bloop') }) @@ -136,7 +147,7 @@ describe('errors', () => { }) }) -describe.skip('multiline tests', () => { +describe('multiline tests', () => { test('multiline function', () => { expect(` add = fn a b: diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 3591f39..262b91e 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -13,8 +13,12 @@ import { getFunctionDefParts, getIfExprParts, getNamedArgParts, + getPipeExprParts, } from '#compiler/utils' +const DEBUG = false +// const DEBUG = true + type Label = `.${string}` export class Compiler { instructions: ProgramItem[] = [] @@ -25,14 +29,21 @@ export class Compiler { constructor(public input: string) { try { const cst = parser.parse(input) - const errors = checkTreeForErrors(cst, input) + const errors = checkTreeForErrors(cst) - if (errors.length > 0) { - throw new CompilerError(`Syntax errors found:\n${errors.join('\n')}`, 0, input.length) + const firstError = errors[0] + if (firstError) { + throw firstError } this.#compileCst(cst, input) + throw new CompilerError( + 'I am a very long fake error to test scrolling and other things this is super long\nand has multiple lines\nand just keeps going and going and going', + 20, + 25 + ) + // Add the labels for (const [label, labelInstructions] of this.fnLabels) { this.instructions.push([`${label}:`]) @@ -40,7 +51,7 @@ export class Compiler { this.instructions.push(['RETURN']) } - // logInstructions(this.instructions) + if (DEBUG) logInstructions(this.instructions) this.bytecode = toBytecode(this.instructions) } catch (error) { @@ -67,6 +78,9 @@ export class Compiler { #compileNode(node: SyntaxNode, input: string): ProgramItem[] { const value = input.slice(node.from, node.to) + + if (DEBUG) console.log(`๐Ÿซฆ ${node.name}: ${value}`) + switch (node.type.id) { case terms.Number: const number = Number(value) @@ -289,123 +303,61 @@ export class Compiler { } case terms.PipeExpr: { - const allChildren = getAllChildren(node) - // Filter out the pipe operator nodes (they're just syntax) - const operands = allChildren.filter((child) => child.type.name !== 'operator') - if (operands.length < 2) { + const { pipedFunctionCall, pipeReceivers } = getPipeExprParts(node) + if (!pipedFunctionCall || pipeReceivers.length === 0) { throw new CompilerError('PipeExpr must have at least two operands', node.from, node.to) } const instructions: ProgramItem[] = [] + instructions.push(...this.#compileNode(pipedFunctionCall, input)) - // Compile first operand normally - instructions.push(...this.#compileNode(operands[0]!, input)) + pipeReceivers.forEach((pipeReceiver) => { + instructions.push(['STORE', '_pipe_value']) - // For each subsequent operand, transform it to receive piped value as first arg - for (let i = 1; i < operands.length; i++) { - const operand = operands[i]! + const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts( + pipeReceiver, + input + ) - // Result from previous stage is on stack - // We need to make it the first argument to the next call + instructions.push(...this.#compileNode(identifierNode, input)) - if (operand.type.id === terms.FunctionCallOrIdentifier) { - // Simple identifier - emit TRY_CALL with piped value as single argument - const identifierNode = operand.getChild('Identifier') - if (!identifierNode) { - throw new CompilerError('FunctionCallOrIdentifier must have Identifier', operand.from, operand.to) - } - const fnName = input.slice(identifierNode.from, identifierNode.to) + const isUnderscoreInPositionalArgs = positionalArgs.some( + (arg) => arg.type.id === terms.Underscore + ) + const isUnderscoreInNamedArgs = namedArgs.some((arg) => { + const { valueNode } = getNamedArgParts(arg, input) + return valueNode.type.id === terms.Underscore + }) - // Stack has: [piped_value] - // Store piped value temporarily - instructions.push(['STORE', '__pipe_value']) + const shouldPushPositionalArg = !isUnderscoreInPositionalArgs && !isUnderscoreInNamedArgs - // Load function - instructions.push(['TRY_LOAD', fnName]) - - // Load piped value as first arg - instructions.push(['LOAD', '__pipe_value']) - - // Call with 1 positional arg and 0 named args - instructions.push(['PUSH', 1]) - instructions.push(['PUSH', 0]) - instructions.push(['CALL']) - - } else if (operand.type.id === terms.FunctionCall) { - // Function call with arguments - check for underscore placeholder - const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(operand, input) - - // Check if any positional arg is an underscore placeholder - let underscoreIndex = -1 - for (let j = 0; j < positionalArgs.length; j++) { - const arg = positionalArgs[j]! - const argValue = input.slice(arg.from, arg.to) - if (argValue === '_') { - underscoreIndex = j - break - } - } - - if (underscoreIndex !== -1) { - // Underscore found - replace it with piped value at that position - // Store piped value temporarily - instructions.push(['STORE', '__pipe_value']) - - // Load function - instructions.push(...this.#compileNode(identifierNode, input)) - - // Push positional args, replacing underscore with piped value - for (let j = 0; j < positionalArgs.length; j++) { - if (j === underscoreIndex) { - instructions.push(['LOAD', '__pipe_value']) - } else { - instructions.push(...this.#compileNode(positionalArgs[j]!, input)) - } - } - - // Push named args - namedArgs.forEach((arg) => { - const { name, valueNode } = getNamedArgParts(arg, input) - instructions.push(['PUSH', name]) - instructions.push(...this.#compileNode(valueNode, input)) - }) - - // Call with positionalArgs.length and namedArgs - instructions.push(['PUSH', positionalArgs.length]) - instructions.push(['PUSH', namedArgs.length]) - instructions.push(['CALL']) - } else { - // No underscore - piped value becomes first argument - // Store piped value temporarily - instructions.push(['STORE', '__pipe_value']) - - // Load function - instructions.push(...this.#compileNode(identifierNode, input)) - - // Push piped value as first arg - instructions.push(['LOAD', '__pipe_value']) - - // Push remaining positional args - positionalArgs.forEach((arg) => { - instructions.push(...this.#compileNode(arg, input)) - }) - - // Push named args - namedArgs.forEach((arg) => { - const { name, valueNode } = getNamedArgParts(arg, input) - instructions.push(['PUSH', name]) - instructions.push(...this.#compileNode(valueNode, input)) - }) - - // Call with (positionalArgs + 1 for piped value) and namedArgs - instructions.push(['PUSH', positionalArgs.length + 1]) - instructions.push(['PUSH', namedArgs.length]) - instructions.push(['CALL']) - } - } else { - throw new CompilerError(`Unsupported pipe operand type: ${operand.type.name}`, operand.from, operand.to) + // If no underscore is explicitly used, add the piped value as the first positional arg + if (shouldPushPositionalArg) { + instructions.push(['LOAD', '_pipe_value']) } - } + + positionalArgs.forEach((arg) => { + if (arg.type.id === terms.Underscore) { + instructions.push(['LOAD', '_pipe_value']) + } else { + instructions.push(...this.#compileNode(arg, input)) + } + }) + + namedArgs.forEach((arg) => { + const { name, valueNode } = getNamedArgParts(arg, input) + instructions.push(['PUSH', name]) + if (valueNode.type.id === terms.Underscore) { + instructions.push(['LOAD', '_pipe_value']) + } else { + instructions.push(...this.#compileNode(valueNode, input)) + } + }) + + instructions.push(['PUSH', positionalArgs.length + (shouldPushPositionalArg ? 1 : 0)]) + instructions.push(['PUSH', namedArgs.length]) + instructions.push(['CALL']) + }) return instructions } diff --git a/src/compiler/compilerError.ts b/src/compiler/compilerError.ts index 96c44f4..c5492a1 100644 --- a/src/compiler/compilerError.ts +++ b/src/compiler/compilerError.ts @@ -1,6 +1,11 @@ export class CompilerError extends Error { constructor(message: string, private from: number, private to: number) { super(message) + + if (from < 0 || to < 0 || to < from) { + throw new Error(`Invalid CompilerError positions: from=${from}, to=${to}`) + } + this.name = 'CompilerError' this.message = message } @@ -19,27 +24,24 @@ export class CompilerError extends Error { const lines = previousSevenLines .map((line, index) => { const currentLineNumber = lineNumber - previousSevenLines.length + index + 1 + // repace leading whitespace with barely visible characters so they show up in terminal + line = line.replace(/^\s+/, (ws) => ws.replace(/ /g, green('ยท')).replace(/\t/g, 'โ†’ ')) return `${grey(currentLineNumber.toString().padStart(padding))} โ”‚ ${line}` }) .join('\n') - const underlineStartLen = (columnEnd - columnStart) / 2 - const underlineEndLen = columnEnd - columnStart - underlineStartLen - const underline = - ' '.repeat(columnStart - 1) + - 'โ”€'.repeat(underlineStartLen) + - 'โ”ฌ' + - 'โ”€'.repeat(underlineEndLen) + const underlineLen = columnEnd - columnStart + 1 + const underline = ' '.repeat(columnStart - 1) + red('โ•'.repeat(underlineLen)) - const messageWithArrow = - ' '.repeat(columnStart + underlineStartLen - 1) + 'โ•ฐโ”€โ”€ ' + blue(this.message) + const messageWithArrow = blue(this.message) const message = `${green('')} ${ws}โ•ญโ”€โ”€โ”€โ”จ ${red('Compiler Error')} โ”ƒ ${ws}โ”‚ ${lines} ${ws}โ”‚ ${underline} -${ws}โ”‚ ${messageWithArrow} +${ws}โ”‚ ${messageWithArrow.split('\n').join(`\n${ws}โ”‚ `)} +${ws}โ”‚ ${ws}โ•ฐโ”€โ”€โ”€ ` @@ -54,7 +56,7 @@ ${ws}โ•ฐโ”€โ”€โ”€ const line = lines[i]! if (this.from >= currentPos && this.from <= currentPos + line.length) { const columnStart = this.from - currentPos + 1 - const columnEnd = columnStart + (this.to - this.from) - 1 + const columnEnd = columnStart + (this.to - this.from) // If the error spans multiple lines, so just return the line start if (columnEnd > line.length) { diff --git a/src/compiler/pipe.test.ts b/src/compiler/pipe.test.ts index f172a19..2f5e7f7 100644 --- a/src/compiler/pipe.test.ts +++ b/src/compiler/pipe.test.ts @@ -2,60 +2,80 @@ import { describe, test, expect } from 'bun:test' describe('pipe expressions', () => { test('simple pipe passes result as first argument', () => { - const code = `double = fn x: x * 2 end; result = 5 | double; result` + const code = ` + double = fn x: x * 2 end + double 2 | double` - expect(code).toEvaluateTo(10) + expect(code).toEvaluateTo(8) }) test('pipe chain with three stages', () => { - const code = `add-one = fn x: x + 1 end; double = fn x: x * 2 end; square = fn x: x * x end; result = 3 | add-one | double | square; result` - - // 3 -> 4 -> 8 -> 64 - expect(code).toEvaluateTo(64) + const code = ` + add-one = fn x: x + 1 end + double = fn x: x * 2 end + minus-point-one = fn x: x - 0.1 end + add-one 3 | double | minus-point-one` + // 4 8 7.9 + expect(code).toEvaluateTo(7.9) }) - test.skip('pipe with function that has additional arguments', () => { - // TODO: This test reveals a bug where functions with 2+ parameters - // don't properly bind all arguments. This is a general Shrimp issue, - // not specific to pipes. Skipping until the broader issue is fixed. - const code = `multiply = fn a b: a * b end; result = 5 | multiply 3; result` + test('pipe with function that has additional arguments', () => { + const code = ` + multiply = fn a b: a * b end + get-five = fn: 5 end + get-five | multiply 3` - // 5 becomes first arg, 3 is second arg: 5 * 3 = 15 expect(code).toEvaluateTo(15) }) test('pipe with bare identifier', () => { - const code = `get-value = 42; process = fn x: x + 10 end; result = get-value | process; result` + const code = ` + get-value = 42 + process = fn x: x + 10 end + get-value | process` expect(code).toEvaluateTo(52) }) test('pipe in assignment', () => { - const code = `add-ten = fn x: x + 10 end; result = 5 | add-ten; result` + const code = ` + add-ten = fn x: x + 10 end + result = add-ten 5 | add-ten + result` - expect(code).toEvaluateTo(15) + // 5 + 10 = 15, then 15 + 10 = 25 + expect(code).toEvaluateTo(25) }) - test.skip('pipe with underscore placeholder', () => { - // TODO: This test depends on the fix for two-parameter functions - // which is tracked by the skipped test above. The underscore placeholder - // logic is implemented, but we can't verify it works until the broader - // multi-parameter function bug is fixed. - const code = `divide = fn a b: a / b end; result = 10 | divide 2 _; result` + test('pipe with named underscore arg', () => { + expect(` + divide = fn a b: a / b end + get-ten = fn: 10 end + get-ten | divide 2 b=_`).toEvaluateTo(0.2) - // Underscore is replaced with piped value (10) - // Should call: divide(2, 10) = 2 / 10 = 0.2 - expect(code).toEvaluateTo(0.2) + expect(` + divide = fn a b: a / b end + get-ten = fn: 10 end + get-ten | divide b=_ 2`).toEvaluateTo(0.2) + + expect(` + divide = fn a b: a / b end + get-ten = fn: 10 end + get-ten | divide 2 a=_`).toEvaluateTo(5) + + expect(` + divide = fn a b: a / b end + get-ten = fn: 10 end + get-ten | divide a=_ 2`).toEvaluateTo(5) }) - test('pipe with underscore placeholder (single param verification)', () => { - // Since multi-param functions don't work yet, let's verify the underscore - // detection and replacement logic works by testing that underscore is NOT - // treated as a literal word/string - const code = `identity = fn x: x end; result = 42 | identity _; result` - - // If underscore is properly detected and replaced, we get 42 - // If it's treated as a Word, we'd get the string "_" - expect(code).toEvaluateTo(42) + test('nested pipes', () => { + // This is complicated, but the idea is to make sure the underscore + // handling logic works correctly when there are multiple pipe stages + // in a single expression. + expect(` + sub = fn a b: a - b end + div = fn a b: a / b end + sub 3 1 | div (sub 110 9 | sub 1) _ | div 5`).toEvaluateTo(10) }) }) diff --git a/src/compiler/utils.ts b/src/compiler/utils.ts index 3410dde..c308331 100644 --- a/src/compiler/utils.ts +++ b/src/compiler/utils.ts @@ -2,13 +2,12 @@ import { CompilerError } from '#compiler/compilerError.ts' import * as terms from '#parser/shrimp.terms' import type { SyntaxNode, Tree } from '@lezer/common' -export const checkTreeForErrors = (tree: Tree, input: string): string[] => { - const errors: string[] = [] +export const checkTreeForErrors = (tree: Tree): CompilerError[] => { + const errors: CompilerError[] = [] tree.iterate({ enter: (node) => { if (node.type.isError) { - const errorText = input.slice(node.from, node.to) - errors.push(`Syntax error at ${node.from}-${node.to}: "${errorText}"`) + errors.push(new CompilerError(`Unexpected syntax.`, node.from, node.to)) } }, }) @@ -70,18 +69,16 @@ export const getFunctionDefParts = (node: SyntaxNode, input: string) => { ) } - const paramNames = getAllChildren(paramsNode) - .map((param) => { - if (param.type.id !== terms.Identifier) { - throw new CompilerError( - `FunctionDef params must be Identifiers, got ${param.type.name}`, - param.from, - param.to - ) - } - return input.slice(param.from, param.to) - }) - .join(' ') + const paramNames = getAllChildren(paramsNode).map((param) => { + if (param.type.id !== terms.Identifier) { + throw new CompilerError( + `FunctionDef params must be Identifiers, got ${param.type.name}`, + param.from, + param.to + ) + } + return input.slice(param.from, param.to) + }) return { paramNames, bodyNode } } @@ -115,7 +112,7 @@ export const getNamedArgParts = (node: SyntaxNode, input: string) => { throw new CompilerError(message, node.from, node.to) } - const name = input.slice(namedArgPrefix.from, namedArgPrefix.to - 2) // Remove the trailing = + const name = input.slice(namedArgPrefix.from, namedArgPrefix.to - 1) // Remove the trailing = return { name, valueNode } } @@ -156,3 +153,15 @@ export const getIfExprParts = (node: SyntaxNode, input: string) => { return { conditionNode, thenBlock, elseThenBlock, elseIfBlocks } } + +export const getPipeExprParts = (node: SyntaxNode) => { + const [pipedFunctionCall, operator, ...rest] = getAllChildren(node) + if (!pipedFunctionCall || !operator || rest.length === 0) { + const message = `PipeExpr expected at least 3 children, got ${getAllChildren(node).length}` + throw new CompilerError(message, node.from, node.to) + } + + const pipeReceivers = rest.filter((child) => child.name !== 'operator') + + return { pipedFunctionCall, pipeReceivers } +} diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar index e63998a..4c92174 100644 --- a/src/parser/shrimp.grammar +++ b/src/parser/shrimp.grammar @@ -18,6 +18,7 @@ rightParen { ")" } colon[closedBy="end", @name="colon"] { ":" } end[openedBy="colon", @name="end"] { "end" } + Underscore { "_" } "fn" [@name=keyword] "if" [@name=keyword] "elsif" [@name=keyword] @@ -68,7 +69,7 @@ PipeExpr { } pipeOperand { - FunctionCall | FunctionCallOrIdentifier | expressionWithoutIdentifier + FunctionCall | FunctionCallOrIdentifier } FunctionCallOrIdentifier { @@ -87,12 +88,13 @@ arg { PositionalArg | NamedArg } + PositionalArg { - expression | FunctionDef + expression | FunctionDef | Underscore } NamedArg { - NamedArgPrefix (expression | FunctionDef) + NamedArgPrefix (expression | FunctionDef | Underscore) } FunctionDef { @@ -158,7 +160,7 @@ BinOp { } ParenExpr { - leftParen (ambiguousFunctionCall | BinOp | expressionWithoutIdentifier | ConditionalOp ) rightParen + leftParen (ambiguousFunctionCall | BinOp | expressionWithoutIdentifier | ConditionalOp | PipeExpr) rightParen } expression { diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts index 425c83c..b6a093f 100644 --- a/src/parser/shrimp.terms.ts +++ b/src/parser/shrimp.terms.ts @@ -17,10 +17,11 @@ export const Params = 28, colon = 29, end = 30, - NamedArg = 31, - NamedArgPrefix = 32, - IfExpr = 34, - ThenBlock = 37, - ElsifExpr = 38, - ElseExpr = 40, - Assign = 42 + Underscore = 31, + NamedArg = 32, + NamedArgPrefix = 33, + IfExpr = 35, + ThenBlock = 38, + ElsifExpr = 39, + ElseExpr = 41, + Assign = 43 diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts index 0712028..ec565ea 100644 --- a/src/parser/shrimp.ts +++ b/src/parser/shrimp.ts @@ -4,10 +4,10 @@ import {tokenizer} from "./tokenizer" import {highlighting} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: ",xQVQTOOO!lQUO'#CdO#PQPO'#DiO#_QPO'#CeO#mQPO'#DcO$gQTO'#CcOOQS'#Dg'#DgO$nQPO'#DfO%YQTO'#DkOOQS'#Cv'#CvO%bQPO'#C`O%gQTO'#DoOOQO'#DO'#DOOOQO'#Dc'#DcO%nQPO'#DbOOQS'#Db'#DbOOQS'#DX'#DXQVQTOOOOQS'#Df'#DfOOQS'#Cb'#CbO%vQTO'#C{OOQS'#De'#DeOOQS'#DY'#DYO&QQUO,58{O&nQTO,59rO%gQTO,59PO%gQTO,59PO&{QUO'#CdOOQO'#Di'#DiO(WQPO'#CeO(hQPO,58}O(tQPO,58}O(yQPO,58}OOQS'#DZ'#DZO)tQTO'#CxO)|QPO,5:VO*RQTO'#D]O*YQPO,58zO*hQPO,5:ZO*oQPO,5:ZOOQS,59|,59|OOQS-E7V-E7VOOQS,59g,59gOOQS-E7W-E7WOOQO1G/^1G/^OOQO1G.k1G.kO*tQPO1G.kO%gQTO,59UO%gQTO,59UOOQS1G.i1G.iOOQS-E7X-E7XO+`QTO1G/qO+pQUO'#CdOOQO'#Dd'#DdOOQO,59w,59wOOQO-E7Z-E7ZO,ZQTO1G/uOOQO1G.p1G.pO,kQPO1G.pO,uQPO7+%]O,zQTO7+%^OOQO'#DQ'#DQOOQO7+%a7+%aO-[QTO7+%bOOQS<dAN>dO%gQTO'#DSOOQO'#D^'#D^O.oQPOAN>hO.zQPO'#DUOOQOAN>hAN>hO/PQPOAN>hO/UQPO,59nO/]QPO,59nOOQO-E7[-E7[OOQOG24SG24SO/bQPOG24SO/gQPO,59pO/lQPO1G/YOOQOLD)nLD)nO,zQTO1G/[O-[QTO7+$tOOQO7+$v7+$vOOQO<dAN>dO%bQTO'#DTOOQO'#D_'#D_O/PQPOAN>hO/[QPO'#DVOOQOAN>hAN>hO/aQPOAN>hO/fQPO,59oO/mQPO,59oOOQO-E7]-E7]OOQOG24SG24SO/rQPOG24SO/wQPO,59qO/|QPO1G/ZOOQOLD)nLD)nO-[QTO1G/]O-lQTO7+$uOOQO7+$w7+$wOOQO<