Skip to content

Commit 7a10db0

Browse files
authored
feat: add Ruby 4.0 support preparation (#28)
* feat: add Ruby 4.0 support preparation (#20) - Add benchmark gem as explicit dependency to resolve deprecation warning - Add ruby-head to CI matrix with continue-on-error for future compatibility testing * feat: implement version-specific code generation for Ruby 3.0-4.0 - Add RubyVersion value object for version parsing, comparison, and feature detection - Add CodeEmitter strategy pattern for version-specific code transformations - Auto-detect target Ruby version from current environment - Add UnsupportedRubyVersionError for versions outside 3.0-4.x range - Make listen gem optional to fix Ruby 4.0 ffi compatibility in CI - Add block parameter type annotation erasure Closes #20 * fix(ci): resolve Ruby 4.0 ffi compatibility and RuboCop issues - Remove add_development_dependency from gemspec (Gemspec/DevelopmentDependencies) - Add special handling for Ruby head to remove listen gem before bundle install - Keep listen in Gemfile for Ruby < 4.0
1 parent 370cad7 commit 7a10db0

File tree

17 files changed

+1176
-21
lines changed

17 files changed

+1176
-21
lines changed

.github/workflows/ci.yml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ jobs:
1818
strategy:
1919
fail-fast: false
2020
matrix:
21-
ruby: ['3.1', '3.2', '3.3', '3.4']
21+
ruby: ['3.1', '3.2', '3.3', '3.4', 'head']
22+
include:
23+
- ruby: 'head'
24+
skip_listen: true
2225

2326
steps:
2427
- uses: actions/checkout@v4
@@ -27,11 +30,19 @@ jobs:
2730
uses: ruby/setup-ruby@v1
2831
with:
2932
ruby-version: ${{ matrix.ruby }}
30-
bundler-cache: true
33+
bundler-cache: ${{ matrix.ruby != 'head' }}
34+
35+
# Ruby head (4.0) requires special handling due to ffi incompatibility
36+
- name: Install dependencies (Ruby head)
37+
if: matrix.ruby == 'head'
38+
run: |
39+
# Remove listen from Gemfile for Ruby 4.0+ (ffi incompatible)
40+
sed -i '/gem "listen"/d' Gemfile
41+
bundle install
3142
3243
- name: Run tests
3344
run: bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec_results.xml
34-
continue-on-error: false
45+
continue-on-error: ${{ matrix.ruby == 'head' }}
3546

3647
- name: Upload test results
3748
uses: actions/upload-artifact@v4

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ group :development, :test do
1212
gem "rubocop", require: false
1313
gem "simplecov", "~> 0.22.0", require: false
1414
gem "simplecov-lcov", "~> 0.8.0", require: false
15+
16+
# listen gem for watch mode - skip on Ruby 4.0+ due to ffi compatibility
17+
gem "listen", "~> 3.8" if RUBY_VERSION < "4.0"
1518
end

Gemfile.lock

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ PATH
22
remote: .
33
specs:
44
t-ruby (0.0.41)
5-
listen (~> 3.8)
5+
benchmark
66

77
GEM
88
remote: https://rubygems.org/
99
specs:
1010
ast (2.4.3)
11+
benchmark (0.5.0)
1112
diff-lcs (1.6.2)
1213
docile (1.4.1)
1314
ffi (1.17.2)
@@ -79,6 +80,7 @@ PLATFORMS
7980
x86_64-linux
8081

8182
DEPENDENCIES
83+
listen (~> 3.8)
8284
rake (~> 13.0)
8385
rbs (~> 3.0)
8486
rspec (~> 3.0)

lib/t_ruby.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require_relative "t_ruby/version"
44
require_relative "t_ruby/version_checker"
5+
require_relative "t_ruby/ruby_version"
6+
require_relative "t_ruby/code_emitter"
57
require_relative "t_ruby/config"
68

79
# Core infrastructure (must be loaded first)

lib/t_ruby/cli.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def init_project
135135
compiler:
136136
strictness: standard # strict | standard | permissive
137137
generate_rbs: true
138-
target_ruby: "3.0"
138+
target_ruby: "#{RubyVersion.current.major}.#{RubyVersion.current.minor}"
139139
# experimental: []
140140
# checks:
141141
# no_implicit_any: false

lib/t_ruby/code_emitter.rb

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# frozen_string_literal: true
2+
3+
module TRuby
4+
# Version-specific code transformation strategies
5+
#
6+
# @example
7+
# emitter = CodeEmitter.for_version("4.0")
8+
# result = emitter.transform(source)
9+
#
10+
module CodeEmitter
11+
# Factory method to get appropriate emitter for target Ruby version
12+
#
13+
# @param target_ruby [String] target Ruby version (e.g., "3.0", "4.0")
14+
# @return [Base] appropriate emitter instance
15+
def self.for_version(target_ruby)
16+
version = RubyVersion.parse(target_ruby)
17+
18+
if version.numbered_parameters_raise_error?
19+
Ruby40.new(version)
20+
elsif version.supports_it_parameter?
21+
Ruby34.new(version)
22+
elsif version.supports_anonymous_block_forwarding?
23+
Ruby31.new(version)
24+
else
25+
Ruby30.new(version)
26+
end
27+
end
28+
29+
# Base class for version-specific code emitters
30+
class Base
31+
attr_reader :version
32+
33+
def initialize(version)
34+
@version = version
35+
end
36+
37+
# Apply all transformations for this version
38+
#
39+
# @param source [String] source code to transform
40+
# @return [String] transformed source code
41+
def transform(source)
42+
result = source.dup
43+
result = transform_numbered_params(result)
44+
transform_block_forwarding(result)
45+
end
46+
47+
# Transform numbered block parameters (_1, _2, etc.)
48+
# Default: no transformation
49+
#
50+
# @param source [String] source code
51+
# @return [String] transformed source code
52+
def transform_numbered_params(source)
53+
source
54+
end
55+
56+
# Transform block forwarding syntax
57+
# Default: no transformation
58+
#
59+
# @param source [String] source code
60+
# @return [String] transformed source code
61+
def transform_block_forwarding(source)
62+
source
63+
end
64+
65+
# Check if this version supports the `it` implicit block parameter
66+
#
67+
# @return [Boolean]
68+
def supports_it?
69+
false
70+
end
71+
72+
# Check if numbered parameters raise NameError in this version
73+
#
74+
# @return [Boolean]
75+
def numbered_params_error?
76+
false
77+
end
78+
end
79+
80+
# Ruby 3.0 emitter - baseline, no transformations
81+
class Ruby30 < Base
82+
# Ruby 3.0 uses standard syntax, no transformations needed
83+
end
84+
85+
# Ruby 3.1+ emitter - supports anonymous block forwarding
86+
class Ruby31 < Base
87+
# Transform `def foo(&block) ... bar(&block)` to `def foo(&) ... bar(&)`
88+
#
89+
# Only transforms when the block parameter is ONLY used for forwarding,
90+
# not when it's called directly (e.g., block.call)
91+
def transform_block_forwarding(source)
92+
result = source.dup
93+
94+
# Find method definitions with block parameters
95+
# Pattern: def method_name(&block_name)
96+
result.gsub!(/def\s+(\w+[?!=]?)\s*\(([^)]*?)&(\w+)\s*\)/) do |_match|
97+
method_name = ::Regexp.last_match(1)
98+
other_params = ::Regexp.last_match(2)
99+
block_name = ::Regexp.last_match(3)
100+
101+
# Find the method body to check block usage
102+
method_start = ::Regexp.last_match.begin(0)
103+
remaining = result[method_start..]
104+
105+
# Check if block is only used for forwarding (not called directly)
106+
if block_only_forwarded?(remaining, block_name)
107+
"def #{method_name}(#{other_params}&)"
108+
else
109+
"def #{method_name}(#{other_params}&#{block_name})"
110+
end
111+
end
112+
113+
# Replace block forwarding calls with anonymous forwarding
114+
# This is a simplified approach - in practice we'd need proper scope tracking
115+
result.gsub!(/(\w+)\s*\(\s*&(\w+)\s*\)/) do |match|
116+
call_name = ::Regexp.last_match(1)
117+
::Regexp.last_match(2)
118+
119+
# Check if this block name was converted to anonymous
120+
if result.include?("def ") && result.include?("(&)")
121+
"#{call_name}(&)"
122+
else
123+
match
124+
end
125+
end
126+
127+
result
128+
end
129+
130+
private
131+
132+
# Check if a block parameter is only used for forwarding
133+
def block_only_forwarded?(method_body, block_name)
134+
# Simple heuristic: if block_name appears with .call or without &, it's not just forwarding
135+
# Look for patterns like: block_name.call, block_name.(), yield
136+
137+
# Extract method body (until next def or end of class)
138+
lines = method_body.lines
139+
depth = 0
140+
body_lines = []
141+
142+
lines.each do |line|
143+
depth += 1 if line.match?(/\b(def|class|module|do|begin|case|if|unless|while|until)\b/)
144+
depth -= 1 if line.match?(/\bend\b/)
145+
body_lines << line
146+
break if depth <= 0 && body_lines.length > 1
147+
end
148+
149+
body = body_lines.join
150+
151+
# Check for direct block usage
152+
return false if body.match?(/\b#{block_name}\s*\./) # block.call, block.(), etc.
153+
return false if body.match?(/\b#{block_name}\s*\[/) # block[args]
154+
return false if body.match?(/\byield\b/) # yield instead of forwarding
155+
156+
# Only &block_name patterns - this is forwarding
157+
true
158+
end
159+
end
160+
161+
# Ruby 3.4+ emitter - supports `it` implicit block parameter
162+
class Ruby34 < Ruby31
163+
def supports_it?
164+
true
165+
end
166+
167+
# Ruby 3.4 still supports _1 syntax, so no transformation needed by default
168+
# Users can opt-in to using `it` style if they want
169+
end
170+
171+
# Ruby 4.0+ emitter - _1 raises NameError, must use `it`
172+
class Ruby40 < Ruby34
173+
def numbered_params_error?
174+
true
175+
end
176+
177+
# Transform numbered parameters to appropriate syntax
178+
#
179+
# - Single _1 → it
180+
# - Multiple (_1, _2) → explicit |k, v| params
181+
def transform_numbered_params(source)
182+
result = source.dup
183+
184+
# Simple approach: replace all _1 with it when it's the only numbered param in scope
185+
# For complex cases with _2+, we'd need proper parsing
186+
# For now, do a global replacement if _2 etc are not present
187+
if result.match?(/\b_[2-9]\b/)
188+
# Has multiple numbered params - need to convert to explicit params
189+
# This is a complex case that requires proper block parsing
190+
transform_multi_numbered_params(result)
191+
else
192+
# Only _1 is used - simple replacement
193+
result.gsub(/\b_1\b/, "it")
194+
end
195+
end
196+
197+
private
198+
199+
def transform_multi_numbered_params(source)
200+
result = source.dup
201+
202+
# Find blocks and transform them
203+
# Use a recursive approach with placeholder replacement
204+
205+
# Replace innermost blocks first
206+
loop do
207+
changed = false
208+
result = result.gsub(/\{([^{}]*)\}/) do |block|
209+
content = ::Regexp.last_match(1)
210+
max_param = find_max_numbered_param(content)
211+
212+
if max_param > 1
213+
# Multiple params - convert to explicit
214+
param_names = generate_param_names(max_param)
215+
new_content = content.dup
216+
(1..max_param).each do |i|
217+
new_content.gsub!(/\b_#{i}\b/, param_names[i - 1])
218+
end
219+
changed = true
220+
"{ |#{param_names.join(", ")}| #{new_content.strip} }"
221+
elsif max_param == 1
222+
# Single _1 - convert to it
223+
changed = true
224+
"{ #{content.gsub(/\b_1\b/, "it").strip} }"
225+
else
226+
block
227+
end
228+
end
229+
break unless changed
230+
end
231+
232+
result
233+
end
234+
235+
def find_max_numbered_param(content)
236+
max = 0
237+
content.scan(/\b_(\d+)\b/) do |match|
238+
num = match[0].to_i
239+
max = num if num > max
240+
end
241+
max
242+
end
243+
244+
def generate_param_names(count)
245+
# Generate simple parameter names: a, b, c, ... or k, v for 2
246+
if count == 2
247+
%w[k v]
248+
else
249+
("a".."z").take(count)
250+
end
251+
end
252+
end
253+
end
254+
end

lib/t_ruby/compiler.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,8 @@ def transform_with_ir(source, parser)
380380
ir_program = result[:program]
381381
end
382382

383-
# Generate Ruby code using IR-aware generator
384-
generator = IRCodeGenerator.new
383+
# Generate Ruby code using IR-aware generator with target Ruby version
384+
generator = IRCodeGenerator.new(target_ruby: @config.target_ruby)
385385
generator.generate_with_source(ir_program, source)
386386
end
387387

@@ -434,8 +434,12 @@ def generate_rbs_from_ruby_to_path(rbs_path, input_path)
434434

435435
# IR-aware code generator for source-preserving transformation
436436
class IRCodeGenerator
437-
def initialize
437+
attr_reader :emitter
438+
439+
# @param target_ruby [String] target Ruby version (e.g., "3.0", "4.0")
440+
def initialize(target_ruby: "3.0")
438441
@output = []
442+
@emitter = CodeEmitter.for_version(target_ruby)
439443
end
440444

441445
# Generate Ruby code from IR program
@@ -471,6 +475,9 @@ def generate_with_source(program, source)
471475
# Remove return type annotations
472476
result = erase_return_types(result)
473477

478+
# Apply version-specific transformations
479+
result = @emitter.transform(result)
480+
474481
# Clean up extra blank lines
475482
result.gsub(/\n{3,}/, "\n\n")
476483
end
@@ -545,6 +552,14 @@ def clean_param(param)
545552
param = param.strip
546553
return nil if param.empty?
547554

555+
# 0. 블록 파라미터: &name: Type -> &name
556+
if param.start_with?("&")
557+
match = param.match(/^&(\w+)(?::\s*.+)?$/)
558+
return "&#{match[1]}" if match
559+
560+
return param
561+
end
562+
548563
# 1. 더블 스플랫: **name: Type -> **name
549564
if param.start_with?("**")
550565
match = param.match(/^\*\*(\w+)(?::\s*.+)?$/)

0 commit comments

Comments
 (0)