keisan 0.8.1 → 0.8.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25f0cd7f7a5bd124a62c244a270bb4db0632fc0ce295d7f55fa25409eae66473
4
- data.tar.gz: 5cdeae27276969608966b56891abe91fe8b2bddaef9f7ef3fdeb6a3fe6696176
3
+ metadata.gz: 496551eafa9e9774866f8322a750ec0e0b88c463c9cb0f9174cd3461003d95a5
4
+ data.tar.gz: 9d509cb07d53c12758bf3cbc1ddb45c4e75809432267cbf3215ab0ce9451404f
5
5
  SHA512:
6
- metadata.gz: 32d761e97663ad54bc37d2e3894875068daa8567ae521dd877879a94f66417c752cc338eba7f1a06bbe23f2a1455fce7751690323fcf4948f243d8254c69f1b4
7
- data.tar.gz: 6c62bfc43752fe4918022e0217227c4a31d1fae6225bf70d65bc61afb025dbef5af74597055b1216a4ef9d261252e2f2d9ac26467319e33c4c842a2764711053
6
+ metadata.gz: a0047d2ffeea473809ce46b8e7a0c5458012a5ee27182384d28f9ce5706eeaf0385a7c6aecf4619b637760ec235d8c55f8815c537665f03af58ed388180d5fe4
7
+ data.tar.gz: 29d2bf8124706bc44129abec0839bba5809678e71c50ac6b2b6d56e2584f4d7cf3f094005db1e8f6e2606334a637f6df90ab95f1a80ac8e6f9e844f67aaf032d
data/README.md CHANGED
@@ -178,6 +178,22 @@ calculator.evaluate("x = 11; {let x = 12}; x")
178
178
  #=> 11
179
179
  ```
180
180
 
181
+ ##### Comments
182
+
183
+ When working with multi-line blocks of code, sometimes comments are useful to include.
184
+ Comments are parts of a string from the `#` character to the end of a line (indicated by a newline character `"\n"`).
185
+
186
+ ```
187
+ calculator = Keisan::Calculator.new
188
+ calculator.evaluate("""
189
+ # This is a comment
190
+ x = 'foo'
191
+ x += '#bar' # Notice that `#` inside strings is not part of the comment
192
+ x # Should print 'foo#bar'
193
+ """)
194
+ #=> "foo#bar"
195
+ ```
196
+
181
197
  ##### Lists
182
198
 
183
199
  Just like in Ruby, lists can be defined using square brackets, and indexed using square brackets
@@ -256,6 +272,7 @@ calculator.evaluate("range(1, 6).map(x, [x, x**2]).to_h")
256
272
  Keisan supports date and time objects like in Ruby.
257
273
  You create a date object using either the method `date` (either a string to be parsed, or year, month, day numerical arguments) or `today`.
258
274
  They support methods `year`, `month`, `day`, `weekday`, `strftime`, and `to_time` to convert to a time object.
275
+ `epoch_days` computes the number of days since Unix epoch (Jan 1, 1970).
259
276
 
260
277
  ```ruby
261
278
  calculator = Keisan::Calculator.new
@@ -266,10 +283,13 @@ calculator.evaluate("today() > date(2018, 11, 1)")
266
283
  #=> true
267
284
  calculator.evaluate("date('1999-12-31').to_time + 10")
268
285
  #=> Time.new(1999, 12, 31, 0, 0, 10)
286
+ calculator.evaluate("date(1970, 1, 15).epoch_days")
287
+ #=> 14
269
288
  ```
270
289
 
271
290
  Time objects are created using `time` (either a string to be parsed, or year, month, day, hour, minute, second arguments) or `now`.
272
291
  They support methods `year`, `month`, `day`, `hour`, `minute`, `second`, `weekday`, `strftime`, and `to_date` to convert to a date object.
292
+ `epoch_seconds` computes the number of seconds since Unix epoch (00:00:00 on Jan 1, 1970).
273
293
 
274
294
  ```ruby
275
295
  calculator = Keisan::Calculator.new
@@ -279,6 +299,8 @@ calculator.evaluate("time('2000-4-15 12:34:56').minute")
279
299
  #=> 34
280
300
  calculator.evaluate("time('5000-10-10 20:30:40').strftime('%b %d, %Y')")
281
301
  #=> "Oct 10, 5000"
302
+ calculator.evaluate("time(1970, 1, 1, 2, 3, 4).epoch_seconds")
303
+ #=> 7384
282
304
  ```
283
305
 
284
306
  ##### Functional programming methods
@@ -17,6 +17,10 @@ module Keisan
17
17
  child.unbound_functions(local)
18
18
  end
19
19
 
20
+ def contains_a?(klass)
21
+ super || child.contains_a?(klass)
22
+ end
23
+
20
24
  def deep_dup
21
25
  dupped = dup
22
26
  dupped.instance_variable_set(
@@ -15,6 +15,10 @@ module Keisan
15
15
  node.unbound_functions(context)
16
16
  end
17
17
 
18
+ def contains_a?(klass)
19
+ super || node.contains_a?(klass)
20
+ end
21
+
18
22
  def deep_dup
19
23
  dupped = dup
20
24
  dupped.instance_variable_set(
@@ -29,8 +29,7 @@ module Keisan
29
29
 
30
30
  def evaluate
31
31
  # Blocks might have local variable/function definitions, so skip check
32
- verify_rhs_of_function_assignment_is_valid! unless rhs.is_a?(Block)
33
-
32
+ verify_rhs_of_function_assignment_is_valid!
34
33
  context.register_function!(lhs.name, expression_function, local: local)
35
34
  rhs
36
35
  end
@@ -38,15 +37,26 @@ module Keisan
38
37
  private
39
38
 
40
39
  def verify_rhs_of_function_assignment_is_valid!
41
- # Only variables that can appear are those that are arguments to the function
42
- unless rhs.unbound_variables(context) <= Set.new(argument_names)
43
- raise Exceptions::InvalidExpression.new("Unbound variables found in function definition")
44
- end
40
+ verify_unbound_functions!
41
+ verify_unbound_variables!
42
+ end
43
+
44
+ def verify_unbound_functions!
45
45
  # Cannot have undefined functions unless allowed by context
46
46
  unless context.allow_recursive || rhs.unbound_functions(context).empty?
47
47
  raise Exceptions::InvalidExpression.new("Unbound function definitions are not allowed by current context")
48
48
  end
49
49
  end
50
+
51
+ def verify_unbound_variables!
52
+ # We allow unbound variables inside block statements, as they could be temporary
53
+ # variables assigned locally
54
+ return if rhs.is_a?(Block)
55
+ # Only variables that can appear are those that are arguments to the function
56
+ unless rhs.unbound_variables(context) <= Set.new(argument_names)
57
+ raise Exceptions::InvalidExpression.new("Unbound variables found in function definition")
58
+ end
59
+ end
50
60
  end
51
61
  end
52
62
  end
@@ -21,6 +21,10 @@ module Keisan
21
21
  end
22
22
  end
23
23
 
24
+ def contains_a?(klass)
25
+ super || @hash.any? {|k, v| k.to_node.contains_a?(klass) || v.contains_a?(klass) }
26
+ end
27
+
24
28
  def evaluate(context = nil)
25
29
  context ||= Context.new
26
30
 
@@ -37,6 +37,15 @@ module Keisan
37
37
  value(context)
38
38
  end
39
39
 
40
+ def contains_a?(klass)
41
+ case klass
42
+ when Array
43
+ klass.any? {|k| is_a?(k) }
44
+ else
45
+ is_a?(klass)
46
+ end
47
+ end
48
+
40
49
  def evaluate_assignments(context = nil)
41
50
  self
42
51
  end
@@ -25,6 +25,10 @@ module Keisan
25
25
  end
26
26
  end
27
27
 
28
+ def contains_a?(klass)
29
+ super || children.any? {|child| child.contains_a?(klass) }
30
+ end
31
+
28
32
  def freeze
29
33
  children.each(&:freeze)
30
34
  super
@@ -2,8 +2,14 @@ module Keisan
2
2
  class Calculator
3
3
  attr_reader :context
4
4
 
5
- def initialize(context: nil, allow_recursive: false)
6
- @context = context || Context.new(allow_recursive: allow_recursive)
5
+ # Note, allow_recursive would be more appropriately named:
6
+ # allow_unbound_functions_in_function_definitions, but it is too late for that.
7
+ def initialize(context: nil, allow_recursive: false, allow_blocks: true, allow_multiline: true)
8
+ @context = context || Context.new(
9
+ allow_recursive: allow_recursive,
10
+ allow_blocks: allow_blocks,
11
+ allow_multiline: allow_multiline
12
+ )
7
13
  end
8
14
 
9
15
  def allow_recursive
@@ -14,6 +20,14 @@ module Keisan
14
20
  context.allow_recursive!
15
21
  end
16
22
 
23
+ def allow_blocks
24
+ context.allow_blocks
25
+ end
26
+
27
+ def allow_multiline
28
+ context.allow_multiline
29
+ end
30
+
17
31
  def evaluate(expression, definitions = {})
18
32
  Evaluator.new(self).evaluate(expression, definitions)
19
33
  end
@@ -23,7 +37,7 @@ module Keisan
23
37
  end
24
38
 
25
39
  def ast(expression)
26
- Evaluator.new(self).ast(expression)
40
+ Evaluator.new(self).parse_ast(expression)
27
41
  end
28
42
 
29
43
  def define_variable!(name, value)
@@ -1,13 +1,24 @@
1
1
  module Keisan
2
2
  class Context
3
- attr_reader :function_registry, :variable_registry, :allow_recursive
4
-
5
- def initialize(parent: nil, random: nil, allow_recursive: false, shadowed: [])
3
+ attr_reader :function_registry,
4
+ :variable_registry,
5
+ :allow_recursive,
6
+ :allow_multiline,
7
+ :allow_blocks
8
+
9
+ def initialize(parent: nil,
10
+ random: nil,
11
+ allow_recursive: false,
12
+ allow_multiline: true,
13
+ allow_blocks: true,
14
+ shadowed: [])
6
15
  @parent = parent
7
16
  @function_registry = Functions::Registry.new(parent: @parent&.function_registry)
8
17
  @variable_registry = Variables::Registry.new(parent: @parent&.variable_registry, shadowed: shadowed)
9
18
  @random = random
10
19
  @allow_recursive = allow_recursive
20
+ @allow_multiline = allow_multiline
21
+ @allow_blocks = allow_blocks
11
22
  end
12
23
 
13
24
  def allow_recursive!
@@ -110,7 +121,13 @@ module Keisan
110
121
  end
111
122
 
112
123
  def pure_child(shadowed: [])
113
- self.class.new(parent: self, shadowed: shadowed, allow_recursive: allow_recursive)
124
+ self.class.new(
125
+ parent: self,
126
+ shadowed: shadowed,
127
+ allow_recursive: allow_recursive,
128
+ allow_multiline: allow_multiline,
129
+ allow_blocks: allow_blocks
130
+ )
114
131
  end
115
132
  end
116
133
  end
@@ -8,7 +8,7 @@ module Keisan
8
8
 
9
9
  def evaluate(expression, definitions = {})
10
10
  context = calculator.context.spawn_child(definitions: definitions, transient: true)
11
- ast = ast(expression)
11
+ ast = parse_ast(expression)
12
12
  last_line = last_line(ast)
13
13
 
14
14
  evaluation = ast.evaluated(context)
@@ -24,16 +24,28 @@ module Keisan
24
24
 
25
25
  def simplify(expression, definitions = {})
26
26
  context = calculator.context.spawn_child(definitions: definitions, transient: true)
27
- ast = AST.parse(expression)
27
+ ast = parse_ast(expression)
28
28
  ast.simplify(context)
29
29
  end
30
30
 
31
- def ast(expression)
32
- AST.parse(expression)
31
+ def parse_ast(expression)
32
+ AST.parse(expression).tap do |ast|
33
+ disallowed = disallowed_nodes
34
+ if !disallowed.empty? && ast.contains_a?(disallowed)
35
+ raise Keisan::Exceptions::InvalidExpression.new("Context does not permit expressions with #{disallowed}")
36
+ end
37
+ end
33
38
  end
34
39
 
35
40
  private
36
41
 
42
+ def disallowed_nodes
43
+ disallowed = []
44
+ disallowed << Keisan::AST::Block unless calculator.allow_blocks
45
+ disallowed << Keisan::AST::MultiLine unless calculator.allow_multiline
46
+ disallowed
47
+ end
48
+
37
49
  def last_line(ast)
38
50
  ast.is_a?(AST::MultiLine) ? ast.children.last : ast
39
51
  end
@@ -147,6 +147,9 @@ module Keisan
147
147
 
148
148
  registry.register!(:to_time, Proc.new {|d| d.to_time }, force: true)
149
149
  registry.register!(:to_date, Proc.new {|t| t.to_date }, force: true)
150
+
151
+ registry.register!(:epoch_seconds, Proc.new {|d| d.to_time - Time.new(1970, 1, 1, 0, 0, 0) }, force: true)
152
+ registry.register!(:epoch_days, Proc.new {|t| t.to_date - Date.new(1970, 1, 1) }, force: true)
150
153
  end
151
154
 
152
155
  def self.register_date_time!(registry)
@@ -21,14 +21,17 @@ module Keisan
21
21
  context ||= Context.new
22
22
 
23
23
  operand, arguments, expression = operand_arguments_expression_for(ast_function, context)
24
+
25
+ # Extract underlying operand for cells
26
+ real_operand = operand.is_a?(AST::Cell) ? operand.node : operand
24
27
 
25
- case operand
28
+ case real_operand
26
29
  when AST::List
27
- evaluate_list(operand, arguments, expression, context).evaluate(context)
30
+ evaluate_list(real_operand, arguments, expression, context).evaluate(context)
28
31
  when AST::Hash
29
- evaluate_hash(operand, arguments, expression, context).evaluate(context)
32
+ evaluate_hash(real_operand, arguments, expression, context).evaluate(context)
30
33
  else
31
- raise Exceptions::InvalidFunctionError.new("Unhandled first argument to #{name}: #{operand}")
34
+ raise Exceptions::InvalidFunctionError.new("Unhandled first argument to #{name}: #{real_operand}")
32
35
  end
33
36
  end
34
37
 
@@ -9,24 +9,26 @@ module Keisan
9
9
  end
10
10
 
11
11
  class StringPortion < Portion
12
- attr_reader :string
12
+ attr_reader :string, :escaped_string
13
13
 
14
14
  def initialize(expression, start_index)
15
15
  super(start_index)
16
16
 
17
17
  @string = expression[start_index]
18
+ @escaped_string = expression[start_index]
18
19
  @end_index = start_index + 1
19
20
 
20
21
  while @end_index < expression.size
21
22
  if expression[@end_index] == quote_type
22
23
  @string << quote_type
24
+ @escaped_string << quote_type
23
25
  @end_index += 1
24
26
  # Successfully parsed the string
25
27
  return
26
28
  end
27
29
 
28
- n, c = process_next_character(expression, @end_index)
29
- @string << c
30
+ n, c = get_potentially_escaped_next_character(expression, @end_index)
31
+ @escaped_string << c
30
32
  @end_index += n
31
33
  end
32
34
 
@@ -44,9 +46,13 @@ module Keisan
44
46
  private
45
47
 
46
48
  # Returns number of processed input characters, and the output character
47
- def process_next_character(expression, index)
48
- # escape character
49
- if expression[index] == "\\"
49
+ # If a sequence like '\"' is encountered, the first backslash escapes the
50
+ # second double-quote, and the two characters will act as a one double-quote
51
+ # character.
52
+ def get_potentially_escaped_next_character(expression, index)
53
+ @string << expression[index]
54
+ if expression[index] == "\\" && index + 1 < expression.size
55
+ @string << expression[index + 1]
50
56
  return [2, escaped_character(expression[index + 1])]
51
57
  else
52
58
  return [1, expression[index]]
@@ -129,7 +135,7 @@ module Keisan
129
135
 
130
136
  while index < expression.size
131
137
  case expression[index]
132
- when STRING_CHARACTER_REGEX, OPEN_GROUP_REGEX, CLOSED_GROUP_REGEX
138
+ when STRING_CHARACTER_REGEX, OPEN_GROUP_REGEX, CLOSED_GROUP_REGEX, COMMENT_CHARACTER_REGEX
133
139
  break
134
140
  else
135
141
  index += 1
@@ -149,9 +155,40 @@ module Keisan
149
155
  end
150
156
  end
151
157
 
158
+ class CommentPortion < Portion
159
+ attr_reader :string
160
+
161
+ def initialize(expression, start_index)
162
+ super(start_index)
163
+
164
+ if expression[start_index] != '#'
165
+ raise Keisan::Exceptions::TokenizingError.new("Comment should start with '#'")
166
+ else
167
+ index = start_index + 1
168
+ end
169
+
170
+ while index < expression.size
171
+ break if expression[index] == "\n"
172
+ index += 1
173
+ end
174
+
175
+ @end_index = index
176
+ @string = expression[start_index...end_index]
177
+ end
178
+
179
+ def size
180
+ string.size
181
+ end
182
+
183
+ def to_s
184
+ string
185
+ end
186
+ end
187
+
152
188
  # An ordered array of "portions", which
153
189
  attr_reader :portions, :size
154
190
 
191
+ COMMENT_CHARACTER_REGEX = /[#]/
155
192
  STRING_CHARACTER_REGEX = /["']/
156
193
  OPEN_GROUP_REGEX = /[\(\{\[]/
157
194
  CLOSED_GROUP_REGEX = /[\)\}\]]/
@@ -176,6 +213,11 @@ module Keisan
176
213
  when CLOSED_GROUP_REGEX
177
214
  raise Keisan::Exceptions::TokenizingError.new("Tokenizing error, unexpected closing brace #{expression[start_index]}")
178
215
 
216
+ when COMMENT_CHARACTER_REGEX
217
+ portion = CommentPortion.new(expression, index)
218
+ index += portion.size
219
+ @portions << portion
220
+
179
221
  else
180
222
  portion = OtherPortion.new(expression, index)
181
223
  index += portion.size
@@ -24,14 +24,16 @@ module Keisan
24
24
  attr_reader :expression, :tokens
25
25
 
26
26
  def initialize(expression)
27
- @expression = self.class.normalize_expression(expression)
27
+ @expression = expression
28
28
 
29
- portions = StringAndGroupParser.new(@expression).portions
29
+ portions = StringAndGroupParser.new(expression).portions.reject do |portion|
30
+ portion.is_a? StringAndGroupParser::CommentPortion
31
+ end
30
32
 
31
33
  @tokens = portions.inject([]) do |tokens, portion|
32
34
  case portion
33
35
  when StringAndGroupParser::StringPortion
34
- tokens << Tokens::String.new(portion.to_s)
36
+ tokens << Tokens::String.new(portion.escaped_string)
35
37
  when StringAndGroupParser::GroupPortion
36
38
  tokens << Tokens::Group.new(portion.to_s)
37
39
  when StringAndGroupParser::OtherPortion
@@ -43,21 +45,8 @@ module Keisan
43
45
  end
44
46
  end
45
47
 
46
- def self.normalize_expression(expression)
47
- expression = normalize_line_delimiters(expression)
48
- expression = remove_comments(expression)
49
- end
50
-
51
48
  private
52
49
 
53
- def self.normalize_line_delimiters(expression)
54
- expression.gsub(/\n/, ";")
55
- end
56
-
57
- def self.remove_comments(expression)
58
- expression.gsub(/#[^;]*/, "")
59
- end
60
-
61
50
  def tokenize!(scan)
62
51
  scan.map do |scan_result|
63
52
  i = scan_result.find_index {|token| !token.nil?}
@@ -1,3 +1,3 @@
1
1
  module Keisan
2
- VERSION = "0.8.1"
2
+ VERSION = "0.8.6"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Locke
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-03-15 00:00:00.000000000 Z
11
+ date: 2020-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cmath