1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
require "strscan"

require_relative "error"
require_relative "token"

module Lox
  class Scanner
    TOKENS = %w[
      (  LEFT_PAREN
      )  RIGHT_PAREN
      {  LEFT_BRACE
      }  RIGHT_BRACE
      ,  COMMA
      .  DOT
      -  MINUS
      +  PLUS
      ;  SEMICOLON
      *  STAR
      != BANG_EQUAL
      !  BANG
      == EQUAL_EQUAL
      =  EQUAL
      <= LESS_EQUAL
      <  LESS
      >= GREATER_EQUAL
      >  GREATER
      /  SLASH
    ].each_slice(2).to_h.transform_values(&:to_sym)
    TOKENS_RE = Regexp.union(TOKENS.keys)

    KEYWORDS = %w[
      and    AND
      class  CLASS
      else   ELSE
      false  FALSE
      for    FOR
      fun    FUN
      if     IF
      nil    NIL
      or     OR
      print  PRINT
      return RETURN
      super  SUPER
      this   THIS
      true   TRUE
      var    VAR
      while  WHILE
    ].each_slice(2).to_h.transform_values(&:to_sym)

    class State < Struct.new(:ss, :tokens, :errors, :line)
      def eos? = ss.eos?
      def scan(re) = ss.scan(re)
      def pos = ss.pos

      def initialize(src)
        super(StringScanner.new(src), [], [], 1)
      end

      def add_token(type, text: nil, literal: nil)
        text ||= ss.matched
        self.tokens << Lox::Token.new(type, text, literal, line)
      end
    end

    def scan(src)
      state = State.new(src)

      until state.eos?
        case
        when state.scan(/\/\/(?~\n)/)
          # ignore line comment
        when state.scan(/\/\*/)
          scan_block_comment(state)
        when matched = state.scan(TOKENS_RE)
          state.add_token(TOKENS.fetch(matched))
        when state.scan(/[ \r\t]/)
          # ignore whitespace
        when state.scan(/\n/)
          state.line += 1
        when state.scan(/"/)
          scan_str(state)
        when number = state.scan(/\d+(\.\d+)?/)
          state.add_token(:NUMBER, literal: number.to_f)
        when identifier = state.scan(/[a-zA-Z_]\w*/)
          type = KEYWORDS.fetch(identifier, :IDENTIFIER)
          state.add_token(type)
        else
          state.errors << Error.new(line: state.line, message: "Unexpected character.")
          state.scan(/./) # keep scanning
        end
      end

      # TODO
      fail unless state.errors.empty?

      state.add_token(:EOF, text: "")
      state.tokens
    end

    private

    def scan_str(state)
      text = ?"
      loop do
        case
        when state.scan(/"/)
          text << ?"
          state.add_token(:STRING, text:, literal: text[1..-2])
          return
        when state.scan(/\n/)
          text << ?\n
          state.line += 1
        when state.eos?
          state.errors << Error.new(line: state.line, message: "Unterminated string.")
          return
        when c = state.scan(/(?~"|\n)/)
          text << c
        else
          fail "unreachable!"
        end
      end
    end

    def scan_block_comment(state)
      loop do
        case
        when state.scan(/\/\*/)
          scan_block_comment(state)
        when state.scan(/\*\//)
          return
        when state.scan(/\n/)
          state.line += 1
        when state.eos?
          state.errors << Error.new(line: state.line, message: "Unterminated block comment.")
          return
        when state.scan(/./)
          # no-op
        else
          fail "unreachable!"
        end
      end
    end
  end
end