diff --git a/README.md b/README.md index fa88728..774625a 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Data can be referenced from other places in the program using lookups, like } ``` -You can drill down into the properties of looked-up values: +You can index into the properties of looked-up values: ``` { @@ -100,7 +100,7 @@ You can drill down into the properties of looked-up values: greeting: "Hello, World!" } } - greeting: :deeply.nested.greeting // or :{deeply nested greeting} + greeting: :deeply.nested.greeting } ``` diff --git a/src/end-to-end.test.ts b/src/end-to-end.test.ts index d8fa33f..0df9f0a 100644 --- a/src/end-to-end.test.ts +++ b/src/end-to-end.test.ts @@ -27,10 +27,10 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ['{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')], - ['{a:A b:{@lookup {a}}}', either.makeRight({ a: 'A', b: 'A' })], - ['{a:A b: :{a}}', either.makeRight({ a: 'A', b: 'A' })], - ['{a:A {@lookup {a}}}', either.makeRight({ a: 'A', 0: 'A' })], - ['{a:A :{a}}', either.makeRight({ a: 'A', 0: 'A' })], + ['{a:A b:{@lookup a}}', either.makeRight({ a: 'A', b: 'A' })], + ['{a:A b: :a}', either.makeRight({ a: 'A', b: 'A' })], + ['{a:A {@lookup a}}', either.makeRight({ a: 'A', 0: 'A' })], + ['{a:A :a}', either.makeRight({ a: 'A', 0: 'A' })], ['{ a: (a => :a)(A) }', either.makeRight({ a: 'A' })], ['{ a: ( a => :a )( A ) }', either.makeRight({ a: 'A' })], ['(a => :a)(A)', either.makeRight('A')], @@ -50,7 +50,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight({ 0: '@function', parameter: 'a', - body: { 0: '@lookup', query: { 0: 'a' } }, + body: { 0: '@lookup', key: 'a' }, }), ], ['{ success }.0', either.makeRight('success')], @@ -116,7 +116,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ either.makeRight('output'), ], [':match({ a: A })({ tag: a, value: {} })', either.makeRight('A')], - [':{atom prepend}(a)(b)', either.makeRight('ab')], + [':atom.prepend(a)(b)', either.makeRight('ab')], [':flow({ :atom.append(a) :atom.append(b) })(z)', either.makeRight('zab')], [ `{ @@ -126,7 +126,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [ 0:@runtime function:{ 0:@apply - function:{0:@lookup query:{0:object 1:lookup}} + function:{0:@index object:{0:@lookup key:object} query:{0:lookup}} argument:"key which does not exist in runtime context" } } @@ -138,63 +138,17 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ], [ `{@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: {} }), ], - [ - `{@runtime - {@apply - {@lookup { flow }} - { - {@apply - {@lookup { object lookup }} - environment - } - {@apply - {@lookup { match }} - { - none: "environment does not exist" - some: {@apply - {@lookup { flow }} - { - {@apply - {@lookup { object lookup }} - lookup - } - {@apply - {@lookup { match }} - { - none: "environment.lookup does not exist" - some: {@apply - {@lookup { apply }} - PATH - } - } - } - } - } - } - } - } - } - }`, - output => { - if (either.isLeft(output)) { - assert.fail(output.value.message) - } - assert(typeof output.value === 'object') - assert.deepEqual(output.value['tag'], 'some') - assert.deepEqual(typeof output.value['value'], 'string') - }, - ], [ `{@runtime {@apply :flow { - {@apply :{object lookup} environment} + {@apply :object.lookup environment} {@apply :match { none: "environment does not exist" some: {@apply :flow { - {@apply :{object lookup} lookup} + {@apply :object.lookup lookup} {@apply :match { none: "environment.lookup does not exist" some: {@apply :apply PATH} @@ -213,11 +167,11 @@ testCases(endToEnd, code => code)('end-to-end tests', [ ], [ `{@runtime :flow({ - :{object lookup}(environment) + :object.lookup(environment) :match({ none: "environment does not exist" some: :flow({ - :{object lookup}(lookup) + :object.lookup(lookup) :match({ none: "environment.lookup does not exist" some: :apply(PATH) @@ -287,4 +241,11 @@ testCases(endToEnd, code => code)('end-to-end tests', [ )`, either.makeRight('3'), ], + [ + `{ + true: true + false: :boolean.not(:true) + }`, + either.makeRight({ true: 'true', false: 'false' }), + ], ]) diff --git a/src/language/compiling/compiler.test.ts b/src/language/compiling/compiler.test.ts index 7aae3ba..51fee90 100644 --- a/src/language/compiling/compiler.test.ts +++ b/src/language/compiling/compiler.test.ts @@ -16,40 +16,44 @@ 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', ['@lookup', ['boolean', 'not']], false], + true1: ['@check', true, ['@lookup', 'identity']], + true2: ['@apply', ['@index', ['@lookup', 'boolean'], ['not']], false], true3: [ '@apply', [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@lookup', ['boolean', 'not']], - ['@lookup', ['boolean', 'not']], + ['@index', ['@lookup', 'boolean'], ['not']], + ['@index', ['@lookup', 'boolean'], ['not']], ], ], true, ], - false1: ['@check', false, ['@lookup', ['boolean', 'is']]], - false2: ['@apply', ['@lookup', ['boolean', 'is']], 'not a boolean'], + false1: ['@check', false, ['@index', ['@lookup', 'boolean'], ['is']]], + false2: [ + '@apply', + ['@index', ['@lookup', 'boolean'], ['is']], + 'not a boolean', + ], false3: [ '@apply', [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@lookup', ['boolean', 'not']], - ['@lookup', ['boolean', 'not']], + ['@index', ['@lookup', 'boolean'], ['not']], + ['@index', ['@lookup', 'boolean'], ['not']], ], ], - ['@lookup', ['boolean', 'not']], + ['@index', ['@lookup', 'boolean'], ['not']], ], ], true, @@ -65,29 +69,29 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( }), ], [ - ['@runtime', ['@lookup', ['identity']]], + ['@runtime', ['@lookup', 'identity']], success({ 0: '@runtime', - function: { 0: '@lookup', query: { 0: 'identity' } }, + function: { 0: '@lookup', key: 'identity' }, }), ], [ [ '@runtime', - ['@apply', ['@lookup', ['identity']], ['@lookup', ['identity']]], + ['@apply', ['@lookup', 'identity'], ['@lookup', 'identity']], ], success({ 0: '@runtime', - function: { 0: '@lookup', query: { 0: 'identity' } }, + function: { 0: '@lookup', key: 'identity' }, }), ], [ - ['@check', 'not a boolean', ['@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', ['@lookup', ['boolean', 'not']]], + ['@runtime', ['@index', ['@lookup', 'boolean'], ['not']]], output => { assert(either.isLeft(output)) assert(output.value.kind === 'typeMismatch') @@ -96,7 +100,11 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( [ [ '@runtime', - ['@apply', ['@lookup', ['identity']], ['@lookup', ['boolean', 'not']]], + [ + '@apply', + ['@lookup', 'identity'], + ['@index', ['@lookup', 'boolean'], ['not']], + ], ], output => { assert(either.isLeft(output)) @@ -108,10 +116,10 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( '@runtime', [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@lookup', ['identity']], - ['@lookup', ['identity']], + ['@lookup', 'identity'], + ['@lookup', 'identity'], ], ], ], @@ -119,10 +127,10 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( 0: '@runtime', function: { 0: '@apply', - function: { 0: '@lookup', query: { 0: 'flow' } }, + function: { 0: '@lookup', key: 'flow' }, argument: { - 0: { 0: '@lookup', query: { 0: 'identity' } }, - 1: { 0: '@lookup', query: { 0: 'identity' } }, + 0: { 0: '@lookup', key: 'identity' }, + 1: { 0: '@lookup', key: 'identity' }, }, }, }), @@ -132,10 +140,10 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( '@runtime', [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@lookup', ['boolean', 'not']], - ['@lookup', ['boolean', 'not']], + ['@index', ['@lookup', 'boolean'], ['not']], + ['@index', ['@lookup', 'boolean'], ['not']], ], ], ], @@ -149,7 +157,11 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( 0: '@runtime', function: { 0: '@apply', - function: { 0: '@lookup', query: { 0: 'object', 1: 'lookup' } }, + function: { + 0: '@index', + object: { 0: '@lookup', key: 'object' }, + query: { 0: 'lookup' }, + }, argument: 'key which does not exist in runtime context', }, }, @@ -157,7 +169,11 @@ testCases(compile, input => `compiling \`${JSON.stringify(input)}\``)( 0: '@runtime', function: { 0: '@apply', - function: { 0: '@lookup', query: { 0: 'object', 1: 'lookup' } }, + function: { + 0: '@index', + object: { 0: '@lookup', key: 'object' }, + query: { 0: 'lookup' }, + }, argument: 'key which does not exist in runtime context', }, }), diff --git a/src/language/compiling/semantics.test.ts b/src/language/compiling/semantics.test.ts index acf90e7..925fc60 100644 --- a/src/language/compiling/semantics.test.ts +++ b/src/language/compiling/semantics.test.ts @@ -205,14 +205,14 @@ elaborationSuite('@lookup', [ [ { foo: 'bar', - bar: { 0: '@lookup', 1: { 0: 'foo' } }, + bar: { 0: '@lookup', 1: 'foo' }, }, success({ foo: 'bar', bar: 'bar' }), ], [ { foo: 'bar', - bar: { 0: '@lookup', query: { 0: 'foo' } }, + bar: { 0: '@lookup', key: 'foo' }, }, success({ foo: 'bar', bar: 'bar' }), ], @@ -228,7 +228,7 @@ elaborationSuite('@lookup', [ a: 'A', b: { a: 'different A', - b: { 0: '@lookup', query: { 0: 'a' } }, + b: { 0: '@lookup', key: 'a' }, }, }, success({ @@ -239,53 +239,33 @@ elaborationSuite('@lookup', [ }, }), ], - [ - { - a: 'A', - b: { - a: { nested: 'nested A' }, - b: { 0: '@lookup', query: { 0: 'a', 1: 'nested' } }, - }, - }, - success({ - a: 'A', - b: { - a: { nested: 'nested A' }, - b: 'nested A', - }, - }), - ], [ { foo: 'bar', - bar: { 0: '@lookup', 1: { 0: 'foo' } }, - baz: { 0: '@lookup', 1: { 0: 'bar' } }, + bar: { 0: '@lookup', 1: 'foo' }, + baz: { 0: '@lookup', 1: 'bar' }, }, success({ foo: 'bar', bar: 'bar', baz: 'bar' }), ], [ - { a: { 0: '@lookup', _: 'missing query' } }, + { a: { 0: '@lookup', _: 'missing key' } }, output => assert(either.isLeft(output)), ], [ - { a: { 0: '@lookup', query: 'not a valid selector' } }, - output => assert(either.isLeft(output)), - ], - [ - { a: { 0: '@lookup', query: { 0: 'thisPropertyDoesNotExist' } } }, + { a: { 0: '@lookup', key: 'thisPropertyDoesNotExist' } }, output => assert(either.isLeft(output)), ], // lexical scoping [ { - a: { b: 'C' }, + a: 'C', b: { - c: { 0: '@lookup', query: { 0: 'a', 1: 'b' } }, + c: { 0: '@lookup', key: 'a' }, }, }, success({ - a: { b: 'C' }, + a: 'C', b: { c: 'C', }, @@ -293,25 +273,28 @@ elaborationSuite('@lookup', [ ], [ { - a: { b: 'C' }, + a: 'C', b: { - a: {}, // this `a` should be referenced - c: { 0: '@lookup', query: { 0: 'a', 1: 'b' } }, + a: 'other C', // this `a` should be referenced + c: { 0: '@lookup', key: 'a' }, }, }, - output => assert(either.isLeft(output)), + success({ + a: 'C', + b: { + a: 'other C', + c: 'other C', + }, + }), ], ]) elaborationSuite('@apply', [ - [ - { 0: '@apply', 1: { 0: '@lookup', query: { 0: 'identity' } }, 2: 'a' }, - success('a'), - ], + [{ 0: '@apply', 1: { 0: '@lookup', key: 'identity' }, 2: 'a' }, success('a')], [ { 0: '@apply', - function: { 0: '@lookup', query: { 0: 'identity' } }, + function: { 0: '@lookup', key: 'identity' }, argument: 'a', }, success('a'), @@ -319,7 +302,7 @@ elaborationSuite('@apply', [ [ { 0: '@apply', - function: { 0: '@lookup', query: { 0: 'identity' } }, + function: { 0: '@lookup', key: 'identity' }, argument: { foo: 'bar' }, }, success({ foo: 'bar' }), @@ -348,8 +331,8 @@ elaborationSuite('@apply', [ 0: '@function', parameter: 'b', body: { - A: { 0: '@lookup', query: 'a' }, - B: { 0: '@lookup', query: 'b' }, + A: { 0: '@lookup', key: 'a' }, + B: { 0: '@lookup', key: 'b' }, }, }, argument: 'b', @@ -367,7 +350,11 @@ elaborationSuite('@apply', [ 1: 'x', 2: { 0: '@apply', - function: { 0: '@lookup', 1: { 0: 'boolean', 1: 'not' } }, + function: { + 0: '@index', + 1: { 0: '@lookup', 1: 'boolean' }, + 2: { 0: 'not' }, + }, argument: { 0: '@lookup', 1: 'x' }, }, }, @@ -381,7 +368,11 @@ elaborationSuite('@apply', [ function: { 0: '@function', 1: 'x', - 2: { 0: '@lookup', 1: { 0: 'x', 1: 'a' } }, + 2: { + 0: '@index', + 1: { 0: '@lookup', 1: 'x' }, + 2: { 0: 'a' }, + }, }, argument: { a: 'it works' }, }, @@ -411,7 +402,7 @@ elaborationSuite('@apply', [ parameter: 'a', body: { 0: '@lookup', - query: 'a', + key: 'a', }, }, argument: 'it works', @@ -451,9 +442,9 @@ elaborationSuite('@apply', [ function: { 0: '@function', parameter: 'a', - body: { 0: '@lookup', query: 'a' }, + body: { 0: '@lookup', key: 'a' }, }, - argument: { 0: '@lookup', query: 'a' }, + argument: { 0: '@lookup', key: 'a' }, }, }, }, @@ -488,13 +479,13 @@ elaborationSuite('@apply', [ function: { 0: '@function', parameter: 'a', - body: { 0: '@lookup', query: 'a' }, + body: { 0: '@lookup', key: 'a' }, }, - argument: { 0: '@lookup', query: 'a' }, + argument: { 0: '@lookup', key: 'a' }, }, }, }, - argument: { 0: '@lookup', query: 'a' }, + argument: { 0: '@lookup', key: 'a' }, }, }, success({ @@ -526,7 +517,7 @@ elaborationSuite('@apply', [ function: { 0: '@function', parameter: 'b', - body: { 0: '@lookup', query: 'a' }, + body: { 0: '@lookup', key: 'a' }, }, argument: 'unused', }, @@ -565,7 +556,7 @@ elaborationSuite('@apply', [ function: { 0: '@function', parameter: 'b', - body: { 0: '@lookup', query: 'a' }, + body: { 0: '@lookup', key: 'a' }, }, argument: 'unused', }, @@ -603,7 +594,7 @@ elaborationSuite('@function', [ either.makeRight({ 0: '@function', parameter: 'x', - body: { 0: '@lookup', query: { 0: 'x' } }, + body: { 0: '@lookup', key: 'x' }, }), ) }, @@ -612,7 +603,7 @@ elaborationSuite('@function', [ elaborationSuite('@runtime', [ [ - { 0: '@runtime', 1: { 0: '@lookup', query: { 0: 'identity' } } }, + { 0: '@runtime', 1: { 0: '@lookup', key: 'identity' } }, either.makeRight( withPhantomData()( makeObjectNode({ 0: '@runtime', function: prelude['identity']! }), diff --git a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts index 3c0819b..dc82dd0 100644 --- a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts @@ -1,154 +1,103 @@ import either, { type Either } from '@matt.kantor/either' import option, { type Option } from '@matt.kantor/option' import type { ElaborationError } from '../../../errors.js' +import type { Atom } from '../../../parsing.js' import { applyKeyPathToSemanticGraph, + asSemanticGraph, + isExpression, isObjectNode, - keyPathFromObjectNodeOrMolecule, - keyPathToMolecule, makeLookupExpression, - makeObjectNode, prelude, readFunctionExpression, readLookupExpression, - stringifyKeyPathForEndUser, type Expression, type ExpressionContext, - type KeyPath, type KeywordHandler, type SemanticGraph, } from '../../../semantics.js' +import { inlinePlz, unparse } from '../../../unparsing.js' export const lookupKeywordHandler: KeywordHandler = ( expression: Expression, context: ExpressionContext, ): Either => - either.flatMap(readLookupExpression(expression), ({ query }) => - either.flatMap(keyPathFromObjectNodeOrMolecule(query), relativePath => { - if (isObjectNode(context.program)) { - return either.flatMap( - lookup({ - context, - relativePath, - }), - possibleValue => - option.match(possibleValue, { - none: () => - either.makeLeft({ - kind: 'invalidExpression', - message: `property \`${stringifyKeyPathForEndUser( - relativePath, - )}\` not found`, - }), - some: either.makeRight, + either.flatMap(readLookupExpression(expression), ({ key }) => { + if (isObjectNode(context.program)) { + return either.flatMap(lookup({ context, key }), possibleValue => + option.match(possibleValue, { + none: () => + either.makeLeft({ + kind: 'invalidExpression', + message: `property \`${stringifyKeyForEndUser(key)}\` not found`, }), - ) - } else { - return either.makeLeft({ - kind: 'invalidExpression', - message: 'the program has no properties', - }) - } - }), - ) + some: either.makeRight, + }), + ) + } else { + return either.makeLeft({ + kind: 'invalidExpression', + message: 'the program has no properties', + }) + } + }) const lookup = ({ context, - relativePath, + key, }: { readonly context: ExpressionContext - readonly relativePath: KeyPath + readonly key: Atom }): Either> => { - const [firstPathComponent, ...propertyPath] = relativePath - if (firstPathComponent === undefined) { - // TODO: Consider allowing empty paths, emitting a "hole" of type `nothing` (like `???` in - // Scala, `todo!()` in Rust, `?foo` in Idris, etc). - return either.makeLeft({ - kind: 'invalidExpression', - message: 'key paths cannot be empty', - }) - } if (context.location.length === 0) { // Check the prelude. - return option.match(applyKeyPathToSemanticGraph(prelude, relativePath), { - none: () => - either.makeLeft({ + const valueFromPrelude = prelude[key] + return valueFromPrelude === undefined + ? either.makeLeft({ kind: 'invalidExpression', - message: `property \`${stringifyKeyPathForEndUser( - relativePath, - )}\` not found`, - }), - some: valueFromPrelude => - either.makeRight(option.makeSome(valueFromPrelude)), - }) + message: `property \`${stringifyKeyForEndUser(key)}\` not found`, + }) + : either.makeRight(option.makeSome(asSemanticGraph(valueFromPrelude))) } else { const pathToCurrentScope = context.location.slice(0, -1) - const resultForCurrentScope: Either< - ElaborationError, - Option - > = option.match( + const possibleLookedUpValue = option.flatMap( applyKeyPathToSemanticGraph(context.program, pathToCurrentScope), - { - none: () => either.makeRight(option.none), - some: scope => - either.match(readFunctionExpression(scope), { - left: _ => - option.match( - applyKeyPathToSemanticGraph(scope, [firstPathComponent]), - { - none: () => either.makeRight(option.none), - some: property => - option.match( - applyKeyPathToSemanticGraph(property, propertyPath), - { - none: () => - either.makeLeft({ - kind: 'invalidExpression', - message: `\`${stringifyKeyPathForEndUser( - propertyPath, - )}\` is not a property of \`${stringifyKeyPathForEndUser( - [...pathToCurrentScope, firstPathComponent], - )}\``, - }), - some: lookedUpValue => - either.makeRight(option.makeSome(lookedUpValue)), - }, - ), - }, - ), - right: functionExpression => { - if (functionExpression.parameter === firstPathComponent) { - // Keep an unelaborated `@lookup` around for resolution when the `@function` is called. - return either.makeRight( - option.makeSome( - makeLookupExpression( - makeObjectNode(keyPathToMolecule(relativePath)), - ), - ), - ) - } else { - return either.makeRight(option.none) - } - }, - }), - }, + 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`. + isExpression(scope) + ? option.none + : applyKeyPathToSemanticGraph(scope, [key]), + right: functionExpression => + functionExpression.parameter === key + ? // Keep an unelaborated `@lookup` around for resolution when the `@function` is called. + option.makeSome(makeLookupExpression(key)) + : option.none, + }), ) - return either.flatMap(resultForCurrentScope, possibleLookedUpValue => - option.match(possibleLookedUpValue, { - none: () => - // Try the parent scope. - lookup({ - relativePath, - context: { - keywordHandlers: context.keywordHandlers, - location: pathToCurrentScope, - program: context.program, - }, - }), - some: lookedUpValue => either.makeRight(option.makeSome(lookedUpValue)), - }), - ) + return option.match(possibleLookedUpValue, { + none: () => + // Try the parent scope. + lookup({ + key, + context: { + keywordHandlers: context.keywordHandlers, + location: pathToCurrentScope, + program: context.program, + }, + }), + some: lookedUpValue => either.makeRight(option.makeSome(lookedUpValue)), + }) } } + +const stringifyKeyForEndUser = (key: Atom): string => + either.match(unparse(key, inlinePlz), { + right: stringifiedOutput => stringifiedOutput, + left: error => `(unserializable key: ${error.message})`, + }) diff --git a/src/language/compiling/semantics/keywords.ts b/src/language/compiling/semantics/keywords.ts index e148ee6..d79d50b 100644 --- a/src/language/compiling/semantics/keywords.ts +++ b/src/language/compiling/semantics/keywords.ts @@ -29,7 +29,7 @@ export const keywordHandlers: KeywordHandlers = { '@index': indexKeywordHandler, /** - * Given a query, resolves the value of a property within the program. + * Gets the value of a property with the given key (using lexical scoping). */ '@lookup': lookupKeywordHandler, diff --git a/src/language/compiling/unparsing.test.ts b/src/language/compiling/unparsing.test.ts index 8a1bb31..029e7fa 100644 --- a/src/language/compiling/unparsing.test.ts +++ b/src/language/compiling/unparsing.test.ts @@ -65,7 +65,7 @@ testCases( 1: { 0: '@function', parameter: 'context', - body: { 0: '@lookup', query: 'context.program.start_time' }, + body: { 0: '@lookup', key: 'context.program.start_time' }, }, }, either.makeRight('{ @runtime, context => :context.program.start_time }'), @@ -125,7 +125,7 @@ testCases( 1: { 0: '@function', parameter: 'context', - body: { 0: '@lookup', query: 'context.program.start_time' }, + body: { 0: '@lookup', key: 'context.program.start_time' }, }, }, either.makeRight( diff --git a/src/language/parsing/molecule.ts b/src/language/parsing/molecule.ts index 466c994..745aaca 100644 --- a/src/language/parsing/molecule.ts +++ b/src/language/parsing/molecule.ts @@ -124,8 +124,12 @@ const sugarFreeMolecule: Parser = optionallySurroundedByParentheses( const sugaredLookup: Parser = optionallySurroundedByParentheses( map( - sequence([literal(':'), oneOf([atomParser, sugarFreeMolecule])]), - ([_colon, query]) => ({ 0: '@lookup', query }), + sequence([ + literal(':'), + // Reserve `.` so that `:a.b` is parsed as a lookup followed by an index. + atomWithAdditionalQuotationRequirements(literal('.')), + ]), + ([_colon, key]) => ({ 0: '@lookup', key }), ), ) diff --git a/src/language/runtime/evaluator.test.ts b/src/language/runtime/evaluator.test.ts index 009ae82..5a269ec 100644 --- a/src/language/runtime/evaluator.test.ts +++ b/src/language/runtime/evaluator.test.ts @@ -16,31 +16,39 @@ 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')], [ [ '@runtime', [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@apply', ['@lookup', ['object', 'lookup']], 'environment'], [ '@apply', - ['@lookup', ['match']], + ['@index', ['@lookup', 'object'], ['lookup']], + 'environment', + ], + [ + '@apply', + ['@lookup', 'match'], { none: 'environment does not exist!', some: [ '@apply', - ['@lookup', ['flow']], + ['@lookup', 'flow'], [ - ['@apply', ['@lookup', ['object', 'lookup']], 'lookup'], [ '@apply', - ['@lookup', ['match']], + ['@index', ['@lookup', 'object'], ['lookup']], + 'lookup', + ], + [ + '@apply', + ['@lookup', 'match'], { none: 'environment.lookup does not exist!', - some: ['@apply', ['@lookup', ['apply']], 'PATH'], + some: ['@apply', ['@lookup', 'apply'], 'PATH'], }, ], ], @@ -58,7 +66,7 @@ testCases(evaluate, input => `evaluating \`${JSON.stringify(input)}\``)( }, ], [ - ['@check', 'not a boolean', ['@lookup', ['boolean', 'is']]], + ['@check', 'not a boolean', ['@index', ['@lookup', 'boolean'], 'is']], output => assert(either.isLeft(output)), ], ], diff --git a/src/language/semantics/expressions/lookup-expression.ts b/src/language/semantics/expressions/lookup-expression.ts index 0fde7cb..9085c0f 100644 --- a/src/language/semantics/expressions/lookup-expression.ts +++ b/src/language/semantics/expressions/lookup-expression.ts @@ -1,14 +1,12 @@ import either, { type Either } from '@matt.kantor/either' import type { ElaborationError } from '../../errors.js' -import type { Molecule } from '../../parsing.js' +import type { Atom, Molecule } from '../../parsing.js' import { isSpecificExpression } from '../expression.js' -import { isFunctionNode } from '../function-node.js' -import { - keyPathFromObjectNodeOrMolecule, - keyPathToMolecule, -} from '../key-path.js' import { makeObjectNode, type ObjectNode } from '../object-node.js' -import { type SemanticGraph } from '../semantic-graph.js' +import { + stringifySemanticGraphForEndUser, + type SemanticGraph, +} from '../semantic-graph.js' import { asSemanticGraph, readArgumentsFromExpression, @@ -16,7 +14,7 @@ import { export type LookupExpression = ObjectNode & { readonly 0: '@lookup' - readonly query: ObjectNode | Molecule + readonly key: Atom } export const readLookupExpression = ( @@ -24,24 +22,17 @@ export const readLookupExpression = ( ): Either => isSpecificExpression('@lookup', node) ? either.flatMap( - readArgumentsFromExpression(node, [['query', '1']]), - ([q]) => { - const query = asSemanticGraph(q) - if (isFunctionNode(query)) { + readArgumentsFromExpression(node, [['key', '1']]), + ([key]) => { + if (typeof key !== 'string') { return either.makeLeft({ kind: 'invalidExpression', - message: 'query cannot be a function', + message: `lookup key must be an atom, got \`${stringifySemanticGraphForEndUser( + asSemanticGraph(key), + )}\``, }) } else { - const canonicalizedQuery = - typeof query === 'string' - ? makeObjectNode(keyPathToMolecule(query.split('.'))) - : query - - return either.map( - keyPathFromObjectNodeOrMolecule(canonicalizedQuery), - _keyPath => makeLookupExpression(canonicalizedQuery), - ) + return either.makeRight(makeLookupExpression(key)) } }, ) @@ -50,10 +41,8 @@ export const readLookupExpression = ( message: 'not an expression', }) -export const makeLookupExpression = ( - query: ObjectNode | Molecule, -): LookupExpression => +export const makeLookupExpression = (key: Atom): LookupExpression => makeObjectNode({ 0: '@lookup', - query, + key, }) diff --git a/src/language/semantics/prelude.ts b/src/language/semantics/prelude.ts index 7251f09..c7e4514 100644 --- a/src/language/semantics/prelude.ts +++ b/src/language/semantics/prelude.ts @@ -2,7 +2,11 @@ import either, { type Either } from '@matt.kantor/either' import option from '@matt.kantor/option' import type { DependencyUnavailable, Panic } from '../errors.js' import type { Atom } from '../parsing.js' -import { makeApplyExpression, makeLookupExpression } from '../semantics.js' +import { + makeApplyExpression, + makeIndexExpression, + makeLookupExpression, +} from '../semantics.js' import { isFunctionNode, makeFunctionNode } from './function-node.js' import { keyPathToMolecule, type KeyPath } from './key-path.js' import { @@ -44,17 +48,32 @@ const handleUnavailableDependencies = } } +type NonEmptyKeyPath = readonly [Atom, ...KeyPath] + +const keyPathToLookupExpression = (keyPath: NonEmptyKeyPath) => { + const [initialKey, ...indexes] = keyPath + const initialLookup = makeLookupExpression(initialKey) + if (indexes.length === 0) { + return initialLookup + } else { + return makeIndexExpression({ + object: initialLookup, + query: keyPathToMolecule(indexes), + }) + } +} + const serializePartiallyAppliedFunction = - (keyPath: KeyPath, argument: SemanticGraph) => () => + (keyPath: NonEmptyKeyPath, argument: SemanticGraph) => () => either.makeRight( makeApplyExpression({ - function: makeLookupExpression(keyPathToMolecule(keyPath)), + function: keyPathToLookupExpression(keyPath), argument, }), ) const preludeFunction = ( - keyPath: KeyPath, + keyPath: NonEmptyKeyPath, signature: FunctionType['signature'], f: ( value: SemanticGraph, @@ -62,7 +81,7 @@ const preludeFunction = ( ) => makeFunctionNode( signature, - () => either.makeRight(makeLookupExpression(keyPathToMolecule(keyPath))), + () => either.makeRight(keyPathToLookupExpression(keyPath)), option.none, handleUnavailableDependencies(f), ) diff --git a/src/language/unparsing/plz-utilities.ts b/src/language/unparsing/plz-utilities.ts index d91db36..65893b3 100644 --- a/src/language/unparsing/plz-utilities.ts +++ b/src/language/unparsing/plz-utilities.ts @@ -12,7 +12,6 @@ import { serialize, type ApplyExpression, type FunctionExpression, - type KeyPath, type LookupExpression, type SemanticGraph, } from '../semantics.js' @@ -171,35 +170,7 @@ const unparseSugaredFunction = ( const unparseSugaredLookup = ( expression: LookupExpression, unparseAtomOrMolecule: UnparseAtomOrMolecule, -) => { - const keyPath = Object.entries(expression.query).reduce( - (accumulator: KeyPath | 'invalid', [key, value]) => { - if (accumulator === 'invalid') { - return accumulator - } else { - if (key === String(accumulator.length) && typeof value === 'string') { - return [...accumulator, value] - } else { - return 'invalid' - } - } - }, - [], +) => + either.map(unparseAtomOrMolecule(expression.key), key => + kleur.cyan(colon.concat(key)), ) - - if ( - keyPath !== 'invalid' && - Object.keys(expression.query).length === keyPath.length && - keyPath.every(key => !either.isLeft(unquotedAtomParser(key))) - ) { - return either.makeRight(kleur.cyan(colon.concat(keyPath.join(dot)))) - } else { - return either.flatMap( - serializeIfNeeded(expression.query), - serializedKeyPath => - either.map(unparseAtomOrMolecule(serializedKeyPath), keyPathAsString => - kleur.cyan(colon.concat(keyPathAsString)), - ), - ) - } -}