Skip to content

Commit 8f7e855

Browse files
sinsokuclaude
authored andcommitted
Implement RBS Record type support
Add comprehensive support for RBS Record types ({ key: Type }) in TypeProf with field access, type checking, and error handling capabilities. ## Core Implementation * **SigTyRecordNode**: Parse and process Record type definitions from RBS * **Record class**: New type class with field access and matching support * **Hash integration**: Shared builtin support for Hash and Record types * **Type compatibility**: Enable Hash-Record interoperability in method calls ## Features * Field access via [] operator (e.g., record[:name] -> String) * Proper type checking for Record parameters and return values * Union type support for Record field values * Graceful error handling for non-existent field access (returns untyped) * Hash[Symbol, T] to Record type compatibility ## Test Coverage Added 9 comprehensive test files covering: - Basic Record type usage and type propagation - Field access and nested Record structures - Hash-Record type compatibility scenarios - Error handling for invalid field access - Optional Record types and empty Records - Array integration with Record types ## Technical Details Record types are internally represented with a base Hash type to maintain compatibility with Ruby's hash-like access patterns. Field values are properly unified into a union type for the underlying Hash value type. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9abbc38 commit 8f7e855

12 files changed

Lines changed: 301 additions & 6 deletions

lib/typeprof/core/ast/sig_type.rb

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -487,16 +487,55 @@ def show
487487
end
488488

489489
class SigTyRecordNode < SigTyNode
490+
def initialize(raw_decl, lenv)
491+
super(raw_decl, lenv)
492+
@fields = raw_decl.fields.transform_values { |val| AST.create_rbs_type(val, lenv) }
493+
end
494+
495+
attr_reader :fields
496+
def subnodes = { fields: }
497+
490498
def covariant_vertex0(genv, changes, vtx, subst)
491-
raise NotImplementedError
499+
field_vertices = {}
500+
@fields.each do |key, field_node|
501+
field_vertices[key] = field_node.covariant_vertex(genv, changes, subst)
502+
end
503+
504+
# Create base Hash type for Record
505+
key_vtx = Source.new(genv.symbol_type)
506+
# Create union of all field values for the Hash value type
507+
val_vtx = changes.new_covariant_vertex(genv, [self, :union])
508+
field_vertices.each_value do |field_vtx|
509+
changes.add_edge(genv, field_vtx, val_vtx)
510+
end
511+
base_hash_type = genv.gen_hash_type(key_vtx, val_vtx)
512+
513+
changes.add_edge(genv, Source.new(Type::Record.new(genv, field_vertices, base_hash_type)), vtx)
492514
end
493515

494516
def contravariant_vertex0(genv, changes, vtx, subst)
495-
raise NotImplementedError
517+
field_vertices = {}
518+
@fields.each do |key, field_node|
519+
field_vertices[key] = field_node.contravariant_vertex(genv, changes, subst)
520+
end
521+
522+
# Create base Hash type for Record
523+
key_vtx = Source.new(genv.symbol_type)
524+
# Create union of all field values for the Hash value type
525+
val_vtx = changes.new_contravariant_vertex(genv, [self, :union])
526+
field_vertices.each_value do |field_vtx|
527+
changes.add_edge(genv, field_vtx, val_vtx)
528+
end
529+
base_hash_type = genv.gen_hash_type(key_vtx, val_vtx)
530+
531+
changes.add_edge(genv, Source.new(Type::Record.new(genv, field_vertices, base_hash_type)), vtx)
496532
end
497533

498534
def show
499-
"(...record...)"
535+
field_strs = @fields.map do |key, field_node|
536+
"#{ key }: #{ field_node.show }"
537+
end
538+
"{ #{ field_strs.join(", ") } }"
500539
end
501540
end
502541

lib/typeprof/core/builtin.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,16 @@ def array_push(changes, node, ty, a_args, ret)
9090
def hash_aref(changes, node, ty, a_args, ret)
9191
if a_args.positionals.size == 1
9292
case ty
93-
when Type::Hash
93+
when Type::Hash, Type::Record
9494
idx = node.positional_args[0]
9595
idx = idx.is_a?(AST::SymbolNode) ? idx.lit : nil
96-
changes.add_edge(@genv, ty.get_value(idx), ret)
96+
value = ty.get_value(idx)
97+
if value
98+
changes.add_edge(@genv, value, ret)
99+
else
100+
# Return untyped for unknown fields
101+
changes.add_edge(@genv, Source.new(), ret)
102+
end
97103
true
98104
else
99105
false

lib/typeprof/core/type.rb

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,28 @@ def base_type(genv)
252252
end
253253

254254
def check_match(genv, changes, vtx)
255-
# TODO: implement
255+
vtx.each_type do |other_ty|
256+
case other_ty
257+
when Record
258+
# Hash can match with Record if Hash has Symbol keys
259+
# and Hash value type can accept all Record field values
260+
key_ty = get_key
261+
val_ty = get_value
262+
263+
# Check if this Hash has Symbol keys
264+
return false unless key_ty && Source.new(genv.symbol_type).check_match(genv, changes, key_ty)
265+
266+
# Check if Hash value type contains all required types for Record fields
267+
other_ty.fields.each do |_key, field_val_vtx|
268+
# For each Record field, check if field type can match with Hash value type
269+
return false unless val_ty && field_val_vtx.check_match(genv, changes, val_ty)
270+
end
271+
272+
return true
273+
end
274+
end
275+
276+
# Fall back to base_type check for other cases
256277
@base_type.check_match(genv, changes, vtx)
257278
end
258279

@@ -348,5 +369,72 @@ def show
348369
"var[#{ @name }]"
349370
end
350371
end
372+
373+
class Record < Type
374+
#: (GlobalEnv, ::Hash[Symbol, Vertex], Instance) -> void
375+
def initialize(genv, fields, base_type)
376+
@fields = fields
377+
@base_type = base_type
378+
raise unless base_type.is_a?(Instance)
379+
end
380+
381+
attr_reader :fields
382+
383+
def get_value(key = nil)
384+
if key
385+
# Return specific field value if it exists
386+
@fields[key]
387+
elsif @fields.empty?
388+
# Empty record has no values
389+
nil
390+
else
391+
# Return union of all field values if no specific key
392+
@base_type.args[1]
393+
end
394+
end
395+
396+
def base_type(genv)
397+
@base_type
398+
end
399+
400+
def check_match(genv, changes, vtx)
401+
vtx.each_type do |other_ty|
402+
case other_ty
403+
when Record
404+
# Check if all fields match
405+
return false unless @fields.size == other_ty.fields.size
406+
@fields.each do |key, val_vtx|
407+
other_val_vtx = other_ty.fields[key]
408+
return false unless other_val_vtx
409+
return false unless val_vtx.check_match(genv, changes, other_val_vtx)
410+
end
411+
return true
412+
when Hash
413+
# Record can match with Hash only if the Hash has Symbol keys
414+
# and all record values can match with the Hash value type
415+
key_vtx = other_ty.get_key
416+
val_vtx = other_ty.get_value
417+
418+
# Check if Hash key type is Symbol
419+
return false unless key_vtx && Source.new(genv.symbol_type).check_match(genv, changes, key_vtx)
420+
421+
# Check if all record field values can match with Hash value type
422+
@fields.each do |_key, field_val_vtx|
423+
return false unless field_val_vtx.check_match(genv, changes, val_vtx)
424+
end
425+
426+
return true
427+
end
428+
end
429+
return false
430+
end
431+
432+
def show
433+
field_strs = @fields.map do |key, val_vtx|
434+
"#{ key }: #{ Type.strip_parens(val_vtx.show) }"
435+
end
436+
"{ #{ field_strs.join(", ") } }"
437+
end
438+
end
351439
end
352440
end

scenario/rbs/record-arrays.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## update: test.rbs
2+
class RecordArrays
3+
def get_users: -> Array[{ id: Integer, name: String }]
4+
end
5+
6+
## update: test.rb
7+
class RecordArrays
8+
def first_user
9+
users = get_users
10+
users.first
11+
end
12+
end
13+
14+
## assert: test.rb
15+
class RecordArrays
16+
def first_user: -> { id: Integer, name: String }
17+
end

scenario/rbs/record-basic.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## update: test.rbs
2+
class BasicRecord
3+
def simple_record: -> { name: String, age: Integer }
4+
end
5+
6+
## update: test.rb
7+
class BasicRecord
8+
def get_record
9+
simple_record
10+
end
11+
end
12+
13+
## assert: test.rb
14+
class BasicRecord
15+
def get_record: -> { name: String, age: Integer }
16+
end

scenario/rbs/record-empty.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## update: test.rbs
2+
class EmptyRecord
3+
def empty_record: -> { }
4+
end
5+
6+
## update: test.rb
7+
class EmptyRecord
8+
def get_empty
9+
empty_record
10+
end
11+
end
12+
13+
## assert: test.rb
14+
class EmptyRecord
15+
def get_empty: -> { }
16+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## update: test.rbs
2+
class RecordFieldAccess
3+
def get_person: -> { name: String, age: Integer }
4+
end
5+
6+
## update: test.rb
7+
class RecordFieldAccess
8+
def get_name
9+
person = get_person
10+
person[:name]
11+
end
12+
end
13+
14+
## assert: test.rb
15+
class RecordFieldAccess
16+
def get_name: -> String
17+
end

scenario/rbs/record-field-error.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## update: test.rbs
2+
class RecordFieldError
3+
def get_person: -> { name: String, age: Integer }
4+
end
5+
6+
## update: test.rb
7+
class RecordFieldError
8+
def get_unknown_field
9+
person = get_person
10+
person[:unknown] # Access non-existent field
11+
end
12+
end
13+
14+
## assert: test.rb
15+
class RecordFieldError
16+
def get_unknown_field: -> untyped
17+
end

scenario/rbs/record-hash-compat.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
## update: test.rbs
2+
class RecordHashCompat
3+
def get_symbol_hash: -> Hash[Symbol, String | Integer]
4+
def accept_record: ({ name: String, age: Integer }) -> void
5+
end
6+
7+
## update: test.rb
8+
class RecordHashCompat
9+
def test_hash_to_record
10+
hash_data = get_symbol_hash
11+
accept_record(hash_data)
12+
end
13+
end
14+
15+
## assert: test.rb
16+
class RecordHashCompat
17+
def test_hash_to_record: -> Object
18+
end

scenario/rbs/record-nested.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## update: test.rbs
2+
class NestedRecord
3+
def get_company: -> { name: String, owner: { name: String, age: Integer } }
4+
end
5+
6+
## update: test.rb
7+
class NestedRecord
8+
def get_owner_name
9+
company = get_company
10+
company[:owner][:name]
11+
end
12+
end
13+
14+
## assert: test.rb
15+
class NestedRecord
16+
def get_owner_name: -> String
17+
end

0 commit comments

Comments
 (0)