Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/t_ruby/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions lib/t_ruby/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions lib/t_ruby/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Config
"compiler" => {
"strictness" => "standard",
"generate_rbs" => true,
"type_check" => true,
"target_ruby" => "3.0",
"experimental" => [],
"checks" => {
Expand Down Expand Up @@ -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
Expand Down
31 changes: 17 additions & 14 deletions lib/t_ruby/type_checker.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion spec/e2e/integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading