mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 16:51:59 +03:00
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.
158 lines
7.1 KiB
TypeScript
158 lines
7.1 KiB
TypeScript
// 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;
|
||
});
|
||
}
|