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

type-ruby / t-ruby / 20569579414

29 Dec 2025 09:31AM UTC coverage: 80.005% (+0.9%) from 79.076%
20569579414

Pull #30

github

web-flow
Merge 6db8c3a50 into a7c451da7
Pull Request #30: feat: improve error messages with tsc-style diagnostics

525 of 636 new or added lines in 14 files covered. (82.55%)

10 existing lines in 4 files now uncovered.

7110 of 8887 relevant lines covered (80.0%)

896.37 hits per line

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

90.56
/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
193✔
18
      @optimize = optimize
193✔
19
      @declaration_loader = DeclarationLoader.new
193✔
20
      @optimizer = IR::Optimizer.new if optimize
193✔
21
      @type_inferrer = ASTTypeInferrer.new if type_check?
193✔
22
      setup_declaration_paths if @config
193✔
23
    end
24

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

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

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

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

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

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

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

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

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

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

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

76
      output_path
126✔
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
50✔
85
      all_diagnostics = []
50✔
86

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

92
      begin
93
        output_path = compile(input_path)
50✔
94
        # Compilation succeeded, but we may still have diagnostics from analyze
95
        {
96
          success: all_diagnostics.empty?,
37✔
97
          output_path: all_diagnostics.empty? ? output_path : nil,
37✔
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✔
NEW
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 = []
69✔
153
      source_lines = source.split("\n")
69✔
154

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

162
        line_num = Regexp.last_match(1).to_i
8✔
163
        message = Regexp.last_match(2)
8✔
164
        source_line = source_lines[line_num - 1] if line_num.positive?
8✔
165
        diagnostics << Diagnostic.new(
8✔
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)
69✔
179
        tokens = scanner.scan_all
69✔
180
        decl_parser = ParserCombinator::TokenDeclarationParser.new
67✔
181
        decl_parser.parse_program(tokens)
67✔
182

183
        if decl_parser.has_errors?
67✔
184
          decl_parser.errors.each do |err|
15✔
185
            source_line = source_lines[err.line - 1] if err.line.positive? && err.line <= source_lines.length
17✔
186
            diagnostics << Diagnostic.new(
17✔
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)
69✔
206
        parser.parse
69✔
207

208
        # Run type checking if enabled and IR is available
209
        if type_check? && parser.ir_program
68✔
210
          begin
211
            check_types(parser.ir_program, file)
68✔
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
NEW
219
        diagnostics << Diagnostic.from_scan_error(e, file: file, source: source)
×
220
      rescue StandardError => e
NEW
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
69✔
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)
1✔
241

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

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

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

255
      {
256
        ruby: ruby_output,
1✔
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)
244✔
328
      base = relative.sub(/\.[^.]+$/, new_extension)
244✔
329
      File.join(output_dir, base)
244✔
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)
244✔
338
      source_dirs = @config.source_include
244✔
339

340
      # Check if file is inside any source_include directory
341
      if source_dirs.size > 1
244✔
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
240✔
356
        if src_dir
240✔
357
          absolute_src = resolve_path(src_dir)
240✔
358
          if absolute_input.start_with?("#{absolute_src}/")
240✔
359
            return absolute_input.sub("#{absolute_src}/", "")
169✔
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(".")
71✔
367
      if absolute_input.start_with?("#{cwd}/")
71✔
368
        return absolute_input.sub("#{cwd}/", "")
4✔
369
      end
370

371
      # Absolute path from outside cwd: use basename only
372
      File.basename(input_path)
67✔
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|
182✔
382
        case decl
169✔
383
        when IR::MethodDef
384
          check_method_return_type(decl, nil, file_path)
140✔
385
        when IR::ClassDecl
386
          decl.body.each do |member|
18✔
387
            check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef)
25✔
388
          end
389
        end
390
      end
391
    end
392

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

401
      declared_type = normalize_type(method.return_type.to_rbs)
150✔
402

403
      # Create type environment for the class context
404
      class_env = create_class_env(class_def) if class_def
150✔
405

406
      # Infer actual return type
407
      inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
150✔
408
      inferred_type = normalize_type(inferred_type || "nil")
150✔
409

410
      # Check compatibility
411
      return if types_compatible?(inferred_type, declared_type)
150✔
412

413
      location = method.location ? "#{file_path}:#{method.location}" : file_path
16✔
414
      method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
16✔
415

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

425
    # Create type environment for class context
426
    # @param class_def [IR::ClassDecl] class declaration
427
    # @return [TypeEnv] type environment with instance variables
428
    def create_class_env(class_def)
1✔
429
      env = TypeEnv.new
14✔
430

431
      # Register instance variables from class
432
      class_def.instance_vars&.each do |ivar|
14✔
433
        type = ivar.type_annotation&.to_rbs || "untyped"
6✔
434
        env.define_instance_var(ivar.name, type)
6✔
435
      end
436

437
      env
14✔
438
    end
439

440
    # Normalize type string for comparison
441
    # @param type [String] type string
442
    # @return [String] normalized type string
443
    def normalize_type(type)
1✔
444
      return "untyped" if type.nil?
300✔
445

446
      normalized = type.to_s.strip
300✔
447

448
      # Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool)
449
      case normalized
300✔
450
      when "Boolean", "TrueClass", "FalseClass"
451
        "bool"
2✔
452
      else
453
        normalized
298✔
454
      end
455
    end
456

457
    # Check if inferred type is compatible with declared type
458
    # @param inferred [String] inferred type
459
    # @param declared [String] declared type
460
    # @return [Boolean] true if compatible
461
    def types_compatible?(inferred, declared)
1✔
462
      # Exact match
463
      return true if inferred == declared
150✔
464

465
      # untyped is compatible with anything
466
      return true if inferred == "untyped" || declared == "untyped"
81✔
467

468
      # void is compatible with anything (no return value check)
469
      return true if declared == "void"
48✔
470

471
      # nil is compatible with nullable types
472
      return true if inferred == "nil" && declared.end_with?("?")
23✔
473

474
      # Subtype relationships
475
      return true if subtype_of?(inferred, declared)
21✔
476

477
      # Handle generic types (e.g., Array[untyped] is compatible with Array[String])
478
      if inferred.include?("[") && declared.include?("[")
21✔
479
        inferred_base = inferred.split("[").first
2✔
480
        declared_base = declared.split("[").first
2✔
481
        if inferred_base == declared_base
2✔
482
          # Extract type arguments
483
          inferred_args = inferred[/\[(.+)\]/, 1]
2✔
484
          declared_args = declared[/\[(.+)\]/, 1]
2✔
485
          # untyped type argument is compatible with any type argument
486
          return true if inferred_args == "untyped" || declared_args == "untyped"
2✔
487
        end
488
      end
489

490
      # Handle union types in declared
491
      if declared.include?("|")
19✔
492
        declared_types = declared.split("|").map(&:strip)
3✔
493
        return true if declared_types.include?(inferred)
3✔
494
        return true if declared_types.any? { |t| types_compatible?(inferred, t) }
×
495
      end
496

497
      # Handle union types in inferred - all must be compatible
498
      if inferred.include?("|")
16✔
499
        inferred_types = inferred.split("|").map(&:strip)
×
500
        return inferred_types.all? { |t| types_compatible?(t, declared) }
×
501
      end
502

503
      false
16✔
504
    end
505

506
    # Check if subtype is a subtype of supertype
507
    # @param subtype [String] potential subtype
508
    # @param supertype [String] potential supertype
509
    # @return [Boolean] true if subtype
510
    def subtype_of?(subtype, supertype)
1✔
511
      # Handle nullable - X is subtype of X?
512
      return true if supertype.end_with?("?") && supertype[0..-2] == subtype
21✔
513

514
      # Numeric hierarchy
515
      return true if subtype == "Integer" && supertype == "Numeric"
21✔
516
      return true if subtype == "Float" && supertype == "Numeric"
21✔
517

518
      # Object is supertype of everything
519
      return true if supertype == "Object"
21✔
520

521
      false
21✔
522
    end
523

524
    # Resolve path to absolute path, following symlinks
525
    # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
526
    def resolve_path(path)
1✔
527
      File.realpath(path)
560✔
528
    rescue Errno::ENOENT
529
      File.expand_path(path)
70✔
530
    end
531

532
    def setup_declaration_paths
1✔
533
      # Add default declaration paths
534
      @declaration_loader.add_search_path(@config.out_dir)
193✔
535
      @declaration_loader.add_search_path(@config.src_dir)
193✔
536
      @declaration_loader.add_search_path("./types")
193✔
537
      @declaration_loader.add_search_path("./lib/types")
193✔
538
    end
539

540
    # Transform using IR system
541
    def transform_with_ir(source, parser)
1✔
542
      ir_program = parser.ir_program
127✔
543
      return source unless ir_program
127✔
544

545
      # Run optimization passes if enabled
546
      if @optimize && @optimizer
127✔
547
        result = @optimizer.optimize(ir_program)
127✔
548
        ir_program = result[:program]
127✔
549
      end
550

551
      # Generate Ruby code using IR-aware generator with target Ruby version
552
      generator = IRCodeGenerator.new(target_ruby: @config.target_ruby)
127✔
553
      generator.generate_with_source(ir_program, source)
127✔
554
    end
555

556
    # Generate RBS from IR to a specific path
557
    def generate_rbs_from_ir_to_path(rbs_path, ir_program)
1✔
558
      return unless ir_program
113✔
559

560
      generator = IR::RBSGenerator.new
113✔
561
      rbs_content = generator.generate(ir_program)
113✔
562
      File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
113✔
563
    end
564

565
    def generate_dtrb_file(input_path, out_dir)
1✔
566
      dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
×
567
      FileUtils.mkdir_p(File.dirname(dtrb_path))
×
568

569
      generator = DeclarationGenerator.new
×
570
      generator.generate_file_to_path(input_path, dtrb_path)
×
571
    end
572

573
    # Copy .rb file to output directory and generate .rbs signature
574
    def copy_ruby_file(input_path)
1✔
575
      unless File.exist?(input_path)
3✔
576
        raise ArgumentError, "File not found: #{input_path}"
×
577
      end
578

579
      # Compute output path (respects preserve_structure setting)
580
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
3✔
581
      FileUtils.mkdir_p(File.dirname(output_path))
3✔
582

583
      # Copy the .rb file to output directory
584
      FileUtils.cp(input_path, output_path)
3✔
585

586
      # Generate .rbs file if enabled in config
587
      if @config.compiler["generate_rbs"]
3✔
588
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
2✔
589
        FileUtils.mkdir_p(File.dirname(rbs_path))
2✔
590
        generate_rbs_from_ruby_to_path(rbs_path, input_path)
2✔
591
      end
592

593
      output_path
3✔
594
    end
595

596
    # Generate RBS from Ruby file using rbs prototype to a specific path
597
    def generate_rbs_from_ruby_to_path(rbs_path, input_path)
1✔
598
      result = `rbs prototype rb #{input_path} 2>/dev/null`
2✔
599
      File.write(rbs_path, result) unless result.strip.empty?
2✔
600
    end
601
  end
602

603
  # IR-aware code generator for source-preserving transformation
604
  class IRCodeGenerator
1✔
605
    attr_reader :emitter
1✔
606

607
    # @param target_ruby [String] target Ruby version (e.g., "3.0", "4.0")
608
    def initialize(target_ruby: "3.0")
1✔
609
      @output = []
127✔
610
      @emitter = CodeEmitter.for_version(target_ruby)
127✔
611
    end
612

613
    # Generate Ruby code from IR program
614
    def generate(program)
1✔
615
      generator = IR::CodeGenerator.new
×
616
      generator.generate(program)
×
617
    end
618

619
    # Generate Ruby code while preserving source structure
620
    def generate_with_source(program, source)
1✔
621
      result = source.dup
127✔
622

623
      # Collect type alias names to remove
624
      program.declarations
127✔
625
             .select { |d| d.is_a?(IR::TypeAlias) }
123✔
626
             .map(&:name)
627

628
      # Collect interface names to remove
629
      program.declarations
127✔
630
             .select { |d| d.is_a?(IR::Interface) }
123✔
631
             .map(&:name)
632

633
      # Remove type alias definitions
634
      result = result.gsub(/^\s*type\s+\w+\s*=\s*.+?$\n?/, "")
127✔
635

636
      # Remove interface definitions (multi-line)
637
      result = result.gsub(/^\s*interface\s+\w+.*?^\s*end\s*$/m, "")
127✔
638

639
      # Remove parameter type annotations using IR info
640
      # Enhanced: Handle complex types (generics, unions, etc.)
641
      result = erase_parameter_types(result)
127✔
642

643
      # Remove return type annotations
644
      result = erase_return_types(result)
127✔
645

646
      # Apply version-specific transformations
647
      result = @emitter.transform(result)
127✔
648

649
      # Clean up extra blank lines
650
      result.gsub(/\n{3,}/, "\n\n")
127✔
651
    end
652

653
    private
1✔
654

655
    # Erase parameter type annotations
656
    def erase_parameter_types(source)
1✔
657
      result = source.dup
127✔
658

659
      # Match function definitions and remove type annotations from parameters
660
      # Also supports visibility modifiers: private def, protected def, public def
661
      result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
127✔
662
        indent = ::Regexp.last_match(1)
79✔
663
        params = ::Regexp.last_match(2)
79✔
664
        close_paren = ::Regexp.last_match(3)
79✔
665
        ending = ::Regexp.last_match(4)
79✔
666

667
        # Remove type annotations from each parameter
668
        cleaned_params = remove_param_types(params)
79✔
669

670
        "#{indent}#{cleaned_params}#{close_paren.rstrip}#{ending}"
79✔
671
      end
672

673
      result
127✔
674
    end
675

676
    # Remove type annotations from parameter list
677
    def remove_param_types(params_str)
1✔
678
      return params_str if params_str.strip.empty?
79✔
679

680
      params = []
79✔
681
      current = ""
79✔
682
      depth = 0
79✔
683
      brace_depth = 0
79✔
684

685
      params_str.each_char do |char|
79✔
686
        case char
1,517✔
687
        when "<", "[", "("
688
          depth += 1
10✔
689
          current += char
10✔
690
        when ">", "]", ")"
691
          depth -= 1
10✔
692
          current += char
10✔
693
        when "{"
694
          brace_depth += 1
17✔
695
          current += char
17✔
696
        when "}"
697
          brace_depth -= 1
17✔
698
          current += char
17✔
699
        when ","
700
          if depth.zero? && brace_depth.zero?
26✔
701
            cleaned = clean_param(current.strip)
12✔
702
            params.concat(Array(cleaned)) if cleaned
12✔
703
            current = ""
12✔
704
          else
705
            current += char
14✔
706
          end
707
        else
708
          current += char
1,437✔
709
        end
710
      end
711

712
      cleaned = clean_param(current.strip) unless current.empty?
79✔
713
      params.concat(Array(cleaned)) if cleaned
79✔
714
      params.join(", ")
79✔
715
    end
716

717
    # Clean a single parameter (remove type annotation, preserve default value)
718
    # Returns String or Array of Strings (for keyword args group)
719
    def clean_param(param)
1✔
720
      param = param.strip
91✔
721
      return nil if param.empty?
91✔
722

723
      # 0. 블록 파라미터: &name: Type -> &name
724
      if param.start_with?("&")
91✔
725
        match = param.match(/^&(\w+)(?::\s*.+)?$/)
4✔
726
        return "&#{match[1]}" if match
4✔
727

728
        return param
×
729
      end
730

731
      # 1. 더블 스플랫: **name: Type -> **name
732
      if param.start_with?("**")
87✔
733
        match = param.match(/^\*\*(\w+)(?::\s*.+)?$/)
2✔
734
        return "**#{match[1]}" if match
2✔
735

736
        return param
×
737
      end
738

739
      # 2. 키워드 인자 그룹: { ... } 또는 { ... }: InterfaceName
740
      if param.start_with?("{")
85✔
741
        return clean_keyword_args_group(param)
13✔
742
      end
743

744
      # 3. Hash 리터럴: name: { ... } -> name
745
      if param.match?(/^\w+:\s*\{/)
72✔
746
        match = param.match(/^(\w+):\s*\{.+\}(?::\s*\w+)?$/)
3✔
747
        return match[1] if match
3✔
748

749
        return param
×
750
      end
751

752
      # 4. 일반 파라미터: name: Type = value -> name = value 또는 name: Type -> name
753
      # Match: name: Type = value (with default value)
754
      if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:\s*.+?\s*(=\s*.+)$/))
69✔
755
        "#{match[1]} #{match[2]}"
1✔
756
      # Match: name: Type (without default value)
757
      elsif (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:/))
68✔
758
        match[1]
65✔
759
      else
760
        param
3✔
761
      end
762
    end
763

764
    # 키워드 인자 그룹을 Ruby 키워드 인자로 변환
765
    # { name: String, age: Integer = 0 } -> name:, age: 0
766
    # { name:, age: 0 }: UserParams -> name:, age: 0
767
    def clean_keyword_args_group(param)
1✔
768
      # { ... }: InterfaceName 또는 { ... } 형태 파싱
769
      interface_match = param.match(/^\{(.+)\}\s*:\s*\w+\s*$/)
13✔
770
      inline_match = param.match(/^\{(.+)\}\s*$/) unless interface_match
13✔
771

772
      inner_content = if interface_match
13✔
773
                        interface_match[1]
3✔
774
                      elsif inline_match
10✔
775
                        inline_match[1]
10✔
776
                      else
777
                        return param
×
778
                      end
779

780
      # 내부 파라미터 분리
781
      parts = split_nested_content(inner_content)
13✔
782
      keyword_params = []
13✔
783

784
      parts.each do |part|
13✔
785
        part = part.strip
23✔
786
        next if part.empty?
23✔
787

788
        if interface_match
23✔
789
          # interface 참조: name: default_value 또는 name:
790
          if (match = part.match(/^(\w+):\s*(.*)$/))
7✔
791
            name = match[1]
7✔
792
            default_value = match[2].strip
7✔
793
            keyword_params << if default_value.empty?
7✔
794
                                "#{name}:"
4✔
795
                              else
796
                                "#{name}: #{default_value}"
3✔
797
                              end
798
          end
799
        elsif (match = part.match(/^(\w+):\s*(.+)$/))
16✔
800
          # 인라인 타입: name: Type = default 또는 name: Type
801
          name = match[1]
16✔
802
          type_and_default = match[2].strip
16✔
803

804
          # Type = default 분리
805
          default_value = extract_default_value(type_and_default)
16✔
806
          keyword_params << if default_value
16✔
807
                              "#{name}: #{default_value}"
8✔
808
                            else
809
                              "#{name}:"
8✔
810
                            end
811
        end
812
      end
813

814
      keyword_params
13✔
815
    end
816

817
    # 중첩된 내용을 콤마로 분리
818
    def split_nested_content(content)
1✔
819
      StringUtils.split_by_comma(content)
13✔
820
    end
821

822
    # 타입과 기본값에서 기본값만 추출
823
    def extract_default_value(type_and_default)
1✔
824
      StringUtils.extract_default_value(type_and_default)
16✔
825
    end
826

827
    # Erase return type annotations
828
    def erase_return_types(source)
1✔
829
      result = source.dup
127✔
830

831
      # Remove return type after parentheses: ): Type or ): Type<Foo> etc.
832
      result.gsub!(/\)\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
127✔
833
        ")"
21✔
834
      end
835

836
      # Remove return type for methods without parentheses: def method_name: Type
837
      result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN})\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
127✔
838
        ::Regexp.last_match(1)
12✔
839
      end
840

841
      result
127✔
842
    end
843
  end
844
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