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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/lib/chiridion/config.rb +128 -0
- data/lib/chiridion/engine/class_linker.rb +204 -0
- data/lib/chiridion/engine/document_model.rb +299 -0
- data/lib/chiridion/engine/drift_checker.rb +146 -0
- data/lib/chiridion/engine/extractor.rb +311 -0
- data/lib/chiridion/engine/file_renderer.rb +717 -0
- data/lib/chiridion/engine/file_writer.rb +160 -0
- data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
- data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
- data/lib/chiridion/engine/github_linker.rb +87 -0
- data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
- data/lib/chiridion/engine/post_processor.rb +86 -0
- data/lib/chiridion/engine/rbs_loader.rb +150 -0
- data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
- data/lib/chiridion/engine/renderer.rb +598 -0
- data/lib/chiridion/engine/semantic_extractor.rb +740 -0
- data/lib/chiridion/engine/semantic_renderer.rb +334 -0
- data/lib/chiridion/engine/spec_example_loader.rb +84 -0
- data/lib/chiridion/engine/template_renderer.rb +275 -0
- data/lib/chiridion/engine/type_merger.rb +126 -0
- data/lib/chiridion/engine/writer.rb +134 -0
- data/lib/chiridion/engine.rb +359 -0
- data/lib/chiridion/semantic_engine.rb +186 -0
- data/lib/chiridion/version.rb +5 -0
- data/lib/chiridion.rb +106 -0
- data/templates/constants.liquid +27 -0
- data/templates/document.liquid +48 -0
- data/templates/file.liquid +108 -0
- data/templates/index.liquid +21 -0
- data/templates/method.liquid +43 -0
- data/templates/methods.liquid +11 -0
- data/templates/type_aliases.liquid +26 -0
- data/templates/types.liquid +11 -0
- 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
|