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,740 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document_model"
4
+
5
+ module Chiridion
6
+ class Engine
7
+ # Comprehensive semantic extraction from YARD registry.
8
+ #
9
+ # Unlike the simpler Extractor, this captures ALL available metadata from
10
+ # YARD and RBS, populating the DocumentModel structures completely. It
11
+ # addresses the "data being discarded" issues documented in TODO.md.
12
+ #
13
+ # Key improvements over Extractor:
14
+ # - @option tags for hash parameter documentation
15
+ # - @yield, @yieldparam, @yieldreturn for block documentation
16
+ # - @api, @deprecated, @abstract, @note tags
17
+ # - @raise exceptions
18
+ # - Instance variable types (@rbs @name: Type)
19
+ # - Method overloads from RBS
20
+ #
21
+ # Design: Extract everything, render selectively.
22
+ class SemanticExtractor
23
+ include DocumentModel
24
+
25
+ def initialize(
26
+ rbs_types:,
27
+ rbs_attr_types: {},
28
+ rbs_ivar_types: {},
29
+ type_aliases: {},
30
+ spec_examples: {},
31
+ namespace_filter: nil,
32
+ logger: nil
33
+ )
34
+ @rbs_types = rbs_types || {}
35
+ @rbs_attr_types = rbs_attr_types || {}
36
+ @rbs_ivar_types = rbs_ivar_types || {}
37
+ @type_aliases = type_aliases || {}
38
+ @spec_examples = spec_examples || {}
39
+ @namespace_filter = namespace_filter
40
+ @logger = logger
41
+ @type_merger = TypeMerger.new(logger)
42
+ end
43
+
44
+ # Extract complete documentation from YARD registry.
45
+ #
46
+ # @param registry [YARD::Registry] Parsed YARD registry
47
+ # @param title [String] Project title
48
+ # @param description [String] Project description
49
+ # @param root [String] Project root for relative path calculation
50
+ # @return [ProjectDoc] Complete documentation structure
51
+ def extract(registry, title: "API Documentation", description: nil, root: Dir.pwd)
52
+ namespaces = registry.all(:class, :module)
53
+ .select { |obj| should_document?(obj) }
54
+ .map { |obj| extract_namespace(obj) }
55
+
56
+ files = group_by_file(namespaces, root)
57
+
58
+ ProjectDoc.new(
59
+ title: title,
60
+ description: description,
61
+ namespaces: namespaces,
62
+ files: files,
63
+ type_aliases: @type_aliases.transform_values { |types| types.map { |t| to_type_alias_doc(t) } },
64
+ generated_at: Time.now.utc
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ # Group namespaces by their source file.
71
+ #
72
+ # Creates FileDoc entries for each unique source file, collecting all
73
+ # namespaces defined in that file. Also associates type aliases with
74
+ # their defining files.
75
+ #
76
+ # @param namespaces [Array<NamespaceDoc>] All extracted namespaces
77
+ # @param root [String] Project root for relative path calculation
78
+ # @return [Array<FileDoc>] Namespaces grouped by source file
79
+ def group_by_file(namespaces, root)
80
+ # Group namespaces by their source file
81
+ by_file = Hash.new { |h, k| h[k] = [] }
82
+
83
+ namespaces.each do |ns|
84
+ next unless ns.file
85
+
86
+ relative_path = make_relative(ns.file, root)
87
+ by_file[relative_path] << ns
88
+ end
89
+
90
+ # Create FileDoc for each file
91
+ by_file.map do |path, file_namespaces|
92
+ # Collect type aliases from these namespaces
93
+ file_aliases = file_namespaces.flat_map(&:type_aliases)
94
+
95
+ # Try to get line count
96
+ absolute_path = File.join(root, path)
97
+ line_count = File.exist?(absolute_path) ? File.read(absolute_path).lines.count : nil
98
+
99
+ FileDoc.new(
100
+ path: path,
101
+ namespaces: file_namespaces.sort_by(&:path),
102
+ type_aliases: file_aliases,
103
+ line_count: line_count
104
+ )
105
+ end.sort_by(&:path)
106
+ end
107
+
108
+ def make_relative(absolute_path, root)
109
+ return absolute_path unless absolute_path&.start_with?(root)
110
+
111
+ absolute_path.delete_prefix("#{root}/")
112
+ end
113
+
114
+ def should_document?(obj)
115
+ return true unless @namespace_filter
116
+
117
+ obj.path.start_with?(@namespace_filter)
118
+ end
119
+
120
+ # Extract complete namespace (class/module) documentation.
121
+ def extract_namespace(obj)
122
+ path = obj.path
123
+ is_class = obj.is_a?(YARD::CodeObjects::ClassObject)
124
+
125
+ NamespaceDoc.new(
126
+ name: obj.name.to_s,
127
+ path: path,
128
+ type: is_class ? :class : :module,
129
+ superclass: is_class ? obj.superclass&.path : nil,
130
+ docstring: clean_docstring(obj.docstring.to_s),
131
+ examples: extract_examples(obj),
132
+ notes: extract_notes(obj),
133
+ see_also: extract_see_tags(obj),
134
+ api: obj.tag(:api)&.text,
135
+ deprecated: extract_deprecated(obj),
136
+ abstract: obj.has_tag?(:abstract),
137
+ since: obj.tag(:since)&.text,
138
+ todo: obj.tag(:todo)&.text,
139
+ includes: obj.instance_mixins.map(&:path),
140
+ extends: obj.class_mixins.map(&:path),
141
+ constants: extract_constants(obj),
142
+ type_aliases: extract_local_type_aliases(path),
143
+ ivars: extract_ivars(path),
144
+ attributes: extract_attributes(obj, path),
145
+ methods: extract_methods(obj, path, :public),
146
+ private_methods: extract_methods(obj, path, :private),
147
+ file: obj.file,
148
+ line: obj.line,
149
+ end_line: compute_end_line(obj),
150
+ rbs_file: find_rbs_file(path),
151
+ spec_examples: @spec_examples[path],
152
+ referenced_types: [] # Populated post-extraction
153
+ )
154
+ end
155
+
156
+ def extract_examples(obj)
157
+ obj.tags(:example).map do |t|
158
+ ExampleDoc.new(name: t.name, code: t.text)
159
+ end
160
+ end
161
+
162
+ def extract_notes(obj) = obj.tags(:note).map(&:text)
163
+
164
+ def extract_see_tags(obj) = obj.tags(:see).map do |t|
165
+ SeeDoc.new(target: t.name, text: t.text)
166
+ end
167
+
168
+ def extract_deprecated(obj)
169
+ tag = obj.tag(:deprecated)
170
+ return nil unless tag
171
+
172
+ tag.text.to_s.empty? ? "" : tag.text
173
+ end
174
+
175
+ def extract_constants(obj)
176
+ obj.constants.map do |c|
177
+ ConstantDoc.new(
178
+ name: c.name.to_s,
179
+ value: c.value.to_s,
180
+ type: nil, # TODO: extract from RBS if available
181
+ description: clean_docstring(c.docstring.to_s)
182
+ )
183
+ end
184
+ end
185
+
186
+ def extract_local_type_aliases(class_path)
187
+ (@type_aliases[class_path] || []).map { |t| to_type_alias_doc(t, class_path) }
188
+ end
189
+
190
+ def to_type_alias_doc(t, namespace = nil)
191
+ TypeAliasDoc.new(
192
+ name: t[:name],
193
+ definition: t[:definition],
194
+ description: t[:description],
195
+ namespace: namespace || t[:namespace] || ""
196
+ )
197
+ end
198
+
199
+ def extract_ivars(class_path)
200
+ ivar_data = @rbs_ivar_types[class_path] || {}
201
+ ivar_data.map do |name, info|
202
+ IvarDoc.new(
203
+ name: name.to_s,
204
+ type: info.is_a?(Hash) ? info[:type] : info,
205
+ description: info.is_a?(Hash) ? info[:desc] : nil
206
+ )
207
+ end
208
+ end
209
+
210
+ def extract_attributes(obj, class_path)
211
+ # Find attr_reader/attr_writer/attr_accessor methods
212
+ readers = {}
213
+ writers = {}
214
+
215
+ obj.meths(scope: :instance, visibility: :public).each do |m|
216
+ source_info = extract_source(m)
217
+ next unless source_info[:attr_type]
218
+
219
+ if source_info[:attr_type] == :reader
220
+ readers[m.name.to_s] = extract_method(m, class_path, :instance)
221
+ elsif source_info[:attr_type] == :writer
222
+ name = m.name.to_s.chomp("=")
223
+ writers[name] = extract_method(m, class_path, :instance)
224
+ end
225
+ end
226
+
227
+ # Synthesize AttributeDoc for each attribute
228
+ all_names = (readers.keys + writers.keys).uniq.sort
229
+ all_names.map do |name|
230
+ reader = readers[name]
231
+ writer = writers[name]
232
+
233
+ mode = case [reader.nil?, writer.nil?]
234
+ when [false, false] then :read_write
235
+ when [false, true] then :read
236
+ else :write
237
+ end
238
+
239
+ # Get type from RBS attr annotations first, then from YARD
240
+ type = resolve_attr_type(name, class_path, reader, writer)
241
+ desc = resolve_attr_description(name, class_path, reader, writer)
242
+
243
+ AttributeDoc.new(
244
+ name: name,
245
+ type: type,
246
+ description: desc,
247
+ mode: mode,
248
+ reader: reader,
249
+ writer: writer
250
+ )
251
+ end
252
+ end
253
+
254
+ def resolve_attr_type(name, class_path, reader, writer)
255
+ # Check RBS attr_types first
256
+ rbs_data = @rbs_attr_types.dig(class_path, name)
257
+ if rbs_data
258
+ type = rbs_data.is_a?(Hash) ? rbs_data[:type] : rbs_data
259
+ return type if type && type != "untyped"
260
+ end
261
+
262
+ # Fall back to YARD types
263
+ reader&.returns&.type || writer&.params&.first&.type
264
+ end
265
+
266
+ def resolve_attr_description(name, class_path, reader, writer)
267
+ # Check RBS attr_types first
268
+ rbs_data = @rbs_attr_types.dig(class_path, name)
269
+ return rbs_data[:desc] if rbs_data.is_a?(Hash) && rbs_data[:desc] && !rbs_data[:desc].empty?
270
+
271
+ # Fall back to YARD descriptions
272
+ reader&.returns&.description || writer&.params&.first&.description
273
+ end
274
+
275
+ def extract_methods(obj, class_path, visibility)
276
+ scope_methods = []
277
+
278
+ [:instance, :class].each do |scope|
279
+ obj.meths(scope: scope, visibility: visibility).each do |m|
280
+ method_doc = extract_method(m, class_path, scope)
281
+ # Skip pure attr methods (already in attributes)
282
+ next if method_doc.attr_type
283
+
284
+ scope_methods << method_doc
285
+ end
286
+ end
287
+
288
+ scope_methods
289
+ end
290
+
291
+ def extract_method(meth, class_path, scope)
292
+ rbs_data = @rbs_types.dig(class_path, meth.name.to_s)
293
+ source_info = extract_source(meth)
294
+
295
+ # Extract and merge params
296
+ yard_params = extract_yard_params(meth)
297
+ merged_params = merge_params(yard_params, rbs_data)
298
+
299
+ # Extract options for hash params, merging with RBS record types
300
+ options = extract_options(meth, rbs_data)
301
+
302
+ # Extract return
303
+ returns = extract_return(meth, rbs_data)
304
+
305
+ # Extract yield/block info
306
+ yields = extract_yields(meth, rbs_data)
307
+
308
+ # Extract raises
309
+ raises = extract_raises(meth, rbs_data)
310
+
311
+ MethodDoc.new(
312
+ name: meth.name,
313
+ scope: scope,
314
+ visibility: meth.visibility,
315
+ signature: meth.signature,
316
+ docstring: clean_docstring(meth.docstring.to_s),
317
+ params: merged_params,
318
+ options: options,
319
+ returns: returns,
320
+ yields: yields,
321
+ raises: raises,
322
+ examples: extract_examples(meth),
323
+ notes: extract_notes(meth),
324
+ see_also: extract_see_tags(meth),
325
+ api: meth.tag(:api)&.text,
326
+ deprecated: extract_deprecated(meth),
327
+ abstract: meth.has_tag?(:abstract),
328
+ since: meth.tag(:since)&.text,
329
+ todo: meth.tag(:todo)&.text,
330
+ rbs_signature: rbs_data&.dig(:full),
331
+ overloads: extract_overloads(rbs_data),
332
+ source: source_info[:source],
333
+ source_body_lines: source_info[:body_lines],
334
+ attr_type: source_info[:attr_type],
335
+ file: meth.file,
336
+ line: meth.line,
337
+ spec_examples: method_spec_examples(class_path, meth.name),
338
+ spec_behaviors: method_spec_behaviors(class_path, meth.name)
339
+ )
340
+ end
341
+
342
+ def extract_yard_params(meth)
343
+ param_tags = meth.tags(:param).to_h { |t| [t.name, { types: t.types, text: t.text }] }
344
+
345
+ meth.parameters.map do |name, default|
346
+ clean_name = name.to_s.gsub(/\A[*&]+/, "").delete_suffix(":")
347
+ tag_info = param_tags[clean_name] || {}
348
+
349
+ ParamDoc.new(
350
+ name: clean_name,
351
+ type: tag_info[:types]&.first,
352
+ description: tag_info[:text],
353
+ default: default,
354
+ prefix: ParamDoc.extract_prefix(name)
355
+ )
356
+ end
357
+ end
358
+
359
+ def merge_params(yard_params, rbs_data)
360
+ return yard_params unless rbs_data&.dig(:params)
361
+
362
+ rbs_params = rbs_data[:params]
363
+ yard_params.map do |param|
364
+ rbs_info = rbs_params[param.name]
365
+ next param unless rbs_info
366
+
367
+ rbs_type = rbs_info.is_a?(Hash) ? rbs_info[:type] : rbs_info
368
+ rbs_desc = rbs_info.is_a?(Hash) ? rbs_info[:desc] : nil
369
+
370
+ ParamDoc.new(
371
+ name: param.name,
372
+ type: rbs_type || param.type,
373
+ description: merge_descriptions(param.description, rbs_desc),
374
+ default: param.default,
375
+ prefix: param.prefix
376
+ )
377
+ end
378
+ end
379
+
380
+ # Extract @option tags for hash parameters, merging with RBS record types.
381
+ #
382
+ # RBS provides types via record syntax: `{ key: Type, key2: Type2 }`
383
+ # YARD @option provides semantic descriptions for each key.
384
+ # Chiridion merges by key name (RBS type wins).
385
+ #
386
+ # @param meth [YARD::CodeObjects::MethodObject]
387
+ # @param rbs_data [Hash, nil] RBS type data for this method
388
+ # @return [Array<OptionDoc>]
389
+ def extract_options(meth, rbs_data = nil)
390
+ yard_options = meth.tags(:option)
391
+
392
+ # Build map of RBS record types by param name
393
+ rbs_record_types = extract_rbs_record_types(rbs_data)
394
+
395
+ yard_options.map do |opt|
396
+ param_name = opt.name.to_s
397
+
398
+ # @option tags have a nested DefaultTag in `pair` containing the key info
399
+ pair = opt.pair
400
+ key_name = pair&.name&.to_s&.delete_prefix(":") || "unknown"
401
+ yard_type = pair&.types&.first
402
+ description = pair&.text
403
+
404
+ # Look up RBS type for this key (RBS wins over YARD)
405
+ rbs_type = rbs_record_types.dig(param_name, key_name)
406
+
407
+ OptionDoc.new(
408
+ param_name: param_name,
409
+ key: key_name,
410
+ type: rbs_type || yard_type,
411
+ description: description
412
+ )
413
+ end
414
+ end
415
+
416
+ # Extract RBS record types from method params.
417
+ #
418
+ # For a param like `options: { file: String?, path: String? }`,
419
+ # returns { "options" => { "file" => "String?", "path" => "String?" } }
420
+ #
421
+ # @param rbs_data [Hash, nil]
422
+ # @return [Hash{String => Hash{String => String}}]
423
+ def extract_rbs_record_types(rbs_data)
424
+ return {} unless rbs_data&.dig(:params)
425
+
426
+ result = {}
427
+ rbs_data[:params].each do |param_name, param_info|
428
+ type_str = param_info.is_a?(Hash) ? param_info[:type] : param_info
429
+ next unless type_str
430
+
431
+ # Check if it's a record type { key: Type, ... }
432
+ parsed = parse_rbs_record_type(type_str)
433
+ result[param_name.to_s] = parsed if parsed.any?
434
+ end
435
+ result
436
+ end
437
+
438
+ # Parse an RBS record type like "{ file: String?, path: String? }"
439
+ #
440
+ # @param type_str [String]
441
+ # @return [Hash{String => String}] key name to type mapping
442
+ def parse_rbs_record_type(type_str)
443
+ return {} unless type_str
444
+
445
+ clean = type_str.strip
446
+ return {} unless clean.start_with?("{") && clean.end_with?("}")
447
+
448
+ # Remove outer braces
449
+ inner = clean[1..-2].strip
450
+ return {} if inner.empty?
451
+
452
+ result = {}
453
+ # Split on commas, respecting nested brackets/braces
454
+ pairs = split_record_pairs(inner)
455
+
456
+ pairs.each do |pair|
457
+ # Match "key: Type" or "key?: Type"
458
+ next unless (match = pair.match(/\A(\w+)\??\s*:\s*(.+)\z/))
459
+
460
+ key = match[1]
461
+ type = match[2].strip
462
+ result[key] = type
463
+ end
464
+
465
+ result
466
+ end
467
+
468
+ # Split record type pairs, respecting nested structures.
469
+ #
470
+ # "file: String, data: Hash[Symbol, String]" → ["file: String", "data: Hash[Symbol, String]"]
471
+ def split_record_pairs(str)
472
+ return [] if str.nil? || str.strip.empty?
473
+
474
+ pairs = []
475
+ current = +""
476
+ depth = 0
477
+
478
+ str.each_char do |c|
479
+ case c
480
+ when "[", "(", "{" then depth += 1
481
+ current << c
482
+ when "]", ")", "}" then depth -= 1
483
+ current << c
484
+ when ","
485
+ if depth.zero?
486
+ pairs << current.strip unless current.strip.empty?
487
+ current = +""
488
+ else
489
+ current << c
490
+ end
491
+ else
492
+ current << c
493
+ end
494
+ end
495
+
496
+ pairs << current.strip unless current.strip.empty?
497
+ pairs
498
+ end
499
+
500
+ def extract_return(meth, rbs_data)
501
+ yard_tag = meth.tag(:return)
502
+ yard_type = yard_tag&.types&.first
503
+
504
+ if rbs_data&.dig(:returns)
505
+ rbs_ret = rbs_data[:returns]
506
+ rbs_type = rbs_ret.is_a?(Hash) ? rbs_ret[:type] : rbs_ret
507
+ rbs_desc = rbs_ret.is_a?(Hash) ? rbs_ret[:desc] : nil
508
+
509
+ # If RBS says void but YARD has a type (e.g., auto-generated for initialize),
510
+ # prefer YARD's type. This handles `# @rbs () -> void` on initialize methods.
511
+ final_type = (rbs_type == "void" && yard_type) ? yard_type : rbs_type
512
+
513
+ ReturnDoc.new(
514
+ type: final_type,
515
+ description: merge_descriptions(yard_tag&.text, rbs_desc)
516
+ )
517
+ elsif yard_tag
518
+ ReturnDoc.new(
519
+ type: yard_type,
520
+ description: yard_tag.text
521
+ )
522
+ end
523
+ end
524
+
525
+ # Extract @yield, @yieldparam, @yieldreturn, and RBS block signatures.
526
+ #
527
+ # Merges RBS block type `(User, Integer) -> bool` with YARD @yieldparam
528
+ # names/descriptions by position. RBS provides authoritative types,
529
+ # YARD provides semantic names and descriptions.
530
+ def extract_yields(meth, rbs_data)
531
+ yield_tag = meth.tag(:yield)
532
+ yieldparams = meth.tags(:yieldparam)
533
+ yieldreturn = meth.tag(:yieldreturn)
534
+
535
+ # Extract RBS block info
536
+ block_type = nil
537
+ block_desc = nil
538
+ block_param_types = []
539
+ block_return_type = nil
540
+
541
+ if rbs_data&.dig(:params)
542
+ block_param = rbs_data[:params].find { |k, _| k.start_with?("&") || k == "block" }
543
+ if block_param
544
+ block_info = block_param[1]
545
+ block_type = block_info.is_a?(Hash) ? block_info[:type] : block_info
546
+ block_desc = block_info.is_a?(Hash) ? block_info[:desc] : nil
547
+
548
+ # Parse block type to extract positional param types and return type
549
+ if block_type
550
+ parsed = parse_block_type(block_type)
551
+ block_param_types = parsed[:param_types]
552
+ block_return_type = parsed[:return_type]
553
+ end
554
+ end
555
+ end
556
+
557
+ return nil if yield_tag.nil? && yieldparams.empty? && yieldreturn.nil? && block_type.nil?
558
+
559
+ # Merge yieldparams with RBS block param types by position
560
+ merged_params = yieldparams.each_with_index.map do |t, i|
561
+ rbs_type = block_param_types[i]
562
+ ParamDoc.new(
563
+ name: t.name,
564
+ type: rbs_type || t.types&.first, # RBS type takes precedence
565
+ description: t.text,
566
+ default: nil,
567
+ prefix: nil
568
+ )
569
+ end
570
+
571
+ YieldDoc.new(
572
+ description: yield_tag&.text || block_desc,
573
+ params: merged_params,
574
+ return_type: block_return_type || yieldreturn&.types&.first,
575
+ return_desc: yieldreturn&.text,
576
+ block_type: block_type
577
+ )
578
+ end
579
+
580
+ # Parse an RBS block type like "(User, Integer) -> bool"
581
+ #
582
+ # @return [Hash] { param_types: ["User", "Integer"], return_type: "bool" }
583
+ def parse_block_type(block_type)
584
+ return { param_types: [], return_type: nil } unless block_type
585
+
586
+ # Handle formats: (T1, T2) -> R, { (T1, T2) -> R }, ^(T1, T2) -> R
587
+ clean = block_type.strip.delete_prefix("{").delete_suffix("}").delete_prefix("^").strip
588
+
589
+ if (match = clean.match(/\A\(([^)]*)\)\s*->\s*(.+)\z/))
590
+ params_str = match[1]
591
+ return_type = match[2].strip
592
+
593
+ { param_types: split_type_params(params_str), return_type: return_type }
594
+ else
595
+ { param_types: [], return_type: nil }
596
+ end
597
+ end
598
+
599
+ # Split comma-separated type params, respecting nested brackets.
600
+ #
601
+ # "User, Array[String], Hash[Symbol, Integer]" → ["User", "Array[String]", "Hash[Symbol, Integer]"]
602
+ def split_type_params(str)
603
+ return [] if str.nil? || str.strip.empty?
604
+
605
+ params = []
606
+ current = +""
607
+ depth = 0
608
+
609
+ str.each_char do |c|
610
+ case c
611
+ when "[", "(" then depth += 1
612
+ current << c
613
+ when "]", ")" then depth -= 1
614
+ current << c
615
+ when ","
616
+ if depth.zero?
617
+ params << current.strip unless current.strip.empty?
618
+ current = +""
619
+ else
620
+ current << c
621
+ end
622
+ else
623
+ current << c
624
+ end
625
+ end
626
+
627
+ params << current.strip unless current.strip.empty?
628
+ params
629
+ end
630
+
631
+ # Extract @raise tags and @rbs raises.
632
+ def extract_raises(meth, rbs_data)
633
+ yard_raises = meth.tags(:raise).map do |t|
634
+ RaiseDoc.new(type: t.types&.first || t.name, description: t.text)
635
+ end
636
+
637
+ # Add RBS raises if present
638
+ if rbs_data&.dig(:raises)
639
+ rbs_raise = rbs_data[:raises]
640
+ yard_raises << RaiseDoc.new(type: rbs_raise, description: nil) unless yard_raises.any? do |r|
641
+ r.type == rbs_raise
642
+ end
643
+ end
644
+
645
+ yard_raises
646
+ end
647
+
648
+ def extract_overloads(rbs_data)
649
+ return [] unless rbs_data&.dig(:overloads)
650
+
651
+ rbs_data[:overloads].map do |sig|
652
+ OverloadDoc.new(signature: sig, description: nil)
653
+ end
654
+ end
655
+
656
+ def merge_descriptions(yard_desc, rbs_desc)
657
+ return rbs_desc if yard_desc.to_s.strip.empty?
658
+ return yard_desc if rbs_desc.to_s.strip.empty?
659
+
660
+ # Longer description wins; tie goes to RBS (co-located)
661
+ rbs_desc.to_s.length >= yard_desc.to_s.length ? rbs_desc : yard_desc
662
+ end
663
+
664
+ def clean_docstring(str)
665
+ return "" if str.nil? || str.empty?
666
+
667
+ # Strip @rbs! blocks (multi-line RBS annotations meant for RBS::Inline)
668
+ # The block continues while lines are indented (start with whitespace)
669
+ cleaned = str.gsub(/@rbs!\s*\n(?:\s+.*(?:\n|\z))*/, "")
670
+
671
+ cleaned.lines
672
+ .reject { |line| line.strip.match?(/^rubocop:(disable|enable|todo)\b/i) }
673
+ .join
674
+ .strip
675
+ end
676
+
677
+ def compute_end_line(obj)
678
+ return nil unless obj.source
679
+
680
+ obj.line + obj.source.lines.count - 1
681
+ end
682
+
683
+ def extract_source(meth)
684
+ source = meth.source
685
+ return { source: nil, body_lines: nil, attr_type: nil } unless source
686
+
687
+ condensed = condense_attr_source(source)
688
+ return { source: condensed[:source], body_lines: 0, attr_type: condensed[:attr_type] } if condensed
689
+
690
+ lines = source.lines
691
+ total = lines.size
692
+
693
+ if total == 1 || source.match?(/\Adef\s+\S+.*=/)
694
+ { source: source, body_lines: 0, attr_type: nil }
695
+ else
696
+ { source: source, body_lines: [total - 2, 0].max, attr_type: nil }
697
+ end
698
+ end
699
+
700
+ def condense_attr_source(source)
701
+ lines = source.lines.map(&:strip)
702
+ return nil unless lines.size == 3 && lines.last == "end"
703
+
704
+ if (reader_match = lines[0].match(/\Adef\s+(\w+)\z/)) && (ivar_match = lines[1].match(/\A@(\w+)\z/))
705
+ return { source: "def #{reader_match[1]} = @#{ivar_match[1]}", attr_type: :reader }
706
+ end
707
+
708
+ if (writer_match = lines[0].match(/\Adef\s+(\w+)=\((\w+)\)\z/)) && lines[1].match(/\A@(\w+)\s*=\s*(\w+)\z/)
709
+ return { source: "def #{writer_match[1]}=(#{writer_match[2]}) = (@#{writer_match[1]} = #{writer_match[2]})",
710
+ attr_type: :writer }
711
+ end
712
+
713
+ nil
714
+ end
715
+
716
+ def find_rbs_file(class_path)
717
+ parts = class_path.split("::").map { |part| to_snake_case(part) }
718
+ path = "sig/#{parts.join('/')}.rbs"
719
+ File.exist?(path) ? path : nil
720
+ end
721
+
722
+ def to_snake_case(str)
723
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
724
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
725
+ .downcase
726
+ end
727
+
728
+ def method_spec_examples(class_path, method_name) = lookup_spec_data(class_path, :method_examples, method_name)
729
+
730
+ def method_spec_behaviors(class_path, method_name) = lookup_spec_data(class_path, :behaviors, method_name)
731
+
732
+ def lookup_spec_data(class_path, category, method_name)
733
+ return [] unless @spec_examples[class_path]
734
+
735
+ @spec_examples[class_path][category][".#{method_name}"] ||
736
+ @spec_examples[class_path][category]["##{method_name}"] || []
737
+ end
738
+ end
739
+ end
740
+ end