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

type-ruby / t-ruby / 20214493393

14 Dec 2025 09:34PM UTC coverage: 74.239% (-0.04%) from 74.274%
20214493393

push

github

yhk1038
refactor(compiler): always preserve directory structure in output

Remove preserve_structure option and implement smart path handling:
- Single source_include: exclude source dir name from output
  (src/models/user.trb → build/models/user.rb)
- Multiple source_include: include source dir name in output
  (src/models/user.trb → build/src/models/user.rb)
- Files outside source directories: preserve relative path from cwd
  (external/foo.trb → build/external/foo.rb)

Add resolve_path helper to handle macOS symlink paths (/var vs /private/var).

39 of 52 new or added lines in 2 files covered. (75.0%)

1 existing line in 1 file now uncovered.

4415 of 5947 relevant lines covered (74.24%)

1015.64 hits per line

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

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

3
require "fileutils"
1✔
4

5
module TRuby
1✔
6
  class Compiler
1✔
7
    attr_reader :declaration_loader, :use_ir, :optimizer
1✔
8

9
    def initialize(config = nil, use_ir: true, optimize: true)
1✔
10
      @config = config || Config.new
77✔
11
      @use_ir = use_ir
77✔
12
      @optimize = optimize
77✔
13
      @declaration_loader = DeclarationLoader.new
77✔
14
      @optimizer = IR::Optimizer.new if use_ir && optimize
77✔
15
      setup_declaration_paths if @config
77✔
16
    end
17

18
    def compile(input_path)
1✔
19
      unless File.exist?(input_path)
71✔
20
        raise ArgumentError, "File not found: #{input_path}"
8✔
21
      end
22

23
      # Handle .rb files separately
24
      if input_path.end_with?(".rb")
63✔
25
        return copy_ruby_file(input_path)
3✔
26
      end
27

28
      unless input_path.end_with?(".trb")
60✔
29
        raise ArgumentError, "Expected .trb or .rb file, got: #{input_path}"
3✔
30
      end
31

32
      source = File.read(input_path)
57✔
33

34
      # Parse with IR support
35
      parser = Parser.new(source, use_combinator: @use_ir)
57✔
36
      parse_result = parser.parse
57✔
37

38
      # Transform source to Ruby code
39
      output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
57✔
40

41
      # Compute output path (respects preserve_structure setting)
42
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
57✔
43
      FileUtils.mkdir_p(File.dirname(output_path))
57✔
44

45
      File.write(output_path, output)
57✔
46

47
      # Generate .rbs file if enabled in config
48
      if @config.compiler["generate_rbs"]
57✔
49
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
46✔
50
        FileUtils.mkdir_p(File.dirname(rbs_path))
46✔
51
        if @use_ir && parser.ir_program
46✔
52
          generate_rbs_from_ir_to_path(rbs_path, parser.ir_program)
46✔
53
        else
NEW
54
          generate_rbs_file_to_path(rbs_path, parse_result)
×
55
        end
56
      end
57

58
      # Generate .d.trb file if enabled in config (legacy support)
59
      # TODO: Add compiler.generate_dtrb option in future
60
      if @config.compiler.key?("generate_dtrb") && @config.compiler["generate_dtrb"]
57✔
NEW
61
        generate_dtrb_file(input_path, @config.ruby_dir)
×
62
      end
63

64
      output_path
57✔
65
    end
66

67
    # Compile T-Ruby source code from a string (useful for WASM/playground)
68
    # @param source [String] T-Ruby source code
69
    # @param options [Hash] Options for compilation
70
    # @option options [Boolean] :rbs Whether to generate RBS output (default: true)
71
    # @return [Hash] Result with :ruby, :rbs, :errors keys
72
    def compile_string(source, options = {})
1✔
73
      generate_rbs = options.fetch(:rbs, true)
×
74

75
      parser = Parser.new(source, use_combinator: @use_ir)
×
76
      parse_result = parser.parse
×
77

78
      # Transform source to Ruby code
79
      ruby_output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
×
80

81
      # Generate RBS if requested
82
      rbs_output = ""
×
83
      if generate_rbs
×
84
        if @use_ir && parser.ir_program
×
85
          generator = IR::RBSGenerator.new
×
86
          rbs_output = generator.generate(parser.ir_program)
×
87
        else
88
          generator = RBSGenerator.new
×
89
          rbs_output = generator.generate(
×
90
            parse_result[:functions] || [],
91
            parse_result[:type_aliases] || []
92
          )
93
        end
94
      end
95

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

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

121
      source = File.read(input_path)
4✔
122
      parser = Parser.new(source, use_combinator: true)
4✔
123
      parser.parse
4✔
124
      parser.ir_program
4✔
125
    end
126

127
    # Compile from IR program directly
128
    def compile_from_ir(ir_program, output_path)
1✔
129
      out_dir = File.dirname(output_path)
×
130
      FileUtils.mkdir_p(out_dir)
×
131

132
      # Optimize if enabled
133
      program = ir_program
×
134
      if @optimize && @optimizer
×
135
        result = @optimizer.optimize(program)
×
136
        program = result[:program]
×
137
      end
138

139
      # Generate Ruby code
140
      generator = IRCodeGenerator.new
×
141
      output = generator.generate(program)
×
142
      File.write(output_path, output)
×
143

144
      output_path
×
145
    end
146

147
    # Load external declarations from a file
148
    def load_declaration(name)
1✔
149
      @declaration_loader.load(name)
×
150
    end
151

152
    # Add a search path for declaration files
153
    def add_declaration_path(path)
1✔
154
      @declaration_loader.add_search_path(path)
×
155
    end
156

157
    # Get optimization statistics (only available after IR compilation)
158
    def optimization_stats
1✔
159
      @optimizer&.stats
×
160
    end
161

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

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

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

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

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

212
      # Absolute path from outside cwd: use basename only
213
      File.basename(input_path)
67✔
214
    end
215

216
    private
1✔
217

218
    # Resolve path to absolute path, following symlinks
219
    # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
220
    def resolve_path(path)
1✔
221
      File.realpath(path)
288✔
222
    rescue Errno::ENOENT
223
      File.expand_path(path)
70✔
224
    end
225

226
    def setup_declaration_paths
1✔
227
      # Add default declaration paths
228
      @declaration_loader.add_search_path(@config.out_dir)
77✔
229
      @declaration_loader.add_search_path(@config.src_dir)
77✔
230
      @declaration_loader.add_search_path("./types")
77✔
231
      @declaration_loader.add_search_path("./lib/types")
77✔
232
    end
233

234
    # Transform using IR system (new approach)
235
    def transform_with_ir(source, parser)
1✔
236
      ir_program = parser.ir_program
57✔
237
      return transform_legacy(source, parser.parse) unless ir_program
57✔
238

239
      # Run optimization passes if enabled
240
      if @optimize && @optimizer
57✔
241
        result = @optimizer.optimize(ir_program)
57✔
242
        ir_program = result[:program]
57✔
243
      end
244

245
      # Generate Ruby code using IR-aware generator
246
      generator = IRCodeGenerator.new
57✔
247
      generator.generate_with_source(ir_program, source)
57✔
248
    end
249

250
    # Legacy transformation using TypeErasure (backward compatible)
251
    def transform_legacy(source, parse_result)
1✔
252
      if parse_result[:type] == :success
×
253
        eraser = TypeErasure.new(source)
×
254
        eraser.erase
×
255
      else
256
        source
×
257
      end
258
    end
259

260
    # Generate RBS from IR to a specific path
261
    def generate_rbs_from_ir_to_path(rbs_path, ir_program)
1✔
262
      generator = IR::RBSGenerator.new
46✔
263
      rbs_content = generator.generate(ir_program)
46✔
264
      File.write(rbs_path, rbs_content) unless rbs_content.strip.empty?
46✔
265
    end
266

267
    # Legacy RBS generation to a specific path
268
    def generate_rbs_file_to_path(rbs_path, parse_result)
1✔
269
      generator = RBSGenerator.new
×
270
      rbs_content = generator.generate(
×
271
        parse_result[:functions] || [],
272
        parse_result[:type_aliases] || []
273
      )
UNCOV
274
      File.write(rbs_path, rbs_content) unless rbs_content.empty?
×
275
    end
276

277
    def generate_dtrb_file(input_path, out_dir)
1✔
NEW
278
      dtrb_path = compute_output_path(input_path, out_dir, DeclarationGenerator::DECLARATION_EXTENSION)
×
NEW
279
      FileUtils.mkdir_p(File.dirname(dtrb_path))
×
280

281
      generator = DeclarationGenerator.new
×
NEW
282
      generator.generate_file_to_path(input_path, dtrb_path)
×
283
    end
284

285
    # Copy .rb file to output directory and generate .rbs signature
286
    def copy_ruby_file(input_path)
1✔
287
      unless File.exist?(input_path)
3✔
288
        raise ArgumentError, "File not found: #{input_path}"
×
289
      end
290

291
      # Compute output path (respects preserve_structure setting)
292
      output_path = compute_output_path(input_path, @config.ruby_dir, ".rb")
3✔
293
      FileUtils.mkdir_p(File.dirname(output_path))
3✔
294

295
      # Copy the .rb file to output directory
296
      FileUtils.cp(input_path, output_path)
3✔
297

298
      # Generate .rbs file if enabled in config
299
      if @config.compiler["generate_rbs"]
3✔
300
        rbs_path = compute_output_path(input_path, @config.rbs_dir, ".rbs")
2✔
301
        FileUtils.mkdir_p(File.dirname(rbs_path))
2✔
302
        generate_rbs_from_ruby_to_path(rbs_path, input_path)
2✔
303
      end
304

305
      output_path
3✔
306
    end
307

308
    # Generate RBS from Ruby file using rbs prototype to a specific path
309
    def generate_rbs_from_ruby_to_path(rbs_path, input_path)
1✔
310
      result = `rbs prototype rb #{input_path} 2>/dev/null`
2✔
311
      File.write(rbs_path, result) unless result.strip.empty?
2✔
312
    end
313
  end
314

315
  # IR-aware code generator for source-preserving transformation
316
  class IRCodeGenerator
1✔
317
    def initialize
1✔
318
      @output = []
57✔
319
    end
320

321
    # Generate Ruby code from IR program
322
    def generate(program)
1✔
323
      generator = IR::CodeGenerator.new
×
324
      generator.generate(program)
×
325
    end
326

327
    # Generate Ruby code while preserving source structure
328
    def generate_with_source(program, source)
1✔
329
      result = source.dup
57✔
330

331
      # Collect type alias names to remove
332
      program.declarations
57✔
333
             .select { |d| d.is_a?(IR::TypeAlias) }
37✔
334
             .map(&:name)
335

336
      # Collect interface names to remove
337
      program.declarations
57✔
338
             .select { |d| d.is_a?(IR::Interface) }
37✔
339
             .map(&:name)
340

341
      # Remove type alias definitions
342
      result = result.gsub(/^\s*type\s+\w+\s*=\s*.+?$\n?/, "")
57✔
343

344
      # Remove interface definitions (multi-line)
345
      result = result.gsub(/^\s*interface\s+\w+.*?^\s*end\s*$/m, "")
57✔
346

347
      # Remove parameter type annotations using IR info
348
      # Enhanced: Handle complex types (generics, unions, etc.)
349
      result = erase_parameter_types(result)
57✔
350

351
      # Remove return type annotations
352
      result = erase_return_types(result)
57✔
353

354
      # Clean up extra blank lines
355
      result.gsub(/\n{3,}/, "\n\n")
57✔
356
    end
357

358
    private
1✔
359

360
    # Erase parameter type annotations
361
    def erase_parameter_types(source)
1✔
362
      result = source.dup
57✔
363

364
      # Match function definitions and remove type annotations from parameters
365
      result.gsub!(/^(\s*def\s+\w+\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match|
57✔
366
        indent = ::Regexp.last_match(1)
30✔
367
        params = ::Regexp.last_match(2)
30✔
368
        close_paren = ::Regexp.last_match(3)
30✔
369
        ending = ::Regexp.last_match(4)
30✔
370

371
        # Remove type annotations from each parameter
372
        cleaned_params = remove_param_types(params)
30✔
373

374
        "#{indent}#{cleaned_params}#{close_paren.rstrip}#{ending}"
30✔
375
      end
376

377
      result
57✔
378
    end
379

380
    # Remove type annotations from parameter list
381
    def remove_param_types(params_str)
1✔
382
      return params_str if params_str.strip.empty?
30✔
383

384
      params = []
30✔
385
      current = ""
30✔
386
      depth = 0
30✔
387

388
      params_str.each_char do |char|
30✔
389
        case char
391✔
390
        when "<", "[", "("
391
          depth += 1
1✔
392
          current += char
1✔
393
        when ">", "]", ")"
394
          depth -= 1
1✔
395
          current += char
1✔
396
        when ","
397
          if depth.zero?
3✔
398
            params << clean_param(current.strip)
3✔
399
            current = ""
3✔
400
          else
401
            current += char
×
402
          end
403
        else
404
          current += char
386✔
405
        end
406
      end
407

408
      params << clean_param(current.strip) unless current.empty?
30✔
409
      params.join(", ")
30✔
410
    end
411

412
    # Clean a single parameter (remove type annotation)
413
    def clean_param(param)
1✔
414
      # Match: name: Type or name
415
      if (match = param.match(/^(\w+)\s*:/))
33✔
416
        match[1]
30✔
417
      else
418
        param
3✔
419
      end
420
    end
421

422
    # Erase return type annotations
423
    def erase_return_types(source)
1✔
424
      result = source.dup
57✔
425

426
      # Remove return type: ): Type or ): Type<Foo> etc.
427
      result.gsub!(/\)\s*:\s*[^\n]+?(?=\s*$)/m) do |_match|
57✔
428
        ")"
×
429
      end
430

431
      result
57✔
432
    end
433
  end
434

435
  # Legacy Compiler for backward compatibility (no IR)
436
  class LegacyCompiler < Compiler
1✔
437
    def initialize(config)
1✔
438
      super(config, use_ir: false, optimize: false)
×
439
    end
440
  end
441
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