diff --git a/src/calculator/internal/evaluator.spec.ts b/src/calculator/internal/evaluator.spec.ts index efc8ca6c..17cfeb94 100644 --- a/src/calculator/internal/evaluator.spec.ts +++ b/src/calculator/internal/evaluator.spec.ts @@ -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()); + }); } }); } @@ -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"); @@ -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], @@ -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], +]); diff --git a/src/calculator/internal/evaluator.ts b/src/calculator/internal/evaluator.ts index fce96a2a..5649b85c 100644 --- a/src/calculator/internal/evaluator.ts +++ b/src/calculator/internal/evaluator.ts @@ -63,7 +63,7 @@ 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): Result { + function expect>(pattern: p): Result, EvalErrorId> { const token = peek(); if (!token) return err("UNEXPECTED_EOF"); @@ -71,7 +71,7 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an next(); - return ok(token); + return ok(token as P.narrow); } /** @@ -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); @@ -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); @@ -148,7 +172,7 @@ export default function evaluate(tokens: Token[], ans: Decimal, ind: Decimal, an }) .otherwise(() => ok(func(arg))); }), - ), + ), ) .otherwise(() => err("UNEXPECTED_TOKEN")); } @@ -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 { - return expect({ type: "lbrk" }).andThen(() => { + function evalArgs(lbp: number): Result { + 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 { + if (expect({ type: "lsbk" }).isErr()) { + return ok(null); + } + + return evalExpr(0).andThrough(() => expect({ type: "rsbk" })); } function evalExpr(rbp: number): EvalResult { @@ -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) diff --git a/src/calculator/internal/tokeniser.spec.ts b/src/calculator/internal/tokeniser.spec.ts index 8702efff..352b1d87 100644 --- a/src/calculator/internal/tokeniser.spec.ts +++ b/src/calculator/internal/tokeniser.spec.ts @@ -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); @@ -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)); + } + }); +} diff --git a/src/calculator/internal/tokeniser.ts b/src/calculator/internal/tokeniser.ts index 60f9f81b..f30e9476 100644 --- a/src/calculator/internal/tokeniser.ts +++ b/src/calculator/internal/tokeniser.ts @@ -52,22 +52,23 @@ 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}"`); @@ -75,34 +76,82 @@ const tokenMatchers = [ }), ], [ - // 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, diff --git a/src/utils/format-result.spec.ts b/src/utils/format-result.spec.ts index 7dfc2f9e..d818f637 100644 --- a/src/utils/format-result.spec.ts +++ b/src/utils/format-result.spec.ts @@ -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; } diff --git a/src/utils/prettify-expression.spec.ts b/src/utils/prettify-expression.spec.ts index fc90d9d1..07a283aa 100644 --- a/src/utils/prettify-expression.spec.ts +++ b/src/utils/prettify-expression.spec.ts @@ -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)"], @@ -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")); diff --git a/src/utils/prettify-expression.ts b/src/utils/prettify-expression.ts index a960b852..9603b903 100644 --- a/src/utils/prettify-expression.ts +++ b/src/utils/prettify-expression.ts @@ -43,6 +43,10 @@ function* prettiedCharacters(tokens: Token[]) { .with({ type: "litr" }, token => token.value.toFixed().replace(".", ",")) .with({ type: "lbrk" }, () => "(") .with({ type: "rbrk" }, () => ")") + .with({ type: "lsbk" }, () => "[") + .with({ type: "rsbk" }, () => "]") + .with({ type: "lcur" }, () => "{") + .with({ type: "rcur" }, () => "}") .with({ type: "semi" }, () => ";") .with({ type: "memo", name: "ans" }, () => "ANS") .with({ type: "memo", name: "ind" }, () => "M") @@ -66,18 +70,20 @@ function* prettiedCharacters(tokens: Token[]) { yield formattedToken; - // Decide whether we want a space between the *current* and *left-hand-side* tokens: + // Decide whether we want a space between the *current* and *right-hand-side* tokens: const shouldHaveSpace = (lhs || rhs) && match([lhs, cur, rhs]) .with( // No spaces at bracket inside boundaries: "(1 + 1)" - [any, { type: "lbrk" }, not(null)], - [any, any, { type: "rbrk" }], + [any, { type: P.union("lbrk", "lsbk", "lcur") }, not(null)], + [any, any, { type: P.union("rbrk", "rsbk", "rcur") }], // No space between function name and opening brakcet: "sin(…" - [any, { type: "func" }, { type: "lbrk" }], + [any, { type: "func" }, { type: P.union("lbrk", "lsbk", "lcur") }], + // No space between parenthesis/brackets/braces (LaTeX) + [any, { type: P.union("rbrk", "rsbk", "rcur") }, { type: P.union("lbrk", "lsbk", "lcur") }], // Negative numbers: e.g. "-5" and "-5 + 5" instead of "- 5" and "- 5 + 5" - [not({ type: union("litr", "cons", "memo", "rbrk") }), { type: "oper", name: "-" }, any], + [not({ type: union("litr", "cons", "memo", "rbrk", "rsbk", "rcur") }), { type: "oper", name: "-" }, any], // No space at the end [any, any, null], () => false, diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index a85a73c3..91576396 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -14,6 +14,10 @@ export const T = { cons: (name: Token<"cons">["name"]) => ({ type: "cons", name }), lbrk: () => ({ type: "lbrk" }), rbrk: () => ({ type: "rbrk" }), + lsbk: () => ({ type: "lsbk" }), + rsbk: () => ({ type: "rsbk" }), + lcur: () => ({ type: "lcur" }), + rcur: () => ({ type: "rcur" }), semi: () => ({ type: "semi" }), } satisfies Record Token>; @@ -39,9 +43,14 @@ export const t = { log10: T.func("log10"), sqrt: T.func("sqrt"), root: T.func("root"), + latexFrac: T.func("\\frac"), ans: T.memo("ans"), ind: T.memo("ind"), lbrk: T.lbrk(), rbrk: T.rbrk(), + lsbk: T.lsbk(), + rsbk: T.rsbk(), + lcur: T.lcur(), + rcur: T.rcur(), semi: T.semi(), };