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

type-ruby / t-ruby / 20556859504

28 Dec 2025 05:01PM UTC coverage: 77.331% (+0.5%) from 76.821%
20556859504

push

github

web-flow
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

154 of 167 new or added lines in 6 files covered. (92.22%)

1 existing line in 1 file now uncovered.

5424 of 7014 relevant lines covered (77.33%)

1167.05 hits per line

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

95.19
/lib/t_ruby/code_emitter.rb
1
# frozen_string_literal: true
2

3
module TRuby
1✔
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
1✔
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)
1✔
16
      version = RubyVersion.parse(target_ruby)
109✔
17

18
      if version.numbered_parameters_raise_error?
109✔
19
        Ruby40.new(version)
8✔
20
      elsif version.supports_it_parameter?
101✔
21
        Ruby34.new(version)
4✔
22
      elsif version.supports_anonymous_block_forwarding?
97✔
23
        Ruby31.new(version)
93✔
24
      else
25
        Ruby30.new(version)
4✔
26
      end
27
    end
28

29
    # Base class for version-specific code emitters
30
    class Base
1✔
31
      attr_reader :version
1✔
32

33
      def initialize(version)
1✔
34
        @version = version
125✔
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)
1✔
42
        result = source.dup
101✔
43
        result = transform_numbered_params(result)
101✔
44
        transform_block_forwarding(result)
101✔
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)
1✔
53
        source
99✔
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)
1✔
62
        source
4✔
63
      end
64

65
      # Check if this version supports the `it` implicit block parameter
66
      #
67
      # @return [Boolean]
68
      def supports_it?
1✔
NEW
69
        false
×
70
      end
71

72
      # Check if numbered parameters raise NameError in this version
73
      #
74
      # @return [Boolean]
75
      def numbered_params_error?
1✔
NEW
76
        false
×
77
      end
78
    end
79

80
    # Ruby 3.0 emitter - baseline, no transformations
81
    class Ruby30 < Base
1✔
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
1✔
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)
1✔
92
        result = source.dup
103✔
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|
103✔
97
          method_name = ::Regexp.last_match(1)
9✔
98
          other_params = ::Regexp.last_match(2)
9✔
99
          block_name = ::Regexp.last_match(3)
9✔
100

101
          # Find the method body to check block usage
102
          method_start = ::Regexp.last_match.begin(0)
9✔
103
          remaining = result[method_start..]
9✔
104

105
          # Check if block is only used for forwarding (not called directly)
106
          if block_only_forwarded?(remaining, block_name)
9✔
107
            "def #{method_name}(#{other_params}&)"
8✔
108
          else
109
            "def #{method_name}(#{other_params}&#{block_name})"
1✔
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|
103✔
116
          call_name = ::Regexp.last_match(1)
10✔
117
          ::Regexp.last_match(2)
10✔
118

119
          # Check if this block name was converted to anonymous
120
          if result.include?("def ") && result.include?("(&)")
10✔
121
            "#{call_name}(&)"
9✔
122
          else
123
            match
1✔
124
          end
125
        end
126

127
        result
103✔
128
      end
129

130
      private
1✔
131

132
      # Check if a block parameter is only used for forwarding
133
      def block_only_forwarded?(method_body, block_name)
1✔
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
9✔
139
        depth = 0
9✔
140
        body_lines = []
9✔
141

142
        lines.each do |line|
9✔
143
          depth += 1 if line.match?(/\b(def|class|module|do|begin|case|if|unless|while|until)\b/)
29✔
144
          depth -= 1 if line.match?(/\bend\b/)
29✔
145
          body_lines << line
29✔
146
          break if depth <= 0 && body_lines.length > 1
29✔
147
        end
148

149
        body = body_lines.join
9✔
150

151
        # Check for direct block usage
152
        return false if body.match?(/\b#{block_name}\s*\./)     # block.call, block.(), etc.
9✔
153
        return false if body.match?(/\b#{block_name}\s*\[/)     # block[args]
8✔
154
        return false if body.match?(/\byield\b/)                # yield instead of forwarding
8✔
155

156
        # Only &block_name patterns - this is forwarding
157
        true
8✔
158
      end
159
    end
160

161
    # Ruby 3.4+ emitter - supports `it` implicit block parameter
162
    class Ruby34 < Ruby31
1✔
163
      def supports_it?
1✔
164
        true
2✔
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
1✔
173
      def numbered_params_error?
1✔
174
        true
1✔
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)
1✔
182
        result = source.dup
9✔
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/)
9✔
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)
2✔
191
        else
192
          # Only _1 is used - simple replacement
193
          result.gsub(/\b_1\b/, "it")
7✔
194
        end
195
      end
196

197
      private
1✔
198

199
      def transform_multi_numbered_params(source)
1✔
200
        result = source.dup
2✔
201

202
        # Find blocks and transform them
203
        # Use a recursive approach with placeholder replacement
204

205
        # Replace innermost blocks first
206
        loop do
2✔
207
          changed = false
4✔
208
          result = result.gsub(/\{([^{}]*)\}/) do |block|
4✔
209
            content = ::Regexp.last_match(1)
4✔
210
            max_param = find_max_numbered_param(content)
4✔
211

212
            if max_param > 1
4✔
213
              # Multiple params - convert to explicit
214
              param_names = generate_param_names(max_param)
2✔
215
              new_content = content.dup
2✔
216
              (1..max_param).each do |i|
2✔
217
                new_content.gsub!(/\b_#{i}\b/, param_names[i - 1])
4✔
218
              end
219
              changed = true
2✔
220
              "{ |#{param_names.join(", ")}| #{new_content.strip} }"
2✔
221
            elsif max_param == 1
2✔
222
              # Single _1 - convert to it
NEW
223
              changed = true
×
NEW
224
              "{ #{content.gsub(/\b_1\b/, "it").strip} }"
×
225
            else
226
              block
2✔
227
            end
228
          end
229
          break unless changed
4✔
230
        end
231

232
        result
2✔
233
      end
234

235
      def find_max_numbered_param(content)
1✔
236
        max = 0
4✔
237
        content.scan(/\b_(\d+)\b/) do |match|
4✔
238
          num = match[0].to_i
4✔
239
          max = num if num > max
4✔
240
        end
241
        max
4✔
242
      end
243

244
      def generate_param_names(count)
1✔
245
        # Generate simple parameter names: a, b, c, ... or k, v for 2
246
        if count == 2
2✔
247
          %w[k v]
2✔
248
        else
NEW
249
          ("a".."z").take(count)
×
250
        end
251
      end
252
    end
253
  end
254
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