Skip to content

Commit 668bc31

Browse files
committed
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
1 parent a32530d commit 668bc31

File tree

3 files changed

+276
-16
lines changed

3 files changed

+276
-16
lines changed

lib/t_ruby/compiler.rb

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ module TRuby
99
METHOD_NAME_PATTERN = "#{IDENTIFIER_CHAR}+[?!]?".freeze
1010

1111
class Compiler
12-
attr_reader :declaration_loader, :use_ir, :optimizer
12+
attr_reader :declaration_loader, :use_ir, :optimizer, :type_check
1313

14-
def initialize(config = nil, use_ir: true, optimize: true)
14+
def initialize(config = nil, use_ir: true, optimize: true, type_check: false)
1515
@config = config || Config.new
1616
@use_ir = use_ir
1717
@optimize = optimize
18+
@type_check = type_check
1819
@declaration_loader = DeclarationLoader.new
1920
@optimizer = IR::Optimizer.new if use_ir && optimize
21+
@type_inferrer = ASTTypeInferrer.new if type_check
2022
setup_declaration_paths if @config
2123
end
2224

@@ -40,6 +42,11 @@ def compile(input_path)
4042
parser = Parser.new(source, use_combinator: @use_ir)
4143
parse_result = parser.parse
4244

45+
# Run type checking if enabled
46+
if @type_check && @use_ir && parser.ir_program
47+
check_types(parser.ir_program, input_path)
48+
end
49+
4350
# Transform source to Ruby code
4451
output = @use_ir ? transform_with_ir(source, parser) : transform_legacy(source, parse_result)
4552

@@ -220,6 +227,132 @@ def compute_relative_path(input_path)
220227

221228
private
222229

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

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/t_ruby/compiler_spec.rb

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,130 @@
257257
end
258258
end
259259

260+
context "with return type validation" do
261+
it "raises TypeCheckError when return type mismatches declaration" do
262+
Dir.mktmpdir do |tmpdir|
263+
# Method declares bool but returns nil
264+
input_file = File.join(tmpdir, "type_mismatch.trb")
265+
File.write(input_file, <<~RUBY)
266+
def test(name: String): bool
267+
return
268+
end
269+
RUBY
270+
271+
allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir)
272+
allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir)
273+
allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir])
274+
allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false })
275+
276+
compiler = TRuby::Compiler.new(config, type_check: true)
277+
278+
expect do
279+
compiler.compile(input_file)
280+
end.to raise_error(TRuby::TypeCheckError)
281+
end
282+
end
283+
284+
it "raises TypeCheckError when inferred type doesn't match declared type" do
285+
Dir.mktmpdir do |tmpdir|
286+
# Method declares Integer but returns String
287+
input_file = File.join(tmpdir, "type_mismatch2.trb")
288+
File.write(input_file, <<~RUBY)
289+
def get_value(): Integer
290+
"hello"
291+
end
292+
RUBY
293+
294+
allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir)
295+
allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir)
296+
allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir])
297+
allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false })
298+
299+
compiler = TRuby::Compiler.new(config, type_check: true)
300+
301+
error = nil
302+
begin
303+
compiler.compile(input_file)
304+
rescue TRuby::TypeCheckError => e
305+
error = e
306+
end
307+
308+
expect(error).to be_a(TRuby::TypeCheckError)
309+
expect(error.message).to include("Integer")
310+
expect(error.message).to include("String")
311+
end
312+
end
313+
314+
it "passes when return type matches declaration" do
315+
Dir.mktmpdir do |tmpdir|
316+
input_file = File.join(tmpdir, "type_match.trb")
317+
File.write(input_file, <<~RUBY)
318+
def greet(name: String): String
319+
"Hello, " + name
320+
end
321+
RUBY
322+
323+
allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir)
324+
allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir)
325+
allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir])
326+
allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false })
327+
328+
compiler = TRuby::Compiler.new(config, type_check: true)
329+
330+
expect do
331+
compiler.compile(input_file)
332+
end.not_to raise_error
333+
end
334+
end
335+
336+
it "skips type check when type_check option is false" do
337+
Dir.mktmpdir do |tmpdir|
338+
# Type mismatch but type_check is disabled
339+
input_file = File.join(tmpdir, "skip_check.trb")
340+
File.write(input_file, <<~RUBY)
341+
def test(): bool
342+
return
343+
end
344+
RUBY
345+
346+
allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir)
347+
allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir)
348+
allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir])
349+
allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false })
350+
351+
compiler = TRuby::Compiler.new(config, type_check: false)
352+
353+
expect do
354+
compiler.compile(input_file)
355+
end.not_to raise_error
356+
end
357+
end
358+
359+
it "validates class methods" do
360+
Dir.mktmpdir do |tmpdir|
361+
input_file = File.join(tmpdir, "class_method.trb")
362+
File.write(input_file, <<~RUBY)
363+
class Calculator
364+
def add(a: Integer, b: Integer): Integer
365+
"not a number"
366+
end
367+
end
368+
RUBY
369+
370+
allow_any_instance_of(TRuby::Config).to receive(:out_dir).and_return(tmpdir)
371+
allow_any_instance_of(TRuby::Config).to receive(:ruby_dir).and_return(tmpdir)
372+
allow_any_instance_of(TRuby::Config).to receive(:source_include).and_return([tmpdir])
373+
allow_any_instance_of(TRuby::Config).to receive(:compiler).and_return({ "generate_rbs" => false })
374+
375+
compiler = TRuby::Compiler.new(config, type_check: true)
376+
377+
expect do
378+
compiler.compile(input_file)
379+
end.to raise_error(TRuby::TypeCheckError)
380+
end
381+
end
382+
end
383+
260384
context "with directory structure preservation" do
261385
it "preserves directory structure with single source_include" do
262386
Dir.mktmpdir do |tmpdir|

0 commit comments

Comments
 (0)