• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

type-ruby / t-ruby / 20560733162

28 Dec 2025 10:59PM UTC coverage: 79.076% (+1.7%) from 77.331%
20560733162

Pull #29

github

web-flow
Merge 5e12f0648 into fda099366
Pull Request #29: refactor: migrate parser from regex to token-based parser combinator

1849 of 2098 new or added lines in 53 files covered. (88.13%)

6 existing lines in 2 files now uncovered.

6644 of 8402 relevant lines covered (79.08%)

908.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.79
/lib/t_ruby/scanner.rb
1
# frozen_string_literal: true
2

3
module TRuby
1✔
4
  # Scanner - T-Ruby 소스 코드를 토큰 스트림으로 변환
5
  # TypeScript 컴파일러와 유사한 구조로, 파서와 분리되어 증분 파싱을 지원
6
  class Scanner
1✔
7
    # 토큰 구조체
8
    Token = Struct.new(:type, :value, :start_pos, :end_pos, :line, :column)
1✔
9

10
    # 스캔 에러
11
    class ScanError < StandardError
1✔
12
      attr_reader :line, :column, :position
1✔
13

14
      def initialize(message, line:, column:, position:)
1✔
15
        @line = line
2✔
16
        @column = column
2✔
17
        @position = position
2✔
18
        super("#{message} at line #{line}, column #{column}")
2✔
19
      end
20
    end
21

22
    # 키워드 맵
23
    KEYWORDS = {
1✔
24
      "def" => :def,
25
      "end" => :end,
26
      "class" => :class,
27
      "module" => :module,
28
      "if" => :if,
29
      "unless" => :unless,
30
      "else" => :else,
31
      "elsif" => :elsif,
32
      "return" => :return,
33
      "type" => :type,
34
      "interface" => :interface,
35
      "public" => :public,
36
      "private" => :private,
37
      "protected" => :protected,
38
      "true" => true,
39
      "false" => false,
40
      "nil" => :nil,
41
      "while" => :while,
42
      "until" => :until,
43
      "for" => :for,
44
      "do" => :do,
45
      "begin" => :begin,
46
      "rescue" => :rescue,
47
      "ensure" => :ensure,
48
      "case" => :case,
49
      "when" => :when,
50
      "then" => :then,
51
      "and" => :and,
52
      "or" => :or,
53
      "not" => :not,
54
      "in" => :in,
55
      "self" => :self,
56
      "super" => :super,
57
      "yield" => :yield,
58
      "break" => :break,
59
      "next" => :next,
60
      "redo" => :redo,
61
      "retry" => :retry,
62
      "raise" => :raise,
63
      "alias" => :alias,
64
      "defined?" => :defined,
65
      "__FILE__" => :__file__,
66
      "__LINE__" => :__line__,
67
      "__ENCODING__" => :__encoding__,
68
    }.freeze
69

70
    def initialize(source)
1✔
71
      @source = source
3,769✔
72
      @position = 0
3,769✔
73
      @line = 1
3,769✔
74
      @column = 1
3,769✔
75
      @tokens = []
3,769✔
76
      @token_index = 0
3,769✔
77
      @scanned = false
3,769✔
78
    end
79

80
    # 전체 토큰화 (캐싱용)
81
    def scan_all
1✔
82
      return @tokens if @scanned
3,769✔
83

84
      @tokens = []
3,769✔
85
      @position = 0
3,769✔
86
      @line = 1
3,769✔
87
      @column = 1
3,769✔
88

89
      while @position < @source.length
3,769✔
90
        token = scan_token
11,648✔
91
        @tokens << token if token
11,646✔
92
      end
93

94
      @tokens << Token.new(:eof, "", @position, @position, @line, @column)
3,767✔
95
      @scanned = true
3,767✔
96
      @tokens
3,767✔
97
    end
98

99
    # 단일 토큰 반환 (스트리밍용)
100
    def next_token
1✔
101
      scan_all unless @scanned
4✔
102

103
      token = @tokens[@token_index]
4✔
104
      @token_index += 1 unless token&.type == :eof
4✔
105
      token || @tokens.last
4✔
106
    end
107

108
    # lookahead
109
    def peek(n = 1)
1✔
110
      scan_all unless @scanned
2✔
111

112
      if n == 1
2✔
113
        @tokens[@token_index] || @tokens.last
1✔
114
      else
115
        @tokens[@token_index, n] || [@tokens.last]
1✔
116
      end
117
    end
118

119
    # 토큰 인덱스 리셋
120
    def reset
1✔
NEW
121
      @token_index = 0
×
122
    end
123

124
    private
1✔
125

126
    def scan_token
1✔
127
      skip_whitespace
11,682✔
128

129
      return nil if @position >= @source.length
11,682✔
130

131
      start_pos = @position
11,682✔
132
      start_line = @line
11,682✔
133
      start_column = @column
11,682✔
134
      char = current_char
11,682✔
135

136
      case char
11,682✔
137
      when "\n"
138
        scan_newline
140✔
139
      when "#"
140
        scan_comment
2✔
141
      when '"'
142
        scan_double_quoted_string
48✔
143
      when "'"
144
        scan_single_quoted_string
9✔
145
      when ":"
146
        scan_colon_or_symbol
46✔
147
      when "@"
148
        scan_instance_or_class_variable
31✔
149
      when "$"
150
        scan_global_variable
4✔
151
      when /[a-z_]/i
152
        scan_identifier_or_keyword
7,520✔
153
      when /[0-9]/
154
        scan_number
100✔
155
      when "<"
156
        scan_less_than_or_heredoc
12✔
157
      when ">"
158
        scan_greater_than
9✔
159
      when "="
160
        scan_equals
34✔
161
      when "!"
162
        scan_bang
4✔
163
      when "&"
164
        scan_ampersand
9✔
165
      when "|"
166
        scan_pipe
7✔
167
      when "+"
168
        scan_plus
21✔
169
      when "-"
170
        scan_minus_or_arrow
10✔
171
      when "*"
172
        scan_star
27✔
173
      when "/"
174
        scan_slash
2✔
175
      when "%"
176
        scan_percent
2✔
177
      when "?"
178
        advance
3✔
179
        Token.new(:question, "?", start_pos, @position, start_line, start_column)
3✔
180
      when "("
181
        advance
27✔
182
        Token.new(:lparen, "(", start_pos, @position, start_line, start_column)
27✔
183
      when ")"
184
        advance
27✔
185
        Token.new(:rparen, ")", start_pos, @position, start_line, start_column)
27✔
186
      when "["
187
        advance
13✔
188
        Token.new(:lbracket, "[", start_pos, @position, start_line, start_column)
13✔
189
      when "]"
190
        advance
13✔
191
        Token.new(:rbracket, "]", start_pos, @position, start_line, start_column)
13✔
192
      when "{"
193
        advance
16✔
194
        Token.new(:lbrace, "{", start_pos, @position, start_line, start_column)
16✔
195
      when "}"
196
        advance
16✔
197
        Token.new(:rbrace, "}", start_pos, @position, start_line, start_column)
16✔
198
      when ","
199
        advance
21✔
200
        Token.new(:comma, ",", start_pos, @position, start_line, start_column)
21✔
201
      when "."
202
        advance
3,508✔
203
        Token.new(:dot, ".", start_pos, @position, start_line, start_column)
3,508✔
204
      else
205
        raise ScanError.new(
1✔
206
          "Unexpected character '#{char}'",
207
          line: start_line,
208
          column: start_column,
209
          position: start_pos
210
        )
211
      end
212
    end
213

214
    def scan_newline
1✔
215
      start_pos = @position
140✔
216
      start_line = @line
140✔
217
      start_column = @column
140✔
218

219
      advance
140✔
220
      @line += 1
140✔
221
      @column = 1
140✔
222

223
      Token.new(:newline, "\n", start_pos, @position, start_line, start_column)
140✔
224
    end
225

226
    def scan_comment
1✔
227
      start_pos = @position
2✔
228
      start_line = @line
2✔
229
      start_column = @column
2✔
230

231
      value = ""
2✔
232
      while @position < @source.length && current_char != "\n"
2✔
233
        value += current_char
38✔
234
        advance
38✔
235
      end
236

237
      Token.new(:comment, value, start_pos, @position, start_line, start_column)
2✔
238
    end
239

240
    def scan_double_quoted_string
1✔
241
      start_pos = @position
48✔
242
      start_line = @line
48✔
243
      start_column = @column
48✔
244

245
      # 보간이 있는지 확인을 위해 먼저 스캔
246
      advance # skip opening "
48✔
247

248
      has_interpolation = false
48✔
249
      temp_pos = @position
48✔
250
      while temp_pos < @source.length
48✔
251
        c = @source[temp_pos]
296✔
252
        break if c == '"' && (temp_pos == @position || @source[temp_pos - 1] != "\\")
296✔
253

254
        if c == "#" && temp_pos + 1 < @source.length && @source[temp_pos + 1] == "{"
267✔
255
          has_interpolation = true
18✔
256
          break
18✔
257
        end
258
        temp_pos += 1
249✔
259
      end
260

261
      @position = start_pos + 1 # reset to after opening "
48✔
262

263
      if has_interpolation
48✔
264
        scan_interpolated_string(start_pos, start_line, start_column)
18✔
265
      else
266
        scan_simple_string(start_pos, start_line, start_column, '"')
30✔
267
      end
268
    end
269

270
    def scan_interpolated_string(start_pos, start_line, start_column)
1✔
271
      # string_start 토큰 반환
272
      @tokens << Token.new(:string_start, '"', start_pos, start_pos + 1, start_line, start_column)
18✔
273

274
      content = ""
18✔
275
      content_start = @position
18✔
276
      content_line = @line
18✔
277
      content_column = @column
18✔
278

279
      while @position < @source.length
18✔
280
        char = current_char
142✔
281

282
        if char == '"'
142✔
283
          # 문자열 끝
284
          if content.length.positive?
18✔
285
            @tokens << Token.new(:string_content, content, content_start, @position, content_line, content_column)
10✔
286
          end
287
          advance
18✔
288
          return Token.new(:string_end, '"', @position - 1, @position, @line, @column - 1)
18✔
289
        elsif char == "\\" && peek_char
124✔
290
          # 이스케이프 시퀀스
NEW
291
          content += char
×
NEW
292
          advance
×
NEW
293
          content += current_char if @position < @source.length
×
NEW
294
          advance
×
295
        elsif char == "#" && peek_char == "{"
124✔
296
          # 보간 시작
297
          if content.length.positive?
25✔
298
            @tokens << Token.new(:string_content, content, content_start, @position, content_line, content_column)
18✔
299
            content = ""
18✔
300
          end
301

302
          interp_start = @position
25✔
303
          advance # skip #
25✔
304
          advance # skip {
25✔
305
          @tokens << Token.new(:interpolation_start, '#{', interp_start, @position, @line, @column - 2)
25✔
306

307
          # 보간 내부 토큰 스캔 (중첩된 {} 고려)
308
          scan_interpolation_content
25✔
309

310
          content_start = @position
25✔
311
          content_line = @line
25✔
312
          content_column = @column
25✔
313
        else
314
          content += char
99✔
315
          advance
99✔
316
        end
317
      end
318

NEW
319
      raise ScanError.new(
×
320
        "Unterminated string",
321
        line: start_line,
322
        column: start_column,
323
        position: start_pos
324
      )
325
    end
326

327
    def scan_interpolation_content
1✔
328
      depth = 1
25✔
329

330
      while @position < @source.length && depth.positive?
25✔
331
        skip_whitespace_in_interpolation
59✔
332

333
        break if @position >= @source.length
59✔
334

335
        char = current_char
59✔
336

337
        if char == "}"
59✔
338
          depth -= 1
25✔
339
          if depth.zero?
25✔
340
            interp_end_pos = @position
25✔
341
            advance
25✔
342
            @tokens << Token.new(:interpolation_end, "}", interp_end_pos, @position, @line, @column - 1)
25✔
343
            return
25✔
344
          end
345
        elsif char == "{"
34✔
NEW
346
          depth += 1
×
347
        end
348

349
        # 보간 내부의 토큰 스캔
350
        token = scan_token
34✔
351
        @tokens << token if token
34✔
352
      end
353
    end
354

355
    def skip_whitespace_in_interpolation
1✔
356
      advance while @position < @source.length && current_char =~ /[ \t]/
59✔
357
    end
358

359
    def scan_simple_string(start_pos, start_line, start_column, quote)
1✔
360
      value = quote
39✔
361

362
      while @position < @source.length
39✔
363
        char = current_char
252✔
364

365
        if char == quote
252✔
366
          value += char
38✔
367
          advance
38✔
368
          return Token.new(:string, value, start_pos, @position, start_line, start_column)
38✔
369
        elsif char == "\\" && peek_char
214✔
NEW
370
          value += char
×
NEW
371
          advance
×
NEW
372
          value += current_char
×
NEW
373
          advance
×
374
        elsif char == "\n"
214✔
NEW
375
          raise ScanError.new(
×
376
            "Unterminated string",
377
            line: start_line,
378
            column: start_column,
379
            position: start_pos
380
          )
381
        else
382
          value += char
214✔
383
          advance
214✔
384
        end
385
      end
386

387
      raise ScanError.new(
1✔
388
        "Unterminated string",
389
        line: start_line,
390
        column: start_column,
391
        position: start_pos
392
      )
393
    end
394

395
    def scan_single_quoted_string
1✔
396
      start_pos = @position
9✔
397
      start_line = @line
9✔
398
      start_column = @column
9✔
399

400
      advance # skip opening '
9✔
401
      scan_simple_string(start_pos, start_line, start_column, "'")
9✔
402
    end
403

404
    def scan_colon_or_symbol
1✔
405
      start_pos = @position
46✔
406
      start_line = @line
46✔
407
      start_column = @column
46✔
408

409
      advance # skip :
46✔
410

411
      # 심볼인지 확인
412
      if @position < @source.length && current_char =~ /[a-zA-Z_]/
46✔
413
        value = ":"
11✔
414
        while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
11✔
415
          value += current_char
47✔
416
          advance
47✔
417
        end
418
        Token.new(:symbol, value, start_pos, @position, start_line, start_column)
11✔
419
      else
420
        Token.new(:colon, ":", start_pos, @position, start_line, start_column)
35✔
421
      end
422
    end
423

424
    def scan_instance_or_class_variable
1✔
425
      start_pos = @position
31✔
426
      start_line = @line
31✔
427
      start_column = @column
31✔
428

429
      advance # skip first @
31✔
430

431
      if current_char == "@"
31✔
432
        # 클래스 변수
433
        advance # skip second @
4✔
434
        value = "@@"
4✔
435
        while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
4✔
436
          value += current_char
23✔
437
          advance
23✔
438
        end
439
        Token.new(:cvar, value, start_pos, @position, start_line, start_column)
4✔
440
      else
441
        # 인스턴스 변수
442
        value = "@"
27✔
443
        while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
27✔
444
          value += current_char
114✔
445
          advance
114✔
446
        end
447
        Token.new(:ivar, value, start_pos, @position, start_line, start_column)
27✔
448
      end
449
    end
450

451
    def scan_global_variable
1✔
452
      start_pos = @position
4✔
453
      start_line = @line
4✔
454
      start_column = @column
4✔
455

456
      value = "$"
4✔
457
      advance # skip $
4✔
458

459
      while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
4✔
460
        value += current_char
19✔
461
        advance
19✔
462
      end
463

464
      Token.new(:gvar, value, start_pos, @position, start_line, start_column)
4✔
465
    end
466

467
    def scan_identifier_or_keyword
1✔
468
      start_pos = @position
7,520✔
469
      start_line = @line
7,520✔
470
      start_column = @column
7,520✔
471

472
      value = ""
7,520✔
473
      while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
7,520✔
474
        value += current_char
33,561✔
475
        advance
33,561✔
476
      end
477

478
      # ? 또는 ! 접미사 처리
479
      if @position < @source.length && ["?", "!"].include?(current_char)
7,520✔
480
        value += current_char
6✔
481
        advance
6✔
482
      end
483

484
      # 키워드인지 확인
485
      if KEYWORDS.key?(value)
7,520✔
486
        Token.new(KEYWORDS[value], value, start_pos, @position, start_line, start_column)
169✔
487
      elsif value[0] =~ /[A-Z]/
7,351✔
488
        Token.new(:constant, value, start_pos, @position, start_line, start_column)
61✔
489
      else
490
        Token.new(:identifier, value, start_pos, @position, start_line, start_column)
7,290✔
491
      end
492
    end
493

494
    def scan_number
1✔
495
      start_pos = @position
100✔
496
      start_line = @line
100✔
497
      start_column = @column
100✔
498

499
      value = ""
100✔
500
      while @position < @source.length && current_char =~ /[0-9_]/
100✔
501
        value += current_char
124✔
502
        advance
124✔
503
      end
504

505
      # 소수점 확인
506
      if @position < @source.length && current_char == "." && peek_char =~ /[0-9]/
100✔
507
        value += current_char
5✔
508
        advance
5✔
509
        while @position < @source.length && current_char =~ /[0-9_]/
5✔
510
          value += current_char
10✔
511
          advance
10✔
512
        end
513
        Token.new(:float, value, start_pos, @position, start_line, start_column)
5✔
514
      else
515
        Token.new(:integer, value, start_pos, @position, start_line, start_column)
95✔
516
      end
517
    end
518

519
    def scan_less_than_or_heredoc
1✔
520
      start_pos = @position
12✔
521
      start_line = @line
12✔
522
      start_column = @column
12✔
523

524
      advance # skip <
12✔
525

526
      if current_char == "<"
12✔
527
        # heredoc 또는 <<
528
        advance
1✔
529
        # heredoc: <<EOF, <<-EOF, <<~EOF 형태
530
        if current_char =~ /[~-]/ || current_char =~ /[A-Z_]/i
1✔
531
          scan_heredoc(start_pos, start_line, start_column)
1✔
532
        else
533
          # << 연산자? 아니면 다시 되돌리기
NEW
534
          @position = start_pos + 1
×
NEW
535
          @column = start_column + 1
×
NEW
536
          Token.new(:lt, "<", start_pos, @position, start_line, start_column)
×
537
        end
538
      elsif current_char == "="
11✔
539
        advance
4✔
540
        if current_char == ">"
4✔
541
          advance
2✔
542
          Token.new(:spaceship, "<=>", start_pos, @position, start_line, start_column)
2✔
543
        else
544
          Token.new(:lt_eq, "<=", start_pos, @position, start_line, start_column)
2✔
545
        end
546
      else
547
        Token.new(:lt, "<", start_pos, @position, start_line, start_column)
7✔
548
      end
549
    end
550

551
    def scan_heredoc(start_pos, start_line, start_column)
1✔
552
      # <<~, <<-, << 형식 처리
553
      squiggly = false
1✔
554
      dash = false
1✔
555

556
      if current_char == "~"
1✔
557
        squiggly = true
1✔
558
        advance
1✔
NEW
559
      elsif current_char == "-"
×
NEW
560
        dash = true
×
NEW
561
        advance
×
562
      end
563

564
      # 종료 마커 읽기
565
      delimiter = ""
1✔
566
      while @position < @source.length && current_char =~ /[A-Za-z0-9_]/
1✔
567
        delimiter += current_char
3✔
568
        advance
3✔
569
      end
570

571
      # 현재 줄 끝까지 스킵
572
      advance while @position < @source.length && current_char != "\n"
1✔
573
      advance if @position < @source.length # skip newline
1✔
574
      @line += 1
1✔
575
      @column = 1
1✔
576

577
      # heredoc 내용 수집
578
      content = ""
1✔
579

580
      while @position < @source.length
1✔
581
        line_content = ""
2✔
582

583
        while @position < @source.length && current_char != "\n"
2✔
584
          line_content += current_char
24✔
585
          advance
24✔
586
        end
587

588
        # 종료 마커 확인
589
        stripped = squiggly || dash ? line_content.lstrip : line_content
2✔
590
        if stripped == delimiter || line_content.strip == delimiter
2✔
591
          # heredoc 끝
592
          value = "<<#{if squiggly
1✔
593
                         "~"
1✔
594
                       else
NEW
595
                         (dash ? "-" : "")
×
596
                       end}#{delimiter}\n#{content}#{delimiter}"
597
          return Token.new(:heredoc, value, start_pos, @position, start_line, start_column)
1✔
598
        end
599

600
        content += line_content
1✔
601
        next unless @position < @source.length
1✔
602

603
        content += "\n"
1✔
604
        advance # skip newline
1✔
605
        @line += 1
1✔
606
        @column = 1
1✔
607
      end
608

609
      # 종료 마커를 찾지 못함
NEW
610
      raise ScanError.new(
×
611
        "Unterminated heredoc",
612
        line: start_line,
613
        column: start_column,
614
        position: start_pos
615
      )
616
    end
617

618
    def scan_greater_than
1✔
619
      start_pos = @position
9✔
620
      start_line = @line
9✔
621
      start_column = @column
9✔
622

623
      advance # skip >
9✔
624

625
      if current_char == "="
9✔
626
        advance
2✔
627
        Token.new(:gt_eq, ">=", start_pos, @position, start_line, start_column)
2✔
628
      else
629
        Token.new(:gt, ">", start_pos, @position, start_line, start_column)
7✔
630
      end
631
    end
632

633
    def scan_equals
1✔
634
      start_pos = @position
34✔
635
      start_line = @line
34✔
636
      start_column = @column
34✔
637

638
      advance # skip =
34✔
639

640
      case current_char
34✔
641
      when "="
642
        advance
6✔
643
        Token.new(:eq_eq, "==", start_pos, @position, start_line, start_column)
6✔
644
      when ">"
645
        advance
1✔
646
        Token.new(:hash_rocket, "=>", start_pos, @position, start_line, start_column)
1✔
647
      else
648
        Token.new(:eq, "=", start_pos, @position, start_line, start_column)
27✔
649
      end
650
    end
651

652
    def scan_bang
1✔
653
      start_pos = @position
4✔
654
      start_line = @line
4✔
655
      start_column = @column
4✔
656

657
      advance # skip !
4✔
658

659
      if current_char == "="
4✔
660
        advance
2✔
661
        Token.new(:bang_eq, "!=", start_pos, @position, start_line, start_column)
2✔
662
      else
663
        Token.new(:bang, "!", start_pos, @position, start_line, start_column)
2✔
664
      end
665
    end
666

667
    def scan_ampersand
1✔
668
      start_pos = @position
9✔
669
      start_line = @line
9✔
670
      start_column = @column
9✔
671

672
      advance # skip &
9✔
673

674
      if current_char == "&"
9✔
675
        advance
4✔
676
        Token.new(:and_and, "&&", start_pos, @position, start_line, start_column)
4✔
677
      else
678
        Token.new(:amp, "&", start_pos, @position, start_line, start_column)
5✔
679
      end
680
    end
681

682
    def scan_pipe
1✔
683
      start_pos = @position
7✔
684
      start_line = @line
7✔
685
      start_column = @column
7✔
686

687
      advance # skip |
7✔
688

689
      if current_char == "|"
7✔
690
        advance
2✔
691
        Token.new(:or_or, "||", start_pos, @position, start_line, start_column)
2✔
692
      else
693
        Token.new(:pipe, "|", start_pos, @position, start_line, start_column)
5✔
694
      end
695
    end
696

697
    def scan_plus
1✔
698
      start_pos = @position
21✔
699
      start_line = @line
21✔
700
      start_column = @column
21✔
701

702
      advance # skip +
21✔
703

704
      if current_char == "="
21✔
705
        advance
2✔
706
        Token.new(:plus_eq, "+=", start_pos, @position, start_line, start_column)
2✔
707
      else
708
        Token.new(:plus, "+", start_pos, @position, start_line, start_column)
19✔
709
      end
710
    end
711

712
    def scan_minus_or_arrow
1✔
713
      start_pos = @position
10✔
714
      start_line = @line
10✔
715
      start_column = @column
10✔
716

717
      advance # skip -
10✔
718

719
      case current_char
10✔
720
      when ">"
721
        advance
5✔
722
        Token.new(:arrow, "->", start_pos, @position, start_line, start_column)
5✔
723
      when "="
NEW
724
        advance
×
NEW
725
        Token.new(:minus_eq, "-=", start_pos, @position, start_line, start_column)
×
726
      else
727
        Token.new(:minus, "-", start_pos, @position, start_line, start_column)
5✔
728
      end
729
    end
730

731
    def scan_star
1✔
732
      start_pos = @position
27✔
733
      start_line = @line
27✔
734
      start_column = @column
27✔
735

736
      advance # skip *
27✔
737

738
      case current_char
27✔
739
      when "*"
740
        advance
4✔
741
        Token.new(:star_star, "**", start_pos, @position, start_line, start_column)
4✔
742
      when "="
NEW
743
        advance
×
NEW
744
        Token.new(:star_eq, "*=", start_pos, @position, start_line, start_column)
×
745
      else
746
        Token.new(:star, "*", start_pos, @position, start_line, start_column)
23✔
747
      end
748
    end
749

750
    def scan_slash
1✔
751
      start_pos = @position
2✔
752
      start_line = @line
2✔
753
      start_column = @column
2✔
754

755
      advance # skip /
2✔
756

757
      if current_char == "="
2✔
NEW
758
        advance
×
NEW
759
        Token.new(:slash_eq, "/=", start_pos, @position, start_line, start_column)
×
760
      elsif regex_context?
2✔
761
        # 정규표현식 리터럴 스캔
NEW
762
        scan_regex(start_pos, start_line, start_column)
×
763
      else
764
        Token.new(:slash, "/", start_pos, @position, start_line, start_column)
2✔
765
      end
766
    end
767

768
    def regex_context?
1✔
769
      # Check if / followed by whitespace - always division
770
      next_char = @source[@position]
2✔
771
      return false if [" ", "\t", "\n"].include?(next_char)
2✔
772

773
      # Check previous token context
NEW
774
      return true if @tokens.empty?
×
775

NEW
776
      last_token = @tokens.last
×
NEW
777
      return true if last_token.nil?
×
778

779
      # After values/expressions - division operator
NEW
780
      case last_token.type
×
781
      when :identifier, :constant, :integer, :float, :string, :symbol,
782
           :rparen, :rbracket, :rbrace, :ivar, :cvar, :gvar, :regex
NEW
783
        false
×
784
      # After binary operators - could be regex in `a * /pattern/` but safer to treat as division
785
      # unless there's no space after /
786
      when :plus, :minus, :star, :slash, :percent, :star_star,
787
           :lt, :gt, :lt_eq, :gt_eq, :eq_eq, :bang_eq, :spaceship,
788
           :and_and, :or_or, :amp, :pipe, :caret
789
        # Already checked no whitespace after /, so this could be regex
NEW
790
        true
×
791
      # After keywords that expect expression - regex context
792
      when :kw_if, :kw_unless, :kw_when, :kw_case, :kw_while, :kw_until,
793
           :kw_and, :kw_or, :kw_not, :kw_return, :kw_yield
NEW
794
        true
×
795
      # After opening brackets/parens, comma, equals - regex context
796
      when :lparen, :lbracket, :lbrace, :comma, :eq, :colon, :semicolon,
797
           :plus_eq, :minus_eq, :star_eq, :slash_eq, :percent_eq,
798
           :and_eq, :or_eq, :caret_eq, :arrow
NEW
799
        true
×
800
      else
NEW
801
        false
×
802
      end
803
    end
804

805
    def scan_regex(start_pos, start_line, start_column)
1✔
NEW
806
      value = "/"
×
807

NEW
808
      while @position < @source.length
×
NEW
809
        char = current_char
×
810

NEW
811
        case char
×
812
        when "/"
NEW
813
          value += char
×
NEW
814
          advance
×
815
          # 플래그 스캔 (i, m, x, o 등)
NEW
816
          while @position < @source.length && current_char =~ /[imxo]/
×
NEW
817
            value += current_char
×
NEW
818
            advance
×
819
          end
NEW
820
          return Token.new(:regex, value, start_pos, @position, start_line, start_column)
×
821
        when "\\"
822
          # 이스케이프 시퀀스
NEW
823
          value += char
×
NEW
824
          advance
×
NEW
825
          if @position < @source.length
×
NEW
826
            value += current_char
×
NEW
827
            advance
×
828
          end
829
        when "\n"
NEW
830
          raise ScanError.new(
×
831
            "Unterminated regex",
832
            line: start_line,
833
            column: start_column,
834
            position: start_pos
835
          )
836
        else
NEW
837
          value += char
×
NEW
838
          advance
×
839
        end
840
      end
841

NEW
842
      raise ScanError.new(
×
843
        "Unterminated regex",
844
        line: start_line,
845
        column: start_column,
846
        position: start_pos
847
      )
848
    end
849

850
    def scan_percent
1✔
851
      start_pos = @position
2✔
852
      start_line = @line
2✔
853
      start_column = @column
2✔
854

855
      advance # skip %
2✔
856

857
      if current_char == "="
2✔
NEW
858
        advance
×
NEW
859
        Token.new(:percent_eq, "%=", start_pos, @position, start_line, start_column)
×
860
      else
861
        Token.new(:percent, "%", start_pos, @position, start_line, start_column)
2✔
862
      end
863
    end
864

865
    def skip_whitespace
1✔
866
      advance while @position < @source.length && current_char =~ /[ \t\r]/
11,682✔
867
    end
868

869
    def current_char
1✔
870
      @source[@position]
107,977✔
871
    end
872

873
    def peek_char
1✔
874
      @source[@position + 1]
30✔
875
    end
876

877
    def advance
1✔
878
      @column += 1
46,460✔
879
      @position += 1
46,460✔
880
    end
881
  end
882
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc