docscribe 1.4.1 → 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 +149 -0
  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 +302 -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 +1 -0
  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 +1488 -602
  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 +1009 -595
  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,119 +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: nil,
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] }
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
105
163
 
106
- method_overrides_by_pos = {}
107
- all = deduplicate_insertions(all, method_overrides_by_pos: method_overrides_by_pos)
108
- rewriter = Parser::Source::TreeRewriter.new(buffer)
109
- merge_inserts = Hash.new { |h, k| h[k] = [] }
110
- changes = []
164
+ private
111
165
 
112
- all.sort_by { |(kind, ins)| plugin_insertion_pos(kind, ins) }
113
- .reverse_each do |kind, ins|
114
- case kind
115
- when :method
116
- pos = plugin_insertion_pos(:method, ins)
117
- method_override = method_overrides_by_pos[pos]
118
-
119
- apply_method_insertion!(
120
- rewriter: rewriter,
121
- buffer: buffer,
122
- insertion: ins,
123
- config: config,
124
- signature_provider: signature_provider,
125
- core_rbs_provider: core_rbs_provider,
126
- strategy: strategy,
127
- changes: changes,
128
- file: file.to_s,
129
- method_override: method_override
130
- )
131
- when :attr
132
- apply_attr_insertion!(
133
- rewriter: rewriter,
134
- buffer: buffer,
135
- insertion: ins,
136
- config: config,
137
- signature_provider: signature_provider,
138
- strategy: strategy,
139
- merge_inserts: merge_inserts
140
- )
141
- when :plugin
142
- apply_plugin_insertion!(
143
- rewriter: rewriter,
144
- buffer: buffer,
145
- insertion: ins,
146
- strategy: strategy,
147
- config: config
148
- )
149
- end
150
- end
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
151
179
 
152
- apply_merge_inserts!(rewriter: rewriter, buffer: buffer, merge_inserts: merge_inserts)
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) }
183
+ end
153
184
 
154
- { output: rewriter.process, changes: changes }
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
155
198
  end
156
199
 
157
- private
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
158
215
 
159
216
  # Deduplicate insertions by source position.
160
217
  #
@@ -168,130 +225,238 @@ module Docscribe
168
225
  #
169
226
  # @private
170
227
  # @param [Array<Array(Symbol,Object)>] insertions tagged insertion list
171
- # @param [nil] method_overrides_by_pos Param documentation.
228
+ # @param [nil] method_overrides_by_pos method-level overrides keyed by insertion position
172
229
  # @return [Array<Array(Symbol,Object)>]
173
230
  def deduplicate_insertions(insertions, method_overrides_by_pos: nil)
174
- groups = {}
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
175
235
 
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]
176
263
  insertions.each do |kind, ins|
177
264
  pos = plugin_insertion_pos(kind, ins)
178
265
  (groups[pos] ||= []) << [kind, ins]
179
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)
278
+ end
279
+ end
180
280
 
181
- 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
182
295
 
183
- groups.each do |pos, items|
184
- # plugin insertions at this pos
185
- 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
186
299
 
187
- # no plugins -> keep as-is
188
- if plugin_items.empty?
189
- result.concat(items)
190
- next
191
- 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) }
192
310
 
193
- method_items = items.select { |k, _| k == :method }
194
-
195
- plugin_doc_items =
196
- plugin_items.select do |_k, ins|
197
- ins.is_a?(Hash) && ins[:doc] && !ins[:doc].empty?
198
- end
199
-
200
- override_items =
201
- plugin_items.select do |_k, ins|
202
- ins.is_a?(Hash) && ins[:method_override].is_a?(Hash)
203
- end
204
-
205
- # --- Case A: raw plugin doc exists => legacy behavior: plugin replaces method ---
206
- if plugin_doc_items.any?
207
- # drop standard method insertions at same pos
208
- items = items.reject { |k, _| k == :method }
209
-
210
- # drop method_overrides too (they have nothing to patch)
211
- items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
212
-
213
- # keep only highest-priority plugin docs
214
- max_prio = plugin_doc_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
215
-
216
- dropped = items.select { |k, ins| k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio }
217
-
218
- items = items.reject do |k, ins|
219
- k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio
220
- end
221
-
222
- if Docscribe::Plugin.debug? && dropped.any?
223
- kept_labels = items.select { |k, _| k == :plugin }
224
- .map { |_k, ins| plugin_insertion_label(ins) }
225
- .uniq
226
- dropped_labels = dropped.map { |_k, ins| plugin_insertion_label(ins) }.uniq
227
- line = plugin_insertion_line(plugin_items.first[1])
228
- loc = +"pos=#{pos}"
229
- loc << " line=#{line}" if line
230
- warn "Docscribe: CollectorPlugin conflict at #{loc} — " \
231
- "#{dropped_labels.join(', ')} (pri=#{dropped.map { |_k, ins| plugin_insertion_priority(ins) }.max}) " \
232
- "dropped in favor of #{kept_labels.join(', ')} (pri=#{max_prio}). " \
233
- 'Set explicit priority or adjust anchor_node to avoid collision.'
234
- end
235
-
236
- result.concat(items)
237
- next
238
- end
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
239
317
 
240
- # --- Case B: method_override exists and there is a method insertion => patch method ---
241
- if override_items.any? && method_items.any?
242
- if method_overrides_by_pos
243
- winning_ins = pick_highest_priority_override_insertion(override_items, pos: pos)
244
- method_overrides_by_pos[pos] = winning_ins[:method_override] if winning_ins
245
- end
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
246
326
 
247
- # remove override items from final insertions (they are applied via method_overrides_by_pos)
248
- items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
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) }
249
337
 
250
- result.concat(items)
251
- next
252
- 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
253
346
 
254
- # --- Case C: override exists, but no method insertion (filtered out, etc.) => ignore override ---
255
- items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
256
- 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
257
374
  end
375
+ end
258
376
 
259
- result
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
260
405
  end
261
406
 
262
407
  # @private
263
- # @param override_items [Array<Array(Symbol, Hash)>] list of [:plugin, insertion_hash] that include :method_override
408
+ # @param override_items [Array<Array(Symbol, Hash)>] list of [:plugin, insertion_hash]
409
+ # that include :method_override
264
410
  # @param pos [Integer] begin_pos (used only for debug output)
265
411
  # @return [Hash, nil] winning insertion hash (the one whose override will be applied)
266
412
  def pick_highest_priority_override_insertion(override_items, pos:)
267
413
  return nil if override_items.empty?
268
414
 
269
- max_prio =
270
- override_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
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)
271
418
 
272
- winners =
273
- override_items.select { |_k, ins| plugin_insertion_priority(ins) == max_prio }
419
+ warn_override_conflict!(winners_sorted, max_prio, pos)
274
420
 
275
- # Deterministic tie-break: smallest plugin order wins.
276
- # (We warn in debug if the tie is between different plugins.)
277
- winners_sorted =
278
- winners.sort_by do |_k, ins|
279
- order = ins.is_a?(Hash) ? ins[:__docscribe_plugin_order] : nil
280
- order.nil? ? 0 : order
281
- end
421
+ winners_sorted.first[1]
422
+ end
282
423
 
283
- if Docscribe::Plugin.debug?
284
- labels = winners_sorted.map { |_k, ins| plugin_insertion_label(ins) }.uniq
285
- if labels.size > 1
286
- line = plugin_insertion_line(winners_sorted.first[1])
287
- loc = +"pos=#{pos}"
288
- loc << " line=#{line}" if line
289
- warn "Docscribe: method_override conflict at #{loc} (priority=#{max_prio}): " \
290
- "#{labels.join(', ')} — using first by registration order."
291
- end
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
292
440
  end
441
+ end
293
442
 
294
- winners_sorted.first[1]
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."
295
460
  end
296
461
 
297
462
  # @private
@@ -326,7 +491,9 @@ module Docscribe
326
491
  def plugin_insertion_line(insertion)
327
492
  return nil unless insertion.is_a?(Hash)
328
493
 
329
- insertion[:anchor_node]&.loc&.expression&.line
494
+ anchor_node = insertion[:anchor_node]
495
+ expression = anchor_node&.loc&.expression
496
+ expression&.line
330
497
  rescue StandardError
331
498
  nil
332
499
  end
@@ -360,22 +527,29 @@ module Docscribe
360
527
  # @param [Docscribe::Config] config
361
528
  # @return [void]
362
529
  def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:, config:)
363
- anchor_node = insertion[:anchor_node]
364
- doc = insertion[:doc]
530
+ anchor_node, doc = insertion.values_at(:anchor_node, :doc)
365
531
  return unless anchor_node && doc && !doc.empty?
366
532
 
367
533
  indent = SourceHelpers.line_indent(anchor_node)
368
- doc = normalize_plugin_doc(doc, indent, config: config, anchor_node: anchor_node)
534
+ doc = normalize_plugin_doc(doc, indent, config: config, anchor_node: anchor_node)
369
535
  bol_range = SourceHelpers.line_start_range(buffer, anchor_node)
536
+ insert_plugin_doc(rewriter, buffer, bol_range, doc, strategy)
537
+ end
370
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)
371
548
  case strategy
372
549
  when :aggressive
373
- # Will remove ANY comments above the method. Plugin will decide what will be changed.
374
- if (range = any_comment_block_removal_range(buffer, bol_range.begin_pos))
375
- rewriter.remove(range)
376
- end
550
+ range = any_comment_block_removal_range(buffer, bol_range.begin_pos)
551
+ rewriter.remove(range) if range
377
552
  rewriter.insert_before(bol_range, doc)
378
-
379
553
  when :safe
380
554
  return if SourceHelpers.already_has_doc_immediately_above?(buffer, bol_range.begin_pos)
381
555
 
@@ -396,31 +570,56 @@ module Docscribe
396
570
  def any_comment_block_removal_range(buffer, bol_pos)
397
571
  src = buffer.source
398
572
  lines = src.lines
399
- def_line_idx = src[0...bol_pos].count("\n")
400
- i = def_line_idx - 1
573
+ i = nearest_comment_line_index(src, lines, bol_pos)
574
+ return nil unless i
401
575
 
402
- # Skip blank lines directly above node
403
- i -= 1 while i >= 0 && lines[i].strip.empty?
576
+ start_idx = comment_block_start_index(lines, i)
404
577
 
405
- # Nearest non-blank line must be a comment
406
- return nil unless i >= 0 && lines[i] =~ /^\s*#/
578
+ removable_start_idx = skip_preserved_lines(lines, start_idx, i)
579
+ return nil if removable_start_idx > i
407
580
 
408
- # Walk upward through the entire contiguous comment block
409
- start_idx = i
410
- start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
411
- start_idx += 1
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
412
584
 
413
- # Preserve leading directive-style lines (rubocop, magic comments, etc.)
414
- removable_start_idx = start_idx
415
- while removable_start_idx <= i &&
416
- SourceHelpers.preserved_comment_line?(lines[removable_start_idx])
417
- removable_start_idx += 1
418
- end
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?
595
+ return nil unless i >= 0 && lines[i] =~ /^\s*#/
419
596
 
420
- return nil if removable_start_idx > i
597
+ i
598
+ end
421
599
 
422
- start_pos = removable_start_idx.positive? ? lines[0...removable_start_idx].join.length : 0
423
- Parser::Source::Range.new(buffer, start_pos, bol_pos)
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
608
+ start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
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
424
623
  end
425
624
 
426
625
  # Normalize a CollectorPlugin-provided doc string before insertion.
@@ -439,32 +638,54 @@ module Docscribe
439
638
  # @return [String] Normalized doc string ready to be inserted
440
639
  def normalize_plugin_doc(doc, indent, config:, anchor_node:)
441
640
  doc = normalize_plugin_doc_indent(doc, indent)
641
+ doc = trim_trailing_blank_lines(doc)
442
642
 
443
- lines = doc.lines
444
- lines.pop while lines.any? && lines.last.strip.empty?
445
-
446
- doc = lines.join
447
- doc << "\n" unless doc.end_with?("\n")
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)
645
+ end
448
646
 
449
- if %i[def defs].include?(anchor_node&.type) && config.include_default_message?
450
- scope = anchor_node.type == :defs ? :class : :instance
451
- msg = config.default_message(scope, :public)
647
+ doc
648
+ end
452
649
 
453
- has_prose = doc.lines.any? do |l|
454
- s = l.strip
455
- next false if s.empty? || s == '#'
456
- next false if s.start_with?('# @') # tag line
457
- next false if s.start_with?('# +') # header line
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
458
660
 
459
- true
460
- end
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
461
675
 
462
- unless has_prose
463
- doc = "#{indent}# #{msg}\n#{indent}#\n" + doc
464
- end
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
465
688
  end
466
-
467
- doc
468
689
  end
469
690
 
470
691
  # Normalize indentation of a plugin-generated doc block.
@@ -528,215 +749,299 @@ module Docscribe
528
749
  # - insert a fresh regenerated block
529
750
  #
530
751
  # @private
531
- # @param [Parser::Source::TreeRewriter] rewriter
532
- # @param [Parser::Source::Buffer] buffer
533
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
534
- # @param [Docscribe::Config] config
535
- # @param [Object, nil] signature_provider
536
- # @param [Symbol] strategy
537
- # @param [Array<Hash>] changes structured change records
538
- # @param [String] file
539
- # @param [Object] core_rbs_provider Param documentation.
540
- # @param [nil] method_override Param documentation.
752
+ # @param [Hash] options kwargs with insertion, config, rewriter, buffer, strategy, changes, file, doc params
541
753
  # @return [void]
542
- def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, core_rbs_provider:,
543
- strategy:, changes:, file:, method_override: nil)
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)
544
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
545
794
 
546
- return unless config.process_method?(
547
- container: insertion.container,
548
- scope: insertion.scope,
549
- visibility: insertion.visibility,
550
- name: name
551
- )
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
552
811
 
553
- 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?
554
824
 
555
- # Create external_sig for param_types lookup
556
- 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(
557
835
  container: insertion.container,
558
836
  scope: insertion.scope,
559
837
  name: SourceHelpers.node_name(insertion.node)
560
838
  )
561
- override_return_type =
562
- method_override.is_a?(Hash) ? method_override[:return_type] : nil
563
-
564
- override_param_types =
565
- method_override.is_a?(Hash) && method_override[:param_types].is_a?(Hash) ? method_override[:param_types] : nil
566
-
567
- override_tags =
568
- if method_override.is_a?(Hash)
569
- Array(method_override[:tags]).filter_map do |t|
570
- case t
571
- when Docscribe::Plugin::Tag then t
572
- when Hash
573
- Docscribe::Plugin::Tag.new(**t.transform_keys(&:to_sym))
574
- end
575
- end
576
- else
577
- []
578
- end
839
+ end
579
840
 
580
- case strategy
581
- when :aggressive
582
- if (range = method_comment_block_removal_range(buffer, insertion))
583
- rewriter.remove(range)
584
- 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
585
852
 
586
- effective_param_types = external_sig&.param_types || DocBuilder.build_param_types_from_node(
587
- insertion.node,
588
- external_sig: external_sig,
589
- config: config
590
- )
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
591
871
 
592
- if override_param_types && !override_param_types.empty?
593
- effective_param_types = effective_param_types.merge(override_param_types)
594
- end
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
595
882
 
596
- doc = build_method_doc(
597
- insertion,
598
- config: config,
599
- signature_provider: signature_provider,
600
- core_rbs_provider: core_rbs_provider,
601
- param_types: effective_param_types,
602
- return_type_override: override_return_type,
603
- override_tags: override_tags
604
- )
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])
605
890
 
606
- return if doc.nil? || doc.empty?
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
607
897
 
608
- rewriter.insert_before(anchor_bol_range, doc)
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
609
915
 
610
- add_change(
611
- changes,
612
- type: :insert_full_doc_block,
613
- insertion: insertion,
614
- file: file,
615
- message: 'missing docs'
616
- )
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
617
933
 
618
- when :safe
619
- info = method_doc_comment_info(buffer, insertion)
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
620
941
 
621
- effective_param_types = external_sig&.param_types || DocBuilder.build_param_types_from_node(
622
- insertion.node,
623
- external_sig: external_sig,
624
- config: config
625
- )
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')
963
+ end
626
964
 
627
- if override_param_types && !override_param_types.empty?
628
- effective_param_types = effective_param_types.merge(override_param_types)
629
- end
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
630
982
 
631
- if info
632
- merge_result = build_missing_method_merge_result(
633
- insertion,
634
- existing_lines: info[:doc_lines],
635
- config: config,
636
- signature_provider: signature_provider,
637
- core_rbs_provider: core_rbs_provider,
638
- param_types: effective_param_types,
639
- strategy: strategy,
640
- return_type_override: override_return_type,
641
- override_tags: override_tags
642
- )
643
-
644
- missing_lines = merge_result[:lines]
645
- reason_specs = merge_result[:reasons] || []
646
-
647
- sorted_existing_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
648
- info[:doc_lines],
649
- missing_lines: [],
650
- sort_tags: config.sort_tags?,
651
- tag_order: config.tag_order
652
- )
653
-
654
- merged_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
655
- info[:doc_lines],
656
- missing_lines: missing_lines,
657
- sort_tags: config.sort_tags?,
658
- tag_order: config.tag_order
659
- )
660
-
661
- existing_order_changed = sorted_existing_doc_lines != info[:doc_lines]
662
- new_block = (info[:preserved_lines] + merged_doc_lines).join
663
- old_block = info[:lines].join
664
-
665
- if new_block != old_block
666
- range = Parser::Source::Range.new(buffer, info[:start_pos], info[:end_pos])
667
- rewriter.replace(range, new_block)
668
-
669
- if existing_order_changed
670
- add_change(
671
- changes,
672
- type: :unsorted_tags,
673
- insertion: insertion,
674
- file: file,
675
- message: 'unsorted tags'
676
- )
677
- end
678
- end
679
-
680
- type_mismatch_reasons = reason_specs.select { |r| %i[updated_param updated_return].include?(r[:type]) }
681
-
682
- if new_block != old_block || type_mismatch_reasons.any?
683
- reason_specs.each do |reason|
684
- add_change(
685
- changes,
686
- type: reason[:type],
687
- insertion: insertion,
688
- file: file,
689
- message: reason[:message],
690
- extra: reason[:extra] || {}
691
- )
692
- end
693
- end
694
-
695
- return
696
- end
983
+ # Log changes for method doc updates.
984
+ #
985
+ # @private
986
+ # @param [Collector::Insertion] insertion
987
+ # @param [Hash] merge_result
988
+ # @param [String] new_block
989
+ # @param [String] old_block
990
+ # @param [Array<Hash>] changes
991
+ # @param [String] file
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]) }
697
997
 
698
- doc = build_method_doc(
699
- insertion,
700
- config: config,
701
- signature_provider: signature_provider,
702
- core_rbs_provider: core_rbs_provider,
703
- param_types: effective_param_types,
704
- return_type_override: override_return_type,
705
- override_tags: override_tags
706
- )
707
- return if doc.nil? || doc.empty?
708
-
709
- rewriter.insert_before(anchor_bol_range, doc)
710
-
711
- add_change(
712
- changes,
713
- type: :insert_full_doc_block,
714
- insertion: insertion,
715
- file: file,
716
- message: 'missing docs'
717
- )
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] || {})
718
1003
  end
719
1004
  end
720
1005
 
1006
+ # Apply method insertion in safe mode when no existing doc info is present.
1007
+ #
1008
+ # @private
1009
+ # @param [Hash] options keyword options
1010
+ # @return [void]
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
+
721
1031
  # Append a structured change record.
722
1032
  #
723
1033
  # @private
724
- # @param [Array<Hash>] changes
725
- # @param [Symbol] type
726
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
727
- # @param [String] file
728
- # @param [String] message
729
- # @param [Integer, nil] line
730
- # @param [Hash] extra
1034
+ # @param [Hash] options kwargs for change record (type, file, line, method, message, insertion, changes, extra)
731
1035
  # @return [void]
732
- def add_change(changes, type:, insertion:, file:, message:, line: nil, extra: {})
1036
+ def add_change(**options)
1037
+ changes = options[:changes]
733
1038
  changes << {
734
- type: type,
735
- file: file,
736
- line: line || method_line_for(insertion),
737
- method: method_id_for(insertion),
738
- message: message
739
- }.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] || {})
740
1045
  end
741
1046
 
742
1047
  # Build a printable method identifier from a collected insertion.
@@ -752,154 +1057,201 @@ module Docscribe
752
1057
  # Apply one attribute insertion according to the selected strategy.
753
1058
  #
754
1059
  # @private
755
- # @param [Parser::Source::TreeRewriter] rewriter
756
- # @param [Parser::Source::Buffer] buffer
757
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] insertion
758
- # @param [Docscribe::Config] config
759
- # @param [Object, nil] signature_provider
760
- # @param [Symbol] strategy
761
- # @param [Hash] merge_inserts
1060
+ # @param [Hash] options kwargs (insertion, config, rewriter, buffer, strategy, signature_provider, merge_inserts)
762
1061
  # @return [void]
763
- def apply_attr_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, merge_inserts:)
1062
+ def apply_attr_insertion!(**options)
1063
+ config = options[:config]
764
1064
  return unless config.respond_to?(:emit_attributes?) && config.emit_attributes?
765
- return unless attribute_allowed?(config, insertion)
1065
+ return unless attribute_allowed?(config, options[:insertion])
766
1066
 
767
- bol_range = SourceHelpers.line_start_range(buffer, insertion.node)
768
-
769
- case strategy
770
- when :aggressive
771
- if (range = SourceHelpers.comment_block_removal_range(buffer, bol_range.begin_pos))
772
- rewriter.remove(range)
773
- 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
774
1071
 
775
- doc = build_attr_doc_for_node(
776
- insertion,
777
- config: config,
778
- signature_provider: signature_provider
779
- )
780
- 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
781
1085
 
782
- 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
783
1097
 
784
- when :safe
785
- 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
786
1107
 
787
- if info
788
- additions = build_attr_merge_additions(
789
- insertion,
790
- existing_lines: info[:lines],
791
- config: config,
792
- signature_provider: signature_provider
793
- )
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?
794
1111
 
795
- if additions && !additions.empty?
796
- merge_inserts[info[:end_pos]] << [insertion.node.loc.expression.begin_pos, additions]
797
- end
1112
+ rewriter.insert_before(params[:bol_range], doc)
1113
+ end
798
1114
 
799
- return
800
- 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
801
1130
 
802
- doc = build_attr_doc_for_node(
803
- insertion,
804
- config: config,
805
- signature_provider: signature_provider
806
- )
807
- 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?
808
1134
 
809
- rewriter.insert_before(bol_range, doc)
810
- end
1135
+ rewriter.insert_before(params[:bol_range], doc)
811
1136
  end
812
1137
 
813
- # Apply aggregated merge inserts at shared end positions.
814
1138
  #
815
- # 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
+
816
1154
  #
817
1155
  # @private
818
- # @param [Parser::Source::TreeRewriter] rewriter
819
- # @param [Parser::Source::Buffer] buffer
820
- # @param [Hash{Integer=>Array<(Integer,String)>}] merge_inserts
821
- # @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]
822
1160
  def apply_merge_inserts!(rewriter:, buffer:, merge_inserts:)
823
- sep_re = /^\s*#\s*\r?\n$/
824
-
825
1161
  merge_inserts.keys.sort.reverse_each do |end_pos|
826
- chunks = merge_inserts[end_pos]
827
- next if chunks.empty?
1162
+ text = merge_text_for_pos(merge_inserts[end_pos])
1163
+ next if text.nil? || text.empty?
828
1164
 
829
- 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
830
1169
 
831
- 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?
832
1176
 
833
- chunks.each do |(_k, chunk)|
834
- 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$/
835
1180
 
836
- lines = chunk.lines
837
- seps = []
838
- seps << lines.shift while !lines.empty? && lines.first.match?(sep_re)
1181
+ chunks.each do |(_k, chunk)|
1182
+ next if chunk.nil? || chunk.empty?
839
1183
 
840
- sep = seps.first
841
- out_lines << sep if sep && (out_lines.empty? || !out_lines.last.match?(sep_re))
842
- out_lines.concat(lines)
843
- end
1184
+ merge_chunk_into_out(chunk, out_lines, sep_re)
1185
+ end
844
1186
 
845
- text = out_lines.join
846
- next if text.empty?
1187
+ text = out_lines.join
1188
+ text.empty? ? nil : text
1189
+ end
847
1190
 
848
- range = Parser::Source::Range.new(buffer, end_pos, end_pos)
849
- rewriter.insert_before(range, text)
850
- 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
851
1214
  end
852
1215
 
853
- # Build plain-text merge additions for an attribute doc block.
854
1216
  #
855
1217
  # @private
856
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
857
- # @param [Array<String>] existing_lines
858
- # @param [Docscribe::Config] config
859
- # @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
860
1222
  # @raise [StandardError]
861
- # @return [String, nil]
862
- def build_attr_merge_additions(ins, existing_lines:, config:, signature_provider:)
863
- indent = SourceHelpers.line_indent(ins.node)
864
- param_tag_style = config.param_tag_style
865
- existing = existing_attr_names(existing_lines)
866
- 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)
867
1227
  return '' if missing.empty?
868
1228
 
869
- lines = []
1229
+ indent = SourceHelpers.line_indent(ins.node)
1230
+ lines = [] #: Array[String]
870
1231
  lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
871
-
872
- missing.each_with_index do |name_sym, idx|
873
- attr_name = name_sym.to_s
874
- mode = ins.access.to_s
875
- attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
876
-
877
- lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
878
-
879
- if config.emit_visibility_tags?
880
- lines << "#{indent}# @private" if ins.visibility == :private
881
- lines << "#{indent}# @protected" if ins.visibility == :protected
882
- end
883
-
884
- lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
885
- if %i[w rw].include?(ins.access)
886
- lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
887
- end
888
- lines << "#{indent}#" if idx < missing.length - 1
889
- end
890
-
1232
+ lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1233
+ signature_provider: signature_provider, names: missing))
891
1234
  lines.map { |l| "#{l}\n" }.join
892
1235
  rescue StandardError
893
1236
  nil
894
1237
  end
895
1238
 
896
- # Extract already documented attribute names from existing `@!attribute` lines.
897
1239
  #
898
1240
  # @private
899
- # @param [Array<String>] lines
900
- # @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]
901
1253
  def existing_attr_names(lines)
902
- names = {}
1254
+ names = {} #: Hash[String, bool]
903
1255
 
904
1256
  Array(lines).each do |line|
905
1257
  if (m = line.match(/^\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+(\S+)/))
@@ -918,66 +1270,129 @@ module Docscribe
918
1270
  # @return [Boolean]
919
1271
  def attribute_allowed?(config, ins)
920
1272
  ins.names.any? do |name_sym|
921
- ok = false
922
-
923
- if %i[r rw].include?(ins.access)
924
- ok ||= config.process_method?(
925
- container: ins.container,
926
- scope: ins.scope,
927
- visibility: ins.visibility,
928
- name: name_sym
929
- )
930
- end
1273
+ allowed_for_access?(config, ins, name_sym)
1274
+ end
1275
+ end
931
1276
 
932
- if %i[w rw].include?(ins.access)
933
- ok ||= config.process_method?(
934
- container: ins.container,
935
- scope: ins.scope,
936
- visibility: ins.visibility,
937
- name: :"#{name_sym}="
938
- )
939
- 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
940
1290
 
941
- 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}=")
942
1294
  end
1295
+
1296
+ ok
943
1297
  end
944
1298
 
945
- # Build a full `@!attribute` documentation block for one attribute insertion.
946
1299
  #
947
1300
  # @private
948
- # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
949
- # @param [Docscribe::Config] config
950
- # @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
951
1304
  # @raise [StandardError]
952
- # @return [String, nil]
1305
+ # @return [Object]
1306
+ # @return [nil] if StandardError
953
1307
  def build_attr_doc_for_node(ins, config:, signature_provider:)
954
1308
  indent = SourceHelpers.line_indent(ins.node)
955
- param_tag_style = config.param_tag_style
956
- 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
957
1314
 
958
- ins.names.each_with_index do |name_sym, idx|
959
- attr_name = name_sym.to_s
960
- mode = ins.access.to_s
961
- 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
962
1332
 
963
- lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
1333
+ lines
1334
+ end
964
1335
 
965
- if config.emit_visibility_tags?
966
- lines << "#{indent}# @private" if ins.visibility == :private
967
- lines << "#{indent}# @protected" if ins.visibility == :protected
968
- 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
969
1357
 
970
- lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
971
- if %i[w rw].include?(ins.access)
972
- lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
973
- 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
974
1368
 
975
- lines << "#{indent}#" if idx < ins.names.length - 1
976
- 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
977
1382
 
978
- lines.map { |l| "#{l}\n" }.join
979
- rescue StandardError
980
- 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
981
1396
  end
982
1397
 
983
1398
  # Format an attribute `@param` tag line using the configured param tag style.
@@ -1007,7 +1422,7 @@ module Docscribe
1007
1422
  # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
1008
1423
  # @param [Symbol] name_sym
1009
1424
  # @param [Docscribe::Config] config
1010
- # @param [Object] signature_provider Param documentation.
1425
+ # @param [Object] signature_provider RBS signature provider
1011
1426
  # @raise [StandardError]
1012
1427
  # @return [String]
1013
1428
  def attribute_type(ins, name_sym, config, signature_provider:)
@@ -1046,24 +1461,10 @@ module Docscribe
1046
1461
  #
1047
1462
  # @private
1048
1463
  # @param [Collector::Insertion] insertion the collected method insertion
1049
- # @param [Docscribe::Config] config the active configuration
1050
- # @param [Object, nil] signature_provider external signature provider
1051
- # @param [Object, nil] core_rbs_provider RBS core type provider
1052
- # @param [Hash, nil] param_types parameter name -> type map
1053
- # @param [Object] return_type_override Param documentation.
1054
- # @param [Object] override_tags Param documentation.
1464
+ # @param [Hash] options kwargs for DocBuilder.build (param_types, return_type_override, override_tags, config)
1055
1465
  # @return [String, nil] generated doc block or nil
1056
- def build_method_doc(insertion, config:, signature_provider:, core_rbs_provider:, param_types:, return_type_override:,
1057
- override_tags:)
1058
- DocBuilder.build(
1059
- insertion,
1060
- config: config,
1061
- signature_provider: signature_provider,
1062
- core_rbs_provider: core_rbs_provider,
1063
- param_types: param_types,
1064
- return_type_override: return_type_override,
1065
- override_tags: override_tags
1066
- )
1466
+ def build_method_doc(insertion, **options)
1467
+ DocBuilder.build(insertion, **options)
1067
1468
  end
1068
1469
 
1069
1470
  # Delegate to DocBuilder.build_missing_merge_result for generating missing doc lines only.
@@ -1071,27 +1472,10 @@ module Docscribe
1071
1472
  # @private
1072
1473
  # @param [Collector::Insertion] insertion the collected method insertion
1073
1474
  # @param [Array<String>] existing_lines existing doc-like lines
1074
- # @param [Docscribe::Config] config the active configuration
1075
- # @param [Object, nil] signature_provider external signature provider
1076
- # @param [Object, nil] core_rbs_provider RBS core type provider
1077
- # @param [Hash, nil] param_types parameter name -> type map
1078
- # @param [Object] strategy Param documentation.
1079
- # @param [Object] return_type_override Param documentation.
1080
- # @param [nil] override_tags Param documentation.
1475
+ # @param [Hash] options keyword arguments forwarded to DocBuilder.build_missing_merge_result
1081
1476
  # @return [Hash] result with `:lines` and `:reasons` keys
1082
- def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:,
1083
- core_rbs_provider:, param_types:, strategy:, return_type_override:, override_tags: nil)
1084
- DocBuilder.build_missing_merge_result(
1085
- insertion,
1086
- existing_lines: existing_lines,
1087
- config: config,
1088
- signature_provider: signature_provider,
1089
- core_rbs_provider: core_rbs_provider,
1090
- param_types: param_types,
1091
- strategy: strategy,
1092
- return_type_override: return_type_override,
1093
- override_tags: override_tags
1094
- )
1477
+ def build_missing_method_merge_result(insertion, existing_lines:, **options)
1478
+ DocBuilder.build_missing_merge_result(insertion, existing_lines: existing_lines, **options)
1095
1479
  end
1096
1480
 
1097
1481
  # Get doc comment block info (preceding comments) for a method insertion.
@@ -1158,6 +1542,36 @@ module Docscribe
1158
1542
  insertion.node
1159
1543
  end
1160
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
1161
1575
  end
1162
1576
  end
1163
1577
  end