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

type-ruby / t-ruby / 20525264502

26 Dec 2025 03:58PM UTC coverage: 76.169% (+0.1%) from 76.02%
20525264502

Pull #22

github

web-flow
Merge 8e9648dee into a9d85aa44
Pull Request #22: fix: parser improvements for v0.0.40

75 of 82 new or added lines in 6 files covered. (91.46%)

2 existing lines in 2 files now uncovered.

5082 of 6672 relevant lines covered (76.17%)

1195.08 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

76
      output_path
76✔
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✔
85
      generate_rbs = options.fetch(:rbs, true)
×
86

NEW
87
      parser = Parser.new(source)
×
NEW
88
      parser.parse
×
89

90
      # Transform source to Ruby code
NEW
91
      ruby_output = transform_with_ir(source, parser)
×
92

93
      # Generate RBS if requested
94
      rbs_output = ""
×
NEW
95
      if generate_rbs && parser.ir_program
×
NEW
96
        generator = IR::RBSGenerator.new
×
NEW
97
        rbs_output = generator.generate(parser.ir_program)
×
98
      end
99

100
      {
101
        ruby: ruby_output,
×
102
        rbs: rbs_output,
103
        errors: [],
104
      }
105
    rescue ParseError => e
106
      {
107
        ruby: "",
×
108
        rbs: "",
109
        errors: [e.message],
110
      }
111
    rescue StandardError => e
112
      {
113
        ruby: "",
×
114
        rbs: "",
115
        errors: ["Compilation error: #{e.message}"],
116
      }
117
    end
118

119
    # Compile to IR without generating output files
120
    def compile_to_ir(input_path)
1✔
121
      unless File.exist?(input_path)
4✔
122
        raise ArgumentError, "File not found: #{input_path}"
×
123
      end
124

125
      source = File.read(input_path)
4✔
126
      parser = Parser.new(source)
4✔
127
      parser.parse
4✔
128
      parser.ir_program
4✔
129
    end
130

131
    # Compile from IR program directly
132
    def compile_from_ir(ir_program, output_path)
1✔
133
      out_dir = File.dirname(output_path)
×
134
      FileUtils.mkdir_p(out_dir)
×
135

136
      # Optimize if enabled
137
      program = ir_program
×
138
      if @optimize && @optimizer
×
139
        result = @optimizer.optimize(program)
×
140
        program = result[:program]
×
141
      end
142

143
      # Generate Ruby code
144
      generator = IRCodeGenerator.new
×
145
      output = generator.generate(program)
×
146
      File.write(output_path, output)
×
147

148
      output_path
×
149
    end
150

151
    # Load external declarations from a file
152
    def load_declaration(name)
1✔
153
      @declaration_loader.load(name)
×
154
    end
155

156
    # Add a search path for declaration files
157
    def add_declaration_path(path)
1✔
158
      @declaration_loader.add_search_path(path)
×
159
    end
160

161
    # Get optimization statistics (only available after IR compilation)
162
    def optimization_stats
1✔
163
      @optimizer&.stats
×
164
    end
165

166
    # Compute output path for a source file
167
    # @param input_path [String] path to source file
168
    # @param output_dir [String] base output directory
169
    # @param new_extension [String] new file extension (e.g., ".rb", ".rbs")
170
    # @return [String] computed output path (always preserves directory structure)
171
    def compute_output_path(input_path, output_dir, new_extension)
1✔
172
      relative = compute_relative_path(input_path)
144✔
173
      base = relative.sub(/\.[^.]+$/, new_extension)
144✔
174
      File.join(output_dir, base)
144✔
175
    end
176

177
    # Compute relative path from source directory
178
    # @param input_path [String] path to source file
179
    # @return [String] relative path preserving directory structure
180
    def compute_relative_path(input_path)
1✔
181
      # Use realpath to resolve symlinks (e.g., /var vs /private/var on macOS)
182
      absolute_input = resolve_path(input_path)
144✔
183
      source_dirs = @config.source_include
144✔
184

185
      # Check if file is inside any source_include directory
186
      if source_dirs.size > 1
144✔
187
        # Multiple source directories: include the source dir name in output
188
        # src/models/user.trb → src/models/user.trb
189
        source_dirs.each do |src_dir|
4✔
190
          absolute_src = resolve_path(src_dir)
5✔
191
          next unless absolute_input.start_with?("#{absolute_src}/")
5✔
192

193
          # Return path relative to parent of source dir (includes src dir name)
194
          parent_of_src = File.dirname(absolute_src)
4✔
195
          return absolute_input.sub("#{parent_of_src}/", "")
4✔
196
        end
197
      else
198
        # Single source directory: exclude the source dir name from output
199
        # src/models/user.trb → models/user.trb
200
        src_dir = source_dirs.first
140✔
201
        if src_dir
140✔
202
          absolute_src = resolve_path(src_dir)
140✔
203
          if absolute_input.start_with?("#{absolute_src}/")
140✔
204
            return absolute_input.sub("#{absolute_src}/", "")
69✔
205
          end
206
        end
207
      end
208

209
      # File outside source directories: use path relative to current working directory
210
      # external/foo.trb → external/foo.trb
211
      cwd = resolve_path(".")
71✔
212
      if absolute_input.start_with?("#{cwd}/")
71✔
213
        return absolute_input.sub("#{cwd}/", "")
4✔
214
      end
215

216
      # Absolute path from outside cwd: use basename only
217
      File.basename(input_path)
67✔
218
    end
219

220
    private
1✔
221

222
    # Check types in IR program and raise TypeCheckError if mismatches found
223
    # @param ir_program [IR::Program] IR program to check
224
    # @param file_path [String] source file path for error messages
225
    def check_types(ir_program, file_path)
1✔
226
      ir_program.declarations.each do |decl|
75✔
227
        case decl
56✔
228
        when IR::MethodDef
229
          check_method_return_type(decl, nil, file_path)
36✔
230
        when IR::ClassDecl
231
          decl.body.each do |member|
18✔
232
            check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef)
25✔
233
          end
234
        end
235
      end
236
    end
237

238
    # Check if method's inferred return type matches declared return type
239
    # @param method [IR::MethodDef] method to check
240
    # @param class_def [IR::ClassDef, nil] containing class if any
241
    # @param file_path [String] source file path for error messages
242
    def check_method_return_type(method, class_def, file_path)
1✔
243
      # Skip if no explicit return type annotation
244
      return unless method.return_type
61✔
245

246
      declared_type = normalize_type(method.return_type.to_rbs)
46✔
247

248
      # Create type environment for the class context
249
      class_env = create_class_env(class_def) if class_def
46✔
250

251
      # Infer actual return type
252
      inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
46✔
253
      inferred_type = normalize_type(inferred_type || "nil")
46✔
254

255
      # Check compatibility
256
      return if types_compatible?(inferred_type, declared_type)
46✔
257

258
      location = method.location ? "#{file_path}:#{method.location}" : file_path
3✔
259
      method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
3✔
260

261
      raise TypeCheckError.new(
3✔
262
        message: "Return type mismatch in method '#{method_name}': " \
263
                 "declared '#{declared_type}' but inferred '#{inferred_type}'",
264
        location: location,
265
        expected: declared_type,
266
        actual: inferred_type
267
      )
268
    end
269

270
    # Create type environment for class context
271
    # @param class_def [IR::ClassDecl] class declaration
272
    # @return [TypeEnv] type environment with instance variables
273
    def create_class_env(class_def)
1✔
274
      env = TypeEnv.new
14✔
275

276
      # Register instance variables from class
277
      class_def.instance_vars&.each do |ivar|
14✔
278
        type = ivar.type_annotation&.to_rbs || "untyped"
6✔
279
        env.define_instance_var(ivar.name, type)
6✔
280
      end
281

282
      env
14✔
283
    end
284

285
    # Normalize type string for comparison
286
    # @param type [String] type string
287
    # @return [String] normalized type string
288
    def normalize_type(type)
1✔
289
      return "untyped" if type.nil?
92✔
290

291
      normalized = type.to_s.strip
92✔
292

293
      # Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool)
294
      case normalized
92✔
295
      when "Boolean", "TrueClass", "FalseClass"
296
        "bool"
2✔
297
      else
298
        normalized
90✔
299
      end
300
    end
301

302
    # Check if inferred type is compatible with declared type
303
    # @param inferred [String] inferred type
304
    # @param declared [String] declared type
305
    # @return [Boolean] true if compatible
306
    def types_compatible?(inferred, declared)
1✔
307
      # Exact match
308
      return true if inferred == declared
46✔
309

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

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

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

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

322
      # Handle union types in declared
323
      if declared.include?("|")
4✔
324
        declared_types = declared.split("|").map(&:strip)
1✔
325
        return true if declared_types.include?(inferred)
1✔
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✔
331
        inferred_types = inferred.split("|").map(&:strip)
×
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
4✔
345

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

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

353
      false
4✔
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)
360✔
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)
100✔
367
      @declaration_loader.add_search_path(@config.src_dir)
100✔
368
      @declaration_loader.add_search_path("./types")
100✔
369
      @declaration_loader.add_search_path("./lib/types")
100✔
370
    end
371

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

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

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

388
    # Generate RBS from IR to a specific path
389
    def generate_rbs_from_ir_to_path(rbs_path, ir_program)
1✔
390
      return unless ir_program
63✔
391

392
      generator = IR::RBSGenerator.new
63✔
393
      rbs_content = generator.generate(ir_program)
63✔
394
      File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
63✔
395
    end
396

397
    def generate_dtrb_file(input_path, out_dir)
1✔
UNCOV
398
      dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
×
399
      FileUtils.mkdir_p(File.dirname(dtrb_path))
×
400

401
      generator = DeclarationGenerator.new
×
402
      generator.generate_file_to_path(input_path, dtrb_path)
×
403
    end
404

405
    # Copy .rb file to output directory and generate .rbs signature
406
    def copy_ruby_file(input_path)
1✔
407
      unless File.exist?(input_path)
3✔
408
        raise ArgumentError, "File not found: #{input_path}"
×
409
      end
410

411
      # Compute output path (respects preserve_structure setting)
412
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
3✔
413
      FileUtils.mkdir_p(File.dirname(output_path))
3✔
414

415
      # Copy the .rb file to output directory
416
      FileUtils.cp(input_path, output_path)
3✔
417

418
      # Generate .rbs file if enabled in config
419
      if @config.compiler["generate_rbs"]
3✔
420
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
2✔
421
        FileUtils.mkdir_p(File.dirname(rbs_path))
2✔
422
        generate_rbs_from_ruby_to_path(rbs_path, input_path)
2✔
423
      end
424

425
      output_path
3✔
426
    end
427

428
    # Generate RBS from Ruby file using rbs prototype to a specific path
429
    def generate_rbs_from_ruby_to_path(rbs_path, input_path)
1✔
430
      result = `rbs prototype rb #{input_path} 2>/dev/null`
2✔
431
      File.write(rbs_path, result) unless result.strip.empty?
2✔
432
    end
433
  end
434

435
  # IR-aware code generator for source-preserving transformation
436
  class IRCodeGenerator
1✔
437
    def initialize
1✔
438
      @output = []
76✔
439
    end
440

441
    # Generate Ruby code from IR program
442
    def generate(program)
1✔
443
      generator = IR::CodeGenerator.new
×
444
      generator.generate(program)
×
445
    end
446

447
    # Generate Ruby code while preserving source structure
448
    def generate_with_source(program, source)
1✔
449
      result = source.dup
76✔
450

451
      # Collect type alias names to remove
452
      program.declarations
76✔
453
             .select { |d| d.is_a?(IR::TypeAlias) }
64✔
454
             .map(&:name)
455

456
      # Collect interface names to remove
457
      program.declarations
76✔
458
             .select { |d| d.is_a?(IR::Interface) }
64✔
459
             .map(&:name)
460

461
      # Remove type alias definitions
462
      result = result.gsub(/^\s*type\s+\w+\s*=\s*.+?$\n?/, "")
76✔
463

464
      # Remove interface definitions (multi-line)
465
      result = result.gsub(/^\s*interface\s+\w+.*?^\s*end\s*$/m, "")
76✔
466

467
      # Remove parameter type annotations using IR info
468
      # Enhanced: Handle complex types (generics, unions, etc.)
469
      result = erase_parameter_types(result)
76✔
470

471
      # Remove return type annotations
472
      result = erase_return_types(result)
76✔
473

474
      # Clean up extra blank lines
475
      result.gsub(/\n{3,}/, "\n\n")
76✔
476
    end
477

478
    private
1✔
479

480
    # Erase parameter type annotations
481
    def erase_parameter_types(source)
1✔
482
      result = source.dup
76✔
483

484
      # Match function definitions and remove type annotations from parameters
485
      # Also supports visibility modifiers: private def, protected def, public def
486
      result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
76✔
487
        indent = ::Regexp.last_match(1)
42✔
488
        params = ::Regexp.last_match(2)
42✔
489
        close_paren = ::Regexp.last_match(3)
42✔
490
        ending = ::Regexp.last_match(4)
42✔
491

492
        # Remove type annotations from each parameter
493
        cleaned_params = remove_param_types(params)
42✔
494

495
        "#{indent}#{cleaned_params}#{close_paren.rstrip}#{ending}"
42✔
496
      end
497

498
      result
76✔
499
    end
500

501
    # Remove type annotations from parameter list
502
    def remove_param_types(params_str)
1✔
503
      return params_str if params_str.strip.empty?
42✔
504

505
      params = []
42✔
506
      current = ""
42✔
507
      depth = 0
42✔
508

509
      params_str.each_char do |char|
42✔
510
        case char
551✔
511
        when "<", "[", "("
512
          depth += 1
1✔
513
          current += char
1✔
514
        when ">", "]", ")"
515
          depth -= 1
1✔
516
          current += char
1✔
517
        when ","
518
          if depth.zero?
5✔
519
            params << clean_param(current.strip)
5✔
520
            current = ""
5✔
521
          else
522
            current += char
×
523
          end
524
        else
525
          current += char
544✔
526
        end
527
      end
528

529
      params << clean_param(current.strip) unless current.empty?
42✔
530
      params.join(", ")
42✔
531
    end
532

533
    # Clean a single parameter (remove type annotation)
534
    def clean_param(param)
1✔
535
      # Match: name: Type or name (supports Unicode identifiers)
536
      if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:/))
47✔
537
        match[1]
44✔
538
      else
539
        param
3✔
540
      end
541
    end
542

543
    # Erase return type annotations
544
    def erase_return_types(source)
1✔
545
      result = source.dup
76✔
546

547
      # Remove return type: ): Type or ): Type<Foo> etc.
548
      result.gsub!(/\)\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
76✔
549
        ")"
5✔
550
      end
551

552
      result
76✔
553
    end
554
  end
555
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