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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Chiridion
6
+ class Engine
7
+ # Writes per-file documentation to disk.
8
+ #
9
+ # Output structure mirrors source structure:
10
+ # lib/archema/query.rb -> docs/sys/query.md
11
+ # lib/archema/result.rb -> docs/sys/result.md
12
+ #
13
+ # Handles smart write detection to avoid unnecessary file updates.
14
+ class FileWriter
15
+ def initialize(
16
+ output:,
17
+ logger:, namespace_strip: nil,
18
+ include_specs: false,
19
+ verbose: false,
20
+ root: Dir.pwd,
21
+ github_repo: nil,
22
+ github_branch: "main",
23
+ project_title: "API Documentation",
24
+ index_description: nil,
25
+ inline_source_threshold: 10,
26
+ templates_path: nil
27
+ )
28
+ @output = output
29
+ @namespace_strip = namespace_strip
30
+ @verbose = verbose
31
+ @logger = logger
32
+ @root = root
33
+ @index_description = index_description
34
+
35
+ @renderer = FileRenderer.new(
36
+ namespace_strip: namespace_strip,
37
+ include_specs: include_specs,
38
+ root: root,
39
+ github_repo: github_repo,
40
+ github_branch: github_branch,
41
+ project_title: project_title,
42
+ inline_source_threshold: inline_source_threshold,
43
+ templates_path: templates_path
44
+ )
45
+ end
46
+
47
+ # Write all per-file documentation.
48
+ #
49
+ # @param project [ProjectDoc] Documentation structure from SemanticExtractor
50
+ def write(project)
51
+ FileUtils.mkdir_p(@output)
52
+
53
+ @renderer.register_classes(project)
54
+
55
+ counts = { written: 0, skipped: 0 }
56
+
57
+ # Find root file (e.g., lib/archema.rb for Archema::)
58
+ root_file = find_root_file(project.files)
59
+
60
+ # Write per-file docs
61
+ project.files.each do |file_doc|
62
+ is_root = root_file && file_doc.path == root_file.path
63
+ write_file_doc(file_doc, counts, is_root: is_root)
64
+ end
65
+
66
+ # Always write index.md (root file embeds it via ![[index]])
67
+ write_index(project, counts)
68
+
69
+ @logger.info " #{counts[:written]} files written, #{counts[:skipped]} unchanged"
70
+ end
71
+
72
+ private
73
+
74
+ # Find the root lib file that matches the namespace.
75
+ # e.g., lib/archema.rb for Archema::, lib/chiridion.rb for Chiridion::
76
+ def find_root_file(files)
77
+ return nil unless @namespace_strip
78
+
79
+ # Convert Archema:: to archema.rb
80
+ expected_name = @namespace_strip.delete_suffix("::").split("::").last.downcase
81
+ expected_filename = "#{to_snake_case(expected_name)}.rb"
82
+
83
+ files.find { |f| f.filename == expected_filename }
84
+ end
85
+
86
+ def to_snake_case(str)
87
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
88
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
89
+ .gsub("-", "_")
90
+ .downcase
91
+ end
92
+
93
+ def write_index(project, counts)
94
+ content = @renderer.render_index(project, index_description: @index_description)
95
+ content = PostProcessor.process(content)
96
+ wrote = write_file(File.join(@output, "index.md"), content)
97
+ counts[wrote ? :written : :skipped] += 1
98
+ end
99
+
100
+ def write_file_doc(file_doc, counts, is_root: false)
101
+ path = output_path(file_doc.path)
102
+ content = @renderer.render_file(file_doc, is_root: is_root)
103
+ content = PostProcessor.process(content)
104
+
105
+ FileUtils.mkdir_p(File.dirname(path))
106
+ wrote = write_file(path, content)
107
+
108
+ counts[wrote ? :written : :skipped] += 1
109
+ @logger.info " #{wrote ? 'Wrote' : 'Unchanged'} #{path}" if @verbose
110
+ end
111
+
112
+ def write_file(path, new_content)
113
+ new_content = "#{new_content}\n" unless new_content.end_with?("\n")
114
+ return File.write(path, new_content) || true unless File.exist?(path)
115
+
116
+ old_content = File.read(path)
117
+ return false unless content_changed?(old_content, new_content)
118
+
119
+ File.write(path, new_content)
120
+ true
121
+ end
122
+
123
+ def content_changed?(old, new) = normalize(old) != normalize(new)
124
+
125
+ def normalize(content)
126
+ content
127
+ .gsub(/^generated: .+$/, "generated: TIMESTAMP")
128
+ .gsub(/\n{2,}/, "\n\n")
129
+ .strip
130
+ end
131
+
132
+ # Map source file path to output doc path.
133
+ #
134
+ # lib/archema/query.rb -> docs/sys/query.md
135
+ # lib/archema/result.rb -> docs/sys/result.md
136
+ def output_path(source_path)
137
+ # Strip lib/project_name/ prefix
138
+ path = source_path.sub(%r{\Alib/}, "").sub(/\.rb\z/, "")
139
+
140
+ # Strip namespace prefix if configured (e.g., "archema/" from "archema/query")
141
+ if @namespace_strip
142
+ prefix = @namespace_strip.downcase.gsub("::", "/")
143
+ path = path.sub(%r{\A#{Regexp.escape(prefix)}/?}, "")
144
+ end
145
+
146
+ # Convert to kebab-case
147
+ kebab_path = path.split("/").map { |p| to_kebab_case(p) }.join("/")
148
+
149
+ File.join(@output, "#{kebab_path}.md")
150
+ end
151
+
152
+ def to_kebab_case(str)
153
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
154
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
155
+ .gsub("_", "-")
156
+ .downcase
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Chiridion
6
+ class Engine
7
+ # Builds enhanced YAML frontmatter for documentation files.
8
+ #
9
+ # Generates Obsidian-compatible frontmatter with navigation aids,
10
+ # discovery metadata, and search-friendly fields. Each documentation
11
+ # file gets frontmatter that enables:
12
+ #
13
+ # - **Navigation**: parent links for breadcrumb traversal
14
+ # - **Discovery**: tags for filtering, related links for exploration
15
+ # - **Search**: aliases for finding by short name, description for preview
16
+ class FrontmatterBuilder
17
+ def initialize(class_linker, namespace_strip: nil, project_title: "API Documentation")
18
+ @class_linker = class_linker
19
+ @namespace_strip = namespace_strip
20
+ @project_title = project_title
21
+ @inheritance_children = {} # Maps parent class path -> array of child class paths
22
+ end
23
+
24
+ # Pre-compute inheritance relationships from full structure.
25
+ #
26
+ # Must be called before build() to populate inherited-by fields.
27
+ # Scans all classes to build parent->children mapping.
28
+ #
29
+ # @param structure [Hash] Full documentation structure from Extractor
30
+ def register_inheritance(structure)
31
+ @inheritance_children = {}
32
+ structure[:classes].each do |klass|
33
+ parent = klass[:superclass]
34
+ next unless parent && documentable_class?(parent)
35
+
36
+ @inheritance_children[parent] ||= []
37
+ @inheritance_children[parent] << klass[:path]
38
+ end
39
+ end
40
+
41
+ # Build frontmatter hash for a class or module.
42
+ #
43
+ # @param obj [Hash] Extracted object data from Extractor
44
+ # @return [Hash] Frontmatter fields in render order
45
+ def build(obj)
46
+ {
47
+ generated: Time.now.utc.iso8601,
48
+ title: obj[:path],
49
+ type: obj[:type].to_s, # :class or :module
50
+ source: relative_path(obj[:file]),
51
+ description: extract_description(obj[:docstring]),
52
+ inherits: build_inherits_link(obj[:superclass]),
53
+ parent: build_parent_link(obj[:path]),
54
+ inherited_by: build_inherited_by_links(obj[:path]),
55
+ includes: build_mixin_list(obj[:includes]),
56
+ extends: build_mixin_list(obj[:extends]),
57
+ rbs: obj[:rbs_file] ? relative_path(obj[:rbs_file]) : nil,
58
+ tags: build_tags(obj[:path]),
59
+ aliases: build_aliases(obj[:path]),
60
+ constants: build_constant_list(obj[:constants]),
61
+ methods: build_method_list(obj[:methods], obj[:path]),
62
+ related: build_related(obj)
63
+ }.compact
64
+ end
65
+
66
+ # Build frontmatter for index page.
67
+ #
68
+ # @return [Hash] Minimal frontmatter for index
69
+ def build_index
70
+ {
71
+ generated: Time.now.utc.iso8601,
72
+ title: @project_title,
73
+ tags: %w[index api-reference]
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def documentable_class?(class_path)
80
+ return true unless @namespace_strip
81
+
82
+ class_path.start_with?(@namespace_strip)
83
+ end
84
+
85
+ # Extract first sentence as description.
86
+ def extract_description(docstring)
87
+ return nil if docstring.nil? || docstring.strip.empty?
88
+
89
+ # Take first paragraph (up to blank line)
90
+ first_para = docstring.split(/\n\s*\n/).first&.strip
91
+ return nil if first_para.nil? || first_para.empty?
92
+
93
+ # Take first sentence (period/exclamation/question followed by space or end)
94
+ first_sentence = first_para.match(/^(.+?[.!?])(?:\s|$)/m)&.[](1)
95
+ result = first_sentence || first_para
96
+
97
+ # Strip markdown formatting: **bold**, `code`, [[links]]
98
+ result = result.gsub(/\*\*(.+?)\*\*/, '\1') # bold
99
+ result = result.gsub(/`(.+?)`/, '\1') # inline code
100
+ result = result.gsub(/\[\[.+?\|(.+?)\]\]/, '\1') # wikilinks with alias
101
+ result = result.gsub(/\[\[(.+?)\]\]/, '\1') # wikilinks without alias
102
+
103
+ result.strip
104
+ end
105
+
106
+ # Build wikilink to parent namespace documentation.
107
+ def build_parent_link(path)
108
+ parts = path.split("::")
109
+ # Strip namespace prefix to count depth
110
+ stripped_parts = @namespace_strip ? path.sub(/^#{Regexp.escape(@namespace_strip)}/, "").split("::") : parts
111
+ return nil if stripped_parts.size <= 1 # Top-level has no documentable parent
112
+
113
+ parent_parts = parts[0..-2]
114
+ parent_path = parent_parts.join("::")
115
+
116
+ # Build link path (skip namespace prefix for file path)
117
+ link_parts = if @namespace_strip
118
+ parent_path.sub(/^#{Regexp.escape(@namespace_strip)}/,
119
+ "").split("::")
120
+ else
121
+ parent_parts
122
+ end
123
+ link_path = link_parts.map { |p| to_kebab_case(p) }.join("/")
124
+
125
+ "\"[[#{link_path}|#{parent_path}]]\""
126
+ end
127
+
128
+ # Build superclass reference (linked if internal, plain text if external).
129
+ def build_inherits_link(superclass)
130
+ return nil unless superclass
131
+
132
+ linkify_class(superclass) || superclass
133
+ end
134
+
135
+ # Build wikilinks to classes that inherit from this one.
136
+ def build_inherited_by_links(path)
137
+ children = @inheritance_children[path]
138
+ return nil if children.nil? || children.empty?
139
+
140
+ children.sort.filter_map { |child| linkify_class(child) }
141
+ end
142
+
143
+ # Build list of included/extended modules (short names only).
144
+ def build_mixin_list(mixins)
145
+ return nil if mixins.nil? || mixins.empty?
146
+
147
+ mixins.map { |m| m.split("::").last }
148
+ end
149
+
150
+ # Build list of constant names.
151
+ def build_constant_list(constants)
152
+ return nil if constants.nil? || constants.empty?
153
+
154
+ constants.map { |c| c[:name].to_s }
155
+ end
156
+
157
+ # Build list of method names for frontmatter.
158
+ def build_method_list(methods, class_path)
159
+ return nil if methods.nil? || methods.empty?
160
+
161
+ class_name = class_path.split("::").last
162
+
163
+ class_methods = methods.select { |m| m[:scope] == :class }
164
+ .map { |m| format_method_name("#{class_name}.#{m[:name]}") }
165
+ .sort
166
+
167
+ instance_methods = methods.reject { |m| m[:scope] == :class }
168
+ .map { |m| format_method_name(m[:name].to_s) }
169
+ .sort
170
+
171
+ class_methods + instance_methods
172
+ end
173
+
174
+ # Quote method names with special characters for YAML safety.
175
+ def format_method_name(name) = name.match?(/[\[\]{}:,#&*!|>'"%@`]/) ? "'#{name}'" : name
176
+
177
+ # Generate tags from namespace hierarchy.
178
+ def build_tags(path)
179
+ stripped = @namespace_strip ? path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : path
180
+ parts = stripped.split("::")
181
+ parts.map { |p| to_kebab_case(p) }
182
+ end
183
+
184
+ # Build aliases for search discovery.
185
+ def build_aliases(path)
186
+ parts = path.split("::")
187
+ short_name = parts.last
188
+ short_name == path ? [] : [short_name]
189
+ end
190
+
191
+ # Build related links from see_also, includes, extends, superclass.
192
+ def build_related(obj)
193
+ related = []
194
+
195
+ # Add see_also references
196
+ obj[:see_also]&.each do |see|
197
+ link = linkify_class(see[:name])
198
+ related << link if link
199
+ end
200
+
201
+ # Add mixins
202
+ obj[:includes]&.each do |mixin|
203
+ link = linkify_class(mixin)
204
+ related << link if link
205
+ end
206
+
207
+ obj[:extends]&.each do |mixin|
208
+ link = linkify_class(mixin)
209
+ related << link if link
210
+ end
211
+
212
+ # Add superclass (if not Object/BasicObject)
213
+ if obj[:superclass] && !%w[Object BasicObject].include?(obj[:superclass])
214
+ link = linkify_class(obj[:superclass])
215
+ related << link if link
216
+ end
217
+
218
+ related.empty? ? nil : related.uniq
219
+ end
220
+
221
+ # Convert class name to wikilink if we document it.
222
+ def linkify_class(class_name)
223
+ return nil unless documentable_class?(class_name)
224
+
225
+ stripped = @namespace_strip ? class_name.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_name
226
+ link_path = stripped.split("::")
227
+ .map { |p| to_kebab_case(p) }
228
+ .join("/")
229
+
230
+ "\"[[#{link_path}|#{class_name}]]\""
231
+ end
232
+
233
+ def relative_path(absolute_path)
234
+ return absolute_path unless absolute_path
235
+
236
+ # Remove project root prefix if present
237
+ absolute_path
238
+ end
239
+
240
+ def to_kebab_case(str)
241
+ str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
242
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
243
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
244
+ .downcase
245
+ end
246
+ end
247
+ end
248
+ end