summarum/src/engine/tokenizer.ts
Horizontal42 881fb4809a stop '5 min' from losing the minute unit to the min() aggregate
registerCoreVocab() registers "min" as the aggregate function name
before registerUnits() registers it as the minute abbreviation, and
first-registration-wins means the unit phrase never lands. "5 min"
silently became either plain "5" or got overwritten by a MIN of the
preceding block. Disambiguate right after tokenizing: "min"/"мин"
immediately after a number can only mean minutes.
2026-08-12 20:02:58 +03:00

158 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Lexes a line and resolves phrases through the registry into semantic tokens.
import { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
import { lexLine } from "./lexer";
import { Registry, PctOp, DateWord, BitOp } from "./registry";
export type Token =
| { t: "num"; v: Decimal; repr: NumeralRepr; start: number; end: number }
| { t: "unit"; unit: Unit; start: number; end: number }
| { t: "currency"; code: string; start: number; end: number }
| { t: "op"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; start: number; end: number }
| { t: "bitop"; op: BitOp; start: number; end: number }
| { t: "special"; name: "unix" | "todate"; start: number; end: number }
| { t: "conv"; start: number; end: number }
| { t: "assign"; start: number; end: number }
| { t: "pctop"; op: PctOp; start: number; end: number }
| { t: "percent"; start: number; end: number }
| { t: "func"; name: string; start: number; end: number }
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart"; start: number; end: number }
| { t: "scale"; mult: Decimal; start: number; end: number }
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; start: number; end: number }
| { t: "datelit"; ms: number; start: number; end: number }
| { t: "xref"; sheet: string; key: string; start: number; end: number }
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number }
| { t: "semicolon"; start: number; end: number }
| { t: "bang"; start: number; end: number }
| { t: "word"; raw: string; start: number; end: number }
| { t: "unknown"; start: number; end: number }
| { t: "junk"; raw: string; start: number; end: number };
export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dmy"): Token[] {
const lexes = lexLine(line, dateOrder);
const lowers = lexes.map((l) => l.raw.toLowerCase());
const tokens: Token[] = [];
let i = 0;
while (i < lexes.length) {
const lx = lexes[i];
const span = { start: lx.start, end: lx.end };
if (lx.type === "num") {
tokens.push({ t: "num", v: lx.value!, repr: lx.repr!, ...span });
i++;
continue;
}
if (lx.type === "date") {
tokens.push({ t: "datelit", ms: lx.dateMs!, ...span });
i++;
continue;
}
if (lx.type === "xref") {
tokens.push({ t: "xref", sheet: lx.sheet!, key: lx.key!, ...span });
i++;
continue;
}
const m = reg.match(lexes, lowers, i);
if (m) {
const end = lexes[i + m.length - 1].end;
const s = { start: lx.start, end };
const p = m.payload;
switch (p.t) {
case "unit": tokens.push({ t: "unit", unit: p.unit, ...s }); break;
case "currency": tokens.push({ t: "currency", code: p.code, ...s }); break;
case "op": tokens.push({ t: "op", op: p.op, ...s }); break;
case "bitop": tokens.push({ t: "bitop", op: p.op, ...s }); break;
case "special": tokens.push({ t: "special", name: p.name, ...s }); break;
case "conv": tokens.push({ t: "conv", ...s }); break;
case "assign": tokens.push({ t: "assign", ...s }); break;
case "pctop": tokens.push({ t: "pctop", op: p.op, ...s }); break;
case "percent": tokens.push({ t: "percent", ...s }); break;
case "func": tokens.push({ t: "func", name: p.name, ...s }); break;
case "agg": tokens.push({ t: "agg", name: p.name, ...s }); break;
case "scale": tokens.push({ t: "scale", mult: p.mult, ...s }); break;
case "repr": tokens.push({ t: "repr", repr: p.repr, ...s }); break;
case "date": tokens.push({ t: "date", word: p.word, ...s }); break;
case "const": tokens.push({ t: "const", name: p.name, ...s }); break;
}
i += m.length;
continue;
}
if (lx.type === "sym") {
// << and >> arrive as two single-char lexemes
const nextLx = lexes[i + 1];
if ((lx.raw === "<" || lx.raw === ">") && nextLx?.type === "sym" && nextLx.raw === lx.raw && nextLx.start === lx.end) {
tokens.push({ t: "bitop", op: lx.raw === "<" ? "shl" : "shr", start: lx.start, end: nextLx.end });
i += 2;
continue;
}
switch (lx.raw) {
case "&": tokens.push({ t: "bitop", op: "band", ...span }); break;
case "|": tokens.push({ t: "bitop", op: "bor", ...span }); break;
case "+": tokens.push({ t: "op", op: "plus", ...span }); break;
case "-": case "−": case "–": tokens.push({ t: "op", op: "minus", ...span }); break;
case "*": case "×": case "·": tokens.push({ t: "op", op: "mul", ...span }); break;
case "/": case "÷": tokens.push({ t: "op", op: "div", ...span }); break;
case "^": tokens.push({ t: "op", op: "pow", ...span }); break;
case "%": tokens.push({ t: "percent", ...span }); break;
case "(": tokens.push({ t: "lparen", ...span }); break;
case ")": tokens.push({ t: "rparen", ...span }); break;
case "=": tokens.push({ t: "assign", ...span }); break;
case ";": tokens.push({ t: "semicolon", ...span }); break;
case "!": tokens.push({ t: "bang", ...span }); break;
case "?": tokens.push({ t: "unknown", ...span }); break;
default: tokens.push({ t: "junk", raw: lx.raw, ...span });
}
i++;
continue;
}
tokens.push({ t: "word", raw: lx.raw, ...span });
i++;
}
return disambiguateMinAgg(disambiguateIn(tokens, reg, line), reg, line);
}
/**
* "5 ft 4 in in cm" / "6 ft 3 in": the word "in" right after a number,
* followed by another conversion word, an operator or the end of the line,
* actually means inches.
*/
function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
const inch = reg.unitsById.get("inch");
if (!inch) return tokens;
return tokens.map((tk, i) => {
if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") return tk;
const prev = tokens[i - 1];
const next = tokens[i + 1];
const prevIsNum = prev?.t === "num";
const nextEnds = !next || next.t === "conv" || next.t === "op" || next.t === "rparen";
if (prevIsNum && nextEnds) {
return { t: "unit", unit: inch, start: tk.start, end: tk.end } as Token;
}
return tk;
});
}
/**
* "5 min": the "min" aggregate keyword (registered first, so it wins the
* phrase in the registry) collides with the minute unit's "min"/"мин"
* abbreviation. Right after a number it can only mean minutes — the
* standalone `min` aggregate line has no preceding number to collide with.
*/
function disambiguateMinAgg(tokens: Token[], reg: Registry, line: string): Token[] {
const minute = reg.unitsById.get("minute");
if (!minute) return tokens;
return tokens.map((tk, i) => {
if (tk.t !== "agg" || tk.name !== "min") return tk;
const raw = line.slice(tk.start, tk.end).toLowerCase();
if (raw !== "min" && raw !== "мин") return tk;
if (tokens[i - 1]?.t !== "num") return tk;
return { t: "unit", unit: minute, start: tk.start, end: tk.end } as Token;
});
}