t-ruby 0.0.42 → 0.0.43

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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/t_ruby/ast_type_inferrer.rb +2 -0
  3. data/lib/t_ruby/cache.rb +40 -10
  4. data/lib/t_ruby/cli.rb +13 -8
  5. data/lib/t_ruby/compiler.rb +168 -0
  6. data/lib/t_ruby/diagnostic.rb +115 -0
  7. data/lib/t_ruby/diagnostic_formatter.rb +162 -0
  8. data/lib/t_ruby/error_handler.rb +201 -35
  9. data/lib/t_ruby/error_reporter.rb +57 -0
  10. data/lib/t_ruby/ir.rb +39 -1
  11. data/lib/t_ruby/lsp_server.rb +40 -97
  12. data/lib/t_ruby/parser.rb +18 -4
  13. data/lib/t_ruby/parser_combinator/combinators/alternative.rb +20 -0
  14. data/lib/t_ruby/parser_combinator/combinators/chain_left.rb +34 -0
  15. data/lib/t_ruby/parser_combinator/combinators/choice.rb +29 -0
  16. data/lib/t_ruby/parser_combinator/combinators/flat_map.rb +21 -0
  17. data/lib/t_ruby/parser_combinator/combinators/label.rb +22 -0
  18. data/lib/t_ruby/parser_combinator/combinators/lookahead.rb +21 -0
  19. data/lib/t_ruby/parser_combinator/combinators/many.rb +29 -0
  20. data/lib/t_ruby/parser_combinator/combinators/many1.rb +32 -0
  21. data/lib/t_ruby/parser_combinator/combinators/map.rb +17 -0
  22. data/lib/t_ruby/parser_combinator/combinators/not_followed_by.rb +21 -0
  23. data/lib/t_ruby/parser_combinator/combinators/optional.rb +21 -0
  24. data/lib/t_ruby/parser_combinator/combinators/sep_by.rb +34 -0
  25. data/lib/t_ruby/parser_combinator/combinators/sep_by1.rb +34 -0
  26. data/lib/t_ruby/parser_combinator/combinators/sequence.rb +23 -0
  27. data/lib/t_ruby/parser_combinator/combinators/skip_right.rb +23 -0
  28. data/lib/t_ruby/parser_combinator/declaration_parser.rb +147 -0
  29. data/lib/t_ruby/parser_combinator/dsl.rb +115 -0
  30. data/lib/t_ruby/parser_combinator/parse_error.rb +48 -0
  31. data/lib/t_ruby/parser_combinator/parse_result.rb +46 -0
  32. data/lib/t_ruby/parser_combinator/parser.rb +84 -0
  33. data/lib/t_ruby/parser_combinator/primitives/end_of_input.rb +16 -0
  34. data/lib/t_ruby/parser_combinator/primitives/fail.rb +16 -0
  35. data/lib/t_ruby/parser_combinator/primitives/lazy.rb +18 -0
  36. data/lib/t_ruby/parser_combinator/primitives/literal.rb +21 -0
  37. data/lib/t_ruby/parser_combinator/primitives/pure.rb +16 -0
  38. data/lib/t_ruby/parser_combinator/primitives/regex.rb +25 -0
  39. data/lib/t_ruby/parser_combinator/primitives/satisfy.rb +21 -0
  40. data/lib/t_ruby/parser_combinator/token/expression_parser.rb +541 -0
  41. data/lib/t_ruby/parser_combinator/token/statement_parser.rb +644 -0
  42. data/lib/t_ruby/parser_combinator/token/token_alternative.rb +20 -0
  43. data/lib/t_ruby/parser_combinator/token/token_body_parser.rb +54 -0
  44. data/lib/t_ruby/parser_combinator/token/token_declaration_parser.rb +920 -0
  45. data/lib/t_ruby/parser_combinator/token/token_dsl.rb +16 -0
  46. data/lib/t_ruby/parser_combinator/token/token_label.rb +22 -0
  47. data/lib/t_ruby/parser_combinator/token/token_many.rb +29 -0
  48. data/lib/t_ruby/parser_combinator/token/token_many1.rb +32 -0
  49. data/lib/t_ruby/parser_combinator/token/token_map.rb +17 -0
  50. data/lib/t_ruby/parser_combinator/token/token_matcher.rb +29 -0
  51. data/lib/t_ruby/parser_combinator/token/token_optional.rb +21 -0
  52. data/lib/t_ruby/parser_combinator/token/token_parse_result.rb +40 -0
  53. data/lib/t_ruby/parser_combinator/token/token_parser.rb +62 -0
  54. data/lib/t_ruby/parser_combinator/token/token_sep_by.rb +34 -0
  55. data/lib/t_ruby/parser_combinator/token/token_sep_by1.rb +34 -0
  56. data/lib/t_ruby/parser_combinator/token/token_sequence.rb +23 -0
  57. data/lib/t_ruby/parser_combinator/token/token_skip_right.rb +23 -0
  58. data/lib/t_ruby/parser_combinator/type_parser.rb +103 -0
  59. data/lib/t_ruby/parser_combinator.rb +64 -936
  60. data/lib/t_ruby/scanner.rb +883 -0
  61. data/lib/t_ruby/version.rb +1 -1
  62. data/lib/t_ruby/watcher.rb +67 -75
  63. data/lib/t_ruby.rb +15 -1
  64. metadata +51 -2
  65. data/lib/t_ruby/body_parser.rb +0 -561
@@ -0,0 +1,883 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRuby
4
+ # Scanner - T-Ruby 소스 코드를 토큰 스트림으로 변환
5
+ # TypeScript 컴파일러와 유사한 구조로, 파서와 분리되어 증분 파싱을 지원
6
+ class Scanner
7
+ # 토큰 구조체
8
+ Token = Struct.new(:type, :value, :start_pos, :end_pos, :line, :column)
9
+
10
+ # 스캔 에러
11
+ class ScanError < StandardError
12
+ attr_reader :line, :column, :position
13
+
14
+ def initialize(message, line:, column:, position:)
15
+ @line = line
16
+ @column = column
17
+ @position = position
18
+ super("#{message} at line #{line}, column #{column}")
19
+ end
20
+ end
21
+
22
+ # 키워드 맵
23
+ KEYWORDS = {
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)
71
+ @source = source
72
+ @position = 0
73
+ @line = 1
74
+ @column = 1
75
+ @tokens = []
76
+ @token_index = 0
77
+ @scanned = false
78
+ end
79
+
80
+ # 전체 토큰화 (캐싱용)
81
+ def scan_all
82
+ return @tokens if @scanned
83
+
84
+ @tokens = []
85
+ @position = 0
86
+ @line = 1
87
+ @column = 1
88
+
89
+ while @position < @source.length
90
+ token = scan_token
91
+ @tokens << token if token
92
+ end
93
+
94
+ @tokens << Token.new(:eof, "", @position, @position, @line, @column)
95
+ @scanned = true
96
+ @tokens
97
+ end
98
+
99
+ # 단일 토큰 반환 (스트리밍용)
100
+ def next_token
101
+ scan_all unless @scanned
102
+
103
+ token = @tokens[@token_index]
104
+ @token_index += 1 unless token&.type == :eof
105
+ token || @tokens.last
106
+ end
107
+
108
+ # lookahead
109
+ def peek(n = 1)
110
+ scan_all unless @scanned
111
+
112
+ if n == 1
113
+ @tokens[@token_index] || @tokens.last
114
+ else
115
+ @tokens[@token_index, n] || [@tokens.last]
116
+ end
117
+ end
118
+
119
+ # 토큰 인덱스 리셋
120
+ def reset
121
+ @token_index = 0
122
+ end
123
+
124
+ private
125
+
126
+ def scan_token
127
+ skip_whitespace
128
+
129
+ return nil if @position >= @source.length
130
+
131
+ start_pos = @position
132
+ start_line = @line
133
+ start_column = @column
134
+ char = current_char
135
+
136
+ case char
137
+ when "\n"
138
+ scan_newline
139
+ when "#"
140
+ scan_comment
141
+ when '"'
142
+ scan_double_quoted_string
143
+ when "'"
144
+ scan_single_quoted_string
145
+ when ":"
146
+ scan_colon_or_symbol
147
+ when "@"
148
+ scan_instance_or_class_variable
149
+ when "$"
150
+ scan_global_variable
151
+ when /[a-z_\p{L}]/i
152
+ scan_identifier_or_keyword
153
+ when /[0-9]/
154
+ scan_number
155
+ when "<"
156
+ scan_less_than_or_heredoc
157
+ when ">"
158
+ scan_greater_than
159
+ when "="
160
+ scan_equals
161
+ when "!"
162
+ scan_bang
163
+ when "&"
164
+ scan_ampersand
165
+ when "|"
166
+ scan_pipe
167
+ when "+"
168
+ scan_plus
169
+ when "-"
170
+ scan_minus_or_arrow
171
+ when "*"
172
+ scan_star
173
+ when "/"
174
+ scan_slash
175
+ when "%"
176
+ scan_percent
177
+ when "?"
178
+ advance
179
+ Token.new(:question, "?", start_pos, @position, start_line, start_column)
180
+ when "("
181
+ advance
182
+ Token.new(:lparen, "(", start_pos, @position, start_line, start_column)
183
+ when ")"
184
+ advance
185
+ Token.new(:rparen, ")", start_pos, @position, start_line, start_column)
186
+ when "["
187
+ advance
188
+ Token.new(:lbracket, "[", start_pos, @position, start_line, start_column)
189
+ when "]"
190
+ advance
191
+ Token.new(:rbracket, "]", start_pos, @position, start_line, start_column)
192
+ when "{"
193
+ advance
194
+ Token.new(:lbrace, "{", start_pos, @position, start_line, start_column)
195
+ when "}"
196
+ advance
197
+ Token.new(:rbrace, "}", start_pos, @position, start_line, start_column)
198
+ when ","
199
+ advance
200
+ Token.new(:comma, ",", start_pos, @position, start_line, start_column)
201
+ when "."
202
+ advance
203
+ Token.new(:dot, ".", start_pos, @position, start_line, start_column)
204
+ else
205
+ raise ScanError.new(
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
215
+ start_pos = @position
216
+ start_line = @line
217
+ start_column = @column
218
+
219
+ advance
220
+ @line += 1
221
+ @column = 1
222
+
223
+ Token.new(:newline, "\n", start_pos, @position, start_line, start_column)
224
+ end
225
+
226
+ def scan_comment
227
+ start_pos = @position
228
+ start_line = @line
229
+ start_column = @column
230
+
231
+ value = ""
232
+ while @position < @source.length && current_char != "\n"
233
+ value += current_char
234
+ advance
235
+ end
236
+
237
+ Token.new(:comment, value, start_pos, @position, start_line, start_column)
238
+ end
239
+
240
+ def scan_double_quoted_string
241
+ start_pos = @position
242
+ start_line = @line
243
+ start_column = @column
244
+
245
+ # 보간이 있는지 확인을 위해 먼저 스캔
246
+ advance # skip opening "
247
+
248
+ has_interpolation = false
249
+ temp_pos = @position
250
+ while temp_pos < @source.length
251
+ c = @source[temp_pos]
252
+ break if c == '"' && (temp_pos == @position || @source[temp_pos - 1] != "\\")
253
+
254
+ if c == "#" && temp_pos + 1 < @source.length && @source[temp_pos + 1] == "{"
255
+ has_interpolation = true
256
+ break
257
+ end
258
+ temp_pos += 1
259
+ end
260
+
261
+ @position = start_pos + 1 # reset to after opening "
262
+
263
+ if has_interpolation
264
+ scan_interpolated_string(start_pos, start_line, start_column)
265
+ else
266
+ scan_simple_string(start_pos, start_line, start_column, '"')
267
+ end
268
+ end
269
+
270
+ def scan_interpolated_string(start_pos, start_line, start_column)
271
+ # string_start 토큰 반환
272
+ @tokens << Token.new(:string_start, '"', start_pos, start_pos + 1, start_line, start_column)
273
+
274
+ content = ""
275
+ content_start = @position
276
+ content_line = @line
277
+ content_column = @column
278
+
279
+ while @position < @source.length
280
+ char = current_char
281
+
282
+ if char == '"'
283
+ # 문자열 끝
284
+ if content.length.positive?
285
+ @tokens << Token.new(:string_content, content, content_start, @position, content_line, content_column)
286
+ end
287
+ advance
288
+ return Token.new(:string_end, '"', @position - 1, @position, @line, @column - 1)
289
+ elsif char == "\\" && peek_char
290
+ # 이스케이프 시퀀스
291
+ content += char
292
+ advance
293
+ content += current_char if @position < @source.length
294
+ advance
295
+ elsif char == "#" && peek_char == "{"
296
+ # 보간 시작
297
+ if content.length.positive?
298
+ @tokens << Token.new(:string_content, content, content_start, @position, content_line, content_column)
299
+ content = ""
300
+ end
301
+
302
+ interp_start = @position
303
+ advance # skip #
304
+ advance # skip {
305
+ @tokens << Token.new(:interpolation_start, '#{', interp_start, @position, @line, @column - 2)
306
+
307
+ # 보간 내부 토큰 스캔 (중첩된 {} 고려)
308
+ scan_interpolation_content
309
+
310
+ content_start = @position
311
+ content_line = @line
312
+ content_column = @column
313
+ else
314
+ content += char
315
+ advance
316
+ end
317
+ end
318
+
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
328
+ depth = 1
329
+
330
+ while @position < @source.length && depth.positive?
331
+ skip_whitespace_in_interpolation
332
+
333
+ break if @position >= @source.length
334
+
335
+ char = current_char
336
+
337
+ if char == "}"
338
+ depth -= 1
339
+ if depth.zero?
340
+ interp_end_pos = @position
341
+ advance
342
+ @tokens << Token.new(:interpolation_end, "}", interp_end_pos, @position, @line, @column - 1)
343
+ return
344
+ end
345
+ elsif char == "{"
346
+ depth += 1
347
+ end
348
+
349
+ # 보간 내부의 토큰 스캔
350
+ token = scan_token
351
+ @tokens << token if token
352
+ end
353
+ end
354
+
355
+ def skip_whitespace_in_interpolation
356
+ advance while @position < @source.length && current_char =~ /[ \t]/
357
+ end
358
+
359
+ def scan_simple_string(start_pos, start_line, start_column, quote)
360
+ value = quote
361
+
362
+ while @position < @source.length
363
+ char = current_char
364
+
365
+ if char == quote
366
+ value += char
367
+ advance
368
+ return Token.new(:string, value, start_pos, @position, start_line, start_column)
369
+ elsif char == "\\" && peek_char
370
+ value += char
371
+ advance
372
+ value += current_char
373
+ advance
374
+ elsif char == "\n"
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
383
+ advance
384
+ end
385
+ end
386
+
387
+ raise ScanError.new(
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
396
+ start_pos = @position
397
+ start_line = @line
398
+ start_column = @column
399
+
400
+ advance # skip opening '
401
+ scan_simple_string(start_pos, start_line, start_column, "'")
402
+ end
403
+
404
+ def scan_colon_or_symbol
405
+ start_pos = @position
406
+ start_line = @line
407
+ start_column = @column
408
+
409
+ advance # skip :
410
+
411
+ # 심볼인지 확인
412
+ if @position < @source.length && current_char =~ /[a-zA-Z_]/
413
+ value = ":"
414
+ while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
415
+ value += current_char
416
+ advance
417
+ end
418
+ Token.new(:symbol, value, start_pos, @position, start_line, start_column)
419
+ else
420
+ Token.new(:colon, ":", start_pos, @position, start_line, start_column)
421
+ end
422
+ end
423
+
424
+ def scan_instance_or_class_variable
425
+ start_pos = @position
426
+ start_line = @line
427
+ start_column = @column
428
+
429
+ advance # skip first @
430
+
431
+ if current_char == "@"
432
+ # 클래스 변수
433
+ advance # skip second @
434
+ value = "@@"
435
+ while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
436
+ value += current_char
437
+ advance
438
+ end
439
+ Token.new(:cvar, value, start_pos, @position, start_line, start_column)
440
+ else
441
+ # 인스턴스 변수
442
+ value = "@"
443
+ while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
444
+ value += current_char
445
+ advance
446
+ end
447
+ Token.new(:ivar, value, start_pos, @position, start_line, start_column)
448
+ end
449
+ end
450
+
451
+ def scan_global_variable
452
+ start_pos = @position
453
+ start_line = @line
454
+ start_column = @column
455
+
456
+ value = "$"
457
+ advance # skip $
458
+
459
+ while @position < @source.length && current_char =~ /[a-zA-Z0-9_]/
460
+ value += current_char
461
+ advance
462
+ end
463
+
464
+ Token.new(:gvar, value, start_pos, @position, start_line, start_column)
465
+ end
466
+
467
+ def scan_identifier_or_keyword
468
+ start_pos = @position
469
+ start_line = @line
470
+ start_column = @column
471
+
472
+ value = ""
473
+ # Support Unicode letters (\p{L}) and numbers (\p{N}) in identifiers
474
+ while @position < @source.length && current_char =~ /[\p{L}\p{N}_]/
475
+ value += current_char
476
+ advance
477
+ end
478
+
479
+ # ? 또는 ! 접미사 처리
480
+ if @position < @source.length && ["?", "!"].include?(current_char)
481
+ value += current_char
482
+ advance
483
+ end
484
+
485
+ # 키워드인지 확인
486
+ if KEYWORDS.key?(value)
487
+ Token.new(KEYWORDS[value], value, start_pos, @position, start_line, start_column)
488
+ elsif value[0] =~ /\p{Lu}/ # Unicode uppercase letter
489
+ Token.new(:constant, value, start_pos, @position, start_line, start_column)
490
+ else
491
+ Token.new(:identifier, value, start_pos, @position, start_line, start_column)
492
+ end
493
+ end
494
+
495
+ def scan_number
496
+ start_pos = @position
497
+ start_line = @line
498
+ start_column = @column
499
+
500
+ value = ""
501
+ while @position < @source.length && current_char =~ /[0-9_]/
502
+ value += current_char
503
+ advance
504
+ end
505
+
506
+ # 소수점 확인
507
+ if @position < @source.length && current_char == "." && peek_char =~ /[0-9]/
508
+ value += current_char
509
+ advance
510
+ while @position < @source.length && current_char =~ /[0-9_]/
511
+ value += current_char
512
+ advance
513
+ end
514
+ Token.new(:float, value, start_pos, @position, start_line, start_column)
515
+ else
516
+ Token.new(:integer, value, start_pos, @position, start_line, start_column)
517
+ end
518
+ end
519
+
520
+ def scan_less_than_or_heredoc
521
+ start_pos = @position
522
+ start_line = @line
523
+ start_column = @column
524
+
525
+ advance # skip <
526
+
527
+ if current_char == "<"
528
+ # heredoc 또는 <<
529
+ advance
530
+ # heredoc: <<EOF, <<-EOF, <<~EOF 형태
531
+ if current_char =~ /[~-]/ || current_char =~ /[A-Z_]/i
532
+ scan_heredoc(start_pos, start_line, start_column)
533
+ else
534
+ # << 연산자? 아니면 다시 되돌리기
535
+ @position = start_pos + 1
536
+ @column = start_column + 1
537
+ Token.new(:lt, "<", start_pos, @position, start_line, start_column)
538
+ end
539
+ elsif current_char == "="
540
+ advance
541
+ if current_char == ">"
542
+ advance
543
+ Token.new(:spaceship, "<=>", start_pos, @position, start_line, start_column)
544
+ else
545
+ Token.new(:lt_eq, "<=", start_pos, @position, start_line, start_column)
546
+ end
547
+ else
548
+ Token.new(:lt, "<", start_pos, @position, start_line, start_column)
549
+ end
550
+ end
551
+
552
+ def scan_heredoc(start_pos, start_line, start_column)
553
+ # <<~, <<-, << 형식 처리
554
+ squiggly = false
555
+ dash = false
556
+
557
+ if current_char == "~"
558
+ squiggly = true
559
+ advance
560
+ elsif current_char == "-"
561
+ dash = true
562
+ advance
563
+ end
564
+
565
+ # 종료 마커 읽기
566
+ delimiter = ""
567
+ while @position < @source.length && current_char =~ /[A-Za-z0-9_]/
568
+ delimiter += current_char
569
+ advance
570
+ end
571
+
572
+ # 현재 줄 끝까지 스킵
573
+ advance while @position < @source.length && current_char != "\n"
574
+ advance if @position < @source.length # skip newline
575
+ @line += 1
576
+ @column = 1
577
+
578
+ # heredoc 내용 수집
579
+ content = ""
580
+
581
+ while @position < @source.length
582
+ line_content = ""
583
+
584
+ while @position < @source.length && current_char != "\n"
585
+ line_content += current_char
586
+ advance
587
+ end
588
+
589
+ # 종료 마커 확인
590
+ stripped = squiggly || dash ? line_content.lstrip : line_content
591
+ if stripped == delimiter || line_content.strip == delimiter
592
+ # heredoc 끝
593
+ value = "<<#{if squiggly
594
+ "~"
595
+ else
596
+ (dash ? "-" : "")
597
+ end}#{delimiter}\n#{content}#{delimiter}"
598
+ return Token.new(:heredoc, value, start_pos, @position, start_line, start_column)
599
+ end
600
+
601
+ content += line_content
602
+ next unless @position < @source.length
603
+
604
+ content += "\n"
605
+ advance # skip newline
606
+ @line += 1
607
+ @column = 1
608
+ end
609
+
610
+ # 종료 마커를 찾지 못함
611
+ raise ScanError.new(
612
+ "Unterminated heredoc",
613
+ line: start_line,
614
+ column: start_column,
615
+ position: start_pos
616
+ )
617
+ end
618
+
619
+ def scan_greater_than
620
+ start_pos = @position
621
+ start_line = @line
622
+ start_column = @column
623
+
624
+ advance # skip >
625
+
626
+ if current_char == "="
627
+ advance
628
+ Token.new(:gt_eq, ">=", start_pos, @position, start_line, start_column)
629
+ else
630
+ Token.new(:gt, ">", start_pos, @position, start_line, start_column)
631
+ end
632
+ end
633
+
634
+ def scan_equals
635
+ start_pos = @position
636
+ start_line = @line
637
+ start_column = @column
638
+
639
+ advance # skip =
640
+
641
+ case current_char
642
+ when "="
643
+ advance
644
+ Token.new(:eq_eq, "==", start_pos, @position, start_line, start_column)
645
+ when ">"
646
+ advance
647
+ Token.new(:hash_rocket, "=>", start_pos, @position, start_line, start_column)
648
+ else
649
+ Token.new(:eq, "=", start_pos, @position, start_line, start_column)
650
+ end
651
+ end
652
+
653
+ def scan_bang
654
+ start_pos = @position
655
+ start_line = @line
656
+ start_column = @column
657
+
658
+ advance # skip !
659
+
660
+ if current_char == "="
661
+ advance
662
+ Token.new(:bang_eq, "!=", start_pos, @position, start_line, start_column)
663
+ else
664
+ Token.new(:bang, "!", start_pos, @position, start_line, start_column)
665
+ end
666
+ end
667
+
668
+ def scan_ampersand
669
+ start_pos = @position
670
+ start_line = @line
671
+ start_column = @column
672
+
673
+ advance # skip &
674
+
675
+ if current_char == "&"
676
+ advance
677
+ Token.new(:and_and, "&&", start_pos, @position, start_line, start_column)
678
+ else
679
+ Token.new(:amp, "&", start_pos, @position, start_line, start_column)
680
+ end
681
+ end
682
+
683
+ def scan_pipe
684
+ start_pos = @position
685
+ start_line = @line
686
+ start_column = @column
687
+
688
+ advance # skip |
689
+
690
+ if current_char == "|"
691
+ advance
692
+ Token.new(:or_or, "||", start_pos, @position, start_line, start_column)
693
+ else
694
+ Token.new(:pipe, "|", start_pos, @position, start_line, start_column)
695
+ end
696
+ end
697
+
698
+ def scan_plus
699
+ start_pos = @position
700
+ start_line = @line
701
+ start_column = @column
702
+
703
+ advance # skip +
704
+
705
+ if current_char == "="
706
+ advance
707
+ Token.new(:plus_eq, "+=", start_pos, @position, start_line, start_column)
708
+ else
709
+ Token.new(:plus, "+", start_pos, @position, start_line, start_column)
710
+ end
711
+ end
712
+
713
+ def scan_minus_or_arrow
714
+ start_pos = @position
715
+ start_line = @line
716
+ start_column = @column
717
+
718
+ advance # skip -
719
+
720
+ case current_char
721
+ when ">"
722
+ advance
723
+ Token.new(:arrow, "->", start_pos, @position, start_line, start_column)
724
+ when "="
725
+ advance
726
+ Token.new(:minus_eq, "-=", start_pos, @position, start_line, start_column)
727
+ else
728
+ Token.new(:minus, "-", start_pos, @position, start_line, start_column)
729
+ end
730
+ end
731
+
732
+ def scan_star
733
+ start_pos = @position
734
+ start_line = @line
735
+ start_column = @column
736
+
737
+ advance # skip *
738
+
739
+ case current_char
740
+ when "*"
741
+ advance
742
+ Token.new(:star_star, "**", start_pos, @position, start_line, start_column)
743
+ when "="
744
+ advance
745
+ Token.new(:star_eq, "*=", start_pos, @position, start_line, start_column)
746
+ else
747
+ Token.new(:star, "*", start_pos, @position, start_line, start_column)
748
+ end
749
+ end
750
+
751
+ def scan_slash
752
+ start_pos = @position
753
+ start_line = @line
754
+ start_column = @column
755
+
756
+ advance # skip /
757
+
758
+ if current_char == "="
759
+ advance
760
+ Token.new(:slash_eq, "/=", start_pos, @position, start_line, start_column)
761
+ elsif regex_context?
762
+ # 정규표현식 리터럴 스캔
763
+ scan_regex(start_pos, start_line, start_column)
764
+ else
765
+ Token.new(:slash, "/", start_pos, @position, start_line, start_column)
766
+ end
767
+ end
768
+
769
+ def regex_context?
770
+ # Check if / followed by whitespace - always division
771
+ next_char = @source[@position]
772
+ return false if [" ", "\t", "\n"].include?(next_char)
773
+
774
+ # Check previous token context
775
+ return true if @tokens.empty?
776
+
777
+ last_token = @tokens.last
778
+ return true if last_token.nil?
779
+
780
+ # After values/expressions - division operator
781
+ case last_token.type
782
+ when :identifier, :constant, :integer, :float, :string, :symbol,
783
+ :rparen, :rbracket, :rbrace, :ivar, :cvar, :gvar, :regex
784
+ false
785
+ # After binary operators - could be regex in `a * /pattern/` but safer to treat as division
786
+ # unless there's no space after /
787
+ when :plus, :minus, :star, :slash, :percent, :star_star,
788
+ :lt, :gt, :lt_eq, :gt_eq, :eq_eq, :bang_eq, :spaceship,
789
+ :and_and, :or_or, :amp, :pipe, :caret
790
+ # Already checked no whitespace after /, so this could be regex
791
+ true
792
+ # After keywords that expect expression - regex context
793
+ when :kw_if, :kw_unless, :kw_when, :kw_case, :kw_while, :kw_until,
794
+ :kw_and, :kw_or, :kw_not, :kw_return, :kw_yield
795
+ true
796
+ # After opening brackets/parens, comma, equals - regex context
797
+ when :lparen, :lbracket, :lbrace, :comma, :eq, :colon, :semicolon,
798
+ :plus_eq, :minus_eq, :star_eq, :slash_eq, :percent_eq,
799
+ :and_eq, :or_eq, :caret_eq, :arrow
800
+ true
801
+ else
802
+ false
803
+ end
804
+ end
805
+
806
+ def scan_regex(start_pos, start_line, start_column)
807
+ value = "/"
808
+
809
+ while @position < @source.length
810
+ char = current_char
811
+
812
+ case char
813
+ when "/"
814
+ value += char
815
+ advance
816
+ # 플래그 스캔 (i, m, x, o 등)
817
+ while @position < @source.length && current_char =~ /[imxo]/
818
+ value += current_char
819
+ advance
820
+ end
821
+ return Token.new(:regex, value, start_pos, @position, start_line, start_column)
822
+ when "\\"
823
+ # 이스케이프 시퀀스
824
+ value += char
825
+ advance
826
+ if @position < @source.length
827
+ value += current_char
828
+ advance
829
+ end
830
+ when "\n"
831
+ raise ScanError.new(
832
+ "Unterminated regex",
833
+ line: start_line,
834
+ column: start_column,
835
+ position: start_pos
836
+ )
837
+ else
838
+ value += char
839
+ advance
840
+ end
841
+ end
842
+
843
+ raise ScanError.new(
844
+ "Unterminated regex",
845
+ line: start_line,
846
+ column: start_column,
847
+ position: start_pos
848
+ )
849
+ end
850
+
851
+ def scan_percent
852
+ start_pos = @position
853
+ start_line = @line
854
+ start_column = @column
855
+
856
+ advance # skip %
857
+
858
+ if current_char == "="
859
+ advance
860
+ Token.new(:percent_eq, "%=", start_pos, @position, start_line, start_column)
861
+ else
862
+ Token.new(:percent, "%", start_pos, @position, start_line, start_column)
863
+ end
864
+ end
865
+
866
+ def skip_whitespace
867
+ advance while @position < @source.length && current_char =~ /[ \t\r]/
868
+ end
869
+
870
+ def current_char
871
+ @source[@position]
872
+ end
873
+
874
+ def peek_char
875
+ @source[@position + 1]
876
+ end
877
+
878
+ def advance
879
+ @column += 1
880
+ @position += 1
881
+ end
882
+ end
883
+ end