Skip to content

Commit

Permalink
Merge pull request #63 from project-eutopia/hash
Browse files Browse the repository at this point in the history
Add support for Hashes (associative arrays)
  • Loading branch information
project-eutopia committed Jan 9, 2018
2 parents 509bbda + 85bf16a commit bebe86f
Show file tree
Hide file tree
Showing 17 changed files with 276 additions and 30 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,25 @@ calculator.evaluate("[1,2,3,4,5].inject(1, total, x, total*x)")
#=> 120
```

##### Hashes

Keisan also supports associative arrays (hashes), which map strings to some value.

```ruby
calculator = Keisan::Calculator.new
calculator.evaluate("my_hash = {'foo': 3*4, \"bar\": \"hello world\"}")
calculator.evaluate("my_hash['foo']")
#=> 12
calculator.evaluate("s = 'ba'")
calculator.evaluate("my_hash[s + 'r']")
#=> "hello world"
calculator.evaluate("my_hash['baz']")
#=> nil
calculator.evaluate("my_hash['baz'] = 999")
calculator.evaluate("my_hash['baz']")
#=> 999
```

##### Logical operations

`keisan` understands basic boolean logic operators, like `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, so calculations like the following are possible
Expand Down
3 changes: 3 additions & 0 deletions lib/keisan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
require "keisan/ast/logical_greater_than_or_equal_to"
require "keisan/ast/function"
require "keisan/ast/list"
require "keisan/ast/hash"
require "keisan/ast/indexing"

require "keisan/ast/line_builder"
Expand All @@ -65,6 +66,7 @@

require "keisan/token"
require "keisan/tokens/comma"
require "keisan/tokens/colon"
require "keisan/tokens/dot"
require "keisan/tokens/group"
require "keisan/tokens/number"
Expand Down Expand Up @@ -98,6 +100,7 @@
require "keisan/parsing/square_group"
require "keisan/parsing/curly_group"
require "keisan/parsing/list"
require "keisan/parsing/hash"
require "keisan/parsing/indexing"
require "keisan/parsing/argument"
require "keisan/parsing/line_separator"
Expand Down
11 changes: 11 additions & 0 deletions lib/keisan/ast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,20 @@ def value(context = nil)
end
end

module KeisanHash
def to_node
Keisan::AST::Hash.new(map {|k,v| [k.to_node, v.to_node]})
end

def value(context = nil)
self
end
end

class Numeric; prepend KeisanNumeric; end
class String; prepend KeisanString; end
class TrueClass; prepend KeisanTrueClass; end
class FalseClass; prepend KeisanFalseClass; end
class NilClass; prepend KeisanNilClass; end
class Array; prepend KeisanArray; end
class Hash; prepend KeisanHash; end
68 changes: 68 additions & 0 deletions lib/keisan/ast/hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module Keisan
module AST
class Hash < Node
def initialize(key_value_pairs)
@hash = ::Hash[key_value_pairs]
stringify_and_cellify!
end

def [](key)
key = key.to_node
return nil unless key.is_a?(AST::String)

if val = @hash[key.value]
val
else
Cell.new(Null.new).tap do |cell|
@hash[key.value] = cell
end
end
end

def evaluate(context = nil)
context ||= Context.new
stringify_and_cellify!

@hash = ::Hash[
@hash.map do |key, val|
[key, Cell.new(val.evaluate(context))]
end
]

self
end

def simplify(context = nil)
evaluate(context)
end

def value(context = nil)
context ||= Context.new
evaluate(context)

::Hash[
@hash.map {|key, val|
raise Exceptions::InvalidExpression.new("Keisan::AST::Hash#value must have all keys evaluate to strings") unless key.is_a?(::String)
[key, val.value(context)]
}
]
end

def to_s
"{#{@hash.map {|k,v| "'#{k}': #{v}"}.join(', ')}}"
end

private

def stringify_and_cellify!
@hash = ::Hash[
@hash.map do |key, val|
key = key.value if key.is_a?(AST::String)
val = Cell.new(val) unless val.is_a?(Cell)
[key, val]
end
]
end
end
end
end
41 changes: 26 additions & 15 deletions lib/keisan/ast/indexing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,11 @@ def evaluate(context = nil)
@children = children.map {|child| child.evaluate(context)}
@indexes = indexes.map {|index| index.evaluate(context)}

if list = extract_list
list.children[@indexes.first.value(context)]
else
self
end
evaluate_list(context) || evaluate_hash(context) || self
end

def simplify(context = nil)
context ||= Context.new

@indexes = indexes.map {|index| index.simplify(context)}
@children = [child.simplify(context)]

if list = extract_list
Cell.new(list.children[@indexes.first.value(context)].simplify(context))
else
self
end
evaluate(context)
end

def replace(variable, replacement)
Expand All @@ -52,6 +39,20 @@ def replace(variable, replacement)

private

def evaluate_list(context)
if list = extract_list
element = list.children[@indexes.first.value(context)]
element.nil? ? AST::Null.new : element
end
end

def evaluate_hash(context)
if hash = extract_hash
element = hash[@indexes.first.value(context)]
element.nil? ? AST::Null.new : element
end
end

def extract_list
if child.is_a?(List)
child
Expand All @@ -61,6 +62,16 @@ def extract_list
nil
end
end

def extract_hash
if child.is_a?(AST::Hash)
child
elsif child.is_a?(Cell) && child.node.is_a?(AST::Hash)
child.node
else
nil
end
end
end
end
end
9 changes: 9 additions & 0 deletions lib/keisan/ast/line_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ def node_of_component(component)
Builder.new(components: parsing_argument.components).node
}
)
when Parsing::Hash
AST::Hash.new(
component.key_value_pairs.map {|key_value_pair|
[
Builder.new(components: [key_value_pair[0]]).node,
Builder.new(components: key_value_pair[1].components).node
]
}
)
when Parsing::RoundGroup
Builder.new(components: component.components).node
when Parsing::CurlyGroup
Expand Down
5 changes: 1 addition & 4 deletions lib/keisan/ast/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ def evaluate(context = nil)
end

def simplify(context = nil)
context ||= Context.new
super(context)
cellify!
self
evaluate(context)
end

def value(context = nil)
Expand Down
26 changes: 17 additions & 9 deletions lib/keisan/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -196,18 +196,26 @@ def add_element_to_components!(token)
when Tokens::Boolean
@components << Parsing::Boolean.new(token.value)
when Tokens::Group
case token.group_type
when :round
@components << Parsing::RoundGroup.new(token.sub_tokens)
when :square
@components << Parsing::List.new(arguments_from_group(token))
when :curly
@components << Parsing::CurlyGroup.new(token.sub_tokens)
add_group_element_components!(token)
else
raise Exceptions::ParseError.new("Unhandled operator type #{token.operator_type}")
end
end

def add_group_element_components!(token)
case token.group_type
when :round
@components << Parsing::RoundGroup.new(token.sub_tokens)
when :square
@components << Parsing::List.new(arguments_from_group(token))
when :curly
if token.sub_tokens.any? {|token| token.is_a?(Tokens::Colon)}
@components << Parsing::Hash.new(token.sub_tokens.split {|token| token.is_a?(Tokens::Comma)})
else
raise Exceptions::ParseError.new("Unhandled group type #{token.group_type}")
@components << Parsing::CurlyGroup.new(token.sub_tokens)
end
else
raise Exceptions::ParseError.new("Unhandled operator type #{token.operator_type}")
raise Exceptions::ParseError.new("Unhandled group type #{token.group_type}")
end
end

Expand Down
24 changes: 24 additions & 0 deletions lib/keisan/parsing/hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Keisan
module Parsing
class Hash < Element
attr_reader :key_value_pairs

def initialize(key_value_pairs)
@key_value_pairs = Array.wrap(key_value_pairs).map {|key_value_pair|
validate_and_extract_key_value_pair(key_value_pair)
}
end

private

def validate_and_extract_key_value_pair(key_value_pair)
key, value = key_value_pair.split {|token| token.is_a?(Tokens::Colon)}
raise Exceptions::ParseError.new("Invalid hash") unless key.size == 1 && value.size >= 1

key = key.first
raise Exceptions::ParseError.new("Invalid hash (keys must be strings)") unless key.is_a?(Tokens::String)
[Parsing::String.new(key.value), Parsing::RoundGroup.new(value)]
end
end
end
end
1 change: 1 addition & 0 deletions lib/keisan/tokenizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Tokenizer
Tokens::BitwiseOperator,
Tokens::Assignment,
Tokens::Comma,
Tokens::Colon,
Tokens::Dot,
Tokens::LineSeparator
]
Expand Down
11 changes: 11 additions & 0 deletions lib/keisan/tokens/colon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Keisan
module Tokens
class Colon < Token
REGEX = /(\:)/

def self.regex
REGEX
end
end
end
end
9 changes: 9 additions & 0 deletions spec/keisan/ast/builder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@
my_context.register_function!("f", Proc.new { [3,5,7] })
expect(described_class.new(string: "f()").ast.value(my_context)).to eq [3,5,7]
expect(described_class.new(string: "f()[1]").ast.value(my_context)).to eq 5
expect(described_class.new(string: "[1,2,3][3]").ast.value(my_context)).to eq nil
end
end

context "hash" do
it "properly parses" do
expect(described_class.new(string: "{'foo': 1+2, 'bar': 3*4}['foo']").ast.value).to eq 3
expect(described_class.new(string: "{'foo': 1+2, 'bar': 3*4}['bar']").ast.value).to eq 12
expect(described_class.new(string: "{'foo': 1+2, 'bar': 3*4}['baz']").ast.value).to eq nil
end
end

Expand Down
2 changes: 1 addition & 1 deletion spec/keisan/ast/node_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@
context "arithmetic operations" do
it "prints out the AST as a string expression, wrapping operators in brackets" do
ast = Keisan::AST.parse("-15 + x**4 * 3 + sin(y)*(1+(-1))+f(z+1,w+1)[2]")
expect(ast.simplified.to_s).to eq "-15+(3*(x**4))+((f(1+z,1+w))[2])"
expect(ast.simplified.to_s).to eq "-15+(3*(x**4))+((f(z+1,w+1))[2])"
expect(Keisan::AST.parse(ast.to_s)).to eq ast
end
end
Expand Down
39 changes: 39 additions & 0 deletions spec/keisan/calculator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,50 @@
calculator.evaluate("a[0] = 10")
calculator.evaluate("a[1] = [40,50,60]")
calculator.evaluate("a[2][0] = 11")
expect{calculator.evaluate("a[3] = 5")}.to raise_error(Keisan::Exceptions::InvalidExpression)

expect(calculator.evaluate("a")).to eq([10, [40,50,60], [11,8,9]])
end
end

context "hash operations" do
it "evaluates hashes" do
expect(calculator.evaluate("{'a': 1, 'b': 2}")).to eq({"a" => 1, "b" => 2})
end

it "can index hashes" do
expect(calculator.evaluate("{'foo': 1, 'bar': 2}['foo']")).to eq 1
expect(calculator.evaluate("{'foo': 1, 'bar': 2}['b'+'ar']")).to eq 2
expect(calculator.evaluate("{'foo': 1, 'bar': 2}['baz']")).to eq nil
end

it "can change elements of hashes" do
calculator.evaluate("h = {'foo': 100, 'bar': 200}")

expect {
calculator.evaluate("h['foo'] = 99")
}.to change {
calculator.evaluate("h['foo']")
}.from(100).to(99)

expect {
calculator.evaluate("h['baz'] = 300")
}.to change {
calculator.evaluate("h['baz']")
}.from(nil).to(300)

calculator.evaluate("my_string = 'fo'")
expect(calculator.evaluate("h[my_string + 'o']")).to eq 99
end

describe "#to_s" do
it "outputs correct hash format" do
hash_string = "{'a': 1, 'b': 2}"
expect(calculator.ast(hash_string).to_s).to eq hash_string
end
end
end

describe "#simplify" do
it "allows for undefined variables to still exist and returns a string representation of the expression" do
expect{calculator.evaluate("0*x+1")}.to raise_error(Keisan::Exceptions::UndefinedVariableError)
Expand Down
Loading

0 comments on commit bebe86f

Please sign in to comment.