File size: 2,632 Bytes
e5faeaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/* ------------------------------------------------------------------ *
 * 安全四則運算求值。
 *
 * 模型輸出的 expression 屬於不可信輸入,因此:
 *   - 先用正則白色清單擋掉任何運算子以外的字元
 *   - 再用遞迴下降解析器求值
 * 全程不用 Function() / eval() / new Function,沒有代碼執行面。
 * ------------------------------------------------------------------ */

const CALC_RE = /^[\d\s+\-*/().%]+$/

/** 把表達式轉成計算結果字串;任何異常都轉成錯誤訊息回傳,不丟例外。 */
export function safeCalc(expression) {
  if (typeof expression !== 'string' || !expression.trim()) {
    return '計算錯誤:表達式為空'
  }
  if (!CALC_RE.test(expression)) {
    return `計算錯誤:只支援數字與 + - * / ( ) . %(收到「${expression}」)`
  }
  try {
    const value = evaluate(expression.replace(/\s+/g, ''))
    if (!Number.isFinite(value)) return `計算錯誤:結果無效(${expression})`
    // 消掉浮點誤差:0.1 + 0.2 應顯示 0.3
    return `計算結果:${Math.round(value * 1e10) / 1e10}`
  } catch (e) {
    return `計算錯誤:${expression}(${e.message})`
  }
}

/** 遞迴下降解析器:+ - 最外層,* / % 次之,數字/括號最內層。 */
function evaluate(expr) {
  let pos = 0
  const peek = () => expr[pos]
  const take = () => expr[pos++]

  function parseExpr() {
    let left = parseTerm()
    while (peek() === '+' || peek() === '-') {
      const op = take()
      const right = parseTerm()
      left = op === '+' ? left + right : left - right
    }
    return left
  }

  function parseTerm() {
    let left = parseFactor()
    while (peek() === '*' || peek() === '/' || peek() === '%') {
      const op = take()
      const right = parseFactor()
      left = op === '*' ? left * right : op === '/' ? left / right : left % right
    }
    return left
  }

  function parseFactor() {
    if (peek() === '-') { take(); return -parseFactor() }
    if (peek() === '+') { take(); return parseFactor() }
    if (peek() === '(') {
      take()
      const value = parseExpr()
      if (peek() !== ')') throw new Error('括號不配對')
      take()
      return value
    }
    const start = pos
    while (pos < expr.length && /[\d.]/.test(expr[pos])) pos++
    if (start === pos) throw new Error('預期數字')
    const value = Number(expr.slice(start, pos))
    if (Number.isNaN(value)) throw new Error('數字格式錯誤')
    return value
  }

  const result = parseExpr()
  if (pos !== expr.length) throw new Error('表達式有多餘字元')
  return result
}