Skip to content

Commit

Permalink
feat: Support euros (€) and operations/printing on currencies (#23)
Browse files Browse the repository at this point in the history
This introduces `LoxValue` and makes `CurrencyValue` a first-class
citizen. The binary/unary operators have varying behaviors, `$123 +
$245` is OK but `$123 * $245` is not. Whereas `$123 * 2` is fine but
`$123 + 2` is not.

I don't love the type guards here. It seems like this should work:

```ts
type NumsOrStrs =
	| { left: string; right: string }
	| { left: number; right: number };

declare let pair: NumsOrStrs;

if (typeof pair.left === 'number') {
    const sum = pair.left + pair.right;
    //          ~~~~~~~~~~~~~~~~~~~~~~ 
    // Operator '+' cannot be applied to types 'number' and 'string | number'.
}
```
  • Loading branch information
danvk authored Jan 24, 2024
1 parent 1892bc2 commit 2805056
Show file tree
Hide file tree
Showing 22 changed files with 251 additions and 95 deletions.
2 changes: 1 addition & 1 deletion baselines/chapter10-multi-currency.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
$100,000 at 7% for 10 years:
196715.1357289567
$196,715.136
14 changes: 14 additions & 0 deletions baselines/currency/currency-math.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
$1,234.56
$3,579
$617
$2,468
$-1,234
true
€1,234
€2,468
€3,579
true
true
true
false
true
2 changes: 2 additions & 0 deletions baselines/currency/mixed-add.errors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Operands must be the same currency.
[line 1]
2 changes: 2 additions & 0 deletions baselines/currency/mixed-inequality.errors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Operands must be the same currency.
[line 1]
2 changes: 2 additions & 0 deletions baselines/currency/square-dollars.errors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Operand must be a number.
[line 1]
16 changes: 16 additions & 0 deletions examples/currency/currency-math.lox
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
print $1,234.56;
print $1,234 + $2,345;
print $1,234 / 2;
print $1,234 * 2;
// print $-1,234; // this is how it prints, but it does not parse.
print -$1,234;
print !!$1,234;
print €1,234;
print €1,234 * 2;
print €1,234 + €2,345;

print $1,234 > $999;
print $1,234 == $1,234;
print $1,234 >= $1,234;
print $1,234 == €1,234; // false!
print $1,234 != €1,234;
2 changes: 2 additions & 0 deletions examples/currency/mixed-add.lox
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
print $1,234 + €1,234;

1 change: 1 addition & 0 deletions examples/currency/mixed-inequality.lox
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print $1,234 > €1,234;
1 change: 1 addition & 0 deletions examples/currency/square-dollars.lox
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print $1,234 * $1,234;
3 changes: 2 additions & 1 deletion src/ast-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
visitExpr,
visitStmt,
} from "./ast.js";
import { stringify } from "./interpreter.js";

export const astPrinter: ExpressionVisitor<string> & StmtVisitor<string> = {
assign: (stmt) => parenthesize("assign", stmt.name.lexeme, stmt.value),
Expand All @@ -30,7 +31,7 @@ export const astPrinter: ExpressionVisitor<string> & StmtVisitor<string> = {
visitStmt(stmt.thenBranch, astPrinter),
stmt.elseBranch && visitStmt(stmt.elseBranch, astPrinter),
),
literal: (expr) => (expr.value === null ? "nil" : String(expr.value)),
literal: (expr) => stringify(expr.value),
logical: (expr) => parenthesize(expr.operator.lexeme, expr.left, expr.right),
print: (stmt) => parenthesize("print", stmt.expression),
return: (stmt) => parenthesize("return", ...(stmt.value ? [stmt.value] : [])),
Expand Down
3 changes: 2 additions & 1 deletion src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CurrencyValue } from "./lox-value.js";
import { Token } from "./token.js";

export interface Binary {
Expand All @@ -14,7 +15,7 @@ export interface Grouping {

export interface Literal {
kind: "literal";
value: boolean | null | number | string;
value: CurrencyValue | boolean | null | number | string;
}

export interface Unary {
Expand Down
3 changes: 2 additions & 1 deletion src/callable.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Interpreter } from "./interpreter.js";
import { LoxValue } from "./lox-value.js";

export abstract class LoxCallable {
abstract arity(): number;
abstract call(interpreter: Interpreter, args: unknown[]): unknown;
abstract call(interpreter: Interpreter, args: LoxValue[]): LoxValue;
}
20 changes: 12 additions & 8 deletions src/end-to-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,29 @@ async function maybeReadFile(path: string): Promise<null | string> {
}
}

function trimAndSplit(str: string) {
return str.trimEnd().split("\n");
}

// Similar to tests in main.test.ts, except that fs isn't mocked.
describe("end-to-end tests", () => {
let stashedArgv = process.argv;
let exit: ReturnType<typeof mockExit>;
let log: ReturnType<typeof mockLog>;
let error: ReturnType<typeof mockError>;
let logLines: string[] = [];
let errorLines: string[] = [];
let logLines = "";
let errorLines = "";
beforeEach(() => {
stashedArgv = process.argv;
exit = mockExit();
log = vi.spyOn(console, "log").mockImplementation((line: string) => {
logLines.push(line);
logLines += line + "\n";
});
error = vi.spyOn(console, "error").mockImplementation((line: string) => {
errorLines.push(line);
errorLines += line + "\n";
});
logLines = [];
errorLines = [];
logLines = "";
errorLines = "";
resetErrors();
});
afterEach(() => {
Expand All @@ -54,13 +58,13 @@ describe("end-to-end tests", () => {
await main();

if (expected && expected !== "") {
expect(logLines).toEqual(expected.trimEnd().split("\n"));
expect(trimAndSplit(logLines)).toEqual(trimAndSplit(expected));
} else {
expect(log).not.toHaveBeenCalled();
}

if (errors && errors.length > 0) {
expect(errorLines).toEqual(errors.trimEnd().split("\n"));
expect(trimAndSplit(errorLines)).toEqual(trimAndSplit(errors));
expect(exit).toHaveBeenCalledOnce(); // TODO: check exit code
} else {
expect(error).not.toHaveBeenCalled();
Expand Down
25 changes: 15 additions & 10 deletions src/environment.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { RuntimeError } from "./interpreter.js";
import { LoxValue } from "./lox-value.js";
import { Token } from "./token.js";

export class Environment {
#enclosing?: Environment;
#values: Map<string, unknown>;
#values: Map<string, LoxValue>;

constructor(enclosing?: Environment) {
this.#values = new Map();
Expand All @@ -23,7 +24,7 @@ export class Environment {
return env;
}

assign(name: Token, value: unknown) {
assign(name: Token, value: LoxValue) {
if (this.#values.has(name.lexeme)) {
this.#values.set(name.lexeme, value);
return;
Expand All @@ -32,25 +33,29 @@ export class Environment {
throw new RuntimeError(name, `Undefined variable '${name.lexeme}'`);
}

assignAt(distance: number, name: Token, value: unknown) {
assignAt(distance: number, name: Token, value: LoxValue) {
this.ancestor(distance).#values.set(name.lexeme, value);
}

define(name: string, value: unknown) {
define(name: string, value: LoxValue) {
this.#values.set(name, value);
}

get(name: Token): unknown {
get(name: Token): LoxValue {
const { lexeme } = name;
if (this.#values.has(lexeme)) {
// TODO: could check for undefined instead
return this.#values.get(lexeme);
const value = this.#values.get(lexeme);
if (value !== undefined) {
return value;
}
// resolution pass means that we needn't check #enclosing.
throw new RuntimeError(name, `Undefined variable '${lexeme}'.`);
}

getAt(distance: number, name: string): unknown {
return this.ancestor(distance).#values.get(name);
getAt(distance: number, name: string): LoxValue {
const value = this.ancestor(distance).#values.get(name);
if (value !== undefined) {
return value;
}
throw new Error(`Resolution pass failed for ${name}`);
}
}
9 changes: 6 additions & 3 deletions src/interpreter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ describe("interpreter", () => {

it("should report an error on mixed + operands", () => {
expect(() => evaluateExpr(`"12" + 13`)).toThrowError(
"Operands must be two numbers or two strings.",
"Operands must be two numbers/currencies or two strings.",
);
});

it("should report an error on non-numeric operands", () => {
expect(() => evaluateExpr(`"12" / 13`)).toThrowError(
"Operand must be a number.",
"Operand must be a number or currency.",
);
});
});
Expand Down Expand Up @@ -109,7 +109,9 @@ describe("interpreter", () => {
it("should interpret and report an error", () => {
const error = mockError();
runProgram("1 - nil;");
expect(error).toHaveBeenCalledWith("Operand must be a number.\n[line 1]");
expect(error).toHaveBeenCalledWith(
"Operands must both be numbers or currencies.\n[line 1]",
);
});

it("should disallow assignment to undeclared variables", () => {
Expand Down Expand Up @@ -165,6 +167,7 @@ describe("stringify", () => {
});

it("should refuse to stringify undefined", () => {
// @ts-expect-error undefined is not a LoxValue
expect(() => stringify(undefined)).toThrowError(
"undefined is not a valid Lox value",
);
Expand Down
Loading

0 comments on commit 2805056

Please sign in to comment.