From ea07ae2d396c0a3c5cbf6b4beff0208c7e4a787d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6wenfels?= <282+dfl@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:14:28 +0000 Subject: [PATCH 1/6] feat: add Proc(T) -> R type syntax for block parameters - Add Proc type parsing in TypeParser (Ruby-familiar syntax) - Proc(T) -> R maps to FunctionType and outputs ^(T) -> R in RBS - Fix type erasure to handle nested parentheses in complex types - Refactor erase_parameter_types for multi-line parameter handling Example: ```ruby def map(&block: Proc(Integer) -> String): Array # block type erased in .rb output # outputs ^(Integer) -> String in .rbs end ``` Co-Authored-By: Claude Opus 4.5 --- lib/t_ruby/compiler.rb | 120 ++++++++++++++++++-- lib/t_ruby/parser_combinator/type_parser.rb | 7 ++ spec/t_ruby/compiler_spec.rb | 52 +++++++++ spec/t_ruby/parser_combinator_spec.rb | 39 +++++++ 4 files changed, 207 insertions(+), 11 deletions(-) diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index 49ad454..a4338b7 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -656,21 +656,119 @@ def generate_with_source(program, source) def erase_parameter_types(source) result = source.dup - # Match function definitions and remove type annotations from parameters - # Also supports visibility modifiers: private def, protected def, public def - result.gsub!(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()([^)]+)(\)\s*)(?::\s*[^\n]+)?(\s*$)/) do |_match| - indent = ::Regexp.last_match(1) - params = ::Regexp.last_match(2) - close_paren = ::Regexp.last_match(3) - ending = ::Regexp.last_match(4) + # Process each method definition individually + # to handle nested parentheses in types like Proc(Integer) -> String + lines = result.lines + output_lines = [] + i = 0 + + while i < lines.length + line = lines[i] + + # Check if line starts a method definition + if (match = line.match(/^(\s*#{TRuby::VISIBILITY_PATTERN}def\s+#{TRuby::METHOD_NAME_PATTERN}\s*\()/)) + prefix = match[1] + rest_of_line = line[match.end(0)..] + start_line = i + + # Collect the full parameter section (may span multiple lines) + param_text = rest_of_line + paren_depth = 1 + brace_depth = 0 + bracket_depth = 0 + found_close = false + + # Find matching closing paren within current line first + param_text.each_char do |char| + case char + when "(" then paren_depth += 1 + when ")" + paren_depth -= 1 + if paren_depth.zero? && brace_depth.zero? + found_close = true + break + end + when "{" then brace_depth += 1 + when "}" then brace_depth -= 1 + when "[" then bracket_depth += 1 + when "]" then bracket_depth -= 1 + end + end + + # If not found, continue to next lines + while !found_close && i + 1 < lines.length + i += 1 + param_text += lines[i] + lines[i].each_char do |char| + case char + when "(" then paren_depth += 1 + when ")" + paren_depth -= 1 + if paren_depth.zero? && brace_depth.zero? + found_close = true + break + end + when "{" then brace_depth += 1 + when "}" then brace_depth -= 1 + when "[" then bracket_depth += 1 + when "]" then bracket_depth -= 1 + end + end + end - # Remove type annotations from each parameter - cleaned_params = remove_param_types(params) + # Extract params and remainder + params, remainder = extract_balanced_params(param_text) + + if params && found_close + cleaned_params = remove_param_types(params) + remainder = remainder.sub(/^\s*:\s*[^\n]+/, "") + output_lines << "#{prefix}#{cleaned_params})#{remainder}" + else + # Couldn't process, keep original lines + (start_line..i).each { |j| output_lines << lines[j] } + end + else + output_lines << line + end - "#{indent}#{cleaned_params}#{close_paren.rstrip}#{ending}" + i += 1 end - result + output_lines.join + end + + # Extract parameters from string, handling nested parentheses and braces + # Returns [params_string, remainder] or [nil, nil] if no match + def extract_balanced_params(str) + paren_depth = 1 # We're already past the opening paren + brace_depth = 0 + bracket_depth = 0 + pos = 0 + + str.each_char.with_index do |char, i| + case char + when "(" + paren_depth += 1 + when ")" + paren_depth -= 1 + if paren_depth.zero? && brace_depth.zero? && bracket_depth.zero? + pos = i + break + end + when "{" + brace_depth += 1 + when "}" + brace_depth -= 1 + when "[" + bracket_depth += 1 + when "]" + bracket_depth -= 1 + end + end + + return [nil, nil] if paren_depth != 0 + + [str[0...pos], str[(pos + 1)..]] end # Remove type annotations from parameter list diff --git a/lib/t_ruby/parser_combinator/type_parser.rb b/lib/t_ruby/parser_combinator/type_parser.rb index 5cce56e..c8f733d 100644 --- a/lib/t_ruby/parser_combinator/type_parser.rb +++ b/lib/t_ruby/parser_combinator/type_parser.rb @@ -66,6 +66,12 @@ def build_parsers IR::FunctionType.new(param_types: params, return_type: ret) end + # Proc type: Proc(Params) -> ReturnType (Ruby-familiar syntax) + proc_keyword = lexeme(string("Proc")) + proc_type = (proc_keyword >> param_list >> arrow >> type_expr).map do |((_proc, params), _arrow), ret| + IR::FunctionType.new(param_types: params, return_type: ret) + end + # Tuple type: [Type, Type, ...] tuple_type = ( lexeme(char("[")) >> @@ -75,6 +81,7 @@ def build_parsers # Primary type (before operators) primary_type = choice( + proc_type, function_type, tuple_type, paren_type, diff --git a/spec/t_ruby/compiler_spec.rb b/spec/t_ruby/compiler_spec.rb index 61841b1..bb58838 100644 --- a/spec/t_ruby/compiler_spec.rb +++ b/spec/t_ruby/compiler_spec.rb @@ -700,4 +700,56 @@ def error2():String end end end + + describe "Proc type annotations" do + it "compiles method with Proc-typed block parameter" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "test.trb") + File.write(input_file, <<~TRB) + def map_items(&block: Proc(Integer) -> String): Array + [] + end + TRB + + allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir]) + + compiler = TRuby::Compiler.new(config) + output_path = compiler.compile(input_file) + + output_content = File.read(output_path) + expect(output_content).to include("def map_items") + # Type annotations should be erased in Ruby output + expect(output_content).not_to include("Proc") + end + end + + it "generates RBS for method with block parameter" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "test.trb") + File.write(input_file, <<~TRB) + def each_with_transform(&block: Proc(String) -> Integer): void + end + TRB + + allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:rbs_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir]) + allow_any_instance_of(TRuby::Config).to receive(:generate_rbs?).and_return(true) + + compiler = TRuby::Compiler.new(config) + compiler.compile(input_file) + + rbs_path = File.join(tmpdir, "test.rbs") + if File.exist?(rbs_path) + rbs_content = File.read(rbs_path) + # Method signature should be generated (block type in RBS is a future enhancement) + expect(rbs_content).to include("def each_with_transform") + expect(rbs_content).to include("void") + end + end + end + end end diff --git a/spec/t_ruby/parser_combinator_spec.rb b/spec/t_ruby/parser_combinator_spec.rb index 22dbdeb..7b0c969 100644 --- a/spec/t_ruby/parser_combinator_spec.rb +++ b/spec/t_ruby/parser_combinator_spec.rb @@ -361,6 +361,45 @@ expect(result[:type].return_type.name).to eq("Boolean") end + it "parses Proc type with single parameter" do + result = parser.parse("Proc(Integer) -> String") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::FunctionType) + expect(result[:type].param_types.length).to eq(1) + expect(result[:type].param_types.first.name).to eq("Integer") + expect(result[:type].return_type.name).to eq("String") + end + + it "parses Proc type with multiple parameters" do + result = parser.parse("Proc(String, Integer) -> Boolean") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::FunctionType) + expect(result[:type].param_types.length).to eq(2) + expect(result[:type].return_type.name).to eq("Boolean") + end + + it "parses Proc type with no parameters" do + result = parser.parse("Proc() -> void") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::FunctionType) + expect(result[:type].param_types).to be_empty + expect(result[:type].return_type.name).to eq("void") + end + + it "parses Proc type with generic return type" do + result = parser.parse("Proc(Integer) -> Array") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::FunctionType) + expect(result[:type].return_type).to be_a(TRuby::IR::GenericType) + expect(result[:type].return_type.base).to eq("Array") + end + + it "converts Proc type to correct RBS format" do + result = parser.parse("Proc(String, Integer) -> Boolean") + expect(result[:success]).to be true + expect(result[:type].to_rbs).to eq("^(String, Integer) -> Boolean") + end + it "parses tuple type" do result = parser.parse("[String, Integer, Boolean]") expect(result[:success]).to be true From a7dd6ac5f5123078743363c5b4c8251c3e55b98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6wenfels?= <282+dfl@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:35:24 +0000 Subject: [PATCH 2/6] feat: add RBS block signature output for Proc type annotations - Add block type signature to RBS method output: `{ (params) -> return }` - Parse block parameters (`&block: Proc(T) -> R`) in Parser class - Pass `kind` attribute through IR::Builder for block params - Use `ir_type` from parser for proper FunctionType resolution Example input: def each(&block: Proc(Integer) -> void): void RBS output: def each: () { (Integer) -> void } -> void Co-Authored-By: Claude Opus 4.5 --- lib/t_ruby/ir.rb | 26 +++++++++++-- lib/t_ruby/parser.rb | 29 +++++++++++++- spec/e2e/class_rbs_generation_spec.rb | 39 +++++++++++++++++++ spec/t_ruby/ir_spec.rb | 56 +++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index a700225..6d910de 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -811,7 +811,8 @@ def build_method(info) params = (info[:params] || []).map do |param| Parameter.new( name: param[:name], - type_annotation: param[:type] ? parse_type(param[:type]) : nil + type_annotation: param[:ir_type] || (param[:type] ? parse_type(param[:type]) : nil), + kind: param[:kind] || :required ) end @@ -1021,11 +1022,24 @@ def visit_interface_member(node) end def visit_method_def(node) - params = node.params.map do |param| + # Separate block params from regular params + regular_params = node.params.reject { |p| p.kind == :block } + block_param = node.params.find { |p| p.kind == :block } + + params = regular_params.map do |param| type = param.type_annotation&.to_rbs || "untyped" "#{param.name}: #{type}" end.join(", ") + # Build block signature if block param has FunctionType annotation + block_sig = nil + if block_param&.type_annotation.is_a?(FunctionType) + func_type = block_param.type_annotation + block_params = func_type.param_types.map(&:to_rbs).join(", ") + block_return = func_type.return_type.to_rbs + block_sig = "{ (#{block_params}) -> #{block_return} }" + end + # 반환 타입: 명시적 타입 > 추론된 타입 > untyped return_type = node.return_type&.to_rbs @@ -1041,7 +1055,13 @@ def visit_method_def(node) return_type ||= "untyped" visibility_prefix = format_visibility(node.visibility) - emit("#{visibility_prefix}def #{node.name}: (#{params}) -> #{return_type}") + + # Include block signature in RBS output: (params) { block } -> return + if block_sig + emit("#{visibility_prefix}def #{node.name}: (#{params}) #{block_sig} -> #{return_type}") + else + emit("#{visibility_prefix}def #{node.name}: (#{params}) -> #{return_type}") + end end def visit_class_decl(node) diff --git a/lib/t_ruby/parser.rb b/lib/t_ruby/parser.rb index 3b021bf..736d245 100644 --- a/lib/t_ruby/parser.rb +++ b/lib/t_ruby/parser.rb @@ -268,7 +268,11 @@ def parse_parameters(params_str) elsif param.match?(/^\w+:\s*\{/) param_info = parse_hash_literal_parameter(param) parameters << param_info if param_info - # 4. 일반 위치 인자: name: Type 또는 name: Type = default + # 4. Block parameter: &block or &block: Type + elsif param.start_with?("&") + param_info = parse_block_parameter(param) + parameters << param_info if param_info + # 5. 일반 위치 인자: name: Type 또는 name: Type = default else param_info = parse_single_parameter(param) parameters << param_info if param_info @@ -338,6 +342,29 @@ def parse_double_splat_parameter(param) result end + # Block parameter parsing: &block or &block: Proc(T) -> R + def parse_block_parameter(param) + # &name or &name: Type + match = param.match(/^&(\w+)(?::\s*(.+?))?$/) + return nil unless match + + param_name = match[1] + type_str = match[2]&.strip + + result = { + name: param_name, + type: type_str, + kind: :block, + } + + if type_str + type_result = @type_parser.parse(type_str) + result[:ir_type] = type_result[:type] if type_result[:success] + end + + result + end + # 키워드 인자 그룹 파싱: { name: String, age: Integer = 0 } 또는 { name:, age: 0 }: InterfaceName def parse_keyword_args_group(param) # { ... }: InterfaceName 형태 확인 diff --git a/spec/e2e/class_rbs_generation_spec.rb b/spec/e2e/class_rbs_generation_spec.rb index e369b7c..f60f7dc 100644 --- a/spec/e2e/class_rbs_generation_spec.rb +++ b/spec/e2e/class_rbs_generation_spec.rb @@ -298,6 +298,45 @@ class Visible end end + describe "block type annotation" do + it "generates RBS with block signature from Proc type annotation" do + Dir.chdir(tmpdir) do + create_config_file(<<~YAML) + source: + include: + - src + output: + ruby_dir: build + rbs_dir: sig + compiler: + generate_rbs: true + YAML + + create_trb_file("src/iterator.trb", <<~TRB) + class Iterator + def each(&block: Proc(Integer) -> void): void + yield 1 + yield 2 + end + + def map_values(initial: Integer, &block: Proc(Integer, Integer) -> String): Array + [] + end + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/iterator.trb")) + + # Validate RBS syntax using official rbs gem + expect_valid_rbs(rbs_content) + + # Block signature should use RBS block syntax: { (params) -> return } + expect(rbs_content).to include("def each: () { (Integer) -> void } -> void") + expect(rbs_content).to include("def map_values: (initial: Integer) { (Integer, Integer) -> String } -> Array[String]") + end + end + end + describe "HelloWorld integration test" do it "generates correct RBS for HelloWorld sample structure" do Dir.chdir(tmpdir) do diff --git a/spec/t_ruby/ir_spec.rb b/spec/t_ruby/ir_spec.rb index a060324..d20c54f 100644 --- a/spec/t_ruby/ir_spec.rb +++ b/spec/t_ruby/ir_spec.rb @@ -362,6 +362,62 @@ def greet(name: String): String expect(output).to include("def greet: (name: String) -> String") end + + it "generates RBS method signature with block type" do + # Method with block: def each(&block: Proc(Integer) -> void): void + method = TRuby::IR::MethodDef.new( + name: "each", + params: [ + TRuby::IR::Parameter.new( + name: "block", + kind: :block, + type_annotation: TRuby::IR::FunctionType.new( + param_types: [TRuby::IR::SimpleType.new(name: "Integer")], + return_type: TRuby::IR::SimpleType.new(name: "void") + ) + ), + ], + return_type: TRuby::IR::SimpleType.new(name: "void") + ) + program = TRuby::IR::Program.new(declarations: [method]) + + output = generator.generate(program) + + expect(output).to include("def each: () { (Integer) -> void } -> void") + end + + it "generates RBS method signature with params and block type" do + # Method with params and block: def map(initial: T, &block: Proc(T, Integer) -> U): Array + method = TRuby::IR::MethodDef.new( + name: "map_with_index", + params: [ + TRuby::IR::Parameter.new( + name: "initial", + type_annotation: TRuby::IR::SimpleType.new(name: "Integer") + ), + TRuby::IR::Parameter.new( + name: "block", + kind: :block, + type_annotation: TRuby::IR::FunctionType.new( + param_types: [ + TRuby::IR::SimpleType.new(name: "String"), + TRuby::IR::SimpleType.new(name: "Integer"), + ], + return_type: TRuby::IR::SimpleType.new(name: "String") + ) + ), + ], + return_type: TRuby::IR::GenericType.new( + base: "Array", + type_args: [TRuby::IR::SimpleType.new(name: "String")] + ) + ) + program = TRuby::IR::Program.new(declarations: [method]) + + output = generator.generate(program) + + expect(output).to include("def map_with_index: (initial: Integer) { (String, Integer) -> String } -> Array[String]") + end end describe TRuby::IR::Passes::DeadCodeElimination do From a0fa82bee5bc249d035f7662c7ff3b2fec6cff70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6wenfels?= <282+dfl@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:41:32 +0000 Subject: [PATCH 3/6] feat: add yield argument count type checking - Add IR::Yield node and visit_yield to CodeGenerator - Add parse_yield to StatementParser for parsing yield statements - Add check_yield_arguments to compiler type checking phase - Validates yield argument count matches block parameter signature Example error: def each(&block: Proc(Integer, String) -> void) yield 1 # Error: expects 2 arguments, but yield passes 1 Co-Authored-By: Claude Opus 4.5 --- lib/t_ruby/compiler.rb | 66 ++++++++++++++++++- lib/t_ruby/ir.rb | 23 +++++++ .../token/statement_parser.rb | 41 ++++++++++++ spec/t_ruby/compiler_spec.rb | 55 ++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index a4338b7..fa5e596 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -382,9 +382,13 @@ def check_types(ir_program, file_path) case decl when IR::MethodDef check_method_return_type(decl, nil, file_path) + check_yield_arguments(decl, nil, file_path) when IR::ClassDecl decl.body.each do |member| - check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef) + next unless member.is_a?(IR::MethodDef) + + check_method_return_type(member, decl, file_path) + check_yield_arguments(member, decl, file_path) end end end @@ -422,6 +426,66 @@ def check_method_return_type(method, class_def, file_path) ) end + # Check yield statements match block parameter signature + # @param method [IR::MethodDef] method to check + # @param class_def [IR::ClassDecl, nil] containing class if any + # @param file_path [String] source file path for error messages + def check_yield_arguments(method, class_def, file_path) + # Find block parameter with FunctionType annotation + block_param = method.params.find { |p| p.kind == :block } + return unless block_param&.type_annotation.is_a?(IR::FunctionType) + + block_type = block_param.type_annotation + expected_arg_count = block_type.param_types.length + + # Find all yield statements in method body + yields = find_yields_in_body(method.body) + return if yields.empty? + + location = method.location ? "#{file_path}:#{method.location}" : file_path + method_name = class_def ? "#{class_def.name}##{method.name}" : method.name + + yields.each do |yield_node| + actual_arg_count = yield_node.arguments.length + + next if actual_arg_count == expected_arg_count + + raise TypeCheckError.new( + message: "Yield argument count mismatch in '#{method_name}': " \ + "block expects #{expected_arg_count} argument(s) but yield passes #{actual_arg_count}", + location: location, + expected: "#{expected_arg_count} argument(s)", + actual: "#{actual_arg_count} argument(s)" + ) + end + end + + # Find all yield nodes in a method body + # @param node [IR::Node] IR node to search + # @return [Array] yield nodes found + def find_yields_in_body(node) + yields = [] + return yields unless node + + case node + when IR::Yield + yields << node + when IR::Block + node.statements.each { |stmt| yields.concat(find_yields_in_body(stmt)) } + when IR::Conditional + yields.concat(find_yields_in_body(node.then_branch)) + yields.concat(find_yields_in_body(node.else_branch)) + when IR::Loop + yields.concat(find_yields_in_body(node.body)) + when IR::Assignment + yields.concat(find_yields_in_body(node.value)) + when IR::Return + yields.concat(find_yields_in_body(node.value)) + end + + yields + end + # Create type environment for class context # @param class_def [IR::ClassDecl] class declaration # @return [TypeEnv] type environment with instance variables diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index 6d910de..dcf4fbd 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -386,6 +386,20 @@ def children end end + # Yield statement: yield or yield(args) + class Yield < Node + attr_accessor :arguments + + def initialize(arguments: [], **opts) + super(**opts) + @arguments = arguments + end + + def children + @arguments + end + end + # Binary operation class BinaryOp < Node attr_accessor :operator, :left, :right @@ -917,6 +931,15 @@ def visit_return(node) end end + def visit_yield(node) + if node.arguments.empty? + emit("yield") + else + args = node.arguments.map { |a| generate_expression(a) }.join(", ") + emit("yield(#{args})") + end + end + def visit_conditional(node) keyword = node.kind == :unless ? "unless" : "if" emit("#{keyword} #{generate_expression(node.condition)}") diff --git a/lib/t_ruby/parser_combinator/token/statement_parser.rb b/lib/t_ruby/parser_combinator/token/statement_parser.rb index fa253a0..51570d6 100644 --- a/lib/t_ruby/parser_combinator/token/statement_parser.rb +++ b/lib/t_ruby/parser_combinator/token/statement_parser.rb @@ -22,6 +22,8 @@ def parse_statement(tokens, position = 0) case token.type when :return parse_return(tokens, position) + when :yield + parse_yield(tokens, position) when :if parse_if(tokens, position) when :unless @@ -95,6 +97,45 @@ def parse_return(tokens, position) TokenParseResult.success(node, tokens, expr_result.position) end + def parse_yield(tokens, position) + position += 1 # consume 'yield' + + # Check if there are arguments + position = skip_newlines_if_not_modifier(tokens, position) + + if position >= tokens.length || + tokens[position].type == :eof || + tokens[position].type == :newline || + end_of_statement?(tokens, position) + node = IR::Yield.new(arguments: []) + return TokenParseResult.success(node, tokens, position) + end + + # Parse arguments (comma-separated expressions) + arguments = [] + expr_result = @expression_parser.parse_expression(tokens, position) + return expr_result if expr_result.failure? + + arguments << expr_result.value + position = expr_result.position + + while tokens[position]&.type == :comma + position += 1 + expr_result = @expression_parser.parse_expression(tokens, position) + return expr_result if expr_result.failure? + + arguments << expr_result.value + position = expr_result.position + end + + # Check for modifier + modifier_result = parse_modifier(tokens, position, IR::Yield.new(arguments: arguments)) + return modifier_result if modifier_result.success? && modifier_result.value.is_a?(IR::Conditional) + + node = IR::Yield.new(arguments: arguments) + TokenParseResult.success(node, tokens, position) + end + def parse_if(tokens, position) position += 1 # consume 'if' diff --git a/spec/t_ruby/compiler_spec.rb b/spec/t_ruby/compiler_spec.rb index bb58838..d767608 100644 --- a/spec/t_ruby/compiler_spec.rb +++ b/spec/t_ruby/compiler_spec.rb @@ -384,6 +384,61 @@ def add(a: Integer, b: Integer): Integer end.to raise_error(TRuby::TypeCheckError) end end + + it "raises TypeCheckError when yield argument count mismatches block signature" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "yield_mismatch.trb") + File.write(input_file, <<~RUBY) + def each(&block: Proc(Integer, String) -> void): void + yield 1 + end + RUBY + + allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir]) + allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false }) + allow_any_instance_of(TRuby::Config).to receive(:type_check?).and_return(true) + + compiler = TRuby::Compiler.new(config) + + error = nil + begin + compiler.compile(input_file) + rescue TRuby::TypeCheckError => e + error = e + end + + expect(error).to be_a(TRuby::TypeCheckError) + expect(error.message).to include("yield") + expect(error.message).to include("2 argument") + expect(error.message).to include("1 argument") + end + end + + it "passes when yield argument count matches block signature" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "yield_match.trb") + File.write(input_file, <<~RUBY) + def each(&block: Proc(Integer) -> void): void + yield 1 + yield 2 + end + RUBY + + allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir) + allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir]) + allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false }) + allow_any_instance_of(TRuby::Config).to receive(:type_check?).and_return(true) + + compiler = TRuby::Compiler.new(config) + + expect do + compiler.compile(input_file) + end.not_to raise_error + end + end end context "with directory structure preservation" do From 4a9780db78b1313520faa1702bf44cc92629239f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6wenfels?= <282+dfl@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:44:46 +0000 Subject: [PATCH 4/6] feat: add optional block parameter syntax (&block?) - Parse &block? as optional block parameter - Add `optional` attribute to IR::Parameter - Generate ?{ } syntax in RBS for optional blocks - Required blocks use { }, optional blocks use ?{ } Example: def maybe_yield(&block?: Proc(Integer) -> void): void yield 1 if block_given? end RBS output: def maybe_yield: () ?{ (Integer) -> void } -> void Co-Authored-By: Claude Opus 4.5 --- lib/t_ruby/ir.rb | 13 ++++++--- lib/t_ruby/parser.rb | 8 +++--- spec/e2e/class_rbs_generation_spec.rb | 39 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index dcf4fbd..807dcde 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -147,19 +147,21 @@ def children # Method parameter class Parameter < Node - attr_accessor :name, :type_annotation, :default_value, :kind, :interface_ref + attr_accessor :name, :type_annotation, :default_value, :kind, :interface_ref, :optional # kind: :required, :optional, :rest, :keyrest, :block, :keyword # :keyword - 키워드 인자 (구조분해): { name: String } → def foo(name:) # :keyrest - 더블 스플랫: **opts: Type → def foo(**opts) # interface_ref - interface 참조 타입 (예: }: UserParams 부분) - def initialize(name:, type_annotation: nil, default_value: nil, kind: :required, interface_ref: nil, **opts) + # optional - for block params: true if block is optional (&block?) + def initialize(name:, type_annotation: nil, default_value: nil, kind: :required, interface_ref: nil, optional: false, **opts) super(**opts) @name = name @type_annotation = type_annotation @default_value = default_value @kind = kind @interface_ref = interface_ref + @optional = optional end end @@ -826,7 +828,8 @@ def build_method(info) Parameter.new( name: param[:name], type_annotation: param[:ir_type] || (param[:type] ? parse_type(param[:type]) : nil), - kind: param[:kind] || :required + kind: param[:kind] || :required, + optional: param[:optional] || false ) end @@ -1060,7 +1063,9 @@ def visit_method_def(node) func_type = block_param.type_annotation block_params = func_type.param_types.map(&:to_rbs).join(", ") block_return = func_type.return_type.to_rbs - block_sig = "{ (#{block_params}) -> #{block_return} }" + # Use ?{ } for optional blocks, { } for required blocks + prefix = block_param.optional ? "?" : "" + block_sig = "#{prefix}{ (#{block_params}) -> #{block_return} }" end # 반환 타입: 명시적 타입 > 추론된 타입 > untyped diff --git a/lib/t_ruby/parser.rb b/lib/t_ruby/parser.rb index 736d245..2dde88a 100644 --- a/lib/t_ruby/parser.rb +++ b/lib/t_ruby/parser.rb @@ -344,17 +344,19 @@ def parse_double_splat_parameter(param) # Block parameter parsing: &block or &block: Proc(T) -> R def parse_block_parameter(param) - # &name or &name: Type - match = param.match(/^&(\w+)(?::\s*(.+?))?$/) + # &name or &name? or &name: Type or &name?: Type + match = param.match(/^&(\w+)(\?)?(?::\s*(.+?))?$/) return nil unless match param_name = match[1] - type_str = match[2]&.strip + optional = !match[2].nil? + type_str = match[3]&.strip result = { name: param_name, type: type_str, kind: :block, + optional: optional, } if type_str diff --git a/spec/e2e/class_rbs_generation_spec.rb b/spec/e2e/class_rbs_generation_spec.rb index f60f7dc..9bac50d 100644 --- a/spec/e2e/class_rbs_generation_spec.rb +++ b/spec/e2e/class_rbs_generation_spec.rb @@ -335,6 +335,45 @@ def map_values(initial: Integer, &block: Proc(Integer, Integer) -> String): Arra expect(rbs_content).to include("def map_values: (initial: Integer) { (Integer, Integer) -> String } -> Array[String]") end end + + it "generates RBS with optional block signature using ?{ } syntax" do + Dir.chdir(tmpdir) do + create_config_file(<<~YAML) + source: + include: + - src + output: + ruby_dir: build + rbs_dir: sig + compiler: + generate_rbs: true + YAML + + create_trb_file("src/optional_block.trb", <<~TRB) + class OptionalBlock + def maybe_yield(&block?: Proc(Integer) -> void): void + if block_given? + yield 1 + end + end + + def required_block(&block: Proc(String) -> String): String + yield "hello" + end + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/optional_block.trb")) + + # Validate RBS syntax using official rbs gem + expect_valid_rbs(rbs_content) + + # Optional block should use ?{ } syntax + expect(rbs_content).to include("def maybe_yield: () ?{ (Integer) -> void } -> void") + # Required block should use { } syntax (no ?) + expect(rbs_content).to include("def required_block: () { (String) -> String } -> String") + end + end end describe "HelloWorld integration test" do From 9d38dc7a466c16bc52addf27ad9b2341777ead86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6wenfels?= <282+dfl@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:46:35 +0000 Subject: [PATCH 5/6] feat: add Lambda type syntax distinct from Proc - Add Lambda(T) -> R syntax alongside Proc(T) -> R - Add callable_kind attribute to FunctionType (:proc, :lambda, nil) - Add proc? and lambda? helper methods - Both convert to same RBS (^(T) -> R) since RBS doesn't distinguish - to_trb preserves the distinction for round-trip serialization Lambda enforces strict argument checking semantics (like Ruby's lambda), while Proc is more lenient (like Ruby's Proc). Co-Authored-By: Claude Opus 4.5 --- lib/t_ruby/ir.rb | 24 +++++++++-- lib/t_ruby/parser_combinator/type_parser.rb | 9 ++++- spec/t_ruby/parser_combinator_spec.rb | 44 +++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index 807dcde..ddcde63 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -615,22 +615,40 @@ def to_trb # Function/Proc type ((String, Integer) -> Boolean) class FunctionType < TypeNode - attr_accessor :param_types, :return_type + attr_accessor :param_types, :return_type, :callable_kind - def initialize(return_type:, param_types: [], **opts) + # callable_kind: :proc, :lambda, or nil (generic function type) + def initialize(return_type:, param_types: [], callable_kind: nil, **opts) super(**opts) @param_types = param_types @return_type = return_type + @callable_kind = callable_kind end def to_rbs + # RBS doesn't distinguish between Proc and Lambda params = @param_types.map(&:to_rbs).join(", ") "^(#{params}) -> #{@return_type.to_rbs}" end def to_trb params = @param_types.map(&:to_trb).join(", ") - "(#{params}) -> #{@return_type.to_trb}" + case @callable_kind + when :proc + "Proc(#{params}) -> #{@return_type.to_trb}" + when :lambda + "Lambda(#{params}) -> #{@return_type.to_trb}" + else + "(#{params}) -> #{@return_type.to_trb}" + end + end + + def proc? + @callable_kind == :proc + end + + def lambda? + @callable_kind == :lambda end end diff --git a/lib/t_ruby/parser_combinator/type_parser.rb b/lib/t_ruby/parser_combinator/type_parser.rb index c8f733d..7cbb10a 100644 --- a/lib/t_ruby/parser_combinator/type_parser.rb +++ b/lib/t_ruby/parser_combinator/type_parser.rb @@ -69,7 +69,13 @@ def build_parsers # Proc type: Proc(Params) -> ReturnType (Ruby-familiar syntax) proc_keyword = lexeme(string("Proc")) proc_type = (proc_keyword >> param_list >> arrow >> type_expr).map do |((_proc, params), _arrow), ret| - IR::FunctionType.new(param_types: params, return_type: ret) + IR::FunctionType.new(param_types: params, return_type: ret, callable_kind: :proc) + end + + # Lambda type: Lambda(Params) -> ReturnType (strict argument checking) + lambda_keyword = lexeme(string("Lambda")) + lambda_type = (lambda_keyword >> param_list >> arrow >> type_expr).map do |((_lambda, params), _arrow), ret| + IR::FunctionType.new(param_types: params, return_type: ret, callable_kind: :lambda) end # Tuple type: [Type, Type, ...] @@ -81,6 +87,7 @@ def build_parsers # Primary type (before operators) primary_type = choice( + lambda_type, proc_type, function_type, tuple_type, diff --git a/spec/t_ruby/parser_combinator_spec.rb b/spec/t_ruby/parser_combinator_spec.rb index 7b0c969..03d8276 100644 --- a/spec/t_ruby/parser_combinator_spec.rb +++ b/spec/t_ruby/parser_combinator_spec.rb @@ -400,6 +400,50 @@ expect(result[:type].to_rbs).to eq("^(String, Integer) -> Boolean") end + it "parses Proc type with callable_kind :proc" do + result = parser.parse("Proc(Integer) -> String") + expect(result[:success]).to be true + expect(result[:type].callable_kind).to eq(:proc) + expect(result[:type].proc?).to be true + expect(result[:type].lambda?).to be false + end + + it "parses Lambda type with single parameter" do + result = parser.parse("Lambda(Integer) -> String") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::FunctionType) + expect(result[:type].param_types.length).to eq(1) + expect(result[:type].param_types.first.name).to eq("Integer") + expect(result[:type].return_type.name).to eq("String") + end + + it "parses Lambda type with callable_kind :lambda" do + result = parser.parse("Lambda(String) -> void") + expect(result[:success]).to be true + expect(result[:type].callable_kind).to eq(:lambda) + expect(result[:type].lambda?).to be true + expect(result[:type].proc?).to be false + end + + it "converts Lambda type to correct RBS format (same as Proc)" do + result = parser.parse("Lambda(String, Integer) -> Boolean") + expect(result[:success]).to be true + # RBS doesn't distinguish between Proc and Lambda + expect(result[:type].to_rbs).to eq("^(String, Integer) -> Boolean") + end + + it "converts Lambda type to correct t-ruby format" do + result = parser.parse("Lambda(Integer) -> String") + expect(result[:success]).to be true + expect(result[:type].to_trb).to eq("Lambda(Integer) -> String") + end + + it "converts Proc type to correct t-ruby format" do + result = parser.parse("Proc(Integer) -> String") + expect(result[:success]).to be true + expect(result[:type].to_trb).to eq("Proc(Integer) -> String") + end + it "parses tuple type" do result = parser.parse("[String, Integer, Boolean]") expect(result[:success]).to be true From f1f49552950de7405a7f4b279355ce4c34b63f99 Mon Sep 17 00:00:00 2001 From: "Yonghyun Kim (Freddy)" Date: Thu, 22 Jan 2026 17:32:34 +0900 Subject: [PATCH 6/6] fix: resolve rubocop Layout/ExtraSpacing violation Remove unnecessary double space before inline comment. Update Gemfile.lock for unicode-emoji 4.2.0. --- Gemfile.lock | 11 +---------- lib/t_ruby/compiler.rb | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2b35833..5c55d5d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,14 +11,9 @@ GEM benchmark (0.5.0) diff-lcs (1.6.2) docile (1.4.1) - ffi (1.17.2) - ffi (1.17.2-x86_64-linux-gnu) json (2.17.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) - listen (3.9.0) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) parallel (1.27.0) parser (3.3.10.0) @@ -28,9 +23,6 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.3.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) rbs (3.10.0) logger regexp_parser (2.11.3) @@ -73,14 +65,13 @@ GEM simplecov_json_formatter (0.1.4) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) PLATFORMS ruby x86_64-linux DEPENDENCIES - listen (~> 3.8) rake (~> 13.0) rbs (~> 3.0) rspec (~> 3.0) diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index fa5e596..c17e3b3 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -804,7 +804,7 @@ def erase_parameter_types(source) # Extract parameters from string, handling nested parentheses and braces # Returns [params_string, remainder] or [nil, nil] if no match def extract_balanced_params(str) - paren_depth = 1 # We're already past the opening paren + paren_depth = 1 # We're already past the opening paren brace_depth = 0 bracket_depth = 0 pos = 0