[WIP] Implement parsing for lambdas + start implementing call

This commit is contained in:
2026-04-01 22:55:35 -05:00
parent 57a3d5954e
commit 98e40183bb
35 changed files with 341 additions and 73 deletions

View File

@@ -1,5 +1,5 @@
import {ConsoleLogger, Logger, LogLevel} from './util/log.js'
export const log: Logger = new ConsoleLogger(LogLevel.ERROR)
export const log: Logger = new ConsoleLogger(LogLevel.VERBOSE)
// log.setStreamLevel('lexer', LogLevel.INFO)
// log.setStreamLevel('token', LogLevel.INFO)

View File

@@ -8,9 +8,9 @@ export type AssignData = {
}
export class Assign extends Command<AssignData> {
attemptParse(context: ParseContext): Awaitable<AssignData> {
async attemptParse(context: ParseContext): Promise<AssignData> {
return {
value: context.popTerm(),
value: await context.popTerm(),
}
}

68
src/vm/commands/call.ts Normal file
View File

@@ -0,0 +1,68 @@
import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
type CallData = {
callable: StrTerm,
params: StrTerm[],
}
export class Call extends Command<CallData> {
async attemptParse(context: ParseContext): Promise<CallData> {
const data: CallData = {
callable: await context.popTerm(),
params: [],
}
let term: StrTerm|undefined
while ( term = await context.popOptionalTerm() ) {
data.params.push(term)
}
return data
}
getDisplayName(): string {
return 'call'
}
isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'call')
}
async execute(vm: StrVM, data: CallData): Promise<StrVM> {
return vm.replaceContextMatchingTerm(ctx => ({
override: (s) => {
// Resolve the callable and params
const callable = ctx.resolveLambda(data.callable)
const params = data.params.map(p => ctx.resolveRequired(p))
// If we received fewer params than the function actually takes,
// then this is a "partial application" -- return a new lambda.
if ( callable.value.params.length > params.length ) {
// call (|$a $b| ...) foo
// => (|$b| call (|$a $b| ...) foo)
const remainingParams = callable.value.params.slice(params.length)
return {
term: 'lambda',
value: {
params: remainingParams,
body: [{
command: new Call,
data: {
callable: data.callable,
params: [
...data.params,
...remainingParams,
],
},
}],
},
}
}
return s
},
}))
}
}

View File

@@ -2,7 +2,7 @@ import {createHash} from 'node:crypto';
import {LexInput, LexToken, tokenIsLVal} from '../lexer.js'
import {
Executable,
ExpectedEndOfInputError,
ExpectedEndOfInputError, InvalidSubcontextError,
InvalidVariableNameError,
IsNotKeywordError,
UnexpectedEndOfInputError, UnexpectedEndofStatementError
@@ -44,8 +44,8 @@ export type StrInt = { term: 'int', value: number }
export type StrLamba = {
term: 'lambda',
value: {
args: StrLVal[],
exec: Executable<CommandData>[]
params: StrLVal[],
body: Executable<CommandData>[]
},
}
@@ -110,7 +110,7 @@ export const isStrTerm = (val: unknown): val is StrTerm =>
&& hasOwnProperty(val, 'value')))
export const isStrRVal = (term: StrTerm): term is StrRVal =>
term.term === 'string' || term.term === 'int' || term.term === 'destructured'
term.term === 'string' || term.term === 'int' || term.term === 'destructured' || term.term === 'lambda'
export const unwrapString = (term: StrRVal): string => {
if ( term.term === 'int' ) {
@@ -194,16 +194,20 @@ export class ParseContext {
return exec
}
popOptionalTerm(): StrTerm|undefined {
async popOptionalTerm(): Promise<StrTerm|undefined> {
if ( this.inputs.length ) return this.popTerm()
return undefined
}
popTerm(): StrTerm {
async popTerm(): Promise<StrTerm> {
if ( !this.inputs.length ) {
throw new UnexpectedEndOfInputError('Unexpected end of input. Expected term.')
}
if ( this.peekIsSubcontext() ) {
return this.popLambda()
}
const input = this.inputs.shift()!
if ( input.type === 'terminator' ) {
@@ -213,11 +217,24 @@ export class ParseContext {
return this.parseInputToTerm(input)
}
peekTerm(): StrTerm|undefined {
peekIsSubcontext(): boolean {
if ( !this.inputs.length ) {
return false
}
const input = this.inputs[0]
return input.type === 'input' && !input.literal && input.value.startsWith('(')
}
async peekTerm(): Promise<StrTerm|undefined> {
if ( !this.inputs.length ) {
return undefined
}
if ( this.peekIsSubcontext() ) {
return (await this.peekLambda())[0]
}
const input = this.inputs[0]
if ( input.type === 'terminator' ) {
return undefined
@@ -283,6 +300,168 @@ export class ParseContext {
return { term: 'variable', name: input.value }
}
async popLambda(): Promise<StrLamba> {
const [lambda, tokensToPop] = await this.peekLambda()
this.inputs = this.inputs.slice(tokensToPop)
return lambda
}
async peekLambda(): Promise<[StrLamba, number]> {
const [sc, tokensToPop] = this.peekSubcontext()
const lambda: StrLamba['value'] = {
params: [],
body: [],
}
if ( sc.inputs.length < 1 ) {
// This is the empty lambda -- no parameters, no body.
return [{ term: 'lambda', value: lambda }, tokensToPop]
}
if ( sc.inputs[0]!.type === 'terminator' ) {
throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Expected lambda.')
}
// Check if the subcontext starts w/ parameters -- e.g. (|$a, $b| ...) or is point-free (...)
// If so, parse the parameters:
if ( !sc.inputs[0]!.literal && sc.inputs[0]!.value.startsWith('|') ) {
// Inputs might be something like ['|$a', '$b', '$c|split', ...]
// Parameters must follow the form |$a $b $c| (spaces separated)
// Strip off the leading | and then accumulate parameters until we find the
// closing |
let paramString = sc.inputs[0]!.value.substring(1)
sc.inputs.shift()
while ( true ) {
if ( paramString.includes('|') ) {
// We found the closing |
break
}
let input = sc.inputs.shift()
if ( !input ) {
throw new UnexpectedEndOfInputError('Unexpected end of input. Unterminated lambda parameters.')
}
if ( input.type === 'terminator' ) {
throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Unterminated lambda parameters.')
}
paramString += ' ' + input.value
}
// paramString contains something like: $a $b $c|split
// split off the closing | and return the portion back to the input list:
const [combinedParams, head] = paramString.split('|', 1)
if ( head ) {
sc.inputs.unshift({
type: 'input',
value: head,
})
}
const params = combinedParams.split(' ')
.filter(Boolean)
for ( const param of params ) {
if ( !tokenIsLVal({type: 'input', value: param}) ) {
throw new InvalidVariableNameError('Invalid variable name in lambda params: ' + param)
}
}
lambda.params = params
.map(name => ({
term: 'variable',
name,
}))
}
// Now, the remainder of the subcontext inputs should be a series of executables
// separated by `terminator` tokens -- e.g. (split _; join |), so parse executables
// from the subcontext until it is empty:
console.log(sc.inputs)
while ( sc.inputs.length > 0 ) {
const [exec, remainingInputs] = await this.childParser(sc.inputs)
lambda.body.push(exec)
sc.inputs = remainingInputs
}
return [{ term: 'lambda', value: lambda }, tokensToPop]
}
popSubcontext(): ParseSubContext {
const [sc, tokensToPop] = this.peekSubcontext()
this.inputs = this.inputs.slice(tokensToPop)
return sc
}
peekSubcontext(): [ParseSubContext, number] {
if ( this.inputs.length < 1 ) {
throw new UnexpectedEndOfInputError('Unexpected end of input. Expected lambda.')
}
let level = 0
let sc: ParseSubContext = {
inputs: [],
}
let tokenIdx = 0
let first = this.inputs[tokenIdx]!
tokenIdx += 1
if ( first.type === 'terminator' ) {
throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Expected lambda.')
}
if ( !first.value.startsWith('(') ) {
throw new InvalidSubcontextError('Unexpected term: ' + first.value + ' (expected: lambda subcontext)')
}
sc.inputs.push({
...first,
value: first.value.substring(1),
})
level += 1
while ( level > 0 ) {
const input = this.inputs[tokenIdx]
tokenIdx += 1
if ( !input ) {
throw new UnexpectedEndOfInputError('Unexpected end of input. Incomplete lambda subcontext.')
}
sc.inputs.push(input)
if ( input.type === 'input' && !input.literal ) {
if ( input.value.startsWith('(') ) {
// We're entering a nested subcontext, so increment the counter.
level += 1
}
if ( input.value.endsWith(')') ) {
// We're closing a subcontext (maybe a child, maybe our own) so decrement.
level -= 1
}
}
}
// Trim the right-most right-paren from the last input in the subcontext
let last = sc.inputs.pop()!
if ( last.type === 'input' ) {
last = {
...last,
value: last.value.substring(0, last.value.length - 1),
}
}
sc.inputs.push(last)
return [sc, tokenIdx]
}
}
export type CommandData = Record<string, unknown>

View File

@@ -12,13 +12,13 @@ export class Concat extends Command<ConcatData> {
return this.isKeyword(token, 'concat') || this.isKeyword(token, 'cat')
}
attemptParse(context: ParseContext): ConcatData {
async attemptParse(context: ParseContext): Promise<ConcatData> {
const data: ConcatData = {
terms: [],
}
let term: StrTerm|undefined
while ( term = context.popOptionalTerm() ) {
while ( term = await context.popOptionalTerm() ) {
data.terms.push(term)
}

View File

@@ -4,9 +4,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Contains extends Command<{ find: StrTerm }> {
attemptParse(context: ParseContext): { find: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ find: StrTerm }> {
return {
find: context.popTerm(),
find: await context.popTerm(),
}
}

View File

@@ -21,17 +21,17 @@ export type DropData = {
*/
export class Drop extends Command<DropData> {
async attemptParse(context: ParseContext): Promise<DropData> {
const next = context.peekTerm()
const next = await context.peekTerm()
if ( next?.term === 'int' || next?.term === 'variable' ) {
return {
type: 'index',
specific: context.popTerm(),
specific: await context.popTerm(),
}
}
return {
type: context.popKeywordInSet(['line', 'word', 'index']).value,
specific: context.popTerm(),
specific: await context.popTerm(),
}
}

View File

@@ -9,10 +9,10 @@ export type EncloseData = {
}
export class Enclose extends Command<EncloseData> {
attemptParse(context: ParseContext): EncloseData {
async attemptParse(context: ParseContext): Promise<EncloseData> {
return {
left: context.popOptionalTerm(),
right: context.popOptionalTerm(),
left: await context.popOptionalTerm(),
right: await context.popOptionalTerm(),
}
}

View File

@@ -7,10 +7,10 @@ export type IndentData = {
}
export class Indent extends Command<IndentData> {
attemptParse(context: ParseContext): IndentData {
async attemptParse(context: ParseContext): Promise<IndentData> {
return {
type: context.popKeywordInSet(['space', 'tab']).value,
level: context.popOptionalTerm(),
level: await context.popOptionalTerm(),
}
}

View File

@@ -48,10 +48,12 @@ import {Set} from "./set.js";
import {Assign} from "./assign.js";
import {Zip} from "./zip.js";
import {Concat} from "./concat.js";
import {Call} from "./call.js";
export type Commands = Command<CommandData>[]
export const commands: Commands = [
new Assign,
new Call,
new Clear,
new Concat,
new Contains,

View File

@@ -9,8 +9,8 @@ export class InFile extends Command<{ path: StrTerm }> {
return this.isKeyword(token, 'infile')
}
attemptParse(context: ParseContext): { path: StrTerm } {
return { path: context.popTerm() }
async attemptParse(context: ParseContext): Promise<{ path: StrTerm }> {
return { path: await context.popTerm() }
}
getDisplayName(): string {

View File

@@ -4,9 +4,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Join extends Command<{ with?: StrTerm }> {
attemptParse(context: ParseContext): { with?: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ with?: StrTerm }> {
return {
with: context.popOptionalTerm(),
with: await context.popOptionalTerm(),
}
}

View File

@@ -50,9 +50,9 @@ const genLipsumSentence = (i: number = 0) => {
export class Lipsum extends Command<LipsumData> {
attemptParse(context: ParseContext): LipsumData {
async attemptParse(context: ParseContext): Promise<LipsumData> {
return {
length: context.popTerm(),
length: await context.popTerm(),
type: context.popKeywordInSet(['word', 'words', 'line', 'lines', 'para', 'paras']).value,
}
}

View File

@@ -11,8 +11,8 @@ export class Load extends Command<{ path?: StrTerm }> {
return this.isKeyword(token, 'load')
}
attemptParse(context: ParseContext): { path?: StrTerm } {
return { path: context.popOptionalTerm() }
async attemptParse(context: ParseContext): Promise<{ path?: StrTerm }> {
return { path: await context.popOptionalTerm() }
}
getDisplayName(): string {

View File

@@ -9,10 +9,10 @@ export type LSubData = {
}
export class LSub extends Command<LSubData> {
attemptParse(context: ParseContext): LSubData {
async attemptParse(context: ParseContext): Promise<LSubData> {
return {
offset: context.popTerm(),
length: context.popOptionalTerm(),
offset: await context.popTerm(),
length: await context.popOptionalTerm(),
}
}

View File

@@ -4,9 +4,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Missing extends Command<{ find: StrTerm }> {
attemptParse(context: ParseContext): { find: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ find: StrTerm }> {
return {
find: context.popTerm(),
find: await context.popTerm(),
}
}

View File

@@ -29,11 +29,11 @@ export class On extends Command<OnData> {
async attemptParse(context: ParseContext): Promise<OnData> {
// Check if the next term we received is an int or a variable.
// If so, we got the "on 3 <exec>" form of the command.
const next = context.peekTerm()
const next = await context.peekTerm()
if ( next?.term === 'int' || next?.term === 'variable' ) {
return {
type: 'index',
specific: context.popTerm(),
specific: await context.popTerm(),
exec: await context.popExecutable(),
}
}
@@ -41,7 +41,7 @@ export class On extends Command<OnData> {
// Otherwise, assume we got the "on <type> <index> <exec>" form:
return {
type: context.popKeywordInSet(['line', 'word', 'index']).value,
specific: context.popTerm(),
specific: await context.popTerm(),
exec: await context.popExecutable(),
}
}

View File

@@ -9,8 +9,8 @@ export class OutFile extends Command<{ path: StrTerm }> {
return this.isKeyword(token, 'outfile')
}
attemptParse(context: ParseContext): { path: StrTerm } {
return { path: context.popTerm() }
async attemptParse(context: ParseContext): Promise<{ path: StrTerm }> {
return { path: await context.popTerm() }
}
getDisplayName(): string {

View File

@@ -4,9 +4,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Prefix extends Command<{ with: StrTerm }> {
attemptParse(context: ParseContext): { with: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ with: StrTerm }> {
return {
with: context.popTerm(),
with: await context.popTerm(),
}
}

View File

@@ -24,9 +24,9 @@ export const stripQuotemarkLayer = (s: string, marks?: string[]): string => {
}
export class Quote extends Command<{ with?: StrTerm }> {
attemptParse(context: ParseContext): { with?: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ with?: StrTerm }> {
return {
with: context.popOptionalTerm(),
with: await context.popOptionalTerm(),
}
}

View File

@@ -7,9 +7,9 @@ export class Redo extends Command<{ steps?: StrTerm }> {
return this.isKeyword(token, 'redo')
}
attemptParse(context: ParseContext): { steps?: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ steps?: StrTerm }> {
return {
steps: context.popOptionalTerm(),
steps: await context.popOptionalTerm(),
}
}

View File

@@ -11,10 +11,10 @@ export type ReplaceData = {
}
export class Replace extends Command<ReplaceData> {
attemptParse(context: ParseContext): ReplaceData {
async attemptParse(context: ParseContext): Promise<ReplaceData> {
return {
find: context.popTerm(),
with: context.popTerm(),
find: await context.popTerm(),
with: await context.popTerm(),
}
}

View File

@@ -10,10 +10,10 @@ export type RSubData = {
}
export class RSub extends Command<RSubData> {
attemptParse(context: ParseContext): RSubData {
async attemptParse(context: ParseContext): Promise<RSubData> {
return {
offset: context.popTerm(),
length: context.popOptionalTerm(),
offset: await context.popTerm(),
length: await context.popOptionalTerm(),
}
}

View File

@@ -6,8 +6,8 @@ export class RunFile extends Command<{ path: StrTerm }> {
return this.isKeyword(token, 'runfile')
}
attemptParse(context: ParseContext): { path: StrTerm } {
return { path: context.popTerm() }
async attemptParse(context: ParseContext): Promise<{ path: StrTerm }> {
return { path: await context.popTerm() }
}
getDisplayName(): string {

View File

@@ -21,8 +21,8 @@ export class Save extends Command<{ path?: StrTerm }> {
return this.isKeyword(token, 'save')
}
attemptParse(context: ParseContext): { path?: StrTerm } {
return { path: context.popOptionalTerm() }
async attemptParse(context: ParseContext): Promise<{ path?: StrTerm }> {
return { path: await context.popOptionalTerm() }
}
getDisplayName(): string {

View File

@@ -14,14 +14,14 @@ export type SetData = {
* $x = foo
*/
export class Set extends Command<SetData> {
attemptParse(context: ParseContext): Awaitable<SetData> {
const term = context.peekTerm()!
async attemptParse(context: ParseContext): Promise<SetData> {
const term = (await context.peekTerm())!
if ( term.term === 'string' && !term.literal && term.value === 'set' ) {
// We got the `set $x foo` form of the command:
context.popKeywordInSet(['set'])
return {
lval: context.popLVal(),
rval: context.popTerm(),
rval: await context.popTerm(),
}
}
@@ -30,7 +30,7 @@ export class Set extends Command<SetData> {
context.popKeywordInSet(['='])
return {
lval,
rval: context.popTerm(),
rval: await context.popTerm(),
}
}

View File

@@ -9,9 +9,9 @@ export type SplitData = {
}
export class Split extends Command<SplitData> {
attemptParse(context: ParseContext): SplitData {
async attemptParse(context: ParseContext): Promise<SplitData> {
return {
on: context.popTerm(),
on: await context.popTerm(),
}
}

View File

@@ -4,9 +4,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Suffix extends Command<{ with: StrTerm }> {
attemptParse(context: ParseContext): { with: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ with: StrTerm }> {
return {
with: context.popTerm(),
with: await context.popTerm(),
}
}

View File

@@ -10,10 +10,10 @@ export type TrimData = {
}
export class Trim extends Command<TrimData> {
attemptParse(context: ParseContext): TrimData {
async attemptParse(context: ParseContext): Promise<TrimData> {
return {
type: context.popOptionalKeywordInSet(['start', 'end', 'both', 'left', 'right', 'lines'])?.value,
char: context.popOptionalTerm(),
char: await context.popOptionalTerm(),
}
}

View File

@@ -7,9 +7,9 @@ export class Undo extends Command<{ steps?: StrTerm }> {
return this.isKeyword(token, 'undo')
}
attemptParse(context: ParseContext): { steps?: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ steps?: StrTerm }> {
return {
steps: context.popOptionalTerm(),
steps: await context.popOptionalTerm(),
}
}

View File

@@ -5,9 +5,9 @@ import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Unquote extends Command<{ with?: StrTerm }> {
attemptParse(context: ParseContext): { with?: StrTerm } {
async attemptParse(context: ParseContext): Promise<{ with?: StrTerm }> {
return {
with: context.popOptionalTerm(),
with: await context.popOptionalTerm(),
}
}

View File

@@ -8,9 +8,9 @@ export type ZipData = {
}
export class Zip extends Command<ZipData> {
attemptParse(context: ParseContext): Awaitable<ZipData> {
async attemptParse(context: ParseContext): Promise<ZipData> {
return {
with: context.popTerm(),
with: await context.popTerm(),
}
}

View File

@@ -55,6 +55,16 @@ export const getSubjectDisplay = (sub: StrRVal, prefix: string = '', firstLinePr
}
let annotated = firstLinePrefix + '┌───────────────'
if ( sub.term === 'lambda' ) {
const params = [...sub.value.params.map(param => param.name), '()'].join(' :: ')
annotated += `\n${prefix}│ (lambda)`
annotated += `\n${prefix}├───────────────`
annotated += `\n${prefix}│ :: ${params}`
annotated += `\n${prefix}└───────────────`
return annotated
}
if ( sub.term === 'string' ) {
const lines = sub.value.split('\n')
const padLength = `${lines.length}`.length // heh

View File

@@ -13,3 +13,4 @@ export class UnexpectedEndOfInputError extends ParseError {}
export class UnexpectedEndofStatementError extends ParseError {}
export class ExpectedEndOfInputError extends InvalidCommandError {}
export class InvalidVariableNameError extends ParseError {}
export class InvalidSubcontextError extends ParseError {}

View File

@@ -1,7 +1,7 @@
import {Awaitable, hasOwnProperty, JSONData} from "../util/types.js";
import {
CommandData, destructureToLines, isStrLVal,
isStrRVal, joinDestructured, StrDestructured,
isStrRVal, joinDestructured, StrDestructured, StrLamba,
StrLVal,
StrRVal,
StrTerm, TypeError, unwrapDestructured,
@@ -342,6 +342,14 @@ export class ExecutionContext {
return unwrapInt(this.resolveRequired(term))
}
resolveLambda(term: StrTerm): StrLamba {
term = this.resolveRequired(term)
if ( term.term !== 'lambda' ) {
throw new TypeError(`Found unexpected ${term.term} (expected: lambda)`)
}
return term
}
getSubject(): StrRVal {
return {...this.subject}
}