@@ -79,8 +79,9 @@ function findClosingParen(str, start) {
7979// --- Internal debounce for heavy operations ---
8080let heavyTimer = null ;
8181let lastHeavyResult = null ;
82+ let heavyGeneration = 0 ; // monotonic counter to discard stale results
8283
83- function search ( expression ) {
84+ function search ( expression , onHeavyResult ) {
8485 if ( ! expression . trim ( ) ) return [ ] ;
8586 const trimmed = expression . trim ( ) ;
8687 const detected = detectMathMode ( trimmed ) ;
@@ -92,8 +93,16 @@ function search(expression) {
9293 return handleEvaluate ( trimmed ) ;
9394 }
9495
95- // Heavy operations - debounce internally
96- return handleHeavy ( detected ) ;
96+ // Heavy operations — show loading, compute async
97+ clearTimeout ( heavyTimer ) ;
98+ const gen = ++ heavyGeneration ;
99+ heavyTimer = setTimeout ( ( ) => {
100+ const results = handleHeavy ( detected ) ;
101+ if ( gen === heavyGeneration && typeof onHeavyResult === 'function' ) {
102+ onHeavyResult ( results ) ;
103+ }
104+ } , 0 ) ;
105+ return [ { type : 'calc-loading' } ] ;
97106}
98107
99108function detectMathMode ( expr ) {
@@ -526,23 +535,34 @@ const MATH_FUNCS_2 = {
526535const MATH_CONSTS = { pi : Math . PI , e : Math . E } ;
527536
528537function evaluate ( expr ) {
529- const tokens = tokenize ( expr ) ;
530- if ( ! tokens || tokens . length === 0 ) return undefined ;
531- const ctx = { tokens, pos : 0 } ;
532- const result = parseExpr ( ctx ) ;
533- if ( ctx . pos < ctx . tokens . length ) return undefined ;
534- return result ;
538+ try {
539+ const tokens = tokenize ( expr ) ;
540+ if ( ! tokens || tokens . length === 0 ) return undefined ;
541+ const ctx = { tokens, pos : 0 } ;
542+ const result = parseExpr ( ctx ) ;
543+ if ( ctx . pos < ctx . tokens . length ) return undefined ;
544+ // Never return NaN or Infinity — those are domain errors, not valid results
545+ if ( typeof result !== 'number' || ! isFinite ( result ) ) return undefined ;
546+ return result ;
547+ } catch {
548+ return undefined ;
549+ }
535550}
536551
537552function tokenize ( expr ) {
538553 const out = [ ] ;
539- const s = expr . replace ( / \s + / g, '' ) ;
554+ const s = expr . replace ( / \s + / g, '' ) ;
540555 let i = 0 ;
541556 while ( i < s . length ) {
542557 const ch = s [ i ] ;
543558 if ( ( ch >= '0' && ch <= '9' ) || ( ch === '.' && i + 1 < s . length && s [ i + 1 ] >= '0' && s [ i + 1 ] <= '9' ) ) {
544559 let num = '' ;
545- while ( i < s . length && ( ( s [ i ] >= '0' && s [ i ] <= '9' ) || s [ i ] === '.' ) ) { num += s [ i ++ ] ; }
560+ let dots = 0 ;
561+ while ( i < s . length && ( ( s [ i ] >= '0' && s [ i ] <= '9' ) || s [ i ] === '.' ) ) {
562+ if ( s [ i ] === '.' ) dots ++ ;
563+ if ( dots > 1 ) return null ; // e.g. "1.2.3" — invalid number
564+ num += s [ i ++ ] ;
565+ }
546566 out . push ( { type : 'num' , value : parseFloat ( num ) } ) ;
547567 } else if ( ( ch >= 'a' && ch <= 'z' ) || ( ch >= 'A' && ch <= 'Z' ) ) {
548568 let id = '' ;
@@ -562,7 +582,38 @@ function tokenize(expr) {
562582 return null ;
563583 }
564584 }
565- return out ;
585+ // Insert implicit multiplication tokens between adjacent value-producing tokens
586+ return insertImplicitMul ( out ) ;
587+ }
588+
589+ // Detect adjacencies that imply multiplication and insert * tokens
590+ function insertImplicitMul ( tokens ) {
591+ if ( ! tokens || tokens . length < 2 ) return tokens ;
592+ const result = [ tokens [ 0 ] ] ;
593+ for ( let i = 1 ; i < tokens . length ; i ++ ) {
594+ const prev = tokens [ i - 1 ] ;
595+ const cur = tokens [ i ] ;
596+ const needsMul =
597+ // num followed by func: 2sin(...)
598+ ( prev . type === 'num' && cur . type === 'func' ) ||
599+ // num followed by (: 2(3+4)
600+ ( prev . type === 'num' && cur . type === 'op' && cur . value === '(' ) ||
601+ // num followed by num (constant): 2pi
602+ ( prev . type === 'num' && cur . type === 'num' ) ||
603+ // ) followed by (: (2+3)(4+5)
604+ ( prev . type === 'op' && prev . value === ')' && cur . type === 'op' && cur . value === '(' ) ||
605+ // ) followed by func: (2)sin(pi)
606+ ( prev . type === 'op' && prev . value === ')' && cur . type === 'func' ) ||
607+ // ) followed by num: (2+3)4
608+ ( prev . type === 'op' && prev . value === ')' && cur . type === 'num' ) ||
609+ // % followed by num/func/(: 50%2 → 0.5*2
610+ ( prev . type === 'op' && prev . value === '%' && ( cur . type === 'num' || cur . type === 'func' || ( cur . type === 'op' && cur . value === '(' ) ) ) ;
611+ if ( needsMul ) {
612+ result . push ( { type : 'op' , value : '*' } ) ;
613+ }
614+ result . push ( cur ) ;
615+ }
616+ return result ;
566617}
567618
568619function peek ( ctx ) { return ctx . pos < ctx . tokens . length ? ctx . tokens [ ctx . pos ] : null ; }
@@ -658,11 +709,50 @@ function parsePrimary(ctx) {
658709 throw new Error ( 'unexpected token' ) ;
659710}
660711
712+ // Return token info for syntax highlighting in the input overlay.
713+ // Each token: { text, type: 'func'|'const'|'number'|'op'|'unknown', start, end, name? }
714+ function getCalcTokens ( expr ) {
715+ const tokens = [ ] ;
716+ const s = expr ;
717+ let i = 0 ;
718+ while ( i < s . length ) {
719+ const ch = s [ i ] ;
720+ if ( ch === ' ' || ch === '\t' ) {
721+ i ++ ;
722+ continue ;
723+ }
724+ if ( ( ch >= '0' && ch <= '9' ) || ( ch === '.' && i + 1 < s . length && s [ i + 1 ] >= '0' && s [ i + 1 ] <= '9' ) ) {
725+ const start = i ;
726+ while ( i < s . length && ( ( s [ i ] >= '0' && s [ i ] <= '9' ) || s [ i ] === '.' ) ) i ++ ;
727+ tokens . push ( { text : s . slice ( start , i ) , type : 'number' , start, end : i } ) ;
728+ } else if ( ( ch >= 'a' && ch <= 'z' ) || ( ch >= 'A' && ch <= 'Z' ) ) {
729+ const start = i ;
730+ while ( i < s . length && ( ( s [ i ] >= 'a' && s [ i ] <= 'z' ) || ( s [ i ] >= 'A' && s [ i ] <= 'Z' ) ) ) i ++ ;
731+ const word = s . slice ( start , i ) ;
732+ const lower = word . toLowerCase ( ) ;
733+ if ( MATH_CONSTS [ lower ] !== undefined ) {
734+ tokens . push ( { text : word , type : 'const' , start, end : i , name : lower } ) ;
735+ } else if ( MATH_FUNCS [ lower ] || MATH_FUNCS_2 [ lower ] ) {
736+ tokens . push ( { text : word , type : 'func' , start, end : i , name : lower } ) ;
737+ } else {
738+ tokens . push ( { text : word , type : 'unknown' , start, end : i } ) ;
739+ }
740+ } else if ( '+-*/^%(),' . includes ( ch ) ) {
741+ tokens . push ( { text : ch , type : 'op' , start : i , end : i + 1 } ) ;
742+ i ++ ;
743+ } else {
744+ tokens . push ( { text : ch , type : 'unknown' , start : i , end : i + 1 } ) ;
745+ i ++ ;
746+ }
747+ }
748+ return tokens ;
749+ }
750+
661751if ( typeof window !== 'undefined' ) {
662- window . _calculator = { search } ;
752+ window . _calculator = { search, getCalcTokens } ;
663753}
664754
665755// Allow tests to import internals directly
666756if ( typeof module !== 'undefined' && module . exports ) {
667- module . exports = { search, evaluate, prepareForNerdamer, findClosingParen, hasVariables, getVariables, detectMathMode } ;
757+ module . exports = { search, evaluate, prepareForNerdamer, findClosingParen, hasVariables, getVariables, detectMathMode, tokenize } ;
668758}
0 commit comments