From 668bc31610b341b1e410faf55044a9adbead1865 Mon Sep 17 00:00:00 2001 From: "Yonghyun Kim (Freddy)" Date: Wed, 24 Dec 2025 13:47:00 +0900 Subject: [PATCH 1/3] feat: integrate type checker into compiler for return type validation Add `type_check` option to Compiler that validates return types at compile time. When enabled, the compiler infers method return types using ASTTypeInferrer and compares them against declared return type annotations. Raises TypeCheckError on mismatch. Changes: - Add type_check parameter to Compiler.new - Integrate ASTTypeInferrer for return type inference - Validate both top-level functions and class methods - Support union types, nullable types, and subtype relationships - Fix TypeCheckError to properly inherit from StandardError Fixes #14 --- lib/t_ruby/compiler.rb | 137 ++++++++++++++++++++++++++++++++++- lib/t_ruby/type_checker.rb | 31 ++++---- spec/t_ruby/compiler_spec.rb | 124 +++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 16 deletions(-) diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index 2da96f4..546a007 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -9,14 +9,16 @@ module TRuby METHOD_NAME_PATTERN = "#{IDENTIFIER_CHAR}+[?!]?".freeze class Compiler - attr_reader :declaration_loader, :use_ir, :optimizer + attr_reader :declaration_loader, :use_ir, :optimizer, :type_check - def initialize(config = nil, use_ir: true, optimize: true) + def initialize(config = nil, use_ir: true, optimize: true, type_check: false) @config = config || Config.new @use_ir = use_ir @optimize = optimize + @type_check = type_check @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 @@ -40,6 +42,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 +227,132 @@ 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? + + type.to_s.strip + 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/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/t_ruby/compiler_spec.rb b/spec/t_ruby/compiler_spec.rb index 51f0e15..539d1ae 100644 --- a/spec/t_ruby/compiler_spec.rb +++ b/spec/t_ruby/compiler_spec.rb @@ -257,6 +257,130 @@ 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 }) + + compiler = TRuby::Compiler.new(config, type_check: true) + + 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 }) + + compiler = TRuby::Compiler.new(config, type_check: true) + + 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 }) + + compiler = TRuby::Compiler.new(config, type_check: true) + + expect do + compiler.compile(input_file) + end.not_to raise_error + end + end + + it "skips type check when type_check option is false" do + Dir.mktmpdir do |tmpdir| + # Type mismatch but type_check is disabled + 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 }) + + compiler = TRuby::Compiler.new(config, type_check: false) + + 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 }) + + compiler = TRuby::Compiler.new(config, type_check: true) + + 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| From 876d8054b25740e44bfa12c587aa310bb0b6e925 Mon Sep 17 00:00:00 2001 From: "Yonghyun Kim (Freddy)" Date: Wed, 24 Dec 2025 13:52:37 +0900 Subject: [PATCH 2/3] feat: enable type checking by default via Config - Add type_check: true to DEFAULT_CONFIG in Config class - Add Config#type_check? method to read the setting - Compiler reads type_check? from config instead of parameter - Remove redundant type_check parameter from Compiler.new - CLI and Watcher use config-driven type checking - Add type_check? mock to tests not testing type validation --- lib/t_ruby/cli.rb | 3 +++ lib/t_ruby/compiler.rb | 13 ++++++++----- lib/t_ruby/config.rb | 7 +++++++ spec/t_ruby/compiler_spec.rb | 20 +++++++++++++------- 4 files changed, 31 insertions(+), 12 deletions(-) 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 546a007..17084d4 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -9,19 +9,22 @@ module TRuby METHOD_NAME_PATTERN = "#{IDENTIFIER_CHAR}+[?!]?".freeze class Compiler - attr_reader :declaration_loader, :use_ir, :optimizer, :type_check + attr_reader :declaration_loader, :use_ir, :optimizer - def initialize(config = nil, use_ir: true, optimize: true, type_check: false) + def initialize(config = nil, use_ir: true, optimize: true) @config = config || Config.new @use_ir = use_ir @optimize = optimize - @type_check = type_check @declaration_loader = DeclarationLoader.new @optimizer = IR::Optimizer.new if use_ir && optimize - @type_inferrer = ASTTypeInferrer.new if type_check + @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}" @@ -43,7 +46,7 @@ def compile(input_path) parse_result = parser.parse # Run type checking if enabled - if @type_check && @use_ir && parser.ir_program + if type_check? && @use_ir && parser.ir_program check_types(parser.ir_program, input_path) end 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/spec/t_ruby/compiler_spec.rb b/spec/t_ruby/compiler_spec.rb index 539d1ae..fd5659d 100644 --- a/spec/t_ruby/compiler_spec.rb +++ b/spec/t_ruby/compiler_spec.rb @@ -272,8 +272,9 @@ def test(name: String): bool 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, type_check: true) + compiler = TRuby::Compiler.new(config) expect do compiler.compile(input_file) @@ -295,8 +296,9 @@ def get_value(): Integer 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, type_check: true) + compiler = TRuby::Compiler.new(config) error = nil begin @@ -324,8 +326,9 @@ def greet(name: String): String 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, type_check: true) + compiler = TRuby::Compiler.new(config) expect do compiler.compile(input_file) @@ -333,9 +336,9 @@ def greet(name: String): String end end - it "skips type check when type_check option is false" do + it "skips type check when type_check config is false" do Dir.mktmpdir do |tmpdir| - # Type mismatch but type_check is disabled + # 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 @@ -347,8 +350,9 @@ def test(): bool 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, type_check: false) + compiler = TRuby::Compiler.new(config) expect do compiler.compile(input_file) @@ -371,8 +375,9 @@ def add(a: Integer, b: Integer): Integer 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, type_check: true) + compiler = TRuby::Compiler.new(config) expect do compiler.compile(input_file) @@ -490,6 +495,7 @@ def add(a: Integer, b: Integer): Integer 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) From 8615a7f8e40d1601472181075d51552352a172c1 Mon Sep 17 00:00:00 2001 From: "Yonghyun Kim (Freddy)" Date: Wed, 24 Dec 2025 15:06:46 +0900 Subject: [PATCH 3/3] fix: normalize Boolean/bool types and disable type check in e2e - Normalize Boolean, TrueClass, FalseClass to bool in type comparison - Disable type checking in E2E integration test (tests compilation, not types) --- lib/t_ruby/compiler.rb | 10 +++++++++- spec/e2e/integration_spec.rb | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/t_ruby/compiler.rb b/lib/t_ruby/compiler.rb index 17084d4..61a03d0 100644 --- a/lib/t_ruby/compiler.rb +++ b/lib/t_ruby/compiler.rb @@ -299,7 +299,15 @@ def create_class_env(class_def) def normalize_type(type) return "untyped" if type.nil? - type.to_s.strip + 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 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"))