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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Post-processes rendered markdown for consistent formatting.
6
+ #
7
+ # Handles normalization that's easier to do as a final pass rather than
8
+ # trying to get perfect output from templates. May grow into fuller
9
+ # linting/validation over time.
10
+ #
11
+ # Current normalizations:
12
+ # - Collapse multiple consecutive newlines to single newlines
13
+ # - Ensure 2 newlines before horizontal rules (---)
14
+ # - Preserve frontmatter formatting
15
+ class PostProcessor
16
+ # Normalize markdown content.
17
+ #
18
+ # @param content [String] Raw markdown content
19
+ # @return [String] Normalized content
20
+ def self.process(content)
21
+ new.process(content)
22
+ end
23
+
24
+ # @param content [String] Raw markdown content
25
+ # @return [String] Normalized content
26
+ def process(content)
27
+ # Split off frontmatter to preserve it exactly
28
+ frontmatter, body = split_frontmatter(content)
29
+
30
+ # Normalize the body
31
+ normalized = normalize_newlines(body)
32
+
33
+ # Reassemble
34
+ frontmatter ? "#{frontmatter}\n\n#{normalized}" : normalized
35
+ end
36
+
37
+ private
38
+
39
+ # Split YAML frontmatter from body content.
40
+ #
41
+ # @param content [String] Full markdown content
42
+ # @return [Array(String, String), Array(nil, String)] [frontmatter, body] or [nil, content]
43
+ def split_frontmatter(content)
44
+ return [nil, content] unless content.start_with?("---")
45
+
46
+ # Find closing ---
47
+ lines = content.lines
48
+ closing_idx = lines[1..].index { |l| l.strip == "---" }
49
+ return [nil, content] unless closing_idx
50
+
51
+ # closing_idx is relative to lines[1..], so actual index is closing_idx + 1
52
+ fm_end = closing_idx + 2 # +1 for 0-index, +1 for the closing line itself
53
+ frontmatter = lines[0...fm_end].join.strip
54
+ body = lines[fm_end..].join
55
+
56
+ [frontmatter, body]
57
+ end
58
+
59
+ # Normalize newlines in body content.
60
+ #
61
+ # Rules (applied in order):
62
+ # 1. Collapse runs of 3+ newlines to exactly 2 (one blank line)
63
+ # 2. Ensure 2 newlines before horizontal rules (---)
64
+ # 3. Remove blank lines after horizontal rules (---)
65
+ # 4. Remove blank lines after headers (# ## ### ####)
66
+ #
67
+ # @param body [String] Body content (no frontmatter)
68
+ # @return [String] Normalized body
69
+ def normalize_newlines(body)
70
+ # Step 1: Collapse 3+ newlines to exactly 2
71
+ result = body.gsub(/\n{3,}/, "\n\n")
72
+
73
+ # Step 2: Ensure 2 newlines before horizontal rules
74
+ result = result.gsub(/\n(---)/, "\n\n\\1")
75
+
76
+ # Step 3: Remove blank lines after horizontal rules
77
+ result = result.gsub(/^(---)\n+/, "\\1\n")
78
+
79
+ # Step 4: Remove blank lines after headers
80
+ result = result.gsub(/^(\#{1,6}\s+.+)\n+/, "\\1\n")
81
+
82
+ result.strip
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Loads RBS type signatures from sig/ directory for documentation enrichment.
6
+ #
7
+ # Parses RBS files and extracts method signatures to merge with YARD documentation,
8
+ # providing accurate type information in generated docs.
9
+ class RbsLoader
10
+ def initialize(rbs_path, verbose, logger)
11
+ @rbs_path = rbs_path
12
+ @verbose = verbose
13
+ @logger = logger
14
+ end
15
+
16
+ # Load all RBS files and return a hash of class -> method -> signature.
17
+ #
18
+ # @return [Hash{String => Hash{String => Hash}}] Nested hash of signatures
19
+ def load
20
+ signatures = {}
21
+ rbs_files = Dir.glob("#{@rbs_path}/**/*.rbs")
22
+
23
+ return signatures if rbs_files.empty?
24
+
25
+ @logger.info "Loading #{rbs_files.size} RBS files..." if @verbose
26
+ rbs_files.each { |file| parse_file(file, signatures) }
27
+ signatures
28
+ end
29
+
30
+ private
31
+
32
+ def parse_file(file, signatures)
33
+ content = File.read(file)
34
+ current_class = nil
35
+
36
+ content.each_line do |line|
37
+ current_class = extract_class_name(line, signatures, current_class)
38
+ next unless current_class
39
+
40
+ extract_method_signature(line, signatures, current_class)
41
+ extract_attr_signature(line, signatures, current_class)
42
+ end
43
+ end
44
+
45
+ def extract_class_name(line, signatures, current_class)
46
+ return current_class unless line =~ /^\s*(?:class|module)\s+([\w:]+)/
47
+
48
+ class_name = Regexp.last_match(1)
49
+ signatures[class_name] ||= {}
50
+ class_name
51
+ end
52
+
53
+ def extract_method_signature(line, signatures, current_class)
54
+ return unless line =~ /^\s*def\s+(?:self\.)?(\w+[?!]?):\s*(.+)$/
55
+
56
+ method_name = Regexp.last_match(1)
57
+ full_sig = Regexp.last_match(2).strip
58
+ signatures[current_class][method_name] = parse_signature(full_sig)
59
+ end
60
+
61
+ def extract_attr_signature(line, signatures, current_class)
62
+ return unless line =~ /^\s*attr_(?:reader|accessor)\s+(\w+):\s*(.+)$/
63
+
64
+ attr_name = Regexp.last_match(1)
65
+ attr_type = Regexp.last_match(2).strip
66
+ signatures[current_class][attr_name] = {
67
+ full: "() -> #{attr_type}",
68
+ params: {},
69
+ returns: attr_type
70
+ }
71
+ end
72
+
73
+ # Parse RBS signature into structured data.
74
+ #
75
+ # @param sig [String] RBS signature string
76
+ # @return [Hash] Parsed signature with :full, :params, :returns keys
77
+ def parse_signature(sig)
78
+ result = { full: sig, params: {}, returns: nil }
79
+
80
+ if sig =~ /\A\(([^)]*)\)\s*->\s*(.+)\z/
81
+ params_str = Regexp.last_match(1)
82
+ result[:returns] = Regexp.last_match(2).strip
83
+ parse_params(params_str, result[:params])
84
+ elsif sig =~ /\A\(\)\s*->\s*(.+)\z/
85
+ result[:returns] = Regexp.last_match(1).strip
86
+ end
87
+
88
+ result
89
+ end
90
+
91
+ # Parse RBS parameter list, handling nested brackets.
92
+ def parse_params(params_str, result)
93
+ return if params_str.strip.empty?
94
+
95
+ params = split_respecting_brackets(params_str)
96
+
97
+ params.each do |param|
98
+ param = param.strip
99
+ next if param.empty?
100
+
101
+ parse_single_param(param, result)
102
+ end
103
+ end
104
+
105
+ def parse_single_param(param, result)
106
+ # Keyword arg: `?name: Type?` or `name: Type`
107
+ if param =~ /\A\??(\w+):\s*(.+)\z/
108
+ name = Regexp.last_match(1)
109
+ type = Regexp.last_match(2).strip.delete_suffix("?")
110
+ result[name] = type
111
+ # Positional: `?Type name` or `Type name`
112
+ elsif param =~ /\A\??(.+?)\s+(\w+)\z/
113
+ type = Regexp.last_match(1).strip
114
+ name = Regexp.last_match(2)
115
+ result[name] = type
116
+ end
117
+ end
118
+
119
+ # Split string by commas while respecting nested brackets [], {}, ().
120
+ def split_respecting_brackets(str)
121
+ result = []
122
+ current = +"" # Mutable string
123
+ depth = 0
124
+
125
+ str.each_char do |c|
126
+ case c
127
+ when "[", "{", "("
128
+ depth += 1
129
+ current << c
130
+ when "]", "}", ")"
131
+ depth -= 1
132
+ current << c
133
+ when ","
134
+ if depth.zero?
135
+ result << current
136
+ current = +"" # Mutable string
137
+ else
138
+ current << c
139
+ end
140
+ else
141
+ current << c
142
+ end
143
+ end
144
+
145
+ result << current unless current.empty?
146
+ result
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Extracts RBS type alias definitions from generated .rbs files.
6
+ #
7
+ # Reads type aliases from RBS files (typically in sig/generated/) which have
8
+ # already been properly parsed by RBS::Inline. This is more reliable than
9
+ # re-parsing @rbs! blocks ourselves.
10
+ #
11
+ # RBS format is straightforward:
12
+ # module Namespace
13
+ # # Description comment
14
+ # type name = definition
15
+ # end
16
+ #
17
+ # @example
18
+ # loader = RbsTypeAliasLoader.new(true, logger, rbs_dir: "sig/generated")
19
+ # type_aliases = loader.load
20
+ # # => { "Archema" => [{ name: "attribute_value", definition: "...", ... }] }
21
+ #
22
+ class RbsTypeAliasLoader
23
+ def initialize(verbose, logger, rbs_dir: nil)
24
+ @verbose = verbose
25
+ @logger = logger
26
+ @rbs_dir = rbs_dir
27
+ end
28
+
29
+ # Extract type aliases from generated RBS files.
30
+ #
31
+ # @return [Hash{String => Array<Hash>}] namespace -> array of type definitions
32
+ def load
33
+ type_aliases = {}
34
+ return type_aliases unless @rbs_dir && Dir.exist?(@rbs_dir)
35
+
36
+ rbs_files = Dir.glob(File.join(@rbs_dir, "**/*.rbs"))
37
+ rbs_files.each do |file|
38
+ parse_rbs_file(file, type_aliases)
39
+ end
40
+
41
+ count = type_aliases.values.flatten.size
42
+ @logger.info "Extracted #{count} type aliases from #{rbs_files.size} RBS files" if @verbose && count.positive?
43
+ type_aliases
44
+ end
45
+
46
+ private
47
+
48
+ # Parse a .rbs file for type aliases.
49
+ #
50
+ # RBS files have a clean, well-defined format:
51
+ # module Foo
52
+ # # comment
53
+ # type name = definition
54
+ # end
55
+ def parse_rbs_file(file, type_aliases)
56
+ content = File.read(file)
57
+ return unless content.include?("type ")
58
+
59
+ expanded_file = File.expand_path(file)
60
+ lines = content.lines
61
+ namespace_stack = []
62
+ pending_comment = nil
63
+
64
+ lines.each_with_index do |line, idx|
65
+ line_num = idx + 1
66
+ stripped = line.strip
67
+
68
+ # Track module/class context (RBS uses same syntax)
69
+ # Reset pending_comment - comments before class/module are for that class, not types inside
70
+ if stripped =~ /^(?:class|module)\s+([\w:]+)/
71
+ name = Regexp.last_match(1)
72
+ namespace_stack.push(name)
73
+ pending_comment = nil
74
+ next
75
+ end
76
+
77
+ # Track end statements
78
+ if stripped == "end"
79
+ namespace_stack.pop if namespace_stack.any?
80
+ next
81
+ end
82
+
83
+ # Collect comments (description for next type)
84
+ if stripped.start_with?("#")
85
+ # Accumulate multi-line comments, preserving newlines
86
+ comment_text = stripped.sub(/^#\s?/, "")
87
+ pending_comment = pending_comment ? "#{pending_comment}\n#{comment_text}" : comment_text
88
+ next
89
+ end
90
+
91
+ # Skip blank lines (preserve pending_comment across blanks)
92
+ next if stripped.empty?
93
+
94
+ # Parse type definition
95
+ if stripped =~ /^type\s+(\w+)\s*=\s*(.+)$/
96
+ type_name = Regexp.last_match(1)
97
+ type_def = Regexp.last_match(2)
98
+
99
+ namespace = namespace_stack.join("::")
100
+ type_aliases[namespace] ||= []
101
+ type_aliases[namespace] << {
102
+ name: type_name,
103
+ definition: type_def,
104
+ description: pending_comment,
105
+ file: expanded_file,
106
+ line: line_num
107
+ }
108
+ end
109
+
110
+ # Reset pending comment after any non-comment, non-blank line
111
+ pending_comment = nil
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end