diff --git a/src/engine/datetime.ts b/src/engine/datetime.ts index bfaa989..226a331 100644 --- a/src/engine/datetime.ts +++ b/src/engine/datetime.ts @@ -1,4 +1,16 @@ // Date helpers and a city → IANA timezone map for "time in Tokyo". +import { DateOrder } from "./types"; + +/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */ +export function detectDateOrder(): DateOrder { + const parts = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "numeric", year: "numeric" }) + .formatToParts(new Date(2000, 11, 31)); + for (const p of parts) { + if (p.type === "day") return "dmy"; + if (p.type === "month") return "mdy"; + } + return "dmy"; +} const Z: Record = { // ---- common abbreviations diff --git a/src/engine/edge.test.ts b/src/engine/edge.test.ts index 8ba4348..a559c5e 100644 --- a/src/engine/edge.test.ts +++ b/src/engine/edge.test.ts @@ -244,4 +244,41 @@ describe("review fixes", () => { expect(r[0].text).toBe("10"); expect(r[0].commentStart).toBe(6); }); + it("invalid ISO date does not roll over — falls back to arithmetic", () => { + expect(calc("2026-13-45")).toBe("1,968"); + }); + + describe("date literal formats", () => { + const dmyEng = new SumEngine({ dateFormat: "dmy" }); + const mdyEng = new SumEngine({ dateFormat: "mdy" }); + const isoEng = new SumEngine({ dateFormat: "iso" }); + const calcWith = (e: SumEngine, expr: string) => { + const r = e.evaluateDocument(expr); + return r[r.length - 1].text; + }; + + it("dot-separated date respects the configured day/month order", () => { + expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day"); + }); + it("slash-separated date respects the configured day/month order", () => { + expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day"); + }); + it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => { + // dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan + expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day"); + expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day"); + }); + it("an unambiguous component (>12) wins regardless of dateFormat", () => { + expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day"); + }); + it("an invalid date (both components out of range) is not parsed as a date", () => { + const r = dmyEng.evaluateDocument("32.13.2024"); + expect(r[0].value?.kind).not.toBe("date"); + }); + it("display format overrides the OS locale layout", () => { + expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15"); + expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024"); + expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024"); + }); + }); }); diff --git a/src/engine/formatter.ts b/src/engine/formatter.ts index 9c757f6..b6a4cf0 100644 --- a/src/engine/formatter.ts +++ b/src/engine/formatter.ts @@ -1,6 +1,31 @@ // Turns values into display strings: unit/currency formats plus // precision and grouping settings. -import { Decimal, EngineSettings, Value } from "./types"; +import { Decimal, DateVal, EngineSettings, Value } from "./types"; + +// Intl.DateTimeFormat construction is relatively expensive and every line with +// a date rebuilds one on each render — cache by locale+options instead. +const dtfCache = new Map(); +function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = (locale ?? "") + JSON.stringify(opts); + let f = dtfCache.get(key); + if (!f) { + f = new Intl.DateTimeFormat(locale, opts); + dtfCache.set(key, f); + } + return f; +} + +/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */ +function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string { + const parts = dtf("en-GB", { + timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", hourCycle: "h23", + }).formatToParts(v.ms); + const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? ""; + const [y, mo, d] = [get("year"), get("month"), get("day")]; + const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`; + return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date; +} function group(intPart: string, sep: string): string { if (!sep) return intPart; @@ -35,15 +60,16 @@ export function formatValue(v: Value, s: EngineSettings): string { if (v.kind === "date") { const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone }; if (v.timeOnly) { - return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms); + return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms); } + if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat); if (v.hasTime) { - return new Intl.DateTimeFormat(undefined, { + return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false, day: "numeric", month: "short", year: "numeric", }).format(v.ms); } - return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms); + return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms); } // quantity diff --git a/src/engine/index.ts b/src/engine/index.ts index 2d3b83f..0a3d5e3 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,11 +1,12 @@ // Public facade: evaluates whole documents line by line and exposes the // extension API (numi.addUnit / addFunction / setVariable). -import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; +import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import { Registry, buildRegistry, Completion } from "./registry"; import { tokenize, Token } from "./tokenizer"; import { parseLine } from "./parser"; import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; import { formatValue } from "./formatter"; +import { detectDateOrder } from "./datetime"; import snapshot from "./rates-snapshot.json"; import { CRYPTO } from "./extraunits"; @@ -20,6 +21,10 @@ function findCommentStart(line: string): number { return m ? m.index + m[1].length : -1; } +function resolveDateOrder(fmt: EngineSettings["dateFormat"]): DateOrder { + return fmt === "dmy" || fmt === "mdy" ? fmt : detectDateOrder(); +} + export interface LineResult { /** formatted result, null when the line has none (text, comment, header, error) */ text: string | null; @@ -53,9 +58,12 @@ export class SumEngine { settings: EngineSettings; private globals = new Map(); private historicalRates = new Map>(); + /** resolved day/month order for ambiguous date literals like "01/02/2024" */ + private dateOrder: DateOrder; constructor(settings: Partial = {}) { this.settings = { ...defaultSettings, ...settings }; + this.dateOrder = resolveDateOrder(this.settings.dateFormat); this.reg = buildRegistry(); this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot }); } @@ -76,6 +84,7 @@ export class SumEngine { updateSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch }; + if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat); } completions(): Completion[] { @@ -109,7 +118,7 @@ export class SumEngine { continue; } - const tokens = tokenize(line, this.reg); + const tokens = tokenize(line, this.reg, this.dateOrder); const knownVars = new Set(vars.keys()); const parsed = parseLine(tokens, knownVars, line); diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index ecfc822..d06a32c 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -1,6 +1,6 @@ // Splits a line into raw lexemes (numbers, words, symbols). Shared by the // tokenizer and the phrase registry so both segment text the same way. -import { Decimal, NumeralRepr } from "./types"; +import { DateOrder, Decimal, NumeralRepr } from "./types"; export interface Lex { type: "num" | "word" | "sym" | "date" | "xref"; @@ -23,8 +23,18 @@ const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[   ]\d{3})+(?:[.,]\d+)?(?!\d)/; const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/; const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/; const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; +// 31.12.2024 / 12/31/2024 — same separator on both sides, 4-digit year +const SLASH_DOT_DATE_RE = /^(\d{1,2})([./])(\d{1,2})\2(\d{4})(?!\d)/; -export function lexLine(text: string): Lex[] { +/** ms for a real calendar date; null when the components roll over (2026-13-45) */ +function validDateMs(y: number, mo: number, d: number): number | null { + const dt = new Date(y, mo - 1, d); + return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d + ? dt.getTime() + : null; +} + +export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] { const out: Lex[] = []; let i = 0; while (i < text.length) { @@ -36,16 +46,29 @@ export function lexLine(text: string): Lex[] { const rest = text.slice(i); if (ch >= "0" && ch <= "9") { - const iso = /^\d{4}-\d{2}-\d{2}(?!\d)/.exec(rest); + const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest); if (iso) { - const [y, mo, d] = iso[0].split("-").map(Number); - const dt = new Date(y, mo - 1, d); - if (!isNaN(dt.getTime())) { - out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: dt.getTime() }); + const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3])); + if (ms !== null) { + out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms }); i += iso[0].length; continue; } } + const dm = SLASH_DOT_DATE_RE.exec(rest); + if (dm) { + const a = Number(dm[1]); + const b = Number(dm[3]); + // a>12 must be the day, b>12 must be the month; both ≤12 → configured order + const dayFirst = a > 12 ? true : b > 12 ? false : dateOrder === "dmy"; + const y = Number(dm[4]); + const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b); + if (ms !== null) { + out.push({ type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms }); + i += dm[0].length; + continue; + } + } } if (ch >= "0" && ch <= "9") { const hex = /^0[xX][0-9a-fA-F]+/.exec(rest); diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts index 51668f0..a2e1391 100644 --- a/src/engine/tokenizer.ts +++ b/src/engine/tokenizer.ts @@ -1,5 +1,5 @@ // Lexes a line and resolves phrases through the registry into semantic tokens. -import { Decimal, NumeralRepr, Unit } from "./types"; +import { DateOrder, Decimal, NumeralRepr, Unit } from "./types"; import { lexLine } from "./lexer"; import { Registry, PctOp, DateWord, BitOp } from "./registry"; @@ -30,11 +30,8 @@ export type Token = | { t: "unknown"; start: number; end: number } | { t: "junk"; raw: string; start: number; end: number }; -const SYM_OPS: Record = {}; - -export function tokenize(line: string, reg: Registry): Token[] { - void SYM_OPS; - const lexes = lexLine(line); +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; diff --git a/src/engine/types.ts b/src/engine/types.ts index e1f84ac..d18b7c5 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -24,6 +24,12 @@ export type Dimension = export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain"; +/** day-first (31.12.2024) or month-first (12/31/2024) for ambiguous date literals */ +export type DateOrder = "dmy" | "mdy"; + +/** "system" renders via the OS locale; the rest force a fixed layout */ +export type DateFormat = "system" | "iso" | "dmy" | "mdy"; + /** A resolved unit instance attached to a value. */ export interface Unit { /** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */ @@ -88,10 +94,13 @@ export interface EngineSettings { groupSeparator: string; /** decimal separator shown in output */ decimalSeparator: string; + /** date display layout; also disambiguates 01/02/2024 on input */ + dateFormat: DateFormat; } export const defaultSettings: EngineSettings = { precision: 2, groupSeparator: ",", decimalSeparator: ".", + dateFormat: "system", };