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,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
|