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,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Renders documentation to Obsidian-compatible markdown.
6
+ #
7
+ # Uses Liquid templates for the document body content, while YAML frontmatter
8
+ # is rendered directly in Ruby due to its complex formatting requirements
9
+ # (flow vs block arrays, proper quoting, etc.).
10
+ #
11
+ # ## Template Customization
12
+ #
13
+ # Templates are loaded from the gem's templates/ directory by default.
14
+ # Override by passing a custom templates_path to the constructor.
15
+ #
16
+ # ## Enhanced Frontmatter
17
+ #
18
+ # All generated documents include enhanced YAML frontmatter for Obsidian:
19
+ # - **Navigation**: parent links for breadcrumb traversal
20
+ # - **Discovery**: tags for filtering, related links for exploration
21
+ # - **Search**: aliases for finding by short name, description for preview
22
+ class Renderer
23
+ def initialize(
24
+ namespace_strip:,
25
+ include_specs:,
26
+ root: Dir.pwd,
27
+ github_repo: nil,
28
+ github_branch: "main",
29
+ project_title: "API Documentation",
30
+ index_description: nil,
31
+ templates_path: nil,
32
+ inline_source_threshold: 10,
33
+ rbs_attr_types: {}
34
+ )
35
+ @namespace_strip = namespace_strip
36
+ @include_specs = include_specs
37
+ @root = root
38
+ @index_description = index_description || "Auto-generated from source code."
39
+ @inline_source_threshold = inline_source_threshold
40
+ @rbs_attr_types = rbs_attr_types || {}
41
+ @class_linker = ClassLinker.new(namespace_strip: namespace_strip)
42
+ @github_linker = GithubLinker.new(repo: github_repo, branch: github_branch, root: root)
43
+ @frontmatter_builder = FrontmatterBuilder.new(
44
+ @class_linker,
45
+ namespace_strip: namespace_strip,
46
+ project_title: project_title
47
+ )
48
+ @template_renderer = TemplateRenderer.new(templates_path: templates_path)
49
+ end
50
+
51
+ # Register known classes for cross-reference linking and inheritance.
52
+ #
53
+ # @param structure [Hash] Documentation structure from Extractor
54
+ def register_classes(structure)
55
+ @class_linker.register_classes(structure)
56
+ @frontmatter_builder.register_inheritance(structure)
57
+ end
58
+
59
+ # Render the documentation index.
60
+ #
61
+ # @param structure [Hash] Documentation structure from Extractor
62
+ # @return [String] Markdown index
63
+ def render_index(structure)
64
+ frontmatter = @frontmatter_builder.build_index
65
+
66
+ classes = structure[:classes].map do |c|
67
+ { path: c[:path], link_path: link(c[:path]) }
68
+ end
69
+
70
+ modules = structure[:modules].map do |m|
71
+ { path: m[:path], link_path: link(m[:path]) }
72
+ end
73
+
74
+ body = @template_renderer.render_index(
75
+ title: frontmatter[:title],
76
+ description: @index_description,
77
+ classes: classes,
78
+ modules: modules
79
+ )
80
+
81
+ "#{render_frontmatter(frontmatter)}\n\n#{body}\n"
82
+ end
83
+
84
+ # Render class documentation.
85
+ #
86
+ # @param klass [Hash] Class data from Extractor
87
+ # @return [String] Markdown documentation
88
+ def render_class(klass) = render_document(klass, include_mixins: true)
89
+
90
+ # Render module documentation.
91
+ #
92
+ # @param mod [Hash] Module data from Extractor
93
+ # @return [String] Markdown documentation
94
+ def render_module(mod) = render_document(mod, include_mixins: false)
95
+
96
+ # Render type aliases reference page.
97
+ #
98
+ # @param type_aliases [Hash{String => Array<Hash>}] namespace -> types mapping
99
+ # @return [String] Markdown documentation
100
+ def render_type_aliases(type_aliases)
101
+ return nil if type_aliases.nil? || type_aliases.empty?
102
+
103
+ frontmatter = {
104
+ generated: Time.now.utc.iso8601,
105
+ title: "Type Aliases Reference",
106
+ type: "reference",
107
+ description: "RBS type aliases defined across the codebase",
108
+ tags: %w[types rbs reference]
109
+ }
110
+
111
+ # Convert to array format for template
112
+ namespaces = type_aliases.map do |namespace, types|
113
+ {
114
+ name: namespace.empty? ? "(root)" : namespace,
115
+ types: types.map do |t|
116
+ {
117
+ name: t[:name],
118
+ definition: t[:definition],
119
+ description: t[:description]
120
+ }
121
+ end
122
+ }
123
+ end.sort_by { |ns| ns[:name] }
124
+
125
+ body = @template_renderer.render_type_aliases(
126
+ title: "Type Aliases Reference",
127
+ description: "RBS type aliases defined across the codebase. " \
128
+ "These types can be referenced in `@rbs` annotations.",
129
+ namespaces: namespaces
130
+ )
131
+
132
+ "#{render_frontmatter(frontmatter)}\n\n#{body}\n"
133
+ end
134
+
135
+ private
136
+
137
+ def render_document(obj, include_mixins:)
138
+ frontmatter = build_document_frontmatter(obj)
139
+ docstring = @class_linker.linkify_docstring(obj[:docstring], context: obj[:path])
140
+
141
+ # Partition attributes from regular methods
142
+ attrs, regular = partition_attributes(obj[:methods] || [])
143
+ attrs_section = render_attributes_section(attrs, obj[:path])
144
+ methods_section = render_methods_only(regular, obj[:path])
145
+ private_summary = render_private_methods_summary(obj[:private_methods])
146
+ full_methods = [methods_section, private_summary].reject(&:empty?).join("\n\n")
147
+
148
+ body = @template_renderer.render_document(
149
+ title: obj[:path],
150
+ docstring: docstring,
151
+ mixins: include_mixins ? render_mixins(obj) : nil,
152
+ examples: obj[:examples] || [],
153
+ spec_examples: render_spec_examples(obj),
154
+ see_also: render_see_also(obj[:see_also], obj[:path]),
155
+ constants_section: render_constants(obj[:constants]),
156
+ types_section: render_types_section(obj[:referenced_types]),
157
+ attributes_section: attrs_section,
158
+ methods_section: full_methods
159
+ )
160
+
161
+ "#{render_frontmatter(frontmatter)}\n\n#{body}\n"
162
+ end
163
+
164
+ def build_document_frontmatter(obj)
165
+ frontmatter = @frontmatter_builder.build(obj)
166
+ # Convert absolute paths to relative
167
+ frontmatter[:source] = relative_path(frontmatter[:source])
168
+ frontmatter[:source] = format_source_with_lines(frontmatter[:source], obj[:line], obj[:end_line])
169
+ frontmatter[:source_url] = @github_linker.url(
170
+ frontmatter[:source].split(":").first,
171
+ obj[:line],
172
+ obj[:end_line]
173
+ )
174
+ frontmatter
175
+ end
176
+
177
+ # Render frontmatter hash to YAML.
178
+ #
179
+ # Uses flow style [a, b, c] for compact arrays (methods, constants, tags).
180
+ # Uses block style for arrays with wikilinks (related, inherited_by).
181
+ # Omits nil values.
182
+ def render_frontmatter(frontmatter)
183
+ block_style_fields = [:related, :inherited_by]
184
+
185
+ lines = ["---"]
186
+ frontmatter.each do |key, value|
187
+ next if value.nil?
188
+
189
+ if value.is_a?(Array)
190
+ if block_style_fields.include?(key)
191
+ lines << "#{key}:"
192
+ value.each { |v| lines << " - #{v}" }
193
+ else
194
+ lines << "#{key}: [#{value.join(', ')}]"
195
+ end
196
+ else
197
+ lines << "#{key}: #{value}"
198
+ end
199
+ end
200
+ lines << "---"
201
+ lines.join("\n")
202
+ end
203
+
204
+ def relative_path(absolute_path)
205
+ return absolute_path unless absolute_path&.start_with?(@root)
206
+
207
+ absolute_path.delete_prefix("#{@root}/")
208
+ end
209
+
210
+ def link(class_path)
211
+ stripped = @namespace_strip ? class_path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_path
212
+ parts = stripped.split("::")
213
+ kebab_parts = parts.map { |p| to_kebab_case(p) }
214
+ File.join(*kebab_parts[0..-2], kebab_parts.last)
215
+ end
216
+
217
+ def render_mixins(klass)
218
+ return nil unless klass[:includes].any? || klass[:extends].any?
219
+
220
+ parts = []
221
+ if klass[:includes].any?
222
+ linked = klass[:includes].map { |m| @class_linker.link(m, context: klass[:path]) }
223
+ parts << "**Includes:** #{linked.join(', ')}"
224
+ end
225
+ if klass[:extends].any?
226
+ linked = klass[:extends].map { |m| @class_linker.link(m, context: klass[:path]) }
227
+ parts << "**Extended by:** #{linked.join(', ')}"
228
+ end
229
+ parts.join(" · ")
230
+ end
231
+
232
+ def render_see_also(see_tags, context)
233
+ return nil if see_tags.nil? || see_tags.empty?
234
+
235
+ links = see_tags.map do |tag|
236
+ link_text = @class_linker.link(tag[:name], context: context)
237
+ tag[:text].to_s.empty? ? link_text : "#{link_text} — #{tag[:text]}"
238
+ end
239
+ "**See also:** #{links.join(' · ')}"
240
+ end
241
+
242
+ def render_spec_examples(obj)
243
+ return nil unless @include_specs && obj[:spec_examples]
244
+
245
+ ex = obj[:spec_examples]
246
+ return nil if ex[:lets].empty? && ex[:subjects].empty?
247
+
248
+ parts = ["## Usage Examples (from specs)"]
249
+ ex[:subjects].each { |e| parts << "**#{e[:name]}:**\n\n```ruby\n#{clean(e[:code], obj[:path])}\n```" }
250
+ ex[:lets].first(5).each { |e| parts << "**#{e[:name]}:**\n\n```ruby\n#{clean(e[:code], obj[:path])}\n```" }
251
+ parts.join("\n\n")
252
+ end
253
+
254
+ def clean(code, class_path) = code.gsub("described_class", class_path.split("::").last).strip
255
+
256
+ def format_source_with_lines(path, start_line, end_line)
257
+ return path unless start_line
258
+
259
+ if end_line && end_line != start_line
260
+ "#{path}:#{start_line}–#{end_line}"
261
+ else
262
+ "#{path}:#{start_line}"
263
+ end
264
+ end
265
+
266
+ def render_constants(constants)
267
+ return "" if constants.nil? || constants.empty?
268
+
269
+ _, complex = partition_constants(constants)
270
+
271
+ constant_data = constants.map do |c|
272
+ {
273
+ name: c[:name],
274
+ value: format_constant_value(c[:value], complex.include?(c)),
275
+ docstring: c[:docstring].to_s,
276
+ is_complex: complex.include?(c)
277
+ }
278
+ end
279
+
280
+ complex_data = complex.map do |c|
281
+ {
282
+ name: c[:name],
283
+ value: strip_freeze(c[:value]),
284
+ docstring: c[:docstring].to_s
285
+ }
286
+ end
287
+
288
+ @template_renderer.render_constants(
289
+ constants: constant_data,
290
+ complex_constants: complex_data
291
+ )
292
+ end
293
+
294
+ def render_types_section(referenced_types)
295
+ return "" if referenced_types.nil? || referenced_types.empty?
296
+
297
+ types_data = referenced_types.map do |t|
298
+ {
299
+ name: t[:name],
300
+ definition: t[:definition],
301
+ description: t[:description],
302
+ namespace: t[:namespace]
303
+ }
304
+ end
305
+
306
+ @template_renderer.render_types(types: types_data)
307
+ end
308
+
309
+ def partition_constants(constants) = constants.partition { |c| !complex_constant?(c) }
310
+
311
+ def complex_constant?(c)
312
+ value = c[:value].to_s
313
+ doc = c[:docstring].to_s
314
+
315
+ # Complex if value has multiple lines
316
+ return true if value.count("\n") > 1
317
+
318
+ # Complex if docstring has markdown structure or is lengthy
319
+ return true if doc.count("\n") > 1
320
+ return true if doc.match?(/^#+\s/) # Headers
321
+ return true if doc.match?(/^[-*]\s/) # Bullet points
322
+ return true if doc.length > 120 # Long single-line descriptions
323
+
324
+ false
325
+ end
326
+
327
+ def format_constant_value(value, is_complex)
328
+ return "" if is_complex
329
+ return "nil" if value.nil?
330
+
331
+ strip_freeze(value.to_s).gsub("|", "\\|").gsub("\n", "<br />")
332
+ end
333
+
334
+ def strip_freeze(str) = str.to_s.delete_suffix(".freeze")
335
+
336
+ def render_methods_only(methods, context)
337
+ return "" if methods.nil? || methods.empty?
338
+
339
+ rendered_methods = methods.map { |m| render_method(m, context) }
340
+ @template_renderer.render_methods(methods: rendered_methods)
341
+ end
342
+
343
+ # Partition methods into attributes (reader/writer pairs) and regular methods.
344
+ # Returns [attrs_hash, regular_methods] where attrs_hash maps name -> {reader:, writer:}
345
+ def partition_attributes(methods)
346
+ attrs = {}
347
+ regular = []
348
+
349
+ methods.each do |m|
350
+ case m[:attr_type]
351
+ when :reader
352
+ name = m[:name].to_s
353
+ (attrs[name] ||= {})[:reader] = m
354
+ when :writer
355
+ name = m[:name].to_s.chomp("=")
356
+ (attrs[name] ||= {})[:writer] = m
357
+ else
358
+ regular << m
359
+ end
360
+ end
361
+
362
+ [attrs, regular]
363
+ end
364
+
365
+ # Render attributes section with param-like formatting.
366
+ def render_attributes_section(attrs, class_path)
367
+ return "" if attrs.empty?
368
+
369
+ sorted = attrs.sort_by { |name, _| name }
370
+ max_name_len = sorted.map { |name, _| name.length }.max
371
+
372
+ # Build inners to find max width
373
+ inners = sorted.map { |name, info| build_attr_inner(name, info, max_name_len, class_path) }
374
+ max_inner_len = inners.map(&:length).max
375
+
376
+ lines = sorted.zip(inners).map do |(name, info), inner|
377
+ format_attr_line(name, info, inner, max_inner_len, class_path)
378
+ end
379
+
380
+ "## Attributes\n\n#{lines.join("\n")}"
381
+ end
382
+
383
+ def build_attr_inner(name, info, max_name_len, class_path)
384
+ type = attr_type_str(info, name, class_path)
385
+ padded = name.ljust(max_name_len)
386
+ type ? "#{padded} : #{type}" : padded
387
+ end
388
+
389
+ def format_attr_line(name, info, inner, max_inner_len, class_path)
390
+ mode = attr_mode(info)
391
+ desc = attr_description(name, info, class_path)
392
+ padded_sig = "⟨#{inner}⟩".ljust(max_inner_len + 2)
393
+
394
+ # Prepend (Read) or (Write) for non-rw attributes
395
+ prefix = case mode
396
+ when "r" then "(Read) "
397
+ when "w" then "(Write) "
398
+ else ""
399
+ end
400
+
401
+ full_desc = "#{prefix}#{desc}".strip
402
+ full_desc.empty? ? "`#{padded_sig}`" : "`#{padded_sig}` — #{full_desc}"
403
+ end
404
+
405
+ def attr_mode(info)
406
+ has_reader = info[:reader]
407
+ has_writer = info[:writer]
408
+ return "rw" if has_reader && has_writer
409
+ return "r" if has_reader
410
+
411
+ "w"
412
+ end
413
+
414
+ def attr_description(name, info, class_path)
415
+ # First check @rbs_attr_types for description (most specific)
416
+ rbs_data = @rbs_attr_types.dig(class_path, name)
417
+ rbs_desc = rbs_data[:desc] if rbs_data.is_a?(Hash)
418
+ return rbs_desc if rbs_desc && !rbs_desc.empty?
419
+
420
+ # Fall back to YARD reader's return description, then writer's
421
+ reader_desc = info[:reader]&.dig(:returns, :text).to_s
422
+ desc = reader_desc.empty? ? info[:writer]&.dig(:returns, :text).to_s : reader_desc
423
+ # Collapse to single line, capitalize
424
+ clean = desc.gsub(/\s*\n\s*/, " ").strip
425
+ capitalize_first(clean)
426
+ end
427
+
428
+ def attr_type_str(info, attr_name, class_path)
429
+ # First check @rbs_attr_types (from #: annotations or @rbs! blocks)
430
+ rbs_data = @rbs_attr_types.dig(class_path, attr_name)
431
+ rbs_type = rbs_data.is_a?(Hash) ? rbs_data[:type] : rbs_data
432
+ return rbs_type if rbs_type && rbs_type != "untyped"
433
+
434
+ # Fall back to reader's return type or writer's param type
435
+ reader_type = info[:reader]&.dig(:returns, :types)&.first
436
+ return reader_type if reader_type && reader_type != "untyped" && reader_type != "Object"
437
+
438
+ first_param = info[:writer]&.dig(:params)&.first
439
+ writer_type = first_param&.dig(:types)&.first
440
+ return writer_type if writer_type && writer_type != "untyped" && writer_type != "Object"
441
+
442
+ nil
443
+ end
444
+
445
+ # Render a compact summary of private methods.
446
+ def render_private_methods_summary(private_methods)
447
+ return "" if private_methods.nil? || private_methods.empty?
448
+
449
+ sorted = private_methods.sort_by { |m| [m[:scope] == :class ? 0 : 1, m[:name].to_s] }
450
+ items = sorted.map do |m|
451
+ prefix = m[:scope] == :class ? "." : "#"
452
+ line = m[:line] ? ":#{m[:line]}" : ""
453
+ "`#{prefix}#{m[:name]}`#{line}"
454
+ end
455
+
456
+ "---\n\n**Private:** #{items.join(', ')}"
457
+ end
458
+
459
+ def render_method(meth, context)
460
+ display_name = method_display_name(meth)
461
+ docstring = if useful_docstring?(meth[:docstring])
462
+ @class_linker.linkify_docstring(meth[:docstring], context: context)
463
+ end
464
+
465
+ params, return_line = render_params_and_return(meth)
466
+
467
+ @template_renderer.render_method(
468
+ display_name: display_name,
469
+ has_params: meth[:params]&.any?,
470
+ docstring: docstring,
471
+ params: params,
472
+ return_line: return_line,
473
+ examples: meth[:examples] || [],
474
+ behaviors: @include_specs ? (meth[:spec_behaviors] || []).first(8) : [],
475
+ spec_examples: @include_specs ? (meth[:spec_examples] || []).first(3) : [],
476
+ inline_source: inline_source_for(meth)
477
+ )
478
+ end
479
+
480
+ # Returns method source if it's short enough to display inline.
481
+ # Prepends a location comment showing relative file path and line number.
482
+ def inline_source_for(meth)
483
+ return nil unless @inline_source_threshold&.positive?
484
+ return nil unless meth[:source]
485
+
486
+ body_lines = meth[:source_body_lines]
487
+ return nil if body_lines.nil? || body_lines > @inline_source_threshold
488
+
489
+ source = meth[:source]
490
+ if meth[:file] && meth[:line]
491
+ location = "# #{relative_path(meth[:file])} : ~#{meth[:line]}\n"
492
+ "#{location}#{source}"
493
+ else
494
+ source
495
+ end
496
+ end
497
+
498
+ def method_display_name(meth)
499
+ return "#{meth[:class_name]}.new" if meth[:name] == :initialize
500
+ return "#{meth[:class_name]}.#{meth[:name]}" if meth[:scope] == :class
501
+
502
+ meth[:name].to_s
503
+ end
504
+
505
+ def useful_docstring?(docstring)
506
+ return false if docstring.to_s.empty?
507
+ return false if docstring.match?(/\AReturns the value of attribute \w+\.?\z/)
508
+
509
+ true
510
+ end
511
+
512
+ # Render params and return together so they can share alignment width.
513
+ def render_params_and_return(meth)
514
+ params = meth[:params] || []
515
+ returns = meth[:returns]
516
+
517
+ # Calculate param inners and max width
518
+ max_name_len = params.map { |p| clean_param_name(p[:name]).length }.max || 0
519
+ param_inners = params.map { |p| build_param_inner(p, max_name_len) }
520
+ max_inner_len = param_inners.map(&:length).max || 0
521
+
522
+ # Include return type in width calculation
523
+ return_type = extract_return_type(meth)
524
+ max_inner_len = [max_inner_len, return_type&.length || 0].max if return_type
525
+
526
+ param_lines = params.zip(param_inners).map { |p, inner| format_param_line(p, inner, max_inner_len) }
527
+ return_line = render_return_line(returns, return_type, max_inner_len)
528
+
529
+ [param_lines, return_line]
530
+ end
531
+
532
+ def build_param_inner(param, max_name_len)
533
+ name = clean_param_name(param[:name])
534
+ prefix = extract_param_prefix(param[:name])
535
+ raw_type = param[:types]&.first
536
+ type = raw_type && raw_type != "untyped" ? " : #{normalize_type(raw_type)}" : ""
537
+ default = param[:default]
538
+ padded = name.ljust(max_name_len)
539
+
540
+ default ? "#{prefix}#{padded}#{type} = #{default}" : "#{prefix}#{padded}#{type}"
541
+ end
542
+
543
+ def format_param_line(param, inner, max_inner_len)
544
+ desc = param[:text].to_s
545
+ padded_sig = "⟨#{inner}⟩".ljust(max_inner_len + 2) # +2 for ⟨⟩
546
+
547
+ desc.empty? ? "`#{padded_sig}`" : "`#{padded_sig}` — #{desc}"
548
+ end
549
+
550
+ def clean_param_name(name) = name.to_s.delete_prefix("*").delete_prefix("*").delete_prefix("&").chomp(":")
551
+
552
+ def extract_param_prefix(name)
553
+ str = name.to_s
554
+ return "**" if str.start_with?("**")
555
+ return "*" if str.start_with?("*")
556
+ return "&" if str.start_with?("&")
557
+
558
+ ""
559
+ end
560
+
561
+ def normalize_type(type) = type.tr("<", "[").tr(">", "]")
562
+
563
+ def extract_return_type(meth)
564
+ returns = meth[:returns]
565
+ return nil unless returns
566
+
567
+ type = returns[:types]&.first
568
+ type = meth[:class_name] if meth[:name] == :initialize && type == "void"
569
+ return nil if type.nil? || type == "void"
570
+
571
+ normalize_type(type)
572
+ end
573
+
574
+ def render_return_line(returns, type, max_width)
575
+ return nil unless type
576
+
577
+ desc = capitalize_first(returns[:text].to_s)
578
+ padded_sig = type.ljust(max_width)
579
+
580
+ desc.to_s.empty? ? "⟶ `#{padded_sig}`" : "⟶ `#{padded_sig}` — #{desc}"
581
+ end
582
+
583
+ def capitalize_first(str)
584
+ return nil if str.nil? || str.strip.empty?
585
+
586
+ s = str.strip
587
+ s[0].upcase + s[1..]
588
+ end
589
+
590
+ def to_kebab_case(str)
591
+ str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
592
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
593
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
594
+ .downcase
595
+ end
596
+ end
597
+ end
598
+ end