This commit is contained in:
Andrew Pimenov 2026-08-26 17:05:30 +03:00 • committed by GitHub
commit caf440555b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 493 additions and 0 deletions

View file

@ -173,6 +173,17 @@ export function buildRegistry(): Registry {
// operators, percent, dates, numerals, scales, aggregates, built-in functions
function registerCoreVocab(reg: Registry): void {
registerOperations(reg);
registerSpecials(reg);
registerPercentages(reg);
registerDates(reg);
registerRepresentations(reg);
registerScales(reg);
registerConstants(reg);
registerBuiltinFuncs(reg);
}
function registerOperations(reg: Registry): void {
const ops: Array<[string, Payload]> = [
["plus", { t: "op", op: "plus" }],
["minus", { t: "op", op: "minus" }],
@ -209,7 +220,9 @@ function registerCoreVocab(reg: Registry): void {
reg.addPhrase("or", { t: "bitop", op: "bor" }, { caseSensitive: false });
reg.addPhrase("shl", { t: "bitop", op: "shl" }, { caseSensitive: false });
reg.addPhrase("shr", { t: "bitop", op: "shr" }, { caseSensitive: false });
}
function registerSpecials(reg: Registry): void {
// unix timestamps and extra numeral representations
for (const v of ["unix", "unixtime", "unix time", "timestamp", "таймстамп"]) {
reg.addPhrase(v, { t: "special", name: "unix" }, { caseSensitive: false });
@ -223,7 +236,9 @@ function registerCoreVocab(reg: Registry): void {
for (const v of ["roman", "roman numerals", "римские", "римскими"]) {
reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false });
}
}
function registerPercentages(reg: Registry): void {
for (const v of vocab.variants("Percent", "percent.variants")) reg.addPhrase(v, { t: "percent" });
const pctOps: Array<[string, PctOp]> = [
["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"],
@ -233,7 +248,9 @@ function registerCoreVocab(reg: Registry): void {
for (const [key, op] of pctOps) {
for (const v of vocab.variants("Percent", `${key}.variants`)) reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });
}
}
function registerDates(reg: Registry): void {
const dateWords: Array<[string, DateWord]> = [
["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"],
];
@ -242,20 +259,26 @@ function registerCoreVocab(reg: Registry): void {
}
for (const v of vocab.variants("Dates", "current_time.variants")) reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });
for (const v of vocab.variants("Dates", "local_time.variants")) reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });
}
function registerRepresentations(reg: Registry): void {
const reprs: Array<[string, NumeralRepr]> = [
["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"],
];
for (const [key, repr] of reprs) {
for (const v of vocab.variants("Numbers", `${key}.variants`)) reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });
}
}
function registerScales(reg: Registry): void {
for (const [id, mult] of Object.entries(SCALE_DATA)) {
for (const v of vocab.variants("Scales", `${id}.variants`)) {
reg.addPhrase(v, { t: "scale", mult: new Decimal(mult) });
}
}
}
function registerConstants(reg: Registry): void {
reg.addPhrase("pi", { t: "const", name: "pi" }, { caseSensitive: false });
reg.addPhrase("π", { t: "const", name: "pi" });
reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false });
@ -266,7 +289,9 @@ function registerCoreVocab(reg: Registry): void {
reg.addPhrase("one and a half", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
}
function registerBuiltinFuncs(reg: Registry): void {
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
}

468
src/engine/registry.ts.orig Normal file
View file

@ -0,0 +1,468 @@
// Phrase registry: maps every phrase the engine understands (units,
// currencies, word-operators, functions, scales, date words) to a payload
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
// like "m"/"M", "kB") are tried before the case-insensitive ones.
import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
import { Lex, lexLine } from "./lexer";
import * as vocab from "./vocab";
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
/** Operators for percentage calculations. */
export type PctOp =
| "of" | "off" | "on"
| "as_pct_of" | "as_pct_off" | "as_pct_on"
| "of_what_is" | "off_what_is" | "on_what_is";
export type DateWord = "today" | "tomorrow" | "yesterday" | "now" | "time" | "local";
export type BitOp = "band" | "bor" | "bxor" | "shl" | "shr";
export type Payload =
| { t: "unit"; unit: Unit }
| { t: "currency"; code: string }
| { t: "op"; op: "plus" | "minus" | "mul" | "div" | "mod" }
| { t: "bitop"; op: BitOp }
| { t: "conv" }
| { t: "assign" }
| { t: "pctop"; op: PctOp }
| { t: "percent" }
| { t: "func"; name: string }
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
| { t: "scale"; mult: Decimal }
| { t: "repr"; repr: NumeralRepr }
| { t: "special"; name: "unix" | "todate" }
| { t: "date"; word: DateWord }
| { t: "const"; name: "pi" | "e" | "half" | "onehalf" };
interface PhraseEntry {
lexemes: string[]; // raw, as in the phrase
lower: string[];
caseSensitive: boolean;
/**
* per-gap flag: word/number lexemes glued together in the phrase ("log2")
* must also be glued in the input — otherwise "log 2" matches log2
*/
mustTouch: boolean[];
payload: Payload;
}
export type ExtensionFunc = (args: Value[]) => Value;
export interface Completion {
label: string;
type: "unit" | "currency" | "function" | "keyword" | "variable";
detail?: string;
}
const BUILTIN_FUNCS = [
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
"fact", "factorial", "random", "until", "since",
];
export class Registry {
/** key: lowercased first lexeme */
private byFirst = new Map<string, PhraseEntry[]>();
unitsById = new Map<string, Unit>();
currencyCodes = new Set<string>();
currencyFormat = new Map<string, string>();
/** code -> currency units per 1 USD */
rates = new Map<string, Decimal>();
customFuncs = new Map<string, ExtensionFunc>();
completions: Completion[] = [];
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
const lexs = lexLine(phrase);
if (lexs.length === 0) return;
const lexemes = lexs.map((l) => l.raw);
const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
const wordOrNum = (t: Lex["type"]) => t === "word" || t === "num";
const mustTouch: boolean[] = [];
for (let k = 1; k < lexs.length; k++) {
mustTouch.push(lexs[k].start === lexs[k - 1].end && wordOrNum(lexs[k].type) && wordOrNum(lexs[k - 1].type));
}
const entry: PhraseEntry = { lexemes, lower: lexemes.map((l) => l.toLowerCase()), caseSensitive, mustTouch, payload };
const key = entry.lower[0];
const list = this.byFirst.get(key);
if (list) {
// first registration of an identical phrase wins (core vocab is added first)
if (list.some((e) => e.caseSensitive === caseSensitive && (caseSensitive ? e.lexemes.join(" ") === lexemes.join(" ") : e.lower.join(" ") === entry.lower.join(" ")))) {
return;
}
list.push(entry);
list.sort((a, b) => b.lexemes.length - a.lexemes.length || Number(b.caseSensitive) - Number(a.caseSensitive));
} else {
this.byFirst.set(key, [entry]);
}
}
/**
* Match the longest phrase at position i of the lexeme stream.
* lexes are the lexemes of the input line, lowers their lowercased raws.
*/
match(lexes: Lex[], lowers: string[], i: number): { payload: Payload; length: number } | null {
const list = this.byFirst.get(lowers[i]);
if (!list) return null;
for (const e of list) {
if (i + e.lexemes.length > lexes.length) continue;
let ok = true;
for (let k = 0; k < e.lexemes.length; k++) {
if (e.caseSensitive ? lexes[i + k].raw !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
ok = false;
break;
}
if (k > 0 && e.mustTouch[k - 1] && lexes[i + k].start !== lexes[i + k - 1].end) {
ok = false;
break;
}
}
if (ok) return { payload: e.payload, length: e.lexemes.length };
}
return null;
}
setRates(rates: Record<string, number | string>): void {
this.rates.clear();
for (const [code, v] of Object.entries(rates)) {
const d = new Decimal(v);
if (d.isFinite() && d.gt(0)) this.rates.set(code.toUpperCase(), d);
}
this.rates.set("USD", new Decimal(1));
}
makeCurrencyUnit(code: string): Unit | null {
const rate = this.rates.get(code);
if (!rate) return null;
return this.makeCurrencyUnitFromRate(code, rate);
}
makeCurrencyUnitFromRate(code: string, ratePerUsd: Decimal): Unit {
return {
id: code,
dimension: "currency",
ratio: new Decimal(1).div(ratePerUsd), // USD per 1 unit
format: this.currencyFormat.get(code) ?? `{} ${code}`,
};
}
isFunc(name: string): boolean {
return BUILTIN_FUNCS.includes(name) || this.customFuncs.has(name);
}
}
function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { caseSensitive?: boolean } = {}): void {
reg.unitsById.set(unit.id, unit);
for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
}
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
export function buildRegistry(): Registry {
const reg = new Registry();
registerCoreVocab(reg);
const lengthPhrases = registerUnits(reg);
registerAreaVolume(reg, lengthPhrases);
registerCurrencies(reg);
registerExtraUnits(reg);
registerCrypto(reg);
registerCompletions(reg);
return reg;
}
// operators, percent, dates, numerals, scales, aggregates, built-in functions
function registerCoreVocab(reg: Registry): void {
const ops: Array<[string, Payload]> = [
["plus", { t: "op", op: "plus" }],
["minus", { t: "op", op: "minus" }],
["multiply", { t: "op", op: "mul" }],
["divide", { t: "op", op: "div" }],
["conversion", { t: "conv" }],
["assignment", { t: "assign" }],
["sum", { t: "agg", name: "sum" }],
["avg", { t: "agg", name: "avg" }],
["prev", { t: "agg", name: "prev" }],
["count", { t: "agg", name: "count" }],
["min", { t: "agg", name: "min" }],
["max", { t: "agg", name: "max" }],
["product", { t: "agg", name: "product" }],
["chart", { t: "agg", name: "chart" }],
["sqrt", { t: "func", name: "sqrt" }],
["cbrt", { t: "func", name: "cbrt" }],
["abs", { t: "func", name: "abs" }],
["ln", { t: "func", name: "ln" }],
["log", { t: "func", name: "log" }],
["round", { t: "func", name: "round" }],
["random", { t: "func", name: "random" }],
["until", { t: "func", name: "until" }],
["since", { t: "func", name: "since" }],
];
for (const [key, payload] of ops) {
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
}
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
// bitwise: "and" stays an addition word, so & | xor shl shr
reg.addPhrase("xor", { t: "bitop", op: "bxor" }, { caseSensitive: false });
reg.addPhrase("or", { t: "bitop", op: "bor" }, { caseSensitive: false });
reg.addPhrase("shl", { t: "bitop", op: "shl" }, { caseSensitive: false });
reg.addPhrase("shr", { t: "bitop", op: "shr" }, { caseSensitive: false });
// unix timestamps and extra numeral representations
for (const v of ["unix", "unixtime", "unix time", "timestamp", "таймстамп"]) {
reg.addPhrase(v, { t: "special", name: "unix" }, { caseSensitive: false });
}
for (const v of ["date", "datetime", "дата", "дату", "датой"]) {
reg.addPhrase(v, { t: "special", name: "todate" }, { caseSensitive: false });
}
for (const v of ["fraction", "fractions", "дробь", "дробью", "дроби"]) {
reg.addPhrase(v, { t: "repr", repr: "fraction" }, { caseSensitive: false });
}
for (const v of ["roman", "roman numerals", "римские", "римскими"]) {
reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false });
}
for (const v of vocab.variants("Percent", "percent.variants")) reg.addPhrase(v, { t: "percent" });
const pctOps: Array<[string, PctOp]> = [
["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"],
["as_a_percent_of", "as_pct_of"], ["as_a_percent_off", "as_pct_off"], ["as_a_percent_on", "as_pct_on"],
["percent_of_what_is", "of_what_is"], ["percent_off_what_is", "off_what_is"], ["percent_on_what_is", "on_what_is"],
];
for (const [key, op] of pctOps) {
for (const v of vocab.variants("Percent", `${key}.variants`)) reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });
}
const dateWords: Array<[string, DateWord]> = [
["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"],
];
for (const [key, word] of dateWords) {
for (const v of vocab.variants("Dates", `${key}.variants`)) reg.addPhrase(v, { t: "date", word }, { caseSensitive: false });
}
for (const v of vocab.variants("Dates", "current_time.variants")) reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });
for (const v of vocab.variants("Dates", "local_time.variants")) reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });
const reprs: Array<[string, NumeralRepr]> = [
["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"],
];
for (const [key, repr] of reprs) {
for (const v of vocab.variants("Numbers", `${key}.variants`)) reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });
}
for (const [id, mult] of Object.entries(SCALE_DATA)) {
for (const v of vocab.variants("Scales", `${id}.variants`)) {
reg.addPhrase(v, { t: "scale", mult: new Decimal(mult) });
}
}
reg.addPhrase("pi", { t: "const", name: "pi" }, { caseSensitive: false });
reg.addPhrase("π", { t: "const", name: "pi" });
reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false });
reg.addPhrase("e", { t: "const", name: "e" }, { caseSensitive: true });
reg.addPhrase("a half", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("one half", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("половина", { t: "const", name: "half" }, { caseSensitive: false });
reg.addPhrase("one and a half", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
}
// UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the
// area/volume templates need to build "square meter" / "cubic foot" etc.
function registerUnits(reg: Registry): LengthPhrase[] {
const lengthPhrases: LengthPhrase[] = [];
/** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */
const lenientSym = new Map<string, Unit | null>();
for (const d of UNIT_DATA) {
const variants = vocab.variants(d.category, `${d.id}.variants`);
const format = vocab.entryEn(d.category, `${d.id}.format`) ?? d.format ?? d.id;
const symbol = vocab.entryEn(d.category, `${d.id}.symbol`);
const symbolsAll = vocab.entriesAll(d.category, `${d.id}.symbol`);
const unit: Unit = {
id: d.id,
dimension: d.dimension,
ratio: new Decimal(d.ratio),
...(d.offset ? { offset: new Decimal(d.offset) } : {}),
format,
};
addUnitPhrases(reg, variants, unit);
if (d.dimension === "length") {
for (const v of variants) lengthPhrases.push({ phrase: v, unit });
}
if (d.prefixes) {
let prefixes = d.prefixes === "si" ? SI_PREFIXES : [...DATA_SI_PREFIXES, ...IEC_PREFIXES];
if (d.id === "second") {
// only sub-second prefixes are useful; "as" (attosecond) would
// shadow the conversion word "as", "das"/"hs" are noise
prefixes = prefixes.filter((p) => ["milli", "micro", "nano", "pico"].includes(p.id));
}
const wordVariants = variants.filter((v) => v.length > 2 && /^[\p{L}]+$/u.test(v));
for (const p of prefixes) {
const pWords = vocab.variants(p.category, `${p.id}.prefix`);
const pSym = vocab.entryEn(p.category, `${p.id}.symbol`) ?? "";
const pSymsAll = vocab.entriesAll(p.category, `${p.id}.symbol`);
const mult = new Decimal(p.mult);
const pUnit: Unit = {
id: `${p.id}:${d.id}`,
dimension: d.dimension,
ratio: unit.ratio.mul(mult),
format: pSym + (symbol ?? format),
};
const phrases: string[] = [];
const csPhrases: string[] = [];
if (d.id === "bit") {
// no "kb" for bits — bare *b symbols mean bytes (common expectation)
csPhrases.push(`${pSym}bit`);
} else {
// symbol compositions in every locale: "km" and "км"
for (const ps of pSymsAll) for (const us of symbolsAll) csPhrases.push(ps + us);
}
for (const pw of pWords) for (const wv of wordVariants) phrases.push(pw + wv);
if (d.id === "byte" && pSym) {
// lenient: KB/kb/Kb all mean kilobyte
phrases.push((pSym + "b").toLowerCase());
}
reg.unitsById.set(pUnit.id, pUnit);
for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });
for (const ph of phrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });
// collect unambiguous lowercase forms: "KM"/"Km" should still mean km,
// while "mm"/"Mm" stay strict (milli vs mega)
for (const ph of csPhrases) {
const lower = ph.toLowerCase();
const prev = lenientSym.get(lower);
if (prev === undefined) lenientSym.set(lower, pUnit);
else if (prev && prev.id !== pUnit.id) lenientSym.set(lower, null);
}
if (d.dimension === "length") {
for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });
for (const pw of pWords) for (const wv of wordVariants) lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });
}
}
}
}
for (const [lower, unit] of lenientSym) {
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
}
return lengthPhrases;
}
// "square %@" / "cubic %@" applied to every length phrase from registerUnits
function registerAreaVolume(reg: Registry, lengthPhrases: LengthPhrase[]): void {
const applyTemplates = (
templates: string[],
fmtTemplate: string,
dim: Dimension,
pow: 2 | 3,
idPrefix: string,
) => {
for (const t of templates) {
for (const lp of lengthPhrases) {
const phrase = t.replace("%@", lp.phrase);
const id = `${idPrefix}:${lp.unit.id}`;
let u = reg.unitsById.get(id);
if (!u) {
u = {
id,
dimension: dim,
ratio: lp.unit.ratio.pow(pow),
format: fmtTemplate.replace("%@", lp.unit.format),
};
reg.unitsById.set(id, u);
}
reg.addPhrase(phrase, { t: "unit", unit: u }, lp.caseSensitive !== undefined ? { caseSensitive: lp.caseSensitive } : {});
}
}
};
applyTemplates(vocab.variants("Area", "area.templates"), vocab.entryEn("Area", "area.format") ?? "%@²", "area", 2, "sq");
applyTemplates(vocab.variants("Volume", "volume.templates"), vocab.entryEn("Volume", "volume.format") ?? "%@³", "volume", 3, "cb");
}
// Major currencies register first so they win generic phrases
// ("доллар" belongs to USD, not TTD which lists it too and sorts earlier).
function registerCurrencies(reg: Registry): void {
const PRIORITY = [
"USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH",
"BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK",
"DKK", "CZK", "ILS", "HKD", "SGD", "BTC",
];
const codes = vocab.idsOf("Currency").sort((a, b) => {
const pa = PRIORITY.indexOf(a);
const pb = PRIORITY.indexOf(b);
return (pa === -1 ? 999 : pa) - (pb === -1 ? 999 : pb) || a.localeCompare(b);
});
for (const code of codes) {
reg.currencyCodes.add(code);
const fmt = vocab.entryEn("Currency", `${code}.format`);
if (fmt) reg.currencyFormat.set(code, fmt.replace("%@", "{}"));
const variants = vocab.variants("Currency", `${code}.variants`);
if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
}
}
// speed, pressure, energy, power, frequency, fuel
function registerExtraUnits(reg: Registry): void {
for (const d of EXTRA_UNITS) {
const unit: Unit = {
id: d.id,
dimension: d.dimension,
ratio: new Decimal(d.ratio),
...(d.reciprocal ? { reciprocal: true } : {}),
format: d.format,
};
reg.unitsById.set(unit.id, unit);
for (const p of d.phrases.split(",")) {
const ph = p.trim();
if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false });
}
if (d.symbols) {
for (const s of d.symbols.split(",")) {
const ph = s.trim();
if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true });
}
}
}
}
// live prices come from the shell; codes that collide with common words
// (TON, NEAR, DOT, ...) match case-sensitively only
function registerCrypto(reg: Registry): void {
const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]);
for (const c of CRYPTO) {
reg.currencyCodes.add(c.code);
reg.currencyFormat.set(c.code, `{} ${c.code}`);
reg.addPhrase(c.code, { t: "currency", code: c.code }, { caseSensitive: CRYPTO_STRICT_CODES.has(c.code) });
for (const p of c.phrases.split(",")) {
const ph = p.trim();
if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
}
}
}
function registerCompletions(reg: Registry): void {
const seen = new Set<string>();
const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
const k = `${type}:${label.toLowerCase()}`;
if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) return;
seen.add(k);
reg.completions.push({ label, type, detail });
};
for (const d of UNIT_DATA) {
for (const v of vocab.variants(d.category, `${d.id}.variants`)) addCompletion(v, "unit", d.dimension);
}
for (const d of EXTRA_UNITS) {
for (const v of d.phrases.split(",")) addCompletion(v.trim(), "unit", d.dimension);
}
for (const code of reg.currencyCodes) addCompletion(code, "currency");
for (const f of BUILTIN_FUNCS) addCompletion(f, "function");
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
addCompletion(k, "keyword");
}
}