/* The graphing calculator's little algebra: a hand-rolled tokenizer and recursive-descent parser that reads an expression in x — "sin(x) + x / 4", "2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel by pixel. A whole equation answers too: "y^2 + x^2 = 1" reads as a relation F(x, y) = left − right, and the curve is where F is zero. So a text with an "=" is one relation of x and y; without it, an expression mentioning y is the relation that expression equals zero, and one in x alone is the function y = f(x). No library: the grammar is small enough for one file, and the parser can be strict about what it promises — everything it accepts is a real function of x or a real relation in the plane, and everything else is refused with a reason. A name that is not x, y, one of the constants, or a called function is a free variable — "k", "a", "radius" — and asks for a slider: the compiled answer names the variables it speaks, and evaluation reads their values from the scope it is handed. So an expression with a variable means the family of curves those values trace. "k" alone, or "k = 2" ahead of a plain number, is the declaration itself: it claims the slider and plots nothing of its own. Precedence is the usual one: + - bind loosest; then * / % (left, implicit multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates the square; then ^, right-associative, its exponent may carry a sign so 2^-x reads. Functions call, constants stand, parentheses group. */ export type PlotFunction = (x: number) => number /* a relation of the plane: the curve is its zero set */ export type PlaneFunction = (x: number, y: number) => number /* a surface of space: the height z above the point (x, y) */ export type SurfaceFunction = (x: number, y: number) => number /* the values a free name answers with — one slider per variable. A name the scope does not carry yet reads as 1, where a fresh slider starts */ export type VarScope = Record /* why a text was refused: nothing to read, a name the calculator does not speak, or a shape the grammar rejects */ export type PlotErrorReason = 'empty' | 'unknown' | 'syntax' export type CompiledPlot = // each kind carries the free variables it speaks, in order of appearance | { ok: true; kind: 'function'; vars: string[]; fn: PlotFunction } | { ok: true; kind: 'relation'; vars: string[]; fn: PlaneFunction } // "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally // named. Coordinates may ride the sliders, so the dot moves with them | { ok: true; kind: 'point'; vars: string[]; x: number; y: number; label: string | null } // "k" or "k = 2": claims a slider. A null value says the text asked for // one without saying where it starts. Plots nothing of its own | { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null } | { ok: false; reason: PlotErrorReason } type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ',' /* a number carries the text it was read from: the typesetter prints that, so a half-typed decimal keeps the digits exactly as they stand */ type Token = { t: Op } | { t: 'num'; value: number; raw: string } | { t: 'id'; name: string } type Node = | { kind: 'num'; value: number; raw?: string } // x and y are the plane's coordinates; every other name is a slider | { kind: 'var'; name: string } | { kind: 'const'; value: number } | { kind: 'neg'; arg: Node } | { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node } | { kind: 'call'; name: string; args: Node[] } /* the constants a name may stand for */ const CONSTANTS: Record = { pi: Math.PI, tau: Math.PI * 2, e: Math.E, phi: (1 + Math.sqrt(5)) / 2, } /* the functions a name may call — all on the variable x, ln and log split the natural and the base-10 way as calculators write them */ const FUNCTIONS: Record number> = { sin: Math.sin, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos, atan: Math.atan, sinh: Math.sinh, cosh: Math.cosh, tanh: Math.tanh, ln: Math.log, log: Math.log10, log2: Math.log2, log10: Math.log10, sqrt: Math.sqrt, cbrt: Math.cbrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign, min: (...a) => Math.min(...a), max: (...a) => Math.max(...a), mod: (a, b) => a % b, } /* min and max take as many arguments as they are given; mod takes two; everything else wants exactly one */ function arityOk(name: string, args: number): boolean { if (name === 'min' || name === 'max') return args >= 1 if (name === 'mod') return args === 2 return args === 1 } class PlotParseError extends Error { reason: Exclude constructor(reason: 'unknown' | 'syntax') { super(reason) this.reason = reason } } /* the input may wear its "f(x) =" beside the field; a reader who types it too is answered the same — the rest is what gets plotted. A leading "y =" is read by the equation rule instead, so a right side that also carries y still means the same relation, not a mystery name. */ function stripAssignment(src: string): string { return src.replace(/^\s*f\s*\(\s*x\s*\)\s*=/i, '') } function tokenize(src: string): Token[] { /* the typographic shapes a reader may paste in stand for the plain ones */ const s = src .replace(/[×·∗]/g, '*') .replace(/÷/g, '/') .replace(/[−–]/g, '-') .replace(/π/g, 'pi') .replace(/τ/g, 'tau') const toks: Token[] = [] let i = 0 while (i < s.length) { const c = s[i]! if (c === ' ' || c === '\t' || c === '\n') { i++ continue } if ((c >= '0' && c <= '9') || c === '.') { const start = i let dot = false while (i < s.length) { const d = s[i]! if (d >= '0' && d <= '9') i++ else if (d === '.' && !dot) { dot = true i++ } else break } const text = s.slice(start, i) const value = Number.parseFloat(text) if (!Number.isFinite(value)) throw new PlotParseError('syntax') toks.push({ t: 'num', value, raw: text }) continue } if (/[a-z_]/i.test(c)) { const start = i while (i < s.length && /[a-z0-9_]/i.test(s[i]!)) i++ toks.push({ t: 'id', name: s.slice(start, i).toLowerCase() }) continue } if (c === '*' && s[i + 1] === '*') { i += 2 toks.push({ t: '^' }) // ** as an exponent, the programming shape continue } if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%' || c === '^') { toks.push({ t: c as Op }) i++ continue } if (c === '=') { toks.push({ t: '=' }) i++ continue } if (c === '(' || c === ')' || c === ',') { toks.push({ t: c as Op }) i++ continue } throw new PlotParseError('syntax') } return toks } interface Parser { toks: Token[] pos: number /* when set, a name that is not x, y, a constant or a called function becomes a variable recorded here; when absent, it is refused */ free: Set | null } const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null const startsAtom = (tk: Token): boolean => tk.t === 'num' || tk.t === 'id' || tk.t === '(' function parseExpr(p: Parser): Node { let node = parseTerm(p) for (;;) { const tk = peek(p) if (!tk || (tk.t !== '+' && tk.t !== '-')) break p.pos++ node = { kind: 'bin', op: tk.t, a: node, b: parseTerm(p) } } return node } function parseTerm(p: Parser): Node { let node = parseUnary(p) for (;;) { const tk = peek(p) if (!tk) break if (tk.t === '*' || tk.t === '/' || tk.t === '%') { p.pos++ node = { kind: 'bin', op: tk.t, a: node, b: parseUnary(p) } continue } /* nothing between two factors and a factor following: that's a multiplication — 2x, 3(x+1), x sin(x) */ if (startsAtom(tk)) { node = { kind: 'bin', op: '*', a: node, b: parseUnary(p) } continue } break } return node } function parseUnary(p: Parser): Node { const tk = peek(p) if (tk && tk.t === '+') { p.pos++ return parseUnary(p) } if (tk && tk.t === '-') { p.pos++ return { kind: 'neg', arg: parseUnary(p) } } return parsePower(p) } function parsePower(p: Parser): Node { const base = parseAtom(p) const tk = peek(p) if (tk && tk.t === '^') { p.pos++ // right-associative, and the exponent may carry its own sign return { kind: 'bin', op: '^', a: base, b: parseUnary(p) } } return base } function parseAtom(p: Parser): Node { const tk = peek(p) if (!tk) throw new PlotParseError('syntax') if (tk.t === 'num') { p.pos++ return { kind: 'num', value: tk.value, raw: tk.raw } } if (tk.t === '(') { p.pos++ const inner = parseExpr(p) const close = peek(p) if (!close || close.t !== ')') throw new PlotParseError('syntax') p.pos++ return inner } if (tk.t === 'id') { p.pos++ if (tk.name === 'x') return { kind: 'var', name: 'x' } if (tk.name === 'y') return { kind: 'var', name: 'y' } if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! } if (Object.hasOwn(FUNCTIONS, tk.name)) { const open = peek(p) if (!open || open.t !== '(') throw new PlotParseError('syntax') p.pos++ const args: Node[] = [parseExpr(p)] while (peek(p)?.t === ',') { p.pos++ args.push(parseExpr(p)) } const close = peek(p) if (!close || close.t !== ')') throw new PlotParseError('syntax') p.pos++ if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax') return { kind: 'call', name: tk.name, args } } // no coordinate, no constant, no name the calculator calls — then it is // a variable: a slider the reader sets, shared wherever its name is spoken if (p.free) { p.free.add(tk.name) return { kind: 'var', name: tk.name } } throw new PlotParseError('unknown') } throw new PlotParseError('syntax') } function evaluate(node: Node, x: number, y: number, scope: VarScope): number { switch (node.kind) { case 'num': return node.value case 'var': { if (node.name === 'x') return x if (node.name === 'y') return y // a slider's set value; until it carries one, where every slider starts const v = scope[node.name] return typeof v === 'number' && Number.isFinite(v) ? v : 1 } case 'const': return node.value case 'neg': return -evaluate(node.arg, x, y, scope) case 'call': { const fn = FUNCTIONS[node.name]! return fn(...node.args.map((a) => evaluate(a, x, y, scope))) } case 'bin': { const a = evaluate(node.a, x, y, scope) const b = evaluate(node.b, x, y, scope) switch (node.op) { case '+': return a + b case '-': return a - b case '*': return a * b case '/': return a / b case '%': return a % b case '^': return Math.pow(a, b) } } } } /* whether y stands anywhere in a tree — the difference between a function of x, which the canvas can trace column by column, and a relation, which it must search cell by cell */ function mentionsY(node: Node): boolean { switch (node.kind) { case 'var': return node.name === 'y' case 'neg': return mentionsY(node.arg) case 'bin': return mentionsY(node.a) || mentionsY(node.b) case 'call': return node.args.some(mentionsY) default: return false } } /* whether x or y stands anywhere in a tree. A point's coordinate may ride the sliders — each has one value to place the dot at — but a plane variable has no single value, so the dot would have nowhere to go */ function mentionsPlane(node: Node): boolean { switch (node.kind) { case 'var': return node.name === 'x' || node.name === 'y' case 'neg': return mentionsPlane(node.arg) case 'bin': return mentionsPlane(node.a) || mentionsPlane(node.b) case 'call': return node.args.some(mentionsPlane) default: return false } } /* a declaration's value is a plain number — numbers, pi, sqrt(2)… welcome, sliders and x and y not */ function isConstant(node: Node): boolean { switch (node.kind) { case 'var': return false case 'neg': return isConstant(node.arg) case 'bin': return isConstant(node.a) && isConstant(node.b) case 'call': return node.args.every(isConstant) default: return true } } /* the inside of a "(…, …)" split into its two coordinate token runs, or null unless there is exactly one comma at this depth */ function splitPair(inner: Token[]): [Token[], Token[]] | null { let depth = 0 let comma = -1 for (let i = 0; i < inner.length; i++) { const t = inner[i]! if (t.t === '(') depth++ else if (t.t === ')') depth-- else if (t.t === ',' && depth === 0) { if (comma !== -1) return null // more than one separator: not a plane point comma = i } } if (comma === -1) return null return [inner.slice(0, comma), inner.slice(comma + 1)] } /* tokens shaped "(x, y)": one pair of parens wrapping the whole run, split by a single top-level comma — the point "(2, 3)". A "(x+1)" has no comma and a "(1)+(2)" closes its parens early, so neither is read as a point. */ function pointPair(toks: Token[]): [Token[], Token[]] | null { if (toks.length < 4 || toks[0]!.t !== '(' || toks[toks.length - 1]!.t !== ')') return null let depth = 0 for (let i = 0; i < toks.length; i++) { if (toks[i]!.t === '(') depth++ else if (toks[i]!.t === ')') depth-- if (depth === 0 && i < toks.length - 1) return null // parens closed before the end } return splitPair(toks.slice(1, toks.length - 1)) } /* one run of tokens, the whole of it — a leftover is a shape the grammar does not know. Free variables are collected into `free` when given */ function parseAll(toks: Token[], free: Set | null = null): Node { const p: Parser = { toks, pos: 0, free } const ast = parseExpr(p) if (p.pos !== toks.length) throw new PlotParseError('syntax') return ast } /* a name the reader may claim for a slider: not one of the plane's two coordinates, not one of the constants, not a function the calculator calls */ function declName(name: string): string | null { if (name === 'x' || name === 'y') return null if (Object.hasOwn(CONSTANTS, name) || Object.hasOwn(FUNCTIONS, name)) return null return name } /* read a slider's claim from a token run — "k" alone, or "k = 2" with a claimable name ahead of a plain number — or null when the text claims none. The value reads strictly: a "P = (2, 3)" or "k = x" is a point and a relation, not declarations, and says so by not parsing as a number. */ function declarationOf(toks: Token[]): { name: string; value: number | null } | null { if (toks.length === 0 || toks[0]!.t !== 'id') return null const name = declName(toks[0]!.name) if (!name) return null if (toks.length === 1) return { name, value: null } if (toks.length > 2 && toks[1]!.t === '=' && !toks.slice(2).some((tk) => tk.t === '=')) { try { const rhs = parseAll(toks.slice(2)) // strict: a declaration's value is a plain number if (isConstant(rhs)) return { name, value: evaluate(rhs, 0, 0, {}) } } catch { /* not a plain number — not a declaration, whatever it reads as instead */ } } return null } /* read one expression, equation, point or slider declaration; a refusal says whether a name was not known or the shape was wrong — an empty text is the caller's to treat as "nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a name out front — is a point at fixed coordinates. With an "=" otherwise the text is the relation left − right = 0 (a lone "y =" ahead of an expression in x is still the fast function); without it, an expression in x is y = f(x) and one carrying y is the relation that expression equals zero. A name none of those readings speaks — "k" alone, or "k = 2" ahead of a plain number — declares a slider: it plots nothing and its value answers everywhere the name is spoken. Functions, relations and points list the variables they speak in `vars` and read their values from `scope` at their every evaluation — so one dragged slider moves the whole family of curves at once. */ export function compilePlotExpression(src: string, scope: VarScope = {}): CompiledPlot { if (src.length > 400) return { ok: false, reason: 'syntax' } try { const toks = tokenize(stripAssignment(src)) if (toks.length === 0) return { ok: false, reason: 'empty' } const at = toks.findIndex((tk) => tk.t === '=') const free = new Set() // A slider claiming its own row: "k", or "k = 2" ahead of a plain number const decl = declarationOf(toks) if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value } // A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of // the "=". Coordinates may ride the sliders — and must be finite once read. const named = at === 1 && toks[0]!.t === 'id' const pair = pointPair(named ? toks.slice(2) : toks) if (pair) { const cx = parseAll(pair[0], free) const cy = parseAll(pair[1], free) if (mentionsPlane(cx) || mentionsPlane(cy)) return { ok: false, reason: 'syntax' } const x = evaluate(cx, 0, 0, scope) const y = evaluate(cy, 0, 0, scope) if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' } const label = named ? src.slice(0, src.indexOf('=')).trim() : null return { ok: true, kind: 'point', vars: [...free], x, y, label: label || null } } if (at === -1) { const ast = parseAll(toks, free) return mentionsY(ast) ? { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) } : { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(ast, x, 0, scope) } } const left = toks.slice(0, at) const right = toks.slice(at + 1) if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) throw new PlotParseError('syntax') const rhs = parseAll(right, free) // "y = " is the explicit curve it always was: x alone decides it if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs)) return { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(rhs, x, 0, scope) } const lhs = parseAll(left, free) return { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(lhs, x, y, scope) - evaluate(rhs, x, y, scope), } } catch (err) { if (err instanceof PlotParseError) return { ok: false, reason: err.reason } return { ok: false, reason: 'syntax' } } } /* the inside of the 3D field's "z = …" — a lone "z =" ahead of an expression in x and y; the reader may also write "f(x, y) =" or type the height with no left side at all, which reads the same */ function stripSurfaceAssignment(src: string): string { return src .replace(/^\s*f\s*\(\s*x\s*,\s*y\s*\)\s*=/i, '') .replace(/^\s*z\s*=/i, '') } export type CompiledSurface = // each carries the free variables it speaks, in order of appearance | { ok: true; kind: 'surface'; vars: string[]; fn: SurfaceFunction } // "k" or "k = 2": claims a slider, as in the plane's fields. A null value // says the text asked for one without saying where it starts. Draws nothing | { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null } | { ok: false; reason: PlotErrorReason } /* compile the height of a surface above (x, y): the same algebra as the plane, only read as z = f(x, y). A "z =" ahead of the expression is the height's own name and sheds; what is left must speak x and y (constants are welcome — a level surface is still a surface). Names x and y are not are slider variables read from scope at each evaluation, and "k" or "k = 2" is the plane's slider declaration — space and plane wear the very same variables. A stray "=" is still a leftover: space answers a height alone, and refuses whatever cannot read as one. */ export function compileSurfaceExpression(src: string, scope: VarScope = {}): CompiledSurface { if (src.length > 400) return { ok: false, reason: 'syntax' } try { const body = stripSurfaceAssignment(src) const toks = tokenize(body) if (toks.length === 0) return { ok: false, reason: 'empty' } // a slider claiming its own row: "k", or "k = 2" ahead of a plain number const decl = declarationOf(toks) if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value } const free = new Set() const ast = parseAll(toks, free) return { ok: true, kind: 'surface', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) } } catch (err) { if (err instanceof PlotParseError) return { ok: false, reason: err.reason } return { ok: false, reason: 'syntax' } } } /* ---------- printing the same maths back typeset ---------- */ /* the calculator's fields echo their text as set mathematics while it is typed (Desmos' way). Printing the parser's own tree keeps the two readings honest: what is displayed is exactly what would be plotted — so a text that does not compile simply has no type-setting, and an unfinished draft stays plain text until it reads right. Fractions are fractions, radicals are radicals, exponents rise; parentheses appear only where precedence would otherwise lie. */ const LATEX_FUNCTIONS: Record = { sin: '\\sin', cos: '\\cos', tan: '\\tan', asin: '\\arcsin', acos: '\\arccos', atan: '\\arctan', sinh: '\\sinh', cosh: '\\cosh', tanh: '\\tanh', ln: '\\ln', log: '\\log', log2: '\\log_{2}', log10: '\\log_{10}', min: '\\min', max: '\\max', } /* a folded constant answers as its symbol when its value is exactly one */ function constSymbol(v: number): string | null { if (v === Math.PI) return '\\pi' if (v === Math.PI * 2) return '\\tau' if (v === Math.E) return 'e' if (v === (1 + Math.sqrt(5)) / 2) return '\\varphi' return null } /* how tightly a printed node binds: one that binds looser than the context it stands in gains parentheses */ function nodePrec(n: Node): number { if (n.kind === 'bin') { if (n.op === '+' || n.op === '-') return 10 if (n.op === '^') return 40 if (n.op === '/') return 90 // a \\frac carries its own grouping return 20 // * and % } if (n.kind === 'neg') return 30 return 100 } function printNode(n: Node, ctx: number): string { const s = printBare(n) return nodePrec(n) < ctx ? `\\left(${s}\\right)` : s } /* two factors standing side by side are multiplied silently — unless the right one is a number (numbers must not run together), or a bare number is followed by a fraction (which would read as a mixed number) */ function needsDot(a: Node, b: Node): boolean { if (b.kind === 'num') return true return a.kind === 'num' && b.kind === 'bin' && b.op === '/' } function printBare(n: Node): string { switch (n.kind) { case 'num': return n.raw ?? String(n.value) case 'var': return n.name case 'const': return constSymbol(n.value) ?? String(n.value) case 'neg': return `-${printNode(n.arg, 30)}` case 'call': return printCall(n.name, n.args) case 'bin': { switch (n.op) { case '+': return `${printNode(n.a, 10)} + ${printNode(n.b, 11)}` case '-': return `${printNode(n.a, 10)} - ${printNode(n.b, 11)}` case '*': { const a = printNode(n.a, 20) // a looser factor beside a silent product would change its sense — // even a leading minus reads as subtraction there. The gap between // factors is a space: free in math mode, and it keeps a \\pi from // running into the next atom as one control word const b = printNode(n.b, 31) return needsDot(n.a, n.b) ? `${a} \\cdot ${b}` : `${a} ${b}` } case '/': return `\\frac{${printNode(n.a, 0)}}{${printNode(n.b, 0)}}` case '%': return `${printNode(n.a, 20)} \\bmod ${printNode(n.b, 21)}` case '^': // braces group the exponent outright, so it never needs parentheses; // an exp(...) base is already a superscribed e — it takes parens // rather than carry a second superscript return `${n.a.kind === 'call' && n.a.name === 'exp' ? `\\left(${printBare(n.a)}\\right)` : printNode(n.a, 41)}^{${printNode(n.b, 0)}}` } } } } function printCall(name: string, args: Node[]): string { const one = args[0] ?? { kind: 'num', value: 0 } switch (name) { case 'sqrt': return `\\sqrt{${printNode(one, 0)}}` case 'cbrt': return `\\sqrt[3]{${printNode(one, 0)}}` case 'abs': return `\\left|${printNode(one, 0)}\\right|` case 'floor': return `\\left\\lfloor ${printNode(one, 0)} \\right\\rfloor` case 'ceil': return `\\left\\lceil ${printNode(one, 0)} \\right\\rceil` case 'exp': return `e^{${printNode(one, 0)}}` case 'mod': return `${printNode(args[0] ?? one, 20)} \\bmod ${printNode(args[1] ?? one, 21)}` default: { const label = LATEX_FUNCTIONS[name] ?? name return `${label}\\left(${args.map((a) => printNode(a, 0)).join(',\\, ')}\\right)` } } } /* the whole text of a field as one typeset line: the same readings the compiler runs — an "f(x) =" or "z =" ahead of the maths is shed, a lone "y =" the field's gutter already wears is shed too (the gutter prints it), a "(x, y)" — optionally named — is a point, a claimable name alone or ahead of a plain number claims a slider, and any other "=" is the equation it states. Free variables print as themselves, italic like the rest. Null when the text does not read: the caller keeps the plain text, and its red, until it does. */ export function expressionToLatex(src: string): string | null { if (!src.trim() || src.length > 400) return null try { const toks = tokenize(stripSurfaceAssignment(stripAssignment(src))) if (toks.length === 0) return null const at = toks.findIndex((tk) => tk.t === '=') const free = new Set() const named = at === 1 && toks[0]!.t === 'id' const pair = pointPair(named ? toks.slice(2) : toks) if (pair) { const coords = `\\left(${printNode(parseAll(pair[0], free), 0)},\\, ${printNode(parseAll(pair[1], free), 0)}\\right)` if (!named) return coords const label = src.slice(0, src.indexOf('=')).trim() const name = /^[a-z][a-z0-9_]*$/i.test(label) ? label : `\\text{${label.replace(/[\\{}]/g, '')}}` return `${name} = ${coords}` } if (at === -1) return printNode(parseAll(toks, free), 0) const left = toks.slice(0, at) const right = toks.slice(at + 1) if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) return null // "y = " is the function the field's gutter already wears: the // type-set line answers with its right side alone if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !right.some((tk) => tk.t === 'id' && tk.name === 'y')) return printNode(parseAll(right, free), 0) // "=" binds loosest of all — neither side ever needs parentheses return `${printNode(parseAll(left, free), 0)} = ${printNode(parseAll(right, free), 0)}` } catch { return null } }