Class: Synthra::Types::Formula::SafeArithmetic

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/types/formula.rb

Overview

Sandboxed arithmetic evaluator: numbers, + - * / % **, parentheses, and the whitelisted math functions below. No eval, so it is safe for tenant-supplied expressions (the RCE foot-gun is removed). Returns nil for anything outside this grammar — the caller treats nil as 0. Recursive-descent; state is per-instance so it is safe to reuse the type across generations.

Constant Summary collapse

FUNCTIONS =
{
  "min"   => ->(a) { a.min },
  "max"   => ->(a) { a.max },
  "abs"   => ->(a) { a.first.abs },
  "sqrt"  => ->(a) { Math.sqrt(a.first) },
  "floor" => ->(a) { a.first.floor },
  "ceil"  => ->(a) { a.first.ceil },
  "round" => ->(a) { a.length > 1 ? a[0].round(a[1].to_i) : a[0].round },
  "pow"   => ->(a) { a[0]**a[1] }
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(expression) ⇒ SafeArithmetic

Returns a new instance of SafeArithmetic.



72
73
74
75
# File 'lib/synthra/types/formula.rb', line 72

def initialize(expression)
  @tokens = tokenize(expression)
  @pos = 0
end

Class Method Details

.eval(expression) ⇒ Object



66
67
68
69
70
# File 'lib/synthra/types/formula.rb', line 66

def self.eval(expression)
  new(expression).result
rescue StandardError
  nil
end

Instance Method Details

#apply_mul(op, lhs, rhs) ⇒ Object (private)



121
122
123
124
125
126
127
# File 'lib/synthra/types/formula.rb', line 121

def apply_mul(op, lhs, rhs)
  case op
  when "*" then lhs * rhs
  when "/" then lhs.to_f / rhs
  else lhs % rhs
  end
end

#parse_addObject (private)



103
104
105
106
107
108
109
# File 'lib/synthra/types/formula.rb', line 103

def parse_add
  value = parse_mul
  while ["+", "-"].include?(peek)
    value = take == "+" ? value + parse_mul : value - parse_mul
  end
  value
end

#parse_function(name) ⇒ Object (private)



168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/synthra/types/formula.rb', line 168

def parse_function(name)
  fn = FUNCTIONS[name]
  raise "unknown function #{name}" unless fn

  take # name
  raise "expected (" unless take == "("

  args = [parse_add]
  args << parse_add while peek == "," && take
  raise "expected )" unless take == ")"

  fn.call(args)
end

#parse_groupObject (private)



160
161
162
163
164
165
166
# File 'lib/synthra/types/formula.rb', line 160

def parse_group
  take # "("
  value = parse_add
  raise "missing )" unless take == ")"

  value
end

#parse_mulObject (private)



111
112
113
114
115
116
117
118
119
# File 'lib/synthra/types/formula.rb', line 111

def parse_mul
  value = parse_pow
  while ["*", "/", "%"].include?(peek)
    op = take
    rhs = parse_pow
    value = apply_mul(op, value, rhs)
  end
  value
end

#parse_powObject (private)



129
130
131
132
133
134
135
# File 'lib/synthra/types/formula.rb', line 129

def parse_pow
  base = parse_unary
  return base unless peek == "**"

  take
  base**parse_pow # right-associative
end

#parse_primaryObject (private)



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/synthra/types/formula.rb', line 144

def parse_primary
  tok = peek
  raise "unexpected end" if tok.nil?

  if tok == "("
    parse_group
  elsif tok.match?(/\A\d/)
    take
    tok.include?(".") ? tok.to_f : tok.to_i
  elsif tok.match?(/\A[A-Za-z_]/)
    parse_function(tok)
  else
    raise "unexpected token #{tok}"
  end
end

#parse_unaryObject (private)



137
138
139
140
141
142
# File 'lib/synthra/types/formula.rb', line 137

def parse_unary
  return parse_primary unless peek == "-"

  take
  -parse_unary
end

#peekObject (private)



93
94
95
# File 'lib/synthra/types/formula.rb', line 93

def peek
  @tokens[@pos]
end

#resultObject



77
78
79
80
81
82
# File 'lib/synthra/types/formula.rb', line 77

def result
  return nil if @tokens.empty?

  value = parse_add
  @pos == @tokens.length ? value : nil
end

#takeObject (private)



97
98
99
100
101
# File 'lib/synthra/types/formula.rb', line 97

def take
  tok = @tokens[@pos]
  @pos += 1
  tok
end

#tokenize(expression) ⇒ Object (private)

Tokenize and reject (return []) if any character isn't part of the grammar — e.g. quotes, method calls, or leftover identifiers from a non-arithmetic field.



88
89
90
91
# File 'lib/synthra/types/formula.rb', line 88

def tokenize(expression)
  tokens = expression.to_s.scan(%r{\d+\.\d+|\d+|[A-Za-z_]\w*|\*\*|[-+*/%(),]})
  tokens.join == expression.to_s.gsub(/\s+/, "") ? tokens : []
end