diff --git a/lib/t_ruby/ir.rb b/lib/t_ruby/ir.rb index 3093462..da04691 100644 --- a/lib/t_ruby/ir.rb +++ b/lib/t_ruby/ir.rb @@ -633,12 +633,69 @@ def initialize(element_types: [], **opts) end def to_rbs - "[#{@element_types.map(&:to_rbs).join(", ")}]" + # Check if tuple has rest element + has_rest = @element_types.any? { |t| t.is_a?(TupleRestElement) } + + if has_rest + # Fallback: convert to union array (RBS doesn't support tuple rest) + all_types = @element_types.flat_map do |t| + t.is_a?(TupleRestElement) ? t.element_type : t + end + type_names = all_types.map(&:to_rbs).uniq + "Array[#{type_names.join(" | ")}]" + else + "[#{@element_types.map(&:to_rbs).join(", ")}]" + end end def to_trb "[#{@element_types.map(&:to_trb).join(", ")}]" end + + # Validate tuple structure + def validate! + rest_indices = @element_types.each_with_index + .select { |t, _| t.is_a?(TupleRestElement) } + .map(&:last) + + if rest_indices.length > 1 + raise TypeError, "Tuple can have at most one rest element" + end + + if rest_indices.any? && rest_indices.first != @element_types.length - 1 + raise TypeError, "Rest element must be at the end of tuple" + end + + self + end + end + + # Tuple rest element (*Integer[] - variable length elements) + class TupleRestElement < TypeNode + attr_accessor :inner_type + + def initialize(inner_type:, **opts) + super(**opts) + @inner_type = inner_type + end + + def to_rbs + # RBS doesn't support tuple rest, fallback to untyped + "*untyped" + end + + def to_trb + "*#{@inner_type.to_trb}" + end + + # Extract element type from Array type + def element_type + if @inner_type.is_a?(GenericType) && @inner_type.base == "Array" + @inner_type.type_args.first + else + @inner_type + end + end end # Nullable type (String?) diff --git a/lib/t_ruby/lsp_server.rb b/lib/t_ruby/lsp_server.rb index 747e3c2..9f1132e 100644 --- a/lib/t_ruby/lsp_server.rb +++ b/lib/t_ruby/lsp_server.rb @@ -491,7 +491,7 @@ def handle_completion(params) end def type_completions - BUILT_IN_TYPES.map do |type| + completions = BUILT_IN_TYPES.map do |type| { "label" => type, "kind" => CompletionItemKind::CLASS, @@ -499,6 +499,27 @@ def type_completions "documentation" => "T-Ruby built-in type: #{type}", } end + + # Add tuple type completions + completions << { + "label" => "[T, U]", + "kind" => CompletionItemKind::STRUCT, + "detail" => "Tuple type", + "documentation" => "Fixed-length array with typed elements.\n\nExample: `[String, Integer]`", + "insertText" => "[${1:Type}, ${2:Type}]", + "insertTextFormat" => 2, # Snippet format + } + + completions << { + "label" => "[T, *U[]]", + "kind" => CompletionItemKind::STRUCT, + "detail" => "Tuple with rest", + "documentation" => "Tuple with variable-length rest elements.\n\nExample: `[Header, *Row[]]`", + "insertText" => "[${1:Type}, *${2:Type}[]]", + "insertTextFormat" => 2, + } + + completions end def keyword_completions @@ -618,6 +639,20 @@ def get_hover_info(word, text) return "**#{word}** - Built-in T-Ruby type" end + # Check if it's a tuple type pattern + if word.match?(/^\[.*\]$/) + return "**Tuple Type**\n\nFixed-length array with typed elements.\n\n" \ + "Each position can have a different type.\n\n" \ + "Example: `[String, Integer, Boolean]`" + end + + # Check if it's a rest element pattern + if word.start_with?("*") && (word.include?("[]") || word.include?("<")) + return "**Rest Element**\n\nVariable-length elements at the end of tuple.\n\n" \ + "Syntax: `*Type[]` or `*Array`\n\n" \ + "Example: `[Header, *Row[]]`" + end + # Check if it's a type alias parser = Parser.new(text) result = parser.parse diff --git a/lib/t_ruby/parser_combinator/type_parser.rb b/lib/t_ruby/parser_combinator/type_parser.rb index 5cce56e..28c5255 100644 --- a/lib/t_ruby/parser_combinator/type_parser.rb +++ b/lib/t_ruby/parser_combinator/type_parser.rb @@ -66,12 +66,17 @@ def build_parsers IR::FunctionType.new(param_types: params, return_type: ret) end - # Tuple type: [Type, Type, ...] + # Tuple type: [Type, Type, ...] or [Type, *Type[]] + # Note: Uses lazy reference to @tuple_element which is defined after base_type tuple_type = ( lexeme(char("[")) >> - type_expr.sep_by1(lexeme(char(","))) << + lazy { @tuple_element }.sep_by1(lexeme(char(","))) << lexeme(char("]")) - ).map { |(_, types)| IR::TupleType.new(element_types: types) } + ).map do |(_, types)| + tuple = IR::TupleType.new(element_types: types) + tuple.validate! # Validates rest element position + tuple + end # Primary type (before operators) primary_type = choice( @@ -101,6 +106,15 @@ def build_parsers end end + # Rest element for tuple: *Type[] or *Array + # Defined after base_type so it can reference it + rest_element = (lexeme(char("*")) >> base_type).map do |(_, inner)| + IR::TupleRestElement.new(inner_type: inner) + end + + # Tuple element: Type or *Type (rest element) + @tuple_element = rest_element | type_expr + # Union type: Type | Type | ... union_op = lexeme(char("|")) union_type = base_type.sep_by1(union_op).map do |types| diff --git a/spec/e2e/tuple_type_spec.rb b/spec/e2e/tuple_type_spec.rb new file mode 100644 index 0000000..3cd4500 --- /dev/null +++ b/spec/e2e/tuple_type_spec.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tempfile" +require "fileutils" +require "rbs" + +RSpec.describe "Tuple Type E2E" do + let(:tmpdir) { Dir.mktmpdir("trb_tuple_e2e") } + + before do + @original_dir = Dir.pwd + end + + after do + Dir.chdir(@original_dir) + FileUtils.rm_rf(tmpdir) + end + + # Helper to create config file with RBS generation enabled + def create_config_file(yaml_content) + config_path = File.join(tmpdir, "trbconfig.yml") + File.write(config_path, yaml_content) + config_path + end + + # Helper to create a .trb file + def create_trb_file(relative_path, content) + full_path = File.join(tmpdir, relative_path) + FileUtils.mkdir_p(File.dirname(full_path)) + File.write(full_path, content) + full_path + end + + # Helper to compile and get RBS content + def compile_and_get_rbs(trb_path, rbs_dir: "sig") + config = TRuby::Config.new + allow(config).to receive(:type_check?).and_return(false) + compiler = TRuby::Compiler.new(config) + compiler.compile(trb_path) + + relative_path = trb_path.sub("#{tmpdir}/src/", "") + rbs_path = File.join(tmpdir, rbs_dir, relative_path.sub(".trb", ".rbs")) + File.read(rbs_path) if File.exist?(rbs_path) + end + + # Helper to compile and get Ruby content + def compile_and_get_ruby(trb_path, ruby_dir: "build") + config = TRuby::Config.new + allow(config).to receive(:type_check?).and_return(false) + compiler = TRuby::Compiler.new(config) + compiler.compile(trb_path) + + relative_path = trb_path.sub("#{tmpdir}/src/", "") + ruby_path = File.join(tmpdir, ruby_dir, relative_path.sub(".trb", ".rb")) + File.read(ruby_path) if File.exist?(ruby_path) + end + + # Helper to assert RBS is valid + def expect_valid_rbs(rbs_content) + expect(rbs_content).not_to be_nil + expect(rbs_content.strip).not_to be_empty + + begin + RBS::Parser.parse_signature(rbs_content) + rescue RBS::ParsingError => e + first_line = rbs_content.strip.lines.first.to_s + unless first_line.start_with?("def ") || first_line.start_with?("type ") + raise "Generated RBS is invalid:\n#{rbs_content}\n\nParsing error: #{e.message}" + end + end + + rbs_content + end + + describe "basic tuple compilation" do + it "compiles basic tuple type to RBS" 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/pair.trb", <<~TRB) + def get_pair(): [String, Integer] + ["hello", 42] + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/pair.trb")) + + expect_valid_rbs(rbs_content) + expect(rbs_content).to include("def get_pair: () -> [String, Integer]") + end + end + + it "compiles tuple parameter to RBS" 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/process.trb", <<~TRB) + def process_pair(data: [String, Integer]): Boolean + true + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/process.trb")) + + expect_valid_rbs(rbs_content) + expect(rbs_content).to include("def process_pair: (data: [String, Integer]) -> Boolean") + end + end + end + + describe "tuple with rest element" do + it "compiles tuple with rest element to RBS (fallback to union array)" 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/values.trb", <<~TRB) + def get_values(): [String, *Integer[]] + ["header", 1, 2, 3] + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/values.trb")) + + expect_valid_rbs(rbs_content) + # RBS fallback: tuple with rest → union array + expect(rbs_content).to include("def get_values: () -> Array[String | Integer]") + end + end + + it "compiles tuple with generic rest element" 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/table.trb", <<~TRB) + def get_table(): [String, *Array] + ["title", {a: 1}, {b: 2}] + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/table.trb")) + + expect_valid_rbs(rbs_content) + expect(rbs_content).to include("Array[String | Hash]") + end + end + end + + describe "nested tuple compilation" do + it "compiles nested tuples" 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/matrix.trb", <<~TRB) + def get_matrix(): [[Integer, Integer], [Integer, Integer]] + [[1, 2], [3, 4]] + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/matrix.trb")) + + expect_valid_rbs(rbs_content) + expect(rbs_content).to include("def get_matrix: () -> [[Integer, Integer], [Integer, Integer]]") + end + end + end + + describe "type alias with tuple" do + it "compiles type alias with tuple" 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/point.trb", <<~TRB) + type Point = [Integer, Integer] + + def get_origin(): Point + [0, 0] + end + TRB + + rbs_content = compile_and_get_rbs(File.join(tmpdir, "src/point.trb")) + + expect_valid_rbs(rbs_content) + expect(rbs_content).to include("type Point = [Integer, Integer]") + end + end + end + + describe "Ruby output type erasure" do + it "removes tuple types in compiled Ruby" 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/typed_pair.trb", <<~TRB) + def get_pair(): [String, Integer] + ["hello", 42] + end + TRB + + ruby_content = compile_and_get_ruby(File.join(tmpdir, "src/typed_pair.trb")) + + expect(ruby_content).to include("def get_pair()") + expect(ruby_content).not_to include("[String, Integer]") + end + end + + it "removes tuple with rest types in compiled Ruby" 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/typed_rest.trb", <<~TRB) + def get_values(): [String, *Integer[]] + ["header", 1, 2, 3] + end + TRB + + ruby_content = compile_and_get_ruby(File.join(tmpdir, "src/typed_rest.trb")) + + expect(ruby_content).to include("def get_values()") + expect(ruby_content).not_to include("*Integer[]") + end + end + end + + describe "error handling" do + it "raises error when rest element is not at end" 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/bad.trb", <<~TRB) + def bad(): [*String[], Integer] + ["a", "b", 42] + end + TRB + + expect do + compile_and_get_rbs(File.join(tmpdir, "src/bad.trb")) + end.to raise_error(TypeError, /Rest element must be at the end of tuple/) + end + end + + it "raises error when multiple rest elements" 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/bad_multi.trb", <<~TRB) + def bad(): [*String[], *Integer[]] + ["a", 1, 2] + end + TRB + + expect do + compile_and_get_rbs(File.join(tmpdir, "src/bad_multi.trb")) + end.to raise_error(TypeError, /Tuple can have at most one rest element/) + end + end + end +end diff --git a/spec/t_ruby/ir_spec.rb b/spec/t_ruby/ir_spec.rb index 3e6ba47..388a43d 100644 --- a/spec/t_ruby/ir_spec.rb +++ b/spec/t_ruby/ir_spec.rb @@ -103,6 +103,62 @@ expect(type.to_rbs).to eq("[String, Integer]") expect(type.to_trb).to eq("[String, Integer]") end + + it "converts tuple with rest element to RBS (fallback to union array)" do + string_type = TRuby::IR::SimpleType.new(name: "String") + rest_int = TRuby::IR::TupleRestElement.new( + inner_type: TRuby::IR::GenericType.new(base: "Array", type_args: [ + TRuby::IR::SimpleType.new(name: "Integer"), + ]) + ) + tuple = described_class.new(element_types: [string_type, rest_int]) + + # RBS fallback: tuple with rest → union array + expect(tuple.to_rbs).to eq("Array[String | Integer]") + end + + it "preserves tuple with rest in TRB format" do + string_type = TRuby::IR::SimpleType.new(name: "String") + rest_int = TRuby::IR::TupleRestElement.new( + inner_type: TRuby::IR::GenericType.new(base: "Array", type_args: [ + TRuby::IR::SimpleType.new(name: "Integer"), + ]) + ) + tuple = described_class.new(element_types: [string_type, rest_int]) + + expect(tuple.to_trb).to eq("[String, *Array]") + end + end + + describe TRuby::IR::TupleRestElement do + it "converts to TRB format" do + inner = TRuby::IR::GenericType.new(base: "Array", type_args: [ + TRuby::IR::SimpleType.new(name: "Integer"), + ]) + rest = described_class.new(inner_type: inner) + + expect(rest.to_trb).to eq("*Array") + end + + it "converts to RBS format (fallback to untyped)" do + inner = TRuby::IR::GenericType.new(base: "Array", type_args: [ + TRuby::IR::SimpleType.new(name: "Integer"), + ]) + rest = described_class.new(inner_type: inner) + + # RBS doesn't support tuple rest, fallback + expect(rest.to_rbs).to eq("*untyped") + end + + it "extracts element type from Array type" do + inner = TRuby::IR::GenericType.new(base: "Array", type_args: [ + TRuby::IR::SimpleType.new(name: "Integer"), + ]) + rest = described_class.new(inner_type: inner) + + expect(rest.element_type).to be_a(TRuby::IR::SimpleType) + expect(rest.element_type.name).to eq("Integer") + end end describe TRuby::IR::Builder do diff --git a/spec/t_ruby/lsp_server_spec.rb b/spec/t_ruby/lsp_server_spec.rb index 4e79859..8913f1e 100644 --- a/spec/t_ruby/lsp_server_spec.rb +++ b/spec/t_ruby/lsp_server_spec.rb @@ -243,6 +243,33 @@ def send_notification(method, params = {}) expect(labels).to include("type", "interface", "def", "end") end + + it "provides tuple type completions" do + response = send_request("textDocument/completion", { + "textDocument" => { "uri" => "file:///test.trb" }, + "position" => { "line" => 1, "character" => 17 }, + }) + + items = response["result"]["items"] + labels = items.map { |i| i["label"] } + + expect(labels).to include("[T, U]", "[T, *U[]]") + end + + it "provides tuple completion with snippet format" do + response = send_request("textDocument/completion", { + "textDocument" => { "uri" => "file:///test.trb" }, + "position" => { "line" => 1, "character" => 17 }, + }) + + items = response["result"]["items"] + tuple_item = items.find { |i| i["label"] == "[T, U]" } + + expect(tuple_item).not_to be_nil + expect(tuple_item["detail"]).to eq("Tuple type") + expect(tuple_item["insertText"]).to eq("[${1:Type}, ${2:Type}]") + expect(tuple_item["insertTextFormat"]).to eq(2) # Snippet format + end end describe "textDocument/hover" do diff --git a/spec/t_ruby/parser_combinator_spec.rb b/spec/t_ruby/parser_combinator_spec.rb index 22dbdeb..efede9a 100644 --- a/spec/t_ruby/parser_combinator_spec.rb +++ b/spec/t_ruby/parser_combinator_spec.rb @@ -368,6 +368,35 @@ expect(result[:type].element_types.length).to eq(3) end + describe "tuple with rest element" do + it "parses tuple with rest element" do + result = parser.parse("[String, *Integer[]]") + expect(result[:success]).to be true + expect(result[:type]).to be_a(TRuby::IR::TupleType) + expect(result[:type].element_types.length).to eq(2) + expect(result[:type].element_types[1]).to be_a(TRuby::IR::TupleRestElement) + expect(result[:type].element_types[1].inner_type).to be_a(TRuby::IR::GenericType) + end + + it "parses tuple with generic rest element" do + result = parser.parse("[Header, *Array]") + expect(result[:success]).to be true + expect(result[:type].element_types[1]).to be_a(TRuby::IR::TupleRestElement) + end + + it "raises error when rest element is not at end" do + expect do + parser.parse("[*String[], Integer]") + end.to raise_error(TypeError, /Rest element must be at the end of tuple/) + end + + it "raises error when multiple rest elements" do + expect do + parser.parse("[*String[], *Integer[]]") + end.to raise_error(TypeError, /Tuple can have at most one rest element/) + end + end + it "parses complex nested type" do result = parser.parse("Map>> | nil") expect(result[:success]).to be true