Skip to content

Commit a016077

Browse files
committed
Make infix expressions right-associative
1 parent 1a8e4a7 commit a016077

File tree

6 files changed

+134
-94
lines changed

6 files changed

+134
-94
lines changed

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,24 +172,29 @@ example, the expression `x f y` desugars to `:f(y)(x)`. Here's another example:
172172
```
173173
{
174174
cons: b => a => { :a, :b }
175-
list: 1 cons (2 cons 3) // evaluates to `{ 1, { 2, 3 } }`
175+
list: 1 cons 2 cons 3 // evaluates to `{ 1, { 2, 3 } }`
176176
}
177177
```
178178

179179
The standard library contains symbolically-named functions for arithmetic and
180-
other familiar binary operations. For example, `1 + 2 - 3` is `0`. Also
181-
included in the standard library are the functions`|>` (pipe) and `>>` (flow):
180+
other familiar binary operations. For example, `1 + 2 - 3` is `0`. Also included
181+
in the standard library are the functions`|>` (pipe) and `>>` (flow):
182182

183183
```
184184
{
185-
// `>>` composes functions from left to right
185+
// `>>` composes functions
186186
append_bc: :atom.append(b) >> :atom.append(c)
187187
188188
// `|>` pipes an argument into a function
189189
abc: a |> :append_bc
190190
}
191191
```
192192

193+
All binary operations are currently right-associative and there is no operator
194+
precedence. This means some arithmetic expressions may not behave the way you're
195+
used to; for example `1 - 2 - 3` means `1 - (2 - 3)` (2), not `(1 - 2) - 3`
196+
(-4). Use of parentheses is encouraged.
197+
193198
### Semantics
194199

195200
Please is a functional programming language. Currently all functions are pure,

src/end-to-end.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ testCases(endToEnd, code => code)('end-to-end tests', [
168168
[`:integer.subtract(-1)(-1)`, either.makeRight('0')],
169169
[`-1 - -1`, either.makeRight('0')],
170170
[`2 - 1`, either.makeRight('1')],
171+
[
172+
`1 - 2 - 3`,
173+
either.makeRight(
174+
'2', // with traditional operator associativity this would be `-4`
175+
),
176+
],
177+
[`1 - (2 - 3)`, either.makeRight('2')],
178+
[`(1 - 2) - 3`, either.makeRight('-4')],
171179
[':flow(:atom.append(b))(:atom.append(a))(z)', either.makeRight('zab')],
172180
[
173181
`{@runtime
@@ -314,7 +322,8 @@ testCases(endToEnd, code => code)('end-to-end tests', [
314322
[`(a => (1 + :a))(1)`, either.makeRight('2')],
315323
[`2 |> (a => :a)`, either.makeRight('2')],
316324
[`a atom.append b atom.append c`, either.makeRight('abc')],
317-
[`b atom.append c atom.prepend a`, either.makeRight('abc')],
325+
[`a atom.append c atom.prepend b`, either.makeRight('abc')],
326+
[`(b atom.append c) atom.prepend a`, either.makeRight('abc')],
318327
[`a atom.append (c atom.prepend b)`, either.makeRight('abc')],
319328
[
320329
`1
@@ -336,7 +345,7 @@ testCases(endToEnd, code => code)('end-to-end tests', [
336345
(
337346
PATH
338347
|> :context.environment.lookup
339-
|> :match({
348+
>> :match({
340349
none: _ => "$PATH not set"
341350
some: :atom.prepend("PATH=")
342351
})
@@ -376,13 +385,14 @@ testCases(endToEnd, code => code)('end-to-end tests', [
376385
)(0)`,
377386
either.makeRight('10'),
378387
],
379-
[`a |> :atom.append(b) |> :atom.append(c)`, either.makeRight('abc')],
380-
[`a |> (:atom.append(b) >> :atom.append(c))`, either.makeRight('abc')],
388+
[`a |> :atom.append(b) >> :atom.append(c)`, either.makeRight('abc')],
389+
[`(a |> :atom.append(b)) |> :atom.append(c)`, either.makeRight('abc')],
381390
[`:|>(:>>(:atom.append(c))(:atom.append(b)))(a)`, either.makeRight('abc')],
382391
[
383392
`{
384393
|>: f => a => :f(:a)
385-
abc: a |> :atom.append(b) |> :atom.append(c)
394+
ab: a |> :atom.append(b)
395+
abc: :ab |> :atom.append(c)
386396
}.abc`,
387397
either.makeRight('abc'),
388398
],

src/language/parsing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export type { Atom } from './parsing/atom.js'
2-
export type { Molecule } from './parsing/molecule.js'
2+
export type { Molecule } from './parsing/expression.js'
33
export {
44
canonicalize,
55
type JsonValueForbiddingSymbolicKeys,

src/language/parsing/atom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,6 @@ const quotedAtomParser = map(
106106
([_1, contents, _2]) => contents,
107107
)
108108

109-
export const atomParser: Parser<Atom> = optionallySurroundedByParentheses(
109+
export const atom: Parser<Atom> = optionallySurroundedByParentheses(
110110
oneOf([unquotedAtomParser, quotedAtomParser]),
111111
)

src/language/parsing/molecule.ts renamed to src/language/parsing/expression.ts

Lines changed: 103 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import type { Writable } from '../../utility-types.js'
1212
import { keyPathToMolecule, type KeyPath } from '../semantics.js'
1313
import {
14-
atomParser,
14+
atom,
1515
atomWithAdditionalQuotationRequirements,
1616
type Atom,
1717
} from './atom.js'
@@ -49,7 +49,7 @@ const optional = <Output>(
4949
): Parser<Output | undefined> => oneOf([parser, nothing])
5050

5151
const trailingIndexesAndArgumentsToExpression = (
52-
root: Molecule,
52+
root: Atom | Molecule,
5353
trailingIndexesAndArguments: readonly TrailingIndexOrArgument[],
5454
) =>
5555
trailingIndexesAndArguments.reduce((expression, indexOrArgument) => {
@@ -69,73 +69,93 @@ const trailingIndexesAndArgumentsToExpression = (
6969
}
7070
}, root)
7171

72-
const infixOperationToExpression = (
73-
initialExpression: Atom | Molecule,
74-
tokens: readonly [InfixToken, ...InfixToken[]],
75-
): Molecule => {
76-
const [
77-
[
78-
[firstOperatorLookupKey, firstOperatorTrailingIndexesAndArguments],
79-
firstArgument,
80-
],
81-
...additionalTokens
82-
] = tokens
83-
84-
const firstFunction = trailingIndexesAndArgumentsToExpression(
85-
{ 0: '@lookup', key: firstOperatorLookupKey },
86-
firstOperatorTrailingIndexesAndArguments,
87-
)
72+
type InfixOperator = readonly [Atom, readonly TrailingIndexOrArgument[]]
73+
type InfixOperand = Atom | Molecule
74+
type InfixToken = InfixOperator | InfixOperand
8875

89-
const initialApplication: Molecule = {
90-
0: '@apply',
91-
function: {
92-
0: '@apply',
93-
function: firstFunction,
94-
argument: firstArgument,
95-
},
96-
argument: initialExpression,
97-
}
76+
/**
77+
* Infix operations should be of the following form:
78+
* ```
79+
* [InfixOperand, InfixOperator, InfixOperand, InfixOperator, …, InfixOperand]
80+
* ```
81+
* However this can't be directly modeled in TypeScript.
82+
*/
83+
type InfixOperation = readonly [InfixToken, ...InfixToken[]]
9884

99-
return additionalTokens.reduce(
100-
(
101-
expression,
102-
[[operatorLookupKey, operatorTrailingIndexesAndArguments], nextArgument],
103-
) => {
104-
const nextFunction = trailingIndexesAndArgumentsToExpression(
105-
{ 0: '@lookup', key: operatorLookupKey },
106-
operatorTrailingIndexesAndArguments,
85+
const isOperand = (value: InfixToken | undefined): value is InfixOperand =>
86+
!Array.isArray(value)
87+
const isOperator = (value: InfixToken | undefined): value is InfixOperator =>
88+
Array.isArray(value)
89+
90+
const appendToArray = <A>(b: A, init: readonly A[]): readonly [A, ...A[]] =>
91+
// Unfortunately TypeScript can't reason through this on its own:
92+
[...init, b] as readonly A[] as readonly [A, ...A[]]
93+
94+
const infixTokensToExpression = (
95+
operation: InfixOperation,
96+
): Molecule | Atom => {
97+
const firstToken = operation[0]
98+
if (operation.length === 1 && isOperand(firstToken)) {
99+
return firstToken
100+
} else {
101+
const rightmostOperationRHS = operation[operation.length - 1]
102+
if (rightmostOperationRHS === undefined) {
103+
throw new Error('Infix operation was empty. This is a bug!')
104+
}
105+
if (!isOperand(rightmostOperationRHS)) {
106+
throw new Error(
107+
'Rightmost token in infix operation was not an operand. This is a bug!',
107108
)
108-
return {
109+
}
110+
const rightmostOperator = operation[operation.length - 2]
111+
if (!isOperator(rightmostOperator)) {
112+
throw new Error(
113+
'Could not find rightmost operator in infix operation. This is a bug!',
114+
)
115+
}
116+
117+
const rightmostOperationLHS = operation[operation.length - 3]
118+
if (!isOperand(rightmostOperationLHS)) {
119+
throw new Error(
120+
'Missing left-hand side of infix operation. This is a bug!',
121+
)
122+
}
123+
124+
const rightmostFunction = trailingIndexesAndArgumentsToExpression(
125+
{ 0: '@lookup', key: rightmostOperator[0] },
126+
rightmostOperator[1],
127+
)
128+
129+
const reducedRightmostOperation: Molecule = {
130+
0: '@apply',
131+
function: {
109132
0: '@apply',
110-
function: {
111-
0: '@apply',
112-
function: nextFunction,
113-
argument: nextArgument,
114-
},
115-
argument: expression,
116-
}
117-
},
118-
initialApplication,
119-
)
133+
function: rightmostFunction,
134+
argument: rightmostOperationRHS,
135+
},
136+
argument: rightmostOperationLHS,
137+
}
138+
139+
return infixTokensToExpression(
140+
appendToArray(reducedRightmostOperation, operation.slice(0, -3)),
141+
)
142+
}
120143
}
121144

122145
const atomRequiringDotQuotation = atomWithAdditionalQuotationRequirements(dot)
123146

124-
const propertyKey: Parser<Atom> = atomParser
125-
const propertyValue: Parser<Molecule | Atom> = oneOf([
126-
lazy(() => moleculeParser),
127-
optionallySurroundedByParentheses(atomParser),
128-
])
129-
130147
const namedProperty = map(
131-
sequence([propertyKey, colon, optionalTrivia, propertyValue]),
148+
sequence([atom, colon, optionalTrivia, lazy(() => expression)]),
132149
([key, _colon, _trivia, value]) => [key, value] as const,
133150
)
134151

135152
const propertyWithOptionalKey = optionallySurroundedByParentheses(
136153
oneOf([
137154
namedProperty,
138-
map(propertyValue, value => [undefined, value] as const),
155+
map(
156+
lazy(() => expression),
157+
value => [undefined, value] as const,
158+
),
139159
]),
140160
)
141161

@@ -144,7 +164,7 @@ const propertyDelimiter = oneOf([
144164
sequence([optional(triviaExceptNewlines), newline, optionalTrivia]),
145165
])
146166

147-
const argument = surroundedByParentheses(propertyValue)
167+
const argument = surroundedByParentheses(lazy(() => expression))
148168

149169
const compactDottedKeyPathComponent = map(
150170
sequence([dot, atomRequiringDotQuotation]),
@@ -272,12 +292,10 @@ const compactExpression: Parser<Molecule | Atom> = oneOf([
272292
// {}
273293
lazy(() => precededByOpeningBrace),
274294
// 1
275-
atomParser,
295+
atom,
276296
])
277297

278-
const trailingInfixTokens: Parser<
279-
readonly [InfixToken, ...(readonly InfixToken[])]
280-
> = oneOrMore(
298+
const trailingInfixTokens = oneOrMore(
281299
map(
282300
sequence([
283301
trivia,
@@ -301,9 +319,9 @@ const trailingInfixTokens: Parser<
301319
),
302320
)
303321

304-
type InfixToken = readonly [
305-
readonly [Atom, readonly TrailingIndexOrArgument[]],
306-
Molecule | Atom,
322+
type TrailingInfixToken = readonly [
323+
operator: readonly [Atom, readonly TrailingIndexOrArgument[]],
324+
operand: Molecule | Atom,
307325
]
308326
type TrailingFunctionBodyOrInfixTokens =
309327
| {
@@ -313,11 +331,15 @@ type TrailingFunctionBodyOrInfixTokens =
313331
}
314332
| {
315333
readonly kind: 'infixTokens'
316-
readonly tokens: readonly [InfixToken, ...(readonly InfixToken[])]
334+
readonly tokens: readonly [
335+
TrailingInfixToken,
336+
...(readonly TrailingInfixToken[]),
337+
]
317338
}
339+
318340
const precededByAtomThenTrivia = map(
319341
sequence([
320-
atomParser,
342+
atom,
321343
oneOf([
322344
// a => :b
323345
// a => {}
@@ -331,11 +353,11 @@ const precededByAtomThenTrivia = map(
331353
trivia,
332354
zeroOrMore(
333355
map(
334-
sequence([atomParser, trivia, arrow, trivia]),
356+
sequence([atom, trivia, arrow, trivia]),
335357
([parameter, _trivia1, _arrow, _trivia2]) => parameter,
336358
),
337359
),
338-
propertyValue,
360+
lazy(() => expression),
339361
]),
340362
([
341363
_trivia1,
@@ -381,10 +403,10 @@ const precededByAtomThenTrivia = map(
381403
initialFunction,
382404
)
383405
case 'infixTokens':
384-
return infixOperationToExpression(
406+
return infixTokensToExpression([
385407
initialAtom,
386-
trailingFunctionBodyOrInfixTokens.tokens,
387-
)
408+
...trailingFunctionBodyOrInfixTokens.tokens.flat(),
409+
])
388410
}
389411
},
390412
)
@@ -425,9 +447,10 @@ const precededByColonThenAtom = map(
425447
if (firstToken === undefined) {
426448
return initialExpression
427449
} else {
428-
return infixOperationToExpression(initialExpression, [
429-
firstToken,
430-
...additionalTokens,
450+
return infixTokensToExpression([
451+
initialExpression,
452+
...firstToken,
453+
...additionalTokens.flat(),
431454
])
432455
}
433456
},
@@ -444,15 +467,18 @@ const precededByColonThenAtom = map(
444467
const precededByOpeningParenthesis = oneOf([
445468
map(
446469
sequence([
447-
surroundedByParentheses(lazy(() => moleculeParser)),
470+
surroundedByParentheses(lazy(() => expression)),
448471
trailingInfixTokens,
449472
]),
450473
([initialExpression, trailingInfixTokens]) =>
451-
infixOperationToExpression(initialExpression, trailingInfixTokens),
474+
infixTokensToExpression([
475+
initialExpression,
476+
...trailingInfixTokens.flat(),
477+
]),
452478
),
453479
map(
454480
sequence([
455-
surroundedByParentheses(lazy(() => moleculeParser)),
481+
surroundedByParentheses(lazy(() => expression)),
456482
trailingIndexesAndArguments,
457483
]),
458484
([expression, trailingIndexesAndArguments]) =>
@@ -476,9 +502,10 @@ const precededByOpeningBrace = map(
476502
),
477503
)
478504

479-
export const moleculeParser: Parser<Molecule> = oneOf([
505+
export const expression: Parser<Atom | Molecule> = oneOf([
480506
precededByOpeningParenthesis,
481507
precededByOpeningBrace,
482508
precededByColonThenAtom,
483509
precededByAtomThenTrivia,
510+
atom,
484511
])

0 commit comments

Comments
 (0)