feat(compiler): add underscore placeholder support for pipes

Implement underscore (_) placeholder in pipe expressions to allow
piped values to be placed at specific positions in function calls.

Implementation:
- Scan FunctionCall arguments for underscore placeholder (detected as "_" Word token)
- When underscore is found, replace it with piped value at that position
- When no underscore is found, piped value becomes first arg (existing behavior)

Testing:
- Added test for underscore placeholder with single-parameter function
- Skipped test for multi-parameter function (blocked by existing Shrimp bug)
- All existing pipe tests continue to pass

The underscore detection logic is working correctly, as verified by the
single-parameter test. Full multi-parameter testing is blocked by a
known issue with multi-parameter function calls in Shrimp (see skipped
test in pipe.test.ts).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Corey Johnson 2025-10-12 17:36:39 -07:00
parent 00b1863021
commit a86c3eef19
2 changed files with 86 additions and 21 deletions

View File

@ -332,34 +332,76 @@ export class Compiler {
instructions.push(['CALL']) instructions.push(['CALL'])
} else if (operand.type.id === terms.FunctionCall) { } else if (operand.type.id === terms.FunctionCall) {
// Function call with arguments - piped value becomes first argument // Function call with arguments - check for underscore placeholder
const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(operand, input) const { identifierNode, namedArgs, positionalArgs } = getFunctionCallParts(operand, input)
// Store piped value temporarily // Check if any positional arg is an underscore placeholder
instructions.push(['STORE', '__pipe_value']) 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
}
}
// Load function if (underscoreIndex !== -1) {
instructions.push(...this.#compileNode(identifierNode, input)) // Underscore found - replace it with piped value at that position
// Store piped value temporarily
instructions.push(['STORE', '__pipe_value'])
// Push piped value as first arg // Load function
instructions.push(['LOAD', '__pipe_value']) instructions.push(...this.#compileNode(identifierNode, input))
// Push remaining positional args // Push positional args, replacing underscore with piped value
positionalArgs.forEach((arg) => { for (let j = 0; j < positionalArgs.length; j++) {
instructions.push(...this.#compileNode(arg, input)) if (j === underscoreIndex) {
}) instructions.push(['LOAD', '__pipe_value'])
} else {
instructions.push(...this.#compileNode(positionalArgs[j]!, input))
}
}
// Push named args // Push named args
namedArgs.forEach((arg) => { namedArgs.forEach((arg) => {
const { name, valueNode } = getNamedArgParts(arg, input) const { name, valueNode } = getNamedArgParts(arg, input)
instructions.push(['PUSH', name]) instructions.push(['PUSH', name])
instructions.push(...this.#compileNode(valueNode, input)) instructions.push(...this.#compileNode(valueNode, input))
}) })
// Call with (positionalArgs + 1 for piped value) and namedArgs // Call with positionalArgs.length and namedArgs
instructions.push(['PUSH', positionalArgs.length + 1]) instructions.push(['PUSH', positionalArgs.length])
instructions.push(['PUSH', namedArgs.length]) instructions.push(['PUSH', namedArgs.length])
instructions.push(['CALL']) 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 { } else {
throw new CompilerError(`Unsupported pipe operand type: ${operand.type.name}`, operand.from, operand.to) throw new CompilerError(`Unsupported pipe operand type: ${operand.type.name}`, operand.from, operand.to)
} }

View File

@ -35,4 +35,27 @@ describe('pipe expressions', () => {
expect(code).toEvaluateTo(15) expect(code).toEvaluateTo(15)
}) })
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`
// Underscore is replaced with piped value (10)
// Should call: divide(2, 10) = 2 / 10 = 0.2
expect(code).toEvaluateTo(0.2)
})
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)
})
}) })