keisan 0.8.1 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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