From dc6b02bc1bc7e28ddfe56693253de6139849b340 Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Tue, 6 May 2025 17:54:41 -0400 Subject: [PATCH 1/5] Change how keyword expression argments are encoded Previously arguments were additional properties at the top level of the keyword expression object (siblings of the `0` property). Now they are packed into a single property value keyed by `1`. This brings keyword expressions semantics closer to function semantics (both now have a single argument), and will help make the upcoming generalized keyword expression syntax sugar more sensible. --- README.md | 115 ++++--- examples/fibonacci.plz | 18 +- examples/kitchen-sink.plz | 4 +- examples/lookup-environment-variable.plz | 16 +- src/end-to-end.test.ts | 100 +++--- src/language/compiling/compiler.test.ts | 122 +++++--- .../keyword-handlers/apply-handler.test.ts | 284 +++++++++++------- .../keyword-handlers/apply-handler.ts | 6 +- .../keyword-handlers/check-handler.test.ts | 69 +++-- .../keyword-handlers/check-handler.ts | 2 +- .../keyword-handlers/function-handler.test.ts | 7 +- .../keyword-handlers/function-handler.ts | 8 +- .../keyword-handlers/if-handler.test.ts | 88 ++++-- .../semantics/keyword-handlers/if-handler.ts | 37 ++- .../keyword-handlers/index-handler.test.ts | 21 +- .../keyword-handlers/index-handler.ts | 6 +- .../keyword-handlers/lookup-handler.test.ts | 20 +- .../keyword-handlers/lookup-handler.ts | 17 +- .../keyword-handlers/runtime-handler.test.ts | 9 +- .../keyword-handlers/runtime-handler.ts | 44 +-- src/language/compiling/unparsing.test.ts | 122 +++++--- src/language/parsing/expression.ts | 42 ++- src/language/runtime/evaluator.test.ts | 7 +- src/language/runtime/keywords.ts | 73 ++--- src/language/semantics.ts | 5 +- src/language/semantics/expression.ts | 18 +- .../semantics/expressions/apply-expression.ts | 21 +- .../semantics/expressions/check-expression.ts | 18 +- .../expressions/expression-utilities.ts | 68 +++-- .../expressions/function-expression.ts | 21 +- .../semantics/expressions/if-expression.ts | 26 +- .../semantics/expressions/index-expression.ts | 21 +- .../expressions/lookup-expression.ts | 37 ++- .../expressions/runtime-expression.ts | 41 ++- src/language/semantics/function-node.ts | 4 +- src/language/unparsing/plz-utilities.ts | 20 +- 36 files changed, 913 insertions(+), 624 deletions(-) diff --git a/README.md b/README.md index 23730e4..542b4d3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ git clone git@github.com:mkantor/please-lang-prototype.git cd please-lang-prototype npm install npm run build -echo '{@runtime, context => :context.program.start_time}' | ./please --output-format=json +echo '{@runtime, { context => :context.program.start_time }}' | ./please --output-format=json ``` There are more example programs in [`./examples`](./examples). @@ -178,10 +178,11 @@ expressions_. Most of the interesting stuff that Please does involves evaluating keyword expressions. Under the hood, keyword expressions are modeled as objects. For example, `:foo` -desugars to `{ @lookup, key: foo }`. All such expressions have a key `0` -referring to a value that is an `@`-prefixed atom (the keyword). Keywords -include `@apply`, `@check`, `@function`, `@if`, `@index`, `@lookup`, `@panic`, -and `@runtime`. +desugars to `{ @lookup, { key: foo } }`. All such expressions have a property +named `0` referring to a value that is an `@`-prefixed atom (the keyword). Most +keyword expressions also require a property named `1` to pass an argument to the +expression. Keywords include `@apply`, `@check`, `@function`, `@if`, `@index`, +`@lookup`, `@panic`, and `@runtime`. Currently only `@function`, `@lookup`, `@index`, and `@apply` have syntax sugars. @@ -210,7 +211,7 @@ function from other programming languages, except there can be any number of `@runtime` expressions in a given program. Here's an example: ``` -{@runtime, context => :context.program.start_time} +{@runtime, { context => :context.program.start_time }} ``` Unsurprisingly, this program outputs the current time when run. @@ -249,7 +250,7 @@ Take this example `plz` program: { language: Please message: :atom.prepend("Welcome to ")(:language) - now: {@runtime, context => :context.program.start_time} + now: {@runtime, { context => :context.program.start_time }} } ``` @@ -260,39 +261,57 @@ It desugars to the following `plo` program: language: Please message: { 0: @apply - function: { - 0: @apply + 1: { function: { - 0: @index - object: { - 0: @lookup - key: atom + 0: @apply + 1: { + function: { + 0: @index + 1: { + object: { + 0: @lookup + 1: { + key: atom + } + } + query: { + 0: prepend + } + } + } + argument: "Welcome to " } - query: { - 0: prepend + } + argument: { + 0: @lookup + 1: { + key: language } } - argument: "Welcome to " - } - argument: { - 0: @lookup - key: language } } now: { 0: @runtime 1: { - 0: @function - parameter: context - body: { - 0: @index - object: { - 0: @lookup - key: context - } - query: { - 0: program - 1: start_time + 0: { + 0: @function + 1: { + parameter: context + body: { + 0: @index + 1: { + object: { + 0: @lookup + 1: { + key: context + } + } + query: { + 0: program + 1: start_time + } + } + } } } } @@ -308,18 +327,26 @@ Which in turn compiles to the following `plt` program: message: "Welcome to Please" now: { 0: @runtime - function: { - 0: @function - parameter: context - body: { - 0: @index - object: { - 0: @lookup - key: context - } - query: { - 0: program - 1: start_time + 1: { + function: { + 0: @function + 1: { + parameter: context + body: { + 0: @index + 1: { + object: { + 0: @lookup + 1: { + key: context + } + } + query: { + 0: program + 1: start_time + } + } + } } } } @@ -333,7 +360,7 @@ Which produces the following runtime output: { language: Please message: "Welcome to Please" - now: "2025-02-14T18:45:14.168Z" + now: "2025-05-13T22:11:56.804Z" } ``` diff --git a/examples/fibonacci.plz b/examples/fibonacci.plz index 6c90536..c183883 100644 --- a/examples/fibonacci.plz +++ b/examples/fibonacci.plz @@ -1,23 +1,27 @@ { fibonacci: n => { @if - condition: :n < 2 - then: :n - else: :fibonacci(:n - 1) + :fibonacci(:n - 2) + { + condition: :n < 2 + then: :n + else: :fibonacci(:n - 1) + :fibonacci(:n - 2) + } } input: { @runtime - context => :context.arguments.lookup(input) + { context => :context.arguments.lookup(input) } } output: :input match { none: _ => "missing input argument" some: input => { @if - condition: :natural_number.is(:input) - then: :fibonacci(:input) - else: "input must be a natural number" + { + condition: :natural_number.is(:input) + then: :fibonacci(:input) + else: "input must be a natural number" + } } } }.output diff --git a/examples/kitchen-sink.plz b/examples/kitchen-sink.plz index 7548407..5b646d8 100644 --- a/examples/kitchen-sink.plz +++ b/examples/kitchen-sink.plz @@ -8,6 +8,6 @@ add_one: :integer.add(1) three: :add_one(:two) function: x => { value: :x } - conditional_value: :function({ @if, :sky_is_blue, :two, :three }) - side_effect: { @runtime, context => :context.log("this goes to stderr") } + conditional_value: :function({ @if, { :sky_is_blue, :two, :three } }) + side_effect: { @runtime, { context => :context.log("this goes to stderr") } } } diff --git a/examples/lookup-environment-variable.plz b/examples/lookup-environment-variable.plz index 0db7fa2..e11c51c 100644 --- a/examples/lookup-environment-variable.plz +++ b/examples/lookup-environment-variable.plz @@ -4,12 +4,14 @@ */ { @runtime - context => - :context.arguments.lookup(variable) match { - none: {} - some: :context.environment.lookup >> :match({ + { + context => + :context.arguments.lookup(variable) match { none: {} - some: :identity - }) - } + some: :context.environment.lookup >> :match({ + none: {} + some: :identity + }) + } + } } diff --git a/src/end-to-end.test.ts b/src/end-to-end.test.ts index 1be77eb..71abbb9 100644 --- a/src/end-to-end.test.ts +++ b/src/end-to-end.test.ts @@ -28,7 +28,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ['{,a,b,c,}', either.makeRight({ 0: 'a', 1: 'b', 2: 'c' })], ['{a,1:overwritten,c}', either.makeRight({ 0: 'a', 1: 'c' })], ['{overwritten,0:a,c}', either.makeRight({ 0: 'a', 1: 'c' })], - ['{@check, type:true, value:true}', either.makeRight('true')], + ['{@check, {type:true, value:true}}', either.makeRight('true')], [ '{@panic}', result => { @@ -37,12 +37,12 @@ testCases(endToEnd, code => code)('end-to-end tests', [ assert.deepEqual(result.value.kind, 'panic') }, ], - ['{a:A, b:{@lookup, a}}', either.makeRight({ a: 'A', b: 'A' })], - ['{a:A, {@lookup, a}}', either.makeRight({ a: 'A', 0: 'A' })], + ['{a:A, b:{@lookup, {a}}}', either.makeRight({ a: 'A', b: 'A' })], + ['{a:A, {@lookup, {a}}}', either.makeRight({ a: 'A', 0: 'A' })], ['{a:A, b: :a}', either.makeRight({ a: 'A', b: 'A' })], ['{a:A, :a}', either.makeRight({ a: 'A', 0: 'A' })], [ - '{@runtime, _ => {@panic}}', + '{@runtime, {_ => {@panic}}}', result => { assert(either.isLeft(result)) assert('kind' in result.value) @@ -53,16 +53,20 @@ testCases(endToEnd, code => code)('end-to-end tests', [ 'a => :a', either.makeRight({ 0: '@function', - parameter: 'a', - body: { 0: '@lookup', key: 'a' }, + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, }), ], [ '(a => :a)', either.makeRight({ 0: '@function', - parameter: 'a', - body: { 0: '@lookup', key: 'a' }, + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, }), ], ['{ a: ({ A }) }', either.makeRight({ a: { 0: 'A' } })], @@ -87,10 +91,14 @@ testCases(endToEnd, code => code)('end-to-end tests', [ "static data":"blah blah blah" "evaluated data": { 0:@runtime - function:{ - 0:@apply - function:{0:@index, object:{0:@lookup, key:object}, query:{0:lookup}} - argument:"key which does not exist in runtime context" + 1:{ + function:{ + 0:@apply + 1:{ + function:{0:@index, 1:{object:{0:@lookup, 1:{key:object}}, query:{0:lookup}}} + argument:"key which does not exist in runtime context" + } + } } } }`, @@ -145,9 +153,9 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ], [':match({ a: A })({ tag: a, value: {} })', either.makeRight('A')], [ - `{@runtime, context => + `{@runtime, { context => :identity(:context).program.start_time - }`, + }}`, output => { if (either.isLeft(output)) { assert.fail(output.value.message) @@ -174,7 +182,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ [':flow(:atom.append(b))(:atom.append(a))(z)', either.makeRight('zab')], [ `{@runtime - :object.lookup("key which does not exist in runtime context") + { :object.lookup("key which does not exist in runtime context") } }`, either.makeRight({ tag: 'none', value: {} }), ], @@ -205,21 +213,23 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ true: 'true', false: 'false' }), ], [ - `{@runtime, :flow( - :match({ - none: "environment does not exist" - some: :flow( - :match({ - none: "environment.lookup does not exist" - some: :apply(PATH) - }) - )( - :object.lookup(lookup) - ) - }) - )( - :object.lookup(environment) - )}`, + `{@runtime, { + :flow( + :match({ + none: "environment does not exist" + some: :flow( + :match({ + none: "environment.lookup does not exist" + some: :apply(PATH) + }) + )( + :object.lookup(lookup) + ) + }) + )( + :object.lookup(environment) + )} + }`, output => { if (either.isLeft(output)) { assert.fail(output.value.message) @@ -250,9 +260,9 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c', 3: 'd' }), ], [ - `{@runtime, context => + `{@runtime, { context => :context.environment.lookup(PATH) - }`, + }}`, output => { if (either.isLeft(output)) { assert.fail(output.value.message) @@ -263,10 +273,11 @@ testCases(endToEnd, code => code)('end-to-end tests', [ }, ], [ - `{@if, true + `{@if, { + true "it works!" {@panic} - }`, + }}`, either.makeRight('it works!'), ], [ @@ -278,20 +289,23 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c' }), ], [ - `{@runtime, context => - {@if, :boolean.not(:boolean.is(:context)) + `{@runtime, { context => + {@if, { + :boolean.not(:boolean.is(:context)) "it works!" {@panic} - } - }`, + }} + }}`, either.makeRight('it works!'), ], [ `{ fibonacci: n => { - @if, :integer.less_than(2)(:n) - then: :n - else: :fibonacci(:n - 1) + :fibonacci(:n - 2) + @if, { + :integer.less_than(2)(:n) + then: :n + else: :fibonacci(:n - 1) + :fibonacci(:n - 2) + } } result: :fibonacci(10) }.result`, @@ -348,7 +362,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight('2'), ], [ - `{@runtime, context => + `{@runtime, { context => ( PATH |> :context.environment.lookup @@ -357,7 +371,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ some: :atom.prepend("PATH=") }) ) - }`, + }}`, result => { if (either.isLeft(result)) { assert.fail(result.value.message) diff --git a/src/language/compiling/compiler.test.ts b/src/language/compiling/compiler.test.ts index db271d3..9bfbc2b 100644 --- a/src/language/compiling/compiler.test.ts +++ b/src/language/compiling/compiler.test.ts @@ -16,16 +16,21 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( 'compiler', [ ['Hello, world!', success('Hello, world!')], - [['@check', true, ['@lookup', 'identity']], success('true')], + [['@check', [true, ['@lookup', ['identity']]]], success('true')], [ { - true1: ['@check', true, ['@lookup', 'identity']], - true2: ['@apply', ['@index', ['@lookup', 'boolean'], ['not']], false], - false1: ['@check', false, ['@index', ['@lookup', 'boolean'], ['is']]], + true1: ['@check', [true, ['@lookup', ['identity']]]], + true2: [ + '@apply', + [['@index', [['@lookup', ['boolean']], ['not']]], false], + ], + false1: [ + '@check', + [false, ['@index', [['@lookup', ['boolean']], ['is']]]], + ], false2: [ '@apply', - ['@index', ['@lookup', 'boolean'], ['is']], - 'not a boolean', + [['@index', [['@lookup', ['boolean']], ['is']]], 'not a boolean'], ], }, success({ @@ -36,29 +41,40 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( }), ], [ - ['@runtime', ['@lookup', 'identity']], + ['@runtime', [['@lookup', ['identity']]]], success({ 0: '@runtime', - function: { 0: '@lookup', key: 'identity' }, + 1: { function: { 0: '@lookup', 1: { key: 'identity' } } }, }), ], [ [ '@runtime', - ['@apply', ['@lookup', 'identity'], ['@lookup', 'identity']], + [ + [ + '@apply', + [ + ['@lookup', ['identity']], + ['@lookup', ['identity']], + ], + ], + ], ], success({ 0: '@runtime', - function: { 0: '@lookup', key: 'identity' }, + 1: { function: { 0: '@lookup', 1: { key: 'identity' } } }, }), ], [ - ['@check', 'not a boolean', ['@index', ['@lookup', 'boolean'], ['is']]], + [ + '@check', + ['not a boolean', ['@index', [['@lookup', ['boolean']], ['is']]]], + ], output => assert(either.isLeft(output)), ], - [['@lookup', 'compose'], output => assert(either.isLeft(output))], + [[['@lookup', ['compose']]], output => assert(either.isLeft(output))], [ - ['@runtime', ['@index', ['@lookup', 'boolean'], ['not']]], + ['@runtime', [['@index', [['@lookup', ['boolean']], ['not']]]]], output => { assert(either.isLeft(output)) assert(output.value.kind === 'typeMismatch') @@ -68,9 +84,13 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( [ '@runtime', [ - '@apply', - ['@lookup', 'identity'], - ['@index', ['@lookup', 'boolean'], ['not']], + [ + '@apply', + [ + ['@lookup', ['identity']], + ['@index', [['@lookup', ['boolean']], ['not']]], + ], + ], ], ], output => { @@ -82,26 +102,42 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( [ '@runtime', [ - '@apply', - ['@apply', ['@lookup', 'flow'], ['@lookup', 'identity']], - ['@lookup', 'identity'], + [ + '@apply', + [ + [ + '@apply', + [ + ['@lookup', ['flow']], + ['@lookup', ['identity']], + ], + ], + ['@lookup', ['identity']], + ], + ], ], ], success({ 0: '@runtime', - function: { - 0: '@apply', + 1: { function: { 0: '@apply', - function: { 0: '@lookup', key: 'flow' }, - argument: { 0: '@lookup', key: 'identity' }, + 1: { + function: { + 0: '@apply', + 1: { + function: { 0: '@lookup', 1: { key: 'flow' } }, + argument: { 0: '@lookup', 1: { key: 'identity' } }, + }, + }, + argument: { 0: '@lookup', 1: { key: 'identity' } }, + }, }, - argument: { 0: '@lookup', key: 'identity' }, }, }), ], [ - ['@runtime', ['@index', ['@lookup', 'boolean'], ['not']]], + ['@runtime', [['@index', [['@lookup', ['boolean']], ['not']]]]], output => { assert(either.isLeft(output)) assert(output.value.kind === 'typeMismatch') @@ -110,26 +146,38 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( [ { 0: '@runtime', - function: { - 0: '@apply', + 1: { function: { - 0: '@index', - object: { 0: '@lookup', key: 'object' }, - query: { 0: 'lookup' }, + 0: '@apply', + 1: { + function: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'object' } }, + query: { 0: 'lookup' }, + }, + }, + argument: 'key which does not exist in runtime context', + }, }, - argument: 'key which does not exist in runtime context', }, }, success({ 0: '@runtime', - function: { - 0: '@apply', + 1: { function: { - 0: '@index', - object: { 0: '@lookup', key: 'object' }, - query: { 0: 'lookup' }, + 0: '@apply', + 1: { + function: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'object' } }, + query: { 0: 'lookup' }, + }, + }, + argument: 'key which does not exist in runtime context', + }, }, - argument: 'key which does not exist in runtime context', }, }), ], diff --git a/src/language/compiling/semantics/keyword-handlers/apply-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/apply-handler.test.ts index 2d72f39..8041725 100644 --- a/src/language/compiling/semantics/keyword-handlers/apply-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/apply-handler.test.ts @@ -3,91 +3,125 @@ import assert from 'node:assert' import { elaborationSuite, success } from '../test-utilities.test.js' elaborationSuite('@apply', [ - [{ 0: '@apply', 1: { 0: '@lookup', key: 'identity' }, 2: 'a' }, success('a')], + [ + { 0: '@apply', 1: { 0: { 0: '@lookup', 1: { key: 'identity' } }, 1: 'a' } }, + success('a'), + ], [ { 0: '@apply', - function: { 0: '@lookup', key: 'identity' }, - argument: 'a', + 1: { + function: { 0: '@lookup', 1: { key: 'identity' } }, + argument: 'a', + }, }, success('a'), ], [ { 0: '@apply', - function: { 0: '@lookup', key: 'identity' }, - argument: { foo: 'bar' }, + 1: { + function: { 0: '@lookup', 1: { key: 'identity' } }, + argument: { foo: 'bar' }, + }, }, success({ foo: 'bar' }), ], [ - { 0: '@apply', function: 'not a function', argument: 'a' }, + { 0: '@apply', 1: { function: 'not a function', argument: 'a' } }, output => assert(either.isLeft(output)), ], [ { 0: '@apply', - function: { 0: '@function', 1: 'x', 2: { 0: '@lookup', 1: 'x' } }, - argument: 'identity is identical', + 1: { + function: { + 0: '@function', + 1: { 0: 'x', 1: { 0: '@lookup', 1: { 0: 'x' } } }, + }, + argument: 'identity is identical', + }, }, success('identity is identical'), ], [ { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'b', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', body: { - A: { 0: '@lookup', key: 'a' }, - B: { 0: '@lookup', key: 'b' }, + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'b', + body: { + A: { 0: '@lookup', 1: { key: 'a' } }, + B: { 0: '@lookup', 1: { key: 'b' } }, + }, + }, + }, + argument: 'b', + }, }, }, - argument: 'b', }, + argument: 'a', }, - argument: 'a', }, success({ A: 'a', B: 'b' }), ], [ { 0: '@apply', - function: { - 0: '@function', - 1: 'x', - 2: { - 0: '@apply', - function: { - 0: '@index', - 1: { 0: '@lookup', 1: 'boolean' }, - 2: { 0: 'not' }, + 1: { + function: { + 0: '@function', + 1: { + 0: 'x', + 1: { + 0: '@apply', + 1: { + function: { + 0: '@index', + 1: { + 0: { 0: '@lookup', 1: { 0: 'boolean' } }, + 1: { 0: 'not' }, + }, + }, + argument: { 0: '@lookup', 1: { 0: 'x' } }, + }, + }, }, - argument: { 0: '@lookup', 1: 'x' }, }, + argument: 'false', }, - argument: 'false', }, success('true'), ], [ { 0: '@apply', - function: { - 0: '@function', - 1: 'x', - 2: { - 0: '@index', - 1: { 0: '@lookup', 1: 'x' }, - 2: { 0: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + 0: 'x', + 1: { + 0: '@index', + 1: { + 0: { 0: '@lookup', 1: { 0: 'x' } }, + 1: { 0: 'a' }, + }, + }, + }, }, + argument: { a: 'it works' }, }, - argument: { a: 'it works' }, }, success('it works'), ], @@ -103,26 +137,34 @@ elaborationSuite('@apply', [ a: 'a', b: { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - a: 'b', - b: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - 0: '@lookup', - key: 'a', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { + a: 'b', + b: { + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { + 0: '@lookup', + 1: { key: 'a' }, + }, + }, + }, + argument: 'it works', + }, }, }, - argument: 'it works', }, }, + argument: 'unused', }, - argument: 'unused', }, }, success({ @@ -145,23 +187,31 @@ elaborationSuite('@apply', [ a: 'a', b: { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - a: 'it works', - b: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { 0: '@lookup', key: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { + a: 'it works', + b: { + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, + argument: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, }, - argument: { 0: '@lookup', key: 'a' }, }, }, + argument: 'unused', }, - argument: 'unused', }, }, success({ @@ -183,22 +233,30 @@ elaborationSuite('@apply', [ a: 'it works', b: { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - b: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { 0: '@lookup', key: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { + b: { + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, + argument: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, }, - argument: { 0: '@lookup', key: 'a' }, }, }, + argument: { 0: '@lookup', 1: { key: 'a' } }, }, - argument: { 0: '@lookup', key: 'a' }, }, }, success({ @@ -220,23 +278,31 @@ elaborationSuite('@apply', [ a: 'a', b: { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { - a: 'it works', - b: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'b', - body: { 0: '@lookup', key: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { + a: 'it works', + b: { + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'b', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, + argument: 'unused', + }, + }, }, - argument: 'unused', }, }, + argument: 'unused', }, - argument: 'unused', }, }, success({ @@ -259,23 +325,31 @@ elaborationSuite('@apply', [ a: 'it works', b: { 0: '@apply', - function: { - 0: '@function', - parameter: 'b', - body: { - b: 'b', - c: { - 0: '@apply', - function: { - 0: '@function', - parameter: 'b', - body: { 0: '@lookup', key: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'b', + body: { + b: 'b', + c: { + 0: '@apply', + 1: { + function: { + 0: '@function', + 1: { + parameter: 'b', + body: { 0: '@lookup', 1: { key: 'a' } }, + }, + }, + argument: 'unused', + }, + }, }, - argument: 'unused', }, }, + argument: 'unused', }, - argument: 'unused', }, }, success({ diff --git a/src/language/compiling/semantics/keyword-handlers/apply-handler.ts b/src/language/compiling/semantics/keyword-handlers/apply-handler.ts index 2ff3208..bb6a59b 100644 --- a/src/language/compiling/semantics/keyword-handlers/apply-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/apply-handler.ts @@ -18,14 +18,14 @@ export const applyKeywordHandler: KeywordHandler = ( either.flatMap( readApplyExpression(expression), (applyExpression): Either => { - if (containsAnyUnelaboratedNodes(applyExpression.argument)) { + if (containsAnyUnelaboratedNodes(applyExpression[1].argument)) { // The argument isn't ready, so keep the @apply unelaborated. return either.makeRight(applyExpression) } else { - const functionToApply = asSemanticGraph(applyExpression.function) + const functionToApply = asSemanticGraph(applyExpression[1].function) if (isFunctionNode(functionToApply)) { const result = functionToApply( - asSemanticGraph(applyExpression.argument), + asSemanticGraph(applyExpression[1].argument), ) if (either.isLeft(result)) { if (result.value.kind === 'dependencyUnavailable') { diff --git a/src/language/compiling/semantics/keyword-handlers/check-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/check-handler.test.ts index 457d883..9ecb300 100644 --- a/src/language/compiling/semantics/keyword-handlers/check-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/check-handler.test.ts @@ -3,45 +3,54 @@ import assert from 'node:assert' import { elaborationSuite, success } from '../test-utilities.test.js' elaborationSuite('@check', [ - [{ 0: '@check', 1: 'a', 2: 'a' }, success('a')], - [{ 0: '@check', type: 'a', value: 'a' }, success('a')], - [{ 0: '@check', type: '', value: '' }, success('')], - [{ 0: '@check', type: '@@a', value: '@@a' }, success('@a')], - [{ 0: '@check', 1: 'a', 2: 'B' }, output => assert(either.isLeft(output))], + [{ 0: '@check', 1: { 0: 'a', 1: 'a' } }, success('a')], + [{ 0: '@check', 1: { type: 'a', value: 'a' } }, success('a')], + [{ 0: '@check', 1: { type: '', value: '' } }, success('')], + [{ 0: '@check', 1: { type: '@@a', value: '@@a' } }, success('@a')], [ - { 0: '@check', type: 'a', value: 'B' }, + { 0: '@check', 1: { 0: 'a', 1: 'B' } }, output => assert(either.isLeft(output)), ], [ - { 0: '@check', type: 'a', value: {} }, + { 0: '@check', 1: { type: 'a', value: 'B' } }, output => assert(either.isLeft(output)), ], [ - { 0: '@check', type: {}, value: 'a' }, + { 0: '@check', 1: { type: 'a', value: {} } }, + output => assert(either.isLeft(output)), + ], + [ + { 0: '@check', 1: { type: {}, value: 'a' } }, output => assert(either.isLeft(output)), ], [ { 0: '@check', - type: { a: 'b' }, - value: { a: 'not b' }, + 1: { + type: { a: 'b' }, + value: { a: 'not b' }, + }, }, output => assert(either.isLeft(output)), ], [ { 0: '@check', - type: { something: { more: 'complicated' } }, - value: { something: { more: 'complicated' } }, + 1: { + type: { something: { more: 'complicated' } }, + value: { something: { more: 'complicated' } }, + }, }, success({ something: { more: 'complicated' } }), ], [ { 0: '@check', - type: { something: { more: 'complicated' } }, - value: { - something: { more: 'complicated, which also does not typecheck' }, + 1: { + type: { something: { more: 'complicated' } }, + value: { + something: { more: 'complicated, which also does not typecheck' }, + }, }, }, output => assert(either.isLeft(output)), @@ -49,16 +58,20 @@ elaborationSuite('@check', [ [ { 0: '@check', - type: { a: 'b' }, - value: {}, + 1: { + type: { a: 'b' }, + value: {}, + }, }, output => assert(either.isLeft(output)), ], [ { 0: '@check', - type: { a: { b: 'c' } }, - value: { a: {} }, + 1: { + type: { a: { b: 'c' } }, + value: { a: {} }, + }, }, output => assert(either.isLeft(output)), ], @@ -66,24 +79,30 @@ elaborationSuite('@check', [ [ { 0: '@check', - type: { a: 'b' }, - value: { a: 'b', c: 'd' }, + 1: { + type: { a: 'b' }, + value: { a: 'b', c: 'd' }, + }, }, success({ a: 'b', c: 'd' }), ], [ { 0: '@check', - type: {}, - value: { a: 'b' }, + 1: { + type: {}, + value: { a: 'b' }, + }, }, success({ a: 'b' }), ], [ { 0: '@check', - type: { a: {} }, - value: { a: { b: 'c' } }, + 1: { + type: { a: {} }, + value: { a: { b: 'c' } }, + }, }, success({ a: { b: 'c' } }), ], diff --git a/src/language/compiling/semantics/keyword-handlers/check-handler.ts b/src/language/compiling/semantics/keyword-handlers/check-handler.ts index 3c3c93d..109cedd 100644 --- a/src/language/compiling/semantics/keyword-handlers/check-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/check-handler.ts @@ -17,7 +17,7 @@ export const checkKeywordHandler: KeywordHandler = ( expression: Expression, context: ExpressionContext, ): Either => - either.flatMap(readCheckExpression(expression), ({ value, type }) => + either.flatMap(readCheckExpression(expression), ({ 1: { value, type } }) => check({ value: asSemanticGraph(value), type: asSemanticGraph(type), diff --git a/src/language/compiling/semantics/keyword-handlers/function-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/function-handler.test.ts index 9e7daba..19326e4 100644 --- a/src/language/compiling/semantics/keyword-handlers/function-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/function-handler.test.ts @@ -6,11 +6,11 @@ import { elaborationSuite } from '../test-utilities.test.js' elaborationSuite('@function', [ [ - { 0: '@function', 1: 'not a function' }, + { 0: '@function', 1: { 0: 'not a function' } }, output => assert(either.isLeft(output)), ], [ - { 0: '@function', 1: 'x', 2: { 0: '@lookup', 1: 'x' } }, + { 0: '@function', 1: { 0: 'x', 1: { 0: '@lookup', 1: { 0: 'x' } } } }, elaboratedFunction => { assert(!either.isLeft(elaboratedFunction)) assert(isFunctionNode(elaboratedFunction.value)) @@ -22,8 +22,7 @@ elaborationSuite('@function', [ elaboratedFunction.value.serialize(), either.makeRight({ 0: '@function', - parameter: 'x', - body: { 0: '@lookup', key: 'x' }, + 1: { parameter: 'x', body: { 0: '@lookup', 1: { key: 'x' } } }, }), ) }, diff --git a/src/language/compiling/semantics/keyword-handlers/function-handler.ts b/src/language/compiling/semantics/keyword-handlers/function-handler.ts index 9fef0c0..700833f 100644 --- a/src/language/compiling/semantics/keyword-handlers/function-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/function-handler.ts @@ -30,7 +30,7 @@ export const functionKeywordHandler: KeywordHandler = ( return: types.something, }, () => either.makeRight(functionExpression), - option.makeSome(functionExpression.parameter), + option.makeSome(functionExpression[1].parameter), argument => apply(functionExpression, argument, context), ), ) @@ -40,8 +40,8 @@ const apply = ( argument: SemanticGraph, context: ExpressionContext, ): ReturnType => { - const parameter = expression.parameter - const body = asSemanticGraph(expression.body) + const parameter = expression[1].parameter + const body = asSemanticGraph(expression[1].body) const ownKey = context.location[context.location.length - 1] if (ownKey === undefined) { @@ -76,7 +76,7 @@ const apply = ( argument => apply(expression, argument, context), ), // Put the argument in scope. - [expression.parameter]: argument, + [parameter]: argument, [returnKey]: body, }), ), diff --git a/src/language/compiling/semantics/keyword-handlers/if-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/if-handler.test.ts index c789e92..4a1e1e6 100644 --- a/src/language/compiling/semantics/keyword-handlers/if-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/if-handler.test.ts @@ -1,14 +1,22 @@ import { elaborationSuite, success } from '../test-utilities.test.js' elaborationSuite('@if', [ - [{ 0: '@if', condition: 'false', then: 'no', else: 'yes' }, success('yes')], - [{ 0: '@if', condition: 'true', then: 'yes', else: 'no' }, success('yes')], + [ + { 0: '@if', 1: { condition: 'false', then: 'no', else: 'yes' } }, + success('yes'), + ], + [ + { 0: '@if', 1: { condition: 'true', then: 'yes', else: 'no' } }, + success('yes'), + ], [ { 0: '@if', - condition: 'true', - then: 'it works!', - else: { 0: '@panic' }, + 1: { + condition: 'true', + then: 'it works!', + else: { 0: '@panic' }, + }, }, success('it works!'), ], @@ -17,9 +25,11 @@ elaborationSuite('@if', [ a: 'it works!', b: { 0: '@if', - condition: 'true', - then: { 0: '@lookup', key: 'a' }, - else: { 0: '@panic' }, + 1: { + condition: 'true', + then: { 0: '@lookup', 1: { key: 'a' } }, + else: { 0: '@panic' }, + }, }, }, success({ a: 'it works!', b: 'it works!' }), @@ -27,26 +37,34 @@ elaborationSuite('@if', [ [ { 0: '@if', - condition: 'false', - then: { 0: '@panic' }, - else: 'it works!', + 1: { + condition: 'false', + then: { 0: '@panic' }, + else: 'it works!', + }, }, success('it works!'), ], [ { 0: '@if', - condition: { - 0: '@apply', - function: { - 0: '@index', - object: { 0: '@lookup', key: 'boolean' }, - query: { 0: 'not' }, + 1: { + condition: { + 0: '@apply', + 1: { + function: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'boolean' } }, + query: { 0: 'not' }, + }, + }, + argument: 'false', + }, }, - argument: 'false', + then: 'it works!', + else: { 0: '@panic' }, }, - then: 'it works!', - else: { 0: '@panic' }, }, success('it works!'), ], @@ -54,16 +72,22 @@ elaborationSuite('@if', [ { 0: '@if', 1: { - 0: '@apply', - function: { - 0: '@index', - object: { 0: '@lookup', key: 'boolean' }, - query: { 0: 'not' }, + 0: { + 0: '@apply', + 1: { + function: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'boolean' } }, + query: { 0: 'not' }, + }, + }, + argument: 'false', + }, }, - argument: 'false', + 1: 'it works!', + 2: { 0: '@panic' }, }, - 2: 'it works!', - 3: { 0: '@panic' }, }, success('it works!'), ], @@ -71,9 +95,11 @@ elaborationSuite('@if', [ { a: { 0: '@if', - condition: { 0: '@lookup', key: 'b' }, - then: 'it works!', - else: { 0: '@panic' }, + 1: { + condition: { 0: '@lookup', 1: { key: 'b' } }, + then: 'it works!', + else: { 0: '@panic' }, + }, }, b: 'true', }, diff --git a/src/language/compiling/semantics/keyword-handlers/if-handler.ts b/src/language/compiling/semantics/keyword-handlers/if-handler.ts index 47aa711..83ab5c2 100644 --- a/src/language/compiling/semantics/keyword-handlers/if-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/if-handler.ts @@ -10,6 +10,7 @@ import { serialize, type Expression, type ExpressionContext, + type KeyPath, type KeywordHandler, type SemanticGraph, } from '../../../semantics.js' @@ -19,35 +20,45 @@ export const ifKeywordHandler: KeywordHandler = ( context: ExpressionContext, ): Either => either.flatMap(readIfExpression(expression), ifExpression => { - const expressionKeys = { + // TODO: Make this less ad-hoc. + if ( + !('1' in expression) || + typeof expression[1] !== 'object' || + expression[1] === null + ) { + throw new Error( + '`@if` expression was invalid after being validated. This is a bug!', + ) + } + const subexpressionKeyPaths = { // Note: this must be kept in alignment with `readIfExpression`. - condition: 'condition' in expression ? 'condition' : '1', - then: 'then' in expression ? 'then' : '2', - else: 'else' in expression ? 'else' : '3', + condition: ['1', 'condition' in expression[1] ? 'condition' : '0'], + then: ['1', 'then' in expression[1] ? 'then' : '1'], + else: ['1', 'else' in expression[1] ? 'else' : '2'], } const elaboratedCondition = evaluateSubexpression( - expressionKeys.condition, + subexpressionKeyPaths.condition, context, - ifExpression.condition, + ifExpression[1].condition, ) return either.flatMap(elaboratedCondition, elaboratedCondition => { if (elaboratedCondition === 'true') { return either.map( evaluateSubexpression( - expressionKeys.then, + subexpressionKeyPaths.then, context, - ifExpression.then, + ifExpression[1].then, ), asSemanticGraph, ) } else if (elaboratedCondition === 'false') { return either.map( evaluateSubexpression( - expressionKeys.else, + subexpressionKeyPaths.else, context, - ifExpression.else, + ifExpression[1].else, ), asSemanticGraph, ) @@ -59,7 +70,7 @@ export const ifKeywordHandler: KeywordHandler = ( // Return an unelaborated `@if` expression. return either.makeRight( makeIfExpression({ - ...ifExpression, + ...ifExpression[1], condition: elaboratedCondition, }), ) @@ -76,7 +87,7 @@ export const ifKeywordHandler: KeywordHandler = ( }) const evaluateSubexpression = ( - key: string, + subKeyPath: KeyPath, context: ExpressionContext, subexpression: SemanticGraph | Molecule, ) => @@ -86,6 +97,6 @@ const evaluateSubexpression = ( elaborateWithContext(serializedSubexpression, { keywordHandlers: context.keywordHandlers, program: context.program, - location: [...context.location, key], + location: [...context.location, ...subKeyPath], }), ) diff --git a/src/language/compiling/semantics/keyword-handlers/index-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/index-handler.test.ts index a5a58f7..b8e1db4 100644 --- a/src/language/compiling/semantics/keyword-handlers/index-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/index-handler.test.ts @@ -3,29 +3,36 @@ import assert from 'node:assert' import { elaborationSuite, success } from '../test-utilities.test.js' elaborationSuite('@index', [ - [{ 0: '@index', 1: { foo: 'bar' }, 2: { 0: 'foo' } }, success('bar')], + [{ 0: '@index', 1: { 0: { foo: 'bar' }, 1: { 0: 'foo' } } }, success('bar')], [ - { 0: '@index', object: { foo: 'bar' }, query: { 0: 'foo' } }, + { 0: '@index', 1: { object: { foo: 'bar' }, query: { 0: 'foo' } } }, success('bar'), ], [ { 0: '@index', - object: { a: { b: { c: 'it works' } } }, - query: { 0: 'a', 1: 'b', 2: 'c' }, + 1: { + object: { a: { b: { c: 'it works' } } }, + query: { 0: 'a', 1: 'b', 2: 'c' }, + }, }, success('it works'), ], [ { 0: '@index', - object: { a: { b: { c: 'it works' } } }, - query: { 0: 'a', 1: 'b' }, + 1: { + object: { a: { b: { c: 'it works' } } }, + query: { 0: 'a', 1: 'b' }, + }, }, success({ c: 'it works' }), ], [ - { 0: '@index', object: {}, query: { 0: 'thisPropertyDoesNotExist' } }, + { + 0: '@index', + 1: { object: {}, query: { 0: 'thisPropertyDoesNotExist' } }, + }, output => assert(either.isLeft(output)), ], ]) diff --git a/src/language/compiling/semantics/keyword-handlers/index-handler.ts b/src/language/compiling/semantics/keyword-handlers/index-handler.ts index e4b4f60..9c76821 100644 --- a/src/language/compiling/semantics/keyword-handlers/index-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/index-handler.ts @@ -20,15 +20,15 @@ export const indexKeywordHandler: KeywordHandler = ( ): Either => either.flatMap(readIndexExpression(expression), indexExpression => either.flatMap( - keyPathFromObjectNodeOrMolecule(indexExpression.query), + keyPathFromObjectNodeOrMolecule(indexExpression[1].query), keyPath => { - if (containsAnyUnelaboratedNodes(indexExpression.object)) { + if (containsAnyUnelaboratedNodes(indexExpression[1].object)) { // The object isn't ready, so keep the @index unelaborated. return either.makeRight(indexExpression) } else { return option.match( applyKeyPathToSemanticGraph( - asSemanticGraph(indexExpression.object), + asSemanticGraph(indexExpression[1].object), keyPath, ), { diff --git a/src/language/compiling/semantics/keyword-handlers/lookup-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/lookup-handler.test.ts index 088118f..b145cd8 100644 --- a/src/language/compiling/semantics/keyword-handlers/lookup-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/lookup-handler.test.ts @@ -6,21 +6,21 @@ elaborationSuite('@lookup', [ [ { foo: 'bar', - bar: { 0: '@lookup', 1: 'foo' }, + bar: { 0: '@lookup', 1: { 0: 'foo' } }, }, success({ foo: 'bar', bar: 'bar' }), ], [ { foo: 'bar', - bar: { 0: '@lookup', key: 'foo' }, + bar: { 0: '@lookup', 1: { key: 'foo' } }, }, success({ foo: 'bar', bar: 'bar' }), ], [ { foo: 'bar', - bar: { 0: '@lookup', 1: 'foo' }, + bar: { 0: '@lookup', 1: { 0: 'foo' } }, }, success({ foo: 'bar', bar: 'bar' }), ], @@ -29,7 +29,7 @@ elaborationSuite('@lookup', [ a: 'A', b: { a: 'different A', - b: { 0: '@lookup', key: 'a' }, + b: { 0: '@lookup', 1: { key: 'a' } }, }, }, success({ @@ -43,17 +43,17 @@ elaborationSuite('@lookup', [ [ { foo: 'bar', - bar: { 0: '@lookup', 1: 'foo' }, - baz: { 0: '@lookup', 1: 'bar' }, + bar: { 0: '@lookup', 1: { 0: 'foo' } }, + baz: { 0: '@lookup', 1: { 0: 'bar' } }, }, success({ foo: 'bar', bar: 'bar', baz: 'bar' }), ], [ - { a: { 0: '@lookup', _: 'missing key' } }, + { a: { 0: '@lookup', 1: { _: 'missing key' } } }, output => assert(either.isLeft(output)), ], [ - { a: { 0: '@lookup', key: 'thisPropertyDoesNotExist' } }, + { a: { 0: '@lookup', 1: { key: 'thisPropertyDoesNotExist' } } }, output => assert(either.isLeft(output)), ], @@ -62,7 +62,7 @@ elaborationSuite('@lookup', [ { a: 'C', b: { - c: { 0: '@lookup', key: 'a' }, + c: { 0: '@lookup', 1: { key: 'a' } }, }, }, success({ @@ -77,7 +77,7 @@ elaborationSuite('@lookup', [ a: 'C', b: { a: 'other C', // this `a` should be referenced - c: { 0: '@lookup', key: 'a' }, + c: { 0: '@lookup', 1: { key: 'a' } }, }, }, success({ diff --git a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts index 2ca8f60..34bad41 100644 --- a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts @@ -22,7 +22,7 @@ export const lookupKeywordHandler: KeywordHandler = ( expression: Expression, context: ExpressionContext, ): Either => - either.flatMap(readLookupExpression(expression), ({ key }) => { + either.flatMap(readLookupExpression(expression), ({ 1: { key } }) => { if (isObjectNode(context.program)) { return either.flatMap(lookup({ context, key }), possibleValue => option.match(possibleValue, { @@ -61,19 +61,26 @@ const lookup = ({ } else { const pathToCurrentScope = context.location.slice(0, -1) + // TODO: This is sketchy, or at least confusingly-written. Improve test coverage to weed out + // potential bugginess, and consider refactoring to make it easier to follow. + const pathToPossibleExpression = + pathToCurrentScope[pathToCurrentScope.length - 1] === '1' + ? pathToCurrentScope.slice(0, -1) + : pathToCurrentScope + const possibleLookedUpValue = option.flatMap( - applyKeyPathToSemanticGraph(context.program, pathToCurrentScope), + applyKeyPathToSemanticGraph(context.program, pathToPossibleExpression), scope => either.match(readFunctionExpression(scope), { left: _ => // Lookups should not resolve to expression properties. - // For example the value of the lookup expression in `a => :parameter` (which desugars - // to `{@function, parameter: a, body: {@lookup, key: parameter}}`) should not be `a`. + // For example the value of the lookup expression in `a => :parameter` (desugared: + // `{@function, {parameter: a, body: {@lookup, {key: parameter}}}}`) should not be `a`. isExpression(scope) ? option.none : applyKeyPathToSemanticGraph(scope, [key]), right: functionExpression => - functionExpression.parameter === key + functionExpression[1].parameter === key ? // Keep an unelaborated `@lookup` around for resolution when the `@function` is called. option.makeSome(makeLookupExpression(key)) : option.none, diff --git a/src/language/compiling/semantics/keyword-handlers/runtime-handler.test.ts b/src/language/compiling/semantics/keyword-handlers/runtime-handler.test.ts index 70b0645..07a0c45 100644 --- a/src/language/compiling/semantics/keyword-handlers/runtime-handler.test.ts +++ b/src/language/compiling/semantics/keyword-handlers/runtime-handler.test.ts @@ -6,15 +6,18 @@ import { elaborationSuite } from '../test-utilities.test.js' elaborationSuite('@runtime', [ [ - { 0: '@runtime', 1: { 0: '@lookup', key: 'identity' } }, + { 0: '@runtime', 1: { 0: { 0: '@lookup', 1: { key: 'identity' } } } }, either.makeRight( withPhantomData()( - makeObjectNode({ 0: '@runtime', function: prelude['identity']! }), + makeObjectNode({ + 0: '@runtime', + 1: makeObjectNode({ function: prelude['identity']! }), + }), ), ), ], [ - { 0: '@runtime', 1: 'not a function' }, + { 0: '@runtime', 1: { 0: 'not a function' } }, output => assert(either.isLeft(output)), ], ]) diff --git a/src/language/compiling/semantics/keyword-handlers/runtime-handler.ts b/src/language/compiling/semantics/keyword-handlers/runtime-handler.ts index cca10a2..5507ef7 100644 --- a/src/language/compiling/semantics/keyword-handlers/runtime-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/runtime-handler.ts @@ -1,7 +1,6 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../../errors.js' import { - asSemanticGraph, isAssignable, isFunctionNode, makeRuntimeExpression, @@ -18,23 +17,26 @@ export const runtimeKeywordHandler: KeywordHandler = ( expression: Expression, _context: ExpressionContext, ): Either => - either.flatMap(readRuntimeExpression(expression), ({ function: f }) => { - const runtimeFunction = asSemanticGraph(f) - if (isFunctionNode(runtimeFunction)) { - const runtimeFunctionSignature = runtimeFunction.signature - return !isAssignable({ - source: types.runtimeContext, - target: replaceAllTypeParametersWithTheirConstraints( - runtimeFunctionSignature.parameter, - ), - }) - ? either.makeLeft({ - kind: 'typeMismatch', - message: '@runtime function must accept a runtime context argument', - }) - : either.makeRight(makeRuntimeExpression(f)) - } else { - // TODO: Type-check unelaborated nodes. - return either.makeRight(makeRuntimeExpression(f)) - } - }) + either.flatMap( + readRuntimeExpression(expression), + ({ 1: { function: runtimeFunction } }) => { + if (isFunctionNode(runtimeFunction)) { + const runtimeFunctionSignature = runtimeFunction.signature + return !isAssignable({ + source: types.runtimeContext, + target: replaceAllTypeParametersWithTheirConstraints( + runtimeFunctionSignature.parameter, + ), + }) + ? either.makeLeft({ + kind: 'typeMismatch', + message: + '@runtime function must accept a runtime context argument', + }) + : either.makeRight(makeRuntimeExpression(runtimeFunction)) + } else { + // TODO: Type-check unelaborated nodes. + return either.makeRight(makeRuntimeExpression(runtimeFunction)) + } + }, + ) diff --git a/src/language/compiling/unparsing.test.ts b/src/language/compiling/unparsing.test.ts index 8721119..b8aa59a 100644 --- a/src/language/compiling/unparsing.test.ts +++ b/src/language/compiling/unparsing.test.ts @@ -36,13 +36,17 @@ testCases( { identity: { 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { 0: 'a' } }, + }, }, test: { 0: '@apply', - function: { 0: '@lookup', 1: 'identity' }, - argument: 'it works!', + 1: { + function: { 0: '@lookup', 1: { 0: 'identity' } }, + argument: 'it works!', + }, }, }, either.makeRight('{ identity: a => :a, test: :identity("it works!") }'), @@ -50,12 +54,16 @@ testCases( [ { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { 0: 'a' } }, + }, + }, + argument: 'it works!', }, - argument: 'it works!', }, either.makeRight('(a => :a)("it works!")'), ], @@ -63,16 +71,24 @@ testCases( { 0: '@runtime', 1: { - 0: '@function', - parameter: 'context', - body: { - 0: '@index', - object: { 0: '@lookup', key: 'context' }, - query: { 0: 'program', 1: 'start_time' }, + 0: { + 0: '@function', + 1: { + parameter: 'context', + body: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'context' } }, + query: { 0: 'program', 1: 'start_time' }, + }, + }, + }, }, }, }, - either.makeRight('{ @runtime, context => :context.program.start_time }'), + either.makeRight( + '{ @runtime, { context => :context.program.start_time } }', + ), ], [ { @@ -83,8 +99,10 @@ testCases( }, test: { 0: '@index', - object: { 0: '@lookup', 1: 'a.b' }, - query: { 0: 'c "d"', 1: 'e.f' }, + 1: { + object: { 0: '@lookup', 1: { 0: 'a.b' } }, + query: { 0: 'c "d"', 1: 'e.f' }, + }, }, }, either.makeRight( @@ -115,13 +133,14 @@ testCases( { identity: { 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { parameter: 'a', body: { 0: '@lookup', 1: { 0: 'a' } } }, }, test: { 0: '@apply', - function: { 0: '@lookup', 1: 'identity' }, - argument: 'it works!', + 1: { + function: { 0: '@lookup', 1: { 0: 'identity' } }, + argument: 'it works!', + }, }, }, either.makeRight( @@ -131,12 +150,16 @@ testCases( [ { 0: '@apply', - function: { - 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { + function: { + 0: '@function', + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { 0: 'a' } }, + }, + }, + argument: 'it works!', }, - argument: 'it works!', }, either.makeRight('(a => :a)("it works!")'), ], @@ -144,17 +167,23 @@ testCases( { 0: '@runtime', 1: { - 0: '@function', - parameter: 'context', - body: { - 0: '@index', - object: { 0: '@lookup', key: 'context' }, - query: { 0: 'program', 1: 'start_time' }, + 0: { + 0: '@function', + 1: { + parameter: 'context', + body: { + 0: '@index', + 1: { + object: { 0: '@lookup', 1: { key: 'context' } }, + query: { 0: 'program', 1: 'start_time' }, + }, + }, + }, }, }, }, either.makeRight( - '{\n @runtime\n context => :context.program.start_time\n}', + '{\n @runtime\n {\n context => :context.program.start_time\n }\n}', ), ], [ @@ -166,8 +195,10 @@ testCases( }, test: { 0: '@index', - object: { 0: '@lookup', 1: 'a.b' }, - query: { 0: 'c "d"', 1: 'e.f' }, + 1: { + object: { 0: '@lookup', 1: { 0: 'a.b' } }, + query: { 0: 'c "d"', 1: 'e.f' }, + }, }, }, either.makeRight( @@ -200,17 +231,18 @@ testCases( { identity: { 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { parameter: 'a', body: { 0: '@lookup', 1: { 0: 'a' } } }, }, test: { 0: '@apply', - function: { 0: '@lookup', 1: 'identity' }, - argument: 'it works!', + 1: { + function: { 0: '@lookup', 1: { 0: 'identity' } }, + argument: 'it works!', + }, }, }, either.makeRight( - '{\n "identity": {\n "0": "@function",\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": "a"\n }\n },\n "test": {\n "0": "@apply",\n "function": {\n "0": "@lookup",\n "1": "identity"\n },\n "argument": "it works!"\n }\n}', + '{\n "identity": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "test": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "0": "identity"\n }\n },\n "argument": "it works!"\n }\n }\n}', ), ], [ @@ -218,13 +250,15 @@ testCases( 0: '@apply', function: { 0: '@function', - parameter: 'a', - body: { 0: '@lookup', 1: 'a' }, + 1: { + parameter: 'a', + body: { 0: '@lookup', 1: { 0: 'a' } }, + }, }, argument: 'it works!', }, either.makeRight( - '{\n "0": "@apply",\n "function": {\n "0": "@function",\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": "a"\n }\n },\n "argument": "it works!"\n}', + '{\n "0": "@apply",\n "function": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "argument": "it works!"\n}', ), ], ]) diff --git a/src/language/parsing/expression.ts b/src/language/parsing/expression.ts index 6815f83..db3ce63 100644 --- a/src/language/parsing/expression.ts +++ b/src/language/parsing/expression.ts @@ -57,14 +57,18 @@ const trailingIndexesAndArgumentsToExpression = ( case 'argument': return { 0: '@apply', - function: expression, - argument: indexOrArgument.argument, + 1: { + function: expression, + argument: indexOrArgument.argument, + }, } case 'index': return { 0: '@index', - object: expression, - query: keyPathToMolecule(indexOrArgument.query), + 1: { + object: expression, + query: keyPathToMolecule(indexOrArgument.query), + }, } } }, root) @@ -119,18 +123,22 @@ const infixTokensToExpression = ( } const leftmostFunction = trailingIndexesAndArgumentsToExpression( - { 0: '@lookup', key: leftmostOperator[0] }, + { 0: '@lookup', 1: { key: leftmostOperator[0] } }, leftmostOperator[1], ) const reducedLeftmostOperation: Molecule = { 0: '@apply', - function: { - 0: '@apply', - function: leftmostFunction, - argument: leftmostOperationRHS, + 1: { + function: { + 0: '@apply', + 1: { + function: leftmostFunction, + argument: leftmostOperationRHS, + }, + }, + argument: leftmostOperationLHS, }, - argument: leftmostOperationLHS, } return infixTokensToExpression([ @@ -345,14 +353,18 @@ const precededByAtomThenArrow = map( ] const initialFunction = { 0: '@function', - parameter: lastParameter, - body: body, + 1: { + parameter: lastParameter, + body: body, + }, } return additionalParameters.reduce( (expression, additionalParameter) => ({ 0: '@function', - parameter: additionalParameter, - body: expression, + 1: { + parameter: additionalParameter, + body: expression, + }, }), initialFunction, ) @@ -368,7 +380,7 @@ const precededByColonThenAtom = map( sequence([colon, atomRequiringDotQuotation, trailingIndexesAndArguments]), ([_colon, key, trailingIndexesAndArguments]) => trailingIndexesAndArgumentsToExpression( - { 0: '@lookup', key }, + { 0: '@lookup', 1: { key } }, trailingIndexesAndArguments, ), ) diff --git a/src/language/runtime/evaluator.test.ts b/src/language/runtime/evaluator.test.ts index 9e134c4..1d5d9c5 100644 --- a/src/language/runtime/evaluator.test.ts +++ b/src/language/runtime/evaluator.test.ts @@ -16,9 +16,12 @@ testCases(evaluate, input => `evaluating \`${JSON.stringify(input)}\``)( 'evaluator', [ ['Hello, world!', success('Hello, world!')], - [['@check', true, ['@lookup', 'identity']], success('true')], + [['@check', [true, ['@lookup', ['identity']]]], success('true')], [ - ['@check', 'not a boolean', ['@index', ['@lookup', 'boolean'], 'is']], + [ + '@check', + ['not a boolean', ['@index', [['@lookup', ['boolean']], 'is']]], + ], output => assert(either.isLeft(output)), ], ], diff --git a/src/language/runtime/keywords.ts b/src/language/runtime/keywords.ts index ac3e499..b8d2790 100644 --- a/src/language/runtime/keywords.ts +++ b/src/language/runtime/keywords.ts @@ -1,20 +1,17 @@ import either from '@matt.kantor/either' -import option, { type Option } from '@matt.kantor/option' +import option from '@matt.kantor/option' import { parseArgs } from 'node:util' import { writeOutput } from '../cli/output.js' import { keywordHandlers as compilerKeywordHandlers } from '../compiling.js' -import type { Atom } from '../parsing.js' import { isFunctionNode, makeFunctionNode, makeObjectNode, + readRuntimeExpression, serialize, types, - type Expression, type KeywordHandlers, - type SemanticGraph, } from '../semantics.js' -import { lookupPropertyOfObjectNode } from '../semantics/object-node.js' import { prettyJson } from '../unparsing.js' const unserializableFunction = () => @@ -131,45 +128,29 @@ export const keywordHandlers: KeywordHandlers = { /** * Evaluates the given function, passing runtime context captured in `world`. */ - '@runtime': expression => { - const runtimeFunction = lookupWithinExpression( - ['function', '1'], - expression, - ) - if ( - option.isNone(runtimeFunction) || - !isFunctionNode(runtimeFunction.value) - ) { - return either.makeLeft({ - kind: 'invalidExpression', - message: - 'a function must be provided via the property `function` or `1`', - }) - } else { - const result = runtimeFunction.value(runtimeContext) - if (either.isLeft(result)) { - // The runtime function panicked or had an unavailable dependency (which results in a panic - // anyway in this context). - return either.makeLeft({ - kind: 'panic', - message: result.value.message, - }) - } else { - return result - } - } - }, -} - -const lookupWithinExpression = ( - keyAliases: [Atom, ...(readonly Atom[])], - expression: Expression, -): Option => { - for (const key of keyAliases) { - const result = lookupPropertyOfObjectNode(key, makeObjectNode(expression)) - if (!option.isNone(result)) { - return result - } - } - return option.none + '@runtime': expression => + either.flatMap( + readRuntimeExpression(expression), + ({ 1: { function: runtimeFunction } }) => { + if (!isFunctionNode(runtimeFunction)) { + return either.makeLeft({ + kind: 'panic', + message: + 'a function must be provided via the property `function` or `0`', + }) + } else { + const result = runtimeFunction(runtimeContext) + if (either.isLeft(result)) { + // The runtime function panicked or had an unavailable dependency (which results in a panic + // anyway in this context). + return either.makeLeft({ + kind: 'panic', + message: result.value.message, + }) + } else { + return result + } + } + }, + ), } diff --git a/src/language/semantics.ts b/src/language/semantics.ts index 4f53825..7c1afa7 100644 --- a/src/language/semantics.ts +++ b/src/language/semantics.ts @@ -18,10 +18,7 @@ export { readCheckExpression, type CheckExpression, } from './semantics/expressions/check-expression.js' -export { - asSemanticGraph, - readArgumentsFromExpression, -} from './semantics/expressions/expression-utilities.js' +export { asSemanticGraph } from './semantics/expressions/expression-utilities.js' export { makeFunctionExpression, readFunctionExpression, diff --git a/src/language/semantics/expression.ts b/src/language/semantics/expression.ts index 4573b28..5f0da04 100644 --- a/src/language/semantics/expression.ts +++ b/src/language/semantics/expression.ts @@ -1,19 +1,27 @@ -import type { Molecule } from '../parsing.js' +import type { Atom, Molecule } from '../parsing.js' import type { SemanticGraph } from './semantic-graph.js' export type Expression = { readonly 0: `@${string}` + readonly 1?: Atom | Molecule } export const isExpression = ( node: SemanticGraph | Molecule, ): node is Expression => - typeof node === 'object' && typeof node[0] === 'string' && node[0][0] === '@' + typeof node === 'object' && + typeof node[0] === 'string' && + node[0][0] === '@' && + (!('1' in node) || typeof node[1] === 'object' || typeof node[1] === 'string') -export const isSpecificExpression = ( +export const isExpressionWithArgument = ( keyword: Keyword, - node: SemanticGraph | Molecule, + node: Molecule | SemanticGraph, ): node is { readonly 0: Keyword + readonly 1: Molecule } => - typeof node === 'object' && typeof node[0] === 'string' && node[0] === keyword + typeof node === 'object' && + typeof node[0] === 'string' && + node[0] === keyword && + (typeof node[1] === 'string' || typeof node[1] === 'object') diff --git a/src/language/semantics/expressions/apply-expression.ts b/src/language/semantics/expressions/apply-expression.ts index 87d91b5..b4b5f4a 100644 --- a/src/language/semantics/expressions/apply-expression.ts +++ b/src/language/semantics/expressions/apply-expression.ts @@ -1,26 +1,25 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { type SemanticGraph } from '../semantic-graph.js' import { readArgumentsFromExpression } from './expression-utilities.js' export type ApplyExpression = ObjectNode & { readonly 0: '@apply' - readonly function: SemanticGraph | Molecule - readonly argument: SemanticGraph | Molecule + readonly 1: { + readonly function: SemanticGraph | Molecule + readonly argument: SemanticGraph | Molecule + } } export const readApplyExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@apply', node) + isExpressionWithArgument('@apply', node) ? either.map( - readArgumentsFromExpression(node, [ - ['function', '1'], - ['argument', '2'], - ]), + readArgumentsFromExpression(node, ['function', 'argument']), ([f, argument]) => makeApplyExpression({ function: f, argument }), ) : either.makeLeft({ @@ -37,6 +36,8 @@ export const makeApplyExpression = ({ }): ApplyExpression => makeObjectNode({ 0: '@apply', - function: f, - argument, + 1: makeObjectNode({ + function: f, + argument, + }), }) diff --git a/src/language/semantics/expressions/check-expression.ts b/src/language/semantics/expressions/check-expression.ts index 39b234a..51f91bb 100644 --- a/src/language/semantics/expressions/check-expression.ts +++ b/src/language/semantics/expressions/check-expression.ts @@ -1,26 +1,25 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { type SemanticGraph } from '../semantic-graph.js' import { readArgumentsFromExpression } from './expression-utilities.js' export type CheckExpression = ObjectNode & { readonly 0: '@check' - readonly value: SemanticGraph | Molecule - readonly type: SemanticGraph | Molecule + readonly 1: { + readonly value: SemanticGraph | Molecule + readonly type: SemanticGraph | Molecule + } } export const readCheckExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@check', node) + isExpressionWithArgument('@check', node) ? either.map( - readArgumentsFromExpression(node, [ - ['value', '1'], - ['type', '2'], - ]), + readArgumentsFromExpression(node, ['value', 'type']), ([value, type]) => makeCheckExpression({ value, type }), ) : either.makeLeft({ @@ -37,6 +36,5 @@ export const makeCheckExpression = ({ }): CheckExpression => makeObjectNode({ 0: '@check', - value, - type, + 1: makeObjectNode({ value, type }), }) diff --git a/src/language/semantics/expressions/expression-utilities.ts b/src/language/semantics/expressions/expression-utilities.ts index a34b97a..7963e38 100644 --- a/src/language/semantics/expressions/expression-utilities.ts +++ b/src/language/semantics/expressions/expression-utilities.ts @@ -4,6 +4,7 @@ import type { ElaborationError } from '../../errors.js' import type { Atom, Molecule } from '../../parsing.js' import type { ExpressionContext } from '../expression-elaboration.js' import type { Expression } from '../expression.js' +import { isFunctionNode } from '../function-node.js' import { stringifyKeyPathForEndUser } from '../key-path.js' import { lookupPropertyOfObjectNode, @@ -33,46 +34,59 @@ export const locateSelf = (context: ExpressionContext) => }) export const readArgumentsFromExpression = < - const Specification extends readonly (readonly [ - string, - ...(readonly string[]), - ])[], + const Specification extends readonly string[], >( expression: Expression, specification: Specification, ): Either> => { - const expressionArguments: ObjectNode[string][] = [] - for (const aliases of specification) { - const argument = lookupWithinExpression(aliases, expression) - if (option.isNone(argument)) { - const requiredKeySummary = aliases - .map(alias => `\`${alias}\``) - .join(' or ') - return either.makeLeft({ - kind: 'invalidExpression', - message: `missing required property ${requiredKeySummary}`, - }) - } else { - expressionArguments.push(argument.value) + if (expression[1] === undefined) { + return either.makeLeft({ + kind: 'invalidExpression', + message: `missing arguments object`, + }) + } else if (typeof expression[1] === 'string') { + return either.makeLeft({ + kind: 'invalidExpression', + message: `found an atom instead of an arguments object`, + }) + } else if (isFunctionNode(expression[1])) { + return either.makeLeft({ + kind: 'invalidExpression', + message: `found a function instead of an arguments object`, + }) + } else { + const expressionArguments: ObjectNode[string][] = [] + for (const [position, keyword] of specification.entries()) { + const argument = lookupWithinMolecule( + [keyword, String(position)], + expression[1], + ) + if (option.isNone(argument)) { + const requiredKeySummary = `\`${keyword}\` or \`${position}\`` + return either.makeLeft({ + kind: 'invalidExpression', + message: `missing required property ${requiredKeySummary}`, + }) + } else { + expressionArguments.push(argument.value) + } } + return either.makeRight( + // This is correct since the above loop pushes one argument for each `specification` element. + expressionArguments as ParsedExpressionArguments, + ) } - return either.makeRight( - // This is correct since the above loop pushes one argument for each `specification` element. - expressionArguments as ParsedExpressionArguments, - ) } -type ParsedExpressionArguments< - Specification extends readonly (readonly [string, ...(readonly string[])])[], -> = { +type ParsedExpressionArguments = { [Index in keyof Specification]: ObjectNode[string] } -const lookupWithinExpression = ( +const lookupWithinMolecule = ( keyAliases: readonly [Atom, ...(readonly Atom[])], - expression: Expression, + molecule: Molecule | ObjectNode, ): Option => { for (const key of keyAliases) { - const result = lookupPropertyOfObjectNode(key, makeObjectNode(expression)) + const result = lookupPropertyOfObjectNode(key, makeObjectNode(molecule)) if (!option.isNone(result)) { return result } diff --git a/src/language/semantics/expressions/function-expression.ts b/src/language/semantics/expressions/function-expression.ts index 218d369..49506c9 100644 --- a/src/language/semantics/expressions/function-expression.ts +++ b/src/language/semantics/expressions/function-expression.ts @@ -1,7 +1,7 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Atom, Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { serialize, type SemanticGraph } from '../semantic-graph.js' import { @@ -11,19 +11,18 @@ import { export type FunctionExpression = ObjectNode & { readonly 0: '@function' - readonly parameter: Atom - readonly body: SemanticGraph | Molecule + readonly 1: { + readonly parameter: Atom + readonly body: SemanticGraph | Molecule + } } export const readFunctionExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@function', node) + isExpressionWithArgument('@function', node) ? either.flatMap( - readArgumentsFromExpression(node, [ - ['parameter', '1'], - ['body', '2'], - ]), + readArgumentsFromExpression(node, ['parameter', 'body']), ([parameter, body]): Either => typeof parameter !== 'string' ? either.makeLeft({ @@ -45,6 +44,8 @@ export const makeFunctionExpression = ( ): FunctionExpression => makeObjectNode({ 0: '@function', - parameter, - body, + 1: makeObjectNode({ + parameter, + body, + }), }) diff --git a/src/language/semantics/expressions/if-expression.ts b/src/language/semantics/expressions/if-expression.ts index 353df72..04072b1 100644 --- a/src/language/semantics/expressions/if-expression.ts +++ b/src/language/semantics/expressions/if-expression.ts @@ -1,7 +1,7 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { type SemanticGraph } from '../semantic-graph.js' import { readArgumentsFromExpression } from './expression-utilities.js' @@ -9,21 +9,19 @@ import { readArgumentsFromExpression } from './expression-utilities.js' // TODO: Evolve this into pattern matching/destructuring. export type IfExpression = ObjectNode & { readonly 0: '@if' - readonly condition: SemanticGraph | Molecule - readonly then: SemanticGraph | Molecule - readonly else: SemanticGraph | Molecule + readonly 1: { + readonly condition: SemanticGraph | Molecule + readonly then: SemanticGraph | Molecule + readonly else: SemanticGraph | Molecule + } } export const readIfExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@if', node) + isExpressionWithArgument('@if', node) ? either.map( - readArgumentsFromExpression(node, [ - ['condition', '1'], - ['then', '2'], - ['else', '3'], - ]), + readArgumentsFromExpression(node, ['condition', 'then', 'else']), ([condition, then, otherwise]) => makeIfExpression({ condition, then, else: otherwise }), ) @@ -43,7 +41,9 @@ export const makeIfExpression = ({ }): IfExpression => makeObjectNode({ 0: '@if', - condition, - then, - else: otherwise, + 1: makeObjectNode({ + condition, + then, + else: otherwise, + }), }) diff --git a/src/language/semantics/expressions/index-expression.ts b/src/language/semantics/expressions/index-expression.ts index 3e2b574..3d3ab13 100644 --- a/src/language/semantics/expressions/index-expression.ts +++ b/src/language/semantics/expressions/index-expression.ts @@ -1,7 +1,7 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { keyPathFromObjectNodeOrMolecule } from '../key-path.js' import { isObjectNode, @@ -16,19 +16,18 @@ import { export type IndexExpression = ObjectNode & { readonly 0: '@index' - readonly object: ObjectNode | Molecule - readonly query: ObjectNode | Molecule + readonly 1: { + readonly object: ObjectNode | Molecule + readonly query: ObjectNode | Molecule + } } export const readIndexExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@index', node) + isExpressionWithArgument('@index', node) ? either.flatMap( - readArgumentsFromExpression(node, [ - ['object', '1'], - ['query', '2'], - ]), + readArgumentsFromExpression(node, ['object', 'query']), ([o, q]) => { const object = asSemanticGraph(o) const query = asSemanticGraph(q) @@ -64,6 +63,8 @@ export const makeIndexExpression = ({ }): IndexExpression => makeObjectNode({ 0: '@index', - object, - query, + 1: makeObjectNode({ + object, + query, + }), }) diff --git a/src/language/semantics/expressions/lookup-expression.ts b/src/language/semantics/expressions/lookup-expression.ts index 9085c0f..6970663 100644 --- a/src/language/semantics/expressions/lookup-expression.ts +++ b/src/language/semantics/expressions/lookup-expression.ts @@ -1,7 +1,7 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Atom, Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { stringifySemanticGraphForEndUser, @@ -14,28 +14,27 @@ import { export type LookupExpression = ObjectNode & { readonly 0: '@lookup' - readonly key: Atom + readonly 1: { + readonly key: Atom + } } export const readLookupExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@lookup', node) - ? either.flatMap( - readArgumentsFromExpression(node, [['key', '1']]), - ([key]) => { - if (typeof key !== 'string') { - return either.makeLeft({ - kind: 'invalidExpression', - message: `lookup key must be an atom, got \`${stringifySemanticGraphForEndUser( - asSemanticGraph(key), - )}\``, - }) - } else { - return either.makeRight(makeLookupExpression(key)) - } - }, - ) + isExpressionWithArgument('@lookup', node) + ? either.flatMap(readArgumentsFromExpression(node, ['key']), ([key]) => { + if (typeof key !== 'string') { + return either.makeLeft({ + kind: 'invalidExpression', + message: `lookup key must be an atom, got \`${stringifySemanticGraphForEndUser( + asSemanticGraph(key), + )}\``, + }) + } else { + return either.makeRight(makeLookupExpression(key)) + } + }) : either.makeLeft({ kind: 'invalidExpression', message: 'not an expression', @@ -44,5 +43,5 @@ export const readLookupExpression = ( export const makeLookupExpression = (key: Atom): LookupExpression => makeObjectNode({ 0: '@lookup', - key, + 1: makeObjectNode({ key }), }) diff --git a/src/language/semantics/expressions/runtime-expression.ts b/src/language/semantics/expressions/runtime-expression.ts index a70af86..bfb0703 100644 --- a/src/language/semantics/expressions/runtime-expression.ts +++ b/src/language/semantics/expressions/runtime-expression.ts @@ -1,7 +1,7 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' -import { isSpecificExpression } from '../expression.js' +import { isExpressionWithArgument } from '../expression.js' import { isFunctionNode } from '../function-node.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' import { @@ -15,31 +15,28 @@ import { export type RuntimeExpression = ObjectNode & { readonly 0: '@runtime' - readonly function: SemanticGraph + readonly 1: { + readonly function: SemanticGraph + } } export const readRuntimeExpression = ( node: SemanticGraph | Molecule, ): Either => - isSpecificExpression('@runtime', node) - ? either.flatMap( - readArgumentsFromExpression(node, [['function', '1']]), - ([f]) => { - const runtimeFunction = asSemanticGraph(f) - if ( - !( - isFunctionNode(runtimeFunction) || containsAnyUnelaboratedNodes(f) - ) - ) { - return either.makeLeft({ - kind: 'invalidExpression', - message: 'runtime functions must compute something', - }) - } else { - return either.makeRight(makeRuntimeExpression(runtimeFunction)) - } - }, - ) + isExpressionWithArgument('@runtime', node) + ? either.flatMap(readArgumentsFromExpression(node, ['function']), ([f]) => { + const runtimeFunction = asSemanticGraph(f) + if ( + !(isFunctionNode(runtimeFunction) || containsAnyUnelaboratedNodes(f)) + ) { + return either.makeLeft({ + kind: 'invalidExpression', + message: 'runtime functions must compute something', + }) + } else { + return either.makeRight(makeRuntimeExpression(runtimeFunction)) + } + }) : either.makeLeft({ kind: 'invalidExpression', message: 'not an expression', @@ -48,5 +45,5 @@ export const readRuntimeExpression = ( export const makeRuntimeExpression = (f: SemanticGraph): RuntimeExpression => makeObjectNode({ 0: '@runtime', - function: f, + 1: makeObjectNode({ function: f }), }) diff --git a/src/language/semantics/function-node.ts b/src/language/semantics/function-node.ts index 179bae3..274eb19 100644 --- a/src/language/semantics/function-node.ts +++ b/src/language/semantics/function-node.ts @@ -6,7 +6,7 @@ import type { Panic, UnserializableValueError, } from '../errors.js' -import type { Atom } from '../parsing.js' +import type { Atom, Molecule } from '../parsing.js' import type { ObjectNode } from './object-node.js' import { nodeTag, @@ -25,7 +25,7 @@ export type FunctionNode = (( readonly serialize: () => Either } -export const isFunctionNode = (node: SemanticGraph) => +export const isFunctionNode = (node: Molecule | SemanticGraph) => typeof node === 'function' && node[nodeTag] === 'function' export const makeFunctionNode = ( diff --git a/src/language/unparsing/plz-utilities.ts b/src/language/unparsing/plz-utilities.ts index 6e7703d..35012a1 100644 --- a/src/language/unparsing/plz-utilities.ts +++ b/src/language/unparsing/plz-utilities.ts @@ -142,11 +142,11 @@ const unparseSugaredApply = ( const { closeParenthesis, openParenthesis } = punctuation(kleur) const functionUnparseResult = either.map( either.flatMap( - serializeIfNeeded(expression.function), + serializeIfNeeded(expression[1].function), unparseAtomOrMolecule, ), unparsedFunction => - either.isRight(readFunctionExpression(expression.function)) + either.isRight(readFunctionExpression(expression[1].function)) ? // Immediately-applied function expressions need parentheses. openParenthesis.concat(unparsedFunction).concat(closeParenthesis) : unparsedFunction, @@ -156,7 +156,7 @@ const unparseSugaredApply = ( } const argumentUnparseResult = either.flatMap( - serializeIfNeeded(expression.argument), + serializeIfNeeded(expression[1].argument), unparseAtomOrMolecule, ) if (either.isLeft(argumentUnparseResult)) { @@ -175,10 +175,10 @@ const unparseSugaredFunction = ( expression: FunctionExpression, unparseAtomOrMolecule: UnparseAtomOrMolecule, ) => - either.flatMap(serializeIfNeeded(expression.body), serializedBody => + either.flatMap(serializeIfNeeded(expression[1].body), serializedBody => either.map(unparseAtomOrMolecule(serializedBody), bodyAsString => [ - kleur.cyan(expression.parameter), + kleur.cyan(expression[1].parameter), punctuation(kleur).arrow, bodyAsString, ].join(' '), @@ -190,20 +190,20 @@ const unparseSugaredIndex = ( unparseAtomOrMolecule: UnparseAtomOrMolecule, ) => { const objectUnparseResult = either.flatMap( - serializeIfNeeded(expression.object), + serializeIfNeeded(expression[1].object), unparseAtomOrMolecule, ) if (either.isLeft(objectUnparseResult)) { return objectUnparseResult } else { - if (typeof expression.query !== 'object') { + if (typeof expression[1].query !== 'object') { // TODO: It would be nice if this were provably impossible. return either.makeLeft({ kind: 'unserializableValue', message: 'Invalid index expression', }) } else { - const keyPath = Object.entries(expression.query).reduce( + const keyPath = Object.entries(expression[1].query).reduce( (accumulator: KeyPath | 'invalid', [key, value]) => { if (accumulator === 'invalid') { return accumulator @@ -223,7 +223,7 @@ const unparseSugaredIndex = ( if ( keyPath === 'invalid' || - Object.keys(expression.query).length !== keyPath.length + Object.keys(expression[1].query).length !== keyPath.length ) { return either.makeLeft({ kind: 'unserializableValue', @@ -248,7 +248,7 @@ const unparseSugaredLookup = ( either.makeRight( kleur.cyan( punctuation(kleur).colon.concat( - quoteKeyPathComponentIfNecessary(expression.key), + quoteKeyPathComponentIfNecessary(expression[1].key), ), ), ) From 1a321eed08f2bb5e29acf8bf3379fcadebfe1fbe Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Tue, 13 May 2025 12:16:05 -0400 Subject: [PATCH 2/5] Require quotes around `@` in atoms This carves out syntax space for a planned generalized keyword expression sugar (`@` will become relevant to parsing). --- README.md | 38 ++++++++++++------------ examples/fibonacci.plz | 6 ++-- examples/kitchen-sink.plz | 4 +-- examples/lookup-environment-variable.plz | 2 +- src/end-to-end.test.ts | 38 ++++++++++++------------ src/language/compiling/unparsing.test.ts | 8 ++--- src/language/parsing/atom.ts | 2 ++ src/language/parsing/literals.ts | 1 + 8 files changed, 51 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 542b4d3..62130a5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ git clone git@github.com:mkantor/please-lang-prototype.git cd please-lang-prototype npm install npm run build -echo '{@runtime, { context => :context.program.start_time }}' | ./please --output-format=json +echo '{"@runtime", { context => :context.program.start_time }}' | ./please --output-format=json ``` There are more example programs in [`./examples`](./examples). @@ -45,7 +45,7 @@ data representation implied by the fact that a value is an atom (e.g. the atom `2` may be an integer in memory). Bare words not containing any -[reserved character sequences](./src/language/parsing/atom.ts#L33-L55) are +[reserved character sequences](./src/language/parsing/atom.ts#L34-L57) are atoms: ``` @@ -178,7 +178,7 @@ expressions_. Most of the interesting stuff that Please does involves evaluating keyword expressions. Under the hood, keyword expressions are modeled as objects. For example, `:foo` -desugars to `{ @lookup, { key: foo } }`. All such expressions have a property +desugars to `{ "@lookup", { key: foo } }`. All such expressions have a property named `0` referring to a value that is an `@`-prefixed atom (the keyword). Most keyword expressions also require a property named `1` to pass an argument to the expression. Keywords include `@apply`, `@check`, `@function`, `@if`, `@index`, @@ -211,7 +211,7 @@ function from other programming languages, except there can be any number of `@runtime` expressions in a given program. Here's an example: ``` -{@runtime, { context => :context.program.start_time }} +{"@runtime", { context => :context.program.start_time }} ``` Unsurprisingly, this program outputs the current time when run. @@ -250,7 +250,7 @@ Take this example `plz` program: { language: Please message: :atom.prepend("Welcome to ")(:language) - now: {@runtime, { context => :context.program.start_time }} + now: {"@runtime", { context => :context.program.start_time }} } ``` @@ -260,16 +260,16 @@ It desugars to the following `plo` program: { language: Please message: { - 0: @apply + 0: "@apply" 1: { function: { - 0: @apply + 0: "@apply" 1: { function: { - 0: @index + 0: "@index" 1: { object: { - 0: @lookup + 0: "@lookup" 1: { key: atom } @@ -283,7 +283,7 @@ It desugars to the following `plo` program: } } argument: { - 0: @lookup + 0: "@lookup" 1: { key: language } @@ -291,17 +291,17 @@ It desugars to the following `plo` program: } } now: { - 0: @runtime + 0: "@runtime" 1: { 0: { - 0: @function + 0: "@function" 1: { parameter: context body: { - 0: @index + 0: "@index" 1: { object: { - 0: @lookup + 0: "@lookup" 1: { key: context } @@ -326,17 +326,17 @@ Which in turn compiles to the following `plt` program: language: Please message: "Welcome to Please" now: { - 0: @runtime + 0: "@runtime" 1: { function: { - 0: @function + 0: "@function" 1: { parameter: context body: { - 0: @index + 0: "@index" 1: { object: { - 0: @lookup + 0: "@lookup" 1: { key: context } @@ -360,7 +360,7 @@ Which produces the following runtime output: { language: Please message: "Welcome to Please" - now: "2025-05-13T22:11:56.804Z" + now: "2025-05-13T22:47:50.802Z" } ``` diff --git a/examples/fibonacci.plz b/examples/fibonacci.plz index c183883..73a7d7a 100644 --- a/examples/fibonacci.plz +++ b/examples/fibonacci.plz @@ -1,6 +1,6 @@ { fibonacci: n => { - @if + "@if" { condition: :n < 2 then: :n @@ -9,14 +9,14 @@ } input: { - @runtime + "@runtime" { context => :context.arguments.lookup(input) } } output: :input match { none: _ => "missing input argument" some: input => { - @if + "@if" { condition: :natural_number.is(:input) then: :fibonacci(:input) diff --git a/examples/kitchen-sink.plz b/examples/kitchen-sink.plz index 5b646d8..d1acb2c 100644 --- a/examples/kitchen-sink.plz +++ b/examples/kitchen-sink.plz @@ -8,6 +8,6 @@ add_one: :integer.add(1) three: :add_one(:two) function: x => { value: :x } - conditional_value: :function({ @if, { :sky_is_blue, :two, :three } }) - side_effect: { @runtime, { context => :context.log("this goes to stderr") } } + conditional_value: :function({ "@if", { :sky_is_blue, :two, :three } }) + side_effect: { "@runtime", { context => :context.log("this goes to stderr") } } } diff --git a/examples/lookup-environment-variable.plz b/examples/lookup-environment-variable.plz index e11c51c..990c9c6 100644 --- a/examples/lookup-environment-variable.plz +++ b/examples/lookup-environment-variable.plz @@ -3,7 +3,7 @@ * variable named `FOO`. */ { - @runtime + "@runtime" { context => :context.arguments.lookup(variable) match { diff --git a/src/end-to-end.test.ts b/src/end-to-end.test.ts index 71abbb9..d003895 100644 --- a/src/end-to-end.test.ts +++ b/src/end-to-end.test.ts @@ -28,21 +28,21 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ['{,a,b,c,}', either.makeRight({ 0: 'a', 1: 'b', 2: 'c' })], ['{a,1:overwritten,c}', either.makeRight({ 0: 'a', 1: 'c' })], ['{overwritten,0:a,c}', either.makeRight({ 0: 'a', 1: 'c' })], - ['{@check, {type:true, value:true}}', either.makeRight('true')], + ['{"@check", {type:true, value:true}}', either.makeRight('true')], [ - '{@panic}', + '{"@panic"}', result => { assert(either.isLeft(result)) assert('kind' in result.value) assert.deepEqual(result.value.kind, 'panic') }, ], - ['{a:A, b:{@lookup, {a}}}', either.makeRight({ a: 'A', b: 'A' })], - ['{a:A, {@lookup, {a}}}', either.makeRight({ a: 'A', 0: 'A' })], + ['{a:A, b:{"@lookup", {a}}}', either.makeRight({ a: 'A', b: 'A' })], + ['{a:A, {"@lookup", {a}}}', either.makeRight({ a: 'A', 0: 'A' })], ['{a:A, b: :a}', either.makeRight({ a: 'A', b: 'A' })], ['{a:A, :a}', either.makeRight({ a: 'A', 0: 'A' })], [ - '{@runtime, {_ => {@panic}}}', + '{"@runtime", {_ => {"@panic"}}}', result => { assert(either.isLeft(result)) assert('kind' in result.value) @@ -90,12 +90,12 @@ testCases(endToEnd, code => code)('end-to-end tests', [ // foo: bar "static data":"blah blah blah" "evaluated data": { - 0:@runtime + 0:"@runtime" 1:{ function:{ - 0:@apply + 0:"@apply" 1:{ - function:{0:@index, 1:{object:{0:@lookup, 1:{key:object}}, query:{0:lookup}}} + function:{0:"@index", 1:{object:{0:"@lookup", 1:{key:object}}, query:{0:lookup}}} argument:"key which does not exist in runtime context" } } @@ -153,7 +153,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ], [':match({ a: A })({ tag: a, value: {} })', either.makeRight('A')], [ - `{@runtime, { context => + `{"@runtime", { context => :identity(:context).program.start_time }}`, output => { @@ -181,7 +181,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ [`(1 - 2) - 3`, either.makeRight('-4')], [':flow(:atom.append(b))(:atom.append(a))(z)', either.makeRight('zab')], [ - `{@runtime + `{"@runtime" { :object.lookup("key which does not exist in runtime context") } }`, either.makeRight({ tag: 'none', value: {} }), @@ -213,7 +213,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ true: 'true', false: 'false' }), ], [ - `{@runtime, { + `{"@runtime", { :flow( :match({ none: "environment does not exist" @@ -260,7 +260,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c', 3: 'd' }), ], [ - `{@runtime, { context => + `{"@runtime", { context => :context.environment.lookup(PATH) }}`, output => { @@ -273,10 +273,10 @@ testCases(endToEnd, code => code)('end-to-end tests', [ }, ], [ - `{@if, { + `{"@if", { true "it works!" - {@panic} + {"@panic"} }}`, either.makeRight('it works!'), ], @@ -289,11 +289,11 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c' }), ], [ - `{@runtime, { context => - {@if, { + `{"@runtime", { context => + {"@if", { :boolean.not(:boolean.is(:context)) "it works!" - {@panic} + {"@panic"} }} }}`, either.makeRight('it works!'), @@ -301,7 +301,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ [ `{ fibonacci: n => { - @if, { + "@if", { :integer.less_than(2)(:n) then: :n else: :fibonacci(:n - 1) + :fibonacci(:n - 2) @@ -362,7 +362,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight('2'), ], [ - `{@runtime, { context => + `{"@runtime", { context => ( PATH |> :context.environment.lookup diff --git a/src/language/compiling/unparsing.test.ts b/src/language/compiling/unparsing.test.ts index b8aa59a..4c64196 100644 --- a/src/language/compiling/unparsing.test.ts +++ b/src/language/compiling/unparsing.test.ts @@ -24,7 +24,7 @@ testCases( [{}, either.makeRight('{}')], ['a', either.makeRight('a')], ['Hello, world!', either.makeRight('"Hello, world!"')], - ['@test', either.makeRight('@test')], + ['@test', either.makeRight('"@test"')], [{ 0: 'a' }, either.makeRight('{ a }')], [{ 1: 'a' }, either.makeRight('{ 1: a }')], [ @@ -87,7 +87,7 @@ testCases( }, }, either.makeRight( - '{ @runtime, { context => :context.program.start_time } }', + '{ "@runtime", { context => :context.program.start_time } }', ), ], [ @@ -118,7 +118,7 @@ testCases( [{}, either.makeRight('{}')], ['a', either.makeRight('a')], ['Hello, world!', either.makeRight('"Hello, world!"')], - ['@test', either.makeRight('@test')], + ['@test', either.makeRight('"@test"')], [{ 0: 'a' }, either.makeRight('{\n a\n}')], [{ 1: 'a' }, either.makeRight('{\n 1: a\n}')], [ @@ -183,7 +183,7 @@ testCases( }, }, either.makeRight( - '{\n @runtime\n {\n context => :context.program.start_time\n }\n}', + '{\n "@runtime"\n {\n context => :context.program.start_time\n }\n}', ), ], [ diff --git a/src/language/parsing/atom.ts b/src/language/parsing/atom.ts index 802e749..adf87a7 100644 --- a/src/language/parsing/atom.ts +++ b/src/language/parsing/atom.ts @@ -11,6 +11,7 @@ import { zeroOrMore, } from '@matt.kantor/parsing' import { + atSign, backslash, closingBlockCommentDelimiter, closingBrace, @@ -31,6 +32,7 @@ import { whitespace } from './trivia.js' export type Atom = string const atomComponentsRequiringQuotation = [ + atSign, backslash, closingBlockCommentDelimiter, closingBrace, diff --git a/src/language/parsing/literals.ts b/src/language/parsing/literals.ts index 677d105..0d975fc 100644 --- a/src/language/parsing/literals.ts +++ b/src/language/parsing/literals.ts @@ -2,6 +2,7 @@ import { literal } from '@matt.kantor/parsing' export const arrow = literal('=>') export const asterisk = literal('*') +export const atSign = literal('@') export const backslash = literal('\\') export const closingBlockCommentDelimiter = literal('*/') export const closingBrace = literal('}') From c62db5480375b7070e5c5239c27375248b067808 Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Tue, 13 May 2025 13:10:06 -0400 Subject: [PATCH 3/5] Add generalized keyword expression syntax sugar --- README.md | 24 +++++++----- examples/fibonacci.plz | 22 ++++------- examples/kitchen-sink.plz | 4 +- examples/lookup-environment-variable.plz | 18 ++++----- src/end-to-end.test.ts | 47 +++++++++++------------- src/language/parsing/expression.ts | 19 ++++++++++ 6 files changed, 72 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 62130a5..0e780a1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ git clone git@github.com:mkantor/please-lang-prototype.git cd please-lang-prototype npm install npm run build -echo '{"@runtime", { context => :context.program.start_time }}' | ./please --output-format=json +echo '@runtime { context => :context.program.start_time }' | ./please --output-format=json ``` There are more example programs in [`./examples`](./examples). @@ -178,14 +178,18 @@ expressions_. Most of the interesting stuff that Please does involves evaluating keyword expressions. Under the hood, keyword expressions are modeled as objects. For example, `:foo` -desugars to `{ "@lookup", { key: foo } }`. All such expressions have a property -named `0` referring to a value that is an `@`-prefixed atom (the keyword). Most -keyword expressions also require a property named `1` to pass an argument to the -expression. Keywords include `@apply`, `@check`, `@function`, `@if`, `@index`, -`@lookup`, `@panic`, and `@runtime`. +desugars to `{ 0: "@lookup", 1: { key: foo } }`. All such expressions have a +property named `0` referring to a value that is an `@`-prefixed atom (the +keyword). Most keyword expressions also require a property named `1` to pass an +argument to the expression. Keywords include `@apply`, `@check`, `@function`, +`@if`, `@index`, `@lookup`, `@panic`, and `@runtime`. -Currently only `@function`, `@lookup`, `@index`, and `@apply` have syntax -sugars. +In addition to the specific syntax sugars shown above, any keyword expression +can be written using a generalized sugar: + +``` +@keyword { … } // desugars to `{ 0: "@keyword", 1: { … } }` +``` ### Semantics @@ -211,7 +215,7 @@ function from other programming languages, except there can be any number of `@runtime` expressions in a given program. Here's an example: ``` -{"@runtime", { context => :context.program.start_time }} +@runtime { context => :context.program.start_time } ``` Unsurprisingly, this program outputs the current time when run. @@ -250,7 +254,7 @@ Take this example `plz` program: { language: Please message: :atom.prepend("Welcome to ")(:language) - now: {"@runtime", { context => :context.program.start_time }} + now: @runtime { context => :context.program.start_time } } ``` diff --git a/examples/fibonacci.plz b/examples/fibonacci.plz index 73a7d7a..0080f70 100644 --- a/examples/fibonacci.plz +++ b/examples/fibonacci.plz @@ -1,27 +1,21 @@ { - fibonacci: n => { - "@if" - { + fibonacci: n => + @if { condition: :n < 2 then: :n else: :fibonacci(:n - 1) + :fibonacci(:n - 2) } - } - input: { - "@runtime" - { context => :context.arguments.lookup(input) } + input: @runtime { context => + :context.arguments.lookup(input) } output: :input match { none: _ => "missing input argument" - some: input => { - "@if" - { - condition: :natural_number.is(:input) - then: :fibonacci(:input) - else: "input must be a natural number" - } + some: input => @if { + condition: :natural_number.is(:input) + then: :fibonacci(:input) + else: "input must be a natural number" } } }.output diff --git a/examples/kitchen-sink.plz b/examples/kitchen-sink.plz index d1acb2c..1daf3cb 100644 --- a/examples/kitchen-sink.plz +++ b/examples/kitchen-sink.plz @@ -8,6 +8,6 @@ add_one: :integer.add(1) three: :add_one(:two) function: x => { value: :x } - conditional_value: :function({ "@if", { :sky_is_blue, :two, :three } }) - side_effect: { "@runtime", { context => :context.log("this goes to stderr") } } + conditional_value: :function(@if { :sky_is_blue, :two, :three }) + side_effect: @runtime { context => :context.log("this goes to stderr") } } diff --git a/examples/lookup-environment-variable.plz b/examples/lookup-environment-variable.plz index 990c9c6..1afec8d 100644 --- a/examples/lookup-environment-variable.plz +++ b/examples/lookup-environment-variable.plz @@ -2,16 +2,12 @@ * Given CLI arguments like `--variable=FOO`, looks up the environment * variable named `FOO`. */ -{ - "@runtime" - { - context => - :context.arguments.lookup(variable) match { - none: {} - some: :context.environment.lookup >> :match({ - none: {} - some: :identity - }) - } +@runtime { context => + :context.arguments.lookup(variable) match { + none: {} + some: :context.environment.lookup >> :match({ + none: {} + some: :identity + }) } } diff --git a/src/end-to-end.test.ts b/src/end-to-end.test.ts index d003895..686f2a1 100644 --- a/src/end-to-end.test.ts +++ b/src/end-to-end.test.ts @@ -28,9 +28,9 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ['{,a,b,c,}', either.makeRight({ 0: 'a', 1: 'b', 2: 'c' })], ['{a,1:overwritten,c}', either.makeRight({ 0: 'a', 1: 'c' })], ['{overwritten,0:a,c}', either.makeRight({ 0: 'a', 1: 'c' })], - ['{"@check", {type:true, value:true}}', either.makeRight('true')], + ['@check {type:true, value:true}', either.makeRight('true')], [ - '{"@panic"}', + '@panic', result => { assert(either.isLeft(result)) assert('kind' in result.value) @@ -42,7 +42,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ['{a:A, b: :a}', either.makeRight({ a: 'A', b: 'A' })], ['{a:A, :a}', either.makeRight({ a: 'A', 0: 'A' })], [ - '{"@runtime", {_ => {"@panic"}}}', + '@runtime {_ => @panic}', result => { assert(either.isLeft(result)) assert('kind' in result.value) @@ -153,9 +153,9 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ], [':match({ a: A })({ tag: a, value: {} })', either.makeRight('A')], [ - `{"@runtime", { context => + `@runtime { context => :identity(:context).program.start_time - }}`, + }`, output => { if (either.isLeft(output)) { assert.fail(output.value.message) @@ -181,9 +181,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ [`(1 - 2) - 3`, either.makeRight('-4')], [':flow(:atom.append(b))(:atom.append(a))(z)', either.makeRight('zab')], [ - `{"@runtime" - { :object.lookup("key which does not exist in runtime context") } - }`, + `@runtime { :object.lookup("key which does not exist in runtime context") }`, either.makeRight({ tag: 'none', value: {} }), ], [ @@ -213,7 +211,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ true: 'true', false: 'false' }), ], [ - `{"@runtime", { + `@runtime { :flow( :match({ none: "environment does not exist" @@ -228,7 +226,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ }) )( :object.lookup(environment) - )} + ) }`, output => { if (either.isLeft(output)) { @@ -260,9 +258,9 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c', 3: 'd' }), ], [ - `{"@runtime", { context => + `@runtime { context => :context.environment.lookup(PATH) - }}`, + }`, output => { if (either.isLeft(output)) { assert.fail(output.value.message) @@ -273,11 +271,11 @@ testCases(endToEnd, code => code)('end-to-end tests', [ }, ], [ - `{"@if", { + `@if { true "it works!" - {"@panic"} - }}`, + @panic + }`, either.makeRight('it works!'), ], [ @@ -289,24 +287,23 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: 'a', 1: 'b', 2: 'c' }), ], [ - `{"@runtime", { context => - {"@if", { + `@runtime { context => + @if { :boolean.not(:boolean.is(:context)) "it works!" - {"@panic"} - }} - }}`, + @panic + } + }`, either.makeRight('it works!'), ], [ `{ - fibonacci: n => { - "@if", { + fibonacci: n => + @if { :integer.less_than(2)(:n) then: :n else: :fibonacci(:n - 1) + :fibonacci(:n - 2) } - } result: :fibonacci(10) }.result`, either.makeRight('55'), @@ -362,7 +359,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight('2'), ], [ - `{"@runtime", { context => + `@runtime { context => ( PATH |> :context.environment.lookup @@ -371,7 +368,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ some: :atom.prepend("PATH=") }) ) - }}`, + }`, result => { if (either.isLeft(result)) { assert.fail(result.value.message) diff --git a/src/language/parsing/expression.ts b/src/language/parsing/expression.ts index db3ce63..8e7273a 100644 --- a/src/language/parsing/expression.ts +++ b/src/language/parsing/expression.ts @@ -13,10 +13,12 @@ import { keyPathToMolecule, type KeyPath } from '../semantics.js' import { atom, atomWithAdditionalQuotationRequirements, + unquotedAtomParser, type Atom, } from './atom.js' import { arrow, + atSign, closingBrace, colon, comma, @@ -371,6 +373,22 @@ const precededByAtomThenArrow = map( }, ) +// @runtime { context => … } +// @panic +// @todo blah +const precededByAtSign = map( + sequence([ + atSign, + unquotedAtomParser, + optionalTrivia, + optional(lazy(() => expression)), + ]), + ([_atSign, keyword, _trivia, argument]) => ({ + 0: `@${keyword}`, + 1: argument === undefined ? {} : argument, + }), +) + // :a // :a.b // :a.b(1).c @@ -421,6 +439,7 @@ export const expression: Parser = map( oneOf([ precededByOpeningParenthesis, precededByOpeningBrace, + precededByAtSign, precededByColonThenAtom, precededByAtomThenArrow, atom, From 76df97316be1603a416c0b4292f17e0d89626a42 Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Wed, 14 May 2025 07:19:59 -0400 Subject: [PATCH 4/5] Make `isExpression` slightly stricter/more correct --- src/language/semantics/expression.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/language/semantics/expression.ts b/src/language/semantics/expression.ts index 5f0da04..69b7e86 100644 --- a/src/language/semantics/expression.ts +++ b/src/language/semantics/expression.ts @@ -11,7 +11,7 @@ export const isExpression = ( ): node is Expression => typeof node === 'object' && typeof node[0] === 'string' && - node[0][0] === '@' && + /^@[^@]/.test(node['0']) && (!('1' in node) || typeof node[1] === 'object' || typeof node[1] === 'string') export const isExpressionWithArgument = ( From 7fef85acdc2f8d64ef5adbfad560b4ec554001e9 Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Wed, 14 May 2025 08:06:46 -0400 Subject: [PATCH 5/5] Use generalized keyword expression sugar in unparsers --- src/language/compiling/unparsing.test.ts | 8 +--- src/language/unparsing/plz-utilities.ts | 57 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/language/compiling/unparsing.test.ts b/src/language/compiling/unparsing.test.ts index 4c64196..4ff64bf 100644 --- a/src/language/compiling/unparsing.test.ts +++ b/src/language/compiling/unparsing.test.ts @@ -86,9 +86,7 @@ testCases( }, }, }, - either.makeRight( - '{ "@runtime", { context => :context.program.start_time } }', - ), + either.makeRight('@runtime { context => :context.program.start_time }'), ], [ { @@ -182,9 +180,7 @@ testCases( }, }, }, - either.makeRight( - '{\n "@runtime"\n {\n context => :context.program.start_time\n }\n}', - ), + either.makeRight('@runtime {\n context => :context.program.start_time\n}'), ], [ { diff --git a/src/language/unparsing/plz-utilities.ts b/src/language/unparsing/plz-utilities.ts index 35012a1..3363e36 100644 --- a/src/language/unparsing/plz-utilities.ts +++ b/src/language/unparsing/plz-utilities.ts @@ -5,6 +5,7 @@ import type { UnserializableValueError } from '../errors.js' import type { Atom, Molecule } from '../parsing.js' import { unquotedAtomParser } from '../parsing/atom.js' import { + isExpression, isSemanticGraph, readApplyExpression, readFunctionExpression, @@ -12,6 +13,7 @@ import { readLookupExpression, serialize, type ApplyExpression, + type Expression, type FunctionExpression, type IndexExpression, type KeyPath, @@ -55,7 +57,19 @@ export const moleculeUnparser = unparseSugaredLookup(lookupExpression, unparseAtomOrMolecule), }) default: - return unparseSugarFreeMolecule(value, unparseAtomOrMolecule) + if (isExpression(value)) { + const result = unparseSugaredGeneralizedKeywordExpression( + value, + unparseAtomOrMolecule, + ) + if (either.isLeft(result)) { + return unparseSugarFreeMolecule(value, unparseAtomOrMolecule) + } else { + return result + } + } else { + return unparseSugarFreeMolecule(value, unparseAtomOrMolecule) + } } } @@ -101,10 +115,12 @@ export const unparseAtom = (atom: string): Right => : quoteAtomIfNecessary(atom), ) +const requiresQuotation = (atom: string): boolean => + either.isLeft(parsing.parse(unquotedAtomParser, atom)) + const quoteAtomIfNecessary = (value: string): string => { const { quote } = punctuation(kleur) - const unquotedAtomResult = parsing.parse(unquotedAtomParser, value) - if (either.isLeft(unquotedAtomResult)) { + if (requiresQuotation(value)) { return quote.concat(escapeStringContents(value)).concat(quote) } else { return value @@ -252,3 +268,38 @@ const unparseSugaredLookup = ( ), ), ) + +const unparseSugaredGeneralizedKeywordExpression = ( + expression: Expression, + unparseAtomOrMolecule: UnparseAtomOrMolecule, +) => { + if ( + // Not every valid keyword expression can be expressed with the + // generalized sugar, e.g. if there are any additional properties + // besides the keyword and its argument, or if the keyword requires + // quotation (which won't be the case for any built-in keywords, but + // maybe eventually users will be able to create custom keywords). + requiresQuotation(expression['0'].substring(1)) || + Object.keys(expression).some(key => key !== '0' && key !== '1') + ) { + return either.makeLeft({ + kind: 'unserializableValue', + message: + 'expression cannot be faithfully represented using generalized keyword expression sugar', + }) + } else { + const unparsedKeyword = kleur.bold(kleur.underline(expression['0'])) + if ('1' in expression) { + return either.map( + either.flatMap( + serializeIfNeeded(expression['1']), + unparseAtomOrMolecule, + ), + unparsedArgument => + unparsedKeyword.concat(' ').concat(unparsedArgument), + ) + } else { + return either.makeRight(unparsedKeyword) + } + } +}