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

type-ruby / t-ruby / 21099731939

17 Jan 2026 07:31PM UTC coverage: 92.291% (-0.1%) from 92.432%
21099731939

Pull #38

github

web-flow
Merge 9d38dc7a4 into 11dd2a1dd
Pull Request #38: feat: add Proc and Lambda type syntax for block parameters

148 of 177 new or added lines in 5 files covered. (83.62%)

15 existing lines in 1 file now uncovered.

8404 of 9106 relevant lines covered (92.29%)

1673.68 hits per line

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

88.79
/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
  # Visibility modifiers for method definitions
11
  VISIBILITY_PATTERN = '(?:(?:private|protected|public)\s+)?'
1✔
12

13
  class Compiler
1✔
14
    attr_reader :declaration_loader, :optimizer
1✔
15

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

25
    def type_check?
1✔
26
      @config.type_check?
615✔
27
    end
28

29
    def compile(input_path)
1✔
30
      unless File.exist?(input_path)
180✔
31
        raise ArgumentError, "File not found: #{input_path}"
9✔
32
      end
33

34
      # Handle .rb files separately
35
      if input_path.end_with?(".rb")
171✔
36
        return copy_ruby_file(input_path)
3✔
37
      end
38

39
      unless input_path.end_with?(".trb")
168✔
40
        raise ArgumentError, "Expected .trb or .rb file, got: #{input_path}"
4✔
41
      end
42

43
      source = File.read(input_path)
164✔
44

45
      # Parse with IR support
46
      parser = Parser.new(source)
164✔
47
      parser.parse
164✔
48

49
      # Run type checking if enabled
50
      if type_check? && parser.ir_program
163✔
51
        check_types(parser.ir_program, input_path)
131✔
52
      end
53

54
      # Transform source to Ruby code
55
      output = transform_with_ir(source, parser)
158✔
56

57
      # Compute output path (respects preserve_structure setting)
58
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
158✔
59
      FileUtils.mkdir_p(File.dirname(output_path))
158✔
60

61
      File.write(output_path, output)
158✔
62

63
      # Generate .rbs file if enabled in config
64
      if @config.compiler["generate_rbs"]
158✔
65
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
144✔
66
        FileUtils.mkdir_p(File.dirname(rbs_path))
144✔
67
        generate_rbs_from_ir_to_path(rbs_path, parser.ir_program)
144✔
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"]
158✔
73
        generate_dtrb_file(input_path, @config.ruby_dir)
×
74
      end
75

76
      output_path
158✔
77
    end
78

79
    # Compile a file and return result with diagnostics
80
    # This is the unified compilation interface for CLI and Watcher
81
    # @param input_path [String] Path to the input file
82
    # @return [Hash] Result with :success, :output_path, :diagnostics keys
83
    def compile_with_diagnostics(input_path)
1✔
84
      source = File.exist?(input_path) ? File.read(input_path) : nil
60✔
85
      all_diagnostics = []
60✔
86

87
      # Run analyze first to get all diagnostics (colon spacing, etc.)
88
      if source
60✔
89
        all_diagnostics = analyze(source, file: input_path)
54✔
90
      end
91

92
      begin
93
        output_path = compile(input_path)
60✔
94
        # Compilation succeeded, but we may still have diagnostics from analyze
95
        {
96
          success: all_diagnostics.empty?,
47✔
97
          output_path: all_diagnostics.empty? ? output_path : nil,
47✔
98
          diagnostics: all_diagnostics,
99
        }
100
      rescue TypeCheckError => e
101
        # Skip if already reported by analyze (same message and location)
102
        new_diag = Diagnostic.from_type_check_error(e, file: input_path, source: source)
3✔
103
        unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
4✔
104
          all_diagnostics << new_diag
2✔
105
        end
106
        {
107
          success: false,
3✔
108
          output_path: nil,
109
          diagnostics: all_diagnostics,
110
        }
111
      rescue ParseError => e
112
        new_diag = Diagnostic.from_parse_error(e, file: input_path, source: source)
1✔
113
        unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
2✔
114
          all_diagnostics << new_diag
×
115
        end
116
        {
117
          success: false,
1✔
118
          output_path: nil,
119
          diagnostics: all_diagnostics,
120
        }
121
      rescue Scanner::ScanError => e
122
        new_diag = Diagnostic.from_scan_error(e, file: input_path, source: source)
1✔
123
        unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
1✔
124
          all_diagnostics << new_diag
1✔
125
        end
126
        {
127
          success: false,
1✔
128
          output_path: nil,
129
          diagnostics: all_diagnostics,
130
        }
131
      rescue ArgumentError => e
132
        all_diagnostics << Diagnostic.new(
8✔
133
          code: "TR0001",
134
          message: e.message,
135
          file: input_path,
136
          severity: Diagnostic::SEVERITY_ERROR
137
        )
138
        {
139
          success: false,
8✔
140
          output_path: nil,
141
          diagnostics: all_diagnostics,
142
        }
143
      end
144
    end
145

146
    # Analyze source code without compiling - returns diagnostics only
147
    # This is the unified analysis interface for LSP and other tools
148
    # @param source [String] T-Ruby source code
149
    # @param file [String] File path for error reporting (optional)
150
    # @return [Array<Diagnostic>] Array of diagnostic objects
151
    def analyze(source, file: "<source>")
1✔
152
      diagnostics = []
94✔
153
      source_lines = source.split("\n")
94✔
154

155
      # Run ErrorHandler checks (syntax validation, duplicate definitions, etc.)
156
      error_handler = ErrorHandler.new(source)
94✔
157
      errors = error_handler.check
94✔
158
      errors.each do |error|
94✔
159
        # Parse line number from "Line N: message" format
160
        next unless error =~ /^Line (\d+):\s*(.+)$/
13✔
161

162
        line_num = Regexp.last_match(1).to_i
13✔
163
        message = Regexp.last_match(2)
13✔
164
        source_line = source_lines[line_num - 1] if line_num.positive?
13✔
165
        diagnostics << Diagnostic.new(
13✔
166
          code: "TR1002",
167
          message: message,
168
          file: file,
169
          line: line_num,
170
          column: 1,
171
          source_line: source_line,
172
          severity: Diagnostic::SEVERITY_ERROR
173
        )
174
      end
175

176
      # Run TokenDeclarationParser for colon spacing and declaration syntax validation
177
      begin
178
        scanner = Scanner.new(source)
94✔
179
        tokens = scanner.scan_all
94✔
180
        decl_parser = ParserCombinator::TokenDeclarationParser.new
92✔
181
        decl_parser.parse_program(tokens)
92✔
182

183
        if decl_parser.has_errors?
92✔
184
          decl_parser.errors.each do |err|
23✔
185
            source_line = source_lines[err.line - 1] if err.line.positive? && err.line <= source_lines.length
25✔
186
            diagnostics << Diagnostic.new(
25✔
187
              code: "TR1003",
188
              message: err.message,
189
              file: file,
190
              line: err.line,
191
              column: err.column,
192
              source_line: source_line,
193
              severity: Diagnostic::SEVERITY_ERROR
194
            )
195
          end
196
        end
197
      rescue Scanner::ScanError
198
        # Scanner errors will be caught below in the main parse section
199
      rescue StandardError
200
        # Ignore TokenDeclarationParser errors for now - regex parser is authoritative
201
      end
202

203
      begin
204
        # Parse source with regex-based parser for IR generation
205
        parser = Parser.new(source)
94✔
206
        parser.parse
94✔
207

208
        # Run type checking if enabled and IR is available
209
        if type_check? && parser.ir_program
93✔
210
          begin
211
            check_types(parser.ir_program, file)
93✔
212
          rescue TypeCheckError => e
213
            diagnostics << Diagnostic.from_type_check_error(e, file: file, source: source)
12✔
214
          end
215
        end
216
      rescue ParseError => e
217
        diagnostics << Diagnostic.from_parse_error(e, file: file, source: source)
1✔
218
      rescue Scanner::ScanError => e
219
        diagnostics << Diagnostic.from_scan_error(e, file: file, source: source)
×
220
      rescue StandardError => e
221
        diagnostics << Diagnostic.new(
×
222
          code: "TR0001",
223
          message: e.message,
224
          file: file,
225
          line: 1,
226
          column: 1,
227
          severity: Diagnostic::SEVERITY_ERROR
228
        )
229
      end
230

231
      diagnostics
94✔
232
    end
233

234
    # Compile T-Ruby source code from a string (useful for WASM/playground)
235
    # @param source [String] T-Ruby source code
236
    # @param options [Hash] Options for compilation
237
    # @option options [Boolean] :rbs Whether to generate RBS output (default: true)
238
    # @return [Hash] Result with :ruby, :rbs, :errors keys
239
    def compile_string(source, options = {})
1✔
240
      generate_rbs = options.fetch(:rbs, true)
17✔
241

242
      parser = Parser.new(source)
17✔
243
      parser.parse
17✔
244

245
      # Transform source to Ruby code
246
      ruby_output = transform_with_ir(source, parser)
17✔
247

248
      # Generate RBS if requested
249
      rbs_output = ""
17✔
250
      if generate_rbs && parser.ir_program
17✔
251
        generator = IR::RBSGenerator.new
17✔
252
        rbs_output = generator.generate(parser.ir_program)
17✔
253
      end
254

255
      {
256
        ruby: ruby_output,
17✔
257
        rbs: rbs_output,
258
        errors: [],
259
      }
260
    rescue ParseError => e
261
      {
262
        ruby: "",
×
263
        rbs: "",
264
        errors: [e.message],
265
      }
266
    rescue StandardError => e
267
      {
268
        ruby: "",
×
269
        rbs: "",
270
        errors: ["Compilation error: #{e.message}"],
271
      }
272
    end
273

274
    # Compile to IR without generating output files
275
    def compile_to_ir(input_path)
1✔
276
      unless File.exist?(input_path)
4✔
277
        raise ArgumentError, "File not found: #{input_path}"
×
278
      end
279

280
      source = File.read(input_path)
4✔
281
      parser = Parser.new(source)
4✔
282
      parser.parse
4✔
283
      parser.ir_program
4✔
284
    end
285

286
    # Compile from IR program directly
287
    def compile_from_ir(ir_program, output_path)
1✔
288
      out_dir = File.dirname(output_path)
×
289
      FileUtils.mkdir_p(out_dir)
×
290

291
      # Optimize if enabled
292
      program = ir_program
×
293
      if @optimize && @optimizer
×
294
        result = @optimizer.optimize(program)
×
295
        program = result[:program]
×
296
      end
297

298
      # Generate Ruby code
299
      generator = IRCodeGenerator.new
×
300
      output = generator.generate(program)
×
301
      File.write(output_path, output)
×
302

303
      output_path
×
304
    end
305

306
    # Load external declarations from a file
307
    def load_declaration(name)
1✔
308
      @declaration_loader.load(name)
×
309
    end
310

311
    # Add a search path for declaration files
312
    def add_declaration_path(path)
1✔
313
      @declaration_loader.add_search_path(path)
×
314
    end
315

316
    # Get optimization statistics (only available after IR compilation)
317
    def optimization_stats
1✔
318
      @optimizer&.stats
×
319
    end
320

321
    # Compute output path for a source file
322
    # @param input_path [String] path to source file
323
    # @param output_dir [String] base output directory
324
    # @param new_extension [String] new file extension (e.g., ".rb", ".rbs")
325
    # @return [String] computed output path (always preserves directory structure)
326
    def compute_output_path(input_path, output_dir, new_extension)
1✔
327
      relative = compute_relative_path(input_path)
307✔
328
      base = relative.sub(/\.[^.]+$/, new_extension)
307✔
329
      File.join(output_dir, base)
307✔
330
    end
331

332
    # Compute relative path from source directory
333
    # @param input_path [String] path to source file
334
    # @return [String] relative path preserving directory structure
335
    def compute_relative_path(input_path)
1✔
336
      # Use realpath to resolve symlinks (e.g., /var vs /private/var on macOS)
337
      absolute_input = resolve_path(input_path)
307✔
338
      source_dirs = @config.source_include
307✔
339

340
      # Check if file is inside any source_include directory
341
      if source_dirs.size > 1
307✔
342
        # Multiple source directories: include the source dir name in output
343
        # src/models/user.trb → src/models/user.trb
344
        source_dirs.each do |src_dir|
4✔
345
          absolute_src = resolve_path(src_dir)
5✔
346
          next unless absolute_input.start_with?("#{absolute_src}/")
5✔
347

348
          # Return path relative to parent of source dir (includes src dir name)
349
          parent_of_src = File.dirname(absolute_src)
4✔
350
          return absolute_input.sub("#{parent_of_src}/", "")
4✔
351
        end
352
      else
353
        # Single source directory: exclude the source dir name from output
354
        # src/models/user.trb → models/user.trb
355
        src_dir = source_dirs.first
303✔
356
        if src_dir
303✔
357
          absolute_src = resolve_path(src_dir)
303✔
358
          if absolute_input.start_with?("#{absolute_src}/")
303✔
359
            return absolute_input.sub("#{absolute_src}/", "")
218✔
360
          end
361
        end
362
      end
363

364
      # File outside source directories: use path relative to current working directory
365
      # external/foo.trb → external/foo.trb
366
      cwd = resolve_path(".")
85✔
367
      if absolute_input.start_with?("#{cwd}/")
85✔
368
        return absolute_input.sub("#{cwd}/", "")
4✔
369
      end
370

371
      # Absolute path from outside cwd: use basename only
372
      File.basename(input_path)
81✔
373
    end
374

375
    private
1✔
376

377
    # Check types in IR program and raise TypeCheckError if mismatches found
378
    # @param ir_program [IR::Program] IR program to check
379
    # @param file_path [String] source file path for error messages
380
    def check_types(ir_program, file_path)
1✔
381
      ir_program.declarations.each do |decl|
224✔
382
        case decl
199✔
383
        when IR::MethodDef
384
          check_method_return_type(decl, nil, file_path)
164✔
385
          check_yield_arguments(decl, nil, file_path)
149✔
386
        when IR::ClassDecl
387
          decl.body.each do |member|
20✔
388
            next unless member.is_a?(IR::MethodDef)
29✔
389

390
            check_method_return_type(member, decl, file_path)
29✔
391
            check_yield_arguments(member, decl, file_path)
28✔
392
          end
393
        end
394
      end
395
    end
396

397
    # Check if method's inferred return type matches declared return type
398
    # @param method [IR::MethodDef] method to check
399
    # @param class_def [IR::ClassDef, nil] containing class if any
400
    # @param file_path [String] source file path for error messages
401
    def check_method_return_type(method, class_def, file_path)
1✔
402
      # Skip if no explicit return type annotation
403
      return unless method.return_type
193✔
404

405
      declared_type = normalize_type(method.return_type.to_rbs)
176✔
406

407
      # Create type environment for the class context
408
      class_env = create_class_env(class_def) if class_def
176✔
409

410
      # Infer actual return type
411
      inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
176✔
412
      inferred_type = normalize_type(inferred_type || "nil")
176✔
413

414
      # Check compatibility
415
      return if types_compatible?(inferred_type, declared_type)
176✔
416

417
      location = method.location ? "#{file_path}:#{method.location}" : file_path
16✔
418
      method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
16✔
419

420
      raise TypeCheckError.new(
16✔
421
        message: "Return type mismatch in method '#{method_name}': " \
422
                 "declared '#{declared_type}' but inferred '#{inferred_type}'",
423
        location: location,
424
        expected: declared_type,
425
        actual: inferred_type
426
      )
427
    end
428

429
    # Check yield statements match block parameter signature
430
    # @param method [IR::MethodDef] method to check
431
    # @param class_def [IR::ClassDecl, nil] containing class if any
432
    # @param file_path [String] source file path for error messages
433
    def check_yield_arguments(method, class_def, file_path)
1✔
434
      # Find block parameter with FunctionType annotation
435
      block_param = method.params.find { |p| p.kind == :block }
285✔
436
      return unless block_param&.type_annotation.is_a?(IR::FunctionType)
177✔
437

438
      block_type = block_param.type_annotation
8✔
439
      expected_arg_count = block_type.param_types.length
8✔
440

441
      # Find all yield statements in method body
442
      yields = find_yields_in_body(method.body)
8✔
443
      return if yields.empty?
8✔
444

445
      location = method.location ? "#{file_path}:#{method.location}" : file_path
5✔
446
      method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
5✔
447

448
      yields.each do |yield_node|
5✔
449
        actual_arg_count = yield_node.arguments.length
7✔
450

451
        next if actual_arg_count == expected_arg_count
7✔
452

453
        raise TypeCheckError.new(
1✔
454
          message: "Yield argument count mismatch in '#{method_name}': " \
455
                   "block expects #{expected_arg_count} argument(s) but yield passes #{actual_arg_count}",
456
          location: location,
457
          expected: "#{expected_arg_count} argument(s)",
458
          actual: "#{actual_arg_count} argument(s)"
459
        )
460
      end
461
    end
462

463
    # Find all yield nodes in a method body
464
    # @param node [IR::Node] IR node to search
465
    # @return [Array<IR::Yield>] yield nodes found
466
    def find_yields_in_body(node)
1✔
467
      yields = []
20✔
468
      return yields unless node
20✔
469

470
      case node
18✔
471
      when IR::Yield
472
        yields << node
7✔
473
      when IR::Block
474
        node.statements.each { |stmt| yields.concat(find_yields_in_body(stmt)) }
18✔
475
      when IR::Conditional
476
        yields.concat(find_yields_in_body(node.then_branch))
1✔
477
        yields.concat(find_yields_in_body(node.else_branch))
1✔
478
      when IR::Loop
NEW
479
        yields.concat(find_yields_in_body(node.body))
×
480
      when IR::Assignment
NEW
481
        yields.concat(find_yields_in_body(node.value))
×
482
      when IR::Return
NEW
483
        yields.concat(find_yields_in_body(node.value))
×
484
      end
485

486
      yields
18✔
487
    end
488

489
    # Create type environment for class context
490
    # @param class_def [IR::ClassDecl] class declaration
491
    # @return [TypeEnv] type environment with instance variables
492
    def create_class_env(class_def)
1✔
493
      env = TypeEnv.new
18✔
494

495
      # Register instance variables from class
496
      class_def.instance_vars&.each do |ivar|
18✔
497
        type = ivar.type_annotation&.to_rbs || "untyped"
6✔
498
        env.define_instance_var(ivar.name, type)
6✔
499
      end
500

501
      env
18✔
502
    end
503

504
    # Normalize type string for comparison
505
    # @param type [String] type string
506
    # @return [String] normalized type string
507
    def normalize_type(type)
1✔
508
      return "untyped" if type.nil?
352✔
509

510
      normalized = type.to_s.strip
352✔
511

512
      # Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool)
513
      case normalized
352✔
514
      when "Boolean", "TrueClass", "FalseClass"
515
        "bool"
2✔
516
      else
517
        normalized
350✔
518
      end
519
    end
520

521
    # Check if inferred type is compatible with declared type
522
    # @param inferred [String] inferred type
523
    # @param declared [String] declared type
524
    # @return [Boolean] true if compatible
525
    def types_compatible?(inferred, declared)
1✔
526
      # Exact match
527
      return true if inferred == declared
176✔
528

529
      # untyped is compatible with anything
530
      return true if inferred == "untyped" || declared == "untyped"
107✔
531

532
      # void is compatible with anything (no return value check)
533
      return true if declared == "void"
68✔
534

535
      # nil is compatible with nullable types
536
      return true if inferred == "nil" && declared.end_with?("?")
25✔
537

538
      # Subtype relationships
539
      return true if subtype_of?(inferred, declared)
23✔
540

541
      # Handle generic types (e.g., Array[untyped] is compatible with Array[String])
542
      if inferred.include?("[") && declared.include?("[")
23✔
543
        inferred_base = inferred.split("[").first
4✔
544
        declared_base = declared.split("[").first
4✔
545
        if inferred_base == declared_base
4✔
546
          # Extract type arguments
547
          inferred_args = inferred[/\[(.+)\]/, 1]
4✔
548
          declared_args = declared[/\[(.+)\]/, 1]
4✔
549
          # untyped type argument is compatible with any type argument
550
          return true if inferred_args == "untyped" || declared_args == "untyped"
4✔
551
        end
552
      end
553

554
      # Handle union types in declared
555
      if declared.include?("|")
19✔
556
        declared_types = declared.split("|").map(&:strip)
3✔
557
        return true if declared_types.include?(inferred)
3✔
558
        return true if declared_types.any? { |t| types_compatible?(inferred, t) }
×
559
      end
560

561
      # Handle union types in inferred - all must be compatible
562
      if inferred.include?("|")
16✔
563
        inferred_types = inferred.split("|").map(&:strip)
×
564
        return inferred_types.all? { |t| types_compatible?(t, declared) }
×
565
      end
566

567
      false
16✔
568
    end
569

570
    # Check if subtype is a subtype of supertype
571
    # @param subtype [String] potential subtype
572
    # @param supertype [String] potential supertype
573
    # @return [Boolean] true if subtype
574
    def subtype_of?(subtype, supertype)
1✔
575
      # Handle nullable - X is subtype of X?
576
      return true if supertype.end_with?("?") && supertype[0..-2] == subtype
23✔
577

578
      # Numeric hierarchy
579
      return true if subtype == "Integer" && supertype == "Numeric"
23✔
580
      return true if subtype == "Float" && supertype == "Numeric"
23✔
581

582
      # Object is supertype of everything
583
      return true if supertype == "Object"
23✔
584

585
      false
23✔
586
    end
587

588
    # Resolve path to absolute path, following symlinks
589
    # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
590
    def resolve_path(path)
1✔
591
      File.realpath(path)
700✔
592
    rescue Errno::ENOENT
593
      File.expand_path(path)
84✔
594
    end
595

596
    def setup_declaration_paths
1✔
597
      # Add default declaration paths
598
      @declaration_loader.add_search_path(@config.out_dir)
359✔
599
      @declaration_loader.add_search_path(@config.src_dir)
359✔
600
      @declaration_loader.add_search_path("./types")
359✔
601
      @declaration_loader.add_search_path("./lib/types")
359✔
602
    end
603

604
    # Transform using IR system
605
    def transform_with_ir(source, parser)
1✔
606
      ir_program = parser.ir_program
175✔
607
      return source unless ir_program
175✔
608

609
      # Run optimization passes if enabled
610
      if @optimize && @optimizer
175✔
611
        result = @optimizer.optimize(ir_program)
175✔
612
        ir_program = result[:program]
175✔
613
      end
614

615
      # Generate Ruby code using IR-aware generator with target Ruby version
616
      generator = IRCodeGenerator.new(target_ruby: @config.target_ruby)
175✔
617
      generator.generate_with_source(ir_program, source)
175✔
618
    end
619

620
    # Generate RBS from IR to a specific path
621
    def generate_rbs_from_ir_to_path(rbs_path, ir_program)
1✔
622
      return unless ir_program
144✔
623

624
      generator = IR::RBSGenerator.new
144✔
625
      rbs_content = generator.generate(ir_program)
144✔
626
      File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
144✔
627
    end
628

629
    def generate_dtrb_file(input_path, out_dir)
1✔
630
      dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
×
631
      FileUtils.mkdir_p(File.dirname(dtrb_path))
×
632

633
      generator = DeclarationGenerator.new
×
634
      generator.generate_file_to_path(input_path, dtrb_path)
×
635
    end
636

637
    # Copy .rb file to output directory and generate .rbs signature
638
    def copy_ruby_file(input_path)
1✔
639
      unless File.exist?(input_path)
3✔
640
        raise ArgumentError, "File not found: #{input_path}"
×
641
      end
642

643
      # Compute output path (respects preserve_structure setting)
644
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
3✔
645
      FileUtils.mkdir_p(File.dirname(output_path))
3✔
646

647
      # Copy the .rb file to output directory
648
      FileUtils.cp(input_path, output_path)
3✔
649

650
      # Generate .rbs file if enabled in config
651
      if @config.compiler["generate_rbs"]
3✔
652
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
2✔
653
        FileUtils.mkdir_p(File.dirname(rbs_path))
2✔
654
        generate_rbs_from_ruby_to_path(rbs_path, input_path)
2✔
655
      end
656

657
      output_path
3✔
658
    end
659

660
    # Generate RBS from Ruby file using rbs prototype to a specific path
661
    def generate_rbs_from_ruby_to_path(rbs_path, input_path)
1✔
662
      result = `rbs prototype rb #{input_path} 2>/dev/null`
2✔
663
      File.write(rbs_path, result) unless result.strip.empty?
2✔
664
    end
665
  end
666

667
  # IR-aware code generator for source-preserving transformation
668
  class IRCodeGenerator
1✔
669
    attr_reader :emitter
1✔
670

671
    # @param target_ruby [String] target Ruby version (e.g., "3.0", "4.0")
672
    def initialize(target_ruby: "3.0")
1✔
673
      @output = []
175✔
674
      @emitter = CodeEmitter.for_version(target_ruby)
175✔
675
    end
676

677
    # Generate Ruby code from IR program
678
    def generate(program)
1✔
679
      generator = IR::CodeGenerator.new
×
680
      generator.generate(program)
×
681
    end
682

683
    # Generate Ruby code while preserving source structure
684
    def generate_with_source(program, source)
1✔
685
      result = source.dup
175✔
686

687
      # Collect type alias names to remove
688
      program.declarations
175✔
689
             .select { |d| d.is_a?(IR::TypeAlias) }
159✔
690
             .map(&:name)
691

692
      # Collect interface names to remove
693
      program.declarations
175✔
694
             .select { |d| d.is_a?(IR::Interface) }
159✔
695
             .map(&:name)
696

697
      # Remove type alias definitions
698
      result = result.gsub(/^\s*type\s+\w+\s*=\s*.+?$\n?/, "")
175✔
699

700
      # Remove interface definitions (multi-line)
701
      result = result.gsub(/^\s*interface\s+\w+.*?^\s*end\s*$/m, "")
175✔
702

703
      # Remove parameter type annotations using IR info
704
      # Enhanced: Handle complex types (generics, unions, etc.)
705
      result = erase_parameter_types(result)
175✔
706

707
      # Remove return type annotations
708
      result = erase_return_types(result)
175✔
709

710
      # Apply version-specific transformations
711
      result = @emitter.transform(result)
175✔
712

713
      # Clean up extra blank lines
714
      result.gsub(/\n{3,}/, "\n\n")
175✔
715
    end
716

717
    private
1✔
718

719
    # Erase parameter type annotations
720
    def erase_parameter_types(source)
1✔
721
      result = source.dup
175✔
722

723
      # Process each method definition individually
724
      # to handle nested parentheses in types like Proc(Integer) -> String
725
      lines = result.lines
175✔
726
      output_lines = []
175✔
727
      i = 0
175✔
728

729
      while i < lines.length
175✔
730
        line = lines[i]
10,617✔
731

732
        # Check if line starts a method definition
733
        if (match = line.match(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()/))
10,617✔
734
          prefix = match[1]
133✔
735
          rest_of_line = line[match.end(0)..]
133✔
736
          start_line = i
133✔
737

738
          # Collect the full parameter section (may span multiple lines)
739
          param_text = rest_of_line
133✔
740
          paren_depth = 1
133✔
741
          brace_depth = 0
133✔
742
          bracket_depth = 0
133✔
743
          found_close = false
133✔
744

745
          # Find matching closing paren within current line first
746
          param_text.each_char do |char|
133✔
747
            case char
2,052✔
748
            when "(" then paren_depth += 1
7✔
749
            when ")"
750
              paren_depth -= 1
140✔
751
              if paren_depth.zero? && brace_depth.zero?
140✔
752
                found_close = true
133✔
753
                break
133✔
754
              end
755
            when "{" then brace_depth += 1
17✔
756
            when "}" then brace_depth -= 1
17✔
757
            when "[" then bracket_depth += 1
5✔
758
            when "]" then bracket_depth -= 1
5✔
759
            end
760
          end
761

762
          # If not found, continue to next lines
763
          while !found_close && i + 1 < lines.length
133✔
NEW
764
            i += 1
×
NEW
765
            param_text += lines[i]
×
NEW
766
            lines[i].each_char do |char|
×
NEW
767
              case char
×
NEW
768
              when "(" then paren_depth += 1
×
769
              when ")"
NEW
770
                paren_depth -= 1
×
NEW
771
                if paren_depth.zero? && brace_depth.zero?
×
NEW
772
                  found_close = true
×
NEW
773
                  break
×
774
                end
NEW
775
              when "{" then brace_depth += 1
×
NEW
776
              when "}" then brace_depth -= 1
×
NEW
777
              when "[" then bracket_depth += 1
×
NEW
778
              when "]" then bracket_depth -= 1
×
779
              end
780
            end
781
          end
782

783
          # Extract params and remainder
784
          params, remainder = extract_balanced_params(param_text)
133✔
785

786
          if params && found_close
133✔
787
            cleaned_params = remove_param_types(params)
133✔
788
            remainder = remainder.sub(/^\s*:\s*[^\n]+/, "")
133✔
789
            output_lines << "#{prefix}#{cleaned_params})#{remainder}"
133✔
790
          else
791
            # Couldn't process, keep original lines
NEW
792
            (start_line..i).each { |j| output_lines << lines[j] }
×
793
          end
794
        else
795
          output_lines << line
10,484✔
796
        end
797

798
        i += 1
10,617✔
799
      end
800

801
      output_lines.join
175✔
802
    end
803

804
    # Extract parameters from string, handling nested parentheses and braces
805
    # Returns [params_string, remainder] or [nil, nil] if no match
806
    def extract_balanced_params(str)
1✔
807
      paren_depth = 1  # We're already past the opening paren
133✔
808
      brace_depth = 0
133✔
809
      bracket_depth = 0
133✔
810
      pos = 0
133✔
811

812
      str.each_char.with_index do |char, i|
133✔
813
        case char
2,052✔
814
        when "("
815
          paren_depth += 1
7✔
816
        when ")"
817
          paren_depth -= 1
140✔
818
          if paren_depth.zero? && brace_depth.zero? && bracket_depth.zero?
140✔
819
            pos = i
133✔
820
            break
133✔
821
          end
822
        when "{"
823
          brace_depth += 1
17✔
824
        when "}"
825
          brace_depth -= 1
17✔
826
        when "["
827
          bracket_depth += 1
5✔
828
        when "]"
829
          bracket_depth -= 1
5✔
830
        end
831
      end
832

833
      return [nil, nil] if paren_depth != 0
133✔
834

835
      [str[0...pos], str[(pos + 1)..]]
133✔
836
    end
837

838
    # Remove type annotations from parameter list
839
    def remove_param_types(params_str)
1✔
840
      return params_str if params_str.strip.empty?
133✔
841

842
      params = []
95✔
843
      current = ""
95✔
844
      depth = 0
95✔
845
      brace_depth = 0
95✔
846

847
      params_str.each_char do |char|
95✔
848
        case char
1,919✔
849
        when "<", "[", "("
850
          depth += 1
21✔
851
          current += char
21✔
852
        when ">", "]", ")"
853
          depth -= 1
28✔
854
          current += char
28✔
855
        when "{"
856
          brace_depth += 1
17✔
857
          current += char
17✔
858
        when "}"
859
          brace_depth -= 1
17✔
860
          current += char
17✔
861
        when ","
862
          if depth.zero? && brace_depth.zero?
31✔
863
            cleaned = clean_param(current.strip)
16✔
864
            params.concat(Array(cleaned)) if cleaned
16✔
865
            current = ""
16✔
866
          else
867
            current += char
15✔
868
          end
869
        else
870
          current += char
1,805✔
871
        end
872
      end
873

874
      cleaned = clean_param(current.strip) unless current.empty?
95✔
875
      params.concat(Array(cleaned)) if cleaned
95✔
876
      params.join(", ")
95✔
877
    end
878

879
    # Clean a single parameter (remove type annotation, preserve default value)
880
    # Returns String or Array of Strings (for keyword args group)
881
    def clean_param(param)
1✔
882
      param = param.strip
111✔
883
      return nil if param.empty?
111✔
884

885
      # 0. 블록 파라미터: &name: Type -> &name
886
      if param.start_with?("&")
111✔
887
        match = param.match(/^&(\w+)(?::\s*.+)?$/)
11✔
888
        return "&#{match[1]}" if match
11✔
889

890
        return param
1✔
891
      end
892

893
      # 1. 더블 스플랫: **name: Type -> **name
894
      if param.start_with?("**")
100✔
895
        match = param.match(/^\*\*(\w+)(?::\s*.+)?$/)
2✔
896
        return "**#{match[1]}" if match
2✔
897

898
        return param
×
899
      end
900

901
      # 2. 키워드 인자 그룹: { ... } 또는 { ... }: InterfaceName
902
      if param.start_with?("{")
98✔
903
        return clean_keyword_args_group(param)
13✔
904
      end
905

906
      # 3. Hash 리터럴: name: { ... } -> name
907
      if param.match?(/^\w+:\s*\{/)
85✔
908
        match = param.match(/^(\w+):\s*\{.+\}(?::\s*\w+)?$/)
3✔
909
        return match[1] if match
3✔
910

911
        return param
×
912
      end
913

914
      # 4. 일반 파라미터: name: Type = value -> name = value 또는 name: Type -> name
915
      # Match: name: Type = value (with default value)
916
      if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:\s*.+?\s*(=\s*.+)$/))
82✔
917
        "#{match[1]} #{match[2]}"
2✔
918
      # Match: name: Type (without default value)
919
      elsif (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:/))
80✔
920
        match[1]
77✔
921
      else
922
        param
3✔
923
      end
924
    end
925

926
    # 키워드 인자 그룹을 Ruby 키워드 인자로 변환
927
    # { name: String, age: Integer = 0 } -> name:, age: 0
928
    # { name:, age: 0 }: UserParams -> name:, age: 0
929
    def clean_keyword_args_group(param)
1✔
930
      # { ... }: InterfaceName 또는 { ... } 형태 파싱
931
      interface_match = param.match(/^\{(.+)\}\s*:\s*\w+\s*$/)
13✔
932
      inline_match = param.match(/^\{(.+)\}\s*$/) unless interface_match
13✔
933

934
      inner_content = if interface_match
13✔
935
                        interface_match[1]
3✔
936
                      elsif inline_match
10✔
937
                        inline_match[1]
10✔
938
                      else
939
                        return param
×
940
                      end
941

942
      # 내부 파라미터 분리
943
      parts = split_nested_content(inner_content)
13✔
944
      keyword_params = []
13✔
945

946
      parts.each do |part|
13✔
947
        part = part.strip
23✔
948
        next if part.empty?
23✔
949

950
        if interface_match
23✔
951
          # interface 참조: name: default_value 또는 name:
952
          if (match = part.match(/^(\w+):\s*(.*)$/))
7✔
953
            name = match[1]
7✔
954
            default_value = match[2].strip
7✔
955
            keyword_params << if default_value.empty?
7✔
956
                                "#{name}:"
4✔
957
                              else
958
                                "#{name}: #{default_value}"
3✔
959
                              end
960
          end
961
        elsif (match = part.match(/^(\w+):\s*(.+)$/))
16✔
962
          # 인라인 타입: name: Type = default 또는 name: Type
963
          name = match[1]
16✔
964
          type_and_default = match[2].strip
16✔
965

966
          # Type = default 분리
967
          default_value = extract_default_value(type_and_default)
16✔
968
          keyword_params << if default_value
16✔
969
                              "#{name}: #{default_value}"
8✔
970
                            else
971
                              "#{name}:"
8✔
972
                            end
973
        end
974
      end
975

976
      keyword_params
13✔
977
    end
978

979
    # 중첩된 내용을 콤마로 분리
980
    def split_nested_content(content)
1✔
981
      StringUtils.split_by_comma(content)
13✔
982
    end
983

984
    # 타입과 기본값에서 기본값만 추출
985
    def extract_default_value(type_and_default)
1✔
986
      StringUtils.extract_default_value(type_and_default)
16✔
987
    end
988

989
    # Erase return type annotations
990
    def erase_return_types(source)
1✔
991
      result = source.dup
175✔
992

993
      # Remove return type after parentheses: ): Type or ): Type<Foo> etc.
994
      result.gsub!(/\)\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
175✔
995
        ")"
3✔
996
      end
997

998
      # Remove return type for methods without parentheses: def method_name: Type
999
      result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN})\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
175✔
1000
        ::Regexp.last_match(1)
20✔
1001
      end
1002

1003
      result
175✔
1004
    end
1005
  end
1006
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