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,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Comprehensive loader for RBS::Inline-generated .rbs files.
|
|
6
|
+
#
|
|
7
|
+
# Unlike the simpler RbsLoader, this extracts ALL available information
|
|
8
|
+
# from generated RBS files:
|
|
9
|
+
#
|
|
10
|
+
# - Method signatures with parameter types and return types
|
|
11
|
+
# - Instance variable declarations (@name: Type)
|
|
12
|
+
# - Attribute declarations (attr_reader name: Type)
|
|
13
|
+
# - Type aliases (type name = definition)
|
|
14
|
+
# - Class/module structure with comments
|
|
15
|
+
#
|
|
16
|
+
# The generated RBS files are authoritative - they've been properly parsed
|
|
17
|
+
# by rbs-inline from the source annotations. We just need to read them.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# loader = GeneratedRbsLoader.new(verbose: true, logger: logger)
|
|
21
|
+
# data = loader.load("sig/generated")
|
|
22
|
+
# # => { signatures: {...}, ivars: {...}, attrs: {...}, type_aliases: {...} }
|
|
23
|
+
#
|
|
24
|
+
class GeneratedRbsLoader
|
|
25
|
+
# Result structure from loading.
|
|
26
|
+
Result = Data.define(
|
|
27
|
+
:signatures, # Hash[class_path => Hash[method_name => signature_data]]
|
|
28
|
+
:ivars, # Hash[class_path => Hash[ivar_name => { type:, desc: }]]
|
|
29
|
+
:attrs, # Hash[class_path => Hash[attr_name => { type:, desc: }]]
|
|
30
|
+
:type_aliases, # Hash[namespace => Array[{ name:, definition:, description: }]]
|
|
31
|
+
:overloads # Hash[class_path => Hash[method_name => Array[signature_strings]]]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def initialize(verbose: false, logger: nil)
|
|
35
|
+
@verbose = verbose
|
|
36
|
+
@logger = logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Load all data from generated RBS directory.
|
|
40
|
+
#
|
|
41
|
+
# @param rbs_dir [String] Path to generated RBS directory (e.g., "sig/generated")
|
|
42
|
+
# @return [Result] All extracted data
|
|
43
|
+
def load(rbs_dir)
|
|
44
|
+
signatures = {}
|
|
45
|
+
ivars = {}
|
|
46
|
+
attrs = {}
|
|
47
|
+
type_aliases = {}
|
|
48
|
+
overloads = {}
|
|
49
|
+
|
|
50
|
+
return empty_result unless rbs_dir && Dir.exist?(rbs_dir)
|
|
51
|
+
|
|
52
|
+
rbs_files = Dir.glob(File.join(rbs_dir, "**/*.rbs"))
|
|
53
|
+
rbs_files.each do |file|
|
|
54
|
+
parse_file(file, signatures, ivars, attrs, type_aliases, overloads)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
log_stats(signatures, ivars, attrs, type_aliases) if @verbose
|
|
58
|
+
|
|
59
|
+
Result.new(
|
|
60
|
+
signatures: signatures,
|
|
61
|
+
ivars: ivars,
|
|
62
|
+
attrs: attrs,
|
|
63
|
+
type_aliases: type_aliases,
|
|
64
|
+
overloads: overloads
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def empty_result
|
|
71
|
+
Result.new(
|
|
72
|
+
signatures: {},
|
|
73
|
+
ivars: {},
|
|
74
|
+
attrs: {},
|
|
75
|
+
type_aliases: {},
|
|
76
|
+
overloads: {}
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def log_stats(signatures, ivars, attrs, type_aliases)
|
|
81
|
+
method_count = signatures.values.sum { |m| m.size }
|
|
82
|
+
ivar_count = ivars.values.sum { |i| i.size }
|
|
83
|
+
attr_count = attrs.values.sum { |a| a.size }
|
|
84
|
+
type_count = type_aliases.values.sum { |t| t.size }
|
|
85
|
+
|
|
86
|
+
@logger&.info "Loaded from generated RBS: #{method_count} methods, " \
|
|
87
|
+
"#{ivar_count} ivars, #{attr_count} attrs, #{type_count} type aliases"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_file(file, signatures, ivars, attrs, type_aliases, overloads)
|
|
91
|
+
content = File.read(file)
|
|
92
|
+
lines = content.lines
|
|
93
|
+
|
|
94
|
+
namespace_stack = []
|
|
95
|
+
pending_comment = nil
|
|
96
|
+
pending_method = nil # For collecting method overloads
|
|
97
|
+
|
|
98
|
+
lines.each_with_index do |line, _idx|
|
|
99
|
+
stripped = line.strip
|
|
100
|
+
|
|
101
|
+
# Track module/class context
|
|
102
|
+
if stripped =~ /^(?:class|module)\s+([\w:]+)/
|
|
103
|
+
name = Regexp.last_match(1)
|
|
104
|
+
namespace_stack.push(name)
|
|
105
|
+
pending_comment = nil
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Track end statements
|
|
110
|
+
if stripped == "end"
|
|
111
|
+
namespace_stack.pop if namespace_stack.any?
|
|
112
|
+
pending_comment = nil
|
|
113
|
+
pending_method = nil
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Collect comments (may be description for next declaration)
|
|
118
|
+
if stripped.start_with?("#")
|
|
119
|
+
comment_text = stripped.sub(/^#\s*/, "")
|
|
120
|
+
pending_comment = pending_comment ? "#{pending_comment}\n#{comment_text}" : comment_text
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Skip blank lines but preserve pending_comment
|
|
125
|
+
next if stripped.empty?
|
|
126
|
+
|
|
127
|
+
current_namespace = namespace_stack.join("::")
|
|
128
|
+
next if current_namespace.empty?
|
|
129
|
+
|
|
130
|
+
# Parse instance variable declarations: @name: Type
|
|
131
|
+
if stripped =~ /^@(\w+):\s*(.+)$/
|
|
132
|
+
ivar_name = Regexp.last_match(1)
|
|
133
|
+
ivar_type = Regexp.last_match(2).strip
|
|
134
|
+
|
|
135
|
+
ivars[current_namespace] ||= {}
|
|
136
|
+
ivars[current_namespace][ivar_name] = {
|
|
137
|
+
type: ivar_type,
|
|
138
|
+
desc: extract_first_line_desc(pending_comment)
|
|
139
|
+
}
|
|
140
|
+
pending_comment = nil
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse attr_reader/attr_accessor: attr_reader name: Type
|
|
145
|
+
if stripped =~ /^attr_(?:reader|accessor|writer)\s+(\w+):\s*(.+)$/
|
|
146
|
+
attr_name = Regexp.last_match(1)
|
|
147
|
+
attr_type = Regexp.last_match(2).strip
|
|
148
|
+
|
|
149
|
+
attrs[current_namespace] ||= {}
|
|
150
|
+
attrs[current_namespace][attr_name] = {
|
|
151
|
+
type: attr_type,
|
|
152
|
+
desc: extract_first_line_desc(pending_comment)
|
|
153
|
+
}
|
|
154
|
+
pending_comment = nil
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Parse type aliases: type name = definition
|
|
159
|
+
if stripped =~ /^type\s+(\w+)\s*=\s*(.+)$/
|
|
160
|
+
type_name = Regexp.last_match(1)
|
|
161
|
+
type_def = Regexp.last_match(2)
|
|
162
|
+
|
|
163
|
+
type_aliases[current_namespace] ||= []
|
|
164
|
+
type_aliases[current_namespace] << {
|
|
165
|
+
name: type_name,
|
|
166
|
+
definition: type_def,
|
|
167
|
+
description: pending_comment
|
|
168
|
+
}
|
|
169
|
+
pending_comment = nil
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Parse method signatures: def method_name: signature
|
|
174
|
+
# Also handles: def self.method_name: signature
|
|
175
|
+
if stripped =~ /^def\s+(?:self\.)?(\w+[?!=]?|\[\]=?):\s*(.+)$/
|
|
176
|
+
method_name = Regexp.last_match(1)
|
|
177
|
+
full_sig = Regexp.last_match(2).strip
|
|
178
|
+
|
|
179
|
+
signatures[current_namespace] ||= {}
|
|
180
|
+
|
|
181
|
+
# Check if this is a continuation (overload) of previous method
|
|
182
|
+
if pending_method == method_name
|
|
183
|
+
overloads[current_namespace] ||= {}
|
|
184
|
+
overloads[current_namespace][method_name] ||= []
|
|
185
|
+
overloads[current_namespace][method_name] << full_sig
|
|
186
|
+
else
|
|
187
|
+
# New method - parse signature and store
|
|
188
|
+
signatures[current_namespace][method_name] = parse_signature(full_sig, pending_comment)
|
|
189
|
+
pending_method = method_name
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
pending_comment = nil
|
|
193
|
+
next
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check for method overload continuation (line starting with |)
|
|
197
|
+
if stripped.start_with?("|") && pending_method
|
|
198
|
+
overload_sig = stripped.sub(/^\|\s*/, "").strip
|
|
199
|
+
|
|
200
|
+
overloads[current_namespace] ||= {}
|
|
201
|
+
overloads[current_namespace][pending_method] ||= []
|
|
202
|
+
overloads[current_namespace][pending_method] << overload_sig
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Any other non-blank line resets pending state
|
|
207
|
+
pending_comment = nil
|
|
208
|
+
pending_method = nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def extract_first_line_desc(comment)
|
|
213
|
+
return nil if comment.nil? || comment.empty?
|
|
214
|
+
|
|
215
|
+
# Get first line, skip @rbs annotations
|
|
216
|
+
lines = comment.lines.map(&:strip)
|
|
217
|
+
lines.reject { |l| l.start_with?("@rbs") }.first
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Parse RBS signature into structured data.
|
|
221
|
+
#
|
|
222
|
+
# Handles formats like:
|
|
223
|
+
# () -> void
|
|
224
|
+
# (String name, ?Integer age) -> User
|
|
225
|
+
# [T] (T item) -> Array[T]
|
|
226
|
+
#
|
|
227
|
+
# @param sig [String] Full RBS signature
|
|
228
|
+
# @param comment [String, nil] Preceding comment (may contain @rbs descriptions)
|
|
229
|
+
# @return [Hash] Structured signature data
|
|
230
|
+
def parse_signature(sig, comment = nil)
|
|
231
|
+
result = {
|
|
232
|
+
full: sig,
|
|
233
|
+
params: {},
|
|
234
|
+
returns: nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Extract descriptions from comment's @rbs annotations
|
|
238
|
+
param_descs = {}
|
|
239
|
+
return_desc = nil
|
|
240
|
+
raises_type = nil
|
|
241
|
+
|
|
242
|
+
if comment
|
|
243
|
+
comment.lines.each do |line|
|
|
244
|
+
line = line.strip
|
|
245
|
+
|
|
246
|
+
# @rbs param_name: Type -- description
|
|
247
|
+
if line =~ /@rbs\s+(\w+):\s*\S+\s+--\s+(.+)$/
|
|
248
|
+
param_descs[Regexp.last_match(1)] = capitalize_first(Regexp.last_match(2))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @rbs return: Type -- description
|
|
252
|
+
return_desc = capitalize_first(Regexp.last_match(1)) if line =~ /@rbs\s+return:\s*\S+\s+--\s+(.+)$/
|
|
253
|
+
|
|
254
|
+
# @rbs raises: Type
|
|
255
|
+
raises_type = Regexp.last_match(1).strip if line =~ /@rbs\s+raises:\s*(.+)$/
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Parse the signature itself
|
|
260
|
+
# Handle type parameters: [T] (...)
|
|
261
|
+
sig_without_type_params = sig.sub(/^\[[^\]]+\]\s*/, "")
|
|
262
|
+
|
|
263
|
+
if sig_without_type_params =~ /\A\(([^)]*)\)\s*->\s*(.+)\z/
|
|
264
|
+
params_str = Regexp.last_match(1)
|
|
265
|
+
result[:returns] = { type: Regexp.last_match(2).strip, desc: return_desc }
|
|
266
|
+
parse_params(params_str, result[:params], param_descs)
|
|
267
|
+
elsif sig_without_type_params =~ /\A\(\)\s*->\s*(.+)\z/
|
|
268
|
+
result[:returns] = { type: Regexp.last_match(1).strip, desc: return_desc }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
result[:raises] = raises_type if raises_type
|
|
272
|
+
result
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def parse_params(params_str, result, descriptions)
|
|
276
|
+
return if params_str.strip.empty?
|
|
277
|
+
|
|
278
|
+
params = split_respecting_brackets(params_str)
|
|
279
|
+
|
|
280
|
+
params.each do |param|
|
|
281
|
+
param = param.strip
|
|
282
|
+
next if param.empty?
|
|
283
|
+
|
|
284
|
+
parse_single_param(param, result, descriptions)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def parse_single_param(param, result, descriptions)
|
|
289
|
+
# Keyword arg: `?name: Type` or `name: Type`
|
|
290
|
+
if param =~ /\A\??(\w+):\s*(.+)\z/
|
|
291
|
+
name = Regexp.last_match(1)
|
|
292
|
+
type = Regexp.last_match(2).strip
|
|
293
|
+
result[name] = { type: type, desc: descriptions[name] }
|
|
294
|
+
# Block param: `?{ (Type) -> Type }` or `^(Type) -> Type`
|
|
295
|
+
elsif param =~ /\A\??[{^]/
|
|
296
|
+
# Store as special block param
|
|
297
|
+
result["&block"] = { type: param, desc: descriptions["block"] }
|
|
298
|
+
# Positional: `?Type name` or `Type name`
|
|
299
|
+
elsif param =~ /\A\??(.+?)\s+(\w+)\z/
|
|
300
|
+
type = Regexp.last_match(1).strip
|
|
301
|
+
name = Regexp.last_match(2)
|
|
302
|
+
result[name] = { type: type, desc: descriptions[name] }
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def capitalize_first(str)
|
|
307
|
+
return nil if str.nil? || str.strip.empty?
|
|
308
|
+
|
|
309
|
+
s = str.strip
|
|
310
|
+
s[0].upcase + s[1..]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Split string by commas while respecting nested brackets [], {}, ().
|
|
314
|
+
def split_respecting_brackets(str)
|
|
315
|
+
result = []
|
|
316
|
+
current = +""
|
|
317
|
+
depth = 0
|
|
318
|
+
|
|
319
|
+
str.each_char do |c|
|
|
320
|
+
case c
|
|
321
|
+
when "[", "{", "("
|
|
322
|
+
depth += 1
|
|
323
|
+
current << c
|
|
324
|
+
when "]", "}", ")"
|
|
325
|
+
depth -= 1
|
|
326
|
+
current << c
|
|
327
|
+
when ","
|
|
328
|
+
if depth.zero?
|
|
329
|
+
result << current
|
|
330
|
+
current = +""
|
|
331
|
+
else
|
|
332
|
+
current << c
|
|
333
|
+
end
|
|
334
|
+
else
|
|
335
|
+
current << c
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
result << current unless current.empty?
|
|
340
|
+
result
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Generates GitHub source links from file paths and line numbers.
|
|
6
|
+
#
|
|
7
|
+
# Parses git remote URL to extract org/repo, then constructs blob URLs
|
|
8
|
+
# with line references for linking documentation back to source.
|
|
9
|
+
class GithubLinker
|
|
10
|
+
# @return [String, nil] GitHub base URL (e.g., "https://github.com/org/repo")
|
|
11
|
+
attr_reader :base_url
|
|
12
|
+
|
|
13
|
+
# @return [String] Git branch for source links
|
|
14
|
+
attr_reader :branch
|
|
15
|
+
|
|
16
|
+
# @param repo [String, nil] Explicit GitHub repo (e.g., "org/repo")
|
|
17
|
+
# @param branch [String] Git branch for links
|
|
18
|
+
# @param root [String] Project root for detecting git remote
|
|
19
|
+
def initialize(repo: nil, branch: "main", root: Dir.pwd)
|
|
20
|
+
@branch = branch
|
|
21
|
+
@base_url = repo ? "https://github.com/#{repo}" : extract_github_base_url(root)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Generate a markdown link to a source location on GitHub.
|
|
25
|
+
#
|
|
26
|
+
# @param path [String] Project-relative file path
|
|
27
|
+
# @param start_line [Integer] Starting line number
|
|
28
|
+
# @param end_line [Integer, nil] Ending line number (optional)
|
|
29
|
+
# @return [String] Markdown link or plain text if no GitHub remote
|
|
30
|
+
def link(path, start_line, end_line = nil)
|
|
31
|
+
text = format_text(path, start_line, end_line)
|
|
32
|
+
return "`#{text}`" unless @base_url
|
|
33
|
+
|
|
34
|
+
url = format_url(path, start_line, end_line)
|
|
35
|
+
"[#{text}](#{url})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Generate just the URL (for frontmatter).
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] Project-relative file path
|
|
41
|
+
# @param start_line [Integer] Starting line number
|
|
42
|
+
# @param end_line [Integer, nil] Ending line number (optional)
|
|
43
|
+
# @return [String, nil] GitHub URL or nil if no GitHub remote
|
|
44
|
+
def url(path, start_line, end_line = nil)
|
|
45
|
+
return nil unless @base_url
|
|
46
|
+
|
|
47
|
+
format_url(path, start_line, end_line)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def format_text(path, start_line, end_line)
|
|
53
|
+
if end_line && end_line != start_line
|
|
54
|
+
"#{path}:#{start_line}-#{end_line}"
|
|
55
|
+
else
|
|
56
|
+
"#{path}:#{start_line}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def format_url(path, start_line, end_line)
|
|
61
|
+
line_ref = if end_line && end_line != start_line
|
|
62
|
+
"L#{start_line}-L#{end_line}"
|
|
63
|
+
else
|
|
64
|
+
"L#{start_line}"
|
|
65
|
+
end
|
|
66
|
+
"#{@base_url}/blob/#{@branch}/#{path}##{line_ref}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Pattern matching both HTTPS and SSH GitHub remote URLs:
|
|
70
|
+
# - https://github.com/org/repo.git
|
|
71
|
+
# - git@github.com:org/repo.git
|
|
72
|
+
GITHUB_REMOTE_PATTERN = %r{
|
|
73
|
+
(?:https://github\.com/|git@github\.com:)
|
|
74
|
+
([^/]+)/([^/]+?)(?:\.git)?$
|
|
75
|
+
}x
|
|
76
|
+
private_constant :GITHUB_REMOTE_PATTERN
|
|
77
|
+
|
|
78
|
+
def extract_github_base_url(root)
|
|
79
|
+
remote_url = `cd #{root} && git remote get-url origin 2>/dev/null`.strip
|
|
80
|
+
return nil if remote_url.empty?
|
|
81
|
+
|
|
82
|
+
match = remote_url.match(GITHUB_REMOTE_PATTERN)
|
|
83
|
+
"https://github.com/#{match[1]}/#{match[2]}" if match
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Extracts RBS type signatures from inline annotations in Ruby source.
|
|
6
|
+
#
|
|
7
|
+
# Supports the rbs-inline format where types are specified in comments:
|
|
8
|
+
#
|
|
9
|
+
# # @rbs param: String -- description
|
|
10
|
+
# # @rbs return: Integer
|
|
11
|
+
# def method(param)
|
|
12
|
+
#
|
|
13
|
+
# This is the preferred way to specify types in source code, as it keeps
|
|
14
|
+
# type information co-located with the code. The RbsLoader handles
|
|
15
|
+
# separate sig/ files as a fallback.
|
|
16
|
+
#
|
|
17
|
+
# @see https://github.com/soutaro/rbs-inline
|
|
18
|
+
class InlineRbsLoader
|
|
19
|
+
def initialize(verbose, logger)
|
|
20
|
+
@verbose = verbose
|
|
21
|
+
@logger = logger
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Extract inline RBS annotations from Ruby source files.
|
|
25
|
+
#
|
|
26
|
+
# @param source_files [Array<String>] Paths to Ruby files
|
|
27
|
+
# @return [Array(Hash, Hash, Hash)] [signatures, rbs_file_namespaces, attr_types]
|
|
28
|
+
# - signatures: class -> method -> signature
|
|
29
|
+
# - rbs_file_namespaces: file -> [namespaces] for files with @rbs content
|
|
30
|
+
# - attr_types: class -> attr_name -> { type:, desc: } (from #: annotations or @rbs! blocks)
|
|
31
|
+
def load(source_files)
|
|
32
|
+
signatures = {}
|
|
33
|
+
@rbs_file_namespaces = {}
|
|
34
|
+
@attr_types = {}
|
|
35
|
+
|
|
36
|
+
source_files.each do |file|
|
|
37
|
+
next unless File.exist?(file)
|
|
38
|
+
|
|
39
|
+
parse_file(file, signatures)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@logger.info "Extracted inline RBS from #{source_files.size} files" if @verbose && source_files.any?
|
|
43
|
+
[signatures, @rbs_file_namespaces, @attr_types]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def parse_file(file, signatures)
|
|
49
|
+
content = File.read(file)
|
|
50
|
+
expanded_file = File.expand_path(file)
|
|
51
|
+
# Stack of [name_parts, indent_level] for tracking nested namespaces
|
|
52
|
+
namespace_stack = []
|
|
53
|
+
pending_rbs = {}
|
|
54
|
+
file_has_rbs = content.include?("@rbs")
|
|
55
|
+
file_namespaces = []
|
|
56
|
+
in_rbs_block = false
|
|
57
|
+
# Track multi-line Struct.new/Data.define: { indent:, name: } waiting for `) do`
|
|
58
|
+
pending_struct = nil
|
|
59
|
+
|
|
60
|
+
content.each_line.with_index do |line, _idx|
|
|
61
|
+
# Track @rbs! block start
|
|
62
|
+
if line =~ /^\s*#\s*@rbs!/
|
|
63
|
+
in_rbs_block = true
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Parse @rbs! block content for attr_accessor/reader/writer types
|
|
68
|
+
if in_rbs_block
|
|
69
|
+
# Track nested class declarations inside @rbs! blocks
|
|
70
|
+
if line =~ /^\s*#\s+class\s+(\w+)/
|
|
71
|
+
Regexp.last_match(1)
|
|
72
|
+
elsif line =~ /^\s*#\s+attr_(?:accessor|reader|writer)\s+(\w+):\s*(.+)$/
|
|
73
|
+
attr_name = Regexp.last_match(1)
|
|
74
|
+
attr_type = Regexp.last_match(2).strip
|
|
75
|
+
base_class = current_namespace(namespace_stack)
|
|
76
|
+
# Use nested class from @rbs! block if present
|
|
77
|
+
full_class = rbs_block_class ? "#{base_class}::#{rbs_block_class}" : base_class
|
|
78
|
+
# Store as { type:, desc: } for consistency (no desc in @rbs! format)
|
|
79
|
+
(@attr_types[full_class] ||= {})[attr_name] = { type: attr_type, desc: nil } unless full_class.empty?
|
|
80
|
+
elsif line !~ /^\s*#/ # Non-comment line ends @rbs! block
|
|
81
|
+
in_rbs_block = false
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse Struct.new/Data.define member annotations: :name, #: Type -- description
|
|
87
|
+
if pending_struct && line =~ /:(\w+),?\s*#:\s*(.+)$/
|
|
88
|
+
attr_name = Regexp.last_match(1)
|
|
89
|
+
type_and_desc = Regexp.last_match(2).strip
|
|
90
|
+
type, desc = type_and_desc.split(/\s+--\s+/, 2)
|
|
91
|
+
pending_struct[:members] ||= {}
|
|
92
|
+
pending_struct[:members][attr_name] = { type: type.strip, desc: capitalize_first(desc) }
|
|
93
|
+
end
|
|
94
|
+
# Track class/module context - push onto namespace stack with indentation
|
|
95
|
+
if line =~ /^(\s*)(?:class|module)\s+([\w:]+)/
|
|
96
|
+
class_indent = Regexp.last_match(1).length
|
|
97
|
+
name = Regexp.last_match(2)
|
|
98
|
+
# Handle inline fully-qualified names like "class Foo::Bar"
|
|
99
|
+
name_parts = name.split("::")
|
|
100
|
+
namespace_stack.push([name_parts, class_indent])
|
|
101
|
+
current_class = current_namespace(namespace_stack)
|
|
102
|
+
signatures[current_class] ||= {}
|
|
103
|
+
# Track namespaces this file contributes to (for @rbs change detection)
|
|
104
|
+
file_namespaces << current_class if file_has_rbs
|
|
105
|
+
pending_rbs = {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Track Data.define/Struct.new blocks as pseudo-classes
|
|
109
|
+
# Single-line: ConstName = Data.define(...) do or ConstName = Struct.new(...) do
|
|
110
|
+
if line =~ /^(\s*)([\w:]+)\s*=\s*(?:Data\.define|Struct\.new)\b.*\bdo\s*(?:#.*)?$/
|
|
111
|
+
block_indent = Regexp.last_match(1).length
|
|
112
|
+
name = Regexp.last_match(2)
|
|
113
|
+
name_parts = name.split("::")
|
|
114
|
+
namespace_stack.push([name_parts, block_indent])
|
|
115
|
+
current_class = current_namespace(namespace_stack)
|
|
116
|
+
signatures[current_class] ||= {}
|
|
117
|
+
pending_rbs = {}
|
|
118
|
+
# Multi-line start: ConstName = Data.define( or ConstName = Struct.new(
|
|
119
|
+
# We'll complete this when we see `) do` later
|
|
120
|
+
elsif line =~ /^(\s*)([\w:]+)\s*=\s*(?:Data\.define|Struct\.new)\s*\(/
|
|
121
|
+
pending_struct = { indent: Regexp.last_match(1).length, name: Regexp.last_match(2) }
|
|
122
|
+
# Multi-line completion: ) do (possibly with keyword args before)
|
|
123
|
+
elsif pending_struct && line =~ /\)\s*do\s*(?:#.*)?$/
|
|
124
|
+
name_parts = pending_struct[:name].split("::")
|
|
125
|
+
namespace_stack.push([name_parts, pending_struct[:indent]])
|
|
126
|
+
current_class = current_namespace(namespace_stack)
|
|
127
|
+
signatures[current_class] ||= {}
|
|
128
|
+
# Apply accumulated member types to @attr_types
|
|
129
|
+
if pending_struct[:members]&.any?
|
|
130
|
+
@attr_types[current_class] ||= {}
|
|
131
|
+
@attr_types[current_class].merge!(pending_struct[:members])
|
|
132
|
+
end
|
|
133
|
+
pending_rbs = {}
|
|
134
|
+
pending_struct = nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Track `end` statements - pop if indentation matches a namespace
|
|
138
|
+
if line =~ /^(\s*)end\s*(?:#.*)?$/
|
|
139
|
+
end_indent = Regexp.last_match(1).length
|
|
140
|
+
# Pop all namespaces at this indentation level
|
|
141
|
+
namespace_stack.pop while namespace_stack.any? && namespace_stack.last[1] == end_indent
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Collect @rbs annotations
|
|
145
|
+
if line =~ /^\s*#\s*@rbs\s+(\w+):\s*(.+)$/
|
|
146
|
+
key = Regexp.last_match(1)
|
|
147
|
+
value = Regexp.last_match(2).strip
|
|
148
|
+
# Handle "-- description" suffix
|
|
149
|
+
type, desc = value.split(" -- ", 2)
|
|
150
|
+
pending_rbs[key] = { type: type.strip, desc: capitalize_first(desc) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# When we hit a method definition, apply pending RBS
|
|
154
|
+
# Matches: def foo, def foo?, def foo!, def self.foo, def [], def []=, def +, etc.
|
|
155
|
+
current_class = current_namespace(namespace_stack)
|
|
156
|
+
if !current_class.empty? && line =~ %r{^\s*def\s+(?:self\.)?(\w+[?!=]?|\[\]=?|[+\-*/%&|^<>=!~]+)}
|
|
157
|
+
method_name = Regexp.last_match(1)
|
|
158
|
+
if pending_rbs.any?
|
|
159
|
+
signatures[current_class][method_name] = build_signature(pending_rbs)
|
|
160
|
+
pending_rbs = {}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Reset pending RBS on blank lines or non-comment lines (but not on def lines)
|
|
165
|
+
is_comment = line.strip.start_with?("#")
|
|
166
|
+
is_def = line =~ /^\s*def\s/
|
|
167
|
+
is_blank = line.strip.empty?
|
|
168
|
+
|
|
169
|
+
pending_rbs = {} if (is_blank || (!is_comment && !is_def)) && !is_def
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Store file -> namespaces mapping for files with @rbs content
|
|
173
|
+
@rbs_file_namespaces[expanded_file] = file_namespaces.uniq if file_namespaces.any?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def current_namespace(stack) = stack.flat_map { |parts, _indent| parts }.join("::")
|
|
177
|
+
|
|
178
|
+
def capitalize_first(str)
|
|
179
|
+
return nil if str.nil? || str.strip.empty?
|
|
180
|
+
|
|
181
|
+
s = str.strip
|
|
182
|
+
s[0].upcase + s[1..]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_signature(rbs_data)
|
|
186
|
+
params = {}
|
|
187
|
+
returns = rbs_data.delete("return")
|
|
188
|
+
|
|
189
|
+
# Each value is now { type: "...", desc: "..." }
|
|
190
|
+
rbs_data.each do |name, data|
|
|
191
|
+
params[name] = data
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build full signature string using just the types
|
|
195
|
+
param_str = params.map { |name, data| "#{data[:type]} #{name}" }.join(", ")
|
|
196
|
+
return_type = returns&.dig(:type) || "void"
|
|
197
|
+
full = "(#{param_str}) -> #{return_type}"
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
full: full,
|
|
201
|
+
params: params, # { name => { type:, desc: } }
|
|
202
|
+
returns: returns # { type:, desc: } or nil
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|