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

type-ruby / t-ruby / 20478486132

24 Dec 2025 04:47AM UTC coverage: 75.962% (+0.08%) from 75.882%
20478486132

Pull #15

github

web-flow
Merge 668bc3161 into a32530d84
Pull Request #15: feat: integrate type checker into compiler for return type validation

64 of 71 new or added lines in 4 files covered. (90.14%)

38 existing lines in 2 files now uncovered.

5075 of 6681 relevant lines covered (75.96%)

1166.68 hits per line

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

79.05
/lib/t_ruby/compiler.rb
1
# frozen_string_literal: true
2

3
require "fileutils"
1✔
4

5
module TRuby
1✔
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}_]'
1✔
9
  METHOD_NAME_PATTERN = "#{IDENTIFIER_CHAR}+[?!]?".freeze
1✔
10

11
  class Compiler
1✔
12
    attr_reader :declaration_loader, :use_ir, :optimizer, :type_check
1✔
13

14
    def initialize(config = nil, use_ir: true, optimize: true, type_check: false)
1✔
15
      @config = config || Config.new
98✔
16
      @use_ir = use_ir
98✔
17
      @optimize = optimize
98✔
18
      @type_check = type_check
98✔
19
      @declaration_loader = DeclarationLoader.new
98✔
20
      @optimizer = IR::Optimizer.new if use_ir && optimize
98✔
21
      @type_inferrer = ASTTypeInferrer.new if type_check
98✔
22
      setup_declaration_paths if @config
98✔
23
    end
24

25
    def compile(input_path)
1✔
26
      unless File.exist?(input_path)
91✔
27
        raise ArgumentError, "File not found: #{input_path}"
8✔
28
      end
29

30
      # Handle .rb files separately
31
      if input_path.end_with?(".rb")
83✔
32
        return copy_ruby_file(input_path)
3✔
33
      end
34

35
      unless input_path.end_with?(".trb")
80✔
36
        raise ArgumentError, "Expected .trb or .rb file, got: #{input_path}"
3✔
37
      end
38

39
      source = File.read(input_path)
77✔
40

41
      # Parse with IR support
42
      parser = Parser.new(source, use_combinator: @use_ir)
77✔
43
      parse_result = parser.parse
77✔
44

45
      # Run type checking if enabled
46
      if @type_check && @use_ir && parser.ir_program
77✔
47
        check_types(parser.ir_program, input_path)
4✔
48
      end
49

50
      # Transform source to Ruby code
51
      output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
74✔
52

53
      # Compute output path (respects preserve_structure setting)
54
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
74✔
55
      FileUtils.mkdir_p(File.dirname(output_path))
74✔
56

57
      File.write(output_path, output)
74✔
58

59
      # Generate .rbs file if enabled in config
60
      if @config.compiler["generate_rbs"]
74✔
61
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
61✔
62
        FileUtils.mkdir_p(File.dirname(rbs_path))
61✔
63
        if @use_ir && parser.ir_program
61✔
64
          generate_rbs_from_ir_to_path(rbs_path, parser.ir_program)
61✔
65
        else
UNCOV
66
          generate_rbs_file_to_path(rbs_path, parse_result)
×
67
        end
68
      end
69

70
      # Generate .d.trb file if enabled in config (legacy support)
71
      # TODO: Add compiler.generate_dtrb option in future
72
      if @config.compiler.key?("generate_dtrb") && @config.compiler["generate_dtrb"]
74✔
UNCOV
73
        generate_dtrb_file(input_path, @config.ruby_dir)
×
74
      end
75

76
      output_path
74✔
77
    end
78

79
    # Compile T-Ruby source code from a string (useful for WASM/playground)
80
    # @param source [String] T-Ruby source code
81
    # @param options [Hash] Options for compilation
82
    # @option options [Boolean] :rbs Whether to generate RBS output (default: true)
83
    # @return [Hash] Result with :ruby, :rbs, :errors keys
84
    def compile_string(source, options = {})
1✔
UNCOV
85
      generate_rbs = options.fetch(:rbs, true)
×
86

UNCOV
87
      parser = Parser.new(source, use_combinator: @use_ir)
×
88
      parse_result = parser.parse
×
89

90
      # Transform source to Ruby code
91
      ruby_output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
×
92

93
      # Generate RBS if requested
94
      rbs_output = ""
×
UNCOV
95
      if generate_rbs
×
UNCOV
96
        if @use_ir && parser.ir_program
×
97
          generator = IR::RBSGenerator.new
×
98
          rbs_output = generator.generate(parser.ir_program)
×
99
        else
100
          generator = RBSGenerator.new
×
101
          rbs_output = generator.generate(
×
102
            parse_result[:functions] || [],
103
            parse_result[:type_aliases] || []
104
          )
105
        end
106
      end
107

108
      {
UNCOV
109
        ruby: ruby_output,
×
110
        rbs: rbs_output,
111
        errors: [],
112
      }
113
    rescue ParseError => e
114
      {
UNCOV
115
        ruby: "",
×
116
        rbs: "",
117
        errors: [e.message],
118
      }
119
    rescue StandardError => e
120
      {
UNCOV
121
        ruby: "",
×
122
        rbs: "",
123
        errors: ["Compilation error: #{e.message}"],
124
      }
125
    end
126

127
    # Compile to IR without generating output files
128
    def compile_to_ir(input_path)
1✔
129
      unless File.exist?(input_path)
4✔
UNCOV
130
        raise ArgumentError, "File not found: #{input_path}"
×
131
      end
132

133
      source = File.read(input_path)
4✔
134
      parser = Parser.new(source, use_combinator: true)
4✔
135
      parser.parse
4✔
136
      parser.ir_program
4✔
137
    end
138

139
    # Compile from IR program directly
140
    def compile_from_ir(ir_program, output_path)
1✔
UNCOV
141
      out_dir = File.dirname(output_path)
×
UNCOV
142
      FileUtils.mkdir_p(out_dir)
×
143

144
      # Optimize if enabled
145
      program = ir_program
×
UNCOV
146
      if @optimize && @optimizer
×
UNCOV
147
        result = @optimizer.optimize(program)
×
148
        program = result[:program]
×
149
      end
150

151
      # Generate Ruby code
UNCOV
152
      generator = IRCodeGenerator.new
×
UNCOV
153
      output = generator.generate(program)
×
UNCOV
154
      File.write(output_path, output)
×
155

156
      output_path
×
157
    end
158

159
    # Load external declarations from a file
160
    def load_declaration(name)
1✔
UNCOV
161
      @declaration_loader.load(name)
×
162
    end
163

164
    # Add a search path for declaration files
165
    def add_declaration_path(path)
1✔
UNCOV
166
      @declaration_loader.add_search_path(path)
×
167
    end
168

169
    # Get optimization statistics (only available after IR compilation)
170
    def optimization_stats
1✔
UNCOV
171
      @optimizer&.stats
×
172
    end
173

174
    # Compute output path for a source file
175
    # @param input_path [String] path to source file
176
    # @param output_dir [String] base output directory
177
    # @param new_extension [String] new file extension (e.g., ".rb", ".rbs")
178
    # @return [String] computed output path (always preserves directory structure)
179
    def compute_output_path(input_path, output_dir, new_extension)
1✔
180
      relative = compute_relative_path(input_path)
140✔
181
      base = relative.sub(/\.[^.]+$/, new_extension)
140✔
182
      File.join(output_dir, base)
140✔
183
    end
184

185
    # Compute relative path from source directory
186
    # @param input_path [String] path to source file
187
    # @return [String] relative path preserving directory structure
188
    def compute_relative_path(input_path)
1✔
189
      # Use realpath to resolve symlinks (e.g., /var vs /private/var on macOS)
190
      absolute_input = resolve_path(input_path)
140✔
191
      source_dirs = @config.source_include
140✔
192

193
      # Check if file is inside any source_include directory
194
      if source_dirs.size > 1
140✔
195
        # Multiple source directories: include the source dir name in output
196
        # src/models/user.trb → src/models/user.trb
197
        source_dirs.each do |src_dir|
4✔
198
          absolute_src = resolve_path(src_dir)
5✔
199
          next unless absolute_input.start_with?("#{absolute_src}/")
5✔
200

201
          # Return path relative to parent of source dir (includes src dir name)
202
          parent_of_src = File.dirname(absolute_src)
4✔
203
          return absolute_input.sub("#{parent_of_src}/", "")
4✔
204
        end
205
      else
206
        # Single source directory: exclude the source dir name from output
207
        # src/models/user.trb → models/user.trb
208
        src_dir = source_dirs.first
136✔
209
        if src_dir
136✔
210
          absolute_src = resolve_path(src_dir)
136✔
211
          if absolute_input.start_with?("#{absolute_src}/")
136✔
212
            return absolute_input.sub("#{absolute_src}/", "")
65✔
213
          end
214
        end
215
      end
216

217
      # File outside source directories: use path relative to current working directory
218
      # external/foo.trb → external/foo.trb
219
      cwd = resolve_path(".")
71✔
220
      if absolute_input.start_with?("#{cwd}/")
71✔
221
        return absolute_input.sub("#{cwd}/", "")
4✔
222
      end
223

224
      # Absolute path from outside cwd: use basename only
225
      File.basename(input_path)
67✔
226
    end
227

228
    private
1✔
229

230
    # Check types in IR program and raise TypeCheckError if mismatches found
231
    # @param ir_program [IR::Program] IR program to check
232
    # @param file_path [String] source file path for error messages
233
    def check_types(ir_program, file_path)
1✔
234
      ir_program.declarations.each do |decl|
4✔
235
        case decl
4✔
236
        when IR::MethodDef
237
          check_method_return_type(decl, nil, file_path)
3✔
238
        when IR::ClassDecl
239
          decl.body.each do |member|
1✔
240
            check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef)
1✔
241
          end
242
        end
243
      end
244
    end
245

246
    # Check if method's inferred return type matches declared return type
247
    # @param method [IR::MethodDef] method to check
248
    # @param class_def [IR::ClassDef, nil] containing class if any
249
    # @param file_path [String] source file path for error messages
250
    def check_method_return_type(method, class_def, file_path)
1✔
251
      # Skip if no explicit return type annotation
252
      return unless method.return_type
4✔
253

254
      declared_type = normalize_type(method.return_type.to_rbs)
4✔
255

256
      # Create type environment for the class context
257
      class_env = create_class_env(class_def) if class_def
4✔
258

259
      # Infer actual return type
260
      inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
4✔
261
      inferred_type = normalize_type(inferred_type || "nil")
4✔
262

263
      # Check compatibility
264
      return if types_compatible?(inferred_type, declared_type)
4✔
265

266
      location = method.location ? "#{file_path}:#{method.location}" : file_path
3✔
267
      method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
3✔
268

269
      raise TypeCheckError.new(
3✔
270
        message: "Return type mismatch in method '#{method_name}': " \
271
                 "declared '#{declared_type}' but inferred '#{inferred_type}'",
272
        location: location,
273
        expected: declared_type,
274
        actual: inferred_type
275
      )
276
    end
277

278
    # Create type environment for class context
279
    # @param class_def [IR::ClassDecl] class declaration
280
    # @return [TypeEnv] type environment with instance variables
281
    def create_class_env(class_def)
1✔
282
      env = TypeEnv.new
1✔
283

284
      # Register instance variables from class
285
      class_def.instance_vars&.each do |ivar|
1✔
NEW
286
        type = ivar.type_annotation&.to_rbs || "untyped"
×
NEW
287
        env.define_instance_var(ivar.name, type)
×
288
      end
289

290
      env
1✔
291
    end
292

293
    # Normalize type string for comparison
294
    # @param type [String] type string
295
    # @return [String] normalized type string
296
    def normalize_type(type)
1✔
297
      return "untyped" if type.nil?
8✔
298

299
      type.to_s.strip
8✔
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)
1✔
307
      # Exact match
308
      return true if inferred == declared
4✔
309

310
      # untyped is compatible with anything
311
      return true if inferred == "untyped" || declared == "untyped"
3✔
312

313
      # void is compatible with anything (no return value check)
314
      return true if declared == "void"
3✔
315

316
      # nil is compatible with nullable types
317
      return true if inferred == "nil" && declared.end_with?("?")
3✔
318

319
      # Subtype relationships
320
      return true if subtype_of?(inferred, declared)
3✔
321

322
      # Handle union types in declared
323
      if declared.include?("|")
3✔
NEW
324
        declared_types = declared.split("|").map(&:strip)
×
NEW
325
        return true if declared_types.include?(inferred)
×
NEW
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?("|")
3✔
NEW
331
        inferred_types = inferred.split("|").map(&:strip)
×
NEW
332
        return inferred_types.all? { |t| types_compatible?(t, declared) }
×
333
      end
334

335
      false
3✔
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)
1✔
343
      # Handle nullable - X is subtype of X?
344
      return true if supertype.end_with?("?") && supertype[0..-2] == subtype
3✔
345

346
      # Numeric hierarchy
347
      return true if subtype == "Integer" && supertype == "Numeric"
3✔
348
      return true if subtype == "Float" && supertype == "Numeric"
3✔
349

350
      # Object is supertype of everything
351
      return true if supertype == "Object"
3✔
352

353
      false
3✔
354
    end
355

356
    # Resolve path to absolute path, following symlinks
357
    # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
358
    def resolve_path(path)
1✔
359
      File.realpath(path)
352✔
360
    rescue Errno::ENOENT
361
      File.expand_path(path)
70✔
362
    end
363

364
    def setup_declaration_paths
1✔
365
      # Add default declaration paths
366
      @declaration_loader.add_search_path(@config.out_dir)
98✔
367
      @declaration_loader.add_search_path(@config.src_dir)
98✔
368
      @declaration_loader.add_search_path("./types")
98✔
369
      @declaration_loader.add_search_path("./lib/types")
98✔
370
    end
371

372
    # Transform using IR system (new approach)
373
    def transform_with_ir(source, parser)
1✔
374
      ir_program = parser.ir_program
74✔
375
      return transform_legacy(source, parser.parse) unless ir_program
74✔
376

377
      # Run optimization passes if enabled
378
      if @optimize && @optimizer
74✔
379
        result = @optimizer.optimize(ir_program)
74✔
380
        ir_program = result[:program]
74✔
381
      end
382

383
      # Generate Ruby code using IR-aware generator
384
      generator = IRCodeGenerator.new
74✔
385
      generator.generate_with_source(ir_program, source)
74✔
386
    end
387

388
    # Legacy transformation using TypeErasure (backward compatible)
389
    def transform_legacy(source, parse_result)
1✔
UNCOV
390
      if parse_result[:type] == :success
×
UNCOV
391
        eraser = TypeErasure.new(source)
×
UNCOV
392
        eraser.erase
×
393
      else
UNCOV
394
        source
×
395
      end
396
    end
397

398
    # Generate RBS from IR to a specific path
399
    def generate_rbs_from_ir_to_path(rbs_path, ir_program)
1✔
400
      generator = IR::RBSGenerator.new
61✔
401
      rbs_content = generator.generate(ir_program)
61✔
402
      File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
61✔
403
    end
404

405
    # Legacy RBS generation to a specific path
406
    def generate_rbs_file_to_path(rbs_path, parse_result)
1✔
UNCOV
407
      generator = RBSGenerator.new
×
UNCOV
408
      rbs_content = generator.generate(
×
409
        parse_result[:functions] || [],
410
        parse_result[:type_aliases] || []
411
      )
UNCOV
412
      File.write(rbs_path, rbs_content) unless rbs_content.empty?
×
413
    end
414

415
    def generate_dtrb_file(input_path, out_dir)
1✔
UNCOV
416
      dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
×
UNCOV
417
      FileUtils.mkdir_p(File.dirname(dtrb_path))
×
418

419
      generator = DeclarationGenerator.new
×
UNCOV
420
      generator.generate_file_to_path(input_path, dtrb_path)
×
421
    end
422

423
    # Copy .rb file to output directory and generate .rbs signature
424
    def copy_ruby_file(input_path)
1✔
425
      unless File.exist?(input_path)
3✔
UNCOV
426
        raise ArgumentError, "File not found: #{input_path}"
×
427
      end
428

429
      # Compute output path (respects preserve_structure setting)
430
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
3✔
431
      FileUtils.mkdir_p(File.dirname(output_path))
3✔
432

433
      # Copy the .rb file to output directory
434
      FileUtils.cp(input_path, output_path)
3✔
435

436
      # Generate .rbs file if enabled in config
437
      if @config.compiler["generate_rbs"]
3✔
438
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
2✔
439
        FileUtils.mkdir_p(File.dirname(rbs_path))
2✔
440
        generate_rbs_from_ruby_to_path(rbs_path, input_path)
2✔
441
      end
442

443
      output_path
3✔
444
    end
445

446
    # Generate RBS from Ruby file using rbs prototype to a specific path
447
    def generate_rbs_from_ruby_to_path(rbs_path, input_path)
1✔
448
      result = `rbs prototype rb #{input_path} 2>/dev/null`
2✔
449
      File.write(rbs_path, result) unless result.strip.empty?
2✔
450
    end
451
  end
452

453
  # IR-aware code generator for source-preserving transformation
454
  class IRCodeGenerator
1✔
455
    def initialize
1✔
456
      @output = []
74✔
457
    end
458

459
    # Generate Ruby code from IR program
460
    def generate(program)
1✔
UNCOV
461
      generator = IR::CodeGenerator.new
×
UNCOV
462
      generator.generate(program)
×
463
    end
464

465
    # Generate Ruby code while preserving source structure
466
    def generate_with_source(program, source)
1✔
467
      result = source.dup
74✔
468

469
      # Collect type alias names to remove
470
      program.declarations
74✔
471
             .select { |d| d.is_a?(IR::TypeAlias) }
62✔
472
             .map(&:name)
473

474
      # Collect interface names to remove
475
      program.declarations
74✔
476
             .select { |d| d.is_a?(IR::Interface) }
62✔
477
             .map(&:name)
478

479
      # Remove type alias definitions
480
      result = result.gsub(/^\s*type\s+\w+\s*=\s*.+?$\n?/, "")
74✔
481

482
      # Remove interface definitions (multi-line)
483
      result = result.gsub(/^\s*interface\s+\w+.*?^\s*end\s*$/m, "")
74✔
484

485
      # Remove parameter type annotations using IR info
486
      # Enhanced: Handle complex types (generics, unions, etc.)
487
      result = erase_parameter_types(result)
74✔
488

489
      # Remove return type annotations
490
      result = erase_return_types(result)
74✔
491

492
      # Clean up extra blank lines
493
      result.gsub(/\n{3,}/, "\n\n")
74✔
494
    end
495

496
    private
1✔
497

498
    # Erase parameter type annotations
499
    def erase_parameter_types(source)
1✔
500
      result = source.dup
74✔
501

502
      # Match function definitions and remove type annotations from parameters
503
      result.gsub!(/^(\s*def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
74✔
504
        indent = ::Regexp.last_match(1)
39✔
505
        params = ::Regexp.last_match(2)
39✔
506
        close_paren = ::Regexp.last_match(3)
39✔
507
        ending = ::Regexp.last_match(4)
39✔
508

509
        # Remove type annotations from each parameter
510
        cleaned_params = remove_param_types(params)
39✔
511

512
        "#{indent}#{cleaned_params}#{close_paren.rstrip}#{ending}"
39✔
513
      end
514

515
      result
74✔
516
    end
517

518
    # Remove type annotations from parameter list
519
    def remove_param_types(params_str)
1✔
520
      return params_str if params_str.strip.empty?
39✔
521

522
      params = []
39✔
523
      current = ""
39✔
524
      depth = 0
39✔
525

526
      params_str.each_char do |char|
39✔
527
        case char
520✔
528
        when "<", "[", "("
529
          depth += 1
1✔
530
          current += char
1✔
531
        when ">", "]", ")"
532
          depth -= 1
1✔
533
          current += char
1✔
534
        when ","
535
          if depth.zero?
5✔
536
            params << clean_param(current.strip)
5✔
537
            current = ""
5✔
538
          else
UNCOV
539
            current += char
×
540
          end
541
        else
542
          current += char
513✔
543
        end
544
      end
545

546
      params << clean_param(current.strip) unless current.empty?
39✔
547
      params.join(", ")
39✔
548
    end
549

550
    # Clean a single parameter (remove type annotation)
551
    def clean_param(param)
1✔
552
      # Match: name: Type or name (supports Unicode identifiers)
553
      if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:/))
44✔
554
        match[1]
41✔
555
      else
556
        param
3✔
557
      end
558
    end
559

560
    # Erase return type annotations
561
    def erase_return_types(source)
1✔
562
      result = source.dup
74✔
563

564
      # Remove return type: ): Type or ): Type<Foo> etc.
565
      result.gsub!(/\)\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
74✔
566
        ")"
4✔
567
      end
568

569
      result
74✔
570
    end
571
  end
572

573
  # Legacy Compiler for backward compatibility (no IR)
574
  class LegacyCompiler < Compiler
1✔
575
    def initialize(config)
1✔
UNCOV
576
      super(config, use_ir: false, optimize: false)
×
577
    end
578
  end
579
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