Compare commits
12 commits
20b6624ed7
...
7c74c9e34f
Author | SHA1 | Date | |
---|---|---|---|
7c74c9e34f | |||
9acba7431f | |||
7e21e71a07 | |||
ce991fd12a | |||
2b6aff0dab | |||
2770a2de3b | |||
5a34b0f6f7 | |||
1078dc3653 | |||
21e8d95f98 | |||
1a46130b1d | |||
bf44d84e17 | |||
62593b0ab8 |
3 changed files with 250 additions and 5 deletions
80
src/lib/expr.test.ts
Normal file
80
src/lib/expr.test.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { AsText, TextPiece } from "../words";
|
||||
import { Expr } from "./expr";
|
||||
|
||||
describe("expr", () => {
|
||||
describe("does math", () => {
|
||||
test.each([
|
||||
["1", "1"],
|
||||
["-1", "-1"],
|
||||
["1 + 2", "3"],
|
||||
["1 - 2", "-1"],
|
||||
["1 * 2", "2"],
|
||||
["1 / 2", "0.5"],
|
||||
// floored division
|
||||
["1 // 5", "0"],
|
||||
["6 // 5", "1"],
|
||||
["-1 // 5", "-1"],
|
||||
["-6 // 5", "-2"],
|
||||
["1 // -5", "-1"],
|
||||
["6 // -5", "-2"],
|
||||
["-1 // -5", "0"],
|
||||
["-6 // -5", "1"],
|
||||
// floored modulo
|
||||
["1 % 5", "1"],
|
||||
["6 % 5", "1"],
|
||||
["-6 % 5", "4"],
|
||||
["-1 % 5", "4"],
|
||||
["1 % -5", "-4"],
|
||||
["6 % -5", "-4"],
|
||||
["-6 % -5", "-1"],
|
||||
["-1 % -5", "-1"],
|
||||
])("%s", (expression, result) => {
|
||||
const actualResult = Expr({}, [{ text: expression }]);
|
||||
expect("error" in actualResult).toBeFalsy();
|
||||
expect(AsText(actualResult as TextPiece)).toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles operator precedence", () => {
|
||||
test.each([
|
||||
["1 - 2 + 1", "0"],
|
||||
["1 + 2 * 3", "7"],
|
||||
["1 / 2 + 3", "3.5"],
|
||||
])("%s", (expression, result) => {
|
||||
const actualResult = Expr({}, [{ text: expression }]);
|
||||
expect("error" in actualResult).toBeFalsy();
|
||||
expect(AsText(actualResult as TextPiece)).toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: parentheses
|
||||
|
||||
// TODO; error reporting
|
||||
describe("rejects invalid expressions", () => {
|
||||
test.each([[""], ["1 $ 2"], ["1 1 + 2"], ["1 + + 2"], ["$ 1"]])(
|
||||
"%s",
|
||||
(expression) => {
|
||||
const actualResult = Expr({}, [{ text: expression }]);
|
||||
expect("error" in actualResult).toBeTruthy();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: operators should only be accepted as bare words
|
||||
|
||||
describe("ignores an expr prefix", () => {
|
||||
test.each([
|
||||
[["1", "+", "2"]],
|
||||
[["expr", "1", "+", "2"]],
|
||||
[["1 + 2"]],
|
||||
[["expr", "1 + 2"]],
|
||||
])("%s", (argv) => {
|
||||
const actualResult = Expr(
|
||||
{},
|
||||
argv.map((text) => ({ bare: text }))
|
||||
);
|
||||
expect("error" in actualResult).toBeFalsy();
|
||||
expect(AsText(actualResult as TextPiece)).toEqual("3");
|
||||
});
|
||||
});
|
||||
});
|
168
src/lib/expr.ts
168
src/lib/expr.ts
|
@ -1,4 +1,4 @@
|
|||
import { AsText, ProcResult, TextPiece } from "../words";
|
||||
import { AsText, ErrorResult, ProcResult, TextPiece } from "../words";
|
||||
|
||||
export function Expr({}, argv: TextPiece[]): ProcResult {
|
||||
const name = argv[0];
|
||||
|
@ -6,7 +6,171 @@ export function Expr({}, argv: TextPiece[]): ProcResult {
|
|||
// being called as [expr ...], not fallback for math
|
||||
argv.splice(0, 1);
|
||||
}
|
||||
|
||||
const expression = argv.map(AsText).join(" ").trim();
|
||||
|
||||
const parser = new ExpressionParser(expression);
|
||||
const result = parser.parseSubExpression(0);
|
||||
|
||||
if (parser.pos != expression.length) {
|
||||
return { error: "Couldn't parse full expression" };
|
||||
}
|
||||
|
||||
if ("value" in result) {
|
||||
return { text: String(result.value) };
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
// Pratt parser for evaluating arithmatic expressions
|
||||
|
||||
const PLUS_TOKEN = /\s*(\+)/y;
|
||||
const MINUS_TOKEN = /\s*(\-)/y;
|
||||
const TIMES_TOKEN = /\s*(\*)/y;
|
||||
const FLOOR_TOKEN = /\s*(\/\/)/y;
|
||||
const DIV_TOKEN = /\s*(\/)/y;
|
||||
const MOD_TOKEN = /\s*(\%)/y;
|
||||
|
||||
const NUMBER_TOKEN = /\s*(\d+)/y;
|
||||
|
||||
type Value = { value: number };
|
||||
|
||||
type TokenHandler = {
|
||||
leftBindingPower: number;
|
||||
token: RegExp;
|
||||
parse: (
|
||||
left: Value,
|
||||
matched: string,
|
||||
parser: ExpressionParser
|
||||
) => Value | ErrorResult;
|
||||
};
|
||||
|
||||
function map(
|
||||
value: Value | ErrorResult,
|
||||
op: (value: number) => Value | ErrorResult
|
||||
): Value | ErrorResult {
|
||||
if ("error" in value) {
|
||||
return value;
|
||||
} else {
|
||||
return op(value.value);
|
||||
}
|
||||
}
|
||||
|
||||
function makeInfixOp(
|
||||
token: RegExp,
|
||||
leftBindingPower: number,
|
||||
rightBindingPower: number,
|
||||
op: (left: number, right: number) => Value | ErrorResult
|
||||
): TokenHandler {
|
||||
return {
|
||||
text: `Will do math to solve ${argv.map(AsText).join(" ")} eventually`,
|
||||
leftBindingPower,
|
||||
token,
|
||||
parse: ({ value: left }, matched, parser) =>
|
||||
map(parser.parseSubExpression(rightBindingPower), (right) =>
|
||||
op(left, right)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const Operators: TokenHandler[] = [
|
||||
{
|
||||
leftBindingPower: -1,
|
||||
token: NUMBER_TOKEN,
|
||||
parse: (left, matched) => ({ value: Number(matched) }),
|
||||
},
|
||||
{
|
||||
leftBindingPower: -1,
|
||||
token: MINUS_TOKEN,
|
||||
parse: (left, matched, parser) =>
|
||||
map(parser.parseSubExpression(99), (right) => ({ value: -right })),
|
||||
},
|
||||
makeInfixOp(PLUS_TOKEN, 10, 11, (left, right) => ({ value: left + right })),
|
||||
makeInfixOp(MINUS_TOKEN, 10, 11, (left, right) => ({ value: left - right })),
|
||||
makeInfixOp(TIMES_TOKEN, 20, 21, (left, right) => ({ value: left * right })),
|
||||
makeInfixOp(FLOOR_TOKEN, 20, 21, (left, right) => ({
|
||||
value: Math.floor(left / right),
|
||||
})),
|
||||
makeInfixOp(DIV_TOKEN, 20, 21, (left, right) => ({ value: left / right })),
|
||||
makeInfixOp(MOD_TOKEN, 20, 21, (left, right) => ({
|
||||
value: ((left % right) + right) % right,
|
||||
})),
|
||||
];
|
||||
|
||||
const ZERO = { value: 0 };
|
||||
|
||||
class ExpressionParser {
|
||||
pos: number = 0;
|
||||
|
||||
constructor(public text: string) {}
|
||||
|
||||
next(token: RegExp): string | null {
|
||||
token.lastIndex = this.pos;
|
||||
const matches = token.exec(this.text);
|
||||
if (matches) {
|
||||
this.pos = token.lastIndex;
|
||||
return matches[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tries to match one of the given tokens & returns the value of the handler. If none match, returns null. */
|
||||
tryAll(left: Value, options: TokenHandler[]): Value | ErrorResult | null {
|
||||
for (const option of options) {
|
||||
const match = this.next(option.token);
|
||||
if (match) {
|
||||
return option.parse(left, match, this);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
parsePrefixOp(): Value | ErrorResult {
|
||||
return (
|
||||
this.tryAll(
|
||||
ZERO,
|
||||
Operators.filter((operator) => operator.leftBindingPower == -1)
|
||||
) ?? { error: "Couldn't parse number" }
|
||||
);
|
||||
}
|
||||
|
||||
parseSubExpression(rightBindingPower: number): Value | ErrorResult {
|
||||
let value = this.parsePrefixOp();
|
||||
if ("error" in value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
do {
|
||||
const newValue = this.tryAll(
|
||||
value,
|
||||
Operators.filter(
|
||||
(operator) => operator.leftBindingPower > rightBindingPower
|
||||
)
|
||||
);
|
||||
|
||||
if (newValue == null) {
|
||||
break;
|
||||
}
|
||||
value = newValue;
|
||||
} while (!("error" in value));
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// parse expression:
|
||||
// must parse sub-expression with right binding power 0
|
||||
// must be at EOF
|
||||
|
||||
// parse sub-expression with right binding power :
|
||||
// must parse a prefix op
|
||||
// parse as many postfix ops w/ left binding power > right binding power
|
||||
|
||||
// parse op:
|
||||
// must parse associated token (if success- failing the rest of the op fails *everything*)
|
||||
// depending on op:
|
||||
// maybe parse sub-expressions with op's right-binding power
|
||||
// maybe consume other tokens
|
||||
|
|
|
@ -137,10 +137,11 @@ const ERROR_CONTEXT = /(?<=([^\n]{0,50}))([^\n]{0,50})/y;
|
|||
* Parse out a Notcl script into an easier-to-interpret representation.
|
||||
* No script is actually executed yet.
|
||||
*
|
||||
* @param - code to parse
|
||||
* @returns - parsed list of commands, or error message on failure
|
||||
* @param code code to parse
|
||||
* @param offset source position of code, if embedded in a larger source document
|
||||
* @returns parsed list of commands, or error message on failure
|
||||
*/
|
||||
export function parse(code: string): [true, Script] | [false, string] {
|
||||
export function parse(code: string, offset = 0): [true, Script] | [false, string] {
|
||||
/* Parse */
|
||||
const [commands, errorPos, expected] = Script.match(code, 0);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue