Skip to content

Commit a2be080

Browse files
authored
feat: integrate type checker into compiler for return type validation (#15)
* 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 * 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 * 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)
1 parent a32530d commit a2be080

File tree

6 files changed

+303
-15
lines changed

6 files changed

+303
-15
lines changed

lib/t_ruby/cli.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ def compile(input_file, config_path: nil)
216216

217217
output_path = compiler.compile(input_file)
218218
puts "Compiled: #{input_file} -> #{output_path}"
219+
rescue TypeCheckError => e
220+
puts "Type error: #{e.message}"
221+
exit 1
219222
rescue ArgumentError => e
220223
puts "Error: #{e.message}"
221224
exit 1

lib/t_ruby/compiler.rb

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ def initialize(config = nil, use_ir: true, optimize: true)
1717
@optimize = optimize
1818
@declaration_loader = DeclarationLoader.new
1919
@optimizer = IR::Optimizer.new if use_ir && optimize
20+
@type_inferrer = ASTTypeInferrer.new if type_check?
2021
setup_declaration_paths if @config
2122
end
2223

24+
def type_check?
25+
@config.type_check?
26+
end
27+
2328
def compile(input_path)
2429
unless File.exist?(input_path)
2530
raise ArgumentError, "File not found: #{input_path}"
@@ -40,6 +45,11 @@ def compile(input_path)
4045
parser = Parser.new(source, use_combinator: @use_ir)
4146
parse_result = parser.parse
4247

48+
# Run type checking if enabled
49+
if type_check? && @use_ir && parser.ir_program
50+
check_types(parser.ir_program, input_path)
51+
end
52+
4353
# Transform source to Ruby code
4454
output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
4555

@@ -220,6 +230,140 @@ def compute_relative_path(input_path)
220230

221231
private
222232

233+
# Check types in IR program and raise TypeCheckError if mismatches found
234+
# @param ir_program [IR::Program] IR program to check
235+
# @param file_path [String] source file path for error messages
236+
def check_types(ir_program, file_path)
237+
ir_program.declarations.each do |decl|
238+
case decl
239+
when IR::MethodDef
240+
check_method_return_type(decl, nil, file_path)
241+
when IR::ClassDecl
242+
decl.body.each do |member|
243+
check_method_return_type(member, decl, file_path) if member.is_a?(IR::MethodDef)
244+
end
245+
end
246+
end
247+
end
248+
249+
# Check if method's inferred return type matches declared return type
250+
# @param method [IR::MethodDef] method to check
251+
# @param class_def [IR::ClassDef, nil] containing class if any
252+
# @param file_path [String] source file path for error messages
253+
def check_method_return_type(method, class_def, file_path)
254+
# Skip if no explicit return type annotation
255+
return unless method.return_type
256+
257+
declared_type = normalize_type(method.return_type.to_rbs)
258+
259+
# Create type environment for the class context
260+
class_env = create_class_env(class_def) if class_def
261+
262+
# Infer actual return type
263+
inferred_type = @type_inferrer.infer_method_return_type(method, class_env)
264+
inferred_type = normalize_type(inferred_type || "nil")
265+
266+
# Check compatibility
267+
return if types_compatible?(inferred_type, declared_type)
268+
269+
location = method.location ? "#{file_path}:#{method.location}" : file_path
270+
method_name = class_def ? "#{class_def.name}##{method.name}" : method.name
271+
272+
raise TypeCheckError.new(
273+
message: "Return type mismatch in method '#{method_name}': " \
274+
"declared '#{declared_type}' but inferred '#{inferred_type}'",
275+
location: location,
276+
expected: declared_type,
277+
actual: inferred_type
278+
)
279+
end
280+
281+
# Create type environment for class context
282+
# @param class_def [IR::ClassDecl] class declaration
283+
# @return [TypeEnv] type environment with instance variables
284+
def create_class_env(class_def)
285+
env = TypeEnv.new
286+
287+
# Register instance variables from class
288+
class_def.instance_vars&.each do |ivar|
289+
type = ivar.type_annotation&.to_rbs || "untyped"
290+
env.define_instance_var(ivar.name, type)
291+
end
292+
293+
env
294+
end
295+
296+
# Normalize type string for comparison
297+
# @param type [String] type string
298+
# @return [String] normalized type string
299+
def normalize_type(type)
300+
return "untyped" if type.nil?
301+
302+
normalized = type.to_s.strip
303+
304+
# Normalize boolean types (bool/Boolean/TrueClass/FalseClass -> bool)
305+
case normalized
306+
when "Boolean", "TrueClass", "FalseClass"
307+
"bool"
308+
else
309+
normalized
310+
end
311+
end
312+
313+
# Check if inferred type is compatible with declared type
314+
# @param inferred [String] inferred type
315+
# @param declared [String] declared type
316+
# @return [Boolean] true if compatible
317+
def types_compatible?(inferred, declared)
318+
# Exact match
319+
return true if inferred == declared
320+
321+
# untyped is compatible with anything
322+
return true if inferred == "untyped" || declared == "untyped"
323+
324+
# void is compatible with anything (no return value check)
325+
return true if declared == "void"
326+
327+
# nil is compatible with nullable types
328+
return true if inferred == "nil" && declared.end_with?("?")
329+
330+
# Subtype relationships
331+
return true if subtype_of?(inferred, declared)
332+
333+
# Handle union types in declared
334+
if declared.include?("|")
335+
declared_types = declared.split("|").map(&:strip)
336+
return true if declared_types.include?(inferred)
337+
return true if declared_types.any? { |t| types_compatible?(inferred, t) }
338+
end
339+
340+
# Handle union types in inferred - all must be compatible
341+
if inferred.include?("|")
342+
inferred_types = inferred.split("|").map(&:strip)
343+
return inferred_types.all? { |t| types_compatible?(t, declared) }
344+
end
345+
346+
false
347+
end
348+
349+
# Check if subtype is a subtype of supertype
350+
# @param subtype [String] potential subtype
351+
# @param supertype [String] potential supertype
352+
# @return [Boolean] true if subtype
353+
def subtype_of?(subtype, supertype)
354+
# Handle nullable - X is subtype of X?
355+
return true if supertype.end_with?("?") && supertype[0..-2] == subtype
356+
357+
# Numeric hierarchy
358+
return true if subtype == "Integer" && supertype == "Numeric"
359+
return true if subtype == "Float" && supertype == "Numeric"
360+
361+
# Object is supertype of everything
362+
return true if supertype == "Object"
363+
364+
false
365+
end
366+
223367
# Resolve path to absolute path, following symlinks
224368
# Falls back to expand_path if realpath fails (e.g., file doesn't exist yet)
225369
def resolve_path(path)

lib/t_ruby/config.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Config
2424
"compiler" => {
2525
"strictness" => "standard",
2626
"generate_rbs" => true,
27+
"type_check" => true,
2728
"target_ruby" => "3.0",
2829
"experimental" => [],
2930
"checks" => {
@@ -89,6 +90,12 @@ def generate_rbs?
8990
@compiler["generate_rbs"] != false
9091
end
9192

93+
# Check if type checking is enabled
94+
# @return [Boolean] true if type checking is enabled (default: true)
95+
def type_check?
96+
@compiler["type_check"] != false
97+
end
98+
9299
# Get target Ruby version
93100
# @return [String] target Ruby version (e.g., "3.0", "3.2")
94101
def target_ruby

lib/t_ruby/type_checker.rb

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,41 @@
11
# frozen_string_literal: true
22

33
module TRuby
4-
# Represents a type checking error
5-
class TypeCheckError
6-
attr_reader :message, :location, :expected, :actual, :suggestion, :severity
4+
# Represents a type checking error (can be raised as an exception)
5+
class TypeCheckError < StandardError
6+
attr_reader :error_message, :location, :expected, :actual, :suggestion, :severity
77

88
def initialize(message:, location: nil, expected: nil, actual: nil, suggestion: nil, severity: :error)
9-
@message = message
9+
@error_message = message
1010
@location = location
1111
@expected = expected
1212
@actual = actual
1313
@suggestion = suggestion
1414
@severity = severity
15-
end
16-
17-
def to_s
18-
parts = [@message]
19-
parts << " Expected: #{@expected}" if @expected
20-
parts << " Actual: #{@actual}" if @actual
21-
parts << " Suggestion: #{@suggestion}" if @suggestion
22-
parts << " at #{@location}" if @location
23-
parts.join("\n")
15+
super(build_full_message)
2416
end
2517

2618
def to_diagnostic
2719
{
2820
severity: @severity,
29-
message: @message,
21+
message: @error_message,
3022
location: @location,
3123
expected: @expected,
3224
actual: @actual,
3325
suggestion: @suggestion,
3426
}
3527
end
28+
29+
private
30+
31+
def build_full_message
32+
parts = [@error_message]
33+
parts << " Expected: #{@expected}" if @expected
34+
parts << " Actual: #{@actual}" if @actual
35+
parts << " Suggestion: #{@suggestion}" if @suggestion
36+
parts << " at #{@location}" if @location
37+
parts.join("\n")
38+
end
3639
end
3740

3841
# Type hierarchy for subtype checking

spec/e2e/integration_spec.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ def create_user(email: Email, name: String): User
7474
end
7575
TRB
7676

77-
# Compile all files with custom config
77+
# Compile all files with custom config (disable type checking for this test)
7878
config = TRuby::Config.new(File.join(tmpdir, "trbconfig.yml"))
79+
allow(config).to receive(:type_check?).and_return(false)
7980
compiler = TRuby::Compiler.new(config)
8081

8182
trb_files = Dir.glob(File.join(lib_dir, "*.trb"))

0 commit comments

Comments
 (0)