diff --git a/ruby/Gemfile b/ruby/Gemfile index b7c57df..e76dde0 100644 --- a/ruby/Gemfile +++ b/ruby/Gemfile @@ -5,6 +5,5 @@ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "minitest" -gem "mocktail" gem "pry" gem "rake" diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock index dc09b9d..9900ff7 100644 --- a/ruby/Gemfile.lock +++ b/ruby/Gemfile.lock @@ -4,7 +4,6 @@ GEM coderay (1.1.3) method_source (1.0.0) minitest (5.16.2) - mocktail (1.1.3) pry (0.14.1) coderay (~> 1.1) method_source (~> 1.0) @@ -15,7 +14,6 @@ PLATFORMS DEPENDENCIES minitest - mocktail pry rake diff --git a/ruby/bin/lox b/ruby/bin/lox index c0a2c0a..4cbfcbb 100755 --- a/ruby/bin/lox +++ b/ruby/bin/lox @@ -11,17 +11,22 @@ def run_prompt break if line.nil? || line.empty? begin run(line) - rescue Lox::Error => e - puts e.message + rescue Lox::ParseError => e + STDERR.puts e.message + rescue Lox::RuntimeError => e + STDERR.puts e.message end end end def run_file(io) run(io.read) -rescue Lox::Error => e - puts e.message +rescue Lox::ParseError => e + STDERR.puts e.message exit 65 +rescue Lox::RuntimeError => e + STDERR.puts e.message, "[line #{e.token.line}]" + exit 70 end def run(src) diff --git a/ruby/lib/lox.rb b/ruby/lib/lox.rb index 4212b64..3cb4e82 100644 --- a/ruby/lib/lox.rb +++ b/ruby/lib/lox.rb @@ -3,23 +3,25 @@ require_relative "lox/ast_printer" require_relative "lox/error" require_relative "lox/expr" +require_relative "lox/interpreter" require_relative "lox/parser" require_relative "lox/scanner" require_relative "lox/token" module Lox class Runner - def initialize(scanner=Scanner.new, parser=Parser.new) - @scanner, @parser = scanner, parser + def initialize + @scanner = Scanner.new + @parser = Parser.new + @interpreter = Interpreter.new end def run(src) tokens = @scanner.scan(src) expr = @parser.parse(tokens) + value = @interpreter.interpret(expr) - puts AstPrinter.new.print(expr) - rescue ParseError => e - puts e.message + puts value end end end diff --git a/ruby/lib/lox/error.rb b/ruby/lib/lox/error.rb index f3d8d6a..46897e5 100644 --- a/ruby/lib/lox/error.rb +++ b/ruby/lib/lox/error.rb @@ -1,10 +1,23 @@ module Lox class Error < StandardError - def initialize(line, where="", message) + end + + class ParseError < Error + def initialize(token, message) + where = token.type == :EOF ? "end" : "'#{token.lexeme}'" + error = "Error" - error << " #{where}" unless where.empty? + error << " at #{where}" unless where.empty? + super("[line #{token.line}] #{error}: #{message}") + end + end + + class RuntimeError < Error + attr_reader :token - super("[line #{line}] #{error}: #{message}") + def initialize(token, message) + @token = token + super(message) end end end diff --git a/ruby/lib/lox/interpreter.rb b/ruby/lib/lox/interpreter.rb new file mode 100644 index 0000000..3e577c6 --- /dev/null +++ b/ruby/lib/lox/interpreter.rb @@ -0,0 +1,88 @@ +module Lox + class Interpreter + + # The book does printing and error catching here, but + # we're going to do it in the runner instead. + def interpret(expr) + value = evaluate(expr) + stringify(value) + end + + def evaluate(expr) = expr.accept(self) + + def visit_grouping(expr) = evaluate(expr.expr) + def visit_literal(expr) = expr.value + + def visit_unary(expr) + right = evaluate(expr.right) + + case expr.op.type + when :MINUS + check_number_operand!(expr.op, right) + -right + when :BANG then !truthy?(right) + else fail + end + end + + def visit_binary(expr) + left = evaluate(expr.left) + right = evaluate(expr.right) + + case expr.op.type + when :GREATER + check_number_operands!(expr.op, left, right) + left > right + when :GREATER_EQUAL + check_number_operands!(expr.op, left, right) + left >= right + when :LESS + check_number_operands!(expr.op, left, right) + left < right + when :LESS_EQUAL + check_number_operands!(expr.op, left, right) + left <= right + when :BANG_EQUAL then left != right + when :EQUAL_EQUAL then left == right + when :MINUS + check_number_operands!(expr.op, left, right) + left - right + when :PLUS + unless left.is_a?(Float) && right.is_a?(Float) || left.is_a?(String) && right.is_a?(String) + raise RuntimeError.new(expr.op, "Operands must be two numbers or two strings.") + end + left + right + when :SLASH + check_number_operands!(expr.op, left, right) + left / right + when :STAR + check_number_operands!(expr.op, left, right) + left * right + else fail + end + + end + + private + + def truthy?(value) = !!value + + def check_number_operand!(token, operand) + return if operand.is_a?(Float) + + raise RuntimeError.new(token, "Operand must be a number.") + end + + def check_number_operands!(token, left, right) + return if left.is_a?(Float) && right.is_a?(Float) + + raise RuntimeError.new(token, "Operands must be numbers.") + end + + def stringify(value) + return "nil" if value.nil? + return value.to_s.sub(/\.0$/, "") if value.is_a?(Float) + value.to_s + end + end +end diff --git a/ruby/lib/lox/parser.rb b/ruby/lib/lox/parser.rb index 3c061f6..89b0534 100644 --- a/ruby/lib/lox/parser.rb +++ b/ruby/lib/lox/parser.rb @@ -2,14 +2,6 @@ require_relative "error" require_relative "expr" module Lox - class ParseError < Error - def initialize(token, message) - at = token.type == :EOF ? "end" : "'#{token.lexeme}'" - - super(token.line, "at #{at}", message) - end - end - class Parser class State < Struct.new(:tokens, :current) def initialize(tokens) @@ -136,7 +128,7 @@ module Lox # that feels weird so let's move that error handling up the # stack for now. def parse(tokens) - state = State.new(tokens, 0) + state = State.new(tokens) state.expression end end diff --git a/ruby/lib/lox/scanner.rb b/ruby/lib/lox/scanner.rb index bcbbe6f..3deca79 100644 --- a/ruby/lib/lox/scanner.rb +++ b/ruby/lib/lox/scanner.rb @@ -90,6 +90,7 @@ module Lox end end + # TODO fail unless state.errors.empty? state.add_token(:EOF, text: "") diff --git a/ruby/test/lox/test_interpreter.rb b/ruby/test/lox/test_interpreter.rb new file mode 100644 index 0000000..f588f63 --- /dev/null +++ b/ruby/test/lox/test_interpreter.rb @@ -0,0 +1,104 @@ +require_relative "../test_helper" + +require "lox/interpreter" +require "lox/parser" +require "lox/scanner" + +class TestInterpreter < Lox::Test + def setup + @scanner = Lox::Scanner.new + @parser = Lox::Parser.new + @interpreter = Lox::Interpreter.new + end + + def test_literal + assert_interpreted(42.0, "42") + end + + def test_grouping + assert_interpreted(42.0, "(42)") + end + + def test_unary + assert_interpreted(-42.0, "-42") + assert_interpreted(false, "!42") + assert_interpreted(false, "!true") + assert_interpreted(true, "!false") + assert_interpreted(true, "!nil") + end + + def test_binary + assert_interpreted(42.0, "100 - 58") + assert_interpreted(42.0, "84 / 2") + assert_interpreted(42.0, "21 * 2") + + # precedence + assert_interpreted(42.0, "2 * 25 - 8") + + assert_interpreted(42.0, "40 + 2") + assert_interpreted("42", "\"4\" + \"2\"") + + assert_interpreted(true, "1 > 0") + assert_interpreted(false, "0 > 0") + assert_interpreted(false, "0 > 1") + + assert_interpreted(true, "1 >= 0") + assert_interpreted(true, "0 >= 0") + assert_interpreted(false, "0 >= 1") + + assert_interpreted(false, "1 < 0") + assert_interpreted(false, "0 < 0") + assert_interpreted(true, "0 < 1") + + assert_interpreted(false, "1 <= 0") + assert_interpreted(true, "0 <= 0") + assert_interpreted(true, "0 <= 1") + + assert_interpreted(true, "0 != 1") + assert_interpreted(false, "0 != 0") + assert_interpreted(false, "nil != nil") + assert_interpreted(true, "nil != 1") + + assert_interpreted(false, "0 == 1") + assert_interpreted(true, "0 == 0") + assert_interpreted(true, "nil == nil") + assert_interpreted(false, "nil == 1") + end + + def test_errors + [ + "-true", + "12 > true", + "true < 23", + "false * 23", + "false + 23", + ].each do |src| + assert_raises Lox::RuntimeError do + evaluate(src) + end + end + end + + def test_stringify + assert_equal "nil", interpret("nil") + assert_equal "42", interpret("42") + assert_equal "42.1", interpret("42.1") + assert_equal "foo", interpret("\"foo\"") + end + + private + + def evaluate(src) + expr = @parser.parse(@scanner.scan(src)) + @interpreter.evaluate(expr) + end + + def interpret(src) + expr = @parser.parse(@scanner.scan(src)) + @interpreter.interpret(expr) + end + + def assert_interpreted(expected, src) + assert_equal expected, evaluate(src) + end +end diff --git a/ruby/test/lox/test_parser.rb b/ruby/test/lox/test_parser.rb index 2585418..7da6084 100644 --- a/ruby/test/lox/test_parser.rb +++ b/ruby/test/lox/test_parser.rb @@ -9,7 +9,6 @@ class TestParser < Lox::Test def setup @ast_printer = Lox::AstPrinter.new @scanner = Lox::Scanner.new - @parser = Lox::Parser.new end def test_expression diff --git a/ruby/test/test_helper.rb b/ruby/test/test_helper.rb index 4ed7277..681c495 100644 --- a/ruby/test/test_helper.rb +++ b/ruby/test/test_helper.rb @@ -7,32 +7,37 @@ module Lox def assert_lox(path) src = File.read(path) + # https://github.com/munificent/craftinginterpreters/blob/master/tool/bin/test.dart#L12-L18 expected_out = src.scan(/(?<=\/\/ expect: )(?~\n)/).join("\n") + expected_err = src[/(?<=\/\/ expect runtime error: )(?~\n)/] - out, _err, _status = Open3.capture3(LOX_BIN, path) + out, err, _status = Open3.capture3(LOX_BIN, path) assert_equal expected_out, out + assert_equal expected_err, err.lines(chomp: true)[0] end end - book_src = File.expand_path(ENV.fetch("CRAFTING_INTERPRETERS_SRC")) - Dir.chdir(book_src) do - lox_tests = Dir["./test/**/*.lox"] - .group_by {|path| path.rpartition(?/).first.sub(/\.\/test\/?/, "") } - - lox_tests.each do |dir, paths| - klass = Class.new(Test) do - paths.each do |path| - name = File.basename(path, ".lox") - define_method("test_#{name}") do - assert_lox File.expand_path(path, book_src) + if ENV.has_key?("LOX_TEST") + book_src = File.expand_path(ENV.fetch("CRAFTING_INTERPRETERS_SRC")) + Dir.chdir(book_src) do + lox_tests = Dir["./test/**/*.lox"] + .group_by {|path| path.rpartition(?/).first.sub(/\.\/test\/?/, "") } + + lox_tests.each do |dir, paths| + klass = Class.new(Test) do + paths.each do |path| + name = File.basename(path, ".lox") + define_method("test_#{name}") do + assert_lox File.expand_path(path, book_src) + end end end - end - suite = dir.split(?/).map(&:capitalize).join - suite = "Root" if suite.empty? - Object.const_set("Test#{suite}", klass) + suite = dir.split(?/).map(&:capitalize).join + suite = "Root" if suite.empty? + Object.const_set("Test#{suite}", klass) + end end end end diff --git a/ruby/test/test_lox.rb b/ruby/test/test_lox.rb index afdff93..d1d4ed0 100644 --- a/ruby/test/test_lox.rb +++ b/ruby/test/test_lox.rb @@ -4,8 +4,6 @@ require "lox" require "open3" -require "mocktail" - class TestLox < Lox::Test def test_error_on_more_than_one_arg lox_path = File.expand_path("../bin/lox", __dir__) @@ -14,23 +12,3 @@ class TestLox < Lox::Test assert_equal "Usage: #{lox_path} [script]\n", o end end - -class TestRunner < Lox::Test - include Mocktail::DSL - - def teardown - Mocktail.reset - end - - # This test sucks, but we'll live with it just not - # exploding our runner for now. - def test_prints - scanner = Mocktail.of(Lox::Scanner) - parser = Mocktail.of(Lox::Parser) - runner = Lox::Runner.new(scanner, parser) - stubs { scanner.scan("src") }.with { %w[ some tokens ] } - stubs { parser.parse(%w[ some tokens ]) }.with { Lox::Expr::Literal.new("foo") } - - runner.run("src") - end -end