docscribe 1.4.0 → 1.4.2

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +194 -8
  3. data/lib/docscribe/cli/config_builder.rb +125 -35
  4. data/lib/docscribe/cli/generate.rb +288 -117
  5. data/lib/docscribe/cli/init.rb +49 -13
  6. data/lib/docscribe/cli/options.rb +303 -127
  7. data/lib/docscribe/cli/run.rb +391 -135
  8. data/lib/docscribe/cli.rb +23 -5
  9. data/lib/docscribe/config/defaults.rb +11 -11
  10. data/lib/docscribe/config/emit.rb +1 -0
  11. data/lib/docscribe/config/filtering.rb +24 -11
  12. data/lib/docscribe/config/loader.rb +7 -4
  13. data/lib/docscribe/config/plugin.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +31 -22
  15. data/lib/docscribe/config/sorbet.rb +41 -15
  16. data/lib/docscribe/config/sorting.rb +1 -0
  17. data/lib/docscribe/config/template.rb +1 -0
  18. data/lib/docscribe/config/utils.rb +3 -2
  19. data/lib/docscribe/config.rb +1 -0
  20. data/lib/docscribe/infer/constants.rb +15 -0
  21. data/lib/docscribe/infer/literals.rb +43 -25
  22. data/lib/docscribe/infer/names.rb +24 -15
  23. data/lib/docscribe/infer/params.rb +52 -6
  24. data/lib/docscribe/infer/raises.rb +24 -14
  25. data/lib/docscribe/infer/returns.rb +365 -182
  26. data/lib/docscribe/infer.rb +10 -9
  27. data/lib/docscribe/inline_rewriter/collector.rb +766 -375
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +217 -74
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +1495 -583
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +100 -52
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +109 -48
  32. data/lib/docscribe/inline_rewriter.rb +1052 -457
  33. data/lib/docscribe/plugin/base/collector_plugin.rb +2 -3
  34. data/lib/docscribe/plugin/base/tag_plugin.rb +1 -1
  35. data/lib/docscribe/plugin/registry.rb +34 -7
  36. data/lib/docscribe/plugin.rb +48 -17
  37. data/lib/docscribe/types/rbs/collection_loader.rb +0 -1
  38. data/lib/docscribe/types/rbs/provider.rb +75 -26
  39. data/lib/docscribe/types/rbs/type_formatter.rb +127 -59
  40. data/lib/docscribe/types/sorbet/base_provider.rb +31 -12
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +2 -2
@@ -42,112 +42,176 @@ module Docscribe
42
42
  # @param [Symbol, nil] strategy :safe or :aggressive
43
43
  # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
44
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
45
+ # @param [Hash] options additional keyword arguments forwarded to rewrite_with_report
47
46
  # @return [String]
48
- def insert_comments(code, strategy: nil, rewrite: nil, merge: nil, config: nil, file: '(inline)')
47
+ def insert_comments(code, strategy: nil, rewrite: nil, merge: nil, **options)
49
48
  strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
50
49
 
51
- rewrite_with_report(
52
- code,
53
- strategy: strategy,
54
- config: config,
55
- file: file
56
- )[:output]
50
+ rewrite_with_report(code, strategy: strategy, **options)[:output]
57
51
  end
58
52
 
59
53
  # Rewrite source and return both output and structured change information.
60
54
  #
61
- # The result hash includes:
62
- # - `:output` => rewritten source
63
- # - `:changes` => structured change records used by CLI explanation output
64
- #
65
55
  # @param [String] code Ruby source
66
56
  # @param [Symbol, nil] strategy :safe or :aggressive
67
57
  # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
68
58
  # @param [Boolean, nil] merge compatibility alias for safe strategy
69
- # @param [Docscribe::Config] config config object (defaults to loaded config)
70
- # @param [String] file source name used for parser locations/debugging
71
- # @param [nil] core_rbs_provider Param documentation.
59
+ # @param [Hash] **options remaining options (config:, file:, core_rbs_provider:)
60
+ # @param [Hash] options additional keyword arguments forwarded to downstream helpers
72
61
  # @raise [Docscribe::ParseError]
73
62
  # @raise [StandardError]
74
63
  # @return [Hash]
75
- def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, config: Docscribe::Config.new({}),
76
- core_rbs_provider: nil, file: '(inline)')
64
+ def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, **options)
77
65
  strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
78
66
  validate_strategy!(strategy)
67
+ parsed = setup_rewrite_env(code, options)
68
+ pipeline = build_rewrite_pipeline(parsed[:buffer], parsed[:ast])
69
+ dispatch_rewrite_insertions(pipeline, parsed[:buffer],
70
+ config: parsed[:config], signature_provider: parsed[:signature_provider],
71
+ core_rbs_provider: parsed[:core_rbs_provider], strategy: strategy,
72
+ file: parsed[:file])
73
+ { output: pipeline[:rewriter].process, changes: pipeline[:changes] }
74
+ end
79
75
 
80
- buffer = Parser::Source::Buffer.new(file.to_s, source: code)
81
- ast = Docscribe::Parsing.parse_buffer(buffer)
82
- raise Docscribe::ParseError, "Failed to parse #{file}" unless ast
76
+ # Build the insertion pipeline: collector, plugin insertions, dedup, rewriter, merge_inserts, changes.
77
+ #
78
+ # @param [Object] buffer the source buffer being rewritten
79
+ # @param [Object] ast the parsed AST of the source code
80
+ # @return [Hash]
81
+ def build_rewrite_pipeline(buffer, ast)
82
+ all = collect_insertions(buffer, ast)
83
+ method_overrides_by_pos = {} #: Hash[Integer, untyped]
84
+ all = deduplicate_insertions(all, method_overrides_by_pos: method_overrides_by_pos)
85
+ rewriter = Parser::Source::TreeRewriter.new(buffer)
86
+ merge_inserts = Hash.new { |h, k| h[k] = [] } #: Hash[Integer, untyped]
87
+ changes = [] #: Array[untyped]
88
+
89
+ { all: all, method_overrides_by_pos: method_overrides_by_pos, rewriter: rewriter,
90
+ merge_inserts: merge_inserts, changes: changes }
91
+ end
83
92
 
84
- config ||= Docscribe::Config.load
85
- signature_provider = build_signature_provider(config, code, file.to_s)
86
- begin
87
- core_rbs_provider ||= config.core_rbs_provider if config.respond_to?(:core_rbs_provider)
88
- rescue StandardError => e
89
- warn "Docscribe: failed to load core RBS provider: #{e.message}" if ENV['DOCSCRIBE_DEBUG']
90
- core_rbs_provider = nil
93
+ # Dispatch all insertions to the appropriate handler.
94
+ #
95
+ # @param [Object] pipeline the pipeline hash with rewriter, insertions, and tracking state
96
+ # @param [Object] buffer the source buffer being rewritten
97
+ # @param [Hash] options additional kwargs (config, signature_provider, core_rbs_provider, strategy, file)
98
+ # @return [Object]
99
+ def dispatch_rewrite_insertions(pipeline, buffer, **options)
100
+ pipeline[:all].sort_by { |(kind, ins)| plugin_insertion_pos(kind, ins) }
101
+ .reverse_each do |kind, ins|
102
+ case kind
103
+ when :method then dispatch_method_insertion(ins, pipeline, buffer, **options)
104
+ when :attr then dispatch_attr_insertion(ins, pipeline, buffer, **options)
105
+ when :plugin then dispatch_plugin_insertion(ins, pipeline, buffer, **options)
106
+ end
91
107
  end
92
108
 
93
- collector = Docscribe::InlineRewriter::Collector.new(buffer)
94
- collector.process(ast)
109
+ apply_merge_inserts!(rewriter: pipeline[:rewriter], buffer: buffer, merge_inserts: pipeline[:merge_inserts])
110
+ end
95
111
 
96
- # Collect additional insertions from CollectorPlugins
97
- plugin_insertions = Docscribe::Plugin.run_collector_plugins(ast, buffer)
112
+ # Dispatch a method insertion.
113
+ #
114
+ # @private
115
+ # @param [Object] ins
116
+ # @param [Hash] pipeline
117
+ # @param [Object] buffer
118
+ # @param [Hash] options
119
+ # @return [void]
120
+ def dispatch_method_insertion(ins, pipeline, buffer, **options)
121
+ pos = plugin_insertion_pos(:method, ins)
122
+ method_override = pipeline[:method_overrides_by_pos][pos]
123
+
124
+ apply_method_insertion!(
125
+ rewriter: pipeline[:rewriter], buffer: buffer, insertion: ins,
126
+ config: options[:config], signature_provider: options[:signature_provider],
127
+ core_rbs_provider: options[:core_rbs_provider], strategy: options[:strategy],
128
+ changes: pipeline[:changes], file: options[:file],
129
+ method_override: method_override
130
+ )
131
+ end
98
132
 
99
- method_insertions = collector.insertions
100
- attr_insertions = collector.respond_to?(:attr_insertions) ? collector.attr_insertions : []
133
+ # Dispatch an attr insertion.
134
+ #
135
+ # @private
136
+ # @param [Object] ins
137
+ # @param [Hash] pipeline
138
+ # @param [Object] buffer
139
+ # @param [Hash] options
140
+ # @return [void]
141
+ def dispatch_attr_insertion(ins, pipeline, buffer, **options)
142
+ apply_attr_insertion!(
143
+ rewriter: pipeline[:rewriter], buffer: buffer, insertion: ins,
144
+ config: options[:config], signature_provider: options[:signature_provider],
145
+ strategy: options[:strategy], merge_inserts: pipeline[:merge_inserts]
146
+ )
147
+ end
101
148
 
102
- all = method_insertions.map { |i| [:method, i] } +
103
- attr_insertions.map { |i| [:attr, i] } +
104
- plugin_insertions.map { |i| [:plugin, i] }
105
- all = deduplicate_insertions(all)
106
- rewriter = Parser::Source::TreeRewriter.new(buffer)
107
- merge_inserts = Hash.new { |h, k| h[k] = [] }
108
- changes = []
149
+ # Dispatch a plugin insertion.
150
+ #
151
+ # @private
152
+ # @param [Object] ins
153
+ # @param [Hash] pipeline
154
+ # @param [Object] buffer
155
+ # @param [Hash] options
156
+ # @return [void]
157
+ def dispatch_plugin_insertion(ins, pipeline, buffer, **options)
158
+ apply_plugin_insertion!(
159
+ rewriter: pipeline[:rewriter], buffer: buffer, insertion: ins,
160
+ strategy: options[:strategy], config: options[:config]
161
+ )
162
+ end
109
163
 
110
- all.sort_by { |(kind, ins)| plugin_insertion_pos(kind, ins) }
111
- .reverse_each do |kind, ins|
112
- case kind
113
- when :method
114
- apply_method_insertion!(
115
- rewriter: rewriter,
116
- buffer: buffer,
117
- insertion: ins,
118
- config: config,
119
- signature_provider: signature_provider,
120
- core_rbs_provider: core_rbs_provider,
121
- strategy: strategy,
122
- changes: changes,
123
- file: file.to_s
124
- )
125
- when :attr
126
- apply_attr_insertion!(
127
- rewriter: rewriter,
128
- buffer: buffer,
129
- insertion: ins,
130
- config: config,
131
- signature_provider: signature_provider,
132
- strategy: strategy,
133
- merge_inserts: merge_inserts
134
- )
135
- when :plugin
136
- apply_plugin_insertion!(
137
- rewriter: rewriter,
138
- buffer: buffer,
139
- insertion: ins,
140
- strategy: strategy
141
- )
142
- end
143
- end
164
+ private
144
165
 
145
- apply_merge_inserts!(rewriter: rewriter, buffer: buffer, merge_inserts: merge_inserts)
166
+ # Setup the parsing environment for rewrite_with_report.
167
+ # @private
168
+ # @param [Object] code the Ruby source code string to parse and rewrite
169
+ # @param [Object] options hash containing :config, :file, and :core_rbs_provider
170
+ # @raise [Docscribe::ParseError]
171
+ # @return [Hash]
172
+ def setup_rewrite_env(code, options)
173
+ config = options[:config] || Docscribe::Config.load
174
+ file = (options[:file] || '(inline)').to_s
175
+ core_rbs_provider = options[:core_rbs_provider]
176
+ buffer = Parser::Source::Buffer.new(file, source: code)
177
+ ast = Docscribe::Parsing.parse_buffer(buffer)
178
+ raise Docscribe::ParseError, "Failed to parse #{file}" unless ast
146
179
 
147
- { output: rewriter.process, changes: changes }
180
+ { config: config, file: file, buffer: buffer, ast: ast,
181
+ signature_provider: build_signature_provider(config, code, file),
182
+ core_rbs_provider: load_core_rbs_provider(config, core_rbs_provider) }
148
183
  end
149
184
 
150
- private
185
+ # Load core RBS provider from config with safe fallback.
186
+ #
187
+ # @private
188
+ # @param [Object] config the active Docscribe::Config
189
+ # @param [Object] core_rbs_provider optional externally-provided core RBS provider
190
+ # @raise [StandardError]
191
+ # @return [Object]
192
+ # @return [nil] if StandardError
193
+ def load_core_rbs_provider(config, core_rbs_provider)
194
+ core_rbs_provider || (config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil)
195
+ rescue StandardError => e
196
+ warn "Docscribe: failed to load core RBS provider: #{e.message}" if ENV.fetch('DOCSCRIBE_DEBUG', false)
197
+ nil
198
+ end
199
+
200
+ # Collect insertions from collector and plugins into a combined list.
201
+ # @private
202
+ # @param [Object] buffer the source buffer to collect insertions from
203
+ # @param [Object] ast the parsed AST to traverse for collection
204
+ # @return [Object]
205
+ def collect_insertions(buffer, ast)
206
+ collector = Docscribe::InlineRewriter::Collector.new(buffer)
207
+ collector.process(ast)
208
+ plugin_insertions = Docscribe::Plugin.run_collector_plugins(ast, buffer)
209
+ method_insertions = collector.insertions
210
+ attr_insertions = collector.respond_to?(:attr_insertions) ? collector.attr_insertions : [] #: Array[untyped]
211
+ method_insertions.map { |i| [:method, i] } +
212
+ attr_insertions.map { |i| [:attr, i] } +
213
+ plugin_insertions.map { |i| [:plugin, i] }
214
+ end
151
215
 
152
216
  # Deduplicate insertions by source position.
153
217
  #
@@ -161,59 +225,238 @@ module Docscribe
161
225
  #
162
226
  # @private
163
227
  # @param [Array<Array(Symbol,Object)>] insertions tagged insertion list
228
+ # @param [nil] method_overrides_by_pos method-level overrides keyed by insertion position
164
229
  # @return [Array<Array(Symbol,Object)>]
165
- def deduplicate_insertions(insertions)
166
- groups = {}
230
+ def deduplicate_insertions(insertions, method_overrides_by_pos: nil)
231
+ group_by_position(insertions).each_with_object([]) do |(pos, items), result|
232
+ process_dedup_group(pos, items, result, method_overrides_by_pos)
233
+ end
234
+ end
167
235
 
168
- insertions.each do |item|
169
- kind, ins = item
236
+ # Process one group of deduplication at a given position.
237
+ # @private
238
+ # @param [Object] pos the source begin_pos for the group
239
+ # @param [Object] items all [kind, insertion] pairs at this position
240
+ # @param [Object] result the accumulator array for surviving insertions
241
+ # @param [Object] method_overrides_by_pos hash mapping position to method override data
242
+ # @return [Object]
243
+ def process_dedup_group(pos, items, result, method_overrides_by_pos)
244
+ plugin_items = items.select { |pair| pair.first == :plugin }
245
+ return result.concat(items) if plugin_items.empty?
246
+
247
+ method_items = items.select { |pair| pair.first == :method }
248
+ override_items = find_override_items(plugin_items)
249
+ if override_items.any? && method_items.any?
250
+ handle_override_case(result, items, override_items, method_overrides_by_pos, pos)
251
+ else
252
+ result.concat(deduplicate_items(items, plugin_items, pos, method_items))
253
+ end
254
+ end
255
+
256
+ # Group insertions by their source position.
257
+ #
258
+ # @private
259
+ # @param [Array<Array(Symbol,Object)>] insertions
260
+ # @return [Hash{Integer => Array<Array(Symbol,Object)>}]
261
+ def group_by_position(insertions)
262
+ groups = {} #: Hash[Integer, untyped]
263
+ insertions.each do |kind, ins|
170
264
  pos = plugin_insertion_pos(kind, ins)
171
- (groups[pos] ||= []) << item
265
+ (groups[pos] ||= []) << [kind, ins]
266
+ end
267
+ groups
268
+ end
269
+
270
+ # Find plugin items that have a method_override hash.
271
+ #
272
+ # @private
273
+ # @param [Array<Array(Symbol,Object)>] plugin_items
274
+ # @return [Array<Array(Symbol,Object)>]
275
+ def find_override_items(plugin_items)
276
+ plugin_items.select do |_k, ins|
277
+ ins.is_a?(Hash) && ins[:method_override].is_a?(Hash)
172
278
  end
279
+ end
173
280
 
174
- result = []
281
+ # Handle a method_override case: record the winning override and remove override items.
282
+ #
283
+ # @private
284
+ # @param [Array<Array(Symbol,Object)>] result
285
+ # @param [Array<Array(Symbol,Object)>] items
286
+ # @param [Array<Array(Symbol,Object)>] override_items
287
+ # @param [Hash, nil] method_overrides_by_pos
288
+ # @param [Integer] pos
289
+ # @return [Object]
290
+ def handle_override_case(result, items, override_items, method_overrides_by_pos, pos)
291
+ if method_overrides_by_pos
292
+ winning_ins = pick_highest_priority_override_insertion(override_items, pos: pos)
293
+ method_overrides_by_pos[pos] = winning_ins[:method_override] if winning_ins
294
+ end
175
295
 
176
- groups.each do |pos, items|
177
- plugin_items = items.select { |k, _| k == :plugin }
296
+ items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
297
+ result.concat(items)
298
+ end
178
299
 
179
- # No plugins at this position -> keep as-is
180
- if plugin_items.empty?
181
- result.concat(items)
182
- next
183
- end
300
+ # Handle items where no method_override applies (plugin-doc case or fallback).
301
+ #
302
+ # @private
303
+ # @param [Array<Array(Symbol,Object)>] items
304
+ # @param [Array<Array(Symbol,Object)>] plugin_items
305
+ # @param [Integer] pos
306
+ # @param [Object] _method_items method insertion pairs at this position (unused)
307
+ # @return [Array<Array(Symbol,Object)>]
308
+ def deduplicate_items(items, plugin_items, pos, _method_items)
309
+ plugin_doc_items = plugin_items.select { |pair| plugin_doc_item?(pair) }
184
310
 
185
- # Rule 1: plugin overrides method insertion at the same position
186
- items = items.reject { |k, _| k == :method }
311
+ if plugin_doc_items.any?
312
+ deduplicate_plugin_doc_case(items, plugin_doc_items, pos)
313
+ else
314
+ items.reject { |pair| override_or_plugin_method?(pair) }
315
+ end
316
+ end
187
317
 
188
- # Rule 2: keep only highest-priority plugin insertions
189
- max_prio = plugin_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
318
+ # Predicate: insertion pair has a doc key with non-empty content.
319
+ # @private
320
+ # @param [Object] pair the [kind, insertion] tuple to inspect
321
+ # @return [Object]
322
+ def plugin_doc_item?(pair)
323
+ _k, ins = pair
324
+ ins.is_a?(Hash) && ins[:doc] && !ins[:doc].empty?
325
+ end
190
326
 
191
- items = items.reject do |k, ins|
192
- k == :plugin && plugin_insertion_priority(ins) < max_prio
193
- end
327
+ # Deduplicate plugin doc items, keeping only highest-priority entries.
328
+ #
329
+ # @private
330
+ # @param [Array<Array(Symbol,Object)>] items
331
+ # @param [Array<Array(Symbol,Object)>] plugin_doc_items
332
+ # @param [Integer] pos
333
+ # @return [Array<Array(Symbol,Object)>]
334
+ def deduplicate_plugin_doc_case(items, plugin_doc_items, pos)
335
+ items = items.reject { |k, _| k == :method }
336
+ items = items.reject { |pair| override_or_plugin_method?(pair) }
194
337
 
195
- # Warn on ties between different plugins at the winning priority
196
- if Docscribe::Plugin.debug?
197
- kept_plugin_labels =
198
- items
199
- .select { |k, _| k == :plugin }
200
- .map { |_k, ins| plugin_insertion_label(ins) }
201
- .uniq
202
-
203
- if kept_plugin_labels.size > 1
204
- line = plugin_insertion_line(plugin_items.first[1])
205
- loc = +"pos=#{pos}"
206
- loc << " line=#{line}" if line
207
-
208
- warn "Docscribe: CollectorPlugin conflict at #{loc} (priority=#{max_prio}): " \
209
- "#{kept_plugin_labels.join(', ')} — keeping all. Set explicit priority to resolve."
210
- end
211
- end
338
+ max_prio = max_plugin_priority(plugin_doc_items)
339
+ dropped = filter_lower_priority_plugins(items, max_prio)
340
+ items = items.reject { |k, ins| dropped.include?([k, ins]) }
341
+
342
+ warn_plugin_conflict!(dropped, plugin_doc_items, max_prio, pos) if Docscribe::Plugin.debug? && dropped.any?
343
+
344
+ items
345
+ end
212
346
 
213
- result.concat(items)
347
+ # Predicate: insertion pair is a plugin with method_override.
348
+ # @private
349
+ # @param [Object] pair the [kind, insertion] tuple to inspect
350
+ # @return [Object]
351
+ def override_or_plugin_method?(pair)
352
+ k, ins = pair
353
+ k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override)
354
+ end
355
+
356
+ # Find the maximum priority among plugin doc items.
357
+ #
358
+ # @private
359
+ # @param [Array<Array(Symbol,Object)>] plugin_items
360
+ # @return [Integer]
361
+ def max_plugin_priority(plugin_items)
362
+ plugin_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
363
+ end
364
+
365
+ # Filter plugin items that fall below the given priority threshold.
366
+ #
367
+ # @private
368
+ # @param [Array<Array(Symbol,Object)>] items
369
+ # @param [Integer] threshold
370
+ # @return [Array<Array(Symbol,Object)>]
371
+ def filter_lower_priority_plugins(items, threshold)
372
+ items.select do |k, ins|
373
+ k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < threshold
374
+ end
375
+ end
376
+
377
+ # Warn about conflicting collector plugins at a given position.
378
+ #
379
+ # @private
380
+ # @param [Array<Array(Symbol,Object)>] dropped
381
+ # @param [Array<Array(Symbol,Object)>] plugin_items
382
+ # @param [Integer] max_prio
383
+ # @param [Integer] pos
384
+ # @return [Object]
385
+ def warn_plugin_conflict!(dropped, plugin_items, max_prio, pos)
386
+ kept_labels = plugin_items.map { |_k, ins| plugin_insertion_label(ins) }.uniq
387
+ dropped_labels = dropped.map { |_k, ins| plugin_insertion_label(ins) }.uniq
388
+ loc = conflict_location_str(pos, plugin_items)
389
+ warn "Docscribe: CollectorPlugin conflict at #{loc} — " \
390
+ "#{dropped_labels.join(', ')} (pri=#{dropped.map { |_k, ins| plugin_insertion_priority(ins) }.max}) " \
391
+ "dropped in favor of #{kept_labels.join(', ')} (pri=#{max_prio}). " \
392
+ 'Set explicit priority or adjust anchor_node to avoid collision.'
393
+ end
394
+
395
+ # Build a human-readable location string for a conflict warning.
396
+ # @private
397
+ # @param [Object] pos the source position of the conflict
398
+ # @param [Object] plugin_items the plugin insertion pairs involved in the conflict
399
+ # @return [Object]
400
+ def conflict_location_str(pos, plugin_items)
401
+ line = plugin_insertion_line(plugin_items.first[1])
402
+ loc = +"pos=#{pos}"
403
+ loc << " line=#{line}" if line
404
+ loc
405
+ end
406
+
407
+ # @private
408
+ # @param override_items [Array<Array(Symbol, Hash)>] list of [:plugin, insertion_hash]
409
+ # that include :method_override
410
+ # @param pos [Integer] begin_pos (used only for debug output)
411
+ # @return [Hash, nil] winning insertion hash (the one whose override will be applied)
412
+ def pick_highest_priority_override_insertion(override_items, pos:)
413
+ return nil if override_items.empty?
414
+
415
+ max_prio = max_plugin_priority_for(override_items)
416
+ winners = override_items.select { |_k, ins| plugin_insertion_priority(ins) == max_prio }
417
+ winners_sorted = sort_winners_by_order(winners)
418
+
419
+ warn_override_conflict!(winners_sorted, max_prio, pos)
420
+
421
+ winners_sorted.first[1]
422
+ end
423
+
424
+ # Compute max priority among override items.
425
+ # @private
426
+ # @param [Object] override_items plugin insertion pairs containing :method_override
427
+ # @return [Object]
428
+ def max_plugin_priority_for(override_items)
429
+ override_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
430
+ end
431
+
432
+ # Sort override winners deterministically by plugin order.
433
+ # @private
434
+ # @param [Object] winners override insertion pairs tied at the highest priority
435
+ # @return [Object]
436
+ def sort_winners_by_order(winners)
437
+ winners.sort_by do |_k, ins|
438
+ order = ins.is_a?(Hash) ? ins[:__docscribe_plugin_order] : nil
439
+ order || 0
214
440
  end
441
+ end
215
442
 
216
- result
443
+ # Warn about override conflicts in debug mode.
444
+ # @private
445
+ # @param [Object] winners_sorted sorted override winners at max priority
446
+ # @param [Object] max_prio the maximum priority value
447
+ # @param [Object] pos the source position of the conflict
448
+ # @return [Object]
449
+ def warn_override_conflict!(winners_sorted, max_prio, pos)
450
+ return unless Docscribe::Plugin.debug?
451
+
452
+ labels = winners_sorted.map { |_k, ins| plugin_insertion_label(ins) }.uniq
453
+ return unless labels.size > 1
454
+
455
+ line = plugin_insertion_line(winners_sorted.first[1])
456
+ loc = +"pos=#{pos}"
457
+ loc << " line=#{line}" if line
458
+ warn "Docscribe: method_override conflict at #{loc} (priority=#{max_prio}): " \
459
+ "#{labels.join(', ')} — using first by registration order."
217
460
  end
218
461
 
219
462
  # @private
@@ -248,7 +491,9 @@ module Docscribe
248
491
  def plugin_insertion_line(insertion)
249
492
  return nil unless insertion.is_a?(Hash)
250
493
 
251
- insertion[:anchor_node]&.loc&.expression&.line
494
+ anchor_node = insertion[:anchor_node]
495
+ expression = anchor_node&.loc&.expression
496
+ expression&.line
252
497
  rescue StandardError
253
498
  nil
254
499
  end
@@ -279,24 +524,32 @@ module Docscribe
279
524
  # @param [Parser::Source::Buffer] buffer
280
525
  # @param [Hash] insertion { anchor_node:, doc: }
281
526
  # @param [Symbol] strategy
527
+ # @param [Docscribe::Config] config
282
528
  # @return [void]
283
- def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:)
284
- anchor_node = insertion[:anchor_node]
285
- doc = insertion[:doc]
529
+ def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:, config:)
530
+ anchor_node, doc = insertion.values_at(:anchor_node, :doc)
286
531
  return unless anchor_node && doc && !doc.empty?
287
532
 
288
533
  indent = SourceHelpers.line_indent(anchor_node)
289
- doc = normalize_plugin_doc_indent(doc, indent)
534
+ doc = normalize_plugin_doc(doc, indent, config: config, anchor_node: anchor_node)
290
535
  bol_range = SourceHelpers.line_start_range(buffer, anchor_node)
536
+ insert_plugin_doc(rewriter, buffer, bol_range, doc, strategy)
537
+ end
291
538
 
539
+ # Insert plugin doc according to strategy, handling comment removal.
540
+ # @private
541
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
542
+ # @param [Object] buffer the source buffer being rewritten
543
+ # @param [Object] bol_range the beginning-of-line range for the anchor node
544
+ # @param [Object] doc the normalized documentation string to insert
545
+ # @param [Object] strategy :safe or :aggressive rewrite mode
546
+ # @return [Object]
547
+ def insert_plugin_doc(rewriter, buffer, bol_range, doc, strategy)
292
548
  case strategy
293
549
  when :aggressive
294
- # Will remove ANY comments above the method. Plugin will decide what will be changed.
295
- if (range = any_comment_block_removal_range(buffer, bol_range.begin_pos))
296
- rewriter.remove(range)
297
- end
550
+ range = any_comment_block_removal_range(buffer, bol_range.begin_pos)
551
+ rewriter.remove(range) if range
298
552
  rewriter.insert_before(bol_range, doc)
299
-
300
553
  when :safe
301
554
  return if SourceHelpers.already_has_doc_immediately_above?(buffer, bol_range.begin_pos)
302
555
 
@@ -317,31 +570,122 @@ module Docscribe
317
570
  def any_comment_block_removal_range(buffer, bol_pos)
318
571
  src = buffer.source
319
572
  lines = src.lines
320
- def_line_idx = src[0...bol_pos].count("\n")
321
- i = def_line_idx - 1
573
+ i = nearest_comment_line_index(src, lines, bol_pos)
574
+ return nil unless i
322
575
 
323
- # Skip blank lines directly above node
324
- i -= 1 while i >= 0 && lines[i].strip.empty?
576
+ start_idx = comment_block_start_index(lines, i)
577
+
578
+ removable_start_idx = skip_preserved_lines(lines, start_idx, i)
579
+ return nil if removable_start_idx > i
580
+
581
+ start_pos = removable_start_idx.positive? ? (lines[0...removable_start_idx] || []).join.length : 0
582
+ Parser::Source::Range.new(buffer, start_pos, bol_pos)
583
+ end
325
584
 
326
- # Nearest non-blank line must be a comment
585
+ # Find the nearest comment line index above a position.
586
+ # @private
587
+ # @param [Object] src the full source string of the buffer
588
+ # @param [Object] lines array of source code lines
589
+ # @param [Object] bol_pos character position of the beginning of the anchor line
590
+ # @return [Object]
591
+ def nearest_comment_line_index(src, lines, bol_pos)
592
+ def_line_idx = (src[0...bol_pos] || '').count("\n")
593
+ i = def_line_idx - 1
594
+ i -= 1 while i >= 0 && lines[i].strip.empty?
327
595
  return nil unless i >= 0 && lines[i] =~ /^\s*#/
328
596
 
329
- # Walk upward through the entire contiguous comment block
330
- start_idx = i
597
+ i
598
+ end
599
+
600
+ # Walk upward through contiguous comment block to find the start.
601
+ # @private
602
+ # @param [Object] lines array of source code lines
603
+ # @param [Object] i line index of the bottommost comment in the contiguous block
604
+ # @param [Object] def_line_idx the index in lines of the method definition (anchor) line
605
+ # @return [Object]
606
+ def comment_block_start_index(lines, def_line_idx)
607
+ start_idx = def_line_idx
331
608
  start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
332
- start_idx += 1
609
+ start_idx + 1
610
+ end
611
+
612
+ # Skip preserved directive-style lines at the top of the comment block.
613
+ # @private
614
+ # @param [Object] lines array of source code lines
615
+ # @param [Object] start_idx index of the first line of the comment block
616
+ # @param [Object] i current line index while walking upward through the block
617
+ # @param [Object] def_line_idx the index in lines of the method definition (anchor) line
618
+ # @return [Object]
619
+ def skip_preserved_lines(lines, start_idx, def_line_idx)
620
+ idx = start_idx
621
+ idx += 1 while idx <= def_line_idx && SourceHelpers.preserved_comment_line?(lines[idx])
622
+ idx
623
+ end
333
624
 
334
- # Preserve leading directive-style lines (rubocop, magic comments, etc.)
335
- removable_start_idx = start_idx
336
- while removable_start_idx <= i &&
337
- SourceHelpers.preserved_comment_line?(lines[removable_start_idx])
338
- removable_start_idx += 1
625
+ # Normalize a CollectorPlugin-provided doc string before insertion.
626
+ #
627
+ # Responsibilities:
628
+ # - apply indentation based on the anchor node
629
+ # - trim trailing whitespace-only lines
630
+ # - (optionally) prepend the configured default message for `def/defs` anchors
631
+ # when the plugin output contains only tags (no prose)
632
+ #
633
+ # @private
634
+ # @param doc [String] Raw doc string returned by a CollectorPlugin insertion (`:doc`)
635
+ # @param indent [String] Indentation to apply to every doc line
636
+ # @param config [Docscribe::Config] Effective Docscribe config for this run
637
+ # @param anchor_node [Parser::AST::Node, nil] AST node used as insertion anchor
638
+ # @return [String] Normalized doc string ready to be inserted
639
+ def normalize_plugin_doc(doc, indent, config:, anchor_node:)
640
+ doc = normalize_plugin_doc_indent(doc, indent)
641
+ doc = trim_trailing_blank_lines(doc)
642
+
643
+ if anchor_node && %i[def defs].include?(anchor_node.type) && config.include_default_message?
644
+ doc = prepend_default_message_if_no_prose(doc, anchor_node, indent, config)
339
645
  end
340
646
 
341
- return nil if removable_start_idx > i
647
+ doc
648
+ end
342
649
 
343
- start_pos = removable_start_idx.positive? ? lines[0...removable_start_idx].join.length : 0
344
- Parser::Source::Range.new(buffer, start_pos, bol_pos)
650
+ # Trim trailing blank lines from a doc string.
651
+ # @private
652
+ # @param [Object] doc the documentation string to trim
653
+ # @return [Object]
654
+ def trim_trailing_blank_lines(doc)
655
+ lines = doc.lines
656
+ lines.pop while lines.any? && lines.last.strip.empty?
657
+ result = lines.join
658
+ result.end_with?("\n") ? result : "#{result}\n"
659
+ end
660
+
661
+ # Prepend default message if the doc block has only tags.
662
+ # @private
663
+ # @param [Object] doc the plugin-generated documentation string
664
+ # @param [Object] anchor_node the AST node used as the insertion anchor
665
+ # @param [Object] indent whitespace indentation prefix derived from the anchor node
666
+ # @param [Object] config the active Docscribe::Config
667
+ # @return [Object]
668
+ def prepend_default_message_if_no_prose(doc, anchor_node, indent, config)
669
+ return doc if doc_has_prose?(doc)
670
+
671
+ scope = anchor_node.type == :defs ? :class : :instance
672
+ msg = config.default_message(scope, :public)
673
+ "#{indent}# #{msg}\n#{indent}#\n" + doc
674
+ end
675
+
676
+ # Check if a doc block has any prose content.
677
+ # @private
678
+ # @param [Object] doc the documentation string to inspect
679
+ # @return [Boolean]
680
+ def doc_has_prose?(doc)
681
+ doc.lines.any? do |l|
682
+ s = l.strip
683
+ next false if s.empty? || s == '#'
684
+ next false if s.start_with?('# @')
685
+ next false if s.start_with?('# +')
686
+
687
+ true
688
+ end
345
689
  end
346
690
 
347
691
  # Normalize indentation of a plugin-generated doc block.
@@ -405,166 +749,299 @@ module Docscribe
405
749
  # - insert a fresh regenerated block
406
750
  #
407
751
  # @private
408
- # @param [Parser::Source::TreeRewriter] rewriter
409
- # @param [Parser::Source::Buffer] buffer
410
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
411
- # @param [Docscribe::Config] config
412
- # @param [Object, nil] signature_provider
413
- # @param [Symbol] strategy
414
- # @param [Array<Hash>] changes structured change records
415
- # @param [String] file
416
- # @param [Object] core_rbs_provider Param documentation.
752
+ # @param [Hash] options kwargs with insertion, config, rewriter, buffer, strategy, changes, file, doc params
417
753
  # @return [void]
418
- def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, core_rbs_provider:,
419
- strategy:, changes:, file:)
754
+ def apply_method_insertion!(**options)
755
+ insertion = options[:insertion]
756
+ config = options[:config]
757
+ return unless method_insertion_allowed?(insertion, config)
758
+
759
+ anchor_bol_range, = method_bol_ranges(options[:buffer], insertion)
760
+ params = build_method_insertion_params(insertion, config, options[:signature_provider],
761
+ options[:core_rbs_provider], options[:method_override])
762
+ doc = build_method_doc(insertion, **params)
763
+ dispatch_method_insertion_by_strategy!(anchor_bol_range, options, params, doc)
764
+ end
765
+
766
+ # Dispatch method insertion to the aggressive or safe handler.
767
+ # @private
768
+ # @param [Object] anchor_bol_range the beginning-of-line range for the anchor node
769
+ # @param [Object] options the full keyword options hash passed to apply_method_insertion!
770
+ # @param [Object] params precomputed insertion parameters (types, overrides, config)
771
+ # @param [Object] doc the generated documentation block string
772
+ # @return [Object]
773
+ def dispatch_method_insertion_by_strategy!(anchor_bol_range, options, params, doc)
774
+ base = { anchor_bol_range: anchor_bol_range, insertion: options[:insertion],
775
+ rewriter: options[:rewriter], buffer: options[:buffer],
776
+ changes: options[:changes], file: options[:file] }
777
+ case options[:strategy]
778
+ when :aggressive then apply_method_insertion_aggressive!(**base, doc: doc)
779
+ when :safe then apply_method_insertion_safe!(**base, strategy: options[:strategy], **params)
780
+ end
781
+ end
782
+
783
+ # Validate and prepare for method insertion.
784
+ #
785
+ # @private
786
+ # @param [Collector::Insertion] insertion
787
+ # @param [Docscribe::Config] config
788
+ # @return [Boolean] true if insertion should proceed
789
+ def method_insertion_allowed?(insertion, config)
420
790
  name = SourceHelpers.node_name(insertion.node)
791
+ config.process_method?(container: insertion.container, scope: insertion.scope,
792
+ visibility: insertion.visibility || :public, name: name)
793
+ end
421
794
 
422
- return unless config.process_method?(
423
- container: insertion.container,
424
- scope: insertion.scope,
425
- visibility: insertion.visibility,
426
- name: name
427
- )
795
+ # Build all parameters needed for method insertion.
796
+ #
797
+ # @private
798
+ # @param [Collector::Insertion] insertion
799
+ # @param [Docscribe::Config] config
800
+ # @param [Object, nil] signature_provider
801
+ # @param [Object, nil] core_rbs_provider
802
+ # @param [Hash, nil] method_override
803
+ # @return [Hash]
804
+ def build_method_insertion_params(insertion, config, signature_provider, core_rbs_provider, method_override)
805
+ override = extract_method_override!(method_override)
806
+ effective = build_effective_params(insertion, config: config, signature_provider: signature_provider,
807
+ core_rbs_provider: core_rbs_provider, override: override)
808
+ { **effective, config: config, signature_provider: signature_provider,
809
+ core_rbs_provider: core_rbs_provider }
810
+ end
428
811
 
429
- anchor_bol_range, = method_bol_ranges(buffer, insertion)
812
+ # Build effective parameters merging external signatures and overrides.
813
+ #
814
+ # @private
815
+ # @param [Collector::Insertion] insertion
816
+ # @param [Hash] options keyword options
817
+ # @return [Hash{Symbol => Object}]
818
+ def build_effective_params(insertion, **options)
819
+ external_sig = resolve_external_signature(insertion, options[:signature_provider])
820
+ param_types = resolve_param_types(insertion, external_sig, options[:config])
821
+ override = options[:override]
822
+
823
+ param_types = (param_types || {}).merge(override[:param_types]) if override[:param_types]&.any?
430
824
 
431
- # Create external_sig for param_types lookup
432
- external_sig = signature_provider&.signature_for(
825
+ { param_types: param_types, return_type_override: override[:return_type], override_tags: override[:tags] }
826
+ end
827
+
828
+ # Resolve external signature for an insertion.
829
+ # @private
830
+ # @param [Object] insertion the collected method insertion
831
+ # @param [Object] signature_provider external RBS signature provider
832
+ # @return [Object]
833
+ def resolve_external_signature(insertion, signature_provider)
834
+ signature_provider&.signature_for(
433
835
  container: insertion.container,
434
836
  scope: insertion.scope,
435
837
  name: SourceHelpers.node_name(insertion.node)
436
838
  )
839
+ end
437
840
 
438
- case strategy
439
- when :aggressive
440
- if (range = method_comment_block_removal_range(buffer, insertion))
441
- rewriter.remove(range)
442
- end
841
+ # Resolve param types from signature or fall back to node parsing.
842
+ # @private
843
+ # @param [Object] insertion the collected method insertion
844
+ # @param [Object] external_sig the resolved signature from the signature provider
845
+ # @param [Object] config the active Docscribe::Config
846
+ # @return [Object]
847
+ def resolve_param_types(insertion, external_sig, config)
848
+ external_sig&.param_types || DocBuilder.build_param_types_from_node(
849
+ insertion.node, external_sig: external_sig, config: config
850
+ )
851
+ end
443
852
 
444
- effective_param_types = external_sig&.param_types || DocBuilder.build_param_types_from_node(
445
- insertion.node,
446
- external_sig: external_sig,
447
- config: config
448
- )
853
+ # Apply method insertion in aggressive strategy mode.
854
+ #
855
+ # @private
856
+ # @param [Hash] options keyword options
857
+ # @return [void]
858
+ def apply_method_insertion_aggressive!(**options)
859
+ rewriter = options[:rewriter]
860
+ buffer = options[:buffer]
861
+ insertion = options[:insertion]
862
+ doc = options[:doc]
863
+
864
+ remove_method_comment_block(rewriter, buffer, insertion)
865
+ return if doc.nil? || doc.empty?
866
+
867
+ rewriter.insert_before(options[:anchor_bol_range], doc)
868
+ add_change(changes: options[:changes], type: :insert_full_doc_block,
869
+ insertion: insertion, file: options[:file], message: 'missing docs')
870
+ end
449
871
 
450
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
451
- core_rbs_provider: core_rbs_provider, param_types: effective_param_types)
452
- return if doc.nil? || doc.empty?
872
+ # Remove the comment block above a method when present.
873
+ # @private
874
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
875
+ # @param [Object] buffer the source buffer being rewritten
876
+ # @param [Object] insertion the collected method insertion
877
+ # @return [Object]
878
+ def remove_method_comment_block(rewriter, buffer, insertion)
879
+ range = method_comment_block_removal_range(buffer, insertion)
880
+ rewriter.remove(range) if range
881
+ end
453
882
 
454
- rewriter.insert_before(anchor_bol_range, doc)
883
+ # Apply method insertion in safe strategy mode.
884
+ #
885
+ # @private
886
+ # @param [Hash] options keyword options
887
+ # @return [void]
888
+ def apply_method_insertion_safe!(**options)
889
+ info = method_doc_comment_info(options[:buffer], options[:insertion])
455
890
 
456
- add_change(
457
- changes,
458
- type: :insert_full_doc_block,
459
- insertion: insertion,
460
- file: file,
461
- message: 'missing docs'
462
- )
891
+ if info
892
+ apply_method_insertion_safe_with_info!(**options, info: info)
893
+ else
894
+ apply_method_insertion_safe_without_info!(**options)
895
+ end
896
+ end
463
897
 
464
- when :safe
465
- info = method_doc_comment_info(buffer, insertion)
466
-
467
- if info
468
- merge_result = build_missing_method_merge_result(
469
- insertion,
470
- existing_lines: info[:doc_lines],
471
- config: config,
472
- signature_provider: signature_provider,
473
- core_rbs_provider: core_rbs_provider,
474
- param_types: external_sig&.param_types,
475
- strategy: strategy
476
- )
477
-
478
- missing_lines = merge_result[:lines]
479
- reason_specs = merge_result[:reasons] || []
480
-
481
- sorted_existing_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
482
- info[:doc_lines],
483
- missing_lines: [],
484
- sort_tags: config.sort_tags?,
485
- tag_order: config.tag_order
486
- )
487
-
488
- merged_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
489
- info[:doc_lines],
490
- missing_lines: missing_lines,
491
- sort_tags: config.sort_tags?,
492
- tag_order: config.tag_order
493
- )
494
-
495
- existing_order_changed = sorted_existing_doc_lines != info[:doc_lines]
496
- new_block = (info[:preserved_lines] + merged_doc_lines).join
497
- old_block = info[:lines].join
498
-
499
- if new_block != old_block
500
- range = Parser::Source::Range.new(buffer, info[:start_pos], info[:end_pos])
501
- rewriter.replace(range, new_block)
502
-
503
- if existing_order_changed
504
- add_change(
505
- changes,
506
- type: :unsorted_tags,
507
- insertion: insertion,
508
- file: file,
509
- message: 'unsorted tags'
510
- )
511
- end
512
- end
513
-
514
- type_mismatch_reasons = reason_specs.select { |r| %i[updated_param updated_return].include?(r[:type]) }
515
-
516
- if new_block != old_block || type_mismatch_reasons.any?
517
- reason_specs.each do |reason|
518
- add_change(
519
- changes,
520
- type: reason[:type],
521
- insertion: insertion,
522
- file: file,
523
- message: reason[:message],
524
- extra: reason[:extra] || {}
525
- )
526
- end
527
- end
528
-
529
- return
530
- end
898
+ # Apply method insertion in safe mode when existing doc info is present.
899
+ #
900
+ # @private
901
+ # @param [Hash] options keyword options
902
+ # @return [void]
903
+ def apply_method_insertion_safe_with_info!(**options)
904
+ i = options[:info]
905
+ dp = filter_doc_params(options)
906
+ mr = build_missing_method_merge_result(options[:insertion], existing_lines: i[:doc_lines],
907
+ strategy: options[:strategy], **dp)
908
+ changed, n, ob = compute_doc_replacement(i, mr[:lines], strategy: options[:strategy], **dp)
909
+ commit_safe_doc_outcome(options[:rewriter], options[:buffer], i, n,
910
+ old_block: ob, merge_result: mr,
911
+ existing_order_changed: changed,
912
+ insertion: options[:insertion], changes: options[:changes],
913
+ file: options[:file])
914
+ end
531
915
 
532
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
533
- core_rbs_provider: core_rbs_provider,
534
- param_types: external_sig&.param_types)
535
- return if doc.nil? || doc.empty?
916
+ # Commit doc replacement and log changes for safe mode.
917
+ # @private
918
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
919
+ # @param [Object] buffer the source buffer being rewritten
920
+ # @param [Object] info hash containing existing doc comment block data
921
+ # @param [Object] new_block the newly constructed replacement doc block string
922
+ # @param [Hash] rest additional kwargs (old_block, merge_result, existing_order_changed, insertion, changes, file)
923
+ # @return [Object]
924
+ def commit_safe_doc_outcome(rewriter, buffer, info, new_block, **rest)
925
+ handle_doc_replacement(rewriter, buffer, info, new_block,
926
+ insertion: rest[:insertion], changes: rest[:changes],
927
+ file: rest[:file],
928
+ existing_order_changed: rest[:existing_order_changed])
929
+ log_method_doc_changes!(insertion: rest[:insertion], merge_result: rest[:merge_result],
930
+ new_block: new_block, old_block: rest[:old_block],
931
+ changes: rest[:changes], file: rest[:file])
932
+ end
536
933
 
537
- rewriter.insert_before(anchor_bol_range, doc)
934
+ # Filter doc params from options hash.
935
+ # @private
936
+ # @param [Object] options the full options hash to filter
937
+ # @return [Object]
938
+ def filter_doc_params(options)
939
+ options.reject { |k, _| %i[rewriter buffer insertion anchor_bol_range info changes file strategy].include?(k) }
940
+ end
538
941
 
539
- add_change(
540
- changes,
541
- type: :insert_full_doc_block,
542
- insertion: insertion,
543
- file: file,
544
- message: 'missing docs'
545
- )
546
- end
942
+ # Handle replacement when doc block content changed.
943
+ # @private
944
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
945
+ # @param [Object] buffer the source buffer being rewritten
946
+ # @param [Object] info hash containing existing doc comment block data (start_pos, end_pos, lines)
947
+ # @param [Object] new_block the newly constructed replacement doc block string
948
+ # @param [Object] existing_order_changed boolean indicating tag order was modified by sorting
949
+ # @param [Object] insertion the collected method insertion
950
+ # @param [Object] changes array of structured change records for reporting
951
+ # @param [Object] file the source file path string
952
+ # @param [Hash] log_opts additional keyword arguments for logging and recording changes
953
+ # @return [Object]
954
+ def handle_doc_replacement(rewriter, buffer, info, new_block, **log_opts)
955
+ range = Parser::Source::Range.new(buffer, info[:start_pos], info[:end_pos])
956
+ rewriter.replace(range, new_block)
957
+
958
+ return unless log_opts[:existing_order_changed]
959
+
960
+ add_change(changes: log_opts[:changes], type: :unsorted_tags,
961
+ insertion: log_opts[:insertion], file: log_opts[:file],
962
+ message: 'unsorted tags')
547
963
  end
548
964
 
549
- # Append a structured change record.
965
+ # Compute merged doc lines and determine if replacement is needed.
966
+ #
967
+ # @private
968
+ # @param [Hash] info existing doc info
969
+ # @param [Array<String>] missing_lines
970
+ # @param [Hash] options keyword options
971
+ # @return [Array] [existing_order_changed, new_block, old_block]
972
+ def compute_doc_replacement(info, missing_lines, **options)
973
+ dc = options[:config]
974
+ sorted = Docscribe::InlineRewriter::DocBlock.merge(
975
+ info[:doc_lines], missing_lines: [], sort_tags: dc.sort_tags?, tag_order: dc.tag_order
976
+ )
977
+ merged = Docscribe::InlineRewriter::DocBlock.merge(
978
+ info[:doc_lines], missing_lines: missing_lines, sort_tags: dc.sort_tags?, tag_order: dc.tag_order
979
+ )
980
+ [sorted != info[:doc_lines], (info[:preserved_lines] + merged).join, info[:lines].join]
981
+ end
982
+
983
+ # Log changes for method doc updates.
550
984
  #
551
985
  # @private
986
+ # @param [Collector::Insertion] insertion
987
+ # @param [Hash] merge_result
988
+ # @param [String] new_block
989
+ # @param [String] old_block
552
990
  # @param [Array<Hash>] changes
553
- # @param [Symbol] type
554
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
555
991
  # @param [String] file
556
- # @param [String] message
557
- # @param [Integer, nil] line
558
- # @param [Hash] extra
992
+ # @param [Hash] rest additional keyword arguments forwarded to add_change
993
+ # @return [Object]
994
+ def log_method_doc_changes!(insertion:, merge_result:, **rest)
995
+ reason_specs = merge_result[:reasons] || []
996
+ type_mismatch_reasons = reason_specs.select { |r| %i[updated_param updated_return].include?(r[:type]) }
997
+
998
+ return unless rest[:new_block] != rest[:old_block] || type_mismatch_reasons.any?
999
+
1000
+ reason_specs.each do |reason|
1001
+ add_change(changes: rest[:changes], type: reason[:type], insertion: insertion,
1002
+ file: rest[:file], message: reason[:message], extra: reason[:extra] || {})
1003
+ end
1004
+ end
1005
+
1006
+ # Apply method insertion in safe mode when no existing doc info is present.
1007
+ #
1008
+ # @private
1009
+ # @param [Hash] options keyword options
559
1010
  # @return [void]
560
- def add_change(changes, type:, insertion:, file:, message:, line: nil, extra: {})
1011
+ def apply_method_insertion_safe_without_info!(**options)
1012
+ rewriter = options[:rewriter]
1013
+ insertion = options[:insertion]
1014
+ anchor_bol_range = options[:anchor_bol_range]
1015
+ doc = build_method_doc(insertion, **filter_method_doc_params(options))
1016
+ return if doc.nil? || doc.empty?
1017
+
1018
+ rewriter.insert_before(anchor_bol_range, doc)
1019
+ add_change(changes: options[:changes], type: :insert_full_doc_block,
1020
+ insertion: insertion, file: options[:file], message: 'missing docs')
1021
+ end
1022
+
1023
+ # Filter options to keep only doc-building params for safe-without-info mode.
1024
+ # @private
1025
+ # @param [Object] options the full options hash to filter
1026
+ # @return [Object]
1027
+ def filter_method_doc_params(options)
1028
+ options.reject { |k, _| %i[rewriter buffer insertion anchor_bol_range changes file strategy].include?(k) }
1029
+ end
1030
+
1031
+ # Append a structured change record.
1032
+ #
1033
+ # @private
1034
+ # @param [Hash] options kwargs for change record (type, file, line, method, message, insertion, changes, extra)
1035
+ # @return [void]
1036
+ def add_change(**options)
1037
+ changes = options[:changes]
561
1038
  changes << {
562
- type: type,
563
- file: file,
564
- line: line || method_line_for(insertion),
565
- method: method_id_for(insertion),
566
- message: message
567
- }.merge(extra)
1039
+ type: options[:type],
1040
+ file: options[:file],
1041
+ line: options[:line] || method_line_for(options[:insertion]),
1042
+ method: method_id_for(options[:insertion]),
1043
+ message: options[:message]
1044
+ }.merge(options[:extra] || {})
568
1045
  end
569
1046
 
570
1047
  # Build a printable method identifier from a collected insertion.
@@ -580,154 +1057,201 @@ module Docscribe
580
1057
  # Apply one attribute insertion according to the selected strategy.
581
1058
  #
582
1059
  # @private
583
- # @param [Parser::Source::TreeRewriter] rewriter
584
- # @param [Parser::Source::Buffer] buffer
585
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] insertion
586
- # @param [Docscribe::Config] config
587
- # @param [Object, nil] signature_provider
588
- # @param [Symbol] strategy
589
- # @param [Hash] merge_inserts
1060
+ # @param [Hash] options kwargs (insertion, config, rewriter, buffer, strategy, signature_provider, merge_inserts)
590
1061
  # @return [void]
591
- def apply_attr_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, merge_inserts:)
1062
+ def apply_attr_insertion!(**options)
1063
+ config = options[:config]
592
1064
  return unless config.respond_to?(:emit_attributes?) && config.emit_attributes?
593
- return unless attribute_allowed?(config, insertion)
1065
+ return unless attribute_allowed?(config, options[:insertion])
594
1066
 
595
- bol_range = SourceHelpers.line_start_range(buffer, insertion.node)
596
-
597
- case strategy
598
- when :aggressive
599
- if (range = SourceHelpers.comment_block_removal_range(buffer, bol_range.begin_pos))
600
- rewriter.remove(range)
601
- end
1067
+ bol_range = SourceHelpers.line_start_range(options[:buffer], options[:insertion].node)
1068
+ params = attr_insertion_params(options[:insertion], config, options[:signature_provider], bol_range)
1069
+ dispatch_attr_strategy(params, options)
1070
+ end
602
1071
 
603
- doc = build_attr_doc_for_node(
604
- insertion,
605
- config: config,
606
- signature_provider: signature_provider
607
- )
608
- return if doc.nil? || doc.empty?
1072
+ #
1073
+ # @private
1074
+ # @param [Object] insertion the collected attribute insertion
1075
+ # @param [Object] config the active Docscribe::Config
1076
+ # @param [Object] signature_provider external RBS signature provider
1077
+ # @param [Object] bol_range the beginning-of-line range for the attribute node
1078
+ # @return [Hash]
1079
+ def attr_insertion_params(insertion, config, signature_provider, bol_range)
1080
+ {
1081
+ insertion: insertion, config: config,
1082
+ signature_provider: signature_provider, bol_range: bol_range
1083
+ }
1084
+ end
609
1085
 
610
- rewriter.insert_before(bol_range, doc)
1086
+ # Dispatch attr insertion to the aggressive or safe handler.
1087
+ # @private
1088
+ # @param [Object] params precomputed attribute insertion parameters
1089
+ # @param [Object] options the full keyword options hash
1090
+ # @return [Object]
1091
+ def dispatch_attr_strategy(params, options)
1092
+ case options[:strategy]
1093
+ when :aggressive then apply_attr_aggressive!(params, options[:rewriter])
1094
+ when :safe then apply_attr_safe!(params, options[:merge_inserts], options[:rewriter], options[:buffer])
1095
+ end
1096
+ end
611
1097
 
612
- when :safe
613
- info = SourceHelpers.doc_comment_block_info(buffer, bol_range.begin_pos)
1098
+ #
1099
+ # @private
1100
+ # @param [Object] params precomputed attribute insertion parameters
1101
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
1102
+ # @return [Object]
1103
+ def apply_attr_aggressive!(params, rewriter)
1104
+ if (range = SourceHelpers.comment_block_removal_range(params[:bol_range].begin_pos))
1105
+ rewriter.remove(range)
1106
+ end
614
1107
 
615
- if info
616
- additions = build_attr_merge_additions(
617
- insertion,
618
- existing_lines: info[:lines],
619
- config: config,
620
- signature_provider: signature_provider
621
- )
1108
+ doc = build_attr_doc_for_node(params[:insertion], config: params[:config],
1109
+ signature_provider: params[:signature_provider])
1110
+ return if doc.nil? || doc.empty?
622
1111
 
623
- if additions && !additions.empty?
624
- merge_inserts[info[:end_pos]] << [insertion.node.loc.expression.begin_pos, additions]
625
- end
1112
+ rewriter.insert_before(params[:bol_range], doc)
1113
+ end
626
1114
 
627
- return
628
- end
1115
+ #
1116
+ # @private
1117
+ # @param [Object] params precomputed attribute insertion parameters
1118
+ # @param [Object] merge_inserts hash mapping end positions to arrays of doc additions
1119
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
1120
+ # @param [Object] buffer the source buffer being rewritten
1121
+ # @return [Object]
1122
+ def apply_attr_safe!(params, merge_inserts, rewriter, buffer)
1123
+ info = SourceHelpers.doc_comment_block_info(buffer, params[:bol_range].begin_pos)
1124
+
1125
+ if info
1126
+ merge_attr_additions!(insertion: params[:insertion], info: info, merge_inserts: merge_inserts,
1127
+ config: params[:config], signature_provider: params[:signature_provider])
1128
+ return
1129
+ end
629
1130
 
630
- doc = build_attr_doc_for_node(
631
- insertion,
632
- config: config,
633
- signature_provider: signature_provider
634
- )
635
- return if doc.nil? || doc.empty?
1131
+ doc = build_attr_doc_for_node(params[:insertion], config: params[:config],
1132
+ signature_provider: params[:signature_provider])
1133
+ return if doc.nil? || doc.empty?
636
1134
 
637
- rewriter.insert_before(bol_range, doc)
638
- end
1135
+ rewriter.insert_before(params[:bol_range], doc)
639
1136
  end
640
1137
 
641
- # Apply aggregated merge inserts at shared end positions.
642
1138
  #
643
- # Used primarily for attribute merge behavior where multiple additions may target the same block end.
1139
+ # @private
1140
+ # @param [Object] insertion the collected attribute insertion
1141
+ # @param [Object] info hash containing existing doc comment block data
1142
+ # @param [Object] merge_inserts hash mapping end positions to arrays of doc additions
1143
+ # @param [Object] config the active Docscribe::Config
1144
+ # @param [Object] signature_provider external RBS signature provider
1145
+ # @return [Object]
1146
+ def merge_attr_additions!(insertion:, info:, merge_inserts:, config:, signature_provider:)
1147
+ additions = build_attr_merge_additions(ins: insertion, existing_lines: info[:lines],
1148
+ config: config, signature_provider: signature_provider)
1149
+ return unless additions && !additions.empty?
1150
+
1151
+ merge_inserts[info[:end_pos]] << [insertion.node.loc.expression.begin_pos, additions]
1152
+ end
1153
+
644
1154
  #
645
1155
  # @private
646
- # @param [Parser::Source::TreeRewriter] rewriter
647
- # @param [Parser::Source::Buffer] buffer
648
- # @param [Hash{Integer=>Array<(Integer,String)>}] merge_inserts
649
- # @return [void]
1156
+ # @param [Object] rewriter the TreeRewriter accumulating source transformations
1157
+ # @param [Object] buffer the source buffer being rewritten
1158
+ # @param [Object] merge_inserts hash mapping end positions to arrays of attribute doc additions
1159
+ # @return [Object]
650
1160
  def apply_merge_inserts!(rewriter:, buffer:, merge_inserts:)
651
- sep_re = /^\s*#\s*\r?\n$/
652
-
653
1161
  merge_inserts.keys.sort.reverse_each do |end_pos|
654
- chunks = merge_inserts[end_pos]
655
- next if chunks.empty?
1162
+ text = merge_text_for_pos(merge_inserts[end_pos])
1163
+ next if text.nil? || text.empty?
656
1164
 
657
- chunks = chunks.sort_by { |(sort_key, _s)| sort_key }
1165
+ range = Parser::Source::Range.new(buffer, end_pos, end_pos)
1166
+ rewriter.insert_before(range, text)
1167
+ end
1168
+ end
658
1169
 
659
- out_lines = []
1170
+ #
1171
+ # @private
1172
+ # @param [Object] chunks array of [sort_key, doc_text] pairs for a given merge position
1173
+ # @return [Object, nil]
1174
+ def merge_text_for_pos(chunks)
1175
+ return nil if chunks.empty?
660
1176
 
661
- chunks.each do |(_k, chunk)|
662
- next if chunk.nil? || chunk.empty?
1177
+ chunks = chunks.sort_by { |(sort_key, _s)| sort_key }
1178
+ out_lines = [] #: Array[String]
1179
+ sep_re = /^\s*#\s*\r?\n$/
663
1180
 
664
- lines = chunk.lines
665
- seps = []
666
- seps << lines.shift while !lines.empty? && lines.first.match?(sep_re)
1181
+ chunks.each do |(_k, chunk)|
1182
+ next if chunk.nil? || chunk.empty?
667
1183
 
668
- sep = seps.first
669
- out_lines << sep if sep && (out_lines.empty? || !out_lines.last.match?(sep_re))
670
- out_lines.concat(lines)
671
- end
1184
+ merge_chunk_into_out(chunk, out_lines, sep_re)
1185
+ end
672
1186
 
673
- text = out_lines.join
674
- next if text.empty?
1187
+ text = out_lines.join
1188
+ text.empty? ? nil : text
1189
+ end
675
1190
 
676
- range = Parser::Source::Range.new(buffer, end_pos, end_pos)
677
- rewriter.insert_before(range, text)
678
- end
1191
+ # Merge a single chunk into the output lines array.
1192
+ # @private
1193
+ # @param [Object] chunk the doc text string to merge
1194
+ # @param [Object] out_lines the accumulated output lines array
1195
+ # @param [Object] sep_re regex matching separator comment lines (# followed by newline)
1196
+ # @return [Object]
1197
+ def merge_chunk_into_out(chunk, out_lines, sep_re)
1198
+ lines = chunk.lines
1199
+ seps = extract_separators(lines, sep_re)
1200
+ sep = seps.first
1201
+ out_lines << sep if sep && (out_lines.empty? || !out_lines.last.match?(sep_re))
1202
+ out_lines.concat(lines)
1203
+ end
1204
+
1205
+ # Extract leading separator lines from a chunk.
1206
+ # @private
1207
+ # @param [Object] lines array of lines from the chunk
1208
+ # @param [Object] sep_re regex matching separator comment lines
1209
+ # @return [Object]
1210
+ def extract_separators(lines, sep_re)
1211
+ seps = [] #: Array[String]
1212
+ seps << lines.shift while !lines.empty? && lines.first.match?(sep_re)
1213
+ seps
679
1214
  end
680
1215
 
681
- # Build plain-text merge additions for an attribute doc block.
682
1216
  #
683
1217
  # @private
684
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
685
- # @param [Array<String>] existing_lines
686
- # @param [Docscribe::Config] config
687
- # @param [Object] signature_provider Param documentation.
1218
+ # @param [Object] ins the attribute insertion object
1219
+ # @param [Object] existing_lines array of existing doc comment lines
1220
+ # @param [Object] config the active Docscribe::Config
1221
+ # @param [Object] signature_provider external RBS signature provider
688
1222
  # @raise [StandardError]
689
- # @return [String, nil]
690
- def build_attr_merge_additions(ins, existing_lines:, config:, signature_provider:)
691
- indent = SourceHelpers.line_indent(ins.node)
692
- param_tag_style = config.param_tag_style
693
- existing = existing_attr_names(existing_lines)
694
- missing = ins.names.reject { |name_sym| existing[name_sym.to_s] }
1223
+ # @return [Object]
1224
+ # @return [nil] if StandardError
1225
+ def build_attr_merge_additions(ins:, existing_lines:, config:, signature_provider:)
1226
+ missing = missing_attr_names(ins, existing_lines)
695
1227
  return '' if missing.empty?
696
1228
 
697
- lines = []
1229
+ indent = SourceHelpers.line_indent(ins.node)
1230
+ lines = [] #: Array[String]
698
1231
  lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
699
-
700
- missing.each_with_index do |name_sym, idx|
701
- attr_name = name_sym.to_s
702
- mode = ins.access.to_s
703
- attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
704
-
705
- lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
706
-
707
- if config.emit_visibility_tags?
708
- lines << "#{indent}# @private" if ins.visibility == :private
709
- lines << "#{indent}# @protected" if ins.visibility == :protected
710
- end
711
-
712
- lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
713
- if %i[w rw].include?(ins.access)
714
- lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
715
- end
716
- lines << "#{indent}#" if idx < missing.length - 1
717
- end
718
-
1232
+ lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1233
+ signature_provider: signature_provider, names: missing))
719
1234
  lines.map { |l| "#{l}\n" }.join
720
1235
  rescue StandardError
721
1236
  nil
722
1237
  end
723
1238
 
724
- # Extract already documented attribute names from existing `@!attribute` lines.
725
1239
  #
726
1240
  # @private
727
- # @param [Array<String>] lines
728
- # @return [Hash{String=>Boolean}]
1241
+ # @param [Object] ins the attribute insertion object
1242
+ # @param [Object] existing_lines array of existing doc comment lines
1243
+ # @return [Object]
1244
+ def missing_attr_names(ins, existing_lines)
1245
+ existing = existing_attr_names(existing_lines)
1246
+ ins.names.reject { |name_sym| existing[name_sym.to_s] }
1247
+ end
1248
+
1249
+ #
1250
+ # @private
1251
+ # @param [Object] lines array of existing doc comment lines
1252
+ # @return [Object]
729
1253
  def existing_attr_names(lines)
730
- names = {}
1254
+ names = {} #: Hash[String, bool]
731
1255
 
732
1256
  Array(lines).each do |line|
733
1257
  if (m = line.match(/^\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+(\S+)/))
@@ -746,66 +1270,129 @@ module Docscribe
746
1270
  # @return [Boolean]
747
1271
  def attribute_allowed?(config, ins)
748
1272
  ins.names.any? do |name_sym|
749
- ok = false
750
-
751
- if %i[r rw].include?(ins.access)
752
- ok ||= config.process_method?(
753
- container: ins.container,
754
- scope: ins.scope,
755
- visibility: ins.visibility,
756
- name: name_sym
757
- )
758
- end
1273
+ allowed_for_access?(config, ins, name_sym)
1274
+ end
1275
+ end
759
1276
 
760
- if %i[w rw].include?(ins.access)
761
- ok ||= config.process_method?(
762
- container: ins.container,
763
- scope: ins.scope,
764
- visibility: ins.visibility,
765
- name: :"#{name_sym}="
766
- )
767
- end
1277
+ #
1278
+ # @private
1279
+ # @param [Object] config the active Docscribe::Config
1280
+ # @param [Object] ins the attribute insertion object
1281
+ # @param [Object] name_sym the attribute name as a Symbol
1282
+ # @return [Object]
1283
+ def allowed_for_access?(config, ins, name_sym)
1284
+ ok = false
1285
+
1286
+ if %i[r rw].include?(ins.access)
1287
+ ok ||= config.process_method?(container: ins.container, scope: ins.scope,
1288
+ visibility: ins.visibility, name: name_sym)
1289
+ end
768
1290
 
769
- ok
1291
+ if %i[w rw].include?(ins.access)
1292
+ ok ||= config.process_method?(container: ins.container, scope: ins.scope,
1293
+ visibility: ins.visibility, name: :"#{name_sym}=")
770
1294
  end
1295
+
1296
+ ok
771
1297
  end
772
1298
 
773
- # Build a full `@!attribute` documentation block for one attribute insertion.
774
1299
  #
775
1300
  # @private
776
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
777
- # @param [Docscribe::Config] config
778
- # @param [Object] signature_provider Param documentation.
1301
+ # @param [Object] ins the attribute insertion object
1302
+ # @param [Object] config the active Docscribe::Config
1303
+ # @param [Object] signature_provider external RBS signature provider
779
1304
  # @raise [StandardError]
780
- # @return [String, nil]
1305
+ # @return [Object]
1306
+ # @return [nil] if StandardError
781
1307
  def build_attr_doc_for_node(ins, config:, signature_provider:)
782
1308
  indent = SourceHelpers.line_indent(ins.node)
783
- param_tag_style = config.param_tag_style
784
- lines = []
1309
+ lines = build_attr_doc_lines(ins, indent: indent, config: config, signature_provider: signature_provider)
1310
+ lines.map { |l| "#{l}\n" }.join
1311
+ rescue StandardError
1312
+ nil
1313
+ end
785
1314
 
786
- ins.names.each_with_index do |name_sym, idx|
787
- attr_name = name_sym.to_s
788
- mode = ins.access.to_s
789
- attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
1315
+ #
1316
+ # @private
1317
+ # @param [Object] ins the attribute insertion object
1318
+ # @param [Object] indent whitespace indentation prefix derived from the attribute node
1319
+ # @param [Object] config the active Docscribe::Config
1320
+ # @param [Object] signature_provider external RBS signature provider
1321
+ # @param [nil] names optional subset of attribute names to document (defaults to all names)
1322
+ # @return [Object]
1323
+ def build_attr_doc_lines(ins, indent:, config:, signature_provider:, names: nil)
1324
+ names ||= ins.names
1325
+ lines = [] #: Array[untyped]
1326
+
1327
+ names.each_with_index do |name_sym, idx|
1328
+ lines.concat(build_single_attr_lines(ins, name_sym, indent: indent,
1329
+ config: config, signature_provider: signature_provider,
1330
+ idx: idx, total: names.length))
1331
+ end
790
1332
 
791
- lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
1333
+ lines
1334
+ end
792
1335
 
793
- if config.emit_visibility_tags?
794
- lines << "#{indent}# @private" if ins.visibility == :private
795
- lines << "#{indent}# @protected" if ins.visibility == :protected
796
- end
1336
+ # Build doc lines for a single attribute.
1337
+ # @private
1338
+ # @param [Object] ins the attribute insertion object
1339
+ # @param [Object] name_sym the attribute name as a Symbol
1340
+ # @param [Object] idx index of the current attribute in the names array
1341
+ # @param [Object] total total number of attributes to document
1342
+ # @param [Object] indent whitespace indentation prefix
1343
+ # @param [Object] config the active Docscribe::Config
1344
+ # @param [Object] signature_provider external RBS signature provider
1345
+ # @param [Hash] opts additional keyword arguments forwarded from build_attr_doc_lines
1346
+ # @return [Object]
1347
+ def build_single_attr_lines(ins, name_sym, indent:, **opts)
1348
+ cfg = opts[:config]
1349
+ attr_type = attribute_type(ins, name_sym, cfg, signature_provider: opts[:signature_provider])
1350
+ lines = ["#{indent}# @!attribute [#{ins.access}] #{name_sym}"]
1351
+ lines.concat(attr_visibility_lines(indent, cfg, ins))
1352
+ append_attr_return_tag(lines, indent, attr_type, ins.access)
1353
+ append_attr_param_tag(lines, indent, attr_type, ins.access, cfg)
1354
+ lines << "#{indent}#" if opts[:idx] < opts[:total] - 1
1355
+ lines
1356
+ end
797
1357
 
798
- lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
799
- if %i[w rw].include?(ins.access)
800
- lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
801
- end
1358
+ # Append @return tag for readable attribute.
1359
+ # @private
1360
+ # @param [Object] lines the doc lines array being built
1361
+ # @param [Object] indent whitespace indentation prefix
1362
+ # @param [Object] attr_type the resolved type string for the attribute
1363
+ # @param [Object] access the access level (:r, :w, or :rw)
1364
+ # @return [Object]
1365
+ def append_attr_return_tag(lines, indent, attr_type, access)
1366
+ lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(access)
1367
+ end
802
1368
 
803
- lines << "#{indent}#" if idx < ins.names.length - 1
804
- end
1369
+ # Append @param tag for writable attribute.
1370
+ # @private
1371
+ # @param [Object] lines the doc lines array being built
1372
+ # @param [Object] indent whitespace indentation prefix
1373
+ # @param [Object] attr_type the resolved type string for the attribute
1374
+ # @param [Object] access the access level (:r, :w, or :rw)
1375
+ # @param [Object] cfg the active Docscribe::Config
1376
+ # @return [Object]
1377
+ def append_attr_param_tag(lines, indent, attr_type, access, cfg)
1378
+ return unless %i[w rw].include?(access)
1379
+
1380
+ lines << format_attribute_param_tag(indent, 'value', attr_type, style: cfg.param_tag_style)
1381
+ end
805
1382
 
806
- lines.map { |l| "#{l}\n" }.join
807
- rescue StandardError
808
- nil
1383
+ # Build visibility lines for an attribute.
1384
+ # @private
1385
+ # @param [Object] indent whitespace indentation prefix
1386
+ # @param [Object] config the active Docscribe::Config
1387
+ # @param [Object] ins the attribute insertion object
1388
+ # @return [Object]
1389
+ def attr_visibility_lines(indent, config, ins)
1390
+ return [] unless config.emit_visibility_tags?
1391
+
1392
+ lines = [] #: Array[String]
1393
+ lines << "#{indent}# @private" if ins.visibility == :private
1394
+ lines << "#{indent}# @protected" if ins.visibility == :protected
1395
+ lines
809
1396
  end
810
1397
 
811
1398
  # Format an attribute `@param` tag line using the configured param tag style.
@@ -835,7 +1422,7 @@ module Docscribe
835
1422
  # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
836
1423
  # @param [Symbol] name_sym
837
1424
  # @param [Docscribe::Config] config
838
- # @param [Object] signature_provider Param documentation.
1425
+ # @param [Object] signature_provider RBS signature provider
839
1426
  # @raise [StandardError]
840
1427
  # @return [String]
841
1428
  def attribute_type(ins, name_sym, config, signature_provider:)
@@ -874,19 +1461,10 @@ module Docscribe
874
1461
  #
875
1462
  # @private
876
1463
  # @param [Collector::Insertion] insertion the collected method insertion
877
- # @param [Docscribe::Config] config the active configuration
878
- # @param [Object, nil] signature_provider external signature provider
879
- # @param [Object, nil] core_rbs_provider RBS core type provider
880
- # @param [Hash, nil] param_types parameter name -> type map
1464
+ # @param [Hash] options kwargs for DocBuilder.build (param_types, return_type_override, override_tags, config)
881
1465
  # @return [String, nil] generated doc block or nil
882
- def build_method_doc(insertion, config:, signature_provider:, core_rbs_provider:, param_types:)
883
- DocBuilder.build(
884
- insertion,
885
- config: config,
886
- signature_provider: signature_provider,
887
- core_rbs_provider: core_rbs_provider,
888
- param_types: param_types
889
- )
1466
+ def build_method_doc(insertion, **options)
1467
+ DocBuilder.build(insertion, **options)
890
1468
  end
891
1469
 
892
1470
  # Delegate to DocBuilder.build_missing_merge_result for generating missing doc lines only.
@@ -894,23 +1472,10 @@ module Docscribe
894
1472
  # @private
895
1473
  # @param [Collector::Insertion] insertion the collected method insertion
896
1474
  # @param [Array<String>] existing_lines existing doc-like lines
897
- # @param [Docscribe::Config] config the active configuration
898
- # @param [Object, nil] signature_provider external signature provider
899
- # @param [Object, nil] core_rbs_provider RBS core type provider
900
- # @param [Hash, nil] param_types parameter name -> type map
901
- # @param [Object] strategy Param documentation.
1475
+ # @param [Hash] options keyword arguments forwarded to DocBuilder.build_missing_merge_result
902
1476
  # @return [Hash] result with `:lines` and `:reasons` keys
903
- def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:,
904
- core_rbs_provider:, param_types:, strategy:)
905
- DocBuilder.build_missing_merge_result(
906
- insertion,
907
- existing_lines: existing_lines,
908
- config: config,
909
- signature_provider: signature_provider,
910
- core_rbs_provider: core_rbs_provider,
911
- param_types: param_types,
912
- strategy: strategy
913
- )
1477
+ def build_missing_method_merge_result(insertion, existing_lines:, **options)
1478
+ DocBuilder.build_missing_merge_result(insertion, existing_lines: existing_lines, **options)
914
1479
  end
915
1480
 
916
1481
  # Get doc comment block info (preceding comments) for a method insertion.
@@ -977,6 +1542,36 @@ module Docscribe
977
1542
  insertion.node
978
1543
  end
979
1544
  end
1545
+
1546
+ # Extract method override data from an insertion hash.
1547
+ #
1548
+ # @private
1549
+ # @param [Object] method_override the raw override data
1550
+ # @return [Hash{Symbol => Object}] normalized override hash
1551
+ def extract_method_override!(method_override)
1552
+ return {} unless method_override.is_a?(Hash)
1553
+
1554
+ {
1555
+ return_type: method_override[:return_type],
1556
+ param_types: method_override[:param_types].is_a?(Hash) ? method_override[:param_types] : {},
1557
+ tags: normalize_override_tags(method_override[:tags])
1558
+ }
1559
+ end
1560
+
1561
+ # Normalize override tags into Plugin::Tag instances.
1562
+ #
1563
+ # @private
1564
+ # @param [Array<Object>] tags raw tag values
1565
+ # @return [Array<Docscribe::Plugin::Tag>]
1566
+ def normalize_override_tags(tags)
1567
+ Array(tags).filter_map do |tag|
1568
+ case tag
1569
+ when Docscribe::Plugin::Tag then tag
1570
+ when Hash
1571
+ Docscribe::Plugin::Tag.new(**tag.transform_keys(&:to_sym))
1572
+ end
1573
+ end
1574
+ end
980
1575
  end
981
1576
  end
982
1577
  end