loxby 0.0.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c24f85fecb8840b0c2b6331b6b4dda6c5a4899e830d81f52ed6ffa12065b432
4
+ data.tar.gz: 3b694bcad715f645c52501d192944c207932d71909189f0b9f2dd0eae9e3d392
5
+ SHA512:
6
+ metadata.gz: 14167ca0449fa15e8b5eba780ab63af4d9b702c9b762c8bb3d2724840549481a25ba1806a4d5702e8ed0b925518872a1ec72ec17db0bc7f8170011d1891e9903
7
+ data.tar.gz: 6b8366382f8c51bffdfe71802a0b551aee4938e0880c878012778c8508094544a9f1c02ca1f7dde57dcc1e03919545387b652877dc2ea38266c8422d6ba89b14
@@ -0,0 +1,44 @@
1
+ name: Ruby Gem
2
+
3
+ on: workflow_dispatch
4
+
5
+ jobs:
6
+ build:
7
+ name: Build + Publish
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ contents: read
11
+ packages: write
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Set up Ruby 3.1
16
+ # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
17
+ # change this to (see https://github.com/ruby/setup-ruby#versioning):
18
+ uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: 3.1.3
21
+
22
+ - name: Publish to GPR
23
+ run: |
24
+ mkdir -p $HOME/.gem
25
+ touch $HOME/.gem/credentials
26
+ chmod 0600 $HOME/.gem/credentials
27
+ printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
28
+ gem build *.gemspec
29
+ gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem
30
+ env:
31
+ GEM_HOST_API_KEY: "Bearer ${{secrets.GITHUB_TOKEN}}"
32
+ OWNER: ${{ github.repository_owner }}
33
+
34
+ - name: Publish to RubyGems
35
+ run: |
36
+ mkdir -p $HOME/.gem
37
+ touch $HOME/.gem/credentials
38
+ chmod 0600 $HOME/.gem/credentials
39
+ chmod +x bin/loxby
40
+ printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
41
+ gem build *.gemspec
42
+ gem push *.gem
43
+ env:
44
+ GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}"
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1.3
3
+
4
+ Metrics/ClassLength:
5
+ Enabled: false
6
+
7
+ Lint/BooleanSymbol:
8
+ Enabled: false
data/Gemfile.lock ADDED
@@ -0,0 +1,47 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ loxby (0.0.1)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.2)
10
+ json (2.7.2)
11
+ language_server-protocol (3.17.0.3)
12
+ parallel (1.24.0)
13
+ parser (3.3.2.0)
14
+ ast (~> 2.4.1)
15
+ racc
16
+ racc (1.8.0)
17
+ rainbow (3.1.1)
18
+ regexp_parser (2.9.2)
19
+ rexml (3.2.8)
20
+ strscan (>= 3.0.9)
21
+ rubocop (1.64.1)
22
+ json (~> 2.3)
23
+ language_server-protocol (>= 3.17.0)
24
+ parallel (~> 1.10)
25
+ parser (>= 3.3.0.2)
26
+ rainbow (>= 2.2.2, < 4.0)
27
+ regexp_parser (>= 1.8, < 3.0)
28
+ rexml (>= 3.2.5, < 4.0)
29
+ rubocop-ast (>= 1.31.1, < 2.0)
30
+ ruby-progressbar (~> 1.7)
31
+ unicode-display_width (>= 2.4.0, < 3.0)
32
+ rubocop-ast (1.31.3)
33
+ parser (>= 3.3.1.0)
34
+ ruby-progressbar (1.13.0)
35
+ strscan (3.1.0)
36
+ unicode-display_width (2.5.0)
37
+
38
+ PLATFORMS
39
+ x64-mingw-ucrt
40
+
41
+ DEPENDENCIES
42
+ loxby!
43
+ rubocop
44
+ strscan
45
+
46
+ BUNDLED WITH
47
+ 2.5.10
data/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # Loxby
2
+ *A Lox interpreter written in Ruby*
3
+
4
+ Loxby is written following the first half of Robert Nystrom's wonderful web-format book [Crafting Interpreters](https://www.craftinginterpreters.com), adapting the Java code to modern Ruby. This project is intended to explore what elegant object-oriented code can look like and accomplish.
5
+
6
+ ## Usage
7
+ 1. `gem install loxby` or `gem 'loxby'`
8
+ 2. `loxby [filename]` to run a file or `loxby` to run in REPL mode
9
+ 3. To run the interpreter from Ruby:
10
+ ```ruby
11
+ require 'loxby/runner'
12
+ Lox::Runner.new(ARGV, $stdout, $stderr)
13
+ ```
data/bin/loxby ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'loxby/runner'
6
+
7
+ # Start interpreter when run as script
8
+ Lox::Runner.new.run(ARGV) if __FILE__ == $PROGRAM_NAME
data/lib/loxby/core.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'scanner'
4
+ require_relative 'parser'
5
+ require_relative 'interpreter'
6
+ require_relative 'helpers/token_type'
7
+
8
+ # Lox interpreter.
9
+ # Each interpreter keeps track of its own
10
+ # environment, including variable and
11
+ # function scope.
12
+ class Lox
13
+ attr_reader :errored, :interpreter
14
+
15
+ def initialize
16
+ @errored = false
17
+ @errored_in_runtime = false
18
+ @interpreter = Interpreter.new(self) # Make static so REPL sessions reuse it
19
+ end
20
+
21
+ # Run from file
22
+ def run_file(path)
23
+ if File.exist? path
24
+ run File.read(path)
25
+ else
26
+ report(0, '', "No such file: '#{path}'")
27
+ end
28
+ exit(65) if @errored # Don't execute malformed code
29
+ exit(70) if @errored_in_runtime
30
+ end
31
+
32
+ # Run interactively
33
+ def run_prompt
34
+ loop do
35
+ print '> '
36
+ line = gets
37
+ break unless line # Trap eof (Ctrl+D unix, Ctrl+Z win)
38
+
39
+ result = run(line)
40
+ puts "=> #{@interpreter.lox_obj_to_str result}" unless @errored
41
+ @errored = false # Reset so a mistake doesn't kill the repl
42
+ end
43
+ end
44
+
45
+ # Run a string
46
+ def run(source)
47
+ tokens = Scanner.new(source, self).scan_tokens
48
+ parser = Parser.new(tokens, self)
49
+ statements = parser.parse
50
+ return if @errored
51
+
52
+ @interpreter.interpret statements
53
+ end
54
+
55
+ def error(line, message)
56
+ if line.is_a? Lox::Token
57
+ # Parse/runtime error
58
+ where = line.type == :eof ? 'end' : "'#{line.lexeme}'"
59
+ report(line.line, " at #{where}", message)
60
+ else
61
+ # Scan error
62
+ report(line, '', message)
63
+ end
64
+ end
65
+
66
+ # rubocop:disable Style/StderrPuts
67
+
68
+ def runtime_error(err)
69
+ $stderr.puts err.message
70
+ $stderr.puts "[line #{err.token.line}]"
71
+ @errored_in_runtime = true
72
+ end
73
+
74
+ private
75
+
76
+ def report(line, where, message)
77
+ $stderr.puts "[line #{line}] Error#{where}: #{message}"
78
+ @errored = true
79
+ end
80
+ end
81
+ # rubocop:enable Style/StderrPuts
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../visitors/base'
4
+
5
+ class String # rubocop:disable Style/Documentation
6
+ def to_camel_case
7
+ to_s.split(/[-_]/).map(&:capitalize).join('')
8
+ end
9
+ end
10
+
11
+ class Symbol # rubocop:disable Style/Documentation
12
+ def to_camel_case
13
+ to_s.to_camel_case.to_sym
14
+ end
15
+ end
16
+
17
+ class Lox
18
+ # Interface:
19
+ # Lox::AST.define_ast(
20
+ # "ASTBaseClass",
21
+ # {
22
+ # :ast_type => [[:field_one_type, :field_one_name], [:field_two_type, :field_two_name]],
23
+ # :other_ast_type => [[:field_type, :field_name]]
24
+ # }
25
+ # )
26
+ #
27
+ # This defines Lox::AST::ASTBaseClass, which ::AstType and ::OtherAstType descend from
28
+ # and are scoped under. It also defines the Visitor pattern: AstType defines #accept(visitor),
29
+ # which calls `visitor.visit_ast_type(self)`
30
+ module AST
31
+ module_function
32
+
33
+ def define_ast(base_name, types)
34
+ base_class = Class.new
35
+ base_class.include Visitable
36
+ # Define boilerplate visitor methods
37
+ Visitor.define_types(base_name, types.keys)
38
+ # Dynamically create subclasses for each AST type
39
+ types.each do |class_name, fields|
40
+ define_type(base_class, base_name, class_name, fields)
41
+ end
42
+
43
+ define_class base_name.to_camel_case, base_class
44
+ end
45
+
46
+ def define_type(base_class, base_class_name, subtype_name, fields) # rubocop:disable Metrics/MethodLength
47
+ subtype = Class.new(base_class)
48
+ parameters = fields.map { _1[1].to_s }
49
+
50
+ subtype.class_eval <<~RUBY, __FILE__, __LINE__ + 1
51
+ include Visitable # Visitor pattern
52
+ attr_reader #{parameters.map { ":#{_1}" }.join(', ')}
53
+ def initialize(#{parameters.map { "#{_1}:" }.join(', ')})
54
+ #{parameters.map { "@#{_1}" }.join(', ')} = #{parameters.join ', '}
55
+ end
56
+
57
+ # Dynamically generated for visitor pattern.
58
+ # Expects visitor to define #visit_#{subtype_name}
59
+ def accept(visitor)
60
+ visitor.visit_#{subtype_name}_#{base_class_name}(self)
61
+ end
62
+ RUBY
63
+
64
+ define_class(subtype_name.to_camel_case, subtype, base_class:)
65
+ end
66
+
67
+ def define_class(class_name, klass, base_class: Lox::AST)
68
+ base_class.const_set class_name, klass
69
+ end
70
+ end
71
+ end
72
+
73
+ Lox::AST.define_ast(
74
+ :expression,
75
+ {
76
+ assign: [%i[token name], %i[expr value]],
77
+ binary: [%i[expr left], %i[token operator], %i[expr right]],
78
+ ternary: [%i[expr left], %i[token left_operator], %i[expr center], %i[token right_operator], %i[expr right]],
79
+ grouping: [%i[expr expression]],
80
+ literal: [%i[object value]],
81
+ unary: [%i[token operator], %i[expr right]],
82
+ variable: [%i[token name]]
83
+ }
84
+ )
85
+
86
+ Lox::AST.define_ast(
87
+ :statement,
88
+ {
89
+ block: [%i[stmt_list statements]],
90
+ expression: [%i[expr expression]],
91
+ print: [%i[expr expression]],
92
+ var: [%i[token name], %i[expr initializer]]
93
+ }
94
+ )
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ class Lox
6
+ # Lox::Environment stores namespace for
7
+ # a Lox interpreter. Environments can be
8
+ # nested (for scope).
9
+ class Environment
10
+ def initialize(enclosing = nil)
11
+ @enclosing = enclosing
12
+ @values = {}
13
+ end
14
+
15
+ def undefined_variable(name)
16
+ Lox::RunError.new(name, "Undefined variable '#{name.lexeme}'")
17
+ end
18
+
19
+ def []=(name, value)
20
+ @values[name.lexeme] = value
21
+ end
22
+
23
+ def exists?(name)
24
+ @values.keys.member? name.lexeme
25
+ end
26
+
27
+ def [](name)
28
+ if exists? name
29
+ @values[name.lexeme]
30
+ elsif @enclosing
31
+ @enclosing[name]
32
+ else
33
+ raise undefined_variable(name)
34
+ end
35
+ end
36
+
37
+ def assign(name, value)
38
+ if exists? name
39
+ self[name] = value
40
+ elsif @enclosing
41
+ @enclosing.assign(name, value)
42
+ else
43
+ raise undefined_variable(name)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Lox
4
+ class ParseError < RuntimeError; end
5
+
6
+ class RunError < RuntimeError # rubocop:disable Style/Documentation
7
+ attr_reader :token
8
+
9
+ def initialize(token, message)
10
+ super(message)
11
+ @token = token
12
+ end
13
+ end
14
+
15
+ class DividedByZeroError < RunError; end
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Lox
4
+ class Token
5
+ TOKENS = [
6
+ # Single-character tokens.
7
+ :left_paren, :right_paren, :left_brace, :right_brace,
8
+ :comma, :dot, :minus, :plus, :semicolon, :slash, :star,
9
+ :question, :colon,
10
+
11
+ # 1-2 character tokens.
12
+ :bang, :bang_equal,
13
+ :equal, :equal_equal,
14
+ :greater, :greater_equal,
15
+ :less, :less_equal,
16
+
17
+ # Literals.
18
+ :identifier, :string, :number,
19
+
20
+ # Keywords.
21
+ :and, :class, :else, :false, :fun, :for, :if, :nil, :or,
22
+ :print, :return, :super, :this, :true, :var, :while,
23
+
24
+ :eof
25
+ ].freeze
26
+ SINGLE_TOKENS = TOKENS.zip('(){},.-+;/*'.split('')).to_h
27
+
28
+ attr_reader :type, :lexeme, :literal, :line
29
+
30
+ def initialize(type, lexeme, literal, line)
31
+ @type = type
32
+ @lexeme = lexeme
33
+ @literal = literal
34
+ @line = line
35
+ end
36
+
37
+ def to_s = "#{type} #{lexeme} #{literal}"
38
+ def inspect = "#<Lox::Token #{self}>"
39
+ end
40
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/environment'
4
+ require_relative 'helpers/errors'
5
+ require_relative 'visitors/base'
6
+
7
+ # Interpreter class. Walks the AST using
8
+ # the Visitor pattern.
9
+ class Interpreter < Visitor
10
+ def initialize(process) # rubocop:disable Lint/MissingSuper
11
+ @process = process
12
+ @environment = Lox::Environment.new
13
+ end
14
+
15
+ def interpret(statements)
16
+ result = nil
17
+ statements.each { result = lox_eval(_1) }
18
+ result
19
+ rescue Lox::RunError => e
20
+ @process.runtime_error e
21
+ end
22
+
23
+ def lox_eval(expr)
24
+ expr.accept self
25
+ end
26
+
27
+ # Lox's definition of truthiness follows
28
+ # Ruby's by definition. This does nothing.
29
+ def truthy?(obj)
30
+ obj
31
+ end
32
+
33
+ def ensure_number(operator, *objs)
34
+ raise Lox::RunError.new(operator, 'Operand must be a number.') unless objs.all? { _1.is_a?(Float) }
35
+ end
36
+
37
+ def lox_obj_to_str(obj)
38
+ case obj
39
+ when nil
40
+ 'nil'
41
+ when Float
42
+ obj.to_s[-2..] == '.0' ? obj.to_s[0...-2] : obj.to_s
43
+ else
44
+ obj.to_s
45
+ end
46
+ end
47
+
48
+ def visit_expression_statement(statement)
49
+ lox_eval statement.expression
50
+ end
51
+
52
+ def visit_print_statement(statement)
53
+ value = lox_eval statement.expression
54
+ puts lox_obj_to_str(value)
55
+ end
56
+
57
+ def visit_var_statement(statement)
58
+ value = statement.initializer ? lox_eval(statement.initializer) : nil
59
+ @environment[statement.name] = value
60
+ end
61
+
62
+ def visit_variable_expression(expr)
63
+ @environment[expr.name]
64
+ end
65
+
66
+ def visit_assign_expression(expr)
67
+ value = lox_eval expr.value
68
+ @environment.assign expr.name, value
69
+ value
70
+ end
71
+
72
+ def visit_block_statement(statement)
73
+ execute_block(statement.statements, Lox::Environment.new(@environment))
74
+ end
75
+
76
+ def execute_block(statements, environment)
77
+ previous = @environment
78
+ @environment = environment
79
+ statements.each { lox_eval _1 }
80
+ ensure
81
+ @environment = previous
82
+ end
83
+
84
+ # Leaves of the AST. The scanner picks
85
+ # out these values for us beforehand.
86
+ def visit_literal_expression(expr)
87
+ expr.value
88
+ end
89
+
90
+ def visit_grouping_expression(expr)
91
+ lox_eval expr
92
+ end
93
+
94
+ def visit_unary_expression(expr)
95
+ right = lox_eval(expr.right)
96
+
97
+ case expr.operator.type
98
+ when :minus
99
+ ensure_number(expr.operator, right)
100
+ -right.to_f
101
+ when :bang
102
+ truthy? right
103
+ end
104
+ end
105
+
106
+ def visit_binary_expression(expr) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/AbcSize
107
+ left = lox_eval expr.left
108
+ right = lox_eval expr.right
109
+ case expr.operator.type
110
+ when :minus
111
+ ensure_number(expr.operator, left, right)
112
+ left.to_f - right.to_f
113
+ when :slash
114
+ raise Lox::DividedByZeroError.new(expr.operator, 'Cannot divide by zero.') if right == 0.0
115
+
116
+ ensure_number(expr.operator, left, right)
117
+ left.to_f / right
118
+ when :star
119
+ ensure_number(expr.operator, left, right)
120
+ left.to_f * right.to_f
121
+ when :plus
122
+ unless (left.is_a?(Float) || left.is_a?(String)) && left.instance_of?(right.class)
123
+ raise Lox::RunError.new(expr.operator, 'Operands must be two numbers or two strings.')
124
+ end
125
+
126
+ left + right
127
+ when :greater
128
+ ensure_number(expr.operator, left, right)
129
+ left.to_f > right.to_f
130
+ when :greater_equal
131
+ ensure_number(expr.operator, left, right)
132
+ left.to_f >= right.to_f
133
+ when :less
134
+ ensure_number(expr.operator, left, right)
135
+ left.to_f < right.to_f
136
+ when :less_equal
137
+ ensure_number(expr.operator, left, right)
138
+ left.to_f <= right.to_f
139
+ when :bang_equal
140
+ left != right
141
+ when :equal_equal
142
+ left == right
143
+ when :comma
144
+ right
145
+ end
146
+ end
147
+
148
+ def visit_ternary_expression(expr)
149
+ left = lox_eval expr.left
150
+
151
+ left ? lox_eval(expr.center) : lox_eval(expr.right)
152
+ end
153
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/ast'
4
+ require_relative 'helpers/errors'
5
+
6
+ class Lox
7
+ # Lox::Parser converts a list of tokens
8
+ # from Lox::Scanner to a syntax tree.
9
+ # This tree can be interacted with using
10
+ # the Visitor pattern. (See the Visitor
11
+ # class in visitors/base.rb.)
12
+ class Parser
13
+ def initialize(tokens, interpreter)
14
+ @tokens = tokens
15
+ @interpreter = interpreter
16
+ # Variables for parsing
17
+ @current = 0
18
+ end
19
+
20
+ def parse
21
+ statements = []
22
+ statements << declaration until end_of_input?
23
+ statements
24
+ end
25
+
26
+ def declaration
27
+ if matches?(:var)
28
+ var_declaration
29
+ else
30
+ statement
31
+ end
32
+ rescue Lox::ParseError
33
+ synchronize
34
+ nil
35
+ end
36
+
37
+ def var_declaration
38
+ name = consume :identifier, 'Expect variable name.'
39
+ initializer = matches?(:equal) ? expression : nil
40
+ consume :semicolon, "Expect ';' after variable declaration."
41
+ Lox::AST::Statement::Var.new(name:, initializer:)
42
+ end
43
+
44
+ def statement
45
+ if matches? :print
46
+ print_statement
47
+ elsif matches? :left_brace
48
+ Lox::AST::Statement::Block.new(statements: block)
49
+ else
50
+ expression_statement
51
+ end
52
+ end
53
+
54
+ def block
55
+ statements = []
56
+ statements << declaration until check(:right_brace) || end_of_input?
57
+ consume :right_brace, "Expect '}' after block."
58
+ statements
59
+ end
60
+
61
+ def print_statement
62
+ value = expression_list
63
+ consume :semicolon, "Expect ';' after value."
64
+ Lox::AST::Statement::Print.new(expression: value)
65
+ end
66
+
67
+ def expression_statement
68
+ expr = expression_list
69
+ consume :semicolon, "Expect ';' after expression."
70
+ Lox::AST::Statement::Expression.new(expression: expr)
71
+ end
72
+
73
+ def expression_list
74
+ expr = conditional
75
+
76
+ while matches? :comma
77
+ operator = previous
78
+ right = conditional
79
+ expr = Lox::AST::Expression::Binary.new(left: expr, operator:, right:)
80
+ end
81
+
82
+ expr
83
+ end
84
+
85
+ # Ternary operator
86
+ def conditional
87
+ expr = expression
88
+
89
+ if matches? :question
90
+ left_operator = previous
91
+ center = check(:colon) ? Lox::AST::Expression::Literal.new(value: nil) : expression_list
92
+ consume :colon, "Expect ':' after expression (ternary operator)."
93
+ right_operator = previous
94
+ right = conditional # Recurse, right-associative
95
+ expr = Lox::AST::Expression::Ternary.new(left: expr, left_operator:, center:, right_operator:, right:)
96
+ end
97
+
98
+ expr
99
+ end
100
+
101
+ def expression
102
+ assignment
103
+ end
104
+
105
+ def assignment # rubocop:disable Metrics/MethodLength
106
+ expr = equality
107
+
108
+ if matches? :equal
109
+ equals = previous
110
+ value = assignment
111
+
112
+ if expr.is_a? Lox::AST::Expression::Variable
113
+ name = expr.name
114
+ return Lox::AST::Expression::Assign.new(name:, value:)
115
+ end
116
+
117
+ error equals, 'Invalid assignment target.'
118
+ end
119
+
120
+ expr
121
+ end
122
+
123
+ def equality
124
+ expr = comparison
125
+ while matches?(:bang_equal, :equal_equal)
126
+ operator = previous
127
+ right = comparison
128
+ # Compose (equality is left-associative)
129
+ expr = Lox::AST::Expression::Binary.new(left: expr, operator:, right:)
130
+ end
131
+ expr
132
+ end
133
+
134
+ def comparison
135
+ expr = term
136
+
137
+ while matches?(:greater, :greater_equal, :less, :less_equal)
138
+ operator = previous
139
+ right = term
140
+ expr = Lox::AST::Expression::Binary.new(left: expr, operator:, right:)
141
+ end
142
+
143
+ expr
144
+ end
145
+
146
+ def term
147
+ expr = factor
148
+
149
+ while matches?(:minus, :plus)
150
+ operator = previous
151
+ right = factor
152
+ expr = Lox::AST::Expression::Binary.new(left: expr, operator:, right:)
153
+ end
154
+
155
+ expr
156
+ end
157
+
158
+ def factor
159
+ expr = unary
160
+
161
+ while matches?(:slash, :star)
162
+ operator = previous
163
+ right = unary
164
+ expr = Lox::AST::Expression::Binary.new(left: expr, operator:, right:)
165
+ end
166
+
167
+ expr
168
+ end
169
+
170
+ def unary
171
+ # Unary operators are right-associative, so we match
172
+ # first, then recurse.
173
+ if matches?(:bang, :minus)
174
+ operator = previous
175
+ right = unary
176
+
177
+ Lox::AST::Expression::Unary.new(operator:, right:)
178
+ else
179
+ primary
180
+ end
181
+ end
182
+
183
+ def primary # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
184
+ if matches? :false
185
+ Lox::AST::Expression::Literal.new(value: false)
186
+ elsif matches? :true
187
+ Lox::AST::Expression::Literal.new(value: true)
188
+ elsif matches? :nil
189
+ Lox::AST::Expression::Literal.new(value: nil)
190
+ elsif matches? :number, :string
191
+ Lox::AST::Expression::Literal.new(value: previous.literal)
192
+ elsif matches? :identifier
193
+ Lox::AST::Expression::Variable.new(name: previous)
194
+ elsif matches? :left_paren
195
+ expr = expression_list
196
+ consume :right_paren, "Expect ')' after expression."
197
+ Lox::AST::Expression::Grouping.new(expression: expr)
198
+ # Error productions--binary operator without left operand
199
+ elsif matches? :comma
200
+ err = error(previous, "Expect expression before ',' operator.")
201
+ conditional # Parse and throw away
202
+ raise err
203
+ elsif matches? :question
204
+ err = error(previous, 'Expect expression before ternary operator.')
205
+ expression_list
206
+ consume :colon, "Expect ':' after '?' (ternary operator)."
207
+ conditional
208
+ raise err
209
+ elsif matches? :bang_equal, :equal_equal
210
+ err = error(previous, "Expect value before '#{previous.lexeme}'.")
211
+ comparison
212
+ raise err
213
+ elsif matches? :greater, :greater_equal, :less, :less_equal
214
+ err = error(previous, "Expect value before '#{previous.lexeme}'.")
215
+ term
216
+ raise err
217
+ elsif matches? :plus
218
+ err = error(previous, "Expect value before '+'.")
219
+ factor
220
+ raise err
221
+ elsif matches? :slash, :star
222
+ err = error(previous, "Expect value before '#{previous.lexeme}'.")
223
+ unary
224
+ raise err
225
+ # Base case--no match.
226
+ else
227
+ raise error(peek, 'Expect expression.')
228
+ end
229
+ end
230
+
231
+ private
232
+
233
+ def consume(type, message)
234
+ return advance if check(type)
235
+
236
+ # Call #error to report the error to the interpreter, and
237
+ # raise the error in prep for synchronizing (panic mode).
238
+ raise error(peek, message)
239
+ end
240
+
241
+ def error(token, message)
242
+ @interpreter.error(token, message)
243
+ Lox::ParseError.new
244
+ end
245
+
246
+ # Synchronize the parser (i.e. panic mode).
247
+ # We're skipping possibly erroneous tokens
248
+ # to prevent cascading errors.
249
+ def synchronize
250
+ advance
251
+
252
+ until end_of_input?
253
+ return if previous.type == :semicolon
254
+ return if peek.type == :return
255
+
256
+ advance
257
+ end
258
+ end
259
+
260
+ # Checks if current token is a given type, consuming
261
+ # it if it is.
262
+ def matches?(*types)
263
+ # Array#any? guarantees short-circuit evaluation.
264
+ # #advance returns a Token/Expression, which is truthy.
265
+ # `advance if check(type)` is truthy when `check(type)`
266
+ # is.
267
+ types.any? { |type| advance if check(type) }
268
+ end
269
+
270
+ # Checks if current token is a given type. Does not
271
+ # consume it.
272
+ def check(type)
273
+ peek.type == type && !end_of_input?
274
+ end
275
+
276
+ def advance
277
+ @current += 1 unless end_of_input?
278
+ previous
279
+ end
280
+
281
+ def end_of_input?
282
+ peek.type == :eof
283
+ end
284
+
285
+ def peek
286
+ @tokens[@current]
287
+ end
288
+
289
+ def previous
290
+ @tokens[@current - 1]
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../loxby'
4
+
5
+ class Lox
6
+ # Lox::Runner is the interactive runner
7
+ # which kickstarts the interpreter.
8
+ # An instance is created when loxby is
9
+ # initialized from the command line.
10
+ class Runner
11
+ def initialize(out = $stdout, err = $stderr)
12
+ trap('SIGINT') { exit } # Exit cleanly on Ctrl-C
13
+ @interpreter = Lox.new
14
+ @out = out
15
+ @err = err
16
+ end
17
+
18
+ def run(args)
19
+ if args.size > 1
20
+ @out.puts 'Usage: loxby.rb [script]'
21
+ exit 64
22
+ elsif args.size == 1
23
+ @interpreter.run_file args[0]
24
+ else
25
+ @interpreter.run_prompt # Run interactively
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/token_type'
4
+
5
+ class Lox
6
+ class Scanner
7
+ EXPRESSIONS = {
8
+ whitespace: /\s/,
9
+ number_literal: /\d/,
10
+ identifier: /[a-zA-Z_]/
11
+ }.freeze
12
+ KEYWORDS = %w[and class else false for fun if nil or print return super this true var while]
13
+ .map { [_1, _1.to_sym] }
14
+ .to_h
15
+
16
+ attr_accessor :line
17
+
18
+ def initialize(source, interpreter)
19
+ @source = source
20
+ @tokens = []
21
+ @interpreter = interpreter
22
+ # Variables for scanning
23
+ @start = 0
24
+ @current = 0
25
+ @line = 1
26
+ end
27
+
28
+ def scan_tokens
29
+ until end_of_source?
30
+ # Beginnning of next lexeme
31
+ @start = @current
32
+ scan_token
33
+ end
34
+
35
+ # Implicitly return @tokens
36
+ @tokens << Lox::Token.new(:eof, "", nil, @line)
37
+ end
38
+
39
+ def scan_token # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
40
+ character = advance_character
41
+
42
+ case character
43
+ # Single-character tokens
44
+ when '('
45
+ add_token :left_paren
46
+ when ')'
47
+ add_token :right_paren
48
+ when '{'
49
+ add_token :left_brace
50
+ when '}'
51
+ add_token :right_brace
52
+ when ','
53
+ add_token :comma
54
+ when '.'
55
+ add_token :dot
56
+ when '-'
57
+ add_token :minus
58
+ when '+'
59
+ add_token :plus
60
+ when ';'
61
+ add_token :semicolon
62
+ when '*'
63
+ add_token :star
64
+ when '?'
65
+ add_token :question
66
+ when ':'
67
+ add_token :colon
68
+ # 1-2 character tokens
69
+ when '!'
70
+ add_token match('=') ? :bang_equal : :bang
71
+ when '='
72
+ add_token match('=') ? :equal_equal : :equal
73
+ when '<'
74
+ add_token match('=') ? :less_equal : :less
75
+ when '>'
76
+ add_token match('=') ? :greater_equal : :greater
77
+ when '/'
78
+ # '/' is division, '//' is comment, '/* ... */'
79
+ # is block comment. Needs special care.
80
+ if match('/') # comment line
81
+ advance_character until peek == "\n" || end_of_source?
82
+ elsif match('*') # block comment
83
+ scan_block_comment
84
+ else
85
+ add_token :slash
86
+ end
87
+ # Whitespace
88
+ when "\n"
89
+ @line += 1
90
+ when EXPRESSIONS[:whitespace]
91
+ # Literals
92
+ when '"'
93
+ scan_string
94
+ when EXPRESSIONS[:number_literal]
95
+ scan_number
96
+ # Keywords and identifiers
97
+ when EXPRESSIONS[:identifier]
98
+ scan_identifier
99
+ else
100
+ # Unknown character
101
+ @interpreter.error(@line, 'Unexpected character.')
102
+ end
103
+ end
104
+
105
+ def scan_block_comment # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
106
+ advance_character until (peek == '*' && peek_next == '/') || (peek == '/' && peek_next == '*') || end_of_source?
107
+
108
+ if end_of_source? || peek_next == "\0"
109
+ @interpreter.error(line, 'Unterminated block comment.')
110
+ return
111
+ elsif peek == '/' && peek_next == '*'
112
+ # Nested block comment. Skip opening characters
113
+ match '/'
114
+ match '*'
115
+ scan_block_comment # Skip nested comment
116
+ advance_character until (peek == '*' && peek_next == '/') || (peek == '/' && peek_next == '*') || end_of_source?
117
+ end
118
+
119
+ # Skip closing characters
120
+ match '*'
121
+ match '/'
122
+ end
123
+
124
+ def scan_string # rubocop:disable Metrics/MethodLength
125
+ until peek == '"' || end_of_source?
126
+ @line += 1 if peek == "\n"
127
+ advance_character
128
+ end
129
+
130
+ if end_of_source?
131
+ @interpreter.error(line, 'Unterminated string.')
132
+ return
133
+ end
134
+
135
+ # Skip closing "
136
+ advance_character
137
+
138
+ # Trim quotes around literal
139
+ value = @source[(@start + 1)...(@current - 1)]
140
+ add_token :string, value
141
+ end
142
+
143
+ def scan_number
144
+ advance_character while peek =~ EXPRESSIONS[:number_literal]
145
+
146
+ # Check for decimal
147
+ if peek == '.' && peek_next =~ EXPRESSIONS[:number_literal]
148
+ # Consume decimal point
149
+ advance_character
150
+ advance_character while peek =~ EXPRESSIONS[:number_literal]
151
+ end
152
+
153
+ add_token :number, @source[@start...@current].to_f
154
+ end
155
+
156
+ def scan_identifier
157
+ advance_character while peek =~ Regexp.union(EXPRESSIONS[:identifier], /\d/)
158
+ text = @source[@start...@current]
159
+ add_token(KEYWORDS[text] || :identifier)
160
+ end
161
+
162
+ def advance_character
163
+ character = @source[@current]
164
+ @current += 1
165
+ character
166
+ end
167
+
168
+ def add_token(type, literal = nil)
169
+ text = @source[@start...@current]
170
+ @tokens << Lox::Token.new(type, text, literal, @line)
171
+ end
172
+
173
+ def match(expected)
174
+ return false unless @source[@current] == expected || end_of_source?
175
+
176
+ @current += 1
177
+ true
178
+ end
179
+
180
+ def end_of_source? = @current >= @source.size
181
+
182
+ # 1-character lookahead
183
+ def peek
184
+ end_of_source? ? "\0" : @source[@current]
185
+ end
186
+
187
+ # 2-character lookahead
188
+ def peek_next
189
+ (@current + 1) > @source.size ? "\0" : @source[@current + 1]
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Loxby
4
+ VERSION = '0.0.2'
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ class ASTPrinter < Visitor
6
+ def print(expression)
7
+ expression.accept self
8
+ end
9
+
10
+ def parenthesize(*args)
11
+ str = "(#{args[0]}"
12
+ args[1..].each do |expr|
13
+ str << " #{expr.accept self}"
14
+ end
15
+ str << ')'
16
+ end
17
+
18
+ def visit_binary_expression(expr)
19
+ parenthesize expr.operator.lexeme, expr.left, expr.right
20
+ end
21
+
22
+ def visit_ternary_expression(expr)
23
+ parenthesize expr.left_operator.lexeme + expr.right_operator.lexeme, expr.left, expr.center, expr.right
24
+ end
25
+
26
+ def visit_grouping_expression(expr)
27
+ parenthesize 'group', expr.expression
28
+ end
29
+
30
+ def visit_literal_expression(expr)
31
+ expr.value.nil? ? 'nil' : expr.value.to_s
32
+ end
33
+
34
+ def visit_unary_expression(expr)
35
+ parenthesize expr.operator.lexeme, expr.right
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Visitable adds #accept, the only
4
+ # method required to implement the
5
+ # visitor pattern on a class.
6
+ # To use the visitor pattern,
7
+ # `include Visitable` to your class
8
+ # and subclass `Visitor` to implement
9
+ # visitors.
10
+ module Visitable
11
+ def accept(visitor)
12
+ raise NotImplementedError, "#{self.class} has not implemented #accept"
13
+ end
14
+ end
15
+
16
+ # Base visitor class for visitor pattern.
17
+ # See Visitable.
18
+ class Visitor
19
+ def self.define_types(base_type, subtypes)
20
+ subtypes.each do |subtype|
21
+ method_name = "visit_#{subtype}_#{base_type}"
22
+ define_method(method_name.to_sym) do |_|
23
+ raise NotImplementedError, "#{self.class} has not implemented ##{method_name}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ class RPNConverter < Visitor
6
+ def print(expr)
7
+ expr.accept self
8
+ end
9
+
10
+ def visit_binary_expression(expr)
11
+ "#{expr.left.accept self} #{expr.right.accept self} #{expr.operator.lexeme}"
12
+ end
13
+
14
+ def visit_grouping_expression(expr)
15
+ expr.accept self
16
+ end
17
+
18
+ def visit_literal_expression(expr)
19
+ expr.value.to_s
20
+ end
21
+
22
+ def visit_unary_expression(expr)
23
+ "#{expr.right.accept self} #{expr.operator.lexeme}"
24
+ end
25
+ end
data/lib/loxby.rb ADDED
@@ -0,0 +1,5 @@
1
+ # Lox interpreter in Ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'loxby/core'
5
+ require_relative 'loxby/version'
data/loxby.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/loxby/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'loxby'
7
+ s.version = Loxby::VERSION
8
+ s.authors = ['Paul Hartman']
9
+ s.email = ['real.paul.hartman@gmail.com']
10
+
11
+ s.summary = 'A Lox interpreter written in Ruby'
12
+ s.description = 'Loxby is written following the first ' \
13
+ "half of Robert Nystrom's wonderful web-format book " \
14
+ '[Crafting Interpreters](https://www.craftinginterpreters.com), ' \
15
+ 'adapting the Java code to modern Ruby. This project is ' \
16
+ 'intended to explore what elegant object-oriented code ' \
17
+ 'can look like and accomplish.'
18
+ s.homepage = 'https://github.com/paul-c-hartman/loxby'
19
+ s.license = 'MIT'
20
+ s.required_ruby_version = Gem::Requirement.new('>= 3.1.0') # Shorthand hash syntax
21
+
22
+ s.metadata['homepage_uri'] = s.homepage
23
+ s.metadata['source_code_uri'] = s.homepage
24
+
25
+ # Specify which files should be added to the gem when it is released.
26
+ # `git ls-files -z` loads the files in the gem which git is tracking.
27
+ s.files = Dir.chdir(File.expand_path(__dir__)) do
28
+ `git ls-files -z`.split("\x0").reject { _1.match %r{^(test|spec|features)/} } - %w[Gemfile Gemfile.lock.rubocop.yml]
29
+ end
30
+ s.bindir = 'bin'
31
+ s.executables = %w[loxby]
32
+ s.require_paths = %w[lib]
33
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: loxby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Paul Hartman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-06-12 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Loxby is written following the first half of Robert Nystrom's wonderful
14
+ web-format book [Crafting Interpreters](https://www.craftinginterpreters.com), adapting
15
+ the Java code to modern Ruby. This project is intended to explore what elegant object-oriented
16
+ code can look like and accomplish.
17
+ email:
18
+ - real.paul.hartman@gmail.com
19
+ executables:
20
+ - loxby
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - ".github/workflows/gem-push.yml"
25
+ - ".rubocop.yml"
26
+ - Gemfile.lock
27
+ - README.md
28
+ - bin/loxby
29
+ - lib/loxby.rb
30
+ - lib/loxby/core.rb
31
+ - lib/loxby/helpers/ast.rb
32
+ - lib/loxby/helpers/environment.rb
33
+ - lib/loxby/helpers/errors.rb
34
+ - lib/loxby/helpers/token_type.rb
35
+ - lib/loxby/interpreter.rb
36
+ - lib/loxby/parser.rb
37
+ - lib/loxby/runner.rb
38
+ - lib/loxby/scanner.rb
39
+ - lib/loxby/version.rb
40
+ - lib/loxby/visitors/ast_printer.rb
41
+ - lib/loxby/visitors/base.rb
42
+ - lib/loxby/visitors/rpn_converter.rb
43
+ - loxby.gemspec
44
+ homepage: https://github.com/paul-c-hartman/loxby
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/paul-c-hartman/loxby
49
+ source_code_uri: https://github.com/paul-c-hartman/loxby
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 3.1.0
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.3.26
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: A Lox interpreter written in Ruby
69
+ test_files: []