Skip to content

Commit 837b08f

Browse files
Improved type generation + agent context + CI.
1 parent 04cf300 commit 837b08f

File tree

19 files changed

+547
-132
lines changed

19 files changed

+547
-132
lines changed

.github/workflows/test-types.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Test Types
2+
3+
on: [push, pull_request]
4+
5+
permissions:
6+
contents: read
7+
8+
env:
9+
CONSOLE_OUTPUT: XTerm
10+
11+
jobs:
12+
test:
13+
name: ${{matrix.ruby}} on ${{matrix.os}}
14+
runs-on: ${{matrix.os}}-latest
15+
16+
strategy:
17+
matrix:
18+
os:
19+
- ubuntu
20+
21+
ruby:
22+
- "3.4"
23+
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: ruby/setup-ruby@v1
27+
with:
28+
ruby-version: ${{matrix.ruby}}
29+
bundler-cache: true
30+
31+
- name: Run tests
32+
timeout-minutes: 10
33+
run: bundle exec steep check lib

context/types.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Setting Up RBS Types and Steep Type Checking for Ruby Gems
2+
3+
This guide covers the process for establishing robust type checking in Ruby gems using RBS and Steep, focusing on automated generation from source documentation and proper validation.
4+
5+
## Core Process
6+
7+
### Documentation-Driven RBS Generation
8+
9+
Generate RBS files from documentation:
10+
11+
```bash
12+
bake decode:rbs:generate lib > sig/example/gem.rbs`
13+
```
14+
15+
At a minimum, add `@parameter`, `@attribute` and `@returns` documentation to all public methods.
16+
17+
#### Parametric Types
18+
19+
Use `@rbs generic` comments to define type parameters for classes and modules:
20+
21+
```ruby
22+
# @rbs generic T
23+
class Container
24+
# @parameter item [T] The item to store
25+
def initialize(item)
26+
@item = item
27+
end
28+
29+
# @returns [T] The stored item
30+
def get
31+
@item
32+
end
33+
end
34+
```
35+
36+
Use `@rbs` comments for parametric method signatures:
37+
38+
```ruby
39+
# From above:
40+
class Container
41+
# @rbs () { (T) -> void } -> void
42+
def each
43+
yield @item
44+
end
45+
```
46+
47+
#### Interfaces
48+
49+
Create interfaces in `sig/example/gem/interface.rbs`:
50+
51+
```rbs
52+
module Example
53+
module Gem
54+
interface _Interface
55+
end
56+
end
57+
end
58+
```
59+
60+
You can use the interface in `@parameter`, `@attribute` and `@returns` types.
61+
62+
### Testing
63+
64+
Run tests using the `steep` gem.
65+
66+
```bash
67+
steep check
68+
```
69+
70+
**Process**: Start with basic generation, then refine based on Steep feedback.
71+
72+
1. Generate initial RBS from documentation
73+
2. Run `steep check lib` to identify issues
74+
3. Fix structural problems (inheritance, missing docs)
75+
4. Iterate until clean validation

gems.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
gem "rubocop"
2626
gem "rubocop-socketry"
2727

28+
gem "steep"
29+
2830
gem "bake-test"
2931
gem "bake-test-external"
3032

lib/decode/definition.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ def nested_name
106106
end
107107

108108
# Does the definition name match the specified prefix?
109+
# @parameter prefix [String] The prefix to match against.
109110
# @returns [Boolean]
110111
def start_with?(prefix)
111112
self.nested_name.start_with?(prefix)
112113
end
113114

114115
# Convert this definition into another kind of definition.
116+
# @parameter kind [Symbol] The kind to convert to.
115117
def convert(kind)
116118
raise ArgumentError, "Unable to convert #{self} into #{kind}!"
117119
end

lib/decode/language/reference.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ module Language
99
class Reference
1010
# Initialize the reference.
1111
# @parameter identifier [String] The identifier part of the reference.
12+
# @parameter language [Language::Generic] The language this reference belongs to.
13+
# @parameter lexical_path [Array(String) | nil] The lexical path scope for resolution.
1214
def initialize(identifier, language, lexical_path = nil)
1315
@identifier = identifier
1416
@language = language

lib/decode/language/ruby/reference.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module Ruby
1212
class Reference < Language::Reference
1313
# Create a reference from a constant node.
1414
# @parameter node [Prism::Node] The constant node.
15-
# @parameter language [Language] The language instance.
15+
# @parameter language [Language::Generic] The language instance.
1616
def self.from_const(node, language)
1717
lexical_path = append_const(node)
1818

@@ -22,6 +22,7 @@ def self.from_const(node, language)
2222
# Append a constant node to the path.
2323
# @parameter node [Prism::Node] The constant node.
2424
# @parameter path [Array] The path to append to.
25+
# @returns [Array] The path with constant information appended.
2526
def self.append_const(node, path = [])
2627
case node.type
2728
when :constant_read_node

lib/decode/rbs/class.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def generics
2727
# Convert the class definition to RBS AST
2828
def to_rbs_ast(method_definitions = [], index = nil)
2929
name = simple_name_to_rbs(@definition.name)
30-
comment = extract_comment(@definition)
30+
comment = self.comment
3131

3232
# Extract generics from RBS tags
3333
type_params = generics.map do |generic|
@@ -78,9 +78,16 @@ def simple_name_to_rbs(name)
7878
def qualified_name_to_rbs(qualified_name)
7979
parts = qualified_name.split("::")
8080
name = parts.pop
81-
namespace = ::RBS::Namespace.new(path: parts.map(&:to_sym), absolute: true)
8281

83-
::RBS::TypeName.new(name: name.to_sym, namespace: namespace)
82+
# For simple names (no ::), create relative references within current namespace
83+
if parts.empty?
84+
::RBS::TypeName.new(name: name.to_sym, namespace: ::RBS::Namespace.empty)
85+
else
86+
# For qualified names within the same root namespace, use relative references
87+
# This handles cases like Comment::Node, Language::Generic within Decode module
88+
namespace = ::RBS::Namespace.new(path: parts.map(&:to_sym), absolute: false)
89+
::RBS::TypeName.new(name: name.to_sym, namespace: namespace)
90+
end
8491
end
8592

8693
end

lib/decode/rbs/method.rb

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@
66
require "rbs"
77
require "console"
88
require_relative "wrapper"
9+
require_relative "type"
910

1011
module Decode
1112
module RBS
1213
# Represents a Ruby method definition wrapper for RBS generation.
1314
class Method < Wrapper
14-
1515
# Initialize a new method wrapper.
1616
# @parameter definition [Decode::Definition] The method definition to wrap.
1717
def initialize(definition)
1818
super
1919
@signatures = nil
20+
@keyword_arguments = nil
21+
@return_type = nil
22+
@parameters = nil
2023
end
2124

2225
# Extract method signatures from the method definition.
@@ -25,10 +28,28 @@ def signatures
2528
@signatures ||= extract_signatures
2629
end
2730

31+
# Extract keyword arguments from the method definition.
32+
# @returns [Hash] Hash with :required and :optional keys.
33+
def keyword_arguments
34+
@keyword_arguments ||= extract_keyword_arguments(@definition, nil)
35+
end
36+
37+
# Extract return type from the method definition.
38+
# @returns [::RBS::Types::t] The RBS return type.
39+
def return_type
40+
@return_type ||= extract_return_type(@definition, nil) || ::RBS::Parser.parse_type("untyped")
41+
end
42+
43+
# Extract parameters from the method definition.
44+
# @returns [Array] Array of RBS parameter objects.
45+
def parameters
46+
@parameters ||= extract_parameters(@definition, nil)
47+
end
48+
2849
# Convert the method definition to RBS AST
2950
def to_rbs_ast(index = nil)
3051
method_name = @definition.name
31-
comment = extract_comment(@definition)
52+
comment = self.comment
3253

3354
overloads = []
3455
if signatures.any?
@@ -40,8 +61,9 @@ def to_rbs_ast(index = nil)
4061
)
4162
end
4263
else
43-
return_type = extract_return_type(@definition, index) || ::RBS::Parser.parse_type("untyped")
44-
parameters = extract_parameters(@definition, index)
64+
return_type = self.return_type
65+
parameters = self.parameters
66+
keywords = self.keyword_arguments
4567
block_type = extract_block_type(@definition, index)
4668

4769
method_type = ::RBS::MethodType.new(
@@ -51,8 +73,8 @@ def to_rbs_ast(index = nil)
5173
optional_positionals: [],
5274
rest_positionals: nil,
5375
trailing_positionals: [],
54-
required_keywords: {},
55-
optional_keywords: {},
76+
required_keywords: keywords[:required],
77+
optional_keywords: keywords[:optional],
5678
rest_keywords: nil,
5779
return_type: return_type
5880
),
@@ -91,13 +113,23 @@ def extract_return_type(definition, index)
91113
# Look for @returns tags in the method's documentation
92114
documentation = definition.documentation
93115

94-
# Find @returns tag
95-
returns_tag = documentation&.filter(Decode::Comment::Returns)&.first
116+
# Find all @returns tags
117+
returns_tags = documentation&.filter(Decode::Comment::Returns)&.to_a
96118

97-
if returns_tag
98-
# Parse the type from the tag
99-
type_string = returns_tag.type.strip
100-
parse_type_string(type_string)
119+
if returns_tags&.any?
120+
if returns_tags.length == 1
121+
# Single return type
122+
type_string = returns_tags.first.type.strip
123+
Type.parse(type_string)
124+
else
125+
# Multiple return types - create union
126+
types = returns_tags.map do |tag|
127+
type_string = tag.type.strip
128+
Type.parse(type_string)
129+
end
130+
131+
::RBS::Types::Union.new(types: types, location: nil)
132+
end
101133
else
102134
# Infer return type based on method name patterns
103135
infer_return_type(definition)
@@ -109,14 +141,15 @@ def extract_parameters(definition, index)
109141
documentation = definition.documentation
110142
return [] unless documentation
111143

112-
# Find @parameter tags
144+
# Find @parameter tags (but not @option tags, which are handled separately)
113145
param_tags = documentation.filter(Decode::Comment::Parameter).to_a
146+
param_tags = param_tags.reject {|tag| tag.is_a?(Decode::Comment::Option)}
114147
return [] if param_tags.empty?
115148

116149
param_tags.map do |tag|
117150
name = tag.name
118151
type_string = tag.type.strip
119-
type = parse_type_string(type_string)
152+
type = Type.parse(type_string)
120153

121154
::RBS::Types::Function::Param.new(
122155
type: type,
@@ -125,6 +158,38 @@ def extract_parameters(definition, index)
125158
end
126159
end
127160

161+
# Extract keyword arguments from @option tags
162+
def extract_keyword_arguments(definition, index)
163+
documentation = definition.documentation
164+
return { required: {}, optional: {} } unless documentation
165+
166+
# Find @option tags
167+
option_tags = documentation.filter(Decode::Comment::Option).to_a
168+
return { required: {}, optional: {} } if option_tags.empty?
169+
170+
keywords = { required: {}, optional: {} }
171+
172+
option_tags.each do |tag|
173+
name = tag.name.to_s
174+
# Remove leading colon if present (e.g., ":cached" -> "cached")
175+
name = name.sub(/\A:/, "")
176+
177+
type_string = tag.type.strip
178+
type = Type.parse(type_string)
179+
180+
# Determine if the keyword is optional based on the type annotation
181+
# If the type is nullable (contains nil or ends with ?), make it optional
182+
if Type.nullable?(type)
183+
keywords[:optional][name.to_sym] = type
184+
else
185+
keywords[:required][name.to_sym] = type
186+
end
187+
end
188+
189+
keywords
190+
end
191+
192+
128193
# Extract block type from method documentation
129194
def extract_block_type(definition, index)
130195
documentation = definition.documentation
@@ -138,7 +203,7 @@ def extract_block_type(definition, index)
138203
block_params = yields_tag.filter(Decode::Comment::Parameter).map do |param_tag|
139204
name = param_tag.name
140205
type_string = param_tag.type.strip
141-
type = parse_type_string(type_string)
206+
type = Type.parse(type_string)
142207

143208
::RBS::Types::Function::Param.new(
144209
type: type,
@@ -199,19 +264,6 @@ def infer_return_type(definition)
199264
::RBS::Parser.parse_type("untyped")
200265
end
201266

202-
# Parse a type string and convert it to RBS type
203-
def parse_type_string(type_string)
204-
# This is for backwards compatibility with the old syntax, eventually we will emit warnings for these:
205-
type_string = type_string.tr("()", "[]")
206-
type_string.gsub!("| Nil", "| nil")
207-
type_string.gsub!("Boolean", "bool")
208-
209-
return ::RBS::Parser.parse_type(type_string)
210-
rescue => error
211-
Console.warn(self, "Failed to parse type string: #{type_string}", error)
212-
return ::RBS::Parser.parse_type("untyped")
213-
end
214-
215267
end
216268
end
217269
end

lib/decode/rbs/module.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def initialize(definition)
2020
# Convert the module definition to RBS AST
2121
def to_rbs_ast(method_definitions = [], index = nil)
2222
name = simple_name_to_rbs(@definition.name)
23-
comment = extract_comment(@definition)
23+
comment = self.comment
2424

2525
# Build method definitions
2626
methods = method_definitions.map{|method_def| Method.new(method_def).to_rbs_ast(index)}.compact

0 commit comments

Comments
 (0)