docscribe 1.0.0 → 1.2.0

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +692 -180
  3. data/exe/docscribe +2 -74
  4. data/lib/docscribe/cli/config_builder.rb +62 -0
  5. data/lib/docscribe/cli/init.rb +58 -0
  6. data/lib/docscribe/cli/options.rb +204 -0
  7. data/lib/docscribe/cli/run.rb +415 -0
  8. data/lib/docscribe/cli.rb +31 -0
  9. data/lib/docscribe/config/defaults.rb +71 -0
  10. data/lib/docscribe/config/emit.rb +126 -0
  11. data/lib/docscribe/config/filtering.rb +160 -0
  12. data/lib/docscribe/config/loader.rb +59 -0
  13. data/lib/docscribe/config/rbs.rb +51 -0
  14. data/lib/docscribe/config/sorbet.rb +87 -0
  15. data/lib/docscribe/config/sorting.rb +23 -0
  16. data/lib/docscribe/config/template.rb +176 -0
  17. data/lib/docscribe/config/utils.rb +102 -0
  18. data/lib/docscribe/config.rb +20 -230
  19. data/lib/docscribe/infer/ast_walk.rb +28 -0
  20. data/lib/docscribe/infer/constants.rb +11 -0
  21. data/lib/docscribe/infer/literals.rb +55 -0
  22. data/lib/docscribe/infer/names.rb +43 -0
  23. data/lib/docscribe/infer/params.rb +62 -0
  24. data/lib/docscribe/infer/raises.rb +68 -0
  25. data/lib/docscribe/infer/returns.rb +171 -0
  26. data/lib/docscribe/infer.rb +110 -259
  27. data/lib/docscribe/inline_rewriter/collector.rb +845 -0
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +383 -0
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +605 -0
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +228 -0
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +244 -0
  32. data/lib/docscribe/inline_rewriter.rb +604 -425
  33. data/lib/docscribe/parsing.rb +120 -0
  34. data/lib/docscribe/types/provider_chain.rb +37 -0
  35. data/lib/docscribe/types/rbs/provider.rb +213 -0
  36. data/lib/docscribe/types/rbs/type_formatter.rb +132 -0
  37. data/lib/docscribe/types/signature.rb +65 -0
  38. data/lib/docscribe/types/sorbet/base_provider.rb +217 -0
  39. data/lib/docscribe/types/sorbet/rbi_provider.rb +35 -0
  40. data/lib/docscribe/types/sorbet/source_provider.rb +25 -0
  41. data/lib/docscribe/version.rb +1 -1
  42. data/lib/docscribe.rb +1 -0
  43. metadata +85 -17
  44. data/.rspec +0 -3
  45. data/.rubocop.yml +0 -11
  46. data/.rubocop_todo.yml +0 -73
  47. data/CODE_OF_CONDUCT.md +0 -84
  48. data/Gemfile +0 -6
  49. data/Gemfile.lock +0 -73
  50. data/Rakefile +0 -12
  51. data/rakelib/docs.rake +0 -73
  52. data/stingray_docs_internal.gemspec +0 -41
@@ -1,532 +1,711 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'parser/current'
4
- require 'docscribe/infer'
3
+ require 'ast'
4
+ require 'parser/deprecation'
5
+ require 'parser/source/buffer'
6
+ require 'parser/source/range'
7
+ require 'parser/source/tree_rewriter'
8
+
9
+ require 'docscribe/config'
10
+ require 'docscribe/parsing'
11
+
12
+ require 'docscribe/inline_rewriter/source_helpers'
13
+ require 'docscribe/inline_rewriter/doc_builder'
14
+ require 'docscribe/inline_rewriter/collector'
15
+ require 'docscribe/inline_rewriter/doc_block'
5
16
 
6
17
  module Docscribe
18
+ # Raised when source cannot be parsed before rewriting.
19
+ class ParseError < StandardError; end
20
+
21
+ # Rewrite Ruby source to insert or update inline YARD-style documentation.
22
+ #
23
+ # Supported strategies:
24
+ # - `:safe`
25
+ # - insert missing docs
26
+ # - merge into existing doc-like blocks
27
+ # - normalize configured sortable tags
28
+ # - preserve existing prose and directives where possible
29
+ # - `:aggressive`
30
+ # - replace existing doc blocks with freshly generated docs
31
+ #
32
+ # Compatibility note:
33
+ # - `merge: true` maps to `strategy: :safe`
34
+ # - `rewrite: true` maps to `strategy: :aggressive`
7
35
  module InlineRewriter
8
- # +Docscribe::InlineRewriter.insert_comments+ -> Object
9
- #
10
- # Method documentation.
11
- #
12
- # @param [Object] code Param documentation.
13
- # @param [Boolean] rewrite Param documentation.
14
- # @param [nil] config Param documentation.
15
- # @return [Object]
16
- def self.insert_comments(code, rewrite: false, config: nil)
17
- buffer = Parser::Source::Buffer.new('(inline)')
18
- buffer.source = code
19
- parser = Parser::CurrentRuby.new
20
- ast = parser.parse(buffer)
21
- return code unless ast
22
-
23
- config ||= Docscribe::Config.load
24
-
25
- collector = Collector.new(buffer)
26
- collector.process(ast)
27
-
28
- rewriter = Parser::Source::TreeRewriter.new(buffer)
29
-
30
- collector.insertions
31
- .sort_by { |ins| ins.node.loc.expression.begin_pos }
32
- .reverse_each do |ins|
33
- bol_range = line_start_range(buffer, ins.node)
34
-
35
- if rewrite
36
- # If there is a comment block immediately above, remove it (and its trailing blank lines)
37
- if (range = comment_block_removal_range(buffer, bol_range.begin_pos))
38
- rewriter.remove(range)
36
+ class << self
37
+ # Rewrite source and return only the rewritten output string.
38
+ #
39
+ # This is the main convenience entry point for library usage.
40
+ #
41
+ # @param [String] code Ruby source
42
+ # @param [Symbol, nil] strategy :safe or :aggressive
43
+ # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
44
+ # @param [Boolean, nil] merge compatibility alias for safe strategy
45
+ # @param [Docscribe::Config, nil] config config object (defaults to loaded config)
46
+ # @param [String] file source name used for parser locations/debugging
47
+ # @return [String]
48
+ def insert_comments(code, strategy: nil, rewrite: nil, merge: nil, config: nil, file: '(inline)')
49
+ strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
50
+
51
+ rewrite_with_report(
52
+ code,
53
+ strategy: strategy,
54
+ config: config,
55
+ file: file
56
+ )[:output]
57
+ end
58
+
59
+ # Rewrite source and return both output and structured change information.
60
+ #
61
+ # The result hash includes:
62
+ # - `:output` => rewritten source
63
+ # - `:changes` => structured change records used by CLI explanation output
64
+ #
65
+ # @param [String] code Ruby source
66
+ # @param [Symbol, nil] strategy :safe or :aggressive
67
+ # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
68
+ # @param [Boolean, nil] merge compatibility alias for safe strategy
69
+ # @param [Docscribe::Config, nil] config config object (defaults to loaded config)
70
+ # @param [String] file source name used for parser locations/debugging
71
+ # @raise [Docscribe::ParseError]
72
+ # @return [Hash]
73
+ def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, config: nil, file: '(inline)')
74
+ strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
75
+ validate_strategy!(strategy)
76
+
77
+ buffer = Parser::Source::Buffer.new(file.to_s, source: code)
78
+ ast = Docscribe::Parsing.parse_buffer(buffer)
79
+ raise Docscribe::ParseError, "Failed to parse #{file}" unless ast
80
+
81
+ config ||= Docscribe::Config.load
82
+ signature_provider = build_signature_provider(config, code, file.to_s)
83
+
84
+ collector = Docscribe::InlineRewriter::Collector.new(buffer)
85
+ collector.process(ast)
86
+
87
+ method_insertions = collector.insertions
88
+ attr_insertions = collector.respond_to?(:attr_insertions) ? collector.attr_insertions : []
89
+
90
+ all = method_insertions.map { |i| [:method, i] } + attr_insertions.map { |i| [:attr, i] }
91
+
92
+ rewriter = Parser::Source::TreeRewriter.new(buffer)
93
+ merge_inserts = Hash.new { |h, k| h[k] = [] }
94
+ changes = []
95
+
96
+ all.sort_by { |(_kind, ins)| ins.node.loc.expression.begin_pos }
97
+ .reverse_each do |kind, ins|
98
+ case kind
99
+ when :method
100
+ apply_method_insertion!(
101
+ rewriter: rewriter,
102
+ buffer: buffer,
103
+ insertion: ins,
104
+ config: config,
105
+ signature_provider: signature_provider,
106
+ strategy: strategy,
107
+ changes: changes,
108
+ file: file.to_s
109
+ )
110
+ when :attr
111
+ apply_attr_insertion!(
112
+ rewriter: rewriter,
113
+ buffer: buffer,
114
+ insertion: ins,
115
+ config: config,
116
+ signature_provider: signature_provider,
117
+ strategy: strategy,
118
+ merge_inserts: merge_inserts
119
+ )
39
120
  end
40
- elsif already_has_doc_immediately_above?(buffer, bol_range.begin_pos)
41
- # Skip if a doc already exists immediately above
42
- next
43
121
  end
44
122
 
45
- doc = build_doc_for_node(buffer, ins, config)
46
- next unless doc && !doc.empty?
123
+ apply_merge_inserts!(rewriter: rewriter, buffer: buffer, merge_inserts: merge_inserts)
47
124
 
48
- rewriter.insert_before(bol_range, doc)
125
+ { output: rewriter.process, changes: changes }
49
126
  end
50
127
 
51
- rewriter.process
52
- end
53
-
54
- # +Docscribe::InlineRewriter.line_start_range+ -> Range
55
- #
56
- # Method documentation.
57
- #
58
- # @param [Object] buffer Param documentation.
59
- # @param [Object] node Param documentation.
60
- # @return [Range]
61
- def self.line_start_range(buffer, node)
62
- start_pos = node.loc.expression.begin_pos
63
- src = buffer.source
64
- bol = src.rindex("\n", start_pos - 1) || -1
65
- Parser::Source::Range.new(buffer, bol + 1, bol + 1)
66
- end
128
+ private
67
129
 
68
- # +Docscribe::InlineRewriter.node_name+ -> Object
69
- #
70
- # Method documentation.
71
- #
72
- # @param [Object] node Param documentation.
73
- # @return [Object]
74
- def self.node_name(node)
75
- case node.type
76
- when :def
77
- node.children[0]
78
- when :defs
79
- node.children[1] # method name symbol
130
+ # Normalize strategy inputs, including compatibility booleans.
131
+ #
132
+ # Precedence:
133
+ # - explicit `strategy`
134
+ # - `rewrite: true` => `:aggressive`
135
+ # - `merge: true` => `:safe`
136
+ # - default => `:safe`
137
+ #
138
+ # @private
139
+ # @param [Symbol, nil] strategy
140
+ # @param [Boolean, nil] rewrite
141
+ # @param [Boolean, nil] merge
142
+ # @return [Symbol]
143
+ def normalize_strategy(strategy:, rewrite:, merge:)
144
+ return strategy if strategy
145
+ return :aggressive if rewrite
146
+ return :safe if merge
147
+
148
+ :safe
80
149
  end
81
- end
82
150
 
83
- # +Docscribe::InlineRewriter.comment_block_removal_range+ -> Range
84
- #
85
- # Method documentation.
86
- #
87
- # @param [Object] buffer Param documentation.
88
- # @param [Object] def_bol_pos Param documentation.
89
- # @return [Range]
90
- def self.comment_block_removal_range(buffer, def_bol_pos)
91
- src = buffer.source
92
- lines = src.lines
93
- # Find def line index
94
- def_line_idx = src[0...def_bol_pos].count("\n")
95
- i = def_line_idx - 1
96
-
97
- # Walk up and skip blank lines directly above def
98
- i -= 1 while i >= 0 && lines[i].strip.empty?
99
- # Now if the nearest non-blank line isn't a comment, nothing to remove
100
- return nil unless i >= 0 && lines[i] =~ /^\s*#/
101
-
102
- # Find the start of the contiguous comment block
103
- start_idx = i
104
- start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
105
- start_idx += 1
106
-
107
- # End position is at def_bol_pos; start position is BOL of start_idx
108
- # Compute absolute buffer positions
109
- # Position of BOL for start_idx:
110
- start_pos = 0
111
- if start_idx.positive?
112
- # Sum lengths of all preceding lines
113
- start_pos = lines[0...start_idx].join.length
151
+ # Validate a normalized rewrite strategy.
152
+ #
153
+ # @private
154
+ # @param [Symbol] strategy
155
+ # @raise [ArgumentError]
156
+ # @return [void]
157
+ def validate_strategy!(strategy)
158
+ return if %i[safe aggressive].include?(strategy)
159
+
160
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}"
114
161
  end
115
162
 
116
- Parser::Source::Range.new(buffer, start_pos, def_bol_pos)
117
- end
163
+ # Apply one method insertion according to the selected strategy.
164
+ #
165
+ # Safe strategy:
166
+ # - merge into existing doc-like blocks when present
167
+ # - otherwise insert a full doc block non-destructively
168
+ #
169
+ # Aggressive strategy:
170
+ # - remove the existing doc block (if any)
171
+ # - insert a fresh regenerated block
172
+ #
173
+ # @private
174
+ # @param [Parser::Source::TreeRewriter] rewriter
175
+ # @param [Parser::Source::Buffer] buffer
176
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
177
+ # @param [Docscribe::Config] config
178
+ # @param [Object, nil] signature_provider
179
+ # @param [Symbol] strategy
180
+ # @param [Array<Hash>] changes structured change records
181
+ # @param [String] file
182
+ # @return [void]
183
+ def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, changes:,
184
+ file:)
185
+ name = SourceHelpers.node_name(insertion.node)
186
+
187
+ return unless config.process_method?(
188
+ container: insertion.container,
189
+ scope: insertion.scope,
190
+ visibility: insertion.visibility,
191
+ name: name
192
+ )
193
+
194
+ anchor_bol_range, = method_bol_ranges(buffer, insertion)
195
+
196
+ case strategy
197
+ when :aggressive
198
+ if (range = method_comment_block_removal_range(buffer, insertion))
199
+ rewriter.remove(range)
200
+ end
118
201
 
119
- # +Docscribe::InlineRewriter.already_has_doc_immediately_above?+ -> Object
120
- #
121
- # Method documentation.
122
- #
123
- # @param [Object] buffer Param documentation.
124
- # @param [Object] insert_pos Param documentation.
125
- # @return [Object]
126
- def self.already_has_doc_immediately_above?(buffer, insert_pos)
127
- src = buffer.source
128
- lines = src.lines
129
- current_line_index = src[0...insert_pos].count("\n")
130
- i = current_line_index - 1
131
- i -= 1 while i >= 0 && lines[i].strip.empty?
132
- return false if i.negative?
133
-
134
- !!(lines[i] =~ /^\s*#/)
135
- end
202
+ doc = build_method_doc(insertion, config: config, signature_provider: signature_provider)
203
+ return if doc.nil? || doc.empty?
204
+
205
+ rewriter.insert_before(anchor_bol_range, doc)
206
+
207
+ add_change(
208
+ changes,
209
+ type: :insert_full_doc_block,
210
+ insertion: insertion,
211
+ file: file,
212
+ message: 'missing docs'
213
+ )
214
+
215
+ when :safe
216
+ info = method_doc_comment_info(buffer, insertion)
217
+
218
+ if info
219
+ merge_result = build_missing_method_merge_result(
220
+ insertion,
221
+ existing_lines: info[:doc_lines],
222
+ config: config,
223
+ signature_provider: signature_provider
224
+ )
225
+
226
+ missing_lines = merge_result[:lines]
227
+ reason_specs = merge_result[:reasons] || []
228
+
229
+ sorted_existing_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
230
+ info[:doc_lines],
231
+ missing_lines: [],
232
+ sort_tags: config.sort_tags?,
233
+ tag_order: config.tag_order
234
+ )
235
+
236
+ merged_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
237
+ info[:doc_lines],
238
+ missing_lines: missing_lines,
239
+ sort_tags: config.sort_tags?,
240
+ tag_order: config.tag_order
241
+ )
242
+
243
+ existing_order_changed = sorted_existing_doc_lines != info[:doc_lines]
244
+ new_block = (info[:preserved_lines] + merged_doc_lines).join
245
+ old_block = info[:lines].join
246
+
247
+ if new_block != old_block
248
+ range = Parser::Source::Range.new(buffer, info[:start_pos], info[:end_pos])
249
+ rewriter.replace(range, new_block)
250
+
251
+ reason_specs.each do |reason|
252
+ add_change(
253
+ changes,
254
+ type: reason[:type],
255
+ insertion: insertion,
256
+ file: file,
257
+ message: reason[:message],
258
+ extra: reason[:extra] || {}
259
+ )
260
+ end
261
+
262
+ if existing_order_changed
263
+ add_change(
264
+ changes,
265
+ type: :unsorted_tags,
266
+ insertion: insertion,
267
+ file: file,
268
+ message: 'unsorted tags'
269
+ )
270
+ end
271
+ end
272
+
273
+ return
274
+ end
275
+
276
+ doc = build_method_doc(insertion, config: config, signature_provider: signature_provider)
277
+ return if doc.nil? || doc.empty?
136
278
 
137
- # +Docscribe::InlineRewriter.build_doc_for_node+ -> Object
138
- #
139
- # Method documentation.
140
- #
141
- # @param [Object] _buffer Param documentation.
142
- # @param [Object] insertion Param documentation.
143
- # @param [Object] config Param documentation.
144
- # @raise [StandardError]
145
- # @return [Object]
146
- # @return [nil] if StandardError
147
- def self.build_doc_for_node(_buffer, insertion, config)
148
- node = insertion.node
149
- indent = ' ' * node.loc.expression.column
150
-
151
- name =
152
- case node.type
153
- when :def then node.children[0]
154
- when :defs then node.children[1]
279
+ rewriter.insert_before(anchor_bol_range, doc)
280
+
281
+ add_change(
282
+ changes,
283
+ type: :insert_full_doc_block,
284
+ insertion: insertion,
285
+ file: file,
286
+ message: 'missing docs'
287
+ )
155
288
  end
289
+ end
290
+
291
+ # Append a structured change record.
292
+ #
293
+ # @private
294
+ # @param [Array<Hash>] changes
295
+ # @param [Symbol] type
296
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
297
+ # @param [String] file
298
+ # @param [String] message
299
+ # @param [Integer, nil] line
300
+ # @param [Hash] extra
301
+ # @return [void]
302
+ def add_change(changes, type:, insertion:, file:, message:, line: nil, extra: {})
303
+ changes << {
304
+ type: type,
305
+ file: file,
306
+ line: line || method_line_for(insertion),
307
+ method: method_id_for(insertion),
308
+ message: message
309
+ }.merge(extra)
310
+ end
311
+
312
+ # Build a printable method identifier from a collected insertion.
313
+ #
314
+ # @private
315
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
316
+ # @return [String]
317
+ def method_id_for(insertion)
318
+ name = SourceHelpers.node_name(insertion.node)
319
+ "#{insertion.container}#{insertion.scope == :instance ? '#' : '.'}#{name}"
320
+ end
321
+
322
+ # Apply one attribute insertion according to the selected strategy.
323
+ #
324
+ # @private
325
+ # @param [Parser::Source::TreeRewriter] rewriter
326
+ # @param [Parser::Source::Buffer] buffer
327
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] insertion
328
+ # @param [Docscribe::Config] config
329
+ # @param [Object, nil] signature_provider
330
+ # @param [Symbol] strategy
331
+ # @param [Hash] merge_inserts
332
+ # @return [void]
333
+ def apply_attr_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, merge_inserts:)
334
+ return unless config.respond_to?(:emit_attributes?) && config.emit_attributes?
335
+ return unless attribute_allowed?(config, insertion)
336
+
337
+ bol_range = SourceHelpers.line_start_range(buffer, insertion.node)
338
+
339
+ case strategy
340
+ when :aggressive
341
+ if (range = SourceHelpers.comment_block_removal_range(buffer, bol_range.begin_pos))
342
+ rewriter.remove(range)
343
+ end
156
344
 
157
- scope = insertion.scope
158
- visibility = insertion.visibility
345
+ doc = build_attr_doc_for_node(
346
+ insertion,
347
+ config: config,
348
+ signature_provider: signature_provider
349
+ )
350
+ return if doc.nil? || doc.empty?
159
351
 
160
- method_symbol = scope == :instance ? '#' : '.'
161
- container = insertion.container
352
+ rewriter.insert_before(bol_range, doc)
162
353
 
163
- # Params
164
- params_block = config.emit_param_tags? ? build_params_block(node, indent) : nil
354
+ when :safe
355
+ info = SourceHelpers.doc_comment_block_info(buffer, bol_range.begin_pos)
165
356
 
166
- # Raises (rescue and/or raise calls)
167
- raise_types = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(node) : []
357
+ if info
358
+ additions = build_attr_merge_additions(
359
+ insertion,
360
+ existing_lines: info[:lines],
361
+ config: config,
362
+ signature_provider: signature_provider
363
+ )
168
364
 
169
- # Returns: normal + conditional rescue returns
170
- spec = Docscribe::Infer.returns_spec_from_node(node)
171
- normal_type = spec[:normal]
172
- rescue_specs = spec[:rescues]
365
+ if additions && !additions.empty?
366
+ merge_inserts[info[:end_pos]] << [insertion.node.loc.expression.begin_pos, additions]
367
+ end
173
368
 
174
- lines = []
175
- if config.emit_header?
176
- lines << "#{indent}# +#{container}#{method_symbol}#{name}+ -> #{normal_type}"
177
- lines << "#{indent}#"
178
- end
369
+ return
370
+ end
179
371
 
180
- # Default doc text (configurable per scope/vis)
181
- lines << "#{indent}# #{config.default_message(scope, visibility)}"
182
- lines << "#{indent}#"
372
+ doc = build_attr_doc_for_node(
373
+ insertion,
374
+ config: config,
375
+ signature_provider: signature_provider
376
+ )
377
+ return if doc.nil? || doc.empty?
183
378
 
184
- if config.emit_visibility_tags?
185
- case visibility
186
- when :private then lines << "#{indent}# @private"
187
- when :protected then lines << "#{indent}# @protected"
379
+ rewriter.insert_before(bol_range, doc)
188
380
  end
189
381
  end
190
382
 
191
- lines.concat(params_block) if params_block
383
+ # Apply aggregated merge inserts at shared end positions.
384
+ #
385
+ # Used primarily for attribute merge behavior where multiple additions may target the same block end.
386
+ #
387
+ # @private
388
+ # @param [Parser::Source::TreeRewriter] rewriter
389
+ # @param [Parser::Source::Buffer] buffer
390
+ # @param [Hash{Integer=>Array<(Integer,String)>}] merge_inserts
391
+ # @return [void]
392
+ def apply_merge_inserts!(rewriter:, buffer:, merge_inserts:)
393
+ sep_re = /^\s*#\s*\r?\n$/
192
394
 
193
- raise_types.each { |rt| lines << "#{indent}# @raise [#{rt}]" } if config.emit_raise_tags?
395
+ merge_inserts.keys.sort.reverse_each do |end_pos|
396
+ chunks = merge_inserts[end_pos]
397
+ next if chunks.empty?
194
398
 
195
- lines << "#{indent}# @return [#{normal_type}]" if config.emit_return_tag?(scope, visibility)
399
+ chunks = chunks.sort_by { |(sort_key, _s)| sort_key }
196
400
 
197
- if config.emit_rescue_conditional_returns?
198
- rescue_specs.each do |(exceptions, rtype)|
199
- ex_display = exceptions.join(', ')
200
- lines << "#{indent}# @return [#{rtype}] if #{ex_display}"
201
- end
202
- end
401
+ out_lines = []
203
402
 
204
- lines.map { |l| "#{l}\n" }.join
205
- rescue StandardError
206
- nil
207
- end
403
+ chunks.each do |(_k, chunk)|
404
+ next if chunk.nil? || chunk.empty?
208
405
 
209
- # +Docscribe::InlineRewriter.build_params_block+ -> Object?
210
- #
211
- # Method documentation.
212
- #
213
- # @param [Object] node Param documentation.
214
- # @param [Object] indent Param documentation.
215
- # @return [Object?]
216
- def self.build_params_block(node, indent)
217
- args =
218
- case node.type
219
- when :def then node.children[1]
220
- when :defs then node.children[2] # FIX: args is children[2], not [3]
221
- end
222
- return nil unless args
223
-
224
- params = []
225
- (args.children || []).each do |a|
226
- case a.type
227
- when :arg
228
- name = a.children.first.to_s
229
- ty = Infer.infer_param_type(name, nil)
230
- params << "#{indent}# @param [#{ty}] #{name} Param documentation."
231
- when :optarg
232
- name, default = *a
233
- ty = Infer.infer_param_type(name.to_s, default&.loc&.expression&.source)
234
- params << "#{indent}# @param [#{ty}] #{name} Param documentation."
235
- when :kwarg
236
- name = "#{a.children.first}:"
237
- ty = Infer.infer_param_type(name, nil)
238
- pname = name.sub(/:$/, '')
239
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
240
- when :kwoptarg
241
- name, default = *a
242
- name = "#{name}:"
243
- ty = Infer.infer_param_type(name, default&.loc&.expression&.source)
244
- pname = name.sub(/:$/, '')
245
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
246
- when :restarg
247
- name = "*#{a.children.first}"
248
- ty = Infer.infer_param_type(name, nil)
249
- pname = a.children.first.to_s
250
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
251
- when :kwrestarg
252
- name = "**#{a.children.first || 'kwargs'}"
253
- ty = Infer.infer_param_type(name, nil)
254
- pname = (a.children.first || 'kwargs').to_s
255
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
256
- when :blockarg
257
- name = "&#{a.children.first}"
258
- ty = Infer.infer_param_type(name, nil)
259
- pname = a.children.first.to_s
260
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
261
- when :forward_arg
262
- # Ruby 3 '...' forwarding; skip
406
+ lines = chunk.lines
407
+ seps = []
408
+ seps << lines.shift while !lines.empty? && lines.first.match?(sep_re)
409
+
410
+ sep = seps.first
411
+ out_lines << sep if sep && (out_lines.empty? || !out_lines.last.match?(sep_re))
412
+ out_lines.concat(lines)
413
+ end
414
+
415
+ text = out_lines.join
416
+ next if text.empty?
417
+
418
+ range = Parser::Source::Range.new(buffer, end_pos, end_pos)
419
+ rewriter.insert_before(range, text)
263
420
  end
264
421
  end
265
- params.empty? ? nil : params
266
- end
267
-
268
- class VisibilityCtx
269
- attr_accessor :default_instance_vis, :default_class_vis, :inside_sclass
270
- attr_reader :explicit_instance, :explicit_class
271
422
 
272
- # +Docscribe::InlineRewriter::VisibilityCtx#initialize+ -> Object
423
+ # Build plain-text merge additions for an attribute doc block.
273
424
  #
274
- # Method documentation.
275
- #
276
- # @return [Object]
277
- def initialize
278
- @default_instance_vis = :public
279
- @default_class_vis = :public
280
- @explicit_instance = {} # { name_sym => :private|:protected|:public }
281
- @explicit_class = {} # { name_sym => :private|:protected|:public }
282
- @inside_sclass = false
425
+ # @private
426
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
427
+ # @param [Array<String>] existing_lines
428
+ # @param [Docscribe::Config] config
429
+ # @param [Object] signature_provider Param documentation.
430
+ # @raise [StandardError]
431
+ # @return [String, nil]
432
+ def build_attr_merge_additions(ins, existing_lines:, config:, signature_provider:)
433
+ indent = SourceHelpers.line_indent(ins.node)
434
+ param_tag_style = config.param_tag_style
435
+ existing = existing_attr_names(existing_lines)
436
+ missing = ins.names.reject { |name_sym| existing[name_sym.to_s] }
437
+ return '' if missing.empty?
438
+
439
+ lines = []
440
+ lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
441
+
442
+ missing.each_with_index do |name_sym, idx|
443
+ attr_name = name_sym.to_s
444
+ mode = ins.access.to_s
445
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
446
+
447
+ lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
448
+
449
+ if config.emit_visibility_tags?
450
+ lines << "#{indent}# @private" if ins.visibility == :private
451
+ lines << "#{indent}# @protected" if ins.visibility == :protected
452
+ end
453
+
454
+ lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
455
+ if %i[w rw].include?(ins.access)
456
+ lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
457
+ end
458
+ lines << "#{indent}#" if idx < missing.length - 1
459
+ end
460
+
461
+ lines.map { |l| "#{l}\n" }.join
462
+ rescue StandardError
463
+ nil
283
464
  end
284
465
 
285
- # +Docscribe::InlineRewriter::VisibilityCtx#dup+ -> Object
466
+ # Extract already documented attribute names from existing `@!attribute` lines.
286
467
  #
287
- # Method documentation.
288
- #
289
- # @return [Object]
290
- def dup
291
- c = VisibilityCtx.new
292
- c.default_instance_vis = default_instance_vis
293
- c.default_class_vis = default_class_vis
294
- c.inside_sclass = inside_sclass
295
- c.explicit_instance.merge!(explicit_instance)
296
- c.explicit_class.merge!(explicit_class)
297
- c
468
+ # @private
469
+ # @param [Array<String>] lines
470
+ # @return [Hash{String=>Boolean}]
471
+ def existing_attr_names(lines)
472
+ names = {}
473
+
474
+ Array(lines).each do |line|
475
+ if (m = line.match(/^\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+(\S+)/))
476
+ names[m[1]] = true
477
+ end
478
+ end
479
+
480
+ names
298
481
  end
299
- end
300
482
 
301
- # Walks nodes and records where to insert docstrings
302
- class Collector < Parser::AST::Processor
303
- Insertion = Struct.new(:node, :scope, :visibility, :container)
483
+ # Decide whether an attribute macro should be emitted according to method filters.
484
+ #
485
+ # @private
486
+ # @param [Docscribe::Config] config
487
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
488
+ # @return [Boolean]
489
+ def attribute_allowed?(config, ins)
490
+ ins.names.any? do |name_sym|
491
+ ok = false
492
+
493
+ if %i[r rw].include?(ins.access)
494
+ ok ||= config.process_method?(
495
+ container: ins.container,
496
+ scope: ins.scope,
497
+ visibility: ins.visibility,
498
+ name: name_sym
499
+ )
500
+ end
304
501
 
305
- attr_reader :insertions
502
+ if %i[w rw].include?(ins.access)
503
+ ok ||= config.process_method?(
504
+ container: ins.container,
505
+ scope: ins.scope,
506
+ visibility: ins.visibility,
507
+ name: :"#{name_sym}="
508
+ )
509
+ end
306
510
 
307
- # +Docscribe::InlineRewriter::Collector#initialize+ -> Object
308
- #
309
- # Method documentation.
310
- #
311
- # @param [Object] buffer Param documentation.
312
- # @return [Object]
313
- def initialize(buffer)
314
- super()
315
- @buffer = buffer
316
- @insertions = []
317
- @name_stack = [] # e.g., ['Demo']
511
+ ok
512
+ end
318
513
  end
319
514
 
320
- # +Docscribe::InlineRewriter::Collector#on_class+ -> Object
515
+ # Build a full `@!attribute` documentation block for one attribute insertion.
321
516
  #
322
- # Method documentation.
323
- #
324
- # @param [Object] node Param documentation.
325
- # @return [Object]
326
- def on_class(node)
327
- cname_node, _super_node, body = *node
328
- @name_stack.push(const_name(cname_node))
329
- ctx = VisibilityCtx.new
330
- process_body(body, ctx)
331
- @name_stack.pop
332
- node
517
+ # @private
518
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
519
+ # @param [Docscribe::Config] config
520
+ # @param [Object] signature_provider Param documentation.
521
+ # @raise [StandardError]
522
+ # @return [String, nil]
523
+ def build_attr_doc_for_node(ins, config:, signature_provider:)
524
+ indent = SourceHelpers.line_indent(ins.node)
525
+ param_tag_style = config.param_tag_style
526
+ lines = []
527
+
528
+ ins.names.each_with_index do |name_sym, idx|
529
+ attr_name = name_sym.to_s
530
+ mode = ins.access.to_s
531
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
532
+
533
+ lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
534
+
535
+ if config.emit_visibility_tags?
536
+ lines << "#{indent}# @private" if ins.visibility == :private
537
+ lines << "#{indent}# @protected" if ins.visibility == :protected
538
+ end
539
+
540
+ lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
541
+ if %i[w rw].include?(ins.access)
542
+ lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
543
+ end
544
+
545
+ lines << "#{indent}#" if idx < ins.names.length - 1
546
+ end
547
+
548
+ lines.map { |l| "#{l}\n" }.join
549
+ rescue StandardError
550
+ nil
333
551
  end
334
552
 
335
- # +Docscribe::InlineRewriter::Collector#on_module+ -> Object
336
- #
337
553
  # Method documentation.
338
554
  #
339
- # @param [Object] node Param documentation.
340
- # @return [Object]
341
- def on_module(node)
342
- cname_node, body = *node
343
- @name_stack.push(const_name(cname_node))
344
- ctx = VisibilityCtx.new
345
- process_body(body, ctx)
346
- @name_stack.pop
347
- node
555
+ # @private
556
+ # @param [Object] indent Param documentation.
557
+ # @param [Object] name Param documentation.
558
+ # @param [Object] type Param documentation.
559
+ # @param [Object] style Param documentation.
560
+ # @return [String]
561
+ def format_attribute_param_tag(indent, name, type, style:)
562
+ type = type.to_s
563
+
564
+ case style.to_s
565
+ when 'name_type'
566
+ "#{indent}# @param #{name} [#{type}]"
567
+ else
568
+ "#{indent}# @param [#{type}] #{name}"
569
+ end
348
570
  end
349
571
 
350
- # +Docscribe::InlineRewriter::Collector#on_def+ -> Object
572
+ # Determine the attribute type for one attr name.
351
573
  #
352
- # Method documentation.
574
+ # Prefers the RBS reader signature when available; otherwise falls back to the config fallback type.
353
575
  #
354
- # @param [Object] node Param documentation.
355
- # @return [Object]
356
- def on_def(node)
357
- @insertions << Insertion.new(node, :instance, :public, current_container)
358
- node
576
+ # @private
577
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
578
+ # @param [Symbol] name_sym
579
+ # @param [Docscribe::Config] config
580
+ # @param [Object] signature_provider Param documentation.
581
+ # @raise [StandardError]
582
+ # @return [String]
583
+ def attribute_type(ins, name_sym, config, signature_provider:)
584
+ ty = config.fallback_type
585
+ return ty unless signature_provider
586
+
587
+ reader_sig = signature_provider.signature_for(container: ins.container, scope: ins.scope, name: name_sym)
588
+ reader_sig&.return_type || ty
589
+ rescue StandardError
590
+ config.fallback_type
359
591
  end
360
592
 
361
- # +Docscribe::InlineRewriter::Collector#on_defs+ -> Object
362
- #
363
593
  # Method documentation.
364
594
  #
365
- # @param [Object] node Param documentation.
595
+ # @private
596
+ # @param [Object] config Param documentation.
597
+ # @param [Object] code Param documentation.
598
+ # @param [Object] file Param documentation.
599
+ # @raise [StandardError]
366
600
  # @return [Object]
367
- def on_defs(node)
368
- @insertions << Insertion.new(node, :class, :public, current_container)
369
- node
601
+ # @return [Object?] if StandardError
602
+ def build_signature_provider(config, code, file)
603
+ if config.respond_to?(:signature_provider_for)
604
+ config.signature_provider_for(source: code, file: file)
605
+ elsif config.respond_to?(:signature_provider)
606
+ config.signature_provider
607
+ elsif config.respond_to?(:rbs_provider)
608
+ config.rbs_provider
609
+ end
610
+ rescue StandardError
611
+ config.respond_to?(:rbs_provider) ? config.rbs_provider : nil
370
612
  end
371
613
 
372
- private
373
-
374
- # +Docscribe::InlineRewriter::Collector#process_stmt+ -> Object
375
- #
376
614
  # Method documentation.
377
615
  #
378
616
  # @private
379
- # @param [Object] node Param documentation.
380
- # @param [Object] ctx Param documentation.
617
+ # @param [Object] insertion Param documentation.
618
+ # @param [Object] config Param documentation.
619
+ # @param [Object] signature_provider Param documentation.
381
620
  # @return [Object]
382
- def process_stmt(node, ctx)
383
- return unless node
384
-
385
- case node.type
386
- when :def
387
- name, _args, _body = *node
388
- if ctx.inside_sclass
389
- vis = ctx.explicit_class[name] || ctx.default_class_vis
390
- scope = :class
391
- else
392
- vis = ctx.explicit_instance[name] || ctx.default_instance_vis
393
- scope = :instance
394
- end
395
- @insertions << Insertion.new(node, scope, vis, current_container)
396
-
397
- when :defs
398
- _, name, _args, _body = *node
399
- vis = ctx.explicit_class[name] || ctx.default_class_vis
400
- @insertions << Insertion.new(node, :class, vis, current_container)
401
-
402
- when :sclass
403
- recv, body = *node
404
- inner_ctx = ctx.dup
405
- inner_ctx.inside_sclass = self_node?(recv)
406
- inner_ctx.default_class_vis = :public
407
- process_body(body, inner_ctx)
408
-
409
- when :send
410
- process_visibility_send(node, ctx)
411
-
412
- else
413
- process(node)
414
- end
621
+ def build_method_doc(insertion, config:, signature_provider:)
622
+ DocBuilder.build(
623
+ insertion,
624
+ config: config,
625
+ signature_provider: signature_provider
626
+ )
415
627
  end
416
628
 
417
- # +Docscribe::InlineRewriter::Collector#process_visibility_send+ -> Object
418
- #
419
629
  # Method documentation.
420
630
  #
421
631
  # @private
422
- # @param [Object] node Param documentation.
423
- # @param [Object] ctx Param documentation.
632
+ # @param [Object] insertion Param documentation.
633
+ # @param [Object] existing_lines Param documentation.
634
+ # @param [Object] config Param documentation.
635
+ # @param [Object] signature_provider Param documentation.
424
636
  # @return [Object]
425
- def process_visibility_send(node, ctx)
426
- recv, meth, *args = *node
427
- return unless recv.nil? && %i[private protected public].include?(meth)
428
-
429
- if args.empty?
430
- # bare keyword: affects current def-target
431
- if ctx.inside_sclass
432
- ctx.default_class_vis = meth
433
- else
434
- ctx.default_instance_vis = meth
435
- end
436
- else
437
- # explicit list: affects current def-target
438
- args.each do |arg|
439
- sym = extract_name_sym(arg)
440
- next unless sym
441
-
442
- if ctx.inside_sclass
443
- ctx.explicit_class[sym] = meth
444
- else
445
- ctx.explicit_instance[sym] = meth
446
- end
447
-
448
- target = ctx.inside_sclass ? 'class' : 'instance'
449
- if args.empty?
450
- puts "[vis] bare #{meth} -> default_#{target}_vis=#{meth}"
451
- else
452
- puts "[vis] explicit #{meth} -> #{target} names=#{args.map { |a| extract_name_sym(a) }.inspect}"
453
- end
454
- end
455
- end
637
+ def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:)
638
+ DocBuilder.build_missing_merge_result(
639
+ insertion,
640
+ existing_lines: existing_lines,
641
+ config: config,
642
+ signature_provider: signature_provider
643
+ )
456
644
  end
457
645
 
458
- # +Docscribe::InlineRewriter::Collector#extract_name_sym+ -> Object
459
- #
460
646
  # Method documentation.
461
647
  #
462
648
  # @private
463
- # @param [Object] arg Param documentation.
649
+ # @param [Object] buffer Param documentation.
650
+ # @param [Object] insertion Param documentation.
464
651
  # @return [Object]
465
- def extract_name_sym(arg)
466
- case arg.type
467
- when :sym then arg.children.first
468
- when :str then arg.children.first.to_sym
469
- end
652
+ def method_doc_comment_info(buffer, insertion)
653
+ anchor_bol_range, def_bol_range = method_bol_ranges(buffer, insertion)
654
+
655
+ SourceHelpers.doc_comment_block_info(buffer, anchor_bol_range.begin_pos) ||
656
+ SourceHelpers.doc_comment_block_info(buffer, def_bol_range.begin_pos)
470
657
  end
471
658
 
472
- # +Docscribe::InlineRewriter::Collector#self_node?+ -> Object
473
- #
474
659
  # Method documentation.
475
660
  #
476
661
  # @private
477
- # @param [Object] node Param documentation.
662
+ # @param [Object] buffer Param documentation.
663
+ # @param [Object] insertion Param documentation.
478
664
  # @return [Object]
479
- def self_node?(node)
480
- node && node.type == :self
665
+ def method_comment_block_removal_range(buffer, insertion)
666
+ anchor_bol_range, def_bol_range = method_bol_ranges(buffer, insertion)
667
+
668
+ SourceHelpers.comment_block_removal_range(buffer, anchor_bol_range.begin_pos) ||
669
+ SourceHelpers.comment_block_removal_range(buffer, def_bol_range.begin_pos)
481
670
  end
482
671
 
483
- # +Docscribe::InlineRewriter::Collector#current_container+ -> Object
484
- #
485
672
  # Method documentation.
486
673
  #
487
674
  # @private
488
- # @return [Object]
489
- def current_container
490
- @name_stack.empty? ? 'Object' : @name_stack.join('::')
675
+ # @param [Object] buffer Param documentation.
676
+ # @param [Object] insertion Param documentation.
677
+ # @return [Array]
678
+ def method_bol_ranges(buffer, insertion)
679
+ anchor_node = anchor_node_for(insertion)
680
+ [
681
+ SourceHelpers.line_start_range(buffer, anchor_node),
682
+ SourceHelpers.line_start_range(buffer, insertion.node)
683
+ ]
491
684
  end
492
685
 
493
- # +Docscribe::InlineRewriter::Collector#const_name+ -> Object
494
- #
495
686
  # Method documentation.
496
687
  #
497
688
  # @private
498
- # @param [Object] node Param documentation.
689
+ # @param [Object] insertion Param documentation.
690
+ # @raise [StandardError]
499
691
  # @return [Object]
500
- def const_name(node)
501
- return 'Object' unless node
502
-
503
- case node.type
504
- when :const
505
- scope, name = *node
506
- scope_name = scope ? const_name(scope) : nil
507
- [scope_name, name].compact.join('::')
508
- when :cbase
509
- '' # leading ::
510
- else
511
- node.loc.expression.source # fallback
512
- end
692
+ # @return [Object] if StandardError
693
+ def method_line_for(insertion)
694
+ anchor_node_for(insertion).loc.expression.line
695
+ rescue StandardError
696
+ insertion.node.loc.expression.line
513
697
  end
514
698
 
515
- # +Docscribe::InlineRewriter::Collector#process_body+ -> Object
516
- #
517
699
  # Method documentation.
518
700
  #
519
701
  # @private
520
- # @param [Object] body Param documentation.
521
- # @param [Object] ctx Param documentation.
702
+ # @param [Object] insertion Param documentation.
522
703
  # @return [Object]
523
- def process_body(body, ctx)
524
- return unless body
525
-
526
- if body.type == :begin
527
- body.children.each { |child| process_stmt(child, ctx) }
704
+ def anchor_node_for(insertion)
705
+ if insertion.respond_to?(:anchor_node) && insertion.anchor_node
706
+ insertion.anchor_node
528
707
  else
529
- process_stmt(body, ctx)
708
+ insertion.node
530
709
  end
531
710
  end
532
711
  end