6
FossilOrigin-Name: 53d2e006949bbca6adb34110c64c3288c1077e2cb8ba71ef24803b81aedccb12
change kmvqvrlkzspomvuqulzvwxqyqlpwyzoo
commit 6a74fbce87566726990f7153741143547cbe79af
author alpha <alpha@kejadlen.dev>
date
parent wmxutnnx
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