@@ -38,29 +38,27 @@ def compile(input_path)
3838 # Transform source to Ruby code
3939 output = @use_ir ? transform_with_ir ( source , parser ) : transform_legacy ( source , parse_result )
4040
41- out_dir = @config . out_dir
42- FileUtils . mkdir_p ( out_dir )
43-
44- base_filename = File . basename ( input_path , ".trb" )
45- output_path = File . join ( out_dir , "#{ base_filename } .rb" )
41+ # Compute output path (respects preserve_structure setting)
42+ output_path = compute_output_path ( input_path , @config . ruby_dir , ".rb" )
43+ FileUtils . mkdir_p ( File . dirname ( output_path ) )
4644
4745 File . write ( output_path , output )
4846
4947 # Generate .rbs file if enabled in config
5048 if @config . compiler [ "generate_rbs" ]
51- rbs_dir = @config . rbs_dir
52- FileUtils . mkdir_p ( rbs_dir )
49+ rbs_path = compute_output_path ( input_path , @config . rbs_dir , ".rbs" )
50+ FileUtils . mkdir_p ( File . dirname ( rbs_path ) )
5351 if @use_ir && parser . ir_program
54- generate_rbs_from_ir ( base_filename , rbs_dir , parser . ir_program )
52+ generate_rbs_from_ir_to_path ( rbs_path , parser . ir_program )
5553 else
56- generate_rbs_file ( base_filename , rbs_dir , parse_result )
54+ generate_rbs_file_to_path ( rbs_path , parse_result )
5755 end
5856 end
5957
6058 # Generate .d.trb file if enabled in config (legacy support)
6159 # TODO: Add compiler.generate_dtrb option in future
6260 if @config . compiler . key? ( "generate_dtrb" ) && @config . compiler [ "generate_dtrb" ]
63- generate_dtrb_file ( input_path , out_dir )
61+ generate_dtrb_file ( input_path , @config . ruby_dir )
6462 end
6563
6664 output_path
@@ -161,8 +159,70 @@ def optimization_stats
161159 @optimizer &.stats
162160 end
163161
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 )
168+ relative = compute_relative_path ( input_path )
169+ base = relative . sub ( /\. [^.]+$/ , new_extension )
170+ File . join ( output_dir , base )
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 )
177+ # Use realpath to resolve symlinks (e.g., /var vs /private/var on macOS)
178+ absolute_input = resolve_path ( input_path )
179+ source_dirs = @config . source_include
180+
181+ # Check if file is inside any source_include directory
182+ if source_dirs . size > 1
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 |
186+ absolute_src = resolve_path ( src_dir )
187+ next unless absolute_input . start_with? ( "#{ absolute_src } /" )
188+
189+ # Return path relative to parent of source dir (includes src dir name)
190+ parent_of_src = File . dirname ( absolute_src )
191+ return absolute_input . sub ( "#{ parent_of_src } /" , "" )
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
197+ if src_dir
198+ absolute_src = resolve_path ( src_dir )
199+ if absolute_input . start_with? ( "#{ absolute_src } /" )
200+ return absolute_input . sub ( "#{ absolute_src } /" , "" )
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 ( "." )
208+ if absolute_input . start_with? ( "#{ cwd } /" )
209+ return absolute_input . sub ( "#{ cwd } /" , "" )
210+ end
211+
212+ # Absolute path from outside cwd: use basename only
213+ File . basename ( input_path )
214+ end
215+
164216 private
165217
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 )
221+ File . realpath ( path )
222+ rescue Errno ::ENOENT
223+ File . expand_path ( path )
224+ end
225+
166226 def setup_declaration_paths
167227 # Add default declaration paths
168228 @declaration_loader . add_search_path ( @config . out_dir )
@@ -197,30 +257,29 @@ def transform_legacy(source, parse_result)
197257 end
198258 end
199259
200- # Generate RBS from IR
201- def generate_rbs_from_ir ( base_filename , out_dir , ir_program )
260+ # Generate RBS from IR to a specific path
261+ def generate_rbs_from_ir_to_path ( rbs_path , ir_program )
202262 generator = IR ::RBSGenerator . new
203263 rbs_content = generator . generate ( ir_program )
204-
205- rbs_path = File . join ( out_dir , "#{ base_filename } .rbs" )
206264 File . write ( rbs_path , rbs_content ) unless rbs_content . strip . empty?
207265 end
208266
209- # Legacy RBS generation
210- def generate_rbs_file ( base_filename , out_dir , parse_result )
267+ # Legacy RBS generation to a specific path
268+ def generate_rbs_file_to_path ( rbs_path , parse_result )
211269 generator = RBSGenerator . new
212270 rbs_content = generator . generate (
213271 parse_result [ :functions ] || [ ] ,
214272 parse_result [ :type_aliases ] || [ ]
215273 )
216-
217- rbs_path = File . join ( out_dir , "#{ base_filename } .rbs" )
218274 File . write ( rbs_path , rbs_content ) unless rbs_content . empty?
219275 end
220276
221277 def generate_dtrb_file ( input_path , out_dir )
278+ dtrb_path = compute_output_path ( input_path , out_dir , DeclarationGenerator ::DECLARATION_EXTENSION )
279+ FileUtils . mkdir_p ( File . dirname ( dtrb_path ) )
280+
222281 generator = DeclarationGenerator . new
223- generator . generate_file ( input_path , out_dir )
282+ generator . generate_file_to_path ( input_path , dtrb_path )
224283 end
225284
226285 # Copy .rb file to output directory and generate .rbs signature
@@ -229,28 +288,25 @@ def copy_ruby_file(input_path)
229288 raise ArgumentError , "File not found: #{ input_path } "
230289 end
231290
232- out_dir = @config . out_dir
233- FileUtils . mkdir_p ( out_dir )
234-
235- base_filename = File . basename ( input_path , ".rb" )
236- output_path = File . join ( out_dir , "#{ base_filename } .rb" )
291+ # Compute output path (respects preserve_structure setting)
292+ output_path = compute_output_path ( input_path , @config . ruby_dir , ".rb" )
293+ FileUtils . mkdir_p ( File . dirname ( output_path ) )
237294
238295 # Copy the .rb file to output directory
239296 FileUtils . cp ( input_path , output_path )
240297
241298 # Generate .rbs file if enabled in config
242299 if @config . compiler [ "generate_rbs" ]
243- rbs_dir = @config . rbs_dir
244- FileUtils . mkdir_p ( rbs_dir )
245- generate_rbs_from_ruby ( base_filename , rbs_dir , input_path )
300+ rbs_path = compute_output_path ( input_path , @config . rbs_dir , ".rbs" )
301+ FileUtils . mkdir_p ( File . dirname ( rbs_path ) )
302+ generate_rbs_from_ruby_to_path ( rbs_path , input_path )
246303 end
247304
248305 output_path
249306 end
250307
251- # Generate RBS from Ruby file using rbs prototype
252- def generate_rbs_from_ruby ( base_filename , out_dir , input_path )
253- rbs_path = File . join ( out_dir , "#{ base_filename } .rbs" )
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 )
254310 result = `rbs prototype rb #{ input_path } 2>/dev/null`
255311 File . write ( rbs_path , result ) unless result . strip . empty?
256312 end
0 commit comments