diff --git a/ruby/Rakefile b/ruby/Rakefile index ba8c644..bf533d4 100644 --- a/ruby/Rakefile +++ b/ruby/Rakefile @@ -2,3 +2,12 @@ require "minitest/test_task" Minitest::TestTask.create task default: :test + +namespace :test do + desc "Run tests on source changes" + task :watch do + loop do + sh "fd .*.rb | entr -d rake test:isolated" + end + end +end diff --git a/ruby/lib/lox.rb b/ruby/lib/lox.rb index 45c5ad7..4212b64 100644 --- a/ruby/lib/lox.rb +++ b/ruby/lib/lox.rb @@ -1,20 +1,25 @@ #!/usr/bin/env ruby -w +require_relative "lox/ast_printer" require_relative "lox/error" require_relative "lox/expr" +require_relative "lox/parser" require_relative "lox/scanner" require_relative "lox/token" module Lox class Runner - def initialize(scanner: Scanner.new) - @scanner = scanner + def initialize(scanner=Scanner.new, parser=Parser.new) + @scanner, @parser = scanner, parser end def run(src) - @scanner.scan(src).each do |token| - puts token - end + tokens = @scanner.scan(src) + expr = @parser.parse(tokens) + + puts AstPrinter.new.print(expr) + rescue ParseError => e + puts e.message end end end diff --git a/ruby/lib/lox/error.rb b/ruby/lib/lox/error.rb index 4120812..f3d8d6a 100644 --- a/ruby/lib/lox/error.rb +++ b/ruby/lib/lox/error.rb @@ -1,11 +1,10 @@ module Lox class Error < StandardError - def initialize(line:, where: "", message:) - @line, @where, @message = line, where, message - end + def initialize(line, where="", message) + error = "Error" + error << " #{where}" unless where.empty? - def to_s - "[line #@line] Error#@where: #@message" + super("[line #{line}] #{error}: #{message}") end end end diff --git a/ruby/lib/lox/parser.rb b/ruby/lib/lox/parser.rb new file mode 100644 index 0000000..3c061f6 --- /dev/null +++ b/ruby/lib/lox/parser.rb @@ -0,0 +1,143 @@ +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) + super(tokens, 0) + end + + def expression = equality + + def equality + expr = comparison + + while match?(:BANG_EQUAL, :EQUAL_EQUAL) + op = prev + right = comparison + expr = Expr::Binary.new(expr, op, right) + end + + expr + end + + def comparison + expr = term + + while match?(:GREATER, :GREATER_EQUAL, :LESS, :LESS_EQUAL) + op = prev + right = term + expr = Expr::Binary.new(expr, op, right) + end + + expr + end + + + def term + expr = factor + + while match?(:MINUS, :PLUS) + op = prev + right = factor + expr = Expr::Binary.new(expr, op, right) + end + + expr + end + + def factor + expr = unary + + while match?(:SLASH, :STAR) + op = prev + right = unary + expr = Expr::Binary.new(expr, op, right) + end + + expr + end + + def unary + return primary unless match?(:BANG, :MINUS) + + op = prev + right = unary + Expr::Unary.new(op, right) + end + + def primary + return Expr::Literal.new(false) if match?(:FALSE) + return Expr::Literal.new(true) if match?(:TRUE) + return Expr::Literal.new(nil) if match?(:NIL) + return Expr::Literal.new(prev.literal) if match?(:NUMBER, :STRING) + + if match?(:LEFT_PAREN) + expr = expression + consume!(:RIGHT_PAREN, "Expect ')' after expression.") + return Expr::Grouping.new(expr) + end + + raise ParseError.new(peek, "Expect expression.") + end + + private + + def match?(*types) + return false unless check?(*types) + + advance! + return true + end + + def consume!(type, message) + raise ParseError.new(peek, message) unless check?(type) + + advance! + end + + def check?(*types) + return false if eot? + + types.include?(peek.type) + end + + def advance! + self.current += 1 unless eot? + prev + end + + def eot? = peek.type === :EOF + def peek = tokens.fetch(current) + def prev = tokens.fetch(current - 1) + + def synchronize! + advance! + + until eot? + return if prev.type == :SEMICOLON + return if %i[CLASS FUN VAR FOR IF WHILE PRINT RETURN].include?(peek.type) + + advance! + end + end + end + + # In the book, this returns nil when there's an error, but + # that feels weird so let's move that error handling up the + # stack for now. + def parse(tokens) + state = State.new(tokens, 0) + state.expression + end + end +end diff --git a/ruby/lib/lox/scanner.rb b/ruby/lib/lox/scanner.rb index ca3d477..bcbbe6f 100644 --- a/ruby/lib/lox/scanner.rb +++ b/ruby/lib/lox/scanner.rb @@ -1,5 +1,6 @@ require "strscan" +require_relative "error" require_relative "token" module Lox diff --git a/ruby/test/lox/test_parser.rb b/ruby/test/lox/test_parser.rb new file mode 100644 index 0000000..2585418 --- /dev/null +++ b/ruby/test/lox/test_parser.rb @@ -0,0 +1,67 @@ +require_relative "../test_helper" + +require "lox/ast_printer" +require "lox/parser" +require "lox/scanner" +require "lox/token" + +class TestParser < Lox::Test + def setup + @ast_printer = Lox::AstPrinter.new + @scanner = Lox::Scanner.new + @parser = Lox::Parser.new + end + + def test_expression + assert_parsed "(== 4.0 (>= 2.0 1.0))", :expression, "4 == 2 >= 1" + end + + def test_term + assert_parsed "(+ 4.0 (/ 2.0 1.0))", :term, "4 + 2 / 1" + end + + def test_factor + assert_parsed "(/ 4.0 (- 2.0))", :factor, "4 / -2" + assert_parsed "(* 4.0 2.0)", :factor, "4 * 2" + assert_parsed "(- 2.0)", :factor, "-2" + end + + def test_unary + assert_parsed "(! 42.0)", :unary, "!42" + assert_parsed "(- 42.0)", :unary, "-42" + assert_parsed "42.0", :unary, "42" + end + + def test_primary + assert_parsed "false", :primary, "false" + assert_parsed "true", :primary, "true" + assert_parsed "nil", :primary, "nil" + assert_parsed "42.0", :primary, "42" + assert_parsed "foo", :primary, "\"foo\"" + assert_parsed "(group foo)", :primary, "(\"foo\")" + end + + def test_errors + e = assert_raises Lox::ParseError do + parse("(42", :primary) + end + assert_equal "[line 1] Error at end: Expect ')' after expression.", e.message + + e = assert_raises Lox::ParseError do + parse("foo foo", :primary) + end + assert_equal "[line 1] Error at 'foo': Expect expression.", e.message + end + + private + + def parse(src, name) + tokens = @scanner.scan(src) + Lox::Parser::State.new(tokens).send(name) + end + + def assert_parsed(expected, name, src) + expr = parse(src, name) + assert_equal expected, @ast_printer.print(expr) + end +end diff --git a/ruby/test/test_lox.rb b/ruby/test/test_lox.rb index eca0bb0..21b62d6 100644 --- a/ruby/test/test_lox.rb +++ b/ruby/test/test_lox.rb @@ -22,14 +22,16 @@ class TestRunner < Lox::Test Mocktail.reset end - def test_returns_tokens + # This test sucks, but we'll live with it just not + # exploding our runner for now. + def test_prints scanner = Mocktail.of(Lox::Scanner) - runner = Lox::Runner.new(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") } - tokens = runner.run("src") - - assert_equal %w[ some tokens ], tokens + runner.run("src") end end