diff --git a/README.md b/README.md index f199d68..0bfb3df 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ keyword expressions. Under the hood, keyword expressions are modeled as objects. For example, `:foo` desugars to `{@lookup query: foo}`. All such expressions have a key `0` referring to a value that is an `@`-prefixed atom (the keyword). Keywords -include `@function`, `@lookup`, `@apply`, `@check`, and `@runtime`. +include `@function`, `@lookup`, `@apply`, `@check`, `@index`, and `@runtime`. Currently only `@function`, `@lookup`, and `@apply` have syntax sugars. diff --git a/src/language/compiling/semantics.test.ts b/src/language/compiling/semantics.test.ts index 2b37e21..acf90e7 100644 --- a/src/language/compiling/semantics.test.ts +++ b/src/language/compiling/semantics.test.ts @@ -173,6 +173,34 @@ elaborationSuite('@check', [ ], ]) +elaborationSuite('@index', [ + [{ 0: '@index', 1: { foo: 'bar' }, 2: { 0: 'foo' } }, success('bar')], + [ + { 0: '@index', object: { foo: 'bar' }, query: { 0: 'foo' } }, + success('bar'), + ], + [ + { + 0: '@index', + 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' }, + }, + success({ c: 'it works' }), + ], + [ + { 0: '@index', object: {}, query: { 0: 'thisPropertyDoesNotExist' } }, + output => assert(either.isLeft(output)), + ], +]) + elaborationSuite('@lookup', [ [ { diff --git a/src/language/compiling/semantics/keyword-handlers/index-handler.ts b/src/language/compiling/semantics/keyword-handlers/index-handler.ts new file mode 100644 index 0000000..c3ef987 --- /dev/null +++ b/src/language/compiling/semantics/keyword-handlers/index-handler.ts @@ -0,0 +1,36 @@ +import either, { type Either } from '@matt.kantor/either' +import option from '@matt.kantor/option' +import type { ElaborationError } from '../../../errors.js' +import { + applyKeyPathToSemanticGraph, + asSemanticGraph, + keyPathFromObjectNodeOrMolecule, + readIndexExpression, + stringifyKeyPathForEndUser, + type Expression, + type ExpressionContext, + type KeywordHandler, + type SemanticGraph, +} from '../../../semantics.js' + +export const indexKeywordHandler: KeywordHandler = ( + expression: Expression, + _context: ExpressionContext, +): Either => + either.flatMap(readIndexExpression(expression), ({ object, query }) => + either.flatMap(keyPathFromObjectNodeOrMolecule(query), keyPath => + option.match( + applyKeyPathToSemanticGraph(asSemanticGraph(object), keyPath), + { + none: () => + either.makeLeft({ + kind: 'invalidExpression', + message: `property \`${stringifyKeyPathForEndUser( + keyPath, + )}\` not found`, + }), + some: either.makeRight, + }, + ), + ), + ) diff --git a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts index 0c37b07..3c0819b 100644 --- a/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts +++ b/src/language/compiling/semantics/keyword-handlers/lookup-handler.ts @@ -1,13 +1,10 @@ import either, { type Either } from '@matt.kantor/either' import option, { type Option } from '@matt.kantor/option' -import type { - ElaborationError, - InvalidExpressionError, -} from '../../../errors.js' -import type { Molecule } from '../../../parsing.js' +import type { ElaborationError } from '../../../errors.js' import { applyKeyPathToSemanticGraph, isObjectNode, + keyPathFromObjectNodeOrMolecule, keyPathToMolecule, makeLookupExpression, makeObjectNode, @@ -19,7 +16,6 @@ import { type ExpressionContext, type KeyPath, type KeywordHandler, - type ObjectNode, type SemanticGraph, } from '../../../semantics.js' @@ -28,7 +24,7 @@ export const lookupKeywordHandler: KeywordHandler = ( context: ExpressionContext, ): Either => either.flatMap(readLookupExpression(expression), ({ query }) => - either.flatMap(keyPathFromObject(query), relativePath => { + either.flatMap(keyPathFromObjectNodeOrMolecule(query), relativePath => { if (isObjectNode(context.program)) { return either.flatMap( lookup({ @@ -56,28 +52,6 @@ export const lookupKeywordHandler: KeywordHandler = ( }), ) -const keyPathFromObject = ( - node: ObjectNode | Molecule, -): Either => { - const relativePath: string[] = [] - let queryIndex = 0 - // Consume numeric indexes ("0", "1", …) until exhausted, validating that each is an atom. - let key = node[queryIndex] - while (key !== undefined) { - if (typeof key !== 'string') { - return either.makeLeft({ - kind: 'invalidExpression', - message: 'query must be a key path composed of sequential atoms', - }) - } else { - relativePath.push(key) - } - queryIndex++ - key = node[queryIndex] - } - return either.makeRight(relativePath) -} - const lookup = ({ context, relativePath, diff --git a/src/language/compiling/semantics/keywords.ts b/src/language/compiling/semantics/keywords.ts index 4efe461..e148ee6 100644 --- a/src/language/compiling/semantics/keywords.ts +++ b/src/language/compiling/semantics/keywords.ts @@ -2,6 +2,7 @@ import { type KeywordHandlers } from '../../semantics.js' import { applyKeywordHandler } from './keyword-handlers/apply-handler.js' import { checkKeywordHandler } from './keyword-handlers/check-handler.js' import { functionKeywordHandler } from './keyword-handlers/function-handler.js' +import { indexKeywordHandler } from './keyword-handlers/index-handler.js' import { lookupKeywordHandler } from './keyword-handlers/lookup-handler.js' import { runtimeKeywordHandler } from './keyword-handlers/runtime-handler.js' import { todoKeywordHandler } from './keyword-handlers/todo-handler.js' @@ -22,6 +23,11 @@ export const keywordHandlers: KeywordHandlers = { */ '@function': functionKeywordHandler, + /** + * Returns the value of a property within an object. + */ + '@index': indexKeywordHandler, + /** * Given a query, resolves the value of a property within the program. */ diff --git a/src/language/semantics.ts b/src/language/semantics.ts index 040093c..ce67e1e 100644 --- a/src/language/semantics.ts +++ b/src/language/semantics.ts @@ -27,6 +27,11 @@ export { readFunctionExpression, type FunctionExpression, } from './semantics/expressions/function-expression.js' +export { + makeIndexExpression, + readIndexExpression, + type IndexExpression, +} from './semantics/expressions/index-expression.js' export { makeLookupExpression, readLookupExpression, @@ -44,6 +49,7 @@ export { type FunctionNode, } from './semantics/function-node.js' export { + keyPathFromObjectNodeOrMolecule, keyPathToMolecule, stringifyKeyPathForEndUser, type KeyPath, diff --git a/src/language/semantics/expressions/index-expression.ts b/src/language/semantics/expressions/index-expression.ts new file mode 100644 index 0000000..3ae9df9 --- /dev/null +++ b/src/language/semantics/expressions/index-expression.ts @@ -0,0 +1,69 @@ +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 { keyPathFromObjectNodeOrMolecule } from '../key-path.js' +import { + isObjectNode, + makeUnelaboratedObjectNode, + type ObjectNode, +} from '../object-node.js' +import { type SemanticGraph, type unelaboratedKey } from '../semantic-graph.js' +import { + asSemanticGraph, + readArgumentsFromExpression, +} from './expression-utilities.js' + +export type IndexExpression = ObjectNode & { + readonly 0: '@index' + readonly object: ObjectNode | Molecule + readonly query: ObjectNode | Molecule +} + +export const readIndexExpression = ( + node: SemanticGraph | Molecule, +): Either => + isSpecificExpression('@index', node) + ? either.flatMap( + readArgumentsFromExpression(node, [ + ['object', '1'], + ['query', '2'], + ]), + ([o, q]) => { + const object = asSemanticGraph(o) + const query = asSemanticGraph(q) + if (!isObjectNode(object)) { + return either.makeLeft({ + kind: 'invalidExpression', + message: 'object must be an object', + }) + } else if (!isObjectNode(query)) { + return either.makeLeft({ + kind: 'invalidExpression', + message: 'query must be an object', + }) + } else { + return either.map( + keyPathFromObjectNodeOrMolecule(query), + _validKeyPath => makeIndexExpression({ object, query }), + ) + } + }, + ) + : either.makeLeft({ + kind: 'invalidExpression', + message: 'not an expression', + }) + +export const makeIndexExpression = ({ + query, + object, +}: { + readonly query: ObjectNode | Molecule + readonly object: ObjectNode | Molecule +}): IndexExpression & { readonly [unelaboratedKey]: true } => + makeUnelaboratedObjectNode({ + 0: '@index', + object, + query, + }) diff --git a/src/language/semantics/expressions/lookup-expression.ts b/src/language/semantics/expressions/lookup-expression.ts index a01af48..cf34e0f 100644 --- a/src/language/semantics/expressions/lookup-expression.ts +++ b/src/language/semantics/expressions/lookup-expression.ts @@ -1,9 +1,12 @@ import either, { type Either } from '@matt.kantor/either' -import type { ElaborationError, InvalidExpressionError } from '../../errors.js' +import type { ElaborationError } from '../../errors.js' import type { Molecule } from '../../parsing.js' import { isSpecificExpression } from '../expression.js' import { isFunctionNode } from '../function-node.js' -import { keyPathToMolecule, type KeyPath } from '../key-path.js' +import { + keyPathFromObjectNodeOrMolecule, + keyPathToMolecule, +} from '../key-path.js' import { makeObjectNode, makeUnelaboratedObjectNode, @@ -40,7 +43,7 @@ export const readLookupExpression = ( : query return either.map( - keyPathFromObjectNode(canonicalizedQuery), + keyPathFromObjectNodeOrMolecule(canonicalizedQuery), _keyPath => makeLookupExpression(canonicalizedQuery), ) } @@ -58,25 +61,3 @@ export const makeLookupExpression = ( 0: '@lookup', query, }) - -const keyPathFromObjectNode = ( - node: ObjectNode, -): Either => { - const relativePath: string[] = [] - let queryIndex = 0 - // Consume numeric indexes ("0", "1", …) until exhausted, validating that each is an atom. - let key = node[queryIndex] - while (key !== undefined) { - if (typeof key !== 'string') { - return either.makeLeft({ - kind: 'invalidExpression', - message: 'query must be a key path composed of sequential atoms', - }) - } else { - relativePath.push(key) - } - queryIndex++ - key = node[queryIndex] - } - return either.makeRight(relativePath) -} diff --git a/src/language/semantics/key-path.ts b/src/language/semantics/key-path.ts index a8e04e2..0c0e55f 100644 --- a/src/language/semantics/key-path.ts +++ b/src/language/semantics/key-path.ts @@ -1,6 +1,8 @@ -import either from '@matt.kantor/either' +import either, { type Either } from '@matt.kantor/either' +import type { InvalidExpressionError } from '../errors.js' import type { Atom, Molecule } from '../parsing.js' import { inlinePlz, unparse } from '../unparsing.js' +import type { ObjectNode } from './object-node.js' export type KeyPath = readonly Atom[] @@ -12,3 +14,25 @@ export const stringifyKeyPathForEndUser = (keyPath: KeyPath): string => export const keyPathToMolecule = (keyPath: KeyPath): Molecule => Object.fromEntries(keyPath.flatMap((key, index) => [[index, key]])) + +export const keyPathFromObjectNodeOrMolecule = ( + node: ObjectNode | Molecule, +): Either => { + const relativePath: string[] = [] + let queryIndex = 0 + // Consume numeric indexes ("0", "1", …) until exhausted, validating that each is an atom. + let key = node[queryIndex] + while (key !== undefined) { + if (typeof key !== 'string') { + return either.makeLeft({ + kind: 'invalidExpression', + message: 'expected a key path composed of sequential atoms', + }) + } else { + relativePath.push(key) + } + queryIndex++ + key = node[queryIndex] + } + return either.makeRight(relativePath) +} diff --git a/src/language/semantics/keyword.ts b/src/language/semantics/keyword.ts index f92ef17..80c04cb 100644 --- a/src/language/semantics/keyword.ts +++ b/src/language/semantics/keyword.ts @@ -2,6 +2,7 @@ export const isKeyword = (input: string) => input === '@apply' || input === '@check' || input === '@function' || + input === '@index' || input === '@lookup' || input === '@runtime' || input === '@todo'