Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/calculator/internal/evaluator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ function run(title: string, cases: [input: Token[], expected: Decimal][]) {
const title = `"${prettify(input)}" => ${expected.toString()}`;

const result = evaluate(input, new Decimal(0), new Decimal(0), "rad");
if (result.isErr()) expect.unreachable(`Test case could not be evaluated: ${title} (${result.error})`);

test(title, () => expect(result.value.toFraction()).toEqual(expected.toFraction()));
test(title, () => {
if (result.isErr()) expect.unreachable(`Test case could not be evaluated: ${title} (${result.error})`);
expect(result.value.toFraction()).toEqual(expected.toFraction());
});
}
});
}
Expand Down Expand Up @@ -137,6 +139,29 @@ run("Functions", [
]
]);

run("Functions without brackets", [
[[t.sin, litr(1)], d(1).sin()],
[[t.sin, t.sin, t.lbrk, litr(10), t.rbrk], d(10).sin().sin()],
[[t.acos, litr(1)], d(1).acos()],
[[t.acos, t.latexFrac, t.lcur, litr(30), t.rcur, t.lcur, litr(42), t.rcur], d(30).div(d(42)).acos()],
[[t.acos, t.cos, t.lbrk, litr(30), t.rbrk], d(30).cos().acos()],
[[t.latexFrac, t.latexFrac, litr(1), litr(2), litr(3)], d(1).div(d(2)).div(d(3))],
[[t.log10, litr(1)], d(1).log(10)],
[[t.ln, litr(1)], d(1).ln()],
[[t.root, litr(1)], d(1).sqrt()],
[[t.sqrt, litr(1)], d(1).sqrt()],
]);

run("LaTeX roots", [
[[t.root, t.lsbk, litr(3), t.rsbk, litr(27)], d(27).cubeRoot()],
[[t.root, t.lsbk, litr(3), t.rsbk, t.lbrk, litr(27), t.rbrk], d(27).cubeRoot()],
]);

run("LaTeX fraction", [
[[t.latexFrac, t.lcur, litr(1), t.rcur, t.lcur, litr(3), t.rcur], d(1).div(d(3))],
[[t.latexFrac, litr(1), litr(3)], d(1).div(d(3))],
]);

describe("Constants", () => {
test("π is defined", () => {
const result = evaluate([T.cons("pi")], d(0), d(0), "rad");
Expand Down Expand Up @@ -317,9 +342,7 @@ describe("Syntax Errors", () => {
]);

fail("Functions", [
[t.sin, litr(10)],
[t.sin, t.sin, t.lbrk, litr(10), t.rbrk],
[t.sin, t.lbrk, t.sin, t.lbrk, litr(10), t.rbrk],
[t.sin, t.lbrk, t.sin, t.lbrk, litr(10), t.rbrk], // missing rbrk
[t.root, t.lbrk, litr(-8), t.rbrk],
[t.root, t.lbrk, litr(-8), t.semi, t.rbrk],
[t.root, t.lbrk, litr(-8), t.semi, litr(-2), t.rbrk],
Expand Down Expand Up @@ -373,3 +396,8 @@ describe("Exponent errors", () => {
[t.lbrk, t.sub, litr(13), t.rbrk, t.pow, t.lbrk, litr(1), t.div, litr(3), t.rbrk],
]);
});

fail("LaTeX fraction with only one parameter", [
[t.latexFrac, litr(13)],
[t.latexFrac, t.lbrk, litr(13), t.rbrk],
]);
66 changes: 51 additions & 15 deletions src/calculator/internal/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
*
* Can either just peek at the next token or consume it, based on the value of the second argument.
*/
function expect(pattern: Pattern.Pattern<Token>): Result<Token, EvalErrorId> {
function expect<const p extends Pattern.Pattern<Token>>(pattern: p): Result<P.narrow<Token, p>, EvalErrorId> {
const token = peek();

if (!token) return err("UNEXPECTED_EOF");
if (!isMatching(pattern)(token)) return err("UNEXPECTED_TOKEN");

next();

return ok(token);
return ok(token as P.narrow<Token, p>);
}

/**
Expand All @@ -96,15 +96,37 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
.mapErr(() => "NO_RHS_BRACKET" as const),
),
)
.with({ type: "lcur" }, () =>
evalExpr(0).andThen(value =>
expect({ type: "rcur" })
.map(() => value)
.mapErr(() => "NO_RHS_BRACKET" as const),
),
)
.with({ type: "func" }, token =>
evalArgs().andThen(args =>
match(token.name)
.with("root", () => {
match(token.name)
.with("\\frac", () =>
Result.combine([evalArgs(lbp(token)), evalArgs(lbp(token))]).andThen(([args1, args2]) => {
if (args1.length < 1) return err("NOT_ENOUGH_ARGS" as const);
if (args1.length > 1) return err("TOO_MANY_ARGS" as const);

if (args2.length < 1) return err("NOT_ENOUGH_ARGS" as const);
if (args2.length > 1) return err("TOO_MANY_ARGS" as const);

const numerator = args1[0]!;
const denominator = args2[0]!;

return ok(numerator.div(denominator));
}),
)
.with("root", () =>
Result.combine([evalSquareBracketArg(), evalArgs(lbp(token))]).andThen(([squareBracketArg, args]) => {
if (args.length < 1) return err("NOT_ENOUGH_ARGS" as const);
if (args.length > 2) return err("TOO_MANY_ARGS" as const);
if (args.length > 2 || (args.length > 1 && squareBracketArg != null))
return err("TOO_MANY_ARGS" as const);

const radicand = args[0]!;
const degree = args[1] ?? TWO;
const degree = args[1] ?? squareBracketArg ?? TWO;

if (degree.eq(0)) {
return err("NOT_A_NUMBER" as const);
Expand All @@ -117,8 +139,10 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
? radicand.neg().pow(ONE.div(degree)).neg()
: radicand.pow(ONE.div(degree)),
);
})
.otherwise(funcName => {
}),
)
.otherwise(funcName =>
evalArgs(lbp(token)).andThen(args => {
if (args.length < 1) return err("NOT_ENOUGH_ARGS" as const);
if (args.length > 1) return err("TOO_MANY_ARGS" as const);

Expand Down Expand Up @@ -148,7 +172,7 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
})
.otherwise(() => ok(func(arg)));
}),
),
),
)
.otherwise(() => err("UNEXPECTED_TOKEN"));
}
Expand Down Expand Up @@ -177,16 +201,28 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
/**
* Tries to read the arguments of a function call to a list of `Decimal`s.
*/
function evalArgs(): Result<Decimal[], EvalErrorId> {
return expect({ type: "lbrk" }).andThen(() => {
function evalArgs(lbp: number): Result<Decimal[], EvalErrorId> {
if (isMatching({ type: P.union("lbrk", "lcur") }, peek())) {
next();
const out: EvalResult[] = [];

do {
out.push(evalExpr(0));
} while (expect({ type: "semi" }).isOk());

return expect({ type: "rbrk" }).andThen(() => Result.combine(out));
});
return expect({ type: P.union("rbrk", "rcur") }).andThen(() => Result.combine(out));
}

// Allow e.g. "log 3" or "arccos \frac{1}{2}"
return evalExpr(lbp).map(value => [value]);
}

function evalSquareBracketArg(): Result<Decimal | null, EvalErrorId> {
if (expect({ type: "lsbk" }).isErr()) {
return ok(null);
}

return evalExpr(0).andThrough(() => expect({ type: "rsbk" }));
}

function evalExpr(rbp: number): EvalResult {
Expand Down Expand Up @@ -218,7 +254,7 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an
/** Returns the Left Binding Power of the given token */
function lbp(token: Token) {
return match(token)
.with({ type: P.union("lbrk", "rbrk", "semi") }, () => 0)
.with({ type: P.union("lbrk", "rbrk", "lcur", "rcur", "lsbk", "rsbk", "semi") }, () => 0)
.with({ type: P.union("litr", "memo", "cons") }, () => 1)
.with({ type: "oper", name: P.union("+", "-") }, () => 2)
.with({ type: "oper", name: P.union("*", "/") }, () => 3)
Expand Down
36 changes: 35 additions & 1 deletion src/calculator/internal/tokeniser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,36 @@ run("Semicolons", [["(8;3;2;1)", [t.lbrk, litr(8), t.semi, litr(3), t.semi, litr
run("Functions", [["sin cos tan root", [t.sin, t.cos, t.tan, t.root]]]);
run("Memory", [["ans mem", [t.ans, t.ind]]]);

run("LaTeX", [
["\\pi e", [t.pi, t.e]],
["\\frac{1}{2}", [t.latexFrac, t.lcur, litr(1), t.rcur, t.lcur, litr(2), t.rcur]],
["\\dfrac", [t.latexFrac]],
["\\sqrt[3]{27}", [t.root, t.lsbk, litr(3), t.rsbk, t.lcur, litr(27), t.rcur]],
["1{,}2", [litr(1.2)]],
["1\\cdot2\\times3", [litr(1), t.mul, litr(2), t.mul, litr(3)]],
["\\left(\\right)", [t.lbrk, t.rbrk]],
["\\left{\\right}", [t.lcur, t.rcur]],
["\\sin2", [t.sin, litr(2)]],
["\\log2", [t.log10, litr(2)]],
["\\ln2", [t.ln, litr(2)]],
]);

fail("LaTeX fails", [
"\\pie",
"\\asdf",
"\\sin2°"
]);

function run(title: string, cases: [input: string, expected: Token[]][]) {
describe(title, () => {
for (const [input, expected] of cases) {
const title = `"${input}" => ${prettify(expected)}`;

const tokens = tokenise(input);
if (tokens.isErr()) expect.unreachable(`Test case could not be tokenised: ${title}`);
if (tokens.isErr())
expect.unreachable(
`Test case could not be tokenised: ${title} (at index ${tokens.error.idx}: "${input.slice(tokens.error.idx, tokens.error.idx + 10)}")`,
);

const result = prettify(tokens.value);
const wanted = prettify(expected);
Expand All @@ -62,3 +85,14 @@ function run(title: string, cases: [input: string, expected: Token[]][]) {
}
});
}

function fail(title: string, cases: string[]) {
describe(title, () => {
for (const input of cases) {
const title = `"${input}"`;

const tokens = tokenise(input);
test(title, () => expect(tokens.isErr()).toBe(true));
}
});
}
73 changes: 61 additions & 12 deletions src/calculator/internal/tokeniser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,57 +52,106 @@ const tokenMatchers = [

[
// Unsigned numeric literal: "0", "123", "25.6", etc...
/^((\d+[,.]\d+)|([1-9]\d*)|0)/,
// {,} is for decimal commas in LaTeX
/^((\d+([,.]|\{,\})\d+)|([1-9]\d*)|0)/,
str => ({
type: "litr" as const,
value: new Decimal(str.replace(",", ".")),
value: new Decimal(str.replace(/,|\{,\}/, ".")),
}),
],
[
// Operators: "-", "+", "÷", "*", "^"
// Operators: "-", "+", "÷", "*", "^", "\cdot", "\times"
// The multiplication, minus and division signs have unicode variants that also need to be handled
/^[-+/*^−×÷]/,
/^([-+/*^−×÷]|\\(cdot|times)(?![a-zA-Z]))/,
str => ({
type: "oper" as const,
name: match(str)
.with("-", "+", "*", "^", op => op)
.with("−", () => "-" as const)
.with("×", () => "*" as const)
.with("×", "\\cdot", "\\times", () => "*" as const)
.with("÷", "/", () => "/" as const)
.otherwise(op => {
throw Error(`Programmer error: neglected operator "${op}"`);
}),
}),
],
[
// Left bracket: "("
/^\(/,
// Left bracket: "(", "\left("
/^(\\left)?\(/,
_ => ({ type: "lbrk" as const }),
],
[
// Right bracket: ")"
/^\)/,
// Right bracket: ")", "\right)"
/^(\\right)?\)/,
_ => ({ type: "rbrk" as const }),
],
[
// Left curly brace: "{", "\left{"
/^(\\left)?\{/,
_ => ({ type: "lcur" as const }),
],
[
// Right curly brace: "}", "\right}"
/^(\\right)?\}/,
_ => ({ type: "rcur" as const }),
],
[
// Left square bracket: "[", "\left["
/^(\\right)?\[/,
_ => ({ type: "lsbk" as const }),
],
[
// Right square bracket: "]", "\right]"
/^(\\right)?\]/,
_ => ({ type: "rsbk" as const }),
],
[
// Semicolon: ";"
/^;/,
_ => ({ type: "semi" as const }),
],
[
// Constants: "pi", "e", and unicode variations
/^(pi|π|e|ℇ|𝑒|ℯ)/i,
// Constants: "pi", "e", LaTeX "\pi", and unicode variations
/^(pi|π|\\pi(?![a-zA-Z])|e|ℇ|𝑒|ℯ)/i,
str => ({
type: "cons" as const,
name: match(str.toLowerCase())
.with("pi", "e", name => name)
.with("π", () => "pi" as const)
.with("\\pi", "π", () => "pi" as const)
.with("ℇ", "𝑒", "ℯ", () => "e" as const)
.otherwise(name => {
throw Error(`Programmer error: neglected constant "${name}"`);
}),
}),
],

[
// LaTeX functions: \ln, \log, \sin, \cos, \tan, \arcsin, \arccos, \arctan, \sqrt, \root
/^\\(ln|log|sin|cos|tan|arcsin|arccos|arctan|sqrt|root)(?![a-zA-Z])/,
str => ({
type: "func" as const,
name: match(str)
.with("\\ln", "\\sin", "\\cos", "\\tan", name => name.slice(1))
.with("\\log", () => "log10" as const)
.with("\\arcsin", () => "asin" as const)
.with("\\arccos", () => "acos" as const)
.with("\\arctan", () => "atan" as const)
.with("\\sqrt", "\\root", () => "root" as const)
.otherwise(name => {
throw Error(`Programmer error: neglected function "${name}"`);
}),
}),
],

[
// LaTeX fractions
/^\\(frac|dfrac)(?![a-zA-Z])/,
() => ({
type: "func" as const,
name: "\\frac" as const
}),
],

[
// Memory register: "ans" (answer register), "mem" (independent memory register)
/^(ans|mem|m|ind)/i,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/format-result.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const NEGATIVE_EXPONENT_LIMIT = 7;

function testCalculate(expression: string, angleUnit: AngleUnit = "rad") {
const result = calculate(expression, new Decimal(0), new Decimal(0), angleUnit);
if (result.isErr()) throw new Error(`Calculation failed: ${result.error}`);
if (result.isErr()) throw new Error(`Calculation failed: ${JSON.stringify(result.error)}`);
return result.value;
}

Expand Down
11 changes: 11 additions & 0 deletions src/utils/prettify-expression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import prettify from "./prettify-expression";

run("Basic spacing rules", [
["1+(1+1)+(1)", "1 + (1 + 1) + (1)"],
["1+[1+1]+[1]", "1 + [1 + 1] + [1]"],
["1+{1+1}+{1}", "1 + {1 + 1} + {1}"],
["1-(1-1)-(1)", "1 - (1 - 1) - (1)"],
["1+((2+2)+3+(((4))))", "1 + ((2 + 2) + 3 + (((4))))"],
["sin(1+1)", "sin(1 + 1)"],
Expand Down Expand Up @@ -40,6 +42,15 @@ run("Negative numbers", [
["-(5+5)", "-(5 + 5)"],
]);

run("LaTeX", [
["1\\cdot2", "1 * 2"],

// To turn LaTeX into normal form
// (e.g. `√[3](27) -> √(27 ; 3)`, `\frac{1}{2} -> (1) / (2)`),
// we'd have to have an AST to modify.
["\\frac {1 } {3}", "\\frac{1}{3}"],
]);

describe("Arithmetic character rewrites", () => {
// Running these in their own block so the names are more descriptive than "5 - 5 => 5 − 5"
test("Minus", () => expect(prettify("5-5")).toBe("5 − 5"));
Expand Down
Loading