diff --git a/lib/t_ruby.rb b/lib/t_ruby.rb index 9b0a865..14498b9 100644 --- a/lib/t_ruby.rb +++ b/lib/t_ruby.rb @@ -5,6 +5,7 @@ require_relative "t_ruby/config" # Core infrastructure (must be loaded first) +require_relative "t_ruby/string_utils" require_relative "t_ruby/ir" require_relative "t_ruby/parser_combinator" require_relative "t_ruby/smt_solver" diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index 0c285ad..c3b5ca6 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -505,6 +505,7 @@ def remove_param_types(params_str) params = [] current = "" depth = 0 + brace_depth = 0 params_str.each_char do |char| case char @@ -514,9 +515,16 @@ def remove_param_types(params_str) when ">", "]", ")" depth -= 1 current += char + when "{" + brace_depth += 1 + current += char + when "}" + brace_depth -= 1 + current += char when "," - if depth.zero? - params << clean_param(current.strip) + if depth.zero? && brace_depth.zero? + cleaned = clean_param(current.strip) + params.concat(Array(cleaned)) if cleaned current = "" else current += char @@ -526,12 +534,39 @@ def remove_param_types(params_str) end end - params << clean_param(current.strip) unless current.empty? + cleaned = clean_param(current.strip) unless current.empty? + params.concat(Array(cleaned)) if cleaned params.join(", ") end # Clean a single parameter (remove type annotation, preserve default value) + # Returns String or Array of Strings (for keyword args group) def clean_param(param) + param = param.strip + return nil if param.empty? + + # 1. 더블 스플랫: **name: Type -> **name + if param.start_with?("**") + match = param.match(/^\*\*(\w+)(?::\s*.+)?$/) + return "**#{match[1]}" if match + + return param + end + + # 2. 키워드 인자 그룹: { ... } 또는 { ... }: InterfaceName + if param.start_with?("{") + return clean_keyword_args_group(param) + end + + # 3. Hash 리터럴: name: { ... } -> name + if param.match?(/^\w+:\s*\{/) + match = param.match(/^(\w+):\s*\{.+\}(?::\s*\w+)?$/) + return match[1] if match + + return param + end + + # 4. 일반 파라미터: name: Type = value -> name = value 또는 name: Type -> name # Match: name: Type = value (with default value) if (match = param.match(/^(#{TRuby::IDENTIFIER_CHAR}+)\s*:\s*.+?\s*(=\s*.+)$/)) "#{match[1]} #{match[2]}" @@ -543,6 +578,69 @@ def clean_param(param) end end + # 키워드 인자 그룹을 Ruby 키워드 인자로 변환 + # { name: String, age: Integer = 0 } -> name:, age: 0 + # { name:, age: 0 }: UserParams -> name:, age: 0 + def clean_keyword_args_group(param) + # { ... }: InterfaceName 또는 { ... } 형태 파싱 + interface_match = param.match(/^\{(.+)\}\s*:\s*\w+\s*$/) + inline_match = param.match(/^\{(.+)\}\s*$/) unless interface_match + + inner_content = if interface_match + interface_match[1] + elsif inline_match + inline_match[1] + else + return param + end + + # 내부 파라미터 분리 + parts = split_nested_content(inner_content) + keyword_params = [] + + parts.each do |part| + part = part.strip + next if part.empty? + + if interface_match + # interface 참조: name: default_value 또는 name: + if (match = part.match(/^(\w+):\s*(.*)$/)) + name = match[1] + default_value = match[2].strip + keyword_params << if default_value.empty? + "#{name}:" + else + "#{name}: #{default_value}" + end + end + elsif (match = part.match(/^(\w+):\s*(.+)$/)) + # 인라인 타입: name: Type = default 또는 name: Type + name = match[1] + type_and_default = match[2].strip + + # Type = default 분리 + default_value = extract_default_value(type_and_default) + keyword_params << if default_value + "#{name}: #{default_value}" + else + "#{name}:" + end + end + end + + keyword_params + end + + # 중첩된 내용을 콤마로 분리 + def split_nested_content(content) + StringUtils.split_by_comma(content) + end + + # 타입과 기본값에서 기본값만 추출 + def extract_default_value(type_and_default) + StringUtils.extract_default_value(type_and_default) + end + # Erase return type annotations def erase_return_types(source) result = source.dup diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index 6d6f213..2f60130 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -147,15 +147,19 @@ def children # Method parameter class Parameter < Node - attr_accessor :name, :type_annotation, :default_value, :kind + attr_accessor :name, :type_annotation, :default_value, :kind, :interface_ref - # kind: :required, :optional, :rest, :keyrest, :block - def initialize(name:, type_annotation: nil, default_value: nil, kind: :required, **opts) + # 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) super(**opts) @name = name @type_annotation = type_annotation @default_value = default_value @kind = kind + @interface_ref = interface_ref end end diff --git a/lib/t_ruby/parser.rb b/lib/t_ruby/parser.rb index 73a1c3f..5ed14ab 100644 --- a/lib/t_ruby/parser.rb +++ b/lib/t_ruby/parser.rb @@ -244,18 +244,36 @@ def parse_parameters(params_str) param_list = split_params(params_str) param_list.each do |param| - param_info = parse_single_parameter(param) - parameters << param_info if param_info + param = param.strip + + # 1. 더블 스플랫: **name: Type + if param.start_with?("**") + param_info = parse_double_splat_parameter(param) + parameters << param_info if param_info + # 2. 키워드 인자 그룹: { ... } 또는 { ... }: InterfaceName + elsif param.start_with?("{") + keyword_params = parse_keyword_args_group(param) + parameters.concat(keyword_params) if keyword_params + # 3. Hash 리터럴: name: { ... } + elsif param.match?(/^\w+:\s*\{/) + param_info = parse_hash_literal_parameter(param) + parameters << param_info if param_info + # 4. 일반 위치 인자: name: Type 또는 name: Type = default + else + param_info = parse_single_parameter(param) + parameters << param_info if param_info + end end parameters end def split_params(params_str) - # Handle nested generics like Array> + # Handle nested generics, braces, brackets result = [] current = "" depth = 0 + brace_depth = 0 params_str.each_char do |char| case char @@ -265,8 +283,14 @@ def split_params(params_str) when ">", "]", ")" depth -= 1 current += char + when "{" + brace_depth += 1 + current += char + when "}" + brace_depth -= 1 + current += char when "," - if depth.zero? + if depth.zero? && brace_depth.zero? result << current.strip current = "" else @@ -281,8 +305,10 @@ def split_params(params_str) result end - def parse_single_parameter(param) - match = param.match(/^(\w+)(?::\s*(.+?))?$/) + # 더블 스플랫 파라미터 파싱: **opts: Type + def parse_double_splat_parameter(param) + # **name: Type + match = param.match(/^\*\*(\w+)(?::\s*(.+?))?$/) return nil unless match param_name = match[1] @@ -291,6 +317,155 @@ def parse_single_parameter(param) result = { name: param_name, type: type_str, + kind: :keyrest, + } + + 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 형태 확인 + # 또는 { ... } 만 있는 형태 (인라인 타입) + interface_match = param.match(/^\{(.+)\}\s*:\s*(\w+)\s*$/) + inline_match = param.match(/^\{(.+)\}\s*$/) unless interface_match + + if interface_match + inner_content = interface_match[1] + interface_name = interface_match[2] + parse_keyword_args_with_interface(inner_content, interface_name) + elsif inline_match + inner_content = inline_match[1] + parse_keyword_args_inline(inner_content) + end + end + + # interface 참조 키워드 인자 파싱: { name:, age: 0 }: UserParams + def parse_keyword_args_with_interface(inner_content, interface_name) + parameters = [] + parts = split_keyword_args(inner_content) + + parts.each do |part| + part = part.strip + next if part.empty? + + # name: default_value 또는 name: 형태 + next unless part.match?(/^(\w+):\s*(.*)$/) + + match = part.match(/^(\w+):\s*(.*)$/) + param_name = match[1] + default_value = match[2].strip + default_value = nil if default_value.empty? + + parameters << { + name: param_name, + type: nil, # interface에서 타입을 가져옴 + default_value: default_value, + kind: :keyword, + interface_ref: interface_name, + } + end + + parameters + end + + # 인라인 타입 키워드 인자 파싱: { name: String, age: Integer = 0 } + def parse_keyword_args_inline(inner_content) + parameters = [] + parts = split_keyword_args(inner_content) + + parts.each do |part| + part = part.strip + next if part.empty? + + # name: Type = default 또는 name: Type 형태 + next unless part.match?(/^(\w+):\s*(.+)$/) + + match = part.match(/^(\w+):\s*(.+)$/) + param_name = match[1] + type_and_default = match[2].strip + + # Type = default 분리 + type_str, default_value = split_type_and_default(type_and_default) + + result = { + name: param_name, + type: type_str, + default_value: default_value, + kind: :keyword, + } + + if type_str + type_result = @type_parser.parse(type_str) + result[:ir_type] = type_result[:type] if type_result[:success] + end + + parameters << result + end + + parameters + end + + # 키워드 인자 내부를 콤마로 분리 (중첩된 제네릭/배열/해시 고려) + def split_keyword_args(content) + StringUtils.split_by_comma(content) + end + + # 타입과 기본값 분리: "String = 0" -> ["String", "0"] + def split_type_and_default(type_and_default) + StringUtils.split_type_and_default(type_and_default) + end + + # Hash 리터럴 파라미터 파싱: config: { host: String, port: Integer } + def parse_hash_literal_parameter(param) + # name: { ... } 또는 name: { ... }: InterfaceName + match = param.match(/^(\w+):\s*(\{.+\})(?::\s*(\w+))?$/) + return nil unless match + + param_name = match[1] + hash_type = match[2] + interface_name = match[3] + + result = { + name: param_name, + type: interface_name || hash_type, + kind: :required, + hash_type_def: hash_type, # 원본 해시 타입 정의 저장 + } + + result[:interface_ref] = interface_name if interface_name + + result + end + + def parse_single_parameter(param) + # name: Type = default 또는 name: Type 또는 name + # 기본값이 있는 경우 먼저 처리 + type_str = nil + default_value = nil + + if param.include?(":") + match = param.match(/^(\w+):\s*(.+)$/) + return nil unless match + + param_name = match[1] + type_and_default = match[2].strip + type_str, default_value = split_type_and_default(type_and_default) + else + # 타입 없이 이름만 있는 경우 + param_name = param.strip + end + + result = { + name: param_name, + type: type_str, + default_value: default_value, + kind: default_value ? :optional : :required, } # Parse type with combinator diff --git a/lib/t_ruby/string_utils.rb b/lib/t_ruby/string_utils.rb new file mode 100644 index 0000000..985b1c6 --- /dev/null +++ b/lib/t_ruby/string_utils.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module TRuby + # 문자열 파싱을 위한 공통 유틸리티 모듈 + # 파서와 컴파일러에서 공유하는 중첩 괄호 처리 로직 + module StringUtils + module_function + + # 중첩된 괄호를 고려하여 콤마로 문자열 분리 + # @param content [String] 분리할 문자열 + # @return [Array] 분리된 문자열 배열 + def split_by_comma(content) + result = [] + current = "" + depth = 0 + + content.each_char do |char| + case char + when "<", "[", "(", "{" + depth += 1 + current += char + when ">", "]", ")", "}" + depth -= 1 + current += char + when "," + if depth.zero? + result << current.strip + current = "" + else + current += char + end + else + current += char + end + end + + result << current.strip unless current.empty? + result + end + + # 타입과 기본값 분리: "String = 0" -> ["String", "0"] + # 중첩된 괄호 내부의 = 는 무시 + # @param type_and_default [String] "Type = default" 형태의 문자열 + # @return [Array] [type_str, default_value] 또는 [type_str, nil] + def split_type_and_default(type_and_default) + depth = 0 + equals_pos = nil + + type_and_default.each_char.with_index do |char, i| + case char + when "<", "[", "(", "{" + depth += 1 + when ">", "]", ")", "}" + depth -= 1 + when "=" + if depth.zero? + equals_pos = i + break + end + end + end + + if equals_pos + type_str = type_and_default[0...equals_pos].strip + default_value = type_and_default[(equals_pos + 1)..].strip + [type_str, default_value] + else + [type_and_default, nil] + end + end + + # 기본값만 추출 (타입은 버림) + # @param type_and_default [String] "Type = default" 형태의 문자열 + # @return [String, nil] 기본값 또는 nil + def extract_default_value(type_and_default) + _, default_value = split_type_and_default(type_and_default) + default_value + end + end +end diff --git a/spec/e2e/keyword_args_spec.rb b/spec/e2e/keyword_args_spec.rb new file mode 100644 index 0000000..bb5f74b --- /dev/null +++ b/spec/e2e/keyword_args_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tempfile" +require "fileutils" + +RSpec.describe "Keyword Arguments E2E" do + let(:tmpdir) { Dir.mktmpdir("trb_keyword_args") } + + after do + FileUtils.rm_rf(tmpdir) + end + + def create_config(lib_dir) + File.write(File.join(tmpdir, "trbconfig.yml"), <<~YAML) + emit: + rb: true + rbs: true + dtrb: false + paths: + src: "#{lib_dir}" + out: "#{lib_dir}" + rbs: "#{lib_dir}" + YAML + + config = TRuby::Config.new(File.join(tmpdir, "trbconfig.yml")) + allow(config).to receive(:type_check?).and_return(false) + config + end + + def compile_and_read(lib_dir, filename, source) + trb_path = File.join(lib_dir, "#{filename}.trb") + rb_path = File.join(lib_dir, "#{filename}.rb") + + File.write(trb_path, source) + + config = create_config(lib_dir) + compiler = TRuby::Compiler.new(config) + compiler.compile(trb_path) + + File.read(rb_path) + end + + describe "키워드 인자 (구조분해) - 인라인 타입" do + it "필수 키워드 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def greet({ name: String }): String + "Hello, \#{name}!" + end + TRB + + result = compile_and_read(lib_dir, "greet", source) + + expect(result).to include("def greet(name:)") + expect(result).not_to include("String") + expect(result).to include('"Hello, #{name}!"') + end + + it "여러 필수 키워드 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def create_point({ x: Integer, y: Integer }): String + "(\#{x}, \#{y})" + end + TRB + + result = compile_and_read(lib_dir, "point", source) + + expect(result).to include("def create_point(x:, y:)") + expect(result).not_to include("Integer") + end + + it "기본값이 있는 키워드 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def greet_with_prefix({ name: String, prefix: String = "Hello" }): String + "\#{prefix}, \#{name}!" + end + TRB + + result = compile_and_read(lib_dir, "greet_prefix", source) + + expect(result).to include('def greet_with_prefix(name:, prefix: "Hello")') + expect(result).not_to include("String") + end + + it "복잡한 타입과 기본값을 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def process_data({ items: Array = [], options: Hash = {} }): Integer + items.length + end + TRB + + result = compile_and_read(lib_dir, "process", source) + + expect(result).to include("def process_data(items: [], options: {})") + end + end + + describe "키워드 인자 (구조분해) - interface 참조" do + it "interface 참조 키워드 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + interface UserParams + name: String + age: Integer + end + + def create_user({ name:, age: }: UserParams): String + "\#{name} (\#{age})" + end + TRB + + result = compile_and_read(lib_dir, "user", source) + + expect(result).to include("def create_user(name:, age:)") + expect(result).not_to include("UserParams") + expect(result).not_to include("interface") + end + + it "기본값이 있는 interface 참조를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + interface ConnectionOptions + host: String + port?: Integer + timeout?: Integer + end + + def connect({ host:, port: 8080, timeout: 30 }: ConnectionOptions): String + "\#{host}:\#{port}" + end + TRB + + result = compile_and_read(lib_dir, "connect", source) + + expect(result).to include("def connect(host:, port: 8080, timeout: 30)") + end + end + + describe "더블 스플랫 (**opts: Type)" do + it "더블 스플랫 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + interface LogOptions + message: String + level?: Symbol + end + + def log(**kwargs: LogOptions): String + kwargs[:message] + end + TRB + + result = compile_and_read(lib_dir, "log", source) + + expect(result).to include("def log(**kwargs)") + expect(result).not_to include("LogOptions") + end + end + + describe "Hash 리터럴 (config: { ... })" do + it "Hash 리터럴 파라미터를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def process_config(config: { host: String, port: Integer }): String + "\#{config[:host]}:\#{config[:port]}" + end + TRB + + result = compile_and_read(lib_dir, "config", source) + + expect(result).to include("def process_config(config)") + expect(result).not_to include("String") + expect(result).not_to include("Integer") + end + end + + describe "위치 인자 + 키워드 인자 혼합" do + it "위치 인자와 키워드 인자 혼합을 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def format_name(name: String, { uppercase: Boolean = false }): String + uppercase ? name.upcase : name + end + TRB + + result = compile_and_read(lib_dir, "format", source) + + expect(result).to include("def format_name(name, uppercase: false)") + expect(result).not_to include("String") + expect(result).not_to include("Boolean") + end + + it "여러 위치 인자와 키워드 인자를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def calculate(a: Integer, b: Integer, { round: Boolean = false }): Integer + result = a + b + round ? result.round : result + end + TRB + + result = compile_and_read(lib_dir, "calc", source) + + expect(result).to include("def calculate(a, b, round: false)") + end + end + + describe "클래스 내부 메서드" do + it "클래스 내 키워드 인자 메서드를 올바르게 컴파일" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + class ApiClient + def initialize({ base_url: String, timeout: Integer = 30 }) + @base_url = base_url + @timeout = timeout + end + + def get({ path: String }): String + "\#{@base_url}\#{path}" + end + end + TRB + + result = compile_and_read(lib_dir, "api_client", source) + + expect(result).to include("def initialize(base_url:, timeout: 30)") + expect(result).to include("def get(path:)") + expect(result).not_to include("String") + expect(result).not_to include("Integer") + end + end + + describe "기존 위치 인자 호환성" do + it "기존 위치 인자 문법이 여전히 작동" do + lib_dir = File.join(tmpdir, "lib") + FileUtils.mkdir_p(lib_dir) + + source = <<~TRB + def positional_args(name: String, age: Integer = 0): String + "\#{name} (\#{age})" + end + TRB + + result = compile_and_read(lib_dir, "positional", source) + + expect(result).to include("def positional_args(name, age = 0)") + expect(result).not_to include("String") + expect(result).not_to include("Integer") + end + end +end