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,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Extracts documentation structure from YARD registry.
|
|
6
|
+
#
|
|
7
|
+
# Parses Ruby source using YARD and builds a structured representation
|
|
8
|
+
# of classes, modules, methods, and constants for documentation generation.
|
|
9
|
+
# Merges RBS type signatures when available.
|
|
10
|
+
class Extractor
|
|
11
|
+
def initialize(rbs_types, spec_examples, namespace_filter, logger = nil, rbs_file_namespaces: {},
|
|
12
|
+
type_aliases: {})
|
|
13
|
+
@rbs_types = rbs_types
|
|
14
|
+
@spec_examples = spec_examples
|
|
15
|
+
@namespace_filter = namespace_filter
|
|
16
|
+
@logger = logger
|
|
17
|
+
@type_merger = TypeMerger.new(logger)
|
|
18
|
+
@rbs_file_namespaces = rbs_file_namespaces || {}
|
|
19
|
+
@type_aliases = type_aliases || {}
|
|
20
|
+
@type_alias_lookup = build_type_alias_lookup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Extract documentation structure from YARD registry.
|
|
24
|
+
#
|
|
25
|
+
# @param registry [YARD::Registry] Parsed YARD registry
|
|
26
|
+
# @param source_filter [Array<String>, nil] If provided, only objects from these
|
|
27
|
+
# source files are marked for regeneration. All objects are still extracted
|
|
28
|
+
# (for index generation), but only filtered ones get full documentation.
|
|
29
|
+
# @return [Hash] Structure with :namespaces, :classes, :modules keys
|
|
30
|
+
def extract(registry, source_filter: nil)
|
|
31
|
+
structure = { namespaces: [], classes: [], modules: [] }
|
|
32
|
+
@source_filter = source_filter&.map { |f| File.expand_path(f) }
|
|
33
|
+
|
|
34
|
+
registry.all(:class, :module).each do |obj|
|
|
35
|
+
next unless should_document?(obj)
|
|
36
|
+
|
|
37
|
+
doc = extract_object(obj)
|
|
38
|
+
structure[:classes] << doc if obj.is_a?(YARD::CodeObjects::ClassObject)
|
|
39
|
+
structure[:modules] << doc if obj.is_a?(YARD::CodeObjects::ModuleObject)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
structure
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def should_document?(obj)
|
|
48
|
+
return true unless @namespace_filter
|
|
49
|
+
|
|
50
|
+
obj.path.start_with?(@namespace_filter)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_object(obj)
|
|
54
|
+
path = obj.path
|
|
55
|
+
needs_regen = needs_regeneration?(obj)
|
|
56
|
+
methods = needs_regen ? extract_methods(obj, path) : []
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: obj.name,
|
|
60
|
+
path: path,
|
|
61
|
+
type: obj.type,
|
|
62
|
+
docstring: clean_docstring(obj.docstring.to_s),
|
|
63
|
+
file: obj.file,
|
|
64
|
+
line: obj.line,
|
|
65
|
+
end_line: compute_end_line(obj),
|
|
66
|
+
examples: needs_regen ? obj.tags(:example).map { |t| { name: t.name, text: t.text } } : [],
|
|
67
|
+
see_also: needs_regen ? extract_see_tags(obj) : [],
|
|
68
|
+
methods: methods,
|
|
69
|
+
private_methods: needs_regen ? extract_private_methods(obj) : [],
|
|
70
|
+
constants: needs_regen ? extract_constants(obj, path) : [],
|
|
71
|
+
includes: obj.instance_mixins.map(&:path),
|
|
72
|
+
extends: obj.class_mixins.map(&:path),
|
|
73
|
+
superclass: obj.respond_to?(:superclass) ? obj.superclass&.path : nil,
|
|
74
|
+
rbs_file: needs_regen ? find_rbs_file(path) : nil,
|
|
75
|
+
spec_examples: needs_regen ? @spec_examples[path] : nil,
|
|
76
|
+
referenced_types: needs_regen ? collect_referenced_types(methods) : [],
|
|
77
|
+
needs_regeneration: needs_regen
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_see_tags(obj) = obj.tags(:see).map { |t| { name: t.name, text: t.text } }
|
|
82
|
+
|
|
83
|
+
def compute_end_line(obj)
|
|
84
|
+
return nil unless obj.source
|
|
85
|
+
|
|
86
|
+
obj.line + obj.source.lines.count - 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def needs_regeneration?(obj)
|
|
90
|
+
return true if @source_filter.nil?
|
|
91
|
+
|
|
92
|
+
source_file = obj.file && File.expand_path(obj.file)
|
|
93
|
+
return true if @source_filter.include?(source_file)
|
|
94
|
+
|
|
95
|
+
# Also check if any filtered source file has @rbs content for this namespace.
|
|
96
|
+
# This handles files like shared_types.rb that reopen a module with @rbs!
|
|
97
|
+
# blocks but no new methods/classes - YARD attributes them to the original
|
|
98
|
+
# module definition, so we need to check the rbs_file_namespaces mapping.
|
|
99
|
+
obj_path = obj.path
|
|
100
|
+
@source_filter.any? do |filtered_file|
|
|
101
|
+
namespaces = @rbs_file_namespaces[filtered_file]
|
|
102
|
+
namespaces&.include?(obj_path)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_constants(obj, class_path)
|
|
107
|
+
obj.constants.map do |c|
|
|
108
|
+
{ name: c.name, value: c.value, docstring: clean_docstring(c.docstring.to_s), class_path: class_path }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def clean_docstring(str)
|
|
113
|
+
return "" if str.nil? || str.empty?
|
|
114
|
+
|
|
115
|
+
# Strip @rbs! blocks (multi-line RBS annotations meant for RBS::Inline)
|
|
116
|
+
# The block continues while lines are indented (start with whitespace)
|
|
117
|
+
cleaned = str.gsub(/@rbs!\s*\n(?:\s+.*(?:\n|\z))*/, "")
|
|
118
|
+
|
|
119
|
+
cleaned.lines
|
|
120
|
+
.reject { |line| line.strip.match?(/^rubocop:(disable|enable|todo)\b/i) }
|
|
121
|
+
.join
|
|
122
|
+
.strip
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_methods(obj, class_path)
|
|
126
|
+
instance_methods = obj.meths(scope: :instance, visibility: :public).map do |m|
|
|
127
|
+
extract_method(m, class_path, :instance)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class_methods = obj.meths(scope: :class, visibility: :public).map do |m|
|
|
131
|
+
extract_method(m, class_path, :class)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class_methods + instance_methods
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extract minimal info for private methods (for summary display).
|
|
138
|
+
def extract_private_methods(obj)
|
|
139
|
+
private_instance = obj.meths(scope: :instance, visibility: :private).map do |m|
|
|
140
|
+
{ name: m.name, scope: :instance, line: m.line }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class = obj.meths(scope: :class, visibility: :private).map do |m|
|
|
144
|
+
{ name: m.name, scope: :class, line: m.line }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private_class + private_instance
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_method(meth, class_path, scope)
|
|
151
|
+
rbs_data = @rbs_types.dig(class_path, meth.name.to_s)
|
|
152
|
+
yard_params = extract_params(meth)
|
|
153
|
+
yard_return = extract_return(meth)
|
|
154
|
+
source_info = extract_source(meth)
|
|
155
|
+
{
|
|
156
|
+
name: meth.name,
|
|
157
|
+
signature: meth.signature,
|
|
158
|
+
docstring: clean_docstring(meth.docstring.to_s),
|
|
159
|
+
params: @type_merger.merge_params(yard_params, rbs_data, class_path, meth.name),
|
|
160
|
+
returns: @type_merger.merge_return(yard_return, rbs_data, class_path, meth.name),
|
|
161
|
+
examples: meth.tags(:example).map { |t| { name: t.name, text: t.text } },
|
|
162
|
+
visibility: meth.visibility,
|
|
163
|
+
scope: scope,
|
|
164
|
+
class_name: class_path.split("::").last,
|
|
165
|
+
rbs_type: rbs_data&.dig(:full),
|
|
166
|
+
spec_examples: method_spec_examples(class_path, meth.name),
|
|
167
|
+
spec_behaviors: method_spec_behaviors(class_path, meth.name),
|
|
168
|
+
source: source_info[:source],
|
|
169
|
+
source_body_lines: source_info[:body_lines],
|
|
170
|
+
attr_type: source_info[:attr_type],
|
|
171
|
+
file: meth.file,
|
|
172
|
+
line: meth.line
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def extract_params(meth)
|
|
177
|
+
param_tags = meth.tags(:param).to_h { |t| [t.name, { types: t.types, text: t.text }] }
|
|
178
|
+
|
|
179
|
+
meth.parameters.map do |name, default|
|
|
180
|
+
param_name = name.to_s.delete_prefix("*").delete_prefix("**").delete_prefix("&")
|
|
181
|
+
tag_info = param_tags[param_name] || {}
|
|
182
|
+
{ name: name, types: tag_info[:types], text: tag_info[:text], default: default }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def extract_return(meth)
|
|
187
|
+
tag = meth.tag(:return)
|
|
188
|
+
tag ? { types: tag.types, text: tag.text } : nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Extract source code and compute body line count.
|
|
192
|
+
#
|
|
193
|
+
# YARD's meth.source includes the full method including def/end.
|
|
194
|
+
# Body lines = total lines minus def line and end line.
|
|
195
|
+
# For one-liners like `def foo = bar`, body_lines is 0 (inline expression).
|
|
196
|
+
#
|
|
197
|
+
# Condenses attr_reader/attr_writer expansions to one-liner syntax:
|
|
198
|
+
# def foo; @foo; end → def foo = @foo
|
|
199
|
+
# def foo=(v); @foo = v; end → def foo=(v) = (@foo = v)
|
|
200
|
+
#
|
|
201
|
+
# Returns hash with :source, :body_lines, and :attr_type (:reader/:writer/nil)
|
|
202
|
+
def extract_source(meth)
|
|
203
|
+
source = meth.source
|
|
204
|
+
return { source: nil, body_lines: nil, attr_type: nil } unless source
|
|
205
|
+
|
|
206
|
+
# Try to condense attr_* expansions to one-liners
|
|
207
|
+
condensed = condense_attr_source(source)
|
|
208
|
+
return { source: condensed[:source], body_lines: 0, attr_type: condensed[:attr_type] } if condensed
|
|
209
|
+
|
|
210
|
+
lines = source.lines
|
|
211
|
+
total = lines.size
|
|
212
|
+
|
|
213
|
+
# One-liner methods (def foo = ...) have no separate body
|
|
214
|
+
if total == 1 || source.match?(/\Adef\s+\S+.*=/)
|
|
215
|
+
{ source: source, body_lines: 0, attr_type: nil }
|
|
216
|
+
else
|
|
217
|
+
# Subtract def line (1) and end line (1) = body lines
|
|
218
|
+
{ source: source, body_lines: [total - 2, 0].max, attr_type: nil }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Detect and condense attr_reader/attr_writer expanded methods.
|
|
223
|
+
# Returns { source: String, attr_type: Symbol } or nil if not an attr_* pattern.
|
|
224
|
+
def condense_attr_source(source)
|
|
225
|
+
lines = source.lines.map(&:strip)
|
|
226
|
+
return nil unless lines.size == 3 && lines.last == "end"
|
|
227
|
+
|
|
228
|
+
# attr_reader pattern: def foo; @foo; end
|
|
229
|
+
if (reader_match = lines[0].match(/\Adef\s+(\w+)\z/)) && (ivar_match = lines[1].match(/\A@(\w+)\z/))
|
|
230
|
+
return { source: "def #{reader_match[1]} = @#{ivar_match[1]}", attr_type: :reader }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# attr_writer pattern: def foo=(val); @foo = val; end
|
|
234
|
+
if (writer_match = lines[0].match(/\Adef\s+(\w+)=\((\w+)\)\z/)) &&
|
|
235
|
+
(assign_match = lines[1].match(/\A@(\w+)\s*=\s*(\w+)\z/))
|
|
236
|
+
method_name = writer_match[1]
|
|
237
|
+
param_name = writer_match[2]
|
|
238
|
+
ivar_name = assign_match[1]
|
|
239
|
+
return { source: "def #{method_name}=(#{param_name}) = (@#{ivar_name} = #{param_name})", attr_type: :writer }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def method_spec_examples(class_path, method_name) = lookup_spec_data(class_path, :method_examples, method_name)
|
|
246
|
+
|
|
247
|
+
def method_spec_behaviors(class_path, method_name) = lookup_spec_data(class_path, :behaviors, method_name)
|
|
248
|
+
|
|
249
|
+
def lookup_spec_data(class_path, category, method_name)
|
|
250
|
+
return [] unless @spec_examples[class_path]
|
|
251
|
+
|
|
252
|
+
@spec_examples[class_path][category][".#{method_name}"] ||
|
|
253
|
+
@spec_examples[class_path][category]["##{method_name}"] || []
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def find_rbs_file(class_path)
|
|
257
|
+
parts = class_path.split("::").map { |part| to_snake_case(part) }
|
|
258
|
+
path = "sig/#{parts.join('/')}.rbs"
|
|
259
|
+
File.exist?(path) ? path : nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def to_snake_case(str) = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
263
|
+
|
|
264
|
+
# Build flat lookup of type alias names to their full definitions.
|
|
265
|
+
# Allows quick matching when scanning method signatures.
|
|
266
|
+
def build_type_alias_lookup
|
|
267
|
+
lookup = {}
|
|
268
|
+
@type_aliases.each do |namespace, types|
|
|
269
|
+
types.each do |type_info|
|
|
270
|
+
lookup[type_info[:name]] = type_info.merge(namespace: namespace)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
lookup
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Scan method signatures for references to known type aliases.
|
|
277
|
+
# Returns array of type definitions used by this class's methods.
|
|
278
|
+
def collect_referenced_types(methods)
|
|
279
|
+
return [] if @type_alias_lookup.empty? || methods.empty?
|
|
280
|
+
|
|
281
|
+
referenced_names = Set.new
|
|
282
|
+
|
|
283
|
+
methods.each do |meth|
|
|
284
|
+
# Scan param types
|
|
285
|
+
meth[:params]&.each do |param|
|
|
286
|
+
extract_type_names(param[:types]).each { |name| referenced_names.add(name) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Scan return type
|
|
290
|
+
extract_type_names(meth[:returns][:types]).each { |name| referenced_names.add(name) } if meth[:returns]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Look up full definitions for referenced type names
|
|
294
|
+
referenced_names
|
|
295
|
+
.filter_map { |name| @type_alias_lookup[name] }
|
|
296
|
+
.sort_by { |t| t[:name] }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Extract potential type alias names from type strings.
|
|
300
|
+
# Handles types like "filter_value", "Array[filter_value]", "Hash[Symbol, actor]"
|
|
301
|
+
def extract_type_names(types)
|
|
302
|
+
return [] unless types
|
|
303
|
+
|
|
304
|
+
Array(types).flat_map do |type_str|
|
|
305
|
+
# Extract all word tokens that could be type alias names
|
|
306
|
+
type_str.to_s.scan(/\b([a-z_][a-z0-9_]*)\b/i).flatten
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|