t-ruby 0.0.38 → 0.0.40

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.
@@ -3,18 +3,29 @@
3
3
  require "fileutils"
4
4
 
5
5
  module TRuby
6
+ # Pattern for method names that supports Unicode characters
7
+ # \p{L} matches any Unicode letter, \p{N} matches any Unicode number
8
+ IDENTIFIER_CHAR = '[\p{L}\p{N}_]'
9
+ METHOD_NAME_PATTERN = "#{IDENTIFIER_CHAR}+[?!]?".freeze
10
+ # Visibility modifiers for method definitions
11
+ VISIBILITY_PATTERN = '(?:(?:private|protected|public)\s+)?'
12
+
6
13
  class Compiler
7
- attr_reader :declaration_loader, :use_ir, :optimizer
14
+ attr_reader :declaration_loader, :optimizer
8
15
 
9
- def initialize(config = nil, use_ir: true, optimize: true)
16
+ def initialize(config = nil, optimize: true)
10
17
  @config = config || Config.new
11
- @use_ir = use_ir
12
18
  @optimize = optimize
13
19
  @declaration_loader = DeclarationLoader.new
14
- @optimizer = IR::Optimizer.new if use_ir && optimize
20
+ @optimizer = IR::Optimizer.new if optimize
21
+ @type_inferrer = ASTTypeInferrer.new if type_check?
15
22
  setup_declaration_paths if @config
16
23
  end
17
24
 
25
+ def type_check?
26
+ @config.type_check?
27
+ end
28
+
18
29
  def compile(input_path)
19
30
  unless File.exist?(input_path)
20
31
  raise ArgumentError, "File not found: #{input_path}"
@@ -32,11 +43,16 @@ module TRuby
32
43
  source = File.read(input_path)
33
44
 
34
45
  # Parse with IR support
35
- parser = Parser.new(source, use_combinator: @use_ir)
36
- parse_result = parser.parse
46
+ parser = Parser.new(source)
47
+ parser.parse
48
+
49
+ # Run type checking if enabled
50
+ if type_check? && parser.ir_program
51
+ check_types(parser.ir_program, input_path)
52
+ end
37
53
 
38
54
  # Transform source to Ruby code
39
- output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
55
+ output = transform_with_ir(source, parser)
40
56
 
41
57
  # Compute output path (respects preserve_structure setting)
42
58
  output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
@@ -48,11 +64,7 @@ module TRuby
48
64
  if @config.compiler["generate_rbs"]
49
65
  rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
50
66
  FileUtils.mkdir_p(File.dirname(rbs_path))
51
- if @use_ir && parser.ir_program
52
- generate_rbs_from_ir_to_path(rbs_path, parser.ir_program)
53
- else
54
- generate_rbs_file_to_path(rbs_path, parse_result)
55
- end
67
+ generate_rbs_from_ir_to_path(rbs_path, parser.ir_program)
56
68
  end
57
69
 
58
70
  # Generate .d.trb file if enabled in config (legacy support)
@@ -72,25 +84,17 @@ module TRuby
72
84
  def compile_string(source, options = {})
73
85
  generate_rbs = options.fetch(:rbs, true)
74
86
 
75
- parser = Parser.new(source, use_combinator: @use_ir)
76
- parse_result = parser.parse
87
+ parser = Parser.new(source)
88
+ parser.parse
77
89
 
78
90
  # Transform source to Ruby code
79
- ruby_output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
91
+ ruby_output = transform_with_ir(source, parser)
80
92
 
81
93
  # Generate RBS if requested
82
94
  rbs_output = ""
83
- if generate_rbs
84
- if @use_ir && parser.ir_program
85
- generator = IR::RBSGenerator.new
86
- rbs_output = generator.generate(parser.ir_program)
87
- else
88
- generator = RBSGenerator.new
89
- rbs_output = generator.generate(
90
- parse_result[:functions] || [],
91
- parse_result[:type_aliases] || []
92
- )
93
- end
95
+ if generate_rbs && parser.ir_program
96
+ generator = IR::RBSGenerator.new
97
+ rbs_output = generator.generate(parser.ir_program)
94
98
  end
95
99
 
96
100
  {
@@ -119,7 +123,7 @@ module TRuby
119
123
  end
120
124
 
121
125
  source = File.read(input_path)
122
- parser = Parser.new(source, use_combinator: true)
126
+ parser = Parser.new(source)
123
127
  parser.parse
124
128
  parser.ir_program
125
129
  end
@@ -215,6 +219,140 @@ module TRuby
215
219
 
216
220
  private
217
221
 
222
+ # Check types in IR program and raise TypeCheckError if mismatches found
223
+ # @param ir_program [IR::Program] IR program to check
224
+ # @param file_path [String] source file path for error messages
225
+ def check_types(ir_program, file_path)
226
+ ir_program.declarations.each do |decl|
227
+ case decl
228
+ when IR::MethodDef
229
+ check_method_return_type(decl, nil, file_path)
230
+ when IR::ClassDecl
231
+ decl.body.each do |member|
232
+ check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef)
233
+ end
234
+ end
235
+ end
236
+ end
237
+
238
+ # Check if method's inferred return type matches declared return type
239
+ # @param method [IR::MethodDef] method to check
240
+ # @param class_def [IR::ClassDef, nil] containing class if any
241
+ # @param file_path [String] source file path for error messages
242
+ def check_method_return_type(method, class_def, file_path)
243
+ # Skip if no explicit return type annotation
244
+ return unless method.return_type
245
+
246
+ declared_type = normalize_type(method.return_type.to_rbs)
247
+
248
+ # Create type environment for the class context
249
+ class_env = create_class_env(class_def) if class_def
250
+
251
+ # Infer actual return type
252
+ inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
253
+ inferred_type = normalize_type(inferred_type || "nil")
254
+
255
+ # Check compatibility
256
+ return if types_compatible?(inferred_type, declared_type)
257
+
258
+ location = method.location ? "#{file_path}:#{method.location}" : file_path
259
+ method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
260
+
261
+ raise TypeCheckError.new(
262
+ message: "Return type mismatch in method '#{method_name}': " \
263
+ "declared '#{declared_type}' but inferred '#{inferred_type}'",
264
+ location: location,
265
+ expected: declared_type,
266
+ actual: inferred_type
267
+ )
268
+ end
269
+
270
+ # Create type environment for class context
271
+ # @param class_def [IR::ClassDecl] class declaration
272
+ # @return [TypeEnv] type environment with instance variables
273
+ def create_class_env(class_def)
274
+ env = TypeEnv.new
275
+
276
+ # Register instance variables from class
277
+ class_def.instance_vars&.each do |ivar|
278
+ type = ivar.type_annotation&.to_rbs || "untyped"
279
+ env.define_instance_var(ivar.name, type)
280
+ end
281
+
282
+ env
283
+ end
284
+
285
+ # Normalize type string for comparison
286
+ # @param type [String] type string
287
+ # @return [String] normalized type string
288
+ def normalize_type(type)
289
+ return "untyped" if type.nil?
290
+
291
+ normalized = type.to_s.strip
292
+
293
+ # Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool)
294
+ case normalized
295
+ when "Boolean", "TrueClass", "FalseClass"
296
+ "bool"
297
+ else
298
+ normalized
299
+ end
300
+ end
301
+
302
+ # Check if inferred type is compatible with declared type
303
+ # @param inferred [String] inferred type
304
+ # @param declared [String] declared type
305
+ # @return [Boolean] true if compatible
306
+ def types_compatible?(inferred, declared)
307
+ # Exact match
308
+ return true if inferred == declared
309
+
310
+ # untyped is compatible with anything
311
+ return true if inferred == "untyped" || declared == "untyped"
312
+
313
+ # void is compatible with anything (no return value check)
314
+ return true if declared == "void"
315
+
316
+ # nil is compatible with nullable types
317
+ return true if inferred == "nil" && declared.end_with?("?")
318
+
319
+ # Subtype relationships
320
+ return true if subtype_of?(inferred, declared)
321
+
322
+ # Handle union types in declared
323
+ if declared.include?("|")
324
+ declared_types = declared.split("|").map(&:strip)
325
+ return true if declared_types.include?(inferred)
326
+ return true if declared_types.any? { |t| types_compatible?(inferred, t) }
327
+ end
328
+
329
+ # Handle union types in inferred - all must be compatible
330
+ if inferred.include?("|")
331
+ inferred_types = inferred.split("|").map(&:strip)
332
+ return inferred_types.all? { |t| types_compatible?(t, declared) }
333
+ end
334
+
335
+ false
336
+ end
337
+
338
+ # Check if subtype is a subtype of supertype
339
+ # @param subtype [String] potential subtype
340
+ # @param supertype [String] potential supertype
341
+ # @return [Boolean] true if subtype
342
+ def subtype_of?(subtype, supertype)
343
+ # Handle nullable - X is subtype of X?
344
+ return true if supertype.end_with?("?") && supertype[0..-2] == subtype
345
+
346
+ # Numeric hierarchy
347
+ return true if subtype == "Integer" && supertype == "Numeric"
348
+ return true if subtype == "Float" && supertype == "Numeric"
349
+
350
+ # Object is supertype of everything
351
+ return true if supertype == "Object"
352
+
353
+ false
354
+ end
355
+
218
356
  # Resolve path to absolute path, following symlinks
219
357
  # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
220
358
  def resolve_path(path)
@@ -231,10 +369,10 @@ module TRuby
231
369
  @declaration_loader.add_search_path("./lib/types")
232
370
  end
233
371
 
234
- # Transform using IR system (new approach)
372
+ # Transform using IR system
235
373
  def transform_with_ir(source, parser)
236
374
  ir_program = parser.ir_program
237
- return transform_legacy(source, parser.parse) unless ir_program
375
+ return source unless ir_program
238
376
 
239
377
  # Run optimization passes if enabled
240
378
  if @optimize && @optimizer
@@ -247,33 +385,15 @@ module TRuby
247
385
  generator.generate_with_source(ir_program, source)
248
386
  end
249
387
 
250
- # Legacy transformation using TypeErasure (backward compatible)
251
- def transform_legacy(source, parse_result)
252
- if parse_result[:type] == :success
253
- eraser = TypeErasure.new(source)
254
- eraser.erase
255
- else
256
- source
257
- end
258
- end
259
-
260
388
  # Generate RBS from IR to a specific path
261
389
  def generate_rbs_from_ir_to_path(rbs_path, ir_program)
390
+ return unless ir_program
391
+
262
392
  generator = IR::RBSGenerator.new
263
393
  rbs_content = generator.generate(ir_program)
264
394
  File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
265
395
  end
266
396
 
267
- # Legacy RBS generation to a specific path
268
- def generate_rbs_file_to_path(rbs_path, parse_result)
269
- generator = RBSGenerator.new
270
- rbs_content = generator.generate(
271
- parse_result[:functions] || [],
272
- parse_result[:type_aliases] || []
273
- )
274
- File.write(rbs_path, rbs_content) unless rbs_content.empty?
275
- end
276
-
277
397
  def generate_dtrb_file(input_path, out_dir)
278
398
  dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
279
399
  FileUtils.mkdir_p(File.dirname(dtrb_path))
@@ -362,7 +482,8 @@ module TRuby
362
482
  result = source.dup
363
483
 
364
484
  # Match function definitions and remove type annotations from parameters
365
- result.gsub!(/^(\s*def\s+\w+\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
485
+ # Also supports visibility modifiers: private def, protected def, public def
486
+ result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
366
487
  indent = ::Regexp.last_match(1)
367
488
  params = ::Regexp.last_match(2)
368
489
  close_paren = ::Regexp.last_match(3)
@@ -409,10 +530,13 @@ module TRuby
409
530
  params.join(", ")
410
531
  end
411
532
 
412
- # Clean a single parameter (remove type annotation)
533
+ # Clean a single parameter (remove type annotation, preserve default value)
413
534
  def clean_param(param)
414
- # Match: name: Type or name
415
- if (match = param.match(/^(\w+)\s*:/))
535
+ # Match: name: Type = value (with default value)
536
+ if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:\s*.+?\s*(=\s*.+)$/))
537
+ "#{match[1]} #{match[2]}"
538
+ # Match: name: Type (without default value)
539
+ elsif (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:/))
416
540
  match[1]
417
541
  else
418
542
  param
@@ -431,11 +555,4 @@ module TRuby
431
555
  result
432
556
  end
433
557
  end
434
-
435
- # Legacy Compiler for backward compatibility (no IR)
436
- class LegacyCompiler < Compiler
437
- def initialize(config)
438
- super(config, use_ir: false, optimize: false)
439
- end
440
- end
441
558
  end
data/lib/t_ruby/config.rb CHANGED
@@ -24,6 +24,7 @@ module TRuby
24
24
  "compiler" => {
25
25
  "strictness" => "standard",
26
26
  "generate_rbs" => true,
27
+ "type_check" => true,
27
28
  "target_ruby" => "3.0",
28
29
  "experimental" => [],
29
30
  "checks" => {
@@ -89,6 +90,12 @@ module TRuby
89
90
  @compiler["generate_rbs"] != false
90
91
  end
91
92
 
93
+ # Check if type checking is enabled
94
+ # @return [Boolean] true if type checking is enabled (default: true)
95
+ def type_check?
96
+ @compiler["type_check"] != false
97
+ end
98
+
92
99
  # Get target Ruby version
93
100
  # @return [String] target Ruby version (e.g., "3.0", "3.2")
94
101
  def target_ruby
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRuby
4
+ # Detects regions that should be skipped during parsing:
5
+ # - Heredoc content
6
+ # - Block comments (=begin/=end)
7
+ class HeredocDetector
8
+ # Heredoc start patterns:
9
+ # <<IDENTIFIER, <<-IDENTIFIER, <<~IDENTIFIER
10
+ # <<'IDENTIFIER', <<"IDENTIFIER"
11
+ HEREDOC_START_PATTERN = /<<([~-])?(['"]?)(\w+)\2/
12
+
13
+ # Detect all skippable ranges in lines (heredocs and block comments)
14
+ # @param lines [Array<String>] source lines
15
+ # @return [Array<Range>] content ranges to skip (0-indexed)
16
+ def self.detect(lines)
17
+ ranges = []
18
+ i = 0
19
+
20
+ while i < lines.length
21
+ line = lines[i]
22
+
23
+ # Check for =begin block comment
24
+ if line.strip == "=begin"
25
+ start_line = i
26
+ i += 1
27
+
28
+ # Find =end
29
+ while i < lines.length
30
+ break if lines[i].strip == "=end"
31
+
32
+ i += 1
33
+ end
34
+
35
+ # Range covers from =begin to =end (inclusive)
36
+ ranges << (start_line..i) if i < lines.length
37
+ # Check for heredoc
38
+ elsif (match = line.match(HEREDOC_START_PATTERN))
39
+ delimiter = match[3]
40
+ squiggly = match[1] == "~"
41
+ start_line = i
42
+ i += 1
43
+
44
+ # Find closing delimiter
45
+ while i < lines.length
46
+ # For squiggly heredoc or dash heredoc, delimiter can be indented
47
+ # For regular heredoc, delimiter must be at line start
48
+ if squiggly || match[1] == "-"
49
+ break if lines[i].strip == delimiter
50
+ elsif lines[i].chomp == delimiter
51
+ break
52
+ end
53
+ i += 1
54
+ end
55
+
56
+ # Range covers content lines (after start, up to and including end delimiter)
57
+ ranges << ((start_line + 1)..i) if i < lines.length
58
+ end
59
+
60
+ i += 1
61
+ end
62
+
63
+ ranges
64
+ end
65
+
66
+ # Check if a line index is inside any skippable region
67
+ # @param line_index [Integer] line index to check
68
+ # @param heredoc_ranges [Array<Range>] ranges from detect()
69
+ # @return [Boolean]
70
+ def self.inside_heredoc?(line_index, heredoc_ranges)
71
+ heredoc_ranges.any? { |range| range.include?(line_index) }
72
+ end
73
+ end
74
+ end
data/lib/t_ruby/ir.rb CHANGED
@@ -83,15 +83,16 @@ module TRuby
83
83
 
84
84
  # Class declaration
85
85
  class ClassDecl < Node
86
- attr_accessor :name, :superclass, :implements, :type_params, :body
86
+ attr_accessor :name, :superclass, :implements, :type_params, :body, :instance_vars
87
87
 
88
- def initialize(name:, superclass: nil, implements: [], type_params: [], body: [], **opts)
88
+ def initialize(name:, superclass: nil, implements: [], type_params: [], body: [], instance_vars: [], **opts)
89
89
  super(**opts)
90
90
  @name = name
91
91
  @superclass = superclass
92
92
  @implements = implements
93
93
  @type_params = type_params
94
94
  @body = body
95
+ @instance_vars = instance_vars
95
96
  end
96
97
 
97
98
  def children
@@ -99,6 +100,17 @@ module TRuby
99
100
  end
100
101
  end
101
102
 
103
+ # Instance variable declaration
104
+ class InstanceVariable < Node
105
+ attr_accessor :name, :type_annotation
106
+
107
+ def initialize(name:, type_annotation: nil, **opts)
108
+ super(**opts)
109
+ @name = name
110
+ @type_annotation = type_annotation
111
+ end
112
+ end
113
+
102
114
  # Module declaration
103
115
  class ModuleDecl < Node
104
116
  attr_accessor :name, :body
@@ -686,6 +698,11 @@ module TRuby
686
698
  declarations << build_interface(interface_info)
687
699
  end
688
700
 
701
+ # Build classes
702
+ (parse_result[:classes] || []).each do |class_info|
703
+ declarations << build_class(class_info)
704
+ end
705
+
689
706
  # Build functions/methods
690
707
  (parse_result[:functions] || []).each do |func_info|
691
708
  declarations << build_method(func_info)
@@ -724,6 +741,28 @@ module TRuby
724
741
  )
725
742
  end
726
743
 
744
+ def build_class(info)
745
+ # Build methods
746
+ methods = (info[:methods] || []).map do |method_info|
747
+ build_method(method_info)
748
+ end
749
+
750
+ # Build instance variables
751
+ instance_vars = (info[:instance_vars] || []).map do |ivar|
752
+ InstanceVariable.new(
753
+ name: ivar[:name],
754
+ type_annotation: ivar[:type] ? parse_type(ivar[:type]) : nil
755
+ )
756
+ end
757
+
758
+ ClassDecl.new(
759
+ name: info[:name],
760
+ superclass: info[:superclass],
761
+ body: methods,
762
+ instance_vars: instance_vars
763
+ )
764
+ end
765
+
727
766
  def build_method(info)
728
767
  params = (info[:params] || []).map do |param|
729
768
  Parameter.new(
@@ -732,10 +771,15 @@ module TRuby
732
771
  )
733
772
  end
734
773
 
774
+ # 본문 IR이 있으면 사용 (BodyParser에서 파싱됨)
775
+ body = info[:body_ir]
776
+
735
777
  MethodDef.new(
736
778
  name: info[:name],
737
779
  params: params,
738
- return_type: info[:return_type] ? parse_type(info[:return_type]) : nil
780
+ return_type: info[:return_type] ? parse_type(info[:return_type]) : nil,
781
+ body: body,
782
+ visibility: info[:visibility] || :public
739
783
  )
740
784
  end
741
785
 
@@ -947,9 +991,12 @@ module TRuby
947
991
  class RBSGenerator < Visitor
948
992
  attr_reader :output
949
993
 
950
- def initialize
994
+ def initialize(enable_inference: true)
951
995
  @output = []
952
996
  @indent = 0
997
+ @enable_inference = enable_inference
998
+ @inferrer = TRuby::ASTTypeInferrer.new if enable_inference
999
+ @class_env = nil # 현재 클래스의 타입 환경
953
1000
  end
954
1001
 
955
1002
  def generate(program)
@@ -988,26 +1035,76 @@ module TRuby
988
1035
  def visit_method_def(node)
989
1036
  params = node.params.map do |param|
990
1037
  type = param.type_annotation&.to_rbs || "untyped"
991
- "#{type} #{param.name}"
1038
+ "#{param.name}: #{type}"
992
1039
  end.join(", ")
993
1040
 
994
- return_type = node.return_type&.to_rbs || "untyped"
995
- emit("def #{node.name}: (#{params}) -> #{return_type}")
1041
+ # 반환 타입: 명시적 타입 > 추론된 타입 > untyped
1042
+ return_type = node.return_type&.to_rbs
1043
+
1044
+ # initialize 메서드는 특별 처리: 명시적 타입이 없으면 void
1045
+ # Ruby에서 initialize는 생성자이며, 실제 인스턴스 생성은 Class.new가 담당
1046
+ if node.name == "initialize" && return_type.nil?
1047
+ return_type = "void"
1048
+ elsif return_type.nil? && @enable_inference && @inferrer && node.body
1049
+ # 명시적 반환 타입이 없으면 추론 시도
1050
+ inferred = @inferrer.infer_method_return_type(node, @class_env)
1051
+ return_type = inferred if inferred && inferred != "untyped"
1052
+ end
1053
+
1054
+ return_type ||= "untyped"
1055
+ visibility_prefix = format_visibility(node.visibility)
1056
+ emit("#{visibility_prefix}def #{node.name}: (#{params}) -> #{return_type}")
996
1057
  end
997
1058
 
998
1059
  def visit_class_decl(node)
999
1060
  emit("class #{node.name}")
1000
1061
  @indent += 1
1062
+
1063
+ # 클래스 타입 환경 생성
1064
+ @class_env = TRuby::TypeEnv.new if @enable_inference
1065
+
1066
+ # 인스턴스 변수 타입 등록
1067
+ (node.instance_vars || []).each do |ivar|
1068
+ if @class_env && ivar.type_annotation
1069
+ @class_env.define_instance_var("@#{ivar.name}", ivar.type_annotation.to_rbs)
1070
+ end
1071
+
1072
+ # Emit instance variables first
1073
+ visit_instance_variable(ivar)
1074
+ end
1075
+
1076
+ # Add blank line between ivars and methods if both exist
1077
+ @output << "" if node.instance_vars&.any? && node.body&.any?
1078
+
1079
+ # Emit methods
1001
1080
  node.body.each { |member| visit(member) }
1081
+
1082
+ @class_env = nil
1002
1083
  @indent -= 1
1003
1084
  emit("end")
1004
1085
  end
1005
1086
 
1087
+ def visit_instance_variable(node)
1088
+ type = node.type_annotation&.to_rbs || "untyped"
1089
+ emit("@#{node.name}: #{type}")
1090
+ end
1091
+
1006
1092
  private
1007
1093
 
1008
1094
  def emit(text)
1009
1095
  @output << ((" " * @indent) + text)
1010
1096
  end
1097
+
1098
+ def format_visibility(visibility)
1099
+ # RBS only supports private visibility, not protected
1100
+ # See: https://github.com/ruby/rbs/issues/579
1101
+ case visibility
1102
+ when :private
1103
+ "private "
1104
+ else
1105
+ ""
1106
+ end
1107
+ end
1011
1108
  end
1012
1109
 
1013
1110
  #==========================================================================
@@ -787,7 +787,7 @@ module TRuby
787
787
  lines = text.split("\n")
788
788
 
789
789
  # Parse the document to get IR
790
- parser = Parser.new(text, use_combinator: true)
790
+ parser = Parser.new(text)
791
791
  parse_result = parser.parse
792
792
  parser.ir_program
793
793