diff --git a/lib/t_ruby/cli.rb b/lib/t_ruby/cli.rb index 3ff3f7e..52ba006 100644 --- a/lib/t_ruby/cli.rb +++ b/lib/t_ruby/cli.rb @@ -216,6 +216,9 @@ def compile(input_file, config_path: nil) output_path = compiler.compile(input_file) puts "Compiled: #{input_file} -> #{output_path}" + rescue TypeCheckError => e + puts "Type error: #{e.message}" + exit 1 rescue ArgumentError => e puts "Error: #{e.message}" exit 1 diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index 2da96f4..61a03d0 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -17,9 +17,14 @@ def initialize(config = nil, use_ir: true, optimize: true) @optimize = optimize @declaration_loader = DeclarationLoader.new @optimizer = IR::Optimizer.new if use_ir && optimize + @type_inferrer = ASTTypeInferrer.new if type_check? setup_declaration_paths if @config end + def type_check? + @config.type_check? + end + def compile(input_path) unless File.exist?(input_path) raise ArgumentError, "File not found: #{input_path}" @@ -40,6 +45,11 @@ def compile(input_path) parser = Parser.new(source, use_combinator: @use_ir) parse_result = parser.parse + # Run type checking if enabled + if type_check? && @use_ir && parser.ir_program + check_types(parser.ir_program, input_path) + end + # Transform source to Ruby code output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result) @@ -220,6 +230,140 @@ def compute_relative_path(input_path) private + # Check types in IR program and raise TypeCheckError if mismatches found + # @param ir_program [IR::Program] IR program to check + # @param file_path [String] source file path for error messages + def check_types(ir_program, file_path) + ir_program.declarations.each do |decl| + case decl + when IR::MethodDef + check_method_return_type(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) + end + end + end + end + + # Check if method's inferred return type matches declared return type + # @param method [IR::MethodDef] method to check + # @param class_def [IR::ClassDef, nil] containing class if any + # @param file_path [String] source file path for error messages + def check_method_return_type(method, class_def, file_path) + # Skip if no explicit return type annotation + return unless method.return_type + + declared_type = normalize_type(method.return_type.to_rbs) + + # Create type environment for the class context + class_env = create_class_env(class_def) if class_def + + # Infer actual return type + inferred_type = @type_inferrer.infer_method_return_type(method, class_env) + inferred_type = normalize_type(inferred_type || "nil") + + # Check compatibility + return if types_compatible?(inferred_type, declared_type) + + location = method.location ? "#{file_path}:#{method.location}" : file_path + method_name = class_def ? "#{class_def.name}##{method.name}" : method.name + + raise TypeCheckError.new( + message: "Return type mismatch in method '#{method_name}': " \ + "declared '#{declared_type}' but inferred '#{inferred_type}'", + location: location, + expected: declared_type, + actual: inferred_type + ) + end + + # Create type environment for class context + # @param class_def [IR::ClassDecl] class declaration + # @return [TypeEnv] type environment with instance variables + def create_class_env(class_def) + env = TypeEnv.new + + # Register instance variables from class + class_def.instance_vars&.each do |ivar| + type = ivar.type_annotation&.to_rbs || "untyped" + env.define_instance_var(ivar.name, type) + end + + env + end + + # Normalize type string for comparison + # @param type [String] type string + # @return [String] normalized type string + def normalize_type(type) + return "untyped" if type.nil? + + normalized = type.to_s.strip + + # Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool) + case normalized + when "Boolean", "TrueClass", "FalseClass" + "bool" + else + normalized + end + end + + # Check if inferred type is compatible with declared type + # @param inferred [String] inferred type + # @param declared [String] declared type + # @return [Boolean] true if compatible + def types_compatible?(inferred, declared) + # Exact match + return true if inferred == declared + + # untyped is compatible with anything + return true if inferred == "untyped" || declared == "untyped" + + # void is compatible with anything (no return value check) + return true if declared == "void" + + # nil is compatible with nullable types + return true if inferred == "nil" && declared.end_with?("?") + + # Subtype relationships + return true if subtype_of?(inferred, declared) + + # Handle union types in declared + if declared.include?("|") + declared_types = declared.split("|").map(&:strip) + return true if declared_types.include?(inferred) + return true if declared_types.any? { |t| types_compatible?(inferred, t) } + end + + # Handle union types in inferred - all must be compatible + if inferred.include?("|") + inferred_types = inferred.split("|").map(&:strip) + return inferred_types.all? { |t| types_compatible?(t, declared) } + end + + false + end + + # Check if subtype is a subtype of supertype + # @param subtype [String] potential subtype + # @param supertype [String] potential supertype + # @return [Boolean] true if subtype + def subtype_of?(subtype, supertype) + # Handle nullable - X is subtype of X? + return true if supertype.end_with?("?") && supertype[0..-2] == subtype + + # Numeric hierarchy + return true if subtype == "Integer" && supertype == "Numeric" + return true if subtype == "Float" && supertype == "Numeric" + + # Object is supertype of everything + return true if supertype == "Object" + + false + end + # Resolve path to absolute path, following symlinks # Falls back to expand_path if realpath fails (e.g., file doesn't exist yet) def resolve_path(path) diff --git a/lib/t_ruby/config.rb b/lib/t_ruby/config.rb index cb0f7a6..3ab2512 100644 --- a/lib/t_ruby/config.rb +++ b/lib/t_ruby/config.rb @@ -24,6 +24,7 @@ class Config "compiler" => { "strictness" => "standard", "generate_rbs" => true, + "type_check" => true, "target_ruby" => "3.0", "experimental" => [], "checks" => { @@ -89,6 +90,12 @@ def generate_rbs? @compiler["generate_rbs"] != false end + # Check if type checking is enabled + # @return [Boolean] true if type checking is enabled (default: true) + def type_check? + @compiler["type_check"] != false + end + # Get target Ruby version # @return [String] target Ruby version (e.g., "3.0", "3.2") def target_ruby diff --git a/lib/t_ruby/type_checker.rb b/lib/t_ruby/type_checker.rb index 5e224a4..3ce8214 100644 --- a/lib/t_ruby/type_checker.rb +++ b/lib/t_ruby/type_checker.rb @@ -1,38 +1,41 @@ # frozen_string_literal: true module TRuby - # Represents a type checking error - class TypeCheckError - attr_reader :message, :location, :expected, :actual, :suggestion, :severity + # Represents a type checking error (can be raised as an exception) + class TypeCheckError < StandardError + attr_reader :error_message, :location, :expected, :actual, :suggestion, :severity def initialize(message:, location: nil, expected: nil, actual: nil, suggestion: nil, severity: :error) - @message = message + @error_message = message @location = location @expected = expected @actual = actual @suggestion = suggestion @severity = severity - end - - def to_s - parts = [@message] - parts << " Expected: #{@expected}" if @expected - parts << " Actual: #{@actual}" if @actual - parts << " Suggestion: #{@suggestion}" if @suggestion - parts << " at #{@location}" if @location - parts.join("\n") + super(build_full_message) end def to_diagnostic { severity: @severity, - message: @message, + message: @error_message, location: @location, expected: @expected, actual: @actual, suggestion: @suggestion, } end + + private + + def build_full_message + parts = [@error_message] + parts << " Expected: #{@expected}" if @expected + parts << " Actual: #{@actual}" if @actual + parts << " Suggestion: #{@suggestion}" if @suggestion + parts << " at #{@location}" if @location + parts.join("\n") + end end # Type hierarchy for subtype checking diff --git a/spec/e2e/integration_spec.rb b/spec/e2e/integration_spec.rb index 55405c9..9809e0f 100644 --- a/spec/e2e/integration_spec.rb +++ b/spec/e2e/integration_spec.rb @@ -74,8 +74,9 @@ def create_user(email: Email, name: String): User end TRB - # Compile all files with custom config + # Compile all files with custom config (disable type checking for this test) config = TRuby::Config.new(File.join(tmpdir, "trbconfig.yml")) + allow(config).to receive(:type_check?).and_return(false) compiler = TRuby::Compiler.new(config) trb_files = Dir.glob(File.join(lib_dir, "*.trb")) diff --git a/spec/t_ruby/compiler_spec.rb b/spec/t_ruby/compiler_spec.rb index 51f0e15..fd5659d 100644 --- a/spec/t_ruby/compiler_spec.rb +++ b/spec/t_ruby/compiler_spec.rb @@ -257,6 +257,135 @@ end end + context "with return type validation" do + it "raises TypeCheckError when return type mismatches declaration" do + Dir.mktmpdir do |tmpdir| + # Method declares bool but returns nil + input_file = File.join(tmpdir, "type_mismatch.trb") + File.write(input_file, <<~RUBY) + def test(name: String): bool + return + 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.to raise_error(TRuby::TypeCheckError) + end + end + + it "raises TypeCheckError when inferred type doesn't match declared type" do + Dir.mktmpdir do |tmpdir| + # Method declares Integer but returns String + input_file = File.join(tmpdir, "type_mismatch2.trb") + File.write(input_file, <<~RUBY) + def get_value(): Integer + "hello" + 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("Integer") + expect(error.message).to include("String") + end + end + + it "passes when return type matches declaration" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "type_match.trb") + File.write(input_file, <<~RUBY) + def greet(name: String): String + "Hello, " + name + 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 + + it "skips type check when type_check config is false" do + Dir.mktmpdir do |tmpdir| + # Type mismatch but type_check is disabled in config + input_file = File.join(tmpdir, "skip_check.trb") + File.write(input_file, <<~RUBY) + def test(): bool + return + 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(false) + + compiler = TRuby::Compiler.new(config) + + expect do + compiler.compile(input_file) + end.not_to raise_error + end + end + + it "validates class methods" do + Dir.mktmpdir do |tmpdir| + input_file = File.join(tmpdir, "class_method.trb") + File.write(input_file, <<~RUBY) + class Calculator + def add(a: Integer, b: Integer): Integer + "not a number" + end + 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.to raise_error(TRuby::TypeCheckError) + end + end + end + context "with directory structure preservation" do it "preserves directory structure with single source_include" do Dir.mktmpdir do |tmpdir| @@ -366,6 +495,7 @@ allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(out_dir) allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([src_dir]) allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => true }) + allow_any_instance_of(TRuby::Config).to receive(:type_check?).and_return(false) compiler = TRuby::Compiler.new(config) compiler.compile(input_file)