docscribe 1.4.1 → 1.5.0

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