chiridion 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/lib/chiridion/config.rb +128 -0
  6. data/lib/chiridion/engine/class_linker.rb +204 -0
  7. data/lib/chiridion/engine/document_model.rb +299 -0
  8. data/lib/chiridion/engine/drift_checker.rb +146 -0
  9. data/lib/chiridion/engine/extractor.rb +311 -0
  10. data/lib/chiridion/engine/file_renderer.rb +717 -0
  11. data/lib/chiridion/engine/file_writer.rb +160 -0
  12. data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
  13. data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
  14. data/lib/chiridion/engine/github_linker.rb +87 -0
  15. data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
  16. data/lib/chiridion/engine/post_processor.rb +86 -0
  17. data/lib/chiridion/engine/rbs_loader.rb +150 -0
  18. data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
  19. data/lib/chiridion/engine/renderer.rb +598 -0
  20. data/lib/chiridion/engine/semantic_extractor.rb +740 -0
  21. data/lib/chiridion/engine/semantic_renderer.rb +334 -0
  22. data/lib/chiridion/engine/spec_example_loader.rb +84 -0
  23. data/lib/chiridion/engine/template_renderer.rb +275 -0
  24. data/lib/chiridion/engine/type_merger.rb +126 -0
  25. data/lib/chiridion/engine/writer.rb +134 -0
  26. data/lib/chiridion/engine.rb +359 -0
  27. data/lib/chiridion/semantic_engine.rb +186 -0
  28. data/lib/chiridion/version.rb +5 -0
  29. data/lib/chiridion.rb +106 -0
  30. data/templates/constants.liquid +27 -0
  31. data/templates/document.liquid +48 -0
  32. data/templates/file.liquid +108 -0
  33. data/templates/index.liquid +21 -0
  34. data/templates/method.liquid +43 -0
  35. data/templates/methods.liquid +11 -0
  36. data/templates/type_aliases.liquid +26 -0
  37. data/templates/types.liquid +11 -0
  38. metadata +146 -0
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Comprehensive loader for RBS::Inline-generated .rbs files.
6
+ #
7
+ # Unlike the simpler RbsLoader, this extracts ALL available information
8
+ # from generated RBS files:
9
+ #
10
+ # - Method signatures with parameter types and return types
11
+ # - Instance variable declarations (@name: Type)
12
+ # - Attribute declarations (attr_reader name: Type)
13
+ # - Type aliases (type name = definition)
14
+ # - Class/module structure with comments
15
+ #
16
+ # The generated RBS files are authoritative - they've been properly parsed
17
+ # by rbs-inline from the source annotations. We just need to read them.
18
+ #
19
+ # @example
20
+ # loader = GeneratedRbsLoader.new(verbose: true, logger: logger)
21
+ # data = loader.load("sig/generated")
22
+ # # => { signatures: {...}, ivars: {...}, attrs: {...}, type_aliases: {...} }
23
+ #
24
+ class GeneratedRbsLoader
25
+ # Result structure from loading.
26
+ Result = Data.define(
27
+ :signatures, # Hash[class_path => Hash[method_name => signature_data]]
28
+ :ivars, # Hash[class_path => Hash[ivar_name => { type:, desc: }]]
29
+ :attrs, # Hash[class_path => Hash[attr_name => { type:, desc: }]]
30
+ :type_aliases, # Hash[namespace => Array[{ name:, definition:, description: }]]
31
+ :overloads # Hash[class_path => Hash[method_name => Array[signature_strings]]]
32
+ )
33
+
34
+ def initialize(verbose: false, logger: nil)
35
+ @verbose = verbose
36
+ @logger = logger
37
+ end
38
+
39
+ # Load all data from generated RBS directory.
40
+ #
41
+ # @param rbs_dir [String] Path to generated RBS directory (e.g., "sig/generated")
42
+ # @return [Result] All extracted data
43
+ def load(rbs_dir)
44
+ signatures = {}
45
+ ivars = {}
46
+ attrs = {}
47
+ type_aliases = {}
48
+ overloads = {}
49
+
50
+ return empty_result unless rbs_dir && Dir.exist?(rbs_dir)
51
+
52
+ rbs_files = Dir.glob(File.join(rbs_dir, "**/*.rbs"))
53
+ rbs_files.each do |file|
54
+ parse_file(file, signatures, ivars, attrs, type_aliases, overloads)
55
+ end
56
+
57
+ log_stats(signatures, ivars, attrs, type_aliases) if @verbose
58
+
59
+ Result.new(
60
+ signatures: signatures,
61
+ ivars: ivars,
62
+ attrs: attrs,
63
+ type_aliases: type_aliases,
64
+ overloads: overloads
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def empty_result
71
+ Result.new(
72
+ signatures: {},
73
+ ivars: {},
74
+ attrs: {},
75
+ type_aliases: {},
76
+ overloads: {}
77
+ )
78
+ end
79
+
80
+ def log_stats(signatures, ivars, attrs, type_aliases)
81
+ method_count = signatures.values.sum { |m| m.size }
82
+ ivar_count = ivars.values.sum { |i| i.size }
83
+ attr_count = attrs.values.sum { |a| a.size }
84
+ type_count = type_aliases.values.sum { |t| t.size }
85
+
86
+ @logger&.info "Loaded from generated RBS: #{method_count} methods, " \
87
+ "#{ivar_count} ivars, #{attr_count} attrs, #{type_count} type aliases"
88
+ end
89
+
90
+ def parse_file(file, signatures, ivars, attrs, type_aliases, overloads)
91
+ content = File.read(file)
92
+ lines = content.lines
93
+
94
+ namespace_stack = []
95
+ pending_comment = nil
96
+ pending_method = nil # For collecting method overloads
97
+
98
+ lines.each_with_index do |line, _idx|
99
+ stripped = line.strip
100
+
101
+ # Track module/class context
102
+ if stripped =~ /^(?:class|module)\s+([\w:]+)/
103
+ name = Regexp.last_match(1)
104
+ namespace_stack.push(name)
105
+ pending_comment = nil
106
+ next
107
+ end
108
+
109
+ # Track end statements
110
+ if stripped == "end"
111
+ namespace_stack.pop if namespace_stack.any?
112
+ pending_comment = nil
113
+ pending_method = nil
114
+ next
115
+ end
116
+
117
+ # Collect comments (may be description for next declaration)
118
+ if stripped.start_with?("#")
119
+ comment_text = stripped.sub(/^#\s*/, "")
120
+ pending_comment = pending_comment ? "#{pending_comment}\n#{comment_text}" : comment_text
121
+ next
122
+ end
123
+
124
+ # Skip blank lines but preserve pending_comment
125
+ next if stripped.empty?
126
+
127
+ current_namespace = namespace_stack.join("::")
128
+ next if current_namespace.empty?
129
+
130
+ # Parse instance variable declarations: @name: Type
131
+ if stripped =~ /^@(\w+):\s*(.+)$/
132
+ ivar_name = Regexp.last_match(1)
133
+ ivar_type = Regexp.last_match(2).strip
134
+
135
+ ivars[current_namespace] ||= {}
136
+ ivars[current_namespace][ivar_name] = {
137
+ type: ivar_type,
138
+ desc: extract_first_line_desc(pending_comment)
139
+ }
140
+ pending_comment = nil
141
+ next
142
+ end
143
+
144
+ # Parse attr_reader/attr_accessor: attr_reader name: Type
145
+ if stripped =~ /^attr_(?:reader|accessor|writer)\s+(\w+):\s*(.+)$/
146
+ attr_name = Regexp.last_match(1)
147
+ attr_type = Regexp.last_match(2).strip
148
+
149
+ attrs[current_namespace] ||= {}
150
+ attrs[current_namespace][attr_name] = {
151
+ type: attr_type,
152
+ desc: extract_first_line_desc(pending_comment)
153
+ }
154
+ pending_comment = nil
155
+ next
156
+ end
157
+
158
+ # Parse type aliases: type name = definition
159
+ if stripped =~ /^type\s+(\w+)\s*=\s*(.+)$/
160
+ type_name = Regexp.last_match(1)
161
+ type_def = Regexp.last_match(2)
162
+
163
+ type_aliases[current_namespace] ||= []
164
+ type_aliases[current_namespace] << {
165
+ name: type_name,
166
+ definition: type_def,
167
+ description: pending_comment
168
+ }
169
+ pending_comment = nil
170
+ next
171
+ end
172
+
173
+ # Parse method signatures: def method_name: signature
174
+ # Also handles: def self.method_name: signature
175
+ if stripped =~ /^def\s+(?:self\.)?(\w+[?!=]?|\[\]=?):\s*(.+)$/
176
+ method_name = Regexp.last_match(1)
177
+ full_sig = Regexp.last_match(2).strip
178
+
179
+ signatures[current_namespace] ||= {}
180
+
181
+ # Check if this is a continuation (overload) of previous method
182
+ if pending_method == method_name
183
+ overloads[current_namespace] ||= {}
184
+ overloads[current_namespace][method_name] ||= []
185
+ overloads[current_namespace][method_name] << full_sig
186
+ else
187
+ # New method - parse signature and store
188
+ signatures[current_namespace][method_name] = parse_signature(full_sig, pending_comment)
189
+ pending_method = method_name
190
+ end
191
+
192
+ pending_comment = nil
193
+ next
194
+ end
195
+
196
+ # Check for method overload continuation (line starting with |)
197
+ if stripped.start_with?("|") && pending_method
198
+ overload_sig = stripped.sub(/^\|\s*/, "").strip
199
+
200
+ overloads[current_namespace] ||= {}
201
+ overloads[current_namespace][pending_method] ||= []
202
+ overloads[current_namespace][pending_method] << overload_sig
203
+ next
204
+ end
205
+
206
+ # Any other non-blank line resets pending state
207
+ pending_comment = nil
208
+ pending_method = nil
209
+ end
210
+ end
211
+
212
+ def extract_first_line_desc(comment)
213
+ return nil if comment.nil? || comment.empty?
214
+
215
+ # Get first line, skip @rbs annotations
216
+ lines = comment.lines.map(&:strip)
217
+ lines.reject { |l| l.start_with?("@rbs") }.first
218
+ end
219
+
220
+ # Parse RBS signature into structured data.
221
+ #
222
+ # Handles formats like:
223
+ # () -> void
224
+ # (String name, ?Integer age) -> User
225
+ # [T] (T item) -> Array[T]
226
+ #
227
+ # @param sig [String] Full RBS signature
228
+ # @param comment [String, nil] Preceding comment (may contain @rbs descriptions)
229
+ # @return [Hash] Structured signature data
230
+ def parse_signature(sig, comment = nil)
231
+ result = {
232
+ full: sig,
233
+ params: {},
234
+ returns: nil
235
+ }
236
+
237
+ # Extract descriptions from comment's @rbs annotations
238
+ param_descs = {}
239
+ return_desc = nil
240
+ raises_type = nil
241
+
242
+ if comment
243
+ comment.lines.each do |line|
244
+ line = line.strip
245
+
246
+ # @rbs param_name: Type -- description
247
+ if line =~ /@rbs\s+(\w+):\s*\S+\s+--\s+(.+)$/
248
+ param_descs[Regexp.last_match(1)] = capitalize_first(Regexp.last_match(2))
249
+ end
250
+
251
+ # @rbs return: Type -- description
252
+ return_desc = capitalize_first(Regexp.last_match(1)) if line =~ /@rbs\s+return:\s*\S+\s+--\s+(.+)$/
253
+
254
+ # @rbs raises: Type
255
+ raises_type = Regexp.last_match(1).strip if line =~ /@rbs\s+raises:\s*(.+)$/
256
+ end
257
+ end
258
+
259
+ # Parse the signature itself
260
+ # Handle type parameters: [T] (...)
261
+ sig_without_type_params = sig.sub(/^\[[^\]]+\]\s*/, "")
262
+
263
+ if sig_without_type_params =~ /\A\(([^)]*)\)\s*->\s*(.+)\z/
264
+ params_str = Regexp.last_match(1)
265
+ result[:returns] = { type: Regexp.last_match(2).strip, desc: return_desc }
266
+ parse_params(params_str, result[:params], param_descs)
267
+ elsif sig_without_type_params =~ /\A\(\)\s*->\s*(.+)\z/
268
+ result[:returns] = { type: Regexp.last_match(1).strip, desc: return_desc }
269
+ end
270
+
271
+ result[:raises] = raises_type if raises_type
272
+ result
273
+ end
274
+
275
+ def parse_params(params_str, result, descriptions)
276
+ return if params_str.strip.empty?
277
+
278
+ params = split_respecting_brackets(params_str)
279
+
280
+ params.each do |param|
281
+ param = param.strip
282
+ next if param.empty?
283
+
284
+ parse_single_param(param, result, descriptions)
285
+ end
286
+ end
287
+
288
+ def parse_single_param(param, result, descriptions)
289
+ # Keyword arg: `?name: Type` or `name: Type`
290
+ if param =~ /\A\??(\w+):\s*(.+)\z/
291
+ name = Regexp.last_match(1)
292
+ type = Regexp.last_match(2).strip
293
+ result[name] = { type: type, desc: descriptions[name] }
294
+ # Block param: `?{ (Type) -> Type }` or `^(Type) -> Type`
295
+ elsif param =~ /\A\??[{^]/
296
+ # Store as special block param
297
+ result["&block"] = { type: param, desc: descriptions["block"] }
298
+ # Positional: `?Type name` or `Type name`
299
+ elsif param =~ /\A\??(.+?)\s+(\w+)\z/
300
+ type = Regexp.last_match(1).strip
301
+ name = Regexp.last_match(2)
302
+ result[name] = { type: type, desc: descriptions[name] }
303
+ end
304
+ end
305
+
306
+ def capitalize_first(str)
307
+ return nil if str.nil? || str.strip.empty?
308
+
309
+ s = str.strip
310
+ s[0].upcase + s[1..]
311
+ end
312
+
313
+ # Split string by commas while respecting nested brackets [], {}, ().
314
+ def split_respecting_brackets(str)
315
+ result = []
316
+ current = +""
317
+ depth = 0
318
+
319
+ str.each_char do |c|
320
+ case c
321
+ when "[", "{", "("
322
+ depth += 1
323
+ current << c
324
+ when "]", "}", ")"
325
+ depth -= 1
326
+ current << c
327
+ when ","
328
+ if depth.zero?
329
+ result << current
330
+ current = +""
331
+ else
332
+ current << c
333
+ end
334
+ else
335
+ current << c
336
+ end
337
+ end
338
+
339
+ result << current unless current.empty?
340
+ result
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Generates GitHub source links from file paths and line numbers.
6
+ #
7
+ # Parses git remote URL to extract org/repo, then constructs blob URLs
8
+ # with line references for linking documentation back to source.
9
+ class GithubLinker
10
+ # @return [String, nil] GitHub base URL (e.g., "https://github.com/org/repo")
11
+ attr_reader :base_url
12
+
13
+ # @return [String] Git branch for source links
14
+ attr_reader :branch
15
+
16
+ # @param repo [String, nil] Explicit GitHub repo (e.g., "org/repo")
17
+ # @param branch [String] Git branch for links
18
+ # @param root [String] Project root for detecting git remote
19
+ def initialize(repo: nil, branch: "main", root: Dir.pwd)
20
+ @branch = branch
21
+ @base_url = repo ? "https://github.com/#{repo}" : extract_github_base_url(root)
22
+ end
23
+
24
+ # Generate a markdown link to a source location on GitHub.
25
+ #
26
+ # @param path [String] Project-relative file path
27
+ # @param start_line [Integer] Starting line number
28
+ # @param end_line [Integer, nil] Ending line number (optional)
29
+ # @return [String] Markdown link or plain text if no GitHub remote
30
+ def link(path, start_line, end_line = nil)
31
+ text = format_text(path, start_line, end_line)
32
+ return "`#{text}`" unless @base_url
33
+
34
+ url = format_url(path, start_line, end_line)
35
+ "[#{text}](#{url})"
36
+ end
37
+
38
+ # Generate just the URL (for frontmatter).
39
+ #
40
+ # @param path [String] Project-relative file path
41
+ # @param start_line [Integer] Starting line number
42
+ # @param end_line [Integer, nil] Ending line number (optional)
43
+ # @return [String, nil] GitHub URL or nil if no GitHub remote
44
+ def url(path, start_line, end_line = nil)
45
+ return nil unless @base_url
46
+
47
+ format_url(path, start_line, end_line)
48
+ end
49
+
50
+ private
51
+
52
+ def format_text(path, start_line, end_line)
53
+ if end_line && end_line != start_line
54
+ "#{path}:#{start_line}-#{end_line}"
55
+ else
56
+ "#{path}:#{start_line}"
57
+ end
58
+ end
59
+
60
+ def format_url(path, start_line, end_line)
61
+ line_ref = if end_line && end_line != start_line
62
+ "L#{start_line}-L#{end_line}"
63
+ else
64
+ "L#{start_line}"
65
+ end
66
+ "#{@base_url}/blob/#{@branch}/#{path}##{line_ref}"
67
+ end
68
+
69
+ # Pattern matching both HTTPS and SSH GitHub remote URLs:
70
+ # - https://github.com/org/repo.git
71
+ # - git@github.com:org/repo.git
72
+ GITHUB_REMOTE_PATTERN = %r{
73
+ (?:https://github\.com/|git@github\.com:)
74
+ ([^/]+)/([^/]+?)(?:\.git)?$
75
+ }x
76
+ private_constant :GITHUB_REMOTE_PATTERN
77
+
78
+ def extract_github_base_url(root)
79
+ remote_url = `cd #{root} && git remote get-url origin 2>/dev/null`.strip
80
+ return nil if remote_url.empty?
81
+
82
+ match = remote_url.match(GITHUB_REMOTE_PATTERN)
83
+ "https://github.com/#{match[1]}/#{match[2]}" if match
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Extracts RBS type signatures from inline annotations in Ruby source.
6
+ #
7
+ # Supports the rbs-inline format where types are specified in comments:
8
+ #
9
+ # # @rbs param: String -- description
10
+ # # @rbs return: Integer
11
+ # def method(param)
12
+ #
13
+ # This is the preferred way to specify types in source code, as it keeps
14
+ # type information co-located with the code. The RbsLoader handles
15
+ # separate sig/ files as a fallback.
16
+ #
17
+ # @see https://github.com/soutaro/rbs-inline
18
+ class InlineRbsLoader
19
+ def initialize(verbose, logger)
20
+ @verbose = verbose
21
+ @logger = logger
22
+ end
23
+
24
+ # Extract inline RBS annotations from Ruby source files.
25
+ #
26
+ # @param source_files [Array<String>] Paths to Ruby files
27
+ # @return [Array(Hash, Hash, Hash)] [signatures, rbs_file_namespaces, attr_types]
28
+ # - signatures: class -> method -> signature
29
+ # - rbs_file_namespaces: file -> [namespaces] for files with @rbs content
30
+ # - attr_types: class -> attr_name -> { type:, desc: } (from #: annotations or @rbs! blocks)
31
+ def load(source_files)
32
+ signatures = {}
33
+ @rbs_file_namespaces = {}
34
+ @attr_types = {}
35
+
36
+ source_files.each do |file|
37
+ next unless File.exist?(file)
38
+
39
+ parse_file(file, signatures)
40
+ end
41
+
42
+ @logger.info "Extracted inline RBS from #{source_files.size} files" if @verbose && source_files.any?
43
+ [signatures, @rbs_file_namespaces, @attr_types]
44
+ end
45
+
46
+ private
47
+
48
+ def parse_file(file, signatures)
49
+ content = File.read(file)
50
+ expanded_file = File.expand_path(file)
51
+ # Stack of [name_parts, indent_level] for tracking nested namespaces
52
+ namespace_stack = []
53
+ pending_rbs = {}
54
+ file_has_rbs = content.include?("@rbs")
55
+ file_namespaces = []
56
+ in_rbs_block = false
57
+ # Track multi-line Struct.new/Data.define: { indent:, name: } waiting for `) do`
58
+ pending_struct = nil
59
+
60
+ content.each_line.with_index do |line, _idx|
61
+ # Track @rbs! block start
62
+ if line =~ /^\s*#\s*@rbs!/
63
+ in_rbs_block = true
64
+ next
65
+ end
66
+
67
+ # Parse @rbs! block content for attr_accessor/reader/writer types
68
+ if in_rbs_block
69
+ # Track nested class declarations inside @rbs! blocks
70
+ if line =~ /^\s*#\s+class\s+(\w+)/
71
+ Regexp.last_match(1)
72
+ elsif line =~ /^\s*#\s+attr_(?:accessor|reader|writer)\s+(\w+):\s*(.+)$/
73
+ attr_name = Regexp.last_match(1)
74
+ attr_type = Regexp.last_match(2).strip
75
+ base_class = current_namespace(namespace_stack)
76
+ # Use nested class from @rbs! block if present
77
+ full_class = rbs_block_class ? "#{base_class}::#{rbs_block_class}" : base_class
78
+ # Store as { type:, desc: } for consistency (no desc in @rbs! format)
79
+ (@attr_types[full_class] ||= {})[attr_name] = { type: attr_type, desc: nil } unless full_class.empty?
80
+ elsif line !~ /^\s*#/ # Non-comment line ends @rbs! block
81
+ in_rbs_block = false
82
+ nil
83
+ end
84
+ end
85
+
86
+ # Parse Struct.new/Data.define member annotations: :name, #: Type -- description
87
+ if pending_struct && line =~ /:(\w+),?\s*#:\s*(.+)$/
88
+ attr_name = Regexp.last_match(1)
89
+ type_and_desc = Regexp.last_match(2).strip
90
+ type, desc = type_and_desc.split(/\s+--\s+/, 2)
91
+ pending_struct[:members] ||= {}
92
+ pending_struct[:members][attr_name] = { type: type.strip, desc: capitalize_first(desc) }
93
+ end
94
+ # Track class/module context - push onto namespace stack with indentation
95
+ if line =~ /^(\s*)(?:class|module)\s+([\w:]+)/
96
+ class_indent = Regexp.last_match(1).length
97
+ name = Regexp.last_match(2)
98
+ # Handle inline fully-qualified names like "class Foo::Bar"
99
+ name_parts = name.split("::")
100
+ namespace_stack.push([name_parts, class_indent])
101
+ current_class = current_namespace(namespace_stack)
102
+ signatures[current_class] ||= {}
103
+ # Track namespaces this file contributes to (for @rbs change detection)
104
+ file_namespaces << current_class if file_has_rbs
105
+ pending_rbs = {}
106
+ end
107
+
108
+ # Track Data.define/Struct.new blocks as pseudo-classes
109
+ # Single-line: ConstName = Data.define(...) do or ConstName = Struct.new(...) do
110
+ if line =~ /^(\s*)([\w:]+)\s*=\s*(?:Data\.define|Struct\.new)\b.*\bdo\s*(?:#.*)?$/
111
+ block_indent = Regexp.last_match(1).length
112
+ name = Regexp.last_match(2)
113
+ name_parts = name.split("::")
114
+ namespace_stack.push([name_parts, block_indent])
115
+ current_class = current_namespace(namespace_stack)
116
+ signatures[current_class] ||= {}
117
+ pending_rbs = {}
118
+ # Multi-line start: ConstName = Data.define( or ConstName = Struct.new(
119
+ # We'll complete this when we see `) do` later
120
+ elsif line =~ /^(\s*)([\w:]+)\s*=\s*(?:Data\.define|Struct\.new)\s*\(/
121
+ pending_struct = { indent: Regexp.last_match(1).length, name: Regexp.last_match(2) }
122
+ # Multi-line completion: ) do (possibly with keyword args before)
123
+ elsif pending_struct && line =~ /\)\s*do\s*(?:#.*)?$/
124
+ name_parts = pending_struct[:name].split("::")
125
+ namespace_stack.push([name_parts, pending_struct[:indent]])
126
+ current_class = current_namespace(namespace_stack)
127
+ signatures[current_class] ||= {}
128
+ # Apply accumulated member types to @attr_types
129
+ if pending_struct[:members]&.any?
130
+ @attr_types[current_class] ||= {}
131
+ @attr_types[current_class].merge!(pending_struct[:members])
132
+ end
133
+ pending_rbs = {}
134
+ pending_struct = nil
135
+ end
136
+
137
+ # Track `end` statements - pop if indentation matches a namespace
138
+ if line =~ /^(\s*)end\s*(?:#.*)?$/
139
+ end_indent = Regexp.last_match(1).length
140
+ # Pop all namespaces at this indentation level
141
+ namespace_stack.pop while namespace_stack.any? && namespace_stack.last[1] == end_indent
142
+ end
143
+
144
+ # Collect @rbs annotations
145
+ if line =~ /^\s*#\s*@rbs\s+(\w+):\s*(.+)$/
146
+ key = Regexp.last_match(1)
147
+ value = Regexp.last_match(2).strip
148
+ # Handle "-- description" suffix
149
+ type, desc = value.split(" -- ", 2)
150
+ pending_rbs[key] = { type: type.strip, desc: capitalize_first(desc) }
151
+ end
152
+
153
+ # When we hit a method definition, apply pending RBS
154
+ # Matches: def foo, def foo?, def foo!, def self.foo, def [], def []=, def +, etc.
155
+ current_class = current_namespace(namespace_stack)
156
+ if !current_class.empty? && line =~ %r{^\s*def\s+(?:self\.)?(\w+[?!=]?|\[\]=?|[+\-*/%&|^<>=!~]+)}
157
+ method_name = Regexp.last_match(1)
158
+ if pending_rbs.any?
159
+ signatures[current_class][method_name] = build_signature(pending_rbs)
160
+ pending_rbs = {}
161
+ end
162
+ end
163
+
164
+ # Reset pending RBS on blank lines or non-comment lines (but not on def lines)
165
+ is_comment = line.strip.start_with?("#")
166
+ is_def = line =~ /^\s*def\s/
167
+ is_blank = line.strip.empty?
168
+
169
+ pending_rbs = {} if (is_blank || (!is_comment && !is_def)) && !is_def
170
+ end
171
+
172
+ # Store file -> namespaces mapping for files with @rbs content
173
+ @rbs_file_namespaces[expanded_file] = file_namespaces.uniq if file_namespaces.any?
174
+ end
175
+
176
+ def current_namespace(stack) = stack.flat_map { |parts, _indent| parts }.join("::")
177
+
178
+ def capitalize_first(str)
179
+ return nil if str.nil? || str.strip.empty?
180
+
181
+ s = str.strip
182
+ s[0].upcase + s[1..]
183
+ end
184
+
185
+ def build_signature(rbs_data)
186
+ params = {}
187
+ returns = rbs_data.delete("return")
188
+
189
+ # Each value is now { type: "...", desc: "..." }
190
+ rbs_data.each do |name, data|
191
+ params[name] = data
192
+ end
193
+
194
+ # Build full signature string using just the types
195
+ param_str = params.map { |name, data| "#{data[:type]} #{name}" }.join(", ")
196
+ return_type = returns&.dig(:type) || "void"
197
+ full = "(#{param_str}) -> #{return_type}"
198
+
199
+ {
200
+ full: full,
201
+ params: params, # { name => { type:, desc: } }
202
+ returns: returns # { type:, desc: } or nil
203
+ }
204
+ end
205
+ end
206
+ end
207
+ end