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