From 1c25ad62daa40e3ab61dbc74886570d4abf7f4d4 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:04:18 +0300 Subject: [PATCH 01/12] refactor(math): add pipeline type definitions --- .../math-notebook/math-engine/types.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/renderer/composables/math-notebook/math-engine/types.ts b/src/renderer/composables/math-notebook/math-engine/types.ts index 28d615c6..f83d3cfc 100644 --- a/src/renderer/composables/math-notebook/math-engine/types.ts +++ b/src/renderer/composables/math-notebook/math-engine/types.ts @@ -25,3 +25,83 @@ export interface SpecialLineResult { lineResult: LineResult rawResult: any } + +// --- Pipeline types --- + +export interface AnalysisView { + raw: string + expression: string + normalized: string + label?: string +} + +export type PrimaryIntent = + | 'empty' + | 'comment' + | 'aggregate-block' + | 'aggregate-inline' + | 'assignment' + | 'calendar' + | 'timezone' + | 'css' + | 'date-arithmetic' + | 'math' + +export interface RoundingDirective { + type: + | 'dp' + | 'round' + | 'ceil' + | 'floor' + | 'nearest' + | 'nearestCeil' + | 'nearestFloor' + param: number +} + +export interface IntentModifiers { + rounding?: RoundingDirective + resultFormat?: 'hex' | 'bin' | 'oct' | 'sci' | 'multiplier' + stripUnit?: 'number' | 'dec' | 'fraction' +} + +export interface LineFeatures { + hasCurrency: boolean + hasAssignment: boolean + hasConversion: boolean + hasDateTokens: boolean + hasTimezoneTokens: boolean +} + +export interface LineClassification { + primary: PrimaryIntent + modifiers: IntentModifiers + features: LineFeatures + assignmentTarget?: 'math' | 'css' | 'date' + timezoneOperation?: 'display' | 'difference' +} + +export type RewritePhase = + | 'normalize' + | 'syntax-rewrite' + | 'semantic-rewrite' + | 'finalize' + +export interface RewriteContext { + raw: string + view: AnalysisView + line: string + classification: LineClassification +} + +export interface RewriteResult { + line: string + changed: boolean +} + +export interface RewriteRule { + id: string + category: RewritePhase + priority: number + apply: (ctx: RewriteContext) => RewriteResult | null +} From 043bc914bed308e94d499e175682beb31224001e Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:05:11 +0300 Subject: [PATCH 02/12] refactor(math): extract shared utilities to utils.ts --- .../math-notebook/math-engine/utils.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/utils.ts diff --git a/src/renderer/composables/math-notebook/math-engine/utils.ts b/src/renderer/composables/math-notebook/math-engine/utils.ts new file mode 100644 index 00000000..6a9a5f19 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/utils.ts @@ -0,0 +1,117 @@ +export const NUM = String.raw`\d+(?:\.\d+)?` + +export function coerceToNumber(result: any): number { + if (typeof result === 'number') + return result + if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + return result.toNumber() + } + catch { + return Number.NaN + } + } + return Number(result) +} + +export function splitTopLevelAddSub(expression: string) { + const terms: string[] = [] + const operators: Array<'+' | '-'> = [] + let depth = 0 + let segmentStart = 0 + + for (let index = 0; index < expression.length; index++) { + const char = expression[index] + + if (char === '(') { + depth += 1 + continue + } + + if (char === ')') { + depth = Math.max(0, depth - 1) + continue + } + + if (depth !== 0 || (char !== '+' && char !== '-')) { + continue + } + + let prevIndex = index - 1 + while (prevIndex >= 0 && /\s/.test(expression[prevIndex])) { + prevIndex -= 1 + } + + let nextIndex = index + 1 + while (nextIndex < expression.length && /\s/.test(expression[nextIndex])) { + nextIndex += 1 + } + + if (prevIndex < 0 || nextIndex >= expression.length) { + continue + } + + if ('+-*/%^,('.includes(expression[prevIndex])) { + continue + } + + terms.push(expression.slice(segmentStart, index).trim()) + operators.push(char as '+' | '-') + segmentStart = index + 1 + } + + if (operators.length === 0) { + return null + } + + terms.push(expression.slice(segmentStart).trim()) + + if (terms.some(term => !term)) { + return null + } + + return { terms, operators } +} + +export function splitByKeyword(line: string, keywords: string[]) { + const lowerLine = line.toLowerCase() + + for (const keyword of keywords) { + const index = lowerLine.lastIndexOf(keyword) + if (index > 0) { + return [ + line.slice(0, index).trim(), + line.slice(index + keyword.length).trim(), + ] as const + } + } + + return null +} + +export function gcd(a: number, b: number): number { + a = Math.abs(Math.round(a)) + b = Math.abs(Math.round(b)) + while (b) { + const t = b + b = a % b + a = t + } + return a +} + +export function toFraction(decimal: number): string { + if (Number.isInteger(decimal)) + return `${decimal}/1` + const sign = decimal < 0 ? '-' : '' + const abs = Math.abs(decimal) + const precision = 1e10 + const numerator = Math.round(abs * precision) + const denominator = precision + const divisor = gcd(numerator, denominator) + return `${sign}${numerator / divisor}/${denominator / divisor}` +} From e9a850afc69db1c37379004cf147d9cd9907c8ff Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:06:26 +0300 Subject: [PATCH 03/12] refactor(math): add analysis-normalize stage --- .../__tests__/analysisNormalize.test.ts | 58 +++++++++++++++++++ .../math-engine/pipeline/analysisNormalize.ts | 47 +++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/__tests__/analysisNormalize.test.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/analysisNormalize.ts diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/analysisNormalize.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/analysisNormalize.test.ts new file mode 100644 index 00000000..4dbb1ec5 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/analysisNormalize.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { analysisNormalize } from '../pipeline/analysisNormalize' + +describe('analysisNormalize', () => { + it('plain expression', () => { + const view = analysisNormalize('10 + 5') + expect(view.raw).toBe('10 + 5') + expect(view.expression).toBe('10 + 5') + expect(view.normalized).toBe('10 + 5') + expect(view.label).toBeUndefined() + }) + + it('strips label prefix', () => { + const view = analysisNormalize('Price: $100 + $50') + expect(view.expression).toBe('$100 + $50') + expect(view.label).toBe('Price') + }) + + it('strips multi-word label', () => { + const view = analysisNormalize('Monthly cost: 1200 / 12') + expect(view.expression).toBe('1200 / 12') + expect(view.label).toBe('Monthly cost') + }) + + it('strips quoted text', () => { + const view = analysisNormalize('$275 for the "Model 227"') + expect(view.expression).toBe('$275') + }) + + it('strips label and quoted text', () => { + const view = analysisNormalize('Item: $99 "discount"') + expect(view.expression).toBe('$99') + expect(view.label).toBe('Item') + }) + + it('empty string', () => { + const view = analysisNormalize('') + expect(view.raw).toBe('') + expect(view.expression).toBe('') + expect(view.normalized).toBe('') + }) + + it('comment', () => { + const view = analysisNormalize('// comment') + expect(view.expression).toBe('// comment') + }) + + it('normalized is lowercase', () => { + const view = analysisNormalize('5 USD to EUR') + expect(view.normalized).toBe('5 usd to eur') + }) + + it('trims whitespace', () => { + const view = analysisNormalize(' 10 + 5 ') + expect(view.raw).toBe('10 + 5') + expect(view.expression).toBe('10 + 5') + }) +}) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/analysisNormalize.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/analysisNormalize.ts new file mode 100644 index 00000000..4c8a2dc6 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/analysisNormalize.ts @@ -0,0 +1,47 @@ +import type { AnalysisView } from '../types' +import { knownUnitTokens } from '../constants' + +function stripLabel(line: string): { expression: string, label?: string } { + const match = line.match(/^([a-z][a-z0-9]*(?:\s[a-z0-9]+)*):\s(\S.*)$/i) + if (match) { + return { expression: match[2], label: match[1] } + } + return { expression: line } +} + +function stripQuotedText(line: string): string { + const stripped = line + .replace(/"[^"]*"/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (stripped === line) { + return line + } + + const tokens = stripped.split(' ') + while (tokens.length > 1) { + const lastToken = tokens.at(-1)!.toLowerCase() + + if (!/^[a-z-]+$/i.test(lastToken) || knownUnitTokens.has(lastToken)) { + break + } + + tokens.pop() + } + + return tokens.join(' ') +} + +export function analysisNormalize(raw: string): AnalysisView { + const trimmed = raw.trim() + const { expression: afterLabel, label } = stripLabel(trimmed) + const expression = stripQuotedText(afterLabel) + + return { + raw: trimmed, + expression, + normalized: expression.toLowerCase(), + label, + } +} From 0c59a6f9ecee303cb695e5480412492c2a5bc4f3 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:09:00 +0300 Subject: [PATCH 04/12] refactor(math): add classify stage --- .../math-engine/__tests__/classify.test.ts | 175 +++++++++++ .../math-engine/pipeline/classify.ts | 281 ++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts new file mode 100644 index 00000000..5d321be4 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { analysisNormalize } from '../pipeline/analysisNormalize' +import { classify } from '../pipeline/classify' + +function classifyRaw(raw: string) { + return classify(analysisNormalize(raw)) +} + +describe('classify — primary intent', () => { + it('empty', () => expect(classifyRaw('').primary).toBe('empty')) + it('whitespace', () => expect(classifyRaw(' ').primary).toBe('empty')) + + it('// comment', () => + expect(classifyRaw('// text').primary).toBe('comment')) + it('# heading', () => + expect(classifyRaw('# heading').primary).toBe('comment')) + + it('sum', () => expect(classifyRaw('sum').primary).toBe('aggregate-block')) + it('total', () => + expect(classifyRaw('total').primary).toBe('aggregate-block')) + it('average', () => + expect(classifyRaw('average').primary).toBe('aggregate-block')) + it('avg', () => expect(classifyRaw('avg').primary).toBe('aggregate-block')) + it('median', () => + expect(classifyRaw('median').primary).toBe('aggregate-block')) + it('count', () => + expect(classifyRaw('count').primary).toBe('aggregate-block')) + it('sUM case insensitive', () => + expect(classifyRaw('SUM').primary).toBe('aggregate-block')) + + it('total of list', () => + expect(classifyRaw('total of 3, 4 and 9').primary).toBe( + 'aggregate-inline', + )) + it('average of list', () => + expect(classifyRaw('average of 10, 20').primary).toBe('aggregate-inline')) + + it('days since', () => + expect(classifyRaw('days since January 1').primary).toBe('calendar')) + it('days till', () => + expect(classifyRaw('days till December 25').primary).toBe('calendar')) + it('days between', () => + expect(classifyRaw('days between March 1 and March 31').primary).toBe( + 'calendar', + )) + it('5 days from now', () => + expect(classifyRaw('5 days from now').primary).toBe('calendar')) + it('3 days ago', () => + expect(classifyRaw('3 days ago').primary).toBe('calendar')) + it('day of the week', () => + expect(classifyRaw('day of the week on Jan 1, 2024').primary).toBe( + 'calendar', + )) + it('week of year', () => + expect(classifyRaw('week of year').primary).toBe('calendar')) + it('days in February', () => + expect(classifyRaw('days in February 2020').primary).toBe('calendar')) + it('days in Q3', () => + expect(classifyRaw('days in Q3').primary).toBe('calendar')) + it('current timestamp', () => + expect(classifyRaw('current timestamp').primary).toBe('calendar')) + + it('time in Paris', () => { + const c = classifyRaw('time in Paris') + expect(c.primary).toBe('timezone') + expect(c.timezoneOperation).toBe('display') + }) + it('now', () => { + const c = classifyRaw('now') + expect(c.primary).toBe('timezone') + expect(c.timezoneOperation).toBe('display') + }) + it('pST time', () => { + const c = classifyRaw('PST time') + expect(c.primary).toBe('timezone') + expect(c.timezoneOperation).toBe('display') + }) + + it('pST time - Berlin time', () => { + const c = classifyRaw('PST time - Berlin time') + expect(c.primary).toBe('timezone') + expect(c.timezoneOperation).toBe('difference') + }) + + it('ppi = 326', () => { + const c = classifyRaw('ppi = 326') + expect(c.primary).toBe('assignment') + expect(c.assignmentTarget).toBe('css') + }) + it('em = 20px', () => { + const c = classifyRaw('em = 20px') + expect(c.primary).toBe('assignment') + expect(c.assignmentTarget).toBe('css') + }) + + it('12 pt in px', () => + expect(classifyRaw('12 pt in px').primary).toBe('css')) + + it('x = 10', () => { + const c = classifyRaw('x = 10') + expect(c.primary).toBe('assignment') + expect(c.assignmentTarget).toBe('math') + }) + it('start = today', () => { + const c = classifyRaw('start = today') + expect(c.primary).toBe('assignment') + expect(c.assignmentTarget).toBe('date') + }) + + it('today + 3 days', () => + expect(classifyRaw('today + 3 days').primary).toBe('date-arithmetic')) + + it('10 + 5', () => expect(classifyRaw('10 + 5').primary).toBe('math')) + it('sqrt(16)', () => expect(classifyRaw('sqrt(16)').primary).toBe('math')) + it('100 USD to EUR', () => + expect(classifyRaw('100 USD to EUR').primary).toBe('math')) +}) + +describe('classify — modifiers', () => { + it('rounding: to 2 dp', () => { + const c = classifyRaw('1/3 to 2 dp') + expect(c.modifiers.rounding).toEqual({ type: 'dp', param: 2 }) + }) + it('rounding: rounded', () => { + const c = classifyRaw('5.5 rounded') + expect(c.modifiers.rounding).toEqual({ type: 'round', param: 0 }) + }) + it('rounding: to nearest 10', () => { + const c = classifyRaw('37 to nearest 10') + expect(c.modifiers.rounding).toEqual({ type: 'nearest', param: 10 }) + }) + + it('format: in hex', () => { + const c = classifyRaw('255 in hex') + expect(c.modifiers.resultFormat).toBe('hex') + }) + it('format: in sci', () => { + const c = classifyRaw('5300 in sci') + expect(c.modifiers.resultFormat).toBe('sci') + }) + + it('strip: as number', () => { + const c = classifyRaw('$100 as number') + expect(c.modifiers.stripUnit).toBe('number') + }) + it('strip: as dec', () => { + const c = classifyRaw('20% as dec') + expect(c.modifiers.stripUnit).toBe('dec') + }) + it('strip: as fraction', () => { + const c = classifyRaw('0.5 as fraction') + expect(c.modifiers.stripUnit).toBe('fraction') + }) +}) + +describe('classify — features', () => { + it('hasCurrency for $ symbol', () => { + expect(classifyRaw('$100 + $50').features.hasCurrency).toBe(true) + }) + it('hasCurrency for USD code', () => { + expect(classifyRaw('100 USD').features.hasCurrency).toBe(true) + }) + it('no currency for plain math', () => { + expect(classifyRaw('10 + 5').features.hasCurrency).toBe(false) + }) + it('hasAssignment', () => { + expect(classifyRaw('x = 10').features.hasAssignment).toBe(true) + }) + it('no assignment for ==', () => { + expect(classifyRaw('5 == 5').features.hasAssignment).toBe(false) + }) + it('hasConversion', () => { + expect(classifyRaw('5 km to mile').features.hasConversion).toBe(true) + }) +}) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts new file mode 100644 index 00000000..9f781354 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts @@ -0,0 +1,281 @@ +import type { + AnalysisView, + IntentModifiers, + LineClassification, + LineFeatures, + RoundingDirective, +} from '../types' +import { + currencySymbols, + currencyWordNames, + SUPPORTED_CURRENCY_CODES, + timeZoneAliases, +} from '../constants' + +const AGGREGATE_BLOCK_KEYWORDS = new Set([ + 'sum', + 'total', + 'average', + 'avg', + 'median', + 'count', +]) + +const INLINE_AGGREGATE_RE = /^(?:total|sum|average|avg|median|count)\s+of\s+/ + +const CALENDAR_PREFIXES = [ + /^days\s+(since|till|until)\s/, + /^days\s+between\s/, + /^\d+\s+(?:days?|weeks?|months?|years?)\s+(?:from now|ago)$/, + /^(?:day of the week|weekday)\s+on\s/, + /^week\s+of\s+year$/, + /^week\s+number$/, + /^week\s+number\s+on\s/, + /^days\s+in\s+/, + /^\d+\s+(?:days?|weeks?|months?|years?)\s+(?:after|before)\s/, + /^current\s+timestamp$/, +] + +const TIMEZONE_PATTERNS = [ + /^time$/, + /^time\(\)$/, + /^now$/, + /^now\(\)$/, + /\btime$/, + /\bnow$/, + /^time\s+in\s/, + /^now\s+in\s/, +] + +const TIMEZONE_DIFF_RE = /\s+-\s+(?:\S.*)?\b(?:time|now)\b/ + +const CSS_ASSIGNMENT_RE = /^(?:ppi|em)\s*=/i +const CSS_CONVERSION_RE = /\b(?:px|pt|em)\b/ + +const NEAREST_WORDS: Record = { + ten: 10, + hundred: 100, + thousand: 1000, + million: 1000000, +} + +function detectRounding(normalized: string): RoundingDirective | undefined { + let m: RegExpMatchArray | null + + m = normalized.match(/\s+to\s+(\d+)\s+(?:dp|digits?)$/) + if (m) + return { type: 'dp', param: Number(m[1]) } + + m = normalized.match(/\s+rounded\s+(up|down)\s+to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[2]] || Number(m[2]) + if (n > 0) + return { type: m[1] === 'up' ? 'nearestCeil' : 'nearestFloor', param: n } + } + + m = normalized.match(/\s+(?:rounded\s+)?to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[1]] || Number(m[1]) + if (n > 0) + return { type: 'nearest', param: n } + } + + m = normalized.match(/\s+rounded\s+(up|down)$/) + if (m) + return { type: m[1] === 'up' ? 'ceil' : 'floor', param: 0 } + + if (/\s+rounded$/.test(normalized)) + return { type: 'round', param: 0 } + + return undefined +} + +type ResultFormat = NonNullable +type StripUnit = NonNullable + +const FORMAT_SUFFIXES: Record = { + 'in hex': 'hex', + 'in bin': 'bin', + 'in oct': 'oct', + 'in sci': 'sci', + 'in scientific': 'sci', +} + +const STRIP_SUFFIXES: Record = { + 'as number': 'number', + 'to number': 'number', + 'as decimal': 'dec', + 'to decimal': 'dec', + 'as dec': 'dec', + 'to dec': 'dec', + 'as fraction': 'fraction', + 'to fraction': 'fraction', +} + +const MULTIPLIER_SUFFIXES = ['as multiplier', 'to multiplier'] + +function detectResultFormat(normalized: string): ResultFormat | undefined { + for (const [suffix, format] of Object.entries(FORMAT_SUFFIXES)) { + if (normalized.endsWith(suffix)) + return format + } + if (MULTIPLIER_SUFFIXES.some(s => normalized.endsWith(s))) + return 'multiplier' + return undefined +} + +function detectStripUnit(normalized: string): StripUnit | undefined { + for (const [suffix, unit] of Object.entries(STRIP_SUFFIXES)) { + if (normalized.endsWith(suffix)) + return unit + } + return undefined +} + +function detectFeatures(normalized: string, expression: string): LineFeatures { + const currencyCodePattern = new RegExp( + `\\b(${SUPPORTED_CURRENCY_CODES.join('|')})\\b`, + 'i', + ) + const hasCurrencySymbol = Object.keys(currencySymbols).some(s => + expression.includes(s), + ) + const hasCurrencyCode = currencyCodePattern.test(expression) + const hasCurrencyWord = Object.keys(currencyWordNames).some(name => + new RegExp(`\\b${name}\\b`, 'i').test(normalized), + ) + + return { + hasCurrency: hasCurrencySymbol || hasCurrencyCode || hasCurrencyWord, + hasAssignment: /^[a-z_]\w*\s*=(?!=)/i.test(expression), + hasConversion: /\b(?:to|in|as|into)\b/.test(normalized), + hasDateTokens: + /\b(?:today|tomorrow|yesterday|now)\b/.test(normalized) + || /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b/.test( + normalized, + ), + hasTimezoneTokens: + /\b(?:time|now)\b/.test(normalized) + && (/\bin\b/.test(normalized) + || Object.keys(timeZoneAliases).some(tz => normalized.includes(tz))), + } +} + +function probeTimezone(normalized: string): boolean { + return ( + TIMEZONE_PATTERNS.some(re => re.test(normalized)) + || (/\bin\b/.test(normalized) && /\b(?:time|now)\b/.test(normalized)) + ) +} + +function probeTimezoneDiff(normalized: string): boolean { + return TIMEZONE_DIFF_RE.test(normalized) +} + +function probeCalendar(normalized: string): boolean { + return CALENDAR_PREFIXES.some(re => re.test(normalized)) +} + +function probeCss(expression: string, normalized: string): boolean { + if (CSS_ASSIGNMENT_RE.test(expression)) + return true + return CSS_CONVERSION_RE.test(normalized) && /\b(?:to|in)\b/.test(normalized) +} + +function probeDateArithmetic( + normalized: string, + features: LineFeatures, +): boolean { + if (!features.hasDateTokens) + return false + return /[+-]/.test(normalized) +} + +export function classify(view: AnalysisView): LineClassification { + const { raw, expression, normalized } = view + + const modifiers: IntentModifiers = {} + const features = detectFeatures(normalized, expression) + + // Detect modifiers from suffixes + modifiers.rounding = detectRounding(normalized) + modifiers.resultFormat = detectResultFormat(normalized) + modifiers.stripUnit = detectStripUnit(raw.toLowerCase()) + + // --- Primary intent probes (most specific first) --- + + if (!raw) { + return { primary: 'empty', modifiers, features } + } + + if (raw.startsWith('//') || raw.startsWith('#')) { + return { primary: 'comment', modifiers, features } + } + + const normalizedTrimmed = normalized.trim() + + if (AGGREGATE_BLOCK_KEYWORDS.has(normalizedTrimmed)) { + return { primary: 'aggregate-block', modifiers, features } + } + + if (INLINE_AGGREGATE_RE.test(normalizedTrimmed)) { + return { primary: 'aggregate-inline', modifiers, features } + } + + if (probeCalendar(normalizedTrimmed)) { + return { primary: 'calendar', modifiers, features } + } + + if (probeTimezoneDiff(normalizedTrimmed)) { + return { + primary: 'timezone', + modifiers, + features, + timezoneOperation: 'difference', + } + } + + if (probeTimezone(normalizedTrimmed)) { + return { + primary: 'timezone', + modifiers, + features, + timezoneOperation: 'display', + } + } + + if (probeCss(expression, normalizedTrimmed)) { + if (CSS_ASSIGNMENT_RE.test(expression)) { + return { + primary: 'assignment', + modifiers, + features, + assignmentTarget: 'css', + } + } + return { primary: 'css', modifiers, features } + } + + if (features.hasAssignment) { + if (features.hasDateTokens) { + return { + primary: 'assignment', + modifiers, + features, + assignmentTarget: 'date', + } + } + return { + primary: 'assignment', + modifiers, + features, + assignmentTarget: 'math', + } + } + + if (probeDateArithmetic(normalizedTrimmed, features)) { + return { primary: 'date-arithmetic', modifiers, features } + } + + return { primary: 'math', modifiers, features } +} From 8138f5aa89135e9b7b794f699977971622b421e8 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:12:32 +0300 Subject: [PATCH 05/12] refactor(math): add rewrite rules as declarative objects --- .../math-engine/rules/finalize.ts | 218 +++++++++++++++ .../math-engine/rules/normalize.ts | 118 ++++++++ .../math-engine/rules/semanticRewrite.ts | 255 ++++++++++++++++++ .../math-engine/rules/syntaxRewrite.ts | 186 +++++++++++++ 4 files changed, 777 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/rules/finalize.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/rules/normalize.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/rules/semanticRewrite.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/rules/syntaxRewrite.ts diff --git a/src/renderer/composables/math-notebook/math-engine/rules/finalize.ts b/src/renderer/composables/math-notebook/math-engine/rules/finalize.ts new file mode 100644 index 00000000..a7996a78 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/rules/finalize.ts @@ -0,0 +1,218 @@ +import type { RewriteRule } from '../types' +import { SUPPORTED_CURRENCY_CODES } from '../constants' +import { splitByKeyword, splitTopLevelAddSub } from '../utils' + +function extractAdditiveUnit(term: string) { + const match = term.match(/^-?\d+(?:\.\d+)?\s+([a-z][a-z0-9^]*)$/i) + if (!match) + return null + return match[1].toUpperCase() + in Object.fromEntries(SUPPORTED_CURRENCY_CODES.map(code => [code, true])) + ? match[1].toUpperCase() + : match[1] +} + +function isPlainNumberTerm(term: string) { + return /^-?\d+(?:\.\d+)?$/.test(term) +} + +function inferAdditiveUnitsForPlainNumbers(expression: string) { + const split = splitTopLevelAddSub(expression) + if (!split) + return expression + + const explicitUnits = split.terms.map(extractAdditiveUnit) + + let changed = false + const inferredTerms = split.terms.map((term, index) => { + if (!isPlainNumberTerm(term)) + return term + + const previousUnit = explicitUnits.slice(0, index).reverse().find(Boolean) + const nextUnit = explicitUnits.slice(index + 1).find(Boolean) + const inheritedUnit = previousUnit || nextUnit + + if (!inheritedUnit) + return term + + changed = true + return `${term} ${inheritedUnit}` + }) + + if (!changed) + return expression + + let rebuilt = inferredTerms[0] + for (let i = 0; i < split.operators.length; i++) { + rebuilt += ` ${split.operators[i]} ${inferredTerms[i + 1]}` + } + return rebuilt +} + +export const additiveUnitInferenceRule: RewriteRule = { + id: 'additive-unit-inference', + category: 'finalize', + priority: 100, + apply: (ctx) => { + const conversionSplit = splitByKeyword(ctx.line, [ + ' into ', + ' as ', + ' to ', + ' in ', + ]) + const sourceExpression = conversionSplit ? conversionSplit[0] : ctx.line + const conversionTarget = conversionSplit ? conversionSplit[1] : null + + const inferredSource = inferAdditiveUnitsForPlainNumbers(sourceExpression) + const hasAdditiveSource = splitTopLevelAddSub(sourceExpression) !== null + + if (!hasAdditiveSource) + return null + + let line: string + if (!conversionTarget) { + line = inferredSource + } + else { + line = `(${inferredSource}) to ${conversionTarget}` + } + + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const percentagesRule: RewriteRule = { + id: 'percentages', + category: 'finalize', + priority: 200, + apply: (ctx) => { + const line = ctx.line + // Z is X/Y of what → Z / (X/Y) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+what\b/gi, + '$1 / ($2 / $3)', + ) + // X/Y of Z → (X/Y) * Z + .replace( + /(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\b/gi, + '($1 / $2) * $3', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / ($1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 - $1 / 100)', + ) + // X is Y% of what + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+of\s+what\b/gi, + '$1 / ($2 / 100)', + ) + // X is Y% on what + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+on\s+what\b/gi, + '$1 / (1 + $2 / 100)', + ) + // X is Y% off what + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+off\s+what\b/gi, + '$1 / (1 - $2 / 100)', + ) + // X to Y is what % + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X to Y as % + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X is what % off Y + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X is what % on Y + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + // X is what % of Y + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X/Y as % + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+as\s*%/gi, + '($1 / $2) * 100', + ) + // X/Y % + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*%(?!\s*\w)/g, + '($1 / $2) * 100', + ) + // 0.35 as % + .replace(/(\d+(?:\.\d+)?)\s+as\s*%/gi, '$1 * 100') + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 - $1 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') + .replace( + /(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, + '$1 * (1 + $2 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') + .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') + + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const conversionsRule: RewriteRule = { + id: 'conversions', + category: 'finalize', + priority: 300, + apply: (ctx) => { + const line = ctx.line + .replace(/\s+as\s+/gi, ' to ') + .replace(/\s+into\s+/gi, ' to ') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const finalizeRules: RewriteRule[] = [ + additiveUnitInferenceRule, + percentagesRule, + conversionsRule, +] diff --git a/src/renderer/composables/math-notebook/math-engine/rules/normalize.ts b/src/renderer/composables/math-notebook/math-engine/rules/normalize.ts new file mode 100644 index 00000000..969e97d1 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/rules/normalize.ts @@ -0,0 +1,118 @@ +import type { RewriteRule } from '../types' +import { knownUnitTokens, TIME_UNIT_TOKEN_MAP } from '../constants' + +export const groupedNumbers: RewriteRule = { + id: 'grouped-numbers', + category: 'normalize', + priority: 100, + apply: (ctx) => { + let line = ctx.line.replace( + /\b\d{1,3}(?:\s\d{3})+(?=\b|[a-z])/gi, + match => match.replace(/\s+/g, ''), + ) + line = line.replace(/\b\d+(?:\s+\d+)+\b/g, match => + match.replace(/\s+/g, '')) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const degreeSigns: RewriteRule = { + id: 'degree-signs', + category: 'normalize', + priority: 200, + apply: (ctx) => { + const line = ctx.line.replace(/(-?\d+(?:\.\d+)?)\s*°/g, '$1 deg') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const reverseConversion: RewriteRule = { + id: 'reverse-conversion', + category: 'normalize', + priority: 300, + apply: (ctx) => { + const line = ctx.line.replace( + /^([a-z]+)\s+in\s+(\d+(?:\.\d+)?)\s+([a-z]+)$/i, + (match, targetUnit: string, value: string, sourceUnit: string) => { + if ( + knownUnitTokens.has(targetUnit.toLowerCase()) + && knownUnitTokens.has(sourceUnit.toLowerCase()) + ) { + return `${value} ${sourceUnit} to ${targetUnit}` + } + return match + }, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const shorthandConversion: RewriteRule = { + id: 'shorthand-conversion', + category: 'normalize', + priority: 400, + apply: (ctx) => { + const line = ctx.line.replace( + /^([a-z]+)\s+([a-z]+)$/i, + (match, unit1: string, unit2: string) => { + if ( + knownUnitTokens.has(unit1.toLowerCase()) + && knownUnitTokens.has(unit2.toLowerCase()) + ) { + return `1 ${unit1} to ${unit2}` + } + return match + }, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const unitAliases: RewriteRule = { + id: 'unit-aliases', + category: 'normalize', + priority: 500, + apply: (ctx) => { + const line = ctx.line + .replace(/\btea\s+spoons?\b/gi, 'teaspoon') + .replace(/\btable\s+spoons?\b/gi, 'tablespoon') + .replace(/\bnautical\s+miles?\b/gi, 'nauticalmile') + .replace(/\blight\s+years?\b/gi, 'lightyear') + .replace(/\bkm\/h\b/gi, 'kmh') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const timeUnits: RewriteRule = { + id: 'time-units', + category: 'normalize', + priority: 600, + apply: (ctx) => { + const line = ctx.line.replace( + /\b(seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b/gi, + match => TIME_UNIT_TOKEN_MAP[match.toLowerCase()] || match, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const normalizeRules: RewriteRule[] = [ + groupedNumbers, + degreeSigns, + reverseConversion, + shorthandConversion, + unitAliases, + timeUnits, +] diff --git a/src/renderer/composables/math-notebook/math-engine/rules/semanticRewrite.ts b/src/renderer/composables/math-notebook/math-engine/rules/semanticRewrite.ts new file mode 100644 index 00000000..d17f3310 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/rules/semanticRewrite.ts @@ -0,0 +1,255 @@ +import type { RewriteRule } from '../types' +import { MATH_UNARY_FUNCTIONS } from '../constants' + +export const ratesRule: RewriteRule = { + id: 'rates', + category: 'semantic-rewrite', + priority: 100, + apply: (ctx) => { + const line = ctx.line + .replace(/\bper\b/gi, '/') + .replace( + /(\d+(?:\.\d+)?)\s+(\w+)\s+at\s+(\S+)\/(\w+)/gi, + '$1 $2 * $3 / $4', + ) + .replace( + /(\S+)\s+a\s+(day|week|month|year)\s+for\s+(?:a\s+)?(\S+)/gi, + '$1 / $2 * 1 $3', + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const multipliersRule: RewriteRule = { + id: 'multipliers', + category: 'semantic-rewrite', + priority: 200, + apply: (ctx) => { + const line = ctx.line + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s+x\b/gi, + '$2 / $1 as multiplier', + ) + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s+x\b/gi, + '$2 / $1 as multiplier', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+on\s+(\d+(?:\.\d+)?)/gi, + '($1 - $2) / $2 as multiplier', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+off\s+(\d+(?:\.\d+)?)/gi, + '($2 - $1) / $2 as multiplier', + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const phraseFunctionsRule: RewriteRule = { + id: 'phrase-functions', + category: 'semantic-rewrite', + priority: 300, + apply: (ctx) => { + const line = ctx.line + .replace(/\bsquare\s+root\s+of\s+(\S+)/gi, 'sqrt($1)') + .replace(/\bcube\s+root\s+of\s+(\S+)/gi, 'cbrt($1)') + .replace(/\broot\s+(\d+)\s+of\s+(\S+)/gi, 'root($1, $2)') + .replace(/\blog\s+(\S+)\s+base\s+(\S+)/gi, 'log($1, $2)') + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s+to\s+(?:what|the\s+what)\s*(?:power)?/gi, + 'log($1) / log($2)', + ) + .replace(/\b(sin|cos|tan)\((\d+(?:\.\d+)?)\s+degrees\)/gi, '$1($2 deg)') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +function preprocessFunctionExpression(expression: string): string { + const trimmed = expression.trim() + const openIndex = trimmed.indexOf('(') + const closeIndex = trimmed.endsWith(')') ? trimmed.length - 1 : -1 + + if (openIndex > 0 && closeIndex > openIndex) { + if (trimmed.toLowerCase().startsWith('root ')) { + const degree = trimmed.slice(5, openIndex).trim() + const value = trimmed.slice(openIndex + 1, closeIndex).trim() + if (degree && value) + return `root(${degree}, ${value})` + } + if (trimmed.toLowerCase().startsWith('log ')) { + const base = trimmed.slice(4, openIndex).trim() + const value = trimmed.slice(openIndex + 1, closeIndex).trim() + if (base && value) + return `log(${value}, ${base})` + } + } + + const unaryFunctionsPattern = MATH_UNARY_FUNCTIONS.join('|') + const unaryMatch = trimmed.match( + new RegExp(`^(${unaryFunctionsPattern})\\s+(.+)$`, 'i'), + ) + if (unaryMatch && !unaryMatch[2].trim().startsWith('(')) { + return `${unaryMatch[1]}(${unaryMatch[2].trim()})` + } + + return expression +} + +export const functionSyntaxRule: RewriteRule = { + id: 'function-syntax', + category: 'semantic-rewrite', + priority: 400, + apply: (ctx) => { + let line: string + const assignmentIndex = ctx.line.indexOf('=') + if (assignmentIndex > 0) { + const left = ctx.line.slice(0, assignmentIndex).trim() + const right = ctx.line.slice(assignmentIndex + 1).trim() + if (/^[a-z_]\w*$/i.test(left) && right) { + line = `${ctx.line.slice(0, assignmentIndex + 1)} ${preprocessFunctionExpression(right)}` + } + else { + line = preprocessFunctionExpression(ctx.line) + } + } + else { + line = preprocessFunctionExpression(ctx.line) + } + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const functionConversionsRule: RewriteRule = { + id: 'function-conversions', + category: 'semantic-rewrite', + priority: 500, + apply: (ctx) => { + const unaryFunctionsPattern = MATH_UNARY_FUNCTIONS.join('|') + const line = ctx.line.replace( + new RegExp( + `(^|[=,+\\-*/(]\\s*)(${unaryFunctionsPattern})\\(([^()]+?)\\s+(?:in|to|as|into)\\s+([a-z][a-z0-9^]*)\\)`, + 'gi', + ), + (_, prefix: string, fn: string, source: string, target: string) => { + return `${prefix}${fn}(unitValue(to(${source.trim()}, ${target.trim()})))` + }, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const implicitMultiplicationRule: RewriteRule = { + id: 'implicit-multiplication', + category: 'semantic-rewrite', + priority: 600, + apply: (ctx) => { + const line = ctx.line.replace(/\b(\d+)\s*\(/g, '$1 * (') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const conditionalsRule: RewriteRule = { + id: 'conditionals', + category: 'semantic-rewrite', + priority: 700, + apply: (ctx) => { + const lower = ctx.line.toLowerCase() + + const thenIdx = lower.indexOf(' then ') + const elseIdx = lower.indexOf(' else ') + if (/^if\s/i.test(ctx.line) && thenIdx > 2 && elseIdx > thenIdx) { + const cond = ctx.line.slice(3, thenIdx).trim() + const thenExpr = ctx.line.slice(thenIdx + 6, elseIdx).trim() + const elseExpr = ctx.line.slice(elseIdx + 6).trim() + return { + line: `(${cond}) ? (${thenExpr}) : (${elseExpr})`, + changed: true, + } + } + + const unlessIdx = lower.indexOf(' unless ') + if (unlessIdx > 0) { + const expr = ctx.line.slice(0, unlessIdx).trim() + const cond = ctx.line.slice(unlessIdx + 8).trim() + return { line: `(${cond}) ? 0 : (${expr})`, changed: true } + } + + const ifIdx = lower.indexOf(' if ') + if (ifIdx > 0) { + const expr = ctx.line.slice(0, ifIdx).trim() + const cond = ctx.line.slice(ifIdx + 4).trim() + return { line: `(${cond}) ? (${expr}) : 0`, changed: true } + } + + const line = ctx.line.replace(/&&/g, ' and ').replace(/\|\|/g, ' or ') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const wordOperatorsRule: RewriteRule = { + id: 'word-operators', + category: 'semantic-rewrite', + priority: 800, + apply: (ctx) => { + const hasConditional = /\?|[><=!]=?|\btrue\b|\bfalse\b/.test(ctx.line) + + let line = ctx.line + .replace(/\bremainder\s+of\s+(\S+)\s+divided\s+by\s+(\S+)/gi, '$1 % $2') + .replace(/\bto\s+the\s+power\s+of\b/gi, '^') + .replace(/\bmultiplied\s+by\b/gi, '*') + .replace(/\bdivided\s+by\b/gi, '/') + .replace(/\bdivide\s+by\b/gi, '/') + .replace(/(\S+)\s+xor\s+(\S+)/gi, 'bitXor($1, $2)') + .replace(/\btimes\b/gi, '*') + .replace(/\bdivide\b/gi, '/') + .replace(/\bplus\b/gi, '+') + .replace(/\bwith\b/gi, '+') + .replace(/\bminus\b/gi, '-') + .replace(/\bsubtract\b/gi, '-') + .replace(/\bwithout\b/gi, '-') + .replace(/\bmul\b/gi, '*') + .replace(/\bmod\b/gi, '%') + + if (!hasConditional) { + line = line.replace(/\band\b/gi, '+') + } + + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const semanticRewriteRules: RewriteRule[] = [ + ratesRule, + multipliersRule, + phraseFunctionsRule, + functionSyntaxRule, + functionConversionsRule, + implicitMultiplicationRule, + conditionalsRule, + wordOperatorsRule, +] diff --git a/src/renderer/composables/math-notebook/math-engine/rules/syntaxRewrite.ts b/src/renderer/composables/math-notebook/math-engine/rules/syntaxRewrite.ts new file mode 100644 index 00000000..2a2bd3ac --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/rules/syntaxRewrite.ts @@ -0,0 +1,186 @@ +import type { RewriteRule } from '../types' +import { + currencySymbols, + currencyWordNames, + knownUnitTokens, + weightContextPattern, +} from '../constants' + +export const currencySymbolsRule: RewriteRule = { + id: 'currency-symbols', + category: 'syntax-rewrite', + priority: 100, + apply: (ctx) => { + let line = ctx.line.replace( + /R\$\s*(\d+(?:\.\d+)?(?:\s*[kKM])?)\b/g, + '$1 BRL', + ) + + for (const [symbol, code] of Object.entries(currencySymbols)) { + if (symbol === 'R$') + continue + const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + line = line.replace( + new RegExp(`${escaped}\\s*(\\d+(?:\\.\\d+)?(?:\\s*(?:k|M))?)\\b`, 'g'), + `$1 ${code}`, + ) + } + + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const currencyWordsRule: RewriteRule = { + id: 'currency-words', + category: 'syntax-rewrite', + priority: 200, + apply: (ctx) => { + const line = ctx.line.replace( + /(\d+(?:\.\d+)?)\s+(dollars?|euros?|pounds?|roubles?|rubles?|yen|yuan|rupees?|reais|real|pesos?)\b/gi, + ( + match, + amount: string, + currencyName: string, + _offset: number, + fullLine: string, + ) => { + const lower = currencyName.toLowerCase() + if ( + (lower === 'pound' || lower === 'pounds') + && weightContextPattern.test(fullLine) + ) { + return match + } + const code = currencyWordNames[lower] + return code ? `${amount} ${code}` : match + }, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const scalesRule: RewriteRule = { + id: 'scales', + category: 'syntax-rewrite', + priority: 300, + apply: (ctx) => { + const line = ctx.line + .replace(/(\d+(?:\.\d+)?)\s*k\b/g, '($1 * 1000)') + .replace(/(\d+(?:\.\d+)?)\s*M\b/g, '($1 * 1000000)') + .replace(/(\d+(?:\.\d+)?)\s+billion\b/gi, '($1 * 1000000000)') + .replace(/(\d+(?:\.\d+)?)\s+million\b/gi, '($1 * 1000000)') + .replace(/(\d+(?:\.\d+)?)\s+thousand\b/gi, '($1 * 1000)') + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +function normalizePowerUnit(unit: string) { + switch (unit.toLowerCase()) { + case 'inches': + case 'inch': + return 'inch' + case 'feet': + case 'foot': + case 'ft': + return 'ft' + case 'meters': + case 'meter': + case 'm': + return 'm' + case 'centimeter': + case 'centimeters': + case 'cm': + return 'cm' + case 'millimeter': + case 'millimeters': + case 'mm': + return 'mm' + case 'kilometer': + case 'kilometers': + case 'km': + return 'km' + case 'yards': + case 'yard': + return 'yard' + case 'miles': + case 'mile': + return 'mile' + default: + return unit.toLowerCase() + } +} + +export const areaVolumeAliasesRule: RewriteRule = { + id: 'area-volume-aliases', + category: 'syntax-rewrite', + priority: 400, + apply: (ctx) => { + let line = ctx.line.replace(/\bsqm\b/gi, 'm^2').replace(/\bcbm\b/gi, 'm^3') + + line = line.replace( + /(\d+(?:\.\d+)?)\s+(sq|square)\s+([a-z]+)/gi, + (_, value: string, _p: string, unit: string) => + `${value} ${normalizePowerUnit(unit)}^2`, + ) + line = line.replace( + /(\b(?:in|to|as|into)\s+)(sq|square)\s+([a-z]+)/gi, + (_, prefix: string, _k: string, unit: string) => + `${prefix}${normalizePowerUnit(unit)}^2`, + ) + line = line.replace( + /(\d+(?:\.\d+)?)\s+(cu|cubic|cb)\s+([a-z]+)/gi, + (_, value: string, _p: string, unit: string) => + `${value} ${normalizePowerUnit(unit)}^3`, + ) + line = line.replace( + /(\b(?:in|to|as|into)\s+)(cu|cubic|cb)\s+([a-z]+)/gi, + (_, prefix: string, _k: string, unit: string) => + `${prefix}${normalizePowerUnit(unit)}^3`, + ) + + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const stackedUnitsRule: RewriteRule = { + id: 'stacked-units', + category: 'syntax-rewrite', + priority: 500, + apply: (ctx) => { + const line = ctx.line.replace( + /-?\d+(?:\.\d+)?\s+[a-z]+(?:\s+-?\d+(?:\.\d+)?\s+[a-z]+)+/gi, + (match) => { + const tokens = match.trim().split(/\s+/) + const pairs: string[] = [] + for (let i = 0; i < tokens.length; i += 2) { + if (!/^-?\d+(?:\.\d+)?$/.test(tokens[i])) + return match + const unit = tokens[i + 1]?.toLowerCase() + if (!unit || !knownUnitTokens.has(unit)) + return match + pairs.push(`${tokens[i]} ${tokens[i + 1]}`) + } + return pairs.length >= 2 ? `(${pairs.join(' + ')})` : match + }, + ) + if (line === ctx.line) + return null + return { line, changed: true } + }, +} + +export const syntaxRewriteRules: RewriteRule[] = [ + currencySymbolsRule, + currencyWordsRule, + scalesRule, + areaVolumeAliasesRule, + stackedUnitsRule, +] From 88fbc9d85628d538e11bf42b5444775353fe25c8 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:13:34 +0300 Subject: [PATCH 06/12] refactor(math): add rewrite engine with parity tests --- .../math-engine/__tests__/rewrite.test.ts | 100 ++++++++++++++++++ .../math-engine/pipeline/rewrite.ts | 59 +++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/rewrite.ts diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts new file mode 100644 index 00000000..0ac845c1 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { analysisNormalize } from '../pipeline/analysisNormalize' +import { classify } from '../pipeline/classify' +import { rewrite } from '../pipeline/rewrite' +import { preprocessMathExpression } from '../preprocess' + +function rewriteRaw(raw: string): string { + const view = analysisNormalize(raw) + const classification = classify(view) + return rewrite(view, classification) +} + +function oldPreprocess(raw: string): string { + return preprocessMathExpression(raw) +} + +describe('rewrite parity with preprocessMathExpression', () => { + const cases = [ + // Basic arithmetic (no transforms) + '10 + 5', + '2 * 3', + // Grouped numbers + '5 300', + // Degree signs + '45°', + // Scales + '2k', + '3M', + '1.5 billion', + // Currency symbols + '$100', + '€50', + // Currency words + '10 dollars', + // Word operators + '8 times 9', + '10 plus 5', + '3 multiplied by 4', + '100 divide by 4', + '17 mod 5', + // Unit aliases + '1 nautical mile', + // Time units + '3 days', + '2 hours', + // Stacked units + '1 meter 20 cm', + // Area/volume + '20 sq cm', + 'cbm', + // Percentages + '15% of 200', + '200 + 10%', + '5% on 200', + '50 as a % of 100', + // Conversions + '5 km as mile', + '100 celsius into fahrenheit', + // Functions + 'sqrt 16', + 'log 2 (8)', + // Phrase functions + 'square root of 81', + 'log 20 base 4', + // Conditionals + 'if 5 > 3 then 10 else 20', + '42 if 5 > 3', + // Labels + 'Price: $100 + $50', + // Quoted text + '$275 for the "Model 227"', + // Reverse conversion + 'meters in 10 km', + // Shorthand + 'km m', + // Rates + '$50 per week', + // Multipliers + '50 to 75 is what x', + // Implicit multiplication + '6(3)', + // Percentage change + '50 to 75 is what %', + '0.35 as %', + // Additive unit inference + '$100 + 10', + '10 USD + 1 in RUB', + // Power phrase + '3 to the power of 2', + 'remainder of 21 divided by 5', + // Fractions + '2/3 of 600', + ] + + for (const input of cases) { + it(`"${input}"`, () => { + expect(rewriteRaw(input)).toBe(oldPreprocess(input)) + }) + } +}) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/rewrite.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/rewrite.ts new file mode 100644 index 00000000..e8e5c110 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/rewrite.ts @@ -0,0 +1,59 @@ +import type { + AnalysisView, + LineClassification, + RewriteContext, + RewritePhase, + RewriteRule, +} from '../types' +import { finalizeRules } from '../rules/finalize' +import { normalizeRules } from '../rules/normalize' +import { semanticRewriteRules } from '../rules/semanticRewrite' +import { syntaxRewriteRules } from '../rules/syntaxRewrite' + +const PHASE_ORDER: RewritePhase[] = [ + 'normalize', + 'syntax-rewrite', + 'semantic-rewrite', + 'finalize', +] + +const allRules: RewriteRule[] = [ + ...normalizeRules, + ...syntaxRewriteRules, + ...semanticRewriteRules, + ...finalizeRules, +] + +const rulesByPhase = new Map() +for (const phase of PHASE_ORDER) { + rulesByPhase.set( + phase, + allRules + .filter(r => r.category === phase) + .sort((a, b) => a.priority - b.priority), + ) +} + +export function rewrite( + view: AnalysisView, + classification: LineClassification, +): string { + const ctx: RewriteContext = { + raw: view.raw, + view, + line: view.expression, + classification, + } + + for (const phase of PHASE_ORDER) { + const rules = rulesByPhase.get(phase) || [] + for (const rule of rules) { + const result = rule.apply(ctx) + if (result) { + ctx.line = result.line + } + } + } + + return ctx.line +} From 4a07f657dd4cb476521c10cb8e3530faf671b25b Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:14:42 +0300 Subject: [PATCH 07/12] refactor(math): extract evaluator modules (aggregates, dateArithmetic) --- .../math-engine/evaluators/aggregates.ts | 83 +++++++++ .../math-engine/evaluators/dateArithmetic.ts | 163 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/evaluators/aggregates.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/evaluators/dateArithmetic.ts diff --git a/src/renderer/composables/math-notebook/math-engine/evaluators/aggregates.ts b/src/renderer/composables/math-notebook/math-engine/evaluators/aggregates.ts new file mode 100644 index 00000000..624807ad --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/evaluators/aggregates.ts @@ -0,0 +1,83 @@ +export function evaluateBlockAggregate( + keyword: string, + numericBlock: number[], +): { value: number } | null { + switch (keyword) { + case 'sum': + case 'total': + return { value: numericBlock.reduce((sum, v) => sum + v, 0) } + + case 'average': + case 'avg': + return { + value: + numericBlock.length > 0 + ? numericBlock.reduce((sum, v) => sum + v, 0) / numericBlock.length + : 0, + } + + case 'median': + if (numericBlock.length === 0) + return { value: 0 } + { + const sorted = [...numericBlock].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return { + value: + sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2, + } + } + + case 'count': + return { value: numericBlock.length } + + default: + return null + } +} + +export function evaluateInlineAggregate( + line: string, +): { value: number } | null { + const match = line.match(/^(total|sum|average|avg|median|count)\s+of\s+/i) + if (!match) + return null + + const fn = match[1].toLowerCase() + const listStr = line.slice(match[0].length) + const items = listStr + .replace(/\band\b/gi, ',') + .split(',') + .map(s => s.trim()) + .filter(Boolean) + .map(Number) + .filter(n => !Number.isNaN(n)) + + if (items.length === 0) + return null + + switch (fn) { + case 'total': + case 'sum': + return { value: items.reduce((a, b) => a + b, 0) } + case 'average': + case 'avg': + return { value: items.reduce((a, b) => a + b, 0) / items.length } + case 'median': { + const sorted = [...items].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return { + value: + sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2, + } + } + case 'count': + return { value: items.length } + default: + return null + } +} diff --git a/src/renderer/composables/math-notebook/math-engine/evaluators/dateArithmetic.ts b/src/renderer/composables/math-notebook/math-engine/evaluators/dateArithmetic.ts new file mode 100644 index 00000000..d58a06fd --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/evaluators/dateArithmetic.ts @@ -0,0 +1,163 @@ +import type { SpecialLineResult } from '../types' +import { + evaluateTimeZoneLine, + parseExplicitLocalTemporalExpression, +} from '../timeZones' +import { splitTopLevelAddSub } from '../utils' + +interface DateArithmeticDeps { + mathEvaluate: (expression: string, scope: Record) => any + formatResult: (result: any) => any +} + +function evaluateDateLikeExpression( + expression: string, + now: Date, + scope: Record, + deps: DateArithmeticDeps, +) { + const timeZoneResult = evaluateTimeZoneLine(expression, now) + if (timeZoneResult?.rawResult instanceof Date) { + return timeZoneResult.rawResult + } + + const localTemporalResult = parseExplicitLocalTemporalExpression( + expression, + now, + ) + if (localTemporalResult) { + return localTemporalResult.date + } + + try { + const result = deps.mathEvaluate(expression, scope) + return result instanceof Date ? result : null + } + catch { + return null + } +} + +function evaluateDurationMilliseconds( + expression: string, + scope: Record, + deps: DateArithmeticDeps, +) { + try { + const result = deps.mathEvaluate(expression, scope) + if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + const milliseconds = result.toNumber('ms') + return Number.isFinite(milliseconds) ? milliseconds : null + } + } + catch { + return null + } + return null +} + +export function evaluateDateArithmeticLine( + line: string, + now: Date, + scope: Record, + deps: DateArithmeticDeps, +): SpecialLineResult | null { + const split = splitTopLevelAddSub(line) + if (!split) + return null + + const initialDate = evaluateDateLikeExpression( + split.terms[0], + now, + scope, + deps, + ) + const initialDuration = initialDate + ? null + : evaluateDurationMilliseconds(split.terms[0], scope, deps) + + if (!initialDate && initialDuration === null) + return null + + let currentDate = initialDate ? new Date(initialDate.getTime()) : null + let currentDuration = initialDuration + + for (let index = 0; index < split.operators.length; index++) { + const operator = split.operators[index] + const term = split.terms[index + 1] + const nextDate = evaluateDateLikeExpression(term, now, scope, deps) + const nextDuration = nextDate + ? null + : evaluateDurationMilliseconds(term, scope, deps) + + if (currentDate) { + if (nextDuration === null) + return null + currentDate = new Date( + currentDate.getTime() + + (operator === '+' ? nextDuration : -nextDuration), + ) + continue + } + + if (nextDate) { + if (operator !== '+' || currentDuration === null) + return null + currentDate = new Date(nextDate.getTime() + currentDuration) + currentDuration = null + continue + } + + if (currentDuration === null || nextDuration === null) + return null + currentDuration + = operator === '+' + ? currentDuration + nextDuration + : currentDuration - nextDuration + } + + if (!currentDate) + return null + + return { + lineResult: deps.formatResult(currentDate), + rawResult: currentDate, + } +} + +export function evaluateDateAssignmentLine( + line: string, + now: Date, + scope: Record, + deps: DateArithmeticDeps, +): SpecialLineResult | null { + const assignmentIndex = line.indexOf('=') + if (assignmentIndex <= 0) + return null + + const variableName = line.slice(0, assignmentIndex).trim() + if (!/^[a-z_]\w*$/i.test(variableName)) + return null + + const expression = line.slice(assignmentIndex + 1).trim() + if (!expression) + return null + + const dateValue = evaluateDateLikeExpression(expression, now, scope, deps) + if (!dateValue) + return null + + scope[variableName] = dateValue + + return { + lineResult: { + ...deps.formatResult(dateValue), + type: 'assignment', + }, + rawResult: dateValue, + } +} From d7f8da48dc523498335a74c33c9496d4422a2ae5 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:23:56 +0300 Subject: [PATCH 08/12] refactor(math): replace eval loop with pipeline architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analysisNormalize → classify → early return → rewrite → evaluate → format - Speculative handlers for timezone, calendar, CSS, date arithmetic - Modifier early path (rounding, strip-unit, format) before speculative checks - useMathEngine.ts reduced from 1127 to ~480 lines --- .../math-engine/pipeline/classify.ts | 5 + .../math-notebook/useMathEngine.ts | 1038 +++++------------ 2 files changed, 302 insertions(+), 741 deletions(-) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts index 9f781354..027b1cf4 100644 --- a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts @@ -114,6 +114,9 @@ const STRIP_SUFFIXES: Record = { const MULTIPLIER_SUFFIXES = ['as multiplier', 'to multiplier'] +const MULTIPLIER_PHRASE_RE + = /\bas\s+x\s+(?:of|off)\b|\bas\s+multiplier\s+(?:of|on)\b|\bis\s+what\s+x\b|\bas\s+x\b/ + function detectResultFormat(normalized: string): ResultFormat | undefined { for (const [suffix, format] of Object.entries(FORMAT_SUFFIXES)) { if (normalized.endsWith(suffix)) @@ -121,6 +124,8 @@ function detectResultFormat(normalized: string): ResultFormat | undefined { } if (MULTIPLIER_SUFFIXES.some(s => normalized.endsWith(s))) return 'multiplier' + if (MULTIPLIER_PHRASE_RE.test(normalized)) + return 'multiplier' return undefined } diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index ce8e98ce..d932b194 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -1,8 +1,8 @@ import type { CssContext, CurrencyServiceState, + LineClassification, LineResult, - SpecialLineResult, } from './math-engine/types' import { evaluateCalendarLine } from './math-engine/calendar' import { @@ -12,141 +12,28 @@ import { SUPPORTED_CURRENCY_CODES, } from './math-engine/constants' import { evaluateCssLine } from './math-engine/css' +import { + evaluateBlockAggregate, + evaluateInlineAggregate, +} from './math-engine/evaluators/aggregates' +import { + evaluateDateArithmeticLine, + evaluateDateAssignmentLine, +} from './math-engine/evaluators/dateArithmetic' import { formatMathDate, formatMathNumber } from './math-engine/format' import { createMathInstance } from './math-engine/mathInstance' -import { - hasCurrencyExpression, - preprocessMathExpression, -} from './math-engine/preprocess' +import { analysisNormalize } from './math-engine/pipeline/analysisNormalize' +import { classify } from './math-engine/pipeline/classify' +import { rewrite } from './math-engine/pipeline/rewrite' import { evaluateTimeZoneDifferenceLine, evaluateTimeZoneLine, parseExplicitLocalTemporalExpression, } from './math-engine/timeZones' +import { coerceToNumber, toFraction } from './math-engine/utils' export type { LineResult } from './math-engine/types' -interface FormatDirective { - format: - | 'hex' - | 'bin' - | 'oct' - | 'sci' - | 'number' - | 'dec' - | 'fraction' - | 'multiplier' - | null - expression: string -} - -interface RoundingDirective { - type: - | 'dp' - | 'round' - | 'ceil' - | 'floor' - | 'nearest' - | 'nearestCeil' - | 'nearestFloor' - | null - param: number - expression: string -} - -const NEAREST_WORDS: Record = { - ten: 10, - hundred: 100, - thousand: 1000, - million: 1000000, -} - -function detectRoundingDirective(line: string): RoundingDirective { - const lower = line.toLowerCase() - let m: RegExpMatchArray | null - - // "to N dp" / "to N digits" - m = lower.match(/\s+to\s+(\d+)\s+(?:dp|digits?)$/) - if (m) { - return { - type: 'dp', - param: Number(m[1]), - expression: line.slice(0, m.index!).trim(), - } - } - - // "rounded up to nearest X" / "rounded down to nearest X" - m = lower.match(/\s+rounded\s+(up|down)\s+to\s+nearest\s+(\w+)$/) - if (m) { - const n = NEAREST_WORDS[m[2]] || Number(m[2]) - if (n > 0) { - return { - type: m[1] === 'up' ? 'nearestCeil' : 'nearestFloor', - param: n, - expression: line.slice(0, m.index!).trim(), - } - } - } - - // "rounded to nearest X" / "to nearest X" - m = lower.match(/\s+(?:rounded\s+)?to\s+nearest\s+(\w+)$/) - if (m) { - const n = NEAREST_WORDS[m[1]] || Number(m[1]) - if (n > 0) { - return { - type: 'nearest', - param: n, - expression: line.slice(0, m.index!).trim(), - } - } - } - - // "rounded up" / "rounded down" - m = lower.match(/\s+rounded\s+(up|down)$/) - if (m) { - return { - type: m[1] === 'up' ? 'ceil' : 'floor', - param: 0, - expression: line.slice(0, m.index!).trim(), - } - } - - // "rounded" - m = lower.match(/\s+rounded$/) - if (m) { - return { - type: 'round', - param: 0, - expression: line.slice(0, m.index!).trim(), - } - } - - return { type: null, param: 0, expression: line } -} - -function applyRounding(value: number, directive: RoundingDirective): number { - switch (directive.type) { - case 'dp': { - const factor = 10 ** directive.param - return Math.round(value * factor) / factor - } - case 'round': - return Math.round(value) - case 'ceil': - return Math.ceil(value) - case 'floor': - return Math.floor(value) - case 'nearest': - return Math.round(value / directive.param) * directive.param - case 'nearestCeil': - return Math.ceil(value / directive.param) * directive.param - case 'nearestFloor': - return Math.floor(value / directive.param) * directive.param - default: - return value - } -} - let activeCurrencyRates: Record = {} let currencyServiceState: CurrencyServiceState = 'loading' let currencyUnavailableMessage = '' @@ -155,233 +42,7 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 -const STRIP_UNIT_SUFFIXES: Record< - string, - 'number' | 'dec' | 'fraction' | 'multiplier' -> = { - 'as number': 'number', - 'to number': 'number', - 'as decimal': 'dec', - 'to decimal': 'dec', - 'as dec': 'dec', - 'to dec': 'dec', - 'as fraction': 'fraction', - 'to fraction': 'fraction', - 'as multiplier': 'multiplier', - 'to multiplier': 'multiplier', -} - -function gcd(a: number, b: number): number { - a = Math.abs(Math.round(a)) - b = Math.abs(Math.round(b)) - while (b) { - const t = b - b = a % b - a = t - } - return a -} - -function toFraction(decimal: number): string { - if (Number.isInteger(decimal)) - return `${decimal}/1` - const sign = decimal < 0 ? '-' : '' - const abs = Math.abs(decimal) - const precision = 1e10 - const numerator = Math.round(abs * precision) - const denominator = precision - const divisor = gcd(numerator, denominator) - return `${sign}${numerator / divisor}/${denominator / divisor}` -} - -function detectStripUnitDirective(line: string): FormatDirective { - const lower = line.toLowerCase() - for (const [suffix, format] of Object.entries(STRIP_UNIT_SUFFIXES)) { - if (lower.endsWith(suffix)) { - return { - format, - expression: line.slice(0, line.length - suffix.length).trim(), - } - } - } - return { format: null, expression: line } -} - -function detectFormatDirective(line: string): FormatDirective { - const formatMap: Record< - string, - 'hex' | 'bin' | 'oct' | 'sci' | 'multiplier' - > = { - 'in hex': 'hex', - 'in bin': 'bin', - 'in oct': 'oct', - 'in sci': 'sci', - 'in scientific': 'sci', - 'to multiplier': 'multiplier', - } - const lower = line.toLowerCase() - - for (const [suffix, format] of Object.entries(formatMap)) { - if (lower.endsWith(suffix)) { - return { - format, - expression: line.slice(0, line.length - suffix.length).trim(), - } - } - } - - return { format: null, expression: line } -} - -function applyFormat( - result: any, - format: NonNullable, -): LineResult { - if (format === 'multiplier') { - let num: number - if (typeof result === 'number') { - num = result - } - else if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ) { - try { - num = result.toNumber() - } - catch { - num = Number.NaN - } - } - else { - num = Number(result) - } - - if (Number.isNaN(num)) { - return { value: String(result), error: null, type: 'number' } - } - - return { - value: `${formatMathNumber(num, activeLocale, activeDecimalPlaces)}x`, - error: null, - type: 'number', - numericValue: num, - } - } - - if (format === 'fraction') { - let num: number - if (typeof result === 'number') { - num = result - } - else if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ) { - try { - num = result.toNumber() - } - catch { - num = Number.NaN - } - } - else { - num = Number(result) - } - - if (Number.isNaN(num)) { - return { value: String(result), error: null, type: 'number' } - } - - return { - value: toFraction(num), - error: null, - type: 'number', - numericValue: num, - } - } - - if (format === 'number' || format === 'dec') { - let num: number - if (typeof result === 'number') { - num = result - } - else if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ) { - try { - num = result.toNumber() - } - catch { - num = Number.NaN - } - } - else { - num = Number(result) - } - - if (Number.isNaN(num)) { - return { value: String(result), error: null, type: 'number' } - } - - return { - value: formatMathNumber(num, activeLocale, activeDecimalPlaces), - error: null, - type: 'number', - numericValue: num, - } - } - - const num - = typeof result === 'number' - ? result - : result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ? result.toNumber() - : Number(result) - - if (Number.isNaN(num)) { - return { value: String(result), error: null, type: 'number' } - } - - const intValue = Math.round(num) - - switch (format) { - case 'hex': - return { - value: `0x${intValue.toString(16).toUpperCase()}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'bin': - return { - value: `0b${intValue.toString(2)}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'oct': - return { - value: `0o${intValue.toString(8)}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'sci': - return { - value: num.toExponential(), - error: null, - type: 'number', - numericValue: num, - } - } -} +// --- Formatting helpers (depend on module-level math instance and locale) --- function humanizeUnitToken(unitId: string) { const displayUnit = HUMANIZED_UNIT_NAMES[unitId] @@ -414,12 +75,9 @@ function humanizeFormattedUnits(value: string) { }, ) - // Humanize remaining standalone unit tokens (e.g. in compound units like "USD mcday") result = result.replace( /\b(mc(?:second|minute|hour|day|week|month|year))\b/g, - (_, unitId: string) => { - return humanizeUnitToken(unitId) - }, + (_, unitId: string) => humanizeUnitToken(unitId), ) return result @@ -448,7 +106,7 @@ function formatResult(result: any): LineResult { && typeof result.toNumber === 'function' && result.units ) { - // Detect compound currency*time units (implicit rate result) and simplify + // Implicit rate simplification: currency * time → currency if (Array.isArray(result.units) && result.units.length === 2) { const units = result.units as Array<{ unit: { name: string, value: number, base?: { key?: string } } @@ -484,7 +142,7 @@ function formatResult(result: any): LineResult { } } catch { - // Fall through to default formatting + /* fall through */ } } } @@ -499,19 +157,11 @@ function formatResult(result: any): LineResult { } if (typeof result === 'number') { - if (!Number.isFinite(result)) { - return { - value: formatMathNumber(result, activeLocale, activeDecimalPlaces), - error: null, - type: 'number', - } - } - return { value: formatMathNumber(result, activeLocale, activeDecimalPlaces), error: null, type: 'number', - numericValue: result, + ...(Number.isFinite(result) ? { numericValue: result } : {}), } } @@ -535,7 +185,6 @@ function formatResult(result: any): LineResult { function getNumericValue(result: any): number | null { if (typeof result === 'number') return result - if ( result && typeof result === 'object' @@ -548,227 +197,173 @@ function getNumericValue(result: any): number | null { return null } } - return null } -function splitTopLevelAddSub(expression: string) { - const terms: string[] = [] - const operators: Array<'+' | '-'> = [] - let depth = 0 - let segmentStart = 0 - - for (let index = 0; index < expression.length; index++) { - const char = expression[index] +// --- Modifier application --- - if (char === '(') { - depth += 1 - continue - } - - if (char === ')') { - depth = Math.max(0, depth - 1) - continue - } - - if (depth !== 0 || (char !== '+' && char !== '-')) { - continue - } - - let prevIndex = index - 1 - while (prevIndex >= 0 && /\s/.test(expression[prevIndex])) { - prevIndex -= 1 - } - - let nextIndex = index + 1 - while (nextIndex < expression.length && /\s/.test(expression[nextIndex])) { - nextIndex += 1 - } - - if (prevIndex < 0 || nextIndex >= expression.length) { - continue - } - - if ('+-*/%^,('.includes(expression[prevIndex])) { - continue +function applyRoundingModifier( + value: number, + rounding: NonNullable, +): number { + switch (rounding.type) { + case 'dp': { + const factor = 10 ** rounding.param + return Math.round(value * factor) / factor } - - terms.push(expression.slice(segmentStart, index).trim()) - operators.push(char) - segmentStart = index + 1 - } - - if (operators.length === 0) { - return null - } - - terms.push(expression.slice(segmentStart).trim()) - - if (terms.some(term => !term)) { - return null + case 'round': + return Math.round(value) + case 'ceil': + return Math.ceil(value) + case 'floor': + return Math.floor(value) + case 'nearest': + return Math.round(value / rounding.param) * rounding.param + case 'nearestCeil': + return Math.ceil(value / rounding.param) * rounding.param + case 'nearestFloor': + return Math.floor(value / rounding.param) * rounding.param + default: + return value } - - return { terms, operators } } -function evaluateDateLikeExpression( - expression: string, - now: Date, - scope: Record, -) { - const timeZoneResult = evaluateTimeZoneLine(expression, now) - if (timeZoneResult?.rawResult instanceof Date) { - return timeZoneResult.rawResult - } - - const localTemporalResult = parseExplicitLocalTemporalExpression( - expression, - now, - ) - if (localTemporalResult) { - return localTemporalResult.date - } - - try { - const result = math.evaluate(expression, scope) - return result instanceof Date ? result : null - } - catch { - return null - } -} +function applyResultFormat( + result: any, + format: NonNullable, +): LineResult { + const num = coerceToNumber(result) + if (Number.isNaN(num)) + return { value: String(result), error: null, type: 'number' } -function evaluateDurationMilliseconds( - expression: string, - scope: Record, -) { - try { - const result = math.evaluate(expression, scope) - if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ) { - const milliseconds = result.toNumber('ms') - return Number.isFinite(milliseconds) ? milliseconds : null + if (format === 'multiplier') { + return { + value: `${formatMathNumber(num, activeLocale, activeDecimalPlaces)}x`, + error: null, + type: 'number', + numericValue: num, } } - catch { - return null - } - - return null -} - -function evaluateDateArithmeticLine( - line: string, - now: Date, - scope: Record, -): SpecialLineResult | null { - const split = splitTopLevelAddSub(line) - if (!split) { - return null - } - - const initialDate = evaluateDateLikeExpression(split.terms[0], now, scope) - const initialDuration = initialDate - ? null - : evaluateDurationMilliseconds(split.terms[0], scope) - - if (!initialDate && initialDuration === null) { - return null - } - let currentDate = initialDate ? new Date(initialDate.getTime()) : null - let currentDuration = initialDuration - - for (let index = 0; index < split.operators.length; index++) { - const operator = split.operators[index] - const term = split.terms[index + 1] - const nextDate = evaluateDateLikeExpression(term, now, scope) - const nextDuration = nextDate - ? null - : evaluateDurationMilliseconds(term, scope) - - if (currentDate) { - if (nextDuration === null) { - return null + const intValue = Math.round(num) + switch (format) { + case 'hex': + return { + value: `0x${intValue.toString(16).toUpperCase()}`, + error: null, + type: 'number', + numericValue: intValue, } - - currentDate = new Date( - currentDate.getTime() - + (operator === '+' ? nextDuration : -nextDuration), - ) - continue - } - - if (nextDate) { - if (operator !== '+' || currentDuration === null) { - return null + case 'bin': + return { + value: `0b${intValue.toString(2)}`, + error: null, + type: 'number', + numericValue: intValue, + } + case 'oct': + return { + value: `0o${intValue.toString(8)}`, + error: null, + type: 'number', + numericValue: intValue, } + case 'sci': + return { + value: num.toExponential(), + error: null, + type: 'number', + numericValue: num, + } + } +} - currentDate = new Date(nextDate.getTime() + currentDuration) - currentDuration = null - continue - } +function applyStripUnit( + result: any, + strip: NonNullable, +): LineResult { + const num = coerceToNumber(result) + if (Number.isNaN(num)) + return { value: String(result), error: null, type: 'number' } - if (currentDuration === null || nextDuration === null) { - return null + if (strip === 'fraction') { + return { + value: toFraction(num), + error: null, + type: 'number', + numericValue: num, } - - currentDuration - = operator === '+' - ? currentDuration + nextDuration - : currentDuration - nextDuration - } - - if (!currentDate) { - return null } return { - lineResult: formatResult(currentDate), - rawResult: currentDate, + value: formatMathNumber(num, activeLocale, activeDecimalPlaces), + error: null, + type: 'number', + numericValue: num, } } -function evaluateDateAssignmentLine( - line: string, - now: Date, - scope: Record, -): SpecialLineResult | null { - const assignmentIndex = line.indexOf('=') - if (assignmentIndex <= 0) { - return null - } - - const variableName = line.slice(0, assignmentIndex).trim() - if (!/^[a-z_]\w*$/i.test(variableName)) { - return null - } - - const expression = line.slice(assignmentIndex + 1).trim() - if (!expression) { - return null - } - - const dateValue = evaluateDateLikeExpression(expression, now, scope) - - if (!dateValue) { - return null - } - - scope[variableName] = dateValue - - return { - lineResult: { - ...formatResult(dateValue), - type: 'assignment', - }, - rawResult: dateValue, - } +// --- Strip modifier suffix from expression before eval --- + +function stripModifierSuffix( + raw: string, + classification: LineClassification, +): string { + const lower = raw.toLowerCase() + + if (classification.modifiers.rounding) { + const patterns = [ + /\s+to\s+\d+\s+(?:dp|digits?)$/i, + /\s+rounded\s+(?:up|down)\s+to\s+nearest\s+\w+$/i, + /\s+(?:rounded\s+)?to\s+nearest\s+\w+$/i, + /\s+rounded\s+(?:up|down)$/i, + /\s+rounded$/i, + ] + for (const p of patterns) { + const m = lower.match(p) + if (m) + return raw.slice(0, m.index!).trim() + } + } + + if (classification.modifiers.stripUnit) { + const suffixes = [ + 'as number', + 'to number', + 'as decimal', + 'to decimal', + 'as dec', + 'to dec', + 'as fraction', + 'to fraction', + ] + for (const s of suffixes) { + if (lower.endsWith(s)) + return raw.slice(0, raw.length - s.length).trim() + } + } + + if (classification.modifiers.resultFormat) { + const suffixes = [ + 'in hex', + 'in bin', + 'in oct', + 'in sci', + 'in scientific', + 'as multiplier', + 'to multiplier', + ] + for (const s of suffixes) { + if (lower.endsWith(s)) + return raw.slice(0, raw.length - s.length).trim() + } + } + + return raw } +// --- Main composable --- + export function useMathEngine() { function evaluateDocument(text: string): LineResult[] { const lines = text.split('\n') @@ -786,17 +381,30 @@ export function useMathEngine() { let prevResult: any let numericBlock: number[] = [] + const mathDeps = { + mathEvaluate: (expr: string, s: Record) => + math.evaluate(expr, s), + formatResult, + } + for (const line of lines) { const trimmed = line.trim() - if (!trimmed) { + // --- Stage 1: Analysis Normalize --- + const view = analysisNormalize(trimmed) + + // --- Stage 2: Classify --- + const classification = classify(view) + + // --- Early returns --- + if (classification.primary === 'empty') { results.push({ value: null, error: null, type: 'empty' }) prevResult = undefined numericBlock = [] continue } - if (trimmed.startsWith('//') || trimmed.startsWith('#')) { + if (classification.primary === 'comment') { results.push({ value: null, error: null, type: 'comment' }) continue } @@ -805,138 +413,36 @@ export function useMathEngine() { scope.prev = prevResult } - const lowerTrimmed = trimmed.toLowerCase() - if (lowerTrimmed === 'sum' || lowerTrimmed === 'total') { - const total = numericBlock.reduce((sum, value) => sum + value, 0) - const formatted = formatResult(total) - formatted.type = 'aggregate' - results.push(formatted) - prevResult = total - numericBlock.push(total) - continue - } - - if (lowerTrimmed === 'average' || lowerTrimmed === 'avg') { - const total = numericBlock.reduce((sum, value) => sum + value, 0) - const average - = numericBlock.length > 0 ? total / numericBlock.length : 0 - const formatted = formatResult(average) + // --- Aggregates (work on numericBlock, before rewrite) --- + if (classification.primary === 'aggregate-block') { + const keyword = view.normalized.trim() + const aggResult = evaluateBlockAggregate(keyword, numericBlock) + const value = aggResult?.value ?? 0 + const formatted = formatResult(value) formatted.type = 'aggregate' results.push(formatted) - prevResult = average - numericBlock.push(average) + prevResult = value + numericBlock.push(value) continue } - if (lowerTrimmed === 'median') { - if (numericBlock.length > 0) { - const sorted = [...numericBlock].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - const median - = sorted.length % 2 !== 0 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2 - const formatted = formatResult(median) + if (classification.primary === 'aggregate-inline') { + const aggResult = evaluateInlineAggregate(trimmed) + if (aggResult) { + const formatted = formatResult(aggResult.value) formatted.type = 'aggregate' results.push(formatted) - prevResult = median - numericBlock.push(median) - } - else { - results.push(formatResult(0)) - } - continue - } - - if (lowerTrimmed === 'count') { - const count = numericBlock.length - const formatted = formatResult(count) - formatted.type = 'aggregate' - results.push(formatted) - prevResult = count - numericBlock.push(count) - continue - } - - const inlineAggregateMatch = lowerTrimmed.match( - /^(total|sum|average|avg|median|count)\s+of\s+/i, - ) - if (inlineAggregateMatch) { - const fn = inlineAggregateMatch[1].toLowerCase() - const listStr = trimmed.slice(inlineAggregateMatch[0].length) - const items = listStr - .replace(/\band\b/gi, ',') - .split(',') - .map(s => s.trim()) - .filter(Boolean) - .map(Number) - .filter(n => !Number.isNaN(n)) - - if (items.length > 0) { - let value: number - if (fn === 'total' || fn === 'sum') { - value = items.reduce((a, b) => a + b, 0) - } - else if (fn === 'average' || fn === 'avg') { - value = items.reduce((a, b) => a + b, 0) / items.length - } - else if (fn === 'median') { - const sorted = [...items].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - value - = sorted.length % 2 !== 0 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2 - } - else { - value = items.length - } - const formatted = formatResult(value) - formatted.type = 'aggregate' - results.push(formatted) - prevResult = value - numericBlock.push(value) + prevResult = aggResult.value + numericBlock.push(aggResult.value) continue } } try { - const roundingDirective = detectRoundingDirective(trimmed) - if (roundingDirective.type) { - const roundProcessed = preprocessMathExpression( - roundingDirective.expression, - ) - const roundRaw = math.evaluate(roundProcessed, scope) - const num = getNumericValue(roundRaw) - if (num !== null) { - const rounded = applyRounding(num, roundingDirective) - const formatted = formatResult(rounded) - results.push(formatted) - prevResult = rounded - numericBlock.push(rounded) - continue - } - } - - const stripDirective = detectStripUnitDirective(trimmed) - if (stripDirective.format) { - const stripProcessed = preprocessMathExpression( - stripDirective.expression, - ) - const stripResult = math.evaluate(stripProcessed, scope) - const formatted = applyFormat(stripResult, stripDirective.format) - results.push(formatted) - prevResult = stripResult - const numericValue = getNumericValue(stripResult) - if (numericValue !== null) { - numericBlock.push(numericValue) - } - continue - } - + // --- Currency service gate --- if ( currencyServiceState !== 'ready' - && hasCurrencyExpression(trimmed) + && classification.features.hasCurrency ) { results.push( currencyServiceState === 'loading' @@ -952,7 +458,67 @@ export function useMathEngine() { continue } - const timeZoneDifferenceResult = evaluateTimeZoneDifferenceLine( + // --- Strip modifier suffix from trimmed for speculative handlers --- + const effectiveTrimmed = stripModifierSuffix(trimmed, classification) + + // --- Rounding/strip/format early path --- + // If modifiers detected, process expression with stripped suffix first + if ( + classification.modifiers.rounding + || classification.modifiers.stripUnit + || classification.modifiers.resultFormat + ) { + const strippedView = analysisNormalize(effectiveTrimmed) + let processed = rewrite(strippedView, classification) + // Strip rewrite-generated suffixes (e.g. multiplier phrases produce "X to multiplier") + const processedLower = processed.toLowerCase() + if (processedLower.endsWith('to multiplier')) { + processed = processed + .slice(0, processed.length - 'to multiplier'.length) + .trim() + } + const result = math.evaluate(processed, scope) + + if (result !== undefined) { + const { modifiers } = classification + if (modifiers.rounding) { + const num = getNumericValue(result) + if (num !== null) { + const rounded = applyRoundingModifier(num, modifiers.rounding) + const formatted = formatResult(rounded) + results.push(formatted) + prevResult = rounded + numericBlock.push(rounded) + continue + } + } + if (modifiers.stripUnit) { + const formatted = applyStripUnit(result, modifiers.stripUnit) + results.push(formatted) + prevResult = result + const nv = getNumericValue(result) + if (nv !== null) + numericBlock.push(nv) + continue + } + if (modifiers.resultFormat) { + const formatted = applyResultFormat( + result, + modifiers.resultFormat, + ) + results.push(formatted) + prevResult = result + const nv = getNumericValue(result) + if (nv !== null) + numericBlock.push(nv) + continue + } + } + } + + // --- Speculative handlers (timezone, calendar, css) --- + + const tzDiffResult = evaluateTimeZoneDifferenceLine( trimmed, currentDate, { @@ -960,37 +526,33 @@ export function useMathEngine() { formatResult, }, ) - if (timeZoneDifferenceResult) { - results.push(timeZoneDifferenceResult.lineResult) - prevResult = timeZoneDifferenceResult.rawResult - const numericValue = getNumericValue( - timeZoneDifferenceResult.rawResult, - ) - if (numericValue !== null) { - numericBlock.push(numericValue) - } + if (tzDiffResult) { + results.push(tzDiffResult.lineResult) + prevResult = tzDiffResult.rawResult + const nv = getNumericValue(tzDiffResult.rawResult) + if (nv !== null) + numericBlock.push(nv) continue } - const timeZoneResult = evaluateTimeZoneLine(trimmed, currentDate) - if (timeZoneResult) { - results.push(timeZoneResult.lineResult) - prevResult = timeZoneResult.rawResult + const tzResult = evaluateTimeZoneLine(trimmed, currentDate) + if (tzResult) { + results.push(tzResult.lineResult) + prevResult = tzResult.rawResult continue } - const calendarResult = evaluateCalendarLine( + const calResult = evaluateCalendarLine( trimmed, currentDate, activeLocale, ) - if (calendarResult) { - results.push(calendarResult.lineResult) - prevResult = calendarResult.rawResult - const numericValue = getNumericValue(calendarResult.rawResult) - if (numericValue !== null) { - numericBlock.push(numericValue) - } + if (calResult) { + results.push(calResult.lineResult) + prevResult = calResult.rawResult + const nv = getNumericValue(calResult.rawResult) + if (nv !== null) + numericBlock.push(nv) continue } @@ -1000,36 +562,46 @@ export function useMathEngine() { scope.ppi = cssContext.ppi results.push(cssResult.lineResult) prevResult = cssResult.rawResult - if (typeof cssResult.rawResult === 'number') { + if (typeof cssResult.rawResult === 'number') numericBlock.push(cssResult.rawResult) - } continue } - const processed = preprocessMathExpression(trimmed) + // --- Stage 3: Rewrite --- + const strippedExpression = stripModifierSuffix( + view.expression, + classification, + ) + const strippedView = { ...view, expression: strippedExpression } + const processed = rewrite(strippedView, classification) - const dateAssignmentResult = evaluateDateAssignmentLine( + // --- Date assignment (speculative) --- + const dateAssignResult = evaluateDateAssignmentLine( processed, currentDate, scope, + mathDeps, ) - if (dateAssignmentResult) { - results.push(dateAssignmentResult.lineResult) - prevResult = dateAssignmentResult.rawResult + if (dateAssignResult) { + results.push(dateAssignResult.lineResult) + prevResult = dateAssignResult.rawResult continue } - const dateArithmeticResult = evaluateDateArithmeticLine( + // --- Date arithmetic (speculative) --- + const dateArithResult = evaluateDateArithmeticLine( processed, currentDate, scope, + mathDeps, ) - if (dateArithmeticResult) { - results.push(dateArithmeticResult.lineResult) - prevResult = dateArithmeticResult.rawResult + if (dateArithResult) { + results.push(dateArithResult.lineResult) + prevResult = dateArithResult.rawResult continue } + // --- Local temporal expression --- const localTemporalResult = parseExplicitLocalTemporalExpression( processed, currentDate, @@ -1040,10 +612,8 @@ export function useMathEngine() { continue } - const { format, expression: formatExpression } - = detectFormatDirective(processed) - const toEvaluate = format ? formatExpression : processed - const result = math.evaluate(toEvaluate, scope) + // --- Stage 4: Evaluate (math.js) --- + const result = math.evaluate(processed, scope) if (result === undefined) { results.push({ value: null, error: null, type: 'empty' }) @@ -1051,17 +621,7 @@ export function useMathEngine() { continue } - if (format) { - const formatted = applyFormat(result, format) - results.push(formatted) - prevResult = result - const numericValue = getNumericValue(result) - if (numericValue !== null) { - numericBlock.push(numericValue) - } - continue - } - + // Default formatting const formatted = formatResult(result) if ( /^[a-z_]\w*\s*=/i.test(trimmed) @@ -1072,10 +632,9 @@ export function useMathEngine() { results.push(formatted) prevResult = result - const numericValue = getNumericValue(result) - if (numericValue !== null) { - numericBlock.push(numericValue) - } + const nv = getNumericValue(result) + if (nv !== null) + numericBlock.push(nv) } catch (error: any) { results.push({ @@ -1093,9 +652,7 @@ export function useMathEngine() { function updateCurrencyRates(rates: Record) { currencyServiceState = 'ready' currencyUnavailableMessage = '' - activeCurrencyRates = { - ...rates, - } + activeCurrencyRates = { ...rates } math = createMathInstance(activeCurrencyRates) } @@ -1105,7 +662,6 @@ export function useMathEngine() { ) { currencyServiceState = state currencyUnavailableMessage = state === 'unavailable' ? errorMessage : '' - if (state !== 'ready') { activeCurrencyRates = {} math = createMathInstance(activeCurrencyRates) From 98e823bec03d8074552c26aa98f1e79b396e11ef Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 07:25:57 +0300 Subject: [PATCH 09/12] fix(math): remove duplicate point/points in knownUnitTokens --- src/renderer/composables/math-notebook/math-engine/constants.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/composables/math-notebook/math-engine/constants.ts b/src/renderer/composables/math-notebook/math-engine/constants.ts index 27e73823..41bbf2fd 100644 --- a/src/renderer/composables/math-notebook/math-engine/constants.ts +++ b/src/renderer/composables/math-notebook/math-engine/constants.ts @@ -179,8 +179,6 @@ export const knownUnitTokens = new Set([ 'pixel', 'pixels', 'pt', - 'point', - 'points', 'em', 'mph', 'kmh', From 76c47bc536477811cb41fe99a342d320a697c055 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 09:36:27 +0300 Subject: [PATCH 10/12] fix(math): guard modifier compatibility and weight pounds --- .../__tests__/useMathEngine.test.ts | 26 ++++++ .../math-engine/__tests__/classify.test.ts | 3 + .../math-engine/pipeline/classify.ts | 17 +++- .../math-notebook/useMathEngine.ts | 87 +++++++++++++++---- 4 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index de3a99d2..ad25975c 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -334,6 +334,14 @@ describe('currency', () => { expect(result.error).toBe('Currency rates service unavailable') }) + it('does not treat pound weight as currency while loading', () => { + setCurrencyServiceState('loading') + + const result = evalLine('1 pound to lb') + expect(result.type).toBe('unit') + expect(result.value).toContain('lb') + }) + it('$ symbol', () => { const result = evalLine('$30') expect(result.type).toBe('unit') @@ -385,6 +393,24 @@ describe('currency', () => { }) }) +describe('modifier compatibility', () => { + it('returns controlled error for timezone format modifier', () => { + const result = evalLine('time in Paris in hex') + expect(result.type).toBe('empty') + expect(result.error).toBe( + 'Modifier is not supported for this expression type', + ) + }) + + it('returns controlled error for css rounding modifier', () => { + const result = evalLine('12 pt in px rounded') + expect(result.type).toBe('empty') + expect(result.error).toBe( + 'Modifier is not supported for this expression type', + ) + }) +}) + describe('unit conversion', () => { it('stacked units', () => { const result = evalLine('1 meter 20 cm') diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts index 5d321be4..14da0fb6 100644 --- a/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts @@ -160,6 +160,9 @@ describe('classify — features', () => { it('hasCurrency for USD code', () => { expect(classifyRaw('100 USD').features.hasCurrency).toBe(true) }) + it('does not treat pound weight as currency', () => { + expect(classifyRaw('1 pound to lb').features.hasCurrency).toBe(false) + }) it('no currency for plain math', () => { expect(classifyRaw('10 + 5').features.hasCurrency).toBe(false) }) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts index 027b1cf4..16aeb484 100644 --- a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts @@ -10,6 +10,7 @@ import { currencyWordNames, SUPPORTED_CURRENCY_CODES, timeZoneAliases, + weightContextPattern, } from '../constants' const AGGREGATE_BLOCK_KEYWORDS = new Set([ @@ -146,9 +147,19 @@ function detectFeatures(normalized: string, expression: string): LineFeatures { expression.includes(s), ) const hasCurrencyCode = currencyCodePattern.test(expression) - const hasCurrencyWord = Object.keys(currencyWordNames).some(name => - new RegExp(`\\b${name}\\b`, 'i').test(normalized), - ) + const hasCurrencyWord = Object.keys(currencyWordNames).some((name) => { + if (!new RegExp(`\\b${name}\\b`, 'i').test(normalized)) + return false + + if ( + (name === 'pound' || name === 'pounds') + && weightContextPattern.test(normalized) + ) { + return false + } + + return true + }) return { hasCurrency: hasCurrencySymbol || hasCurrencyCode || hasCurrencyWord, diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index d932b194..733d4221 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -362,6 +362,51 @@ function stripModifierSuffix( return raw } +const UNSUPPORTED_MODIFIER_ERROR + = 'Modifier is not supported for this expression type' + +function hasAnyModifier(classification: LineClassification) { + return Boolean( + classification.modifiers.rounding + || classification.modifiers.stripUnit + || classification.modifiers.resultFormat, + ) +} + +function hasUnsupportedModifierCombination( + classification: LineClassification, + normalizedExpression: string, +): boolean { + if (!hasAnyModifier(classification)) + return false + + if ( + classification.primary === 'timezone' + || classification.primary === 'calendar' + || classification.primary === 'css' + || classification.primary === 'date-arithmetic' + ) { + return true + } + + if ( + classification.primary === 'assignment' + && classification.assignmentTarget + && classification.assignmentTarget !== 'math' + ) { + return true + } + + if ( + classification.primary === 'math' + && /\b(?:today|tomorrow|yesterday|now)\b/.test(normalizedExpression) + ) { + return true + } + + return false +} + // --- Main composable --- export function useMathEngine() { @@ -460,16 +505,28 @@ export function useMathEngine() { // --- Strip modifier suffix from trimmed for speculative handlers --- const effectiveTrimmed = stripModifierSuffix(trimmed, classification) + const effectiveView = analysisNormalize(effectiveTrimmed) - // --- Rounding/strip/format early path --- - // If modifiers detected, process expression with stripped suffix first if ( - classification.modifiers.rounding - || classification.modifiers.stripUnit - || classification.modifiers.resultFormat + hasUnsupportedModifierCombination( + classification, + effectiveView.normalized, + ) ) { - const strippedView = analysisNormalize(effectiveTrimmed) - let processed = rewrite(strippedView, classification) + results.push({ + value: null, + error: UNSUPPORTED_MODIFIER_ERROR, + showError: true, + type: 'empty', + }) + prevResult = undefined + continue + } + + // --- Rounding/strip/format early path --- + // If modifiers detected, process expression with stripped suffix first + if (hasAnyModifier(classification)) { + let processed = rewrite(effectiveView, classification) // Strip rewrite-generated suffixes (e.g. multiplier phrases produce "X to multiplier") const processedLower = processed.toLowerCase() if (processedLower.endsWith('to multiplier')) { @@ -516,6 +573,14 @@ export function useMathEngine() { } } + // --- Stage 3: Rewrite --- + const strippedExpression = stripModifierSuffix( + view.expression, + classification, + ) + const strippedView = { ...view, expression: strippedExpression } + const processed = rewrite(strippedView, classification) + // --- Speculative handlers (timezone, calendar, css) --- const tzDiffResult = evaluateTimeZoneDifferenceLine( @@ -567,14 +632,6 @@ export function useMathEngine() { continue } - // --- Stage 3: Rewrite --- - const strippedExpression = stripModifierSuffix( - view.expression, - classification, - ) - const strippedView = { ...view, expression: strippedExpression } - const processed = rewrite(strippedView, classification) - // --- Date assignment (speculative) --- const dateAssignResult = evaluateDateAssignmentLine( processed, From 25060cc3580004993d864a61133719037915907a Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 09:51:59 +0300 Subject: [PATCH 11/12] refactor(math): extract pipeline evaluate and format --- .../math-engine/__tests__/classify.test.ts | 8 + .../math-notebook/math-engine/css.ts | 2 +- .../math-engine/pipeline/classify.ts | 36 +- .../math-engine/pipeline/evaluate.ts | 551 +++++++++++++++ .../math-engine/pipeline/format.ts | 290 ++++++++ .../math-notebook/math-engine/preprocess.ts | 17 +- .../math-notebook/math-engine/timeZones.ts | 2 +- .../math-notebook/useMathEngine.ts | 663 +----------------- 8 files changed, 912 insertions(+), 657 deletions(-) create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/format.ts diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts index 14da0fb6..6ad0cd3d 100644 --- a/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/classify.test.ts @@ -82,6 +82,12 @@ describe('classify — primary intent', () => { expect(c.timezoneOperation).toBe('difference') }) + it('2:30 pm HKT in Berlin', () => { + const c = classifyRaw('2:30 pm HKT in Berlin') + expect(c.primary).toBe('timezone') + expect(c.timezoneOperation).toBe('display') + }) + it('ppi = 326', () => { const c = classifyRaw('ppi = 326') expect(c.primary).toBe('assignment') @@ -95,6 +101,8 @@ describe('classify — primary intent', () => { it('12 pt in px', () => expect(classifyRaw('12 pt in px').primary).toBe('css')) + it('12 pt into px', () => + expect(classifyRaw('12 pt into px').primary).toBe('css')) it('x = 10', () => { const c = classifyRaw('x = 10') diff --git a/src/renderer/composables/math-notebook/math-engine/css.ts b/src/renderer/composables/math-notebook/math-engine/css.ts index 3fae20f0..ed912342 100644 --- a/src/renderer/composables/math-notebook/math-engine/css.ts +++ b/src/renderer/composables/math-notebook/math-engine/css.ts @@ -1,6 +1,6 @@ import type { CssContext, SpecialLineResult } from './types' import { lengthInchesByUnit } from './constants' -import { splitByKeyword } from './preprocess' +import { splitByKeyword } from './utils' function normalizeCssUnit(unit: string) { const normalized = unit.toLowerCase() diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts index 16aeb484..fbf7015a 100644 --- a/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/classify.ts @@ -37,6 +37,12 @@ const CALENDAR_PREFIXES = [ /^current\s+timestamp$/, ] +const MONTH_TOKEN_RE + = /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b/ +const RELATIVE_DATE_TOKEN_RE = /\b(?:today|tomorrow|yesterday|now)\b/ +const ISO_DATE_RE = /\b\d{4}-\d{2}-\d{2}\b/ +const CLOCK_RE = /\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/ + const TIMEZONE_PATTERNS = [ /^time$/, /^time\(\)$/, @@ -166,10 +172,9 @@ function detectFeatures(normalized: string, expression: string): LineFeatures { hasAssignment: /^[a-z_]\w*\s*=(?!=)/i.test(expression), hasConversion: /\b(?:to|in|as|into)\b/.test(normalized), hasDateTokens: - /\b(?:today|tomorrow|yesterday|now)\b/.test(normalized) - || /\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b/.test( - normalized, - ), + RELATIVE_DATE_TOKEN_RE.test(normalized) + || MONTH_TOKEN_RE.test(normalized) + || ISO_DATE_RE.test(normalized), hasTimezoneTokens: /\b(?:time|now)\b/.test(normalized) && (/\bin\b/.test(normalized) @@ -178,6 +183,24 @@ function detectFeatures(normalized: string, expression: string): LineFeatures { } function probeTimezone(normalized: string): boolean { + const conversionIndex = normalized.lastIndexOf(' in ') + if (conversionIndex > 0) { + const source = normalized.slice(0, conversionIndex).trim() + const target = normalized.slice(conversionIndex + 4).trim() + + const looksLikeTemporalSource + = /\b(?:time|now)\b/.test(source) + || CLOCK_RE.test(source) + || RELATIVE_DATE_TOKEN_RE.test(source) + || MONTH_TOKEN_RE.test(source) + || ISO_DATE_RE.test(source) + || Object.keys(timeZoneAliases).some(tz => source.includes(tz)) + + if (looksLikeTemporalSource && /^[a-z/_ ]+$/.test(target)) { + return true + } + } + return ( TIMEZONE_PATTERNS.some(re => re.test(normalized)) || (/\bin\b/.test(normalized) && /\b(?:time|now)\b/.test(normalized)) @@ -195,7 +218,10 @@ function probeCalendar(normalized: string): boolean { function probeCss(expression: string, normalized: string): boolean { if (CSS_ASSIGNMENT_RE.test(expression)) return true - return CSS_CONVERSION_RE.test(normalized) && /\b(?:to|in)\b/.test(normalized) + return ( + CSS_CONVERSION_RE.test(normalized) + && /\b(?:to|in|into|as)\b/.test(normalized) + ) } function probeDateArithmetic( diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts new file mode 100644 index 00000000..d4437fdd --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts @@ -0,0 +1,551 @@ +import type { + AnalysisView, + CssContext, + CurrencyServiceState, + LineClassification, + LineResult, +} from '../types' +import type { LineFormatter } from './format' +import { evaluateCalendarLine } from '../calendar' +import { evaluateCssLine } from '../css' +import { + evaluateBlockAggregate, + evaluateInlineAggregate, +} from '../evaluators/aggregates' +import { + evaluateDateArithmeticLine, + evaluateDateAssignmentLine, +} from '../evaluators/dateArithmetic' +import { + evaluateTimeZoneDifferenceLine, + evaluateTimeZoneLine, + parseExplicitLocalTemporalExpression, +} from '../timeZones' +import { analysisNormalize } from './analysisNormalize' +import { rewrite } from './rewrite' + +const UNSUPPORTED_MODIFIER_ERROR + = 'Modifier is not supported for this expression type' + +interface MathEvaluatorInstance { + evaluate: (expression: string, scope: Record) => any + unit: (value: number, unit: string) => any +} + +interface EvaluateClassifiedLineOptions { + trimmed: string + view: AnalysisView + classification: LineClassification + scope: Record + cssContext: CssContext + currentDate: Date + numericBlock: number[] + math: MathEvaluatorInstance + formatter: LineFormatter + currencyServiceState: CurrencyServiceState + currencyUnavailableMessage: string + activeLocale: string +} + +interface EvaluatedLine { + lineResult: LineResult + rawResult: any + numericValue?: number | null + resetPrev?: boolean + resetNumericBlock?: boolean +} + +interface MathDeps { + mathEvaluate: (expression: string, scope: Record) => any + formatResult: LineFormatter['formatResult'] +} + +function stripModifierSuffix( + raw: string, + classification: LineClassification, +): string { + const lower = raw.toLowerCase() + + if (classification.modifiers.rounding) { + const patterns = [ + /\s+to\s+\d+\s+(?:dp|digits?)$/i, + /\s+rounded\s+(?:up|down)\s+to\s+nearest\s+\w+$/i, + /\s+(?:rounded\s+)?to\s+nearest\s+\w+$/i, + /\s+rounded\s+(?:up|down)$/i, + /\s+rounded$/i, + ] + for (const pattern of patterns) { + const match = lower.match(pattern) + if (match) + return raw.slice(0, match.index!).trim() + } + } + + if (classification.modifiers.stripUnit) { + const suffixes = [ + 'as number', + 'to number', + 'as decimal', + 'to decimal', + 'as dec', + 'to dec', + 'as fraction', + 'to fraction', + ] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) + return raw.slice(0, raw.length - suffix.length).trim() + } + } + + if (classification.modifiers.resultFormat) { + const suffixes = [ + 'in hex', + 'in bin', + 'in oct', + 'in sci', + 'in scientific', + 'as multiplier', + 'to multiplier', + ] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) + return raw.slice(0, raw.length - suffix.length).trim() + } + } + + return raw +} + +function hasAnyModifier(classification: LineClassification) { + return Boolean( + classification.modifiers.rounding + || classification.modifiers.stripUnit + || classification.modifiers.resultFormat, + ) +} + +function hasUnsupportedModifierCombination( + classification: LineClassification, + normalizedExpression: string, +): boolean { + if (!hasAnyModifier(classification)) + return false + + if ( + classification.primary === 'timezone' + || classification.primary === 'calendar' + || classification.primary === 'css' + || classification.primary === 'date-arithmetic' + ) { + return true + } + + if ( + classification.primary === 'assignment' + && classification.assignmentTarget + && classification.assignmentTarget !== 'math' + ) { + return true + } + + if ( + classification.primary === 'math' + && /\b(?:today|tomorrow|yesterday|now)\b/.test(normalizedExpression) + ) { + return true + } + + return false +} + +function toEvaluatedLine( + lineResult: LineResult, + rawResult: any, + numericValue?: number | null, +): EvaluatedLine { + return { + lineResult, + rawResult, + numericValue, + } +} + +function evaluatePrimaryIntent( + classification: LineClassification, + trimmed: string, + processed: string, + currentDate: Date, + activeLocale: string, + cssContext: CssContext, + scope: Record, + math: MathEvaluatorInstance, + formatResult: LineFormatter['formatResult'], + getNumericValue: LineFormatter['getNumericValue'], +): EvaluatedLine | null { + switch (classification.primary) { + case 'timezone': { + if (classification.timezoneOperation === 'difference') { + const timeZoneDifferenceResult = evaluateTimeZoneDifferenceLine( + trimmed, + currentDate, + { + createHourUnit: hours => math.unit(hours, 'hour'), + formatResult, + }, + ) + + if (timeZoneDifferenceResult) { + return toEvaluatedLine( + timeZoneDifferenceResult.lineResult, + timeZoneDifferenceResult.rawResult, + getNumericValue(timeZoneDifferenceResult.rawResult), + ) + } + } + + const timeZoneResult = evaluateTimeZoneLine(trimmed, currentDate) + if (timeZoneResult) { + return toEvaluatedLine( + timeZoneResult.lineResult, + timeZoneResult.rawResult, + ) + } + + return null + } + + case 'calendar': { + const calendarResult = evaluateCalendarLine( + trimmed, + currentDate, + activeLocale, + ) + + if (calendarResult) { + return toEvaluatedLine( + calendarResult.lineResult, + calendarResult.rawResult, + getNumericValue(calendarResult.rawResult), + ) + } + + return null + } + + case 'css': + case 'assignment': { + if ( + classification.primary === 'css' + || classification.assignmentTarget === 'css' + ) { + const cssResult = evaluateCssLine(trimmed, cssContext) + if (cssResult) { + scope.em = cssContext.emPx + scope.ppi = cssContext.ppi + return toEvaluatedLine( + cssResult.lineResult, + cssResult.rawResult, + typeof cssResult.rawResult === 'number' + ? cssResult.rawResult + : null, + ) + } + } + + if (classification.assignmentTarget === 'date') { + const dateAssignmentResult = evaluateDateAssignmentLine( + processed, + currentDate, + scope, + { + mathEvaluate: (expression, nextScope) => + math.evaluate(expression, nextScope), + formatResult, + }, + ) + + if (dateAssignmentResult) { + return toEvaluatedLine( + dateAssignmentResult.lineResult, + dateAssignmentResult.rawResult, + ) + } + } + + return null + } + + case 'date-arithmetic': { + const dateArithmeticResult = evaluateDateArithmeticLine( + processed, + currentDate, + scope, + { + mathEvaluate: (expression, nextScope) => + math.evaluate(expression, nextScope), + formatResult, + }, + ) + + if (dateArithmeticResult) { + return toEvaluatedLine( + dateArithmeticResult.lineResult, + dateArithmeticResult.rawResult, + ) + } + + return null + } + + default: + return null + } +} + +function evaluateMathPath( + trimmed: string, + processed: string, + currentDate: Date, + scope: Record, + math: MathEvaluatorInstance, + mathDeps: MathDeps, + formatResult: LineFormatter['formatResult'], + getNumericValue: LineFormatter['getNumericValue'], +): EvaluatedLine { + const dateAssignmentResult = evaluateDateAssignmentLine( + processed, + currentDate, + scope, + mathDeps, + ) + if (dateAssignmentResult) { + return toEvaluatedLine( + dateAssignmentResult.lineResult, + dateAssignmentResult.rawResult, + ) + } + + const dateArithmeticResult = evaluateDateArithmeticLine( + processed, + currentDate, + scope, + mathDeps, + ) + if (dateArithmeticResult) { + return toEvaluatedLine( + dateArithmeticResult.lineResult, + dateArithmeticResult.rawResult, + ) + } + + const localTemporalResult = parseExplicitLocalTemporalExpression( + processed, + currentDate, + ) + if (localTemporalResult) { + return toEvaluatedLine( + formatResult(localTemporalResult.date), + localTemporalResult.date, + ) + } + + const result = math.evaluate(processed, scope) + if (result === undefined) { + return { + lineResult: { value: null, error: null, type: 'empty' }, + rawResult: undefined, + resetPrev: true, + } + } + + const lineResult = formatResult(result) + if (/^[a-z_]\w*\s*=/i.test(trimmed) && !/^[a-z_]\w*\s*==/i.test(trimmed)) { + lineResult.type = 'assignment' + } + + return toEvaluatedLine(lineResult, result, getNumericValue(result)) +} + +export function evaluateClassifiedLine( + options: EvaluateClassifiedLineOptions, +): EvaluatedLine { + const { + activeLocale, + classification, + cssContext, + currencyServiceState, + currencyUnavailableMessage, + currentDate, + formatter, + math, + numericBlock, + scope, + trimmed, + view, + } = options + const { formatResult, getNumericValue } = formatter + + if (classification.primary === 'empty') { + return { + lineResult: { value: null, error: null, type: 'empty' }, + rawResult: undefined, + resetPrev: true, + resetNumericBlock: true, + } + } + + if (classification.primary === 'comment') { + return { + lineResult: { value: null, error: null, type: 'comment' }, + rawResult: undefined, + } + } + + if (classification.primary === 'aggregate-block') { + const keyword = view.normalized.trim() + const aggregate = evaluateBlockAggregate(keyword, numericBlock) + const value = aggregate?.value ?? 0 + const lineResult = formatResult(value) + lineResult.type = 'aggregate' + return { + lineResult, + rawResult: value, + numericValue: value, + } + } + + if (classification.primary === 'aggregate-inline') { + const aggregate = evaluateInlineAggregate(trimmed) + if (aggregate) { + const lineResult = formatResult(aggregate.value) + lineResult.type = 'aggregate' + return { + lineResult, + rawResult: aggregate.value, + numericValue: aggregate.value, + } + } + } + + if (currencyServiceState !== 'ready' && classification.features.hasCurrency) { + return { + lineResult: + currencyServiceState === 'loading' + ? { value: null, error: null, type: 'pending' } + : { + value: null, + error: currencyUnavailableMessage, + showError: true, + type: 'empty', + }, + rawResult: undefined, + resetPrev: true, + } + } + + const effectiveTrimmed = stripModifierSuffix(trimmed, classification) + const effectiveView = analysisNormalize(effectiveTrimmed) + + if ( + hasUnsupportedModifierCombination(classification, effectiveView.normalized) + ) { + return { + lineResult: { + value: null, + error: UNSUPPORTED_MODIFIER_ERROR, + showError: true, + type: 'empty', + }, + rawResult: undefined, + resetPrev: true, + } + } + + if (hasAnyModifier(classification)) { + let processed = rewrite(effectiveView, classification) + if (processed.toLowerCase().endsWith('to multiplier')) { + processed = processed + .slice(0, processed.length - 'to multiplier'.length) + .trim() + } + + const result = math.evaluate(processed, scope) + if (result !== undefined) { + if (classification.modifiers.rounding) { + const numericValue = getNumericValue(result) + if (numericValue !== null) { + const rounded = formatter.applyRoundingModifier( + numericValue, + classification.modifiers.rounding, + ) + return { + lineResult: formatResult(rounded), + rawResult: rounded, + numericValue: rounded, + } + } + } + + if (classification.modifiers.stripUnit) { + return { + lineResult: formatter.applyStripUnit( + result, + classification.modifiers.stripUnit, + ), + rawResult: result, + numericValue: getNumericValue(result), + } + } + + if (classification.modifiers.resultFormat) { + return { + lineResult: formatter.applyResultFormat( + result, + classification.modifiers.resultFormat, + ), + rawResult: result, + numericValue: getNumericValue(result), + } + } + } + } + + const strippedView = { + ...view, + expression: stripModifierSuffix(view.expression, classification), + } + const processed = rewrite(strippedView, classification) + + const routedResult = evaluatePrimaryIntent( + classification, + trimmed, + processed, + currentDate, + activeLocale, + cssContext, + scope, + math, + formatResult, + getNumericValue, + ) + if (routedResult) { + return routedResult + } + + const mathDeps: MathDeps = { + mathEvaluate: (expression: string, nextScope: Record) => + math.evaluate(expression, nextScope), + formatResult, + } + return evaluateMathPath( + trimmed, + processed, + currentDate, + scope, + math, + mathDeps, + formatResult, + getNumericValue, + ) +} diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/format.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/format.ts new file mode 100644 index 00000000..3a738c8c --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/format.ts @@ -0,0 +1,290 @@ +import type { LineClassification, LineResult } from '../types' +import { HUMANIZED_UNIT_NAMES, SUPPORTED_CURRENCY_CODES } from '../constants' +import { formatMathDate, formatMathNumber } from '../format' +import { coerceToNumber, toFraction } from '../utils' + +interface MathFormatterInstance { + format: (value: any, options: { precision: number }) => string + unit: (value: number, unit: string) => any +} + +interface CreateLineFormatterOptions { + math: MathFormatterInstance + locale: string + decimalPlaces: number +} + +export function createLineFormatter(options: CreateLineFormatterOptions) { + const { math, locale, decimalPlaces } = options + + function humanizeUnitToken(unitId: string) { + const displayUnit = HUMANIZED_UNIT_NAMES[unitId] + return displayUnit ? displayUnit.plural : unitId + } + + function humanizeFormattedUnits(value: string) { + let result = value.replace( + /(-?\d[\d,]*(?:\.\d+)?)\s+([a-z][a-z0-9]*)\b/gi, + (match, amountText: string, unitId: string) => { + const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) + if (Number.isNaN(numericAmount)) + return match + + const formattedAmount = formatMathNumber( + numericAmount, + locale, + decimalPlaces, + ) + const displayUnit = HUMANIZED_UNIT_NAMES[unitId] + if (!displayUnit) + return `${formattedAmount} ${unitId}` + + const unitLabel + = Math.abs(numericAmount) === 1 + ? displayUnit.singular + : displayUnit.plural + + return `${formattedAmount} ${unitLabel}` + }, + ) + + result = result.replace( + /\b(mc(?:second|minute|hour|day|week|month|year))\b/g, + (_, unitId: string) => humanizeUnitToken(unitId), + ) + + return result + } + + function formatResult(result: any): LineResult { + if (result === undefined || result === null) { + return { value: null, error: null, type: 'empty' } + } + + if (typeof result === 'boolean') { + return { value: String(result), error: null, type: 'number' } + } + + if (result instanceof Date) { + return { + value: formatMathDate(result, locale), + error: null, + type: 'date', + } + } + + if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + && result.units + ) { + if (Array.isArray(result.units) && result.units.length === 2) { + const units = result.units as Array<{ + unit: { name: string, value: number, base?: { key?: string } } + prefix: { value: number } + power: number + }> + const currencyUnit = units.find( + u => + u.unit.base?.key?.includes('STUFF') + || SUPPORTED_CURRENCY_CODES.includes(u.unit.name), + ) + const timeUnit = units.find(u => u.unit.base?.key === 'TIME') + if ( + currencyUnit + && timeUnit + && currencyUnit.power === 1 + && timeUnit.power === 1 + ) { + try { + const rawValue = result.value as number + const currencyScale + = currencyUnit.unit.value * currencyUnit.prefix.value + const timeScale = timeUnit.unit.value * timeUnit.prefix.value + const currencyAmount + = (rawValue / (currencyScale * timeScale)) * currencyScale + const simplified = math.unit( + currencyAmount, + currencyUnit.unit.name, + ) + return { + value: humanizeFormattedUnits( + math.format(simplified, { precision: decimalPlaces }), + ), + error: null, + type: 'unit', + } + } + catch { + // Fall through to default unit formatting. + } + } + } + + return { + value: humanizeFormattedUnits( + math.format(result, { precision: decimalPlaces }), + ), + error: null, + type: 'unit', + } + } + + if (typeof result === 'number') { + return { + value: formatMathNumber(result, locale, decimalPlaces), + error: null, + type: 'number', + ...(Number.isFinite(result) ? { numericValue: result } : {}), + } + } + + if (typeof result === 'string') { + return { value: result, error: null, type: 'number' } + } + + if (result && typeof result.toString === 'function') { + return { + value: humanizeFormattedUnits( + math.format(result, { precision: decimalPlaces }), + ), + error: null, + type: 'number', + } + } + + return { value: String(result), error: null, type: 'number' } + } + + function getNumericValue(result: any): number | null { + if (typeof result === 'number') + return result + + if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + return result.toNumber() + } + catch { + return null + } + } + + return null + } + + function applyRoundingModifier( + value: number, + rounding: NonNullable, + ): number { + switch (rounding.type) { + case 'dp': { + const factor = 10 ** rounding.param + return Math.round(value * factor) / factor + } + case 'round': + return Math.round(value) + case 'ceil': + return Math.ceil(value) + case 'floor': + return Math.floor(value) + case 'nearest': + return Math.round(value / rounding.param) * rounding.param + case 'nearestCeil': + return Math.ceil(value / rounding.param) * rounding.param + case 'nearestFloor': + return Math.floor(value / rounding.param) * rounding.param + default: + return value + } + } + + function applyResultFormat( + result: any, + format: NonNullable, + ): LineResult { + const num = coerceToNumber(result) + if (Number.isNaN(num)) + return { value: String(result), error: null, type: 'number' } + + if (format === 'multiplier') { + return { + value: `${formatMathNumber(num, locale, decimalPlaces)}x`, + error: null, + type: 'number', + numericValue: num, + } + } + + const intValue = Math.round(num) + switch (format) { + case 'hex': + return { + value: `0x${intValue.toString(16).toUpperCase()}`, + error: null, + type: 'number', + numericValue: intValue, + } + case 'bin': + return { + value: `0b${intValue.toString(2)}`, + error: null, + type: 'number', + numericValue: intValue, + } + case 'oct': + return { + value: `0o${intValue.toString(8)}`, + error: null, + type: 'number', + numericValue: intValue, + } + case 'sci': + return { + value: num.toExponential(), + error: null, + type: 'number', + numericValue: num, + } + } + } + + function applyStripUnit( + result: any, + strip: NonNullable, + ): LineResult { + const num = coerceToNumber(result) + if (Number.isNaN(num)) + return { value: String(result), error: null, type: 'number' } + + if (strip === 'fraction') { + return { + value: toFraction(num), + error: null, + type: 'number', + numericValue: num, + } + } + + return { + value: formatMathNumber(num, locale, decimalPlaces), + error: null, + type: 'number', + numericValue: num, + } + } + + return { + applyResultFormat, + applyRoundingModifier, + applyStripUnit, + formatResult, + getNumericValue, + } +} + +export type LineFormatter = ReturnType diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 0cb511ce..0bae574a 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -7,6 +7,7 @@ import { TIME_UNIT_TOKEN_MAP, weightContextPattern, } from './constants' +import { splitByKeyword } from './utils' function preprocessLabels(line: string): string { const match = line.match(/^([a-z][a-z0-9]*(?:\s[a-z0-9]+)*):\s(\S.*)$/i) @@ -692,22 +693,6 @@ function preprocessConversions(line: string): string { return line.replace(/\s+as\s+/gi, ' to ').replace(/\s+into\s+/gi, ' to ') } -export function splitByKeyword(line: string, keywords: string[]) { - const lowerLine = line.toLowerCase() - - for (const keyword of keywords) { - const index = lowerLine.lastIndexOf(keyword) - if (index > 0) { - return [ - line.slice(0, index).trim(), - line.slice(index + keyword.length).trim(), - ] as const - } - } - - return null -} - export function hasCurrencyExpression(line: string) { const sanitized = sanitizeForCurrencyDetection(line) diff --git a/src/renderer/composables/math-notebook/math-engine/timeZones.ts b/src/renderer/composables/math-notebook/math-engine/timeZones.ts index 10b80313..63259ad2 100644 --- a/src/renderer/composables/math-notebook/math-engine/timeZones.ts +++ b/src/renderer/composables/math-notebook/math-engine/timeZones.ts @@ -1,6 +1,6 @@ import type { LineResult, SpecialLineResult } from './types' import { MONTH_NAME_TO_INDEX, timeZoneAliases } from './constants' -import { splitByKeyword } from './preprocess' +import { splitByKeyword } from './utils' interface ParsedTemporalExpression { date: Date diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 733d4221..ff546c5a 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -1,36 +1,14 @@ import type { CssContext, CurrencyServiceState, - LineClassification, LineResult, } from './math-engine/types' -import { evaluateCalendarLine } from './math-engine/calendar' -import { - DEFAULT_EM_IN_PX, - DEFAULT_PPI, - HUMANIZED_UNIT_NAMES, - SUPPORTED_CURRENCY_CODES, -} from './math-engine/constants' -import { evaluateCssLine } from './math-engine/css' -import { - evaluateBlockAggregate, - evaluateInlineAggregate, -} from './math-engine/evaluators/aggregates' -import { - evaluateDateArithmeticLine, - evaluateDateAssignmentLine, -} from './math-engine/evaluators/dateArithmetic' -import { formatMathDate, formatMathNumber } from './math-engine/format' +import { DEFAULT_EM_IN_PX, DEFAULT_PPI } from './math-engine/constants' import { createMathInstance } from './math-engine/mathInstance' import { analysisNormalize } from './math-engine/pipeline/analysisNormalize' import { classify } from './math-engine/pipeline/classify' -import { rewrite } from './math-engine/pipeline/rewrite' -import { - evaluateTimeZoneDifferenceLine, - evaluateTimeZoneLine, - parseExplicitLocalTemporalExpression, -} from './math-engine/timeZones' -import { coerceToNumber, toFraction } from './math-engine/utils' +import { evaluateClassifiedLine } from './math-engine/pipeline/evaluate' +import { createLineFormatter } from './math-engine/pipeline/format' export type { LineResult } from './math-engine/types' @@ -42,371 +20,6 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 -// --- Formatting helpers (depend on module-level math instance and locale) --- - -function humanizeUnitToken(unitId: string) { - const displayUnit = HUMANIZED_UNIT_NAMES[unitId] - return displayUnit ? displayUnit.plural : unitId -} - -function humanizeFormattedUnits(value: string) { - let result = value.replace( - /(-?\d[\d,]*(?:\.\d+)?)\s+([a-z][a-z0-9]*)\b/gi, - (match, amountText: string, unitId: string) => { - const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) - if (Number.isNaN(numericAmount)) - return match - - const formattedAmount = formatMathNumber( - numericAmount, - activeLocale, - activeDecimalPlaces, - ) - const displayUnit = HUMANIZED_UNIT_NAMES[unitId] - if (!displayUnit) - return `${formattedAmount} ${unitId}` - - const unitLabel - = Math.abs(numericAmount) === 1 - ? displayUnit.singular - : displayUnit.plural - - return `${formattedAmount} ${unitLabel}` - }, - ) - - result = result.replace( - /\b(mc(?:second|minute|hour|day|week|month|year))\b/g, - (_, unitId: string) => humanizeUnitToken(unitId), - ) - - return result -} - -function formatResult(result: any): LineResult { - if (result === undefined || result === null) { - return { value: null, error: null, type: 'empty' } - } - - if (typeof result === 'boolean') { - return { value: String(result), error: null, type: 'number' } - } - - if (result instanceof Date) { - return { - value: formatMathDate(result, activeLocale), - error: null, - type: 'date', - } - } - - if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - && result.units - ) { - // Implicit rate simplification: currency * time → currency - if (Array.isArray(result.units) && result.units.length === 2) { - const units = result.units as Array<{ - unit: { name: string, value: number, base?: { key?: string } } - prefix: { value: number } - power: number - }> - const currencyUnit = units.find( - u => - u.unit.base?.key?.includes('STUFF') - || SUPPORTED_CURRENCY_CODES.includes(u.unit.name), - ) - const timeUnit = units.find(u => u.unit.base?.key === 'TIME') - if ( - currencyUnit - && timeUnit - && currencyUnit.power === 1 - && timeUnit.power === 1 - ) { - try { - const rawValue = result.value as number - const currencyScale - = currencyUnit.unit.value * currencyUnit.prefix.value - const timeScale = timeUnit.unit.value * timeUnit.prefix.value - const currencyAmount - = (rawValue / (currencyScale * timeScale)) * currencyScale - const simplified = math.unit(currencyAmount, currencyUnit.unit.name) - return { - value: humanizeFormattedUnits( - math.format(simplified, { precision: activeDecimalPlaces }), - ), - error: null, - type: 'unit', - } - } - catch { - /* fall through */ - } - } - } - - return { - value: humanizeFormattedUnits( - math.format(result, { precision: activeDecimalPlaces }), - ), - error: null, - type: 'unit', - } - } - - if (typeof result === 'number') { - return { - value: formatMathNumber(result, activeLocale, activeDecimalPlaces), - error: null, - type: 'number', - ...(Number.isFinite(result) ? { numericValue: result } : {}), - } - } - - if (typeof result === 'string') { - return { value: result, error: null, type: 'number' } - } - - if (result && typeof result.toString === 'function') { - return { - value: humanizeFormattedUnits( - math.format(result, { precision: activeDecimalPlaces }), - ), - error: null, - type: 'number', - } - } - - return { value: String(result), error: null, type: 'number' } -} - -function getNumericValue(result: any): number | null { - if (typeof result === 'number') - return result - if ( - result - && typeof result === 'object' - && typeof result.toNumber === 'function' - ) { - try { - return result.toNumber() - } - catch { - return null - } - } - return null -} - -// --- Modifier application --- - -function applyRoundingModifier( - value: number, - rounding: NonNullable, -): number { - switch (rounding.type) { - case 'dp': { - const factor = 10 ** rounding.param - return Math.round(value * factor) / factor - } - case 'round': - return Math.round(value) - case 'ceil': - return Math.ceil(value) - case 'floor': - return Math.floor(value) - case 'nearest': - return Math.round(value / rounding.param) * rounding.param - case 'nearestCeil': - return Math.ceil(value / rounding.param) * rounding.param - case 'nearestFloor': - return Math.floor(value / rounding.param) * rounding.param - default: - return value - } -} - -function applyResultFormat( - result: any, - format: NonNullable, -): LineResult { - const num = coerceToNumber(result) - if (Number.isNaN(num)) - return { value: String(result), error: null, type: 'number' } - - if (format === 'multiplier') { - return { - value: `${formatMathNumber(num, activeLocale, activeDecimalPlaces)}x`, - error: null, - type: 'number', - numericValue: num, - } - } - - const intValue = Math.round(num) - switch (format) { - case 'hex': - return { - value: `0x${intValue.toString(16).toUpperCase()}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'bin': - return { - value: `0b${intValue.toString(2)}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'oct': - return { - value: `0o${intValue.toString(8)}`, - error: null, - type: 'number', - numericValue: intValue, - } - case 'sci': - return { - value: num.toExponential(), - error: null, - type: 'number', - numericValue: num, - } - } -} - -function applyStripUnit( - result: any, - strip: NonNullable, -): LineResult { - const num = coerceToNumber(result) - if (Number.isNaN(num)) - return { value: String(result), error: null, type: 'number' } - - if (strip === 'fraction') { - return { - value: toFraction(num), - error: null, - type: 'number', - numericValue: num, - } - } - - return { - value: formatMathNumber(num, activeLocale, activeDecimalPlaces), - error: null, - type: 'number', - numericValue: num, - } -} - -// --- Strip modifier suffix from expression before eval --- - -function stripModifierSuffix( - raw: string, - classification: LineClassification, -): string { - const lower = raw.toLowerCase() - - if (classification.modifiers.rounding) { - const patterns = [ - /\s+to\s+\d+\s+(?:dp|digits?)$/i, - /\s+rounded\s+(?:up|down)\s+to\s+nearest\s+\w+$/i, - /\s+(?:rounded\s+)?to\s+nearest\s+\w+$/i, - /\s+rounded\s+(?:up|down)$/i, - /\s+rounded$/i, - ] - for (const p of patterns) { - const m = lower.match(p) - if (m) - return raw.slice(0, m.index!).trim() - } - } - - if (classification.modifiers.stripUnit) { - const suffixes = [ - 'as number', - 'to number', - 'as decimal', - 'to decimal', - 'as dec', - 'to dec', - 'as fraction', - 'to fraction', - ] - for (const s of suffixes) { - if (lower.endsWith(s)) - return raw.slice(0, raw.length - s.length).trim() - } - } - - if (classification.modifiers.resultFormat) { - const suffixes = [ - 'in hex', - 'in bin', - 'in oct', - 'in sci', - 'in scientific', - 'as multiplier', - 'to multiplier', - ] - for (const s of suffixes) { - if (lower.endsWith(s)) - return raw.slice(0, raw.length - s.length).trim() - } - } - - return raw -} - -const UNSUPPORTED_MODIFIER_ERROR - = 'Modifier is not supported for this expression type' - -function hasAnyModifier(classification: LineClassification) { - return Boolean( - classification.modifiers.rounding - || classification.modifiers.stripUnit - || classification.modifiers.resultFormat, - ) -} - -function hasUnsupportedModifierCombination( - classification: LineClassification, - normalizedExpression: string, -): boolean { - if (!hasAnyModifier(classification)) - return false - - if ( - classification.primary === 'timezone' - || classification.primary === 'calendar' - || classification.primary === 'css' - || classification.primary === 'date-arithmetic' - ) { - return true - } - - if ( - classification.primary === 'assignment' - && classification.assignmentTarget - && classification.assignmentTarget !== 'math' - ) { - return true - } - - if ( - classification.primary === 'math' - && /\b(?:today|tomorrow|yesterday|now)\b/.test(normalizedExpression) - ) { - return true - } - - return false -} - // --- Main composable --- export function useMathEngine() { @@ -425,273 +38,54 @@ export function useMathEngine() { let prevResult: any let numericBlock: number[] = [] - - const mathDeps = { - mathEvaluate: (expr: string, s: Record) => - math.evaluate(expr, s), - formatResult, - } + const formatter = createLineFormatter({ + math, + locale: activeLocale, + decimalPlaces: activeDecimalPlaces, + }) for (const line of lines) { const trimmed = line.trim() - - // --- Stage 1: Analysis Normalize --- const view = analysisNormalize(trimmed) - - // --- Stage 2: Classify --- const classification = classify(view) - // --- Early returns --- - if (classification.primary === 'empty') { - results.push({ value: null, error: null, type: 'empty' }) - prevResult = undefined - numericBlock = [] - continue - } - - if (classification.primary === 'comment') { - results.push({ value: null, error: null, type: 'comment' }) - continue - } - if (prevResult !== undefined) { scope.prev = prevResult } - // --- Aggregates (work on numericBlock, before rewrite) --- - if (classification.primary === 'aggregate-block') { - const keyword = view.normalized.trim() - const aggResult = evaluateBlockAggregate(keyword, numericBlock) - const value = aggResult?.value ?? 0 - const formatted = formatResult(value) - formatted.type = 'aggregate' - results.push(formatted) - prevResult = value - numericBlock.push(value) - continue - } - - if (classification.primary === 'aggregate-inline') { - const aggResult = evaluateInlineAggregate(trimmed) - if (aggResult) { - const formatted = formatResult(aggResult.value) - formatted.type = 'aggregate' - results.push(formatted) - prevResult = aggResult.value - numericBlock.push(aggResult.value) - continue - } - } - try { - // --- Currency service gate --- - if ( - currencyServiceState !== 'ready' - && classification.features.hasCurrency - ) { - results.push( - currencyServiceState === 'loading' - ? { value: null, error: null, type: 'pending' } - : { - value: null, - error: currencyUnavailableMessage, - showError: true, - type: 'empty', - }, - ) - prevResult = undefined - continue - } - - // --- Strip modifier suffix from trimmed for speculative handlers --- - const effectiveTrimmed = stripModifierSuffix(trimmed, classification) - const effectiveView = analysisNormalize(effectiveTrimmed) - - if ( - hasUnsupportedModifierCombination( - classification, - effectiveView.normalized, - ) - ) { - results.push({ - value: null, - error: UNSUPPORTED_MODIFIER_ERROR, - showError: true, - type: 'empty', - }) - prevResult = undefined - continue - } - - // --- Rounding/strip/format early path --- - // If modifiers detected, process expression with stripped suffix first - if (hasAnyModifier(classification)) { - let processed = rewrite(effectiveView, classification) - // Strip rewrite-generated suffixes (e.g. multiplier phrases produce "X to multiplier") - const processedLower = processed.toLowerCase() - if (processedLower.endsWith('to multiplier')) { - processed = processed - .slice(0, processed.length - 'to multiplier'.length) - .trim() - } - const result = math.evaluate(processed, scope) - - if (result !== undefined) { - const { modifiers } = classification - if (modifiers.rounding) { - const num = getNumericValue(result) - if (num !== null) { - const rounded = applyRoundingModifier(num, modifiers.rounding) - const formatted = formatResult(rounded) - results.push(formatted) - prevResult = rounded - numericBlock.push(rounded) - continue - } - } - if (modifiers.stripUnit) { - const formatted = applyStripUnit(result, modifiers.stripUnit) - results.push(formatted) - prevResult = result - const nv = getNumericValue(result) - if (nv !== null) - numericBlock.push(nv) - continue - } - if (modifiers.resultFormat) { - const formatted = applyResultFormat( - result, - modifiers.resultFormat, - ) - results.push(formatted) - prevResult = result - const nv = getNumericValue(result) - if (nv !== null) - numericBlock.push(nv) - continue - } - } - } - - // --- Stage 3: Rewrite --- - const strippedExpression = stripModifierSuffix( - view.expression, - classification, - ) - const strippedView = { ...view, expression: strippedExpression } - const processed = rewrite(strippedView, classification) - - // --- Speculative handlers (timezone, calendar, css) --- - - const tzDiffResult = evaluateTimeZoneDifferenceLine( - trimmed, - currentDate, - { - createHourUnit: hours => math.unit(hours, 'hour'), - formatResult, - }, - ) - if (tzDiffResult) { - results.push(tzDiffResult.lineResult) - prevResult = tzDiffResult.rawResult - const nv = getNumericValue(tzDiffResult.rawResult) - if (nv !== null) - numericBlock.push(nv) - continue - } - - const tzResult = evaluateTimeZoneLine(trimmed, currentDate) - if (tzResult) { - results.push(tzResult.lineResult) - prevResult = tzResult.rawResult - continue - } - - const calResult = evaluateCalendarLine( + const evaluated = evaluateClassifiedLine({ trimmed, - currentDate, - activeLocale, - ) - if (calResult) { - results.push(calResult.lineResult) - prevResult = calResult.rawResult - const nv = getNumericValue(calResult.rawResult) - if (nv !== null) - numericBlock.push(nv) - continue - } - - const cssResult = evaluateCssLine(trimmed, cssContext) - if (cssResult) { - scope.em = cssContext.emPx - scope.ppi = cssContext.ppi - results.push(cssResult.lineResult) - prevResult = cssResult.rawResult - if (typeof cssResult.rawResult === 'number') - numericBlock.push(cssResult.rawResult) - continue - } - - // --- Date assignment (speculative) --- - const dateAssignResult = evaluateDateAssignmentLine( - processed, - currentDate, + view, + classification, scope, - mathDeps, - ) - if (dateAssignResult) { - results.push(dateAssignResult.lineResult) - prevResult = dateAssignResult.rawResult - continue - } - - // --- Date arithmetic (speculative) --- - const dateArithResult = evaluateDateArithmeticLine( - processed, + cssContext, currentDate, - scope, - mathDeps, - ) - if (dateArithResult) { - results.push(dateArithResult.lineResult) - prevResult = dateArithResult.rawResult - continue - } + numericBlock, + math, + formatter, + currencyServiceState, + currencyUnavailableMessage, + activeLocale, + }) + results.push(evaluated.lineResult) - // --- Local temporal expression --- - const localTemporalResult = parseExplicitLocalTemporalExpression( - processed, - currentDate, - ) - if (localTemporalResult) { - results.push(formatResult(localTemporalResult.date)) - prevResult = localTemporalResult.date - continue + if (evaluated.resetNumericBlock) { + numericBlock = [] } - // --- Stage 4: Evaluate (math.js) --- - const result = math.evaluate(processed, scope) - - if (result === undefined) { - results.push({ value: null, error: null, type: 'empty' }) + if (evaluated.resetPrev) { prevResult = undefined + delete scope.prev continue } - - // Default formatting - const formatted = formatResult(result) + prevResult = evaluated.rawResult if ( - /^[a-z_]\w*\s*=/i.test(trimmed) - && !/^[a-z_]\w*\s*==/i.test(trimmed) + evaluated.numericValue !== undefined + && evaluated.numericValue !== null ) { - formatted.type = 'assignment' + numericBlock.push(evaluated.numericValue) } - - results.push(formatted) - prevResult = result - const nv = getNumericValue(result) - if (nv !== null) - numericBlock.push(nv) } catch (error: any) { results.push({ @@ -700,6 +94,7 @@ export function useMathEngine() { type: 'empty', }) prevResult = undefined + delete scope.prev } } From f0c4d24c9fa6d874ca294e6e6119adaf5fe5ebfe Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 10:04:34 +0300 Subject: [PATCH 12/12] refactor(math): remove legacy preprocess pipeline --- .../__tests__/useMathEngine.test.ts | 5 - .../math-engine/__tests__/rewrite.test.ts | 135 ++-- .../math-engine/pipeline/evaluate.ts | 371 +-------- .../math-engine/pipeline/evaluateHelpers.ts | 116 +++ .../math-engine/pipeline/evaluateMath.ts | 76 ++ .../math-engine/pipeline/evaluateRoutes.ts | 146 ++++ .../math-engine/pipeline/evaluateTypes.ts | 41 + .../math-notebook/math-engine/preprocess.ts | 759 ------------------ 8 files changed, 440 insertions(+), 1209 deletions(-) create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/evaluateHelpers.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/evaluateMath.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/evaluateRoutes.ts create mode 100644 src/renderer/composables/math-notebook/math-engine/pipeline/evaluateTypes.ts delete mode 100644 src/renderer/composables/math-notebook/math-engine/preprocess.ts diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index ad25975c..cb927490 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -1,6 +1,5 @@ /* eslint-disable test/prefer-lowercase-title */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { preprocessMathExpression } from '../math-notebook/math-engine/preprocess' import { useMathEngine } from '../math-notebook/useMathEngine' const TEST_CURRENCY_RATES = { @@ -989,10 +988,6 @@ describe('mixed currency and plain number', () => { expect(result.type).toBe('unit') }) - it('does not infer currency for percentage operands', () => { - expect(preprocessMathExpression('$100 + 10%')).toBe('100 USD + 10 / 100') - }) - it('keeps trailing conversion on the whole inferred expression', () => { const result = evalLine('10 USD + 1 in RUB') expect(result.type).toBe('unit') diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts index 0ac845c1..4d60f3c0 100644 --- a/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/rewrite.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { analysisNormalize } from '../pipeline/analysisNormalize' import { classify } from '../pipeline/classify' import { rewrite } from '../pipeline/rewrite' -import { preprocessMathExpression } from '../preprocess' function rewriteRaw(raw: string): string { const view = analysisNormalize(raw) @@ -10,91 +9,61 @@ function rewriteRaw(raw: string): string { return rewrite(view, classification) } -function oldPreprocess(raw: string): string { - return preprocessMathExpression(raw) -} - -describe('rewrite parity with preprocessMathExpression', () => { +describe('rewrite canonical output', () => { const cases = [ - // Basic arithmetic (no transforms) - '10 + 5', - '2 * 3', - // Grouped numbers - '5 300', - // Degree signs - '45°', - // Scales - '2k', - '3M', - '1.5 billion', - // Currency symbols - '$100', - '€50', - // Currency words - '10 dollars', - // Word operators - '8 times 9', - '10 plus 5', - '3 multiplied by 4', - '100 divide by 4', - '17 mod 5', - // Unit aliases - '1 nautical mile', - // Time units - '3 days', - '2 hours', - // Stacked units - '1 meter 20 cm', - // Area/volume - '20 sq cm', - 'cbm', - // Percentages - '15% of 200', - '200 + 10%', - '5% on 200', - '50 as a % of 100', - // Conversions - '5 km as mile', - '100 celsius into fahrenheit', - // Functions - 'sqrt 16', - 'log 2 (8)', - // Phrase functions - 'square root of 81', - 'log 20 base 4', - // Conditionals - 'if 5 > 3 then 10 else 20', - '42 if 5 > 3', - // Labels - 'Price: $100 + $50', - // Quoted text - '$275 for the "Model 227"', - // Reverse conversion - 'meters in 10 km', - // Shorthand - 'km m', - // Rates - '$50 per week', - // Multipliers - '50 to 75 is what x', - // Implicit multiplication - '6(3)', - // Percentage change - '50 to 75 is what %', - '0.35 as %', - // Additive unit inference - '$100 + 10', - '10 USD + 1 in RUB', - // Power phrase - '3 to the power of 2', - 'remainder of 21 divided by 5', - // Fractions - '2/3 of 600', - ] + ['10 + 5', '10 + 5'], + ['2 * 3', '2 * 3'], + ['5 300', '5300'], + ['45°', '45 deg'], + ['2k', '(2 * 1000)'], + ['3M', '(3 * 1000000)'], + ['1.5 billion', '(1.5 * 1000000000)'], + ['$100', '100 USD'], + ['€50', '50 EUR'], + ['10 dollars', '10 USD'], + ['8 times 9', '8 * 9'], + ['10 plus 5', '10 + 5'], + ['3 multiplied by 4', '3 * 4'], + ['100 divide by 4', '100 / 4'], + ['17 mod 5', '17 % 5'], + ['1 nautical mile', '1 nauticalmile'], + ['3 days', '3 mcday'], + ['2 hours', '2 mchour'], + ['1 meter 20 cm', '(1 meter + 20 cm)'], + ['20 sq cm', '20 cm^2'], + ['cbm', 'm^3'], + ['15% of 200', '15 / 100 * 200'], + ['200 + 10%', '200 * (1 + 10 / 100)'], + ['5% on 200', '200 * (1 + 5 / 100)'], + ['50 as a % of 100', '(50 / 100) * 100'], + ['5 km as mile', '5 km to mile'], + ['100 celsius into fahrenheit', '100 celsius to fahrenheit'], + ['sqrt 16', 'sqrt(16)'], + ['log 2 (8)', 'log(8, 2)'], + ['square root of 81', 'sqrt(81)'], + ['log 20 base 4', 'log(20, 4)'], + ['if 5 > 3 then 10 else 20', '(5 > 3) ? (10) : (20)'], + ['42 if 5 > 3', '(5 > 3) ? (42) : 0'], + ['Price: $100 + $50', '100 USD + 50 USD'], + ['$275 for the "Model 227"', '275 USD'], + ['meters in 10 km', '10 km to meters'], + ['km m', '1 km to m'], + ['$50 per week', '50 USD / mcweek'], + ['50 to 75 is what x', '75 / 50 to multiplier'], + ['6(3)', '6 * (3)'], + ['50 to 75 is what %', '((75 - 50) / 50) * 100'], + ['0.35 as %', '0.35 * 100'], + ['$100 + 10', '100 USD + 10 USD'], + ['$100 + 10%', '100 USD + 10 / 100'], + ['10 USD + 1 in RUB', '(10 USD + 1 USD) to RUB'], + ['3 to the power of 2', '3 ^ 2'], + ['remainder of 21 divided by 5', '21 % 5'], + ['2/3 of 600', '(2 / 3) * 600'], + ] as const - for (const input of cases) { + for (const [input, expected] of cases) { it(`"${input}"`, () => { - expect(rewriteRaw(input)).toBe(oldPreprocess(input)) + expect(rewriteRaw(input)).toBe(expected) }) } }) diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts index d4437fdd..33a0f4d7 100644 --- a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluate.ts @@ -1,372 +1,19 @@ -import type { - AnalysisView, - CssContext, - CurrencyServiceState, - LineClassification, - LineResult, -} from '../types' -import type { LineFormatter } from './format' -import { evaluateCalendarLine } from '../calendar' -import { evaluateCssLine } from '../css' +import type { EvaluateClassifiedLineOptions, MathDeps } from './evaluateTypes' import { evaluateBlockAggregate, evaluateInlineAggregate, } from '../evaluators/aggregates' -import { - evaluateDateArithmeticLine, - evaluateDateAssignmentLine, -} from '../evaluators/dateArithmetic' -import { - evaluateTimeZoneDifferenceLine, - evaluateTimeZoneLine, - parseExplicitLocalTemporalExpression, -} from '../timeZones' import { analysisNormalize } from './analysisNormalize' +import { + hasAnyModifier, + hasUnsupportedModifierCombination, + stripModifierSuffix, + UNSUPPORTED_MODIFIER_ERROR, +} from './evaluateHelpers' +import { evaluateMathPath } from './evaluateMath' +import { evaluatePrimaryIntent } from './evaluateRoutes' import { rewrite } from './rewrite' -const UNSUPPORTED_MODIFIER_ERROR - = 'Modifier is not supported for this expression type' - -interface MathEvaluatorInstance { - evaluate: (expression: string, scope: Record) => any - unit: (value: number, unit: string) => any -} - -interface EvaluateClassifiedLineOptions { - trimmed: string - view: AnalysisView - classification: LineClassification - scope: Record - cssContext: CssContext - currentDate: Date - numericBlock: number[] - math: MathEvaluatorInstance - formatter: LineFormatter - currencyServiceState: CurrencyServiceState - currencyUnavailableMessage: string - activeLocale: string -} - -interface EvaluatedLine { - lineResult: LineResult - rawResult: any - numericValue?: number | null - resetPrev?: boolean - resetNumericBlock?: boolean -} - -interface MathDeps { - mathEvaluate: (expression: string, scope: Record) => any - formatResult: LineFormatter['formatResult'] -} - -function stripModifierSuffix( - raw: string, - classification: LineClassification, -): string { - const lower = raw.toLowerCase() - - if (classification.modifiers.rounding) { - const patterns = [ - /\s+to\s+\d+\s+(?:dp|digits?)$/i, - /\s+rounded\s+(?:up|down)\s+to\s+nearest\s+\w+$/i, - /\s+(?:rounded\s+)?to\s+nearest\s+\w+$/i, - /\s+rounded\s+(?:up|down)$/i, - /\s+rounded$/i, - ] - for (const pattern of patterns) { - const match = lower.match(pattern) - if (match) - return raw.slice(0, match.index!).trim() - } - } - - if (classification.modifiers.stripUnit) { - const suffixes = [ - 'as number', - 'to number', - 'as decimal', - 'to decimal', - 'as dec', - 'to dec', - 'as fraction', - 'to fraction', - ] - for (const suffix of suffixes) { - if (lower.endsWith(suffix)) - return raw.slice(0, raw.length - suffix.length).trim() - } - } - - if (classification.modifiers.resultFormat) { - const suffixes = [ - 'in hex', - 'in bin', - 'in oct', - 'in sci', - 'in scientific', - 'as multiplier', - 'to multiplier', - ] - for (const suffix of suffixes) { - if (lower.endsWith(suffix)) - return raw.slice(0, raw.length - suffix.length).trim() - } - } - - return raw -} - -function hasAnyModifier(classification: LineClassification) { - return Boolean( - classification.modifiers.rounding - || classification.modifiers.stripUnit - || classification.modifiers.resultFormat, - ) -} - -function hasUnsupportedModifierCombination( - classification: LineClassification, - normalizedExpression: string, -): boolean { - if (!hasAnyModifier(classification)) - return false - - if ( - classification.primary === 'timezone' - || classification.primary === 'calendar' - || classification.primary === 'css' - || classification.primary === 'date-arithmetic' - ) { - return true - } - - if ( - classification.primary === 'assignment' - && classification.assignmentTarget - && classification.assignmentTarget !== 'math' - ) { - return true - } - - if ( - classification.primary === 'math' - && /\b(?:today|tomorrow|yesterday|now)\b/.test(normalizedExpression) - ) { - return true - } - - return false -} - -function toEvaluatedLine( - lineResult: LineResult, - rawResult: any, - numericValue?: number | null, -): EvaluatedLine { - return { - lineResult, - rawResult, - numericValue, - } -} - -function evaluatePrimaryIntent( - classification: LineClassification, - trimmed: string, - processed: string, - currentDate: Date, - activeLocale: string, - cssContext: CssContext, - scope: Record, - math: MathEvaluatorInstance, - formatResult: LineFormatter['formatResult'], - getNumericValue: LineFormatter['getNumericValue'], -): EvaluatedLine | null { - switch (classification.primary) { - case 'timezone': { - if (classification.timezoneOperation === 'difference') { - const timeZoneDifferenceResult = evaluateTimeZoneDifferenceLine( - trimmed, - currentDate, - { - createHourUnit: hours => math.unit(hours, 'hour'), - formatResult, - }, - ) - - if (timeZoneDifferenceResult) { - return toEvaluatedLine( - timeZoneDifferenceResult.lineResult, - timeZoneDifferenceResult.rawResult, - getNumericValue(timeZoneDifferenceResult.rawResult), - ) - } - } - - const timeZoneResult = evaluateTimeZoneLine(trimmed, currentDate) - if (timeZoneResult) { - return toEvaluatedLine( - timeZoneResult.lineResult, - timeZoneResult.rawResult, - ) - } - - return null - } - - case 'calendar': { - const calendarResult = evaluateCalendarLine( - trimmed, - currentDate, - activeLocale, - ) - - if (calendarResult) { - return toEvaluatedLine( - calendarResult.lineResult, - calendarResult.rawResult, - getNumericValue(calendarResult.rawResult), - ) - } - - return null - } - - case 'css': - case 'assignment': { - if ( - classification.primary === 'css' - || classification.assignmentTarget === 'css' - ) { - const cssResult = evaluateCssLine(trimmed, cssContext) - if (cssResult) { - scope.em = cssContext.emPx - scope.ppi = cssContext.ppi - return toEvaluatedLine( - cssResult.lineResult, - cssResult.rawResult, - typeof cssResult.rawResult === 'number' - ? cssResult.rawResult - : null, - ) - } - } - - if (classification.assignmentTarget === 'date') { - const dateAssignmentResult = evaluateDateAssignmentLine( - processed, - currentDate, - scope, - { - mathEvaluate: (expression, nextScope) => - math.evaluate(expression, nextScope), - formatResult, - }, - ) - - if (dateAssignmentResult) { - return toEvaluatedLine( - dateAssignmentResult.lineResult, - dateAssignmentResult.rawResult, - ) - } - } - - return null - } - - case 'date-arithmetic': { - const dateArithmeticResult = evaluateDateArithmeticLine( - processed, - currentDate, - scope, - { - mathEvaluate: (expression, nextScope) => - math.evaluate(expression, nextScope), - formatResult, - }, - ) - - if (dateArithmeticResult) { - return toEvaluatedLine( - dateArithmeticResult.lineResult, - dateArithmeticResult.rawResult, - ) - } - - return null - } - - default: - return null - } -} - -function evaluateMathPath( - trimmed: string, - processed: string, - currentDate: Date, - scope: Record, - math: MathEvaluatorInstance, - mathDeps: MathDeps, - formatResult: LineFormatter['formatResult'], - getNumericValue: LineFormatter['getNumericValue'], -): EvaluatedLine { - const dateAssignmentResult = evaluateDateAssignmentLine( - processed, - currentDate, - scope, - mathDeps, - ) - if (dateAssignmentResult) { - return toEvaluatedLine( - dateAssignmentResult.lineResult, - dateAssignmentResult.rawResult, - ) - } - - const dateArithmeticResult = evaluateDateArithmeticLine( - processed, - currentDate, - scope, - mathDeps, - ) - if (dateArithmeticResult) { - return toEvaluatedLine( - dateArithmeticResult.lineResult, - dateArithmeticResult.rawResult, - ) - } - - const localTemporalResult = parseExplicitLocalTemporalExpression( - processed, - currentDate, - ) - if (localTemporalResult) { - return toEvaluatedLine( - formatResult(localTemporalResult.date), - localTemporalResult.date, - ) - } - - const result = math.evaluate(processed, scope) - if (result === undefined) { - return { - lineResult: { value: null, error: null, type: 'empty' }, - rawResult: undefined, - resetPrev: true, - } - } - - const lineResult = formatResult(result) - if (/^[a-z_]\w*\s*=/i.test(trimmed) && !/^[a-z_]\w*\s*==/i.test(trimmed)) { - lineResult.type = 'assignment' - } - - return toEvaluatedLine(lineResult, result, getNumericValue(result)) -} - export function evaluateClassifiedLine( options: EvaluateClassifiedLineOptions, ): EvaluatedLine { diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateHelpers.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateHelpers.ts new file mode 100644 index 00000000..71dd3f21 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateHelpers.ts @@ -0,0 +1,116 @@ +import type { LineClassification, LineResult } from '../types' +import type { EvaluatedLine } from './evaluateTypes' + +export const UNSUPPORTED_MODIFIER_ERROR + = 'Modifier is not supported for this expression type' + +export function stripModifierSuffix( + raw: string, + classification: LineClassification, +): string { + const lower = raw.toLowerCase() + + if (classification.modifiers.rounding) { + const patterns = [ + /\s+to\s+\d+\s+(?:dp|digits?)$/i, + /\s+rounded\s+(?:up|down)\s+to\s+nearest\s+\w+$/i, + /\s+(?:rounded\s+)?to\s+nearest\s+\w+$/i, + /\s+rounded\s+(?:up|down)$/i, + /\s+rounded$/i, + ] + for (const pattern of patterns) { + const match = lower.match(pattern) + if (match) + return raw.slice(0, match.index!).trim() + } + } + + if (classification.modifiers.stripUnit) { + const suffixes = [ + 'as number', + 'to number', + 'as decimal', + 'to decimal', + 'as dec', + 'to dec', + 'as fraction', + 'to fraction', + ] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) + return raw.slice(0, raw.length - suffix.length).trim() + } + } + + if (classification.modifiers.resultFormat) { + const suffixes = [ + 'in hex', + 'in bin', + 'in oct', + 'in sci', + 'in scientific', + 'as multiplier', + 'to multiplier', + ] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) + return raw.slice(0, raw.length - suffix.length).trim() + } + } + + return raw +} + +export function hasAnyModifier(classification: LineClassification) { + return Boolean( + classification.modifiers.rounding + || classification.modifiers.stripUnit + || classification.modifiers.resultFormat, + ) +} + +export function hasUnsupportedModifierCombination( + classification: LineClassification, + normalizedExpression: string, +): boolean { + if (!hasAnyModifier(classification)) + return false + + if ( + classification.primary === 'timezone' + || classification.primary === 'calendar' + || classification.primary === 'css' + || classification.primary === 'date-arithmetic' + ) { + return true + } + + if ( + classification.primary === 'assignment' + && classification.assignmentTarget + && classification.assignmentTarget !== 'math' + ) { + return true + } + + if ( + classification.primary === 'math' + && /\b(?:today|tomorrow|yesterday|now)\b/.test(normalizedExpression) + ) { + return true + } + + return false +} + +export function toEvaluatedLine( + lineResult: LineResult, + rawResult: any, + numericValue?: number | null, +): EvaluatedLine { + return { + lineResult, + rawResult, + numericValue, + } +} diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateMath.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateMath.ts new file mode 100644 index 00000000..4e90616a --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateMath.ts @@ -0,0 +1,76 @@ +import type { + EvaluatedLine, + MathDeps, + MathEvaluatorInstance, +} from './evaluateTypes' +import type { LineFormatter } from './format' +import { + evaluateDateArithmeticLine, + evaluateDateAssignmentLine, +} from '../evaluators/dateArithmetic' +import { parseExplicitLocalTemporalExpression } from '../timeZones' +import { toEvaluatedLine } from './evaluateHelpers' + +export function evaluateMathPath( + trimmed: string, + processed: string, + currentDate: Date, + scope: Record, + math: MathEvaluatorInstance, + mathDeps: MathDeps, + formatResult: LineFormatter['formatResult'], + getNumericValue: LineFormatter['getNumericValue'], +): EvaluatedLine { + const dateAssignmentResult = evaluateDateAssignmentLine( + processed, + currentDate, + scope, + mathDeps, + ) + if (dateAssignmentResult) { + return toEvaluatedLine( + dateAssignmentResult.lineResult, + dateAssignmentResult.rawResult, + ) + } + + const dateArithmeticResult = evaluateDateArithmeticLine( + processed, + currentDate, + scope, + mathDeps, + ) + if (dateArithmeticResult) { + return toEvaluatedLine( + dateArithmeticResult.lineResult, + dateArithmeticResult.rawResult, + ) + } + + const localTemporalResult = parseExplicitLocalTemporalExpression( + processed, + currentDate, + ) + if (localTemporalResult) { + return toEvaluatedLine( + formatResult(localTemporalResult.date), + localTemporalResult.date, + ) + } + + const result = math.evaluate(processed, scope) + if (result === undefined) { + return { + lineResult: { value: null, error: null, type: 'empty' }, + rawResult: undefined, + resetPrev: true, + } + } + + const lineResult = formatResult(result) + if (/^[a-z_]\w*\s*=/i.test(trimmed) && !/^[a-z_]\w*\s*==/i.test(trimmed)) { + lineResult.type = 'assignment' + } + + return toEvaluatedLine(lineResult, result, getNumericValue(result)) +} diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateRoutes.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateRoutes.ts new file mode 100644 index 00000000..c7a33868 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateRoutes.ts @@ -0,0 +1,146 @@ +import type { CssContext, LineClassification } from '../types' +import type { EvaluatedLine, MathEvaluatorInstance } from './evaluateTypes' +import type { LineFormatter } from './format' +import { evaluateCalendarLine } from '../calendar' +import { evaluateCssLine } from '../css' +import { + evaluateDateArithmeticLine, + evaluateDateAssignmentLine, +} from '../evaluators/dateArithmetic' +import { + evaluateTimeZoneDifferenceLine, + evaluateTimeZoneLine, +} from '../timeZones' +import { toEvaluatedLine } from './evaluateHelpers' + +export function evaluatePrimaryIntent( + classification: LineClassification, + trimmed: string, + processed: string, + currentDate: Date, + activeLocale: string, + cssContext: CssContext, + scope: Record, + math: MathEvaluatorInstance, + formatResult: LineFormatter['formatResult'], + getNumericValue: LineFormatter['getNumericValue'], +): EvaluatedLine | null { + switch (classification.primary) { + case 'timezone': { + if (classification.timezoneOperation === 'difference') { + const timeZoneDifferenceResult = evaluateTimeZoneDifferenceLine( + trimmed, + currentDate, + { + createHourUnit: hours => math.unit(hours, 'hour'), + formatResult, + }, + ) + + if (timeZoneDifferenceResult) { + return toEvaluatedLine( + timeZoneDifferenceResult.lineResult, + timeZoneDifferenceResult.rawResult, + getNumericValue(timeZoneDifferenceResult.rawResult), + ) + } + } + + const timeZoneResult = evaluateTimeZoneLine(trimmed, currentDate) + if (timeZoneResult) { + return toEvaluatedLine( + timeZoneResult.lineResult, + timeZoneResult.rawResult, + ) + } + + return null + } + + case 'calendar': { + const calendarResult = evaluateCalendarLine( + trimmed, + currentDate, + activeLocale, + ) + + if (calendarResult) { + return toEvaluatedLine( + calendarResult.lineResult, + calendarResult.rawResult, + getNumericValue(calendarResult.rawResult), + ) + } + + return null + } + + case 'css': + case 'assignment': { + if ( + classification.primary === 'css' + || classification.assignmentTarget === 'css' + ) { + const cssResult = evaluateCssLine(trimmed, cssContext) + if (cssResult) { + scope.em = cssContext.emPx + scope.ppi = cssContext.ppi + return toEvaluatedLine( + cssResult.lineResult, + cssResult.rawResult, + typeof cssResult.rawResult === 'number' + ? cssResult.rawResult + : null, + ) + } + } + + if (classification.assignmentTarget === 'date') { + const dateAssignmentResult = evaluateDateAssignmentLine( + processed, + currentDate, + scope, + { + mathEvaluate: (expression, nextScope) => + math.evaluate(expression, nextScope), + formatResult, + }, + ) + + if (dateAssignmentResult) { + return toEvaluatedLine( + dateAssignmentResult.lineResult, + dateAssignmentResult.rawResult, + ) + } + } + + return null + } + + case 'date-arithmetic': { + const dateArithmeticResult = evaluateDateArithmeticLine( + processed, + currentDate, + scope, + { + mathEvaluate: (expression, nextScope) => + math.evaluate(expression, nextScope), + formatResult, + }, + ) + + if (dateArithmeticResult) { + return toEvaluatedLine( + dateArithmeticResult.lineResult, + dateArithmeticResult.rawResult, + ) + } + + return null + } + + default: + return null + } +} diff --git a/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateTypes.ts b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateTypes.ts new file mode 100644 index 00000000..c0f100b4 --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/pipeline/evaluateTypes.ts @@ -0,0 +1,41 @@ +import type { + AnalysisView, + CssContext, + CurrencyServiceState, + LineClassification, + LineResult, +} from '../types' +import type { LineFormatter } from './format' + +export interface MathEvaluatorInstance { + evaluate: (expression: string, scope: Record) => any + unit: (value: number, unit: string) => any +} + +export interface EvaluateClassifiedLineOptions { + trimmed: string + view: AnalysisView + classification: LineClassification + scope: Record + cssContext: CssContext + currentDate: Date + numericBlock: number[] + math: MathEvaluatorInstance + formatter: LineFormatter + currencyServiceState: CurrencyServiceState + currencyUnavailableMessage: string + activeLocale: string +} + +export interface EvaluatedLine { + lineResult: LineResult + rawResult: any + numericValue?: number | null + resetPrev?: boolean + resetNumericBlock?: boolean +} + +export interface MathDeps { + mathEvaluate: (expression: string, scope: Record) => any + formatResult: LineFormatter['formatResult'] +} diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts deleted file mode 100644 index 0bae574a..00000000 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { - currencySymbols, - currencyWordNames, - knownUnitTokens, - MATH_UNARY_FUNCTIONS, - SUPPORTED_CURRENCY_CODES, - TIME_UNIT_TOKEN_MAP, - weightContextPattern, -} from './constants' -import { splitByKeyword } from './utils' - -function preprocessLabels(line: string): string { - const match = line.match(/^([a-z][a-z0-9]*(?:\s[a-z0-9]+)*):\s(\S.*)$/i) - if (match) - return match[2] - return line -} - -function preprocessQuotedText(line: string): string { - const stripped = line - .replace(/"[^"]*"/g, ' ') - .replace(/\s+/g, ' ') - .trim() - - if (stripped === line) { - return line - } - - const tokens = stripped.split(' ') - while (tokens.length > 1) { - const lastToken = tokens.at(-1)!.toLowerCase() - - if (!/^[a-z-]+$/i.test(lastToken) || knownUnitTokens.has(lastToken)) { - break - } - - tokens.pop() - } - - return tokens.join(' ') -} - -function sanitizeForCurrencyDetection(line: string) { - return preprocessQuotedText(preprocessLabels(line)) -} - -function preprocessGroupedNumbers(line: string): string { - let result = line.replace(/\b\d{1,3}(?:\s\d{3})+(?=\b|[a-z])/gi, match => - match.replace(/\s+/g, '')) - - result = result.replace(/\b\d+(?:\s+\d+)+\b/g, match => - match.replace(/\s+/g, '')) - - return result -} - -function preprocessDegreeSigns(line: string): string { - return line.replace(/(-?\d+(?:\.\d+)?)\s*°/g, '$1 deg') -} - -function preprocessTimeUnits(line: string): string { - return line.replace( - /\b(seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b/gi, - match => TIME_UNIT_TOKEN_MAP[match.toLowerCase()] || match, - ) -} - -function normalizePowerUnit(unit: string) { - switch (unit.toLowerCase()) { - case 'inches': - case 'inch': - return 'inch' - case 'feet': - case 'foot': - case 'ft': - return 'ft' - case 'meters': - case 'meter': - case 'm': - return 'm' - case 'centimeter': - case 'centimeters': - case 'cm': - return 'cm' - case 'millimeter': - case 'millimeters': - case 'mm': - return 'mm' - case 'kilometer': - case 'kilometers': - case 'km': - return 'km' - case 'yards': - case 'yard': - return 'yard' - case 'miles': - case 'mile': - return 'mile' - default: - return unit.toLowerCase() - } -} - -function preprocessReverseConversion(line: string): string { - // "meters in 10 km" → "10 km to meters" - // "days in 3 weeks" → "3 weeks to days" - return line.replace( - /^([a-z]+)\s+in\s+(\d+(?:\.\d+)?)\s+([a-z]+)$/i, - (_, targetUnit: string, value: string, sourceUnit: string) => { - if ( - knownUnitTokens.has(targetUnit.toLowerCase()) - && knownUnitTokens.has(sourceUnit.toLowerCase()) - ) { - return `${value} ${sourceUnit} to ${targetUnit}` - } - return _ - }, - ) -} - -function preprocessShorthandConversion(line: string): string { - // "km m" → "1 km to m" (two unit names = show conversion factor) - return line.replace( - /^([a-z]+)\s+([a-z]+)$/i, - (_, unit1: string, unit2: string) => { - if ( - knownUnitTokens.has(unit1.toLowerCase()) - && knownUnitTokens.has(unit2.toLowerCase()) - ) { - return `1 ${unit1} to ${unit2}` - } - return _ - }, - ) -} - -function preprocessUnitAliases(line: string): string { - return line - .replace(/\btea\s+spoons?\b/gi, 'teaspoon') - .replace(/\btable\s+spoons?\b/gi, 'tablespoon') - .replace(/\bnautical\s+miles?\b/gi, 'nauticalmile') - .replace(/\blight\s+years?\b/gi, 'lightyear') - .replace(/\bkm\/h\b/gi, 'kmh') -} - -function preprocessAreaVolumeAliases(line: string): string { - line = line.replace(/\bsqm\b/gi, 'm^2') - line = line.replace(/\bcbm\b/gi, 'm^3') - - line = line.replace( - /(\d+(?:\.\d+)?)\s+(sq|square)\s+([a-z]+)/gi, - (_, value: string, _prefix: string, unit: string) => - `${value} ${normalizePowerUnit(unit)}^2`, - ) - line = line.replace( - /(\b(?:in|to|as|into)\s+)(sq|square)\s+([a-z]+)/gi, - (_, prefix: string, _keyword: string, unit: string) => - `${prefix}${normalizePowerUnit(unit)}^2`, - ) - line = line.replace( - /(\d+(?:\.\d+)?)\s+(cu|cubic|cb)\s+([a-z]+)/gi, - (_, value: string, _prefix: string, unit: string) => - `${value} ${normalizePowerUnit(unit)}^3`, - ) - line = line.replace( - /(\b(?:in|to|as|into)\s+)(cu|cubic|cb)\s+([a-z]+)/gi, - (_, prefix: string, _keyword: string, unit: string) => - `${prefix}${normalizePowerUnit(unit)}^3`, - ) - - return line -} - -function preprocessRates(line: string): string { - return ( - line - // "X per Y" → "X / Y" (e.g. $99 per week → $99 / week) - .replace(/\bper\b/gi, '/') - // "X at Y/Z" where Y/Z is a rate → "X * Y / Z" - // "30 hours at $30/hour" → "30 hours * $30 / hour" - .replace( - /(\d+(?:\.\d+)?)\s+(\w+)\s+at\s+(\S+)\/(\w+)/gi, - '$1 $2 * $3 / $4', - ) - // "X a day for Y" → "X / day * Y" (e.g. $24 a day for a year) - .replace( - /(\S+)\s+a\s+(day|week|month|year)\s+for\s+(?:a\s+)?(\S+)/gi, - '$1 / $2 * 1 $3', - ) - ) -} - -function preprocessMultipliers(line: string): string { - return ( - line - // X to Y is what x → Y / X as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s+x\b/gi, - '$2 / $1 as multiplier', - ) - // X to Y as x → Y / X as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s+x\b/gi, - '$2 / $1 as multiplier', - ) - // X as x of Y → X / Y as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+as\s+x\s+of\s+(\d+(?:\.\d+)?)/gi, - '$1 / $2 as multiplier', - ) - // X as multiplier of Y → X / Y as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+of\s+(\d+(?:\.\d+)?)/gi, - '$1 / $2 as multiplier', - ) - // X as multiplier on Y → (X - Y) / Y as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+on\s+(\d+(?:\.\d+)?)/gi, - '($1 - $2) / $2 as multiplier', - ) - // X as x off Y → (Y - X) / Y as multiplier - .replace( - /(\d+(?:\.\d+)?)\s+as\s+x\s+off\s+(\d+(?:\.\d+)?)/gi, - '($2 - $1) / $2 as multiplier', - ) - ) -} - -function preprocessPhraseFunctions(line: string): string { - return line - .replace(/\bsquare\s+root\s+of\s+(\S+)/gi, 'sqrt($1)') - .replace(/\bcube\s+root\s+of\s+(\S+)/gi, 'cbrt($1)') - .replace(/\broot\s+(\d+)\s+of\s+(\S+)/gi, 'root($1, $2)') - .replace(/\blog\s+(\S+)\s+base\s+(\S+)/gi, 'log($1, $2)') - .replace( - /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s+to\s+(?:what|the\s+what)\s*(?:power)?/gi, - 'log($1) / log($2)', - ) - .replace(/\b(sin|cos|tan)\((\d+(?:\.\d+)?)\s+degrees\)/gi, '$1($2 deg)') -} - -function preprocessFunctionExpression(expression: string): string { - const trimmed = expression.trim() - const openIndex = trimmed.indexOf('(') - const closeIndex = trimmed.endsWith(')') ? trimmed.length - 1 : -1 - - if (openIndex > 0 && closeIndex > openIndex) { - if (trimmed.toLowerCase().startsWith('root ')) { - const degree = trimmed.slice(5, openIndex).trim() - const value = trimmed.slice(openIndex + 1, closeIndex).trim() - if (degree && value) { - return `root(${degree}, ${value})` - } - } - - if (trimmed.toLowerCase().startsWith('log ')) { - const base = trimmed.slice(4, openIndex).trim() - const value = trimmed.slice(openIndex + 1, closeIndex).trim() - if (base && value) { - return `log(${value}, ${base})` - } - } - } - - const unaryFunctionsPattern = MATH_UNARY_FUNCTIONS.join('|') - const unaryMatch = trimmed.match( - new RegExp(`^(${unaryFunctionsPattern})\\s+(.+)$`, 'i'), - ) - if (unaryMatch && !unaryMatch[2].trim().startsWith('(')) { - return `${unaryMatch[1]}(${unaryMatch[2].trim()})` - } - - return expression -} - -function preprocessFunctionSyntax(line: string): string { - const assignmentIndex = line.indexOf('=') - if (assignmentIndex > 0) { - const left = line.slice(0, assignmentIndex).trim() - const right = line.slice(assignmentIndex + 1).trim() - - if (/^[a-z_]\w*$/i.test(left) && right) { - return `${line.slice(0, assignmentIndex + 1)} ${preprocessFunctionExpression(right)}` - } - } - - return preprocessFunctionExpression(line) -} - -function preprocessFunctionConversions(line: string): string { - const unaryFunctionsPattern = MATH_UNARY_FUNCTIONS.join('|') - - return line.replace( - new RegExp( - `(^|[=,+\\-*/(]\\s*)(${unaryFunctionsPattern})\\(([^()]+?)\\s+(?:in|to|as|into)\\s+([a-z][a-z0-9^]*)\\)`, - 'gi', - ), - (_, prefix: string, fn: string, source: string, target: string) => { - return `${prefix}${fn}(unitValue(to(${source.trim()}, ${target.trim()})))` - }, - ) -} - -function preprocessCurrencySymbols(line: string): string { - let nextLine = line.replace( - /R\$\s*(\d+(?:\.\d+)?(?:\s*[kKM])?)\b/g, - '$1 BRL', - ) - - for (const [symbol, code] of Object.entries(currencySymbols)) { - if (symbol === 'R$') - continue - - const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - nextLine = nextLine.replace( - new RegExp(`${escaped}\\s*(\\d+(?:\\.\\d+)?(?:\\s*(?:k|M))?)\\b`, 'g'), - `$1 ${code}`, - ) - } - - return nextLine -} - -function preprocessCurrencyWords(line: string): string { - return line.replace( - /(\d+(?:\.\d+)?)\s+(dollars?|euros?|pounds?|roubles?|rubles?|yen|yuan|rupees?|reais|real|pesos?)\b/gi, - ( - _, - amount: string, - currencyName: string, - _offset: number, - fullLine: string, - ) => { - const lowerCurrencyName = currencyName.toLowerCase() - - if ( - (lowerCurrencyName === 'pound' || lowerCurrencyName === 'pounds') - && weightContextPattern.test(fullLine) - ) { - return `${amount} ${currencyName}` - } - - const code = currencyWordNames[lowerCurrencyName] - return code ? `${amount} ${code}` : `${amount} ${currencyName}` - }, - ) -} - -function splitTopLevelAddSubTerms(expression: string) { - const terms: string[] = [] - const operators: Array<'+' | '-'> = [] - let depth = 0 - let segmentStart = 0 - - for (let index = 0; index < expression.length; index++) { - const char = expression[index] - - if (char === '(') { - depth += 1 - continue - } - - if (char === ')') { - depth = Math.max(0, depth - 1) - continue - } - - if (depth !== 0 || (char !== '+' && char !== '-')) { - continue - } - - let prevIndex = index - 1 - while (prevIndex >= 0 && /\s/.test(expression[prevIndex])) { - prevIndex -= 1 - } - - let nextIndex = index + 1 - while (nextIndex < expression.length && /\s/.test(expression[nextIndex])) { - nextIndex += 1 - } - - if (prevIndex < 0 || nextIndex >= expression.length) { - continue - } - - if ('+-*/%^,('.includes(expression[prevIndex])) { - continue - } - - terms.push(expression.slice(segmentStart, index).trim()) - operators.push(char) - segmentStart = index + 1 - } - - if (operators.length === 0) { - return null - } - - terms.push(expression.slice(segmentStart).trim()) - - if (terms.some(term => !term)) { - return null - } - - return { terms, operators } -} - -function extractAdditiveUnit(term: string) { - const match = term.match(/^-?\d+(?:\.\d+)?\s+([a-z][a-z0-9^]*)$/i) - if (!match) { - return null - } - - return match[1].toUpperCase() - in Object.fromEntries(SUPPORTED_CURRENCY_CODES.map(code => [code, true])) - ? match[1].toUpperCase() - : match[1] -} - -function isPlainNumberTerm(term: string) { - return /^-?\d+(?:\.\d+)?$/.test(term) -} - -function inferAdditiveUnitsForPlainNumbers(expression: string) { - const split = splitTopLevelAddSubTerms(expression) - if (!split) { - return expression - } - - const explicitUnits = split.terms.map(extractAdditiveUnit) - - let changed = false - const inferredTerms = split.terms.map((term, index) => { - if (!isPlainNumberTerm(term)) { - return term - } - - const previousUnit = explicitUnits.slice(0, index).reverse().find(Boolean) - const nextUnit = explicitUnits.slice(index + 1).find(Boolean) - const inheritedUnit = previousUnit || nextUnit - - if (!inheritedUnit) { - return term - } - - changed = true - return `${term} ${inheritedUnit}` - }) - - if (!changed) { - return expression - } - - let rebuilt = inferredTerms[0] - for (let index = 0; index < split.operators.length; index++) { - rebuilt += ` ${split.operators[index]} ${inferredTerms[index + 1]}` - } - - return rebuilt -} - -function inferAdditiveUnits(line: string): string { - const conversionSplit = splitByKeyword(line, [ - ' into ', - ' as ', - ' to ', - ' in ', - ]) - const sourceExpression = conversionSplit ? conversionSplit[0] : line - const conversionTarget = conversionSplit ? conversionSplit[1] : null - - const inferredSource = inferAdditiveUnitsForPlainNumbers(sourceExpression) - const hasAdditiveSource = splitTopLevelAddSubTerms(sourceExpression) !== null - - if (!hasAdditiveSource) { - return line - } - - if (!conversionTarget) { - return inferredSource - } - - const groupedSource = `(${inferredSource})` - return `${groupedSource} to ${conversionTarget}` -} - -function preprocessScales(line: string): string { - return line - .replace(/(\d+(?:\.\d+)?)\s*k\b/g, '($1 * 1000)') - .replace(/(\d+(?:\.\d+)?)\s*M\b/g, '($1 * 1000000)') - .replace(/(\d+(?:\.\d+)?)\s+billion\b/gi, '($1 * 1000000000)') - .replace(/(\d+(?:\.\d+)?)\s+million\b/gi, '($1 * 1000000)') - .replace(/(\d+(?:\.\d+)?)\s+thousand\b/gi, '($1 * 1000)') -} - -function preprocessStackedUnits(line: string): string { - return line.replace( - /-?\d+(?:\.\d+)?\s+[a-z]+(?:\s+-?\d+(?:\.\d+)?\s+[a-z]+)+/gi, - (match) => { - const tokens = match.trim().split(/\s+/) - const pairs: string[] = [] - - for (let index = 0; index < tokens.length; index += 2) { - if (!/^-?\d+(?:\.\d+)?$/.test(tokens[index])) { - return match - } - - const unit = tokens[index + 1]?.toLowerCase() - if (!unit || !knownUnitTokens.has(unit)) { - return match - } - - pairs.push(`${tokens[index]} ${tokens[index + 1]}`) - } - - return pairs.length >= 2 ? `(${pairs.join(' + ')})` : match - }, - ) -} - -function preprocessImplicitMultiplication(line: string): string { - return line.replace(/\b(\d+)\s*\(/g, '$1 * (') -} - -function preprocessConditionals(line: string): string { - const lower = line.toLowerCase() - - // if COND then EXPR else EXPR → (COND) ? (EXPR) : (EXPR) - const thenIdx = lower.indexOf(' then ') - const elseIdx = lower.indexOf(' else ') - if (/^if\s/i.test(line) && thenIdx > 2 && elseIdx > thenIdx) { - const cond = line.slice(3, thenIdx).trim() - const thenExpr = line.slice(thenIdx + 6, elseIdx).trim() - const elseExpr = line.slice(elseIdx + 6).trim() - return `(${cond}) ? (${thenExpr}) : (${elseExpr})` - } - - // EXPR unless COND → (COND) ? 0 : (EXPR) (check before postfix if) - const unlessIdx = lower.indexOf(' unless ') - if (unlessIdx > 0) { - const expr = line.slice(0, unlessIdx).trim() - const cond = line.slice(unlessIdx + 8).trim() - return `(${cond}) ? 0 : (${expr})` - } - - // EXPR if COND → (COND) ? (EXPR) : 0 - const ifIdx = lower.indexOf(' if ') - if (ifIdx > 0) { - const expr = line.slice(0, ifIdx).trim() - const cond = line.slice(ifIdx + 4).trim() - return `(${cond}) ? (${expr}) : 0` - } - - // && → and, || → or - return line.replace(/&&/g, ' and ').replace(/\|\|/g, ' or ') -} - -function preprocessWordOperators(line: string): string { - const hasConditional = /\?|[><=!]=?|\btrue\b|\bfalse\b/.test(line) - - let result = line - .replace(/\bremainder\s+of\s+(\S+)\s+divided\s+by\s+(\S+)/gi, '$1 % $2') - .replace(/\bto\s+the\s+power\s+of\b/gi, '^') - .replace(/\bmultiplied\s+by\b/gi, '*') - .replace(/\bdivided\s+by\b/gi, '/') - .replace(/\bdivide\s+by\b/gi, '/') - .replace(/(\S+)\s+xor\s+(\S+)/gi, 'bitXor($1, $2)') - .replace(/\btimes\b/gi, '*') - .replace(/\bdivide\b/gi, '/') - .replace(/\bplus\b/gi, '+') - .replace(/\bwith\b/gi, '+') - .replace(/\bminus\b/gi, '-') - .replace(/\bsubtract\b/gi, '-') - .replace(/\bwithout\b/gi, '-') - .replace(/\bmul\b/gi, '*') - .replace(/\bmod\b/gi, '%') - - if (!hasConditional) { - result = result.replace(/\band\b/gi, '+') - } - - return result -} - -function preprocessPercentages(line: string): string { - return ( - line - // Z is X/Y of what → Z / (X/Y) - .replace( - /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+what\b/gi, - '$1 / ($2 / $3)', - ) - // X/Y of Z → (X/Y) * Z (fraction of value) - .replace( - /(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\b/gi, - '($1 / $2) * $3', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / ($1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 + $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 - $1 / 100)', - ) - // X is Y% of what → X / (Y / 100) - .replace( - /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+of\s+what\b/gi, - '$1 / ($2 / 100)', - ) - // X is Y% on what → X / (1 + Y/100) - .replace( - /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+on\s+what\b/gi, - '$1 / (1 + $2 / 100)', - ) - // X is Y% off what → X / (1 - Y/100) - .replace( - /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+off\s+what\b/gi, - '$1 / (1 - $2 / 100)', - ) - // X to Y is what % → ((Y - X) / X) * 100 - .replace( - /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s*%/gi, - '(($2 - $1) / $1) * 100', - ) - // X to Y as % → ((Y - X) / X) * 100 - .replace( - /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s*%/gi, - '(($2 - $1) / $1) * 100', - ) - // X is what % off Y → ((Y - X) / Y) * 100 - .replace( - /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+off\s+(\d+(?:\.\d+)?)/gi, - '(($2 - $1) / $2) * 100', - ) - // X is what % on Y → ((X - Y) / Y) * 100 - .replace( - /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+on\s+(\d+(?:\.\d+)?)/gi, - '(($1 - $2) / $2) * 100', - ) - // X is what % of Y → (X / Y) * 100 - .replace( - /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+of\s+(\d+(?:\.\d+)?)/gi, - '($1 / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, - '($1 / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, - '(($1 - $2) / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, - '(($2 - $1) / $2) * 100', - ) - // X/Y as % → (X / Y) * 100 - .replace( - /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+as\s*%/gi, - '($1 / $2) * 100', - ) - // X/Y % → (X / Y) * 100 - .replace( - /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*%(?!\s*\w)/g, - '($1 / $2) * 100', - ) - // 0.35 as % → 0.35 * 100 - .replace(/(\d+(?:\.\d+)?)\s+as\s*%/gi, '$1 * 100') - .replace( - /(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, - '$2 * (1 + $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, - '$2 * (1 - $1 / 100)', - ) - .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') - .replace( - /(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, - '$1 * (1 + $2 / 100)', - ) - .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') - .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') - ) -} - -function preprocessConversions(line: string): string { - return line.replace(/\s+as\s+/gi, ' to ').replace(/\s+into\s+/gi, ' to ') -} - -export function hasCurrencyExpression(line: string) { - const sanitized = sanitizeForCurrencyDetection(line) - - if (!sanitized) { - return false - } - - if ( - Object.keys(currencySymbols).some(symbol => sanitized.includes(symbol)) - ) { - return true - } - - const currencyCodePattern = new RegExp( - `\\b(${SUPPORTED_CURRENCY_CODES.join('|')})\\b`, - 'i', - ) - if (currencyCodePattern.test(sanitized)) { - return true - } - - return Object.keys(currencyWordNames).some((currencyName) => { - if (!new RegExp(`\\b${currencyName}\\b`, 'i').test(sanitized)) { - return false - } - - if ( - (currencyName === 'pound' || currencyName === 'pounds') - && weightContextPattern.test(sanitized) - ) { - return false - } - - return true - }) -} - -export function preprocessMathExpression(line: string) { - let processed = preprocessLabels(line) - processed = preprocessQuotedText(processed) - processed = preprocessReverseConversion(processed) - processed = preprocessShorthandConversion(processed) - processed = preprocessGroupedNumbers(processed) - processed = preprocessDegreeSigns(processed) - processed = preprocessUnitAliases(processed) - processed = preprocessTimeUnits(processed) - processed = preprocessCurrencySymbols(processed) - processed = preprocessCurrencyWords(processed) - processed = preprocessScales(processed) - processed = preprocessAreaVolumeAliases(processed) - processed = preprocessStackedUnits(processed) - processed = preprocessRates(processed) - processed = preprocessMultipliers(processed) - processed = preprocessPhraseFunctions(processed) - processed = preprocessFunctionSyntax(processed) - processed = preprocessFunctionConversions(processed) - processed = preprocessImplicitMultiplication(processed) - processed = preprocessConditionals(processed) - processed = preprocessWordOperators(processed) - processed = inferAdditiveUnits(processed) - processed = preprocessPercentages(processed) - processed = preprocessConversions(processed) - return processed -}