docscribe 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +149 -0
  3. data/lib/docscribe/cli/config_builder.rb +125 -35
  4. data/lib/docscribe/cli/generate.rb +288 -117
  5. data/lib/docscribe/cli/init.rb +49 -13
  6. data/lib/docscribe/cli/options.rb +302 -127
  7. data/lib/docscribe/cli/run.rb +391 -135
  8. data/lib/docscribe/cli.rb +23 -5
  9. data/lib/docscribe/config/defaults.rb +11 -11
  10. data/lib/docscribe/config/emit.rb +1 -0
  11. data/lib/docscribe/config/filtering.rb +24 -11
  12. data/lib/docscribe/config/loader.rb +7 -4
  13. data/lib/docscribe/config/plugin.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +31 -22
  15. data/lib/docscribe/config/sorbet.rb +41 -15
  16. data/lib/docscribe/config/sorting.rb +1 -0
  17. data/lib/docscribe/config/template.rb +1 -0
  18. data/lib/docscribe/config/utils.rb +1 -0
  19. data/lib/docscribe/config.rb +1 -0
  20. data/lib/docscribe/infer/constants.rb +15 -0
  21. data/lib/docscribe/infer/literals.rb +43 -25
  22. data/lib/docscribe/infer/names.rb +24 -15
  23. data/lib/docscribe/infer/params.rb +52 -6
  24. data/lib/docscribe/infer/raises.rb +24 -14
  25. data/lib/docscribe/infer/returns.rb +365 -182
  26. data/lib/docscribe/infer.rb +10 -9
  27. data/lib/docscribe/inline_rewriter/collector.rb +766 -375
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +217 -74
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +1488 -602
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +100 -52
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +109 -48
  32. data/lib/docscribe/inline_rewriter.rb +1009 -595
  33. data/lib/docscribe/plugin/base/collector_plugin.rb +2 -3
  34. data/lib/docscribe/plugin/base/tag_plugin.rb +1 -1
  35. data/lib/docscribe/plugin/registry.rb +34 -7
  36. data/lib/docscribe/plugin.rb +48 -17
  37. data/lib/docscribe/types/rbs/collection_loader.rb +0 -1
  38. data/lib/docscribe/types/rbs/provider.rb +75 -26
  39. data/lib/docscribe/types/rbs/type_formatter.rb +127 -59
  40. data/lib/docscribe/types/sorbet/base_provider.rb +31 -12
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +2 -2
@@ -18,6 +18,73 @@ module Docscribe
18
18
  module DocBuilder
19
19
  module_function
20
20
 
21
+ PARAM_TYPE_COLLECTORS = {
22
+ arg: lambda { |arg_node, param_types, external_sig, config|
23
+ collect_param_type(
24
+ arg_node,
25
+ param_types,
26
+ external_sig,
27
+ config,
28
+ infer_name: nil
29
+ )
30
+ },
31
+
32
+ optarg: lambda { |arg_node, param_types, external_sig, config|
33
+ collect_optarg_param_type(
34
+ arg_node,
35
+ param_types,
36
+ external_sig,
37
+ config,
38
+ infer_name: nil
39
+ )
40
+ },
41
+
42
+ kwarg: lambda { |arg_node, param_types, external_sig, config|
43
+ collect_param_type(
44
+ arg_node,
45
+ param_types,
46
+ external_sig,
47
+ config,
48
+ infer_name: ->(param_name) { "#{param_name}:" }
49
+ )
50
+ },
51
+
52
+ kwoptarg: lambda { |arg_node, param_types, external_sig, config|
53
+ collect_optarg_param_type(
54
+ arg_node,
55
+ param_types,
56
+ external_sig,
57
+ config,
58
+ infer_name: ->(param_name) { "#{param_name}:" }
59
+ )
60
+ }
61
+ }.freeze
62
+
63
+ PARAM_BUILDERS = {
64
+ arg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
65
+ [build_arg_line(arg_node, indent, external_sig, param_types_override, **opts)]
66
+ },
67
+ optarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
68
+ build_optarg_lines(arg_node, indent, external_sig, param_types_override, **opts)
69
+ },
70
+ kwarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
71
+ [build_kwarg_line(arg_node, indent, external_sig, param_types_override, **opts)]
72
+ },
73
+ kwoptarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
74
+ [build_kwoptarg_line(arg_node, indent, external_sig, param_types_override, **opts)]
75
+ },
76
+ restarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
77
+ [build_restarg_line(arg_node, indent, external_sig, param_types_override, **opts)]
78
+ },
79
+ kwrestarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
80
+ [build_kwrestarg_line(arg_node, indent, external_sig, param_types_override, **opts)]
81
+ },
82
+ blockarg: lambda { |arg_node, indent, external_sig, param_types_override, **opts|
83
+ [build_blockarg_line(arg_node, indent, external_sig, param_types_override, **opts)]
84
+ },
85
+ forward_arg: ->(*) { [] } #: Array[String]
86
+ }.freeze
87
+
21
88
  # Build a complete doc block for one collected method insertion.
22
89
  #
23
90
  # External signatures, when available, override inferred param and return
@@ -26,662 +93,1279 @@ module Docscribe
26
93
  # @note module_function: when included, also defines #build (instance visibility: private)
27
94
  # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
28
95
  # @param [Docscribe::Config] config
29
- # @param [Object, nil] signature_provider provider responding to
30
- # `signature_for(container:, scope:, name:)`
31
- # @param [nil] core_rbs_provider Param documentation.
32
- # @param [nil] param_types Param documentation.
33
- # @param [nil] return_type_override Param documentation.
34
- # @param [nil] override_tags Param documentation.
96
+ # @param [Hash] opts additional keyword options forwarded to doc_setup
35
97
  # @raise [StandardError]
36
98
  # @return [String, nil]
37
- def build(insertion, config:, signature_provider: nil, core_rbs_provider: nil, param_types: nil, return_type_override: nil, override_tags: nil)
99
+ def build(insertion, config:, **opts)
100
+ setup = doc_setup(insertion, config: config, **opts)
101
+ return nil unless setup
102
+
103
+ build_unsafe(insertion, config: config, setup: setup, **opts)
104
+ rescue StandardError => e
105
+ debug_warn(e, insertion: insertion, name: '(unknown)', phase: 'DocBuilder.build')
106
+ nil
107
+ end
108
+
109
+ # Build only the missing doc lines that should be merged into an existing
110
+ # doc-like block.
111
+ #
112
+ # This is used by safe mode for non-destructive updates.
113
+ #
114
+ # @note module_function: when included, also defines #build_merge_additions (instance visibility: private)
115
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
116
+ # @param [Array<String>] existing_lines
117
+ # @param [Docscribe::Config] config
118
+ # @param [Hash] options additional keyword options forwarded to downstream methods
119
+ # @raise [StandardError]
120
+ # @return [String, nil]
121
+ def build_merge_additions(insertion, existing_lines:, config:, **options)
122
+ setup = doc_setup(insertion, config: config, **options)
123
+ return '' unless setup
124
+
125
+ info = parse_existing_doc_tags(existing_lines)
126
+ merge_dest_lines(existing_lines, setup: setup, insertion: insertion, config: config, info: info,
127
+ param_types: options[:param_types])
128
+ rescue StandardError => e
129
+ debug_warn(e, insertion: insertion, name: setup&.dig(:name) || '(unknown)',
130
+ phase: 'DocBuilder.build_merge_additions')
131
+ nil
132
+ end
133
+
134
+ # Build structured missing-line information for safe merge mode.
135
+ #
136
+ # Returns both:
137
+ # - generated missing lines
138
+ # - structured reasons used by `--explain`
139
+ #
140
+ # @note module_function: when included, also defines #build_missing_merge_result (instance visibility: private)
141
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
142
+ # @param [Array<String>] existing_lines
143
+ # @param [Docscribe::Config] config
144
+ # @param [Hash] options additional keyword options forwarded to downstream methods
145
+ # @raise [StandardError]
146
+ # @return [Hash]
147
+ def build_missing_merge_result(insertion, existing_lines:, config:, **options)
148
+ setup = doc_setup(insertion, config: config, **options)
149
+ return { lines: [], reasons: [] } unless setup
150
+
151
+ info = parse_existing_doc_tags(existing_lines)
152
+ collect_all_missing(setup, info, insertion, config, options)
153
+ rescue StandardError => e
154
+ debug_warn(e, insertion: insertion, name: setup&.dig(:name) || '(unknown)',
155
+ phase: 'DocBuilder.build_missing_merge_result')
156
+ { lines: [], reasons: [] }
157
+ end
158
+
159
+ # Shared document setup extraction for all build methods.
160
+ #
161
+ # @note module_function: when included, also defines #doc_setup (instance visibility: private)
162
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
163
+ # @param [Docscribe::Config] config
164
+ # @param [Hash] opts additional options
165
+ # @return [Hash, nil]
166
+ def doc_setup(insertion, config:, **opts)
38
167
  node = insertion.node
39
168
  name = SourceHelpers.node_name(node)
40
169
  return nil unless name
41
170
 
42
- indent = SourceHelpers.line_indent(node)
43
- scope = insertion.scope
44
- visibility = insertion.visibility
45
- container = insertion.container
46
- method_symbol = scope == :instance ? '#' : '.'
171
+ setup = extract_base_setup(insertion, name)
172
+ resolve_doc_setup!(setup, node, name, config, opts)
173
+ end
47
174
 
48
- external_sig = signature_provider&.signature_for(
49
- container: container,
50
- scope: scope,
51
- name: name
52
- )
175
+ # Build without rescue wrapping (extracted for metric reduction).
176
+ # @note module_function: when included, also defines # (instance visibility: private)
177
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
178
+ # @param [Docscribe::Config] config
179
+ # @param [Hash] setup
180
+ # @param [Hash] opts
181
+ # @return [String, nil]
182
+ def build_unsafe(insertion, config:, setup:, **opts)
183
+ _, pl, rt = build_param_and_raise_info(setup, config, opts)
184
+ lines = build_doc_lines(setup, config: config, insertion: insertion, params_lines: pl, raise_types: rt,
185
+ override_tags: opts[:override_tags])
186
+ lines.map { |l| "#{l}\n" }.join
187
+ end
53
188
 
54
- effective_param_types =
55
- param_types || build_param_types_from_node(node, external_sig: external_sig, config: config)
189
+ # Build param types, param lines, and raise types for doc block.
190
+ # @note module_function: when included, also defines # (instance visibility: private)
191
+ # @param [Hash] setup
192
+ # @param [Docscribe::Config] config
193
+ # @param [Hash] opts
194
+ # @return [Array]
195
+ def build_param_and_raise_info(setup, config, opts)
196
+ pt = opts[:param_types] || build_param_types_from_node(setup[:node], external_sig: setup[:external_sig],
197
+ config: config)
198
+ pl = if config.emit_param_tags?
199
+ build_params_lines(setup[:node], setup[:indent], external_sig: setup[:external_sig], config: config,
200
+ param_types_override: pt)
201
+ end
202
+ rt = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(setup[:node]) : [] #: Array[String]
203
+ [pt, pl, rt]
204
+ end
56
205
 
57
- if config.emit_param_tags?
58
- params_lines = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: effective_param_types)
59
- end
60
- raise_types = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(node) : []
61
-
62
- returns_spec = Docscribe::Infer.returns_spec_from_node(
63
- node,
64
- fallback_type: config.fallback_type,
65
- nil_as_optional: config.nil_as_optional?,
66
- param_types: effective_param_types,
67
- core_rbs_provider: core_rbs_provider
206
+ # Resolve external signature, returns spec, and normal type for doc setup.
207
+ # @note module_function: when included, also defines # (instance visibility: private)
208
+ # @param [Hash] setup
209
+ # @param [Parser::AST::Node] node
210
+ # @param [String] name
211
+ # @param [Docscribe::Config] config
212
+ # @param [Hash] opts
213
+ # @return [Hash]
214
+ def resolve_doc_setup!(setup, node, name, config, opts)
215
+ external_sig = resolve_external_sig(setup[:container], setup[:scope], name, opts[:signature_provider])
216
+ returns_spec = compute_returns_spec(node, config, opts[:param_types], opts[:core_rbs_provider])
217
+ normal_type = opts[:return_type_override] || external_sig&.return_type || returns_spec[:normal]
218
+
219
+ setup.merge(
220
+ external_sig: external_sig,
221
+ normal_type: normal_type,
222
+ rescue_specs: returns_spec[:rescues] || []
68
223
  )
224
+ end
69
225
 
70
- normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
71
- rescue_specs = returns_spec[:rescues] || []
226
+ # Extract base node metadata.
227
+ #
228
+ # @note module_function: when included, also defines #extract_base_setup (instance visibility: private)
229
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
230
+ # @param [String] name
231
+ # @return [Hash]
232
+ def extract_base_setup(insertion, name)
233
+ n = insertion.node
234
+ { node: n, name: name, indent: SourceHelpers.line_indent(n), scope: insertion.scope,
235
+ visibility: insertion.visibility, container: insertion.container,
236
+ method_symbol: insertion.scope == :instance ? '#' : '.' }
237
+ end
72
238
 
73
- lines = []
239
+ # Resolve external signature.
240
+ #
241
+ # @note module_function: when included, also defines #resolve_external_sig (instance visibility: private)
242
+ # @param [String] container
243
+ # @param [Symbol] scope
244
+ # @param [String] name
245
+ # @param [Object, nil] signature_provider
246
+ # @return [Object, nil]
247
+ def resolve_external_sig(container, scope, name, signature_provider)
248
+ signature_provider&.signature_for(container: container, scope: scope, name: name)
249
+ end
74
250
 
75
- if config.emit_header?
76
- lines << "#{indent}# +#{container}#{method_symbol}#{name}+ -> #{normal_type}"
77
- lines << "#{indent}#"
78
- end
251
+ # Compute returns_spec from node.
252
+ #
253
+ # @note module_function: when included, also defines #compute_returns_spec (instance visibility: private)
254
+ # @param [Parser::AST::Node] node
255
+ # @param [Docscribe::Config] config
256
+ # @param [Hash, nil] param_types
257
+ # @param [Object, nil] core_rbs_provider
258
+ # @return [Hash]
259
+ def compute_returns_spec(node, config, param_types, core_rbs_provider)
260
+ Docscribe::Infer.returns_spec_from_node(
261
+ node, fallback_type: config.fallback_type, nil_as_optional: config.nil_as_optional?,
262
+ param_types: param_types, core_rbs_provider: core_rbs_provider
263
+ )
264
+ end
79
265
 
80
- if config.include_default_message?
81
- lines << "#{indent}# #{config.default_message(scope, visibility)}"
82
- lines << "#{indent}#"
266
+ # Parse existing doc comment lines and extract known YARD tags.
267
+ #
268
+ # Extracts: `@param` names, `@return`, `@raise`, `@private`, `@protected`,
269
+ # `@module_function` notes, and `@option` lines.
270
+ #
271
+ # @note module_function: when included, also defines #parse_existing_doc_tags (instance visibility: private)
272
+ # @param [Array<String>] lines existing doc comment lines
273
+ # @return [Hash] parsed tag info
274
+ def parse_existing_doc_tags(lines)
275
+ init = init_parse_info
276
+ Array(lines).each_with_object(init) do |line, info|
277
+ extract_param_info(line, info[:param_names], info[:param_types])
278
+ extract_return_info(line, info)
279
+ extract_visibility_info(line, info)
280
+ extract_raise_info(line, info[:raise_types])
281
+ extract_plugin_info(line, info[:plugin_tags])
83
282
  end
283
+ end
84
284
 
85
- if config.emit_visibility_tags?
86
- case visibility
87
- when :private
88
- lines << "#{indent}# @private"
89
- when :protected
90
- lines << "#{indent}# @protected"
91
- end
92
- end
285
+ # Initialize an empty parse info hash.
286
+ #
287
+ # @note module_function: when included, also defines #init_parse_info (instance visibility: private)
288
+ # @return [Hash]
289
+ def init_parse_info
290
+ {
291
+ param_names: {}, param_types: {}, raise_types: {}, plugin_tags: {},
292
+ has_return: false, return_type: nil,
293
+ has_private: false, has_protected: false, has_module_function_note: false
294
+ }
295
+ end
93
296
 
94
- if insertion.respond_to?(:module_function) && insertion.module_function
95
- included_vis =
96
- if insertion.respond_to?(:included_instance_visibility) && insertion.included_instance_visibility
97
- insertion.included_instance_visibility
98
- else
99
- :private
100
- end
297
+ # Build merged destination lines for safe merge mode.
298
+ # Wrapper that delegates to merge_lines_with_context.
299
+ #
300
+ # @note module_function: when included, also defines #merge_dest_lines (instance visibility: private)
301
+ # @param [Object] existing_lines existing doc comment lines to merge into
302
+ # @param [Hash] ctx merge context hash (setup, insertion, config, info, param_types)
303
+ # @return [Object]
304
+ def merge_dest_lines(existing_lines, **ctx)
305
+ merge_lines_with_context(existing_lines, **ctx)
306
+ end
101
307
 
102
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
103
- "(instance visibility: #{included_vis})"
104
- end
308
+ # Merge dest lines using a context hash (extracted for metric reduction).
309
+ #
310
+ # @note module_function: when included, also defines #merge_lines_with_context (instance visibility: private)
311
+ # @param [Object] existing_lines existing doc comment lines being merged
312
+ # @param [Hash] ctx merge context (setup, insertion, config, info, param_types)
313
+ # @return [Object]
314
+ def merge_lines_with_context(existing_lines, **ctx)
315
+ s = ctx[:setup]
316
+ i = s[:indent]
317
+ config = ctx[:config]
318
+ info = ctx[:info]
319
+ base_ary = build_initial_line_ary(existing_lines, i)
320
+ line_ary = merge_all_tag_lines(base_ary, s: s, i: i, config: config, info: info,
321
+ insertion: ctx[:insertion], param_types: ctx[:param_types])
322
+ useful = line_ary.reject { |l| l.strip == '#' }
323
+ return '' if useful.empty?
105
324
 
106
- lines.concat(params_lines) if params_lines
107
- raise_types.each { |rt| lines << "#{indent}# @raise [#{rt}]" } if config.emit_raise_tags?
108
- lines << "#{indent}# @return [#{normal_type}]" if config.emit_return_tag?(scope, visibility)
325
+ line_ary.map { |l| "#{l}\n" }.join
326
+ end
109
327
 
110
- if config.emit_rescue_conditional_returns?
111
- rescue_specs.each do |exceptions, rtype|
112
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
113
- end
114
- end
115
- plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(insertion, normal_type: normal_type))
116
- plugin_tags.concat(Array(override_tags)) if override_tags
328
+ # Build initial line array for merge dest lines.
329
+ #
330
+ # @note module_function: when included, also defines #build_initial_line_ary (instance visibility: private)
331
+ # @param [Array<String>] existing_lines
332
+ # @param [String] indent
333
+ # @return [Array<String>]
334
+ def build_initial_line_ary(existing_lines, indent)
335
+ existing_lines.any? && existing_lines.last.strip != '#' ? ["#{indent}#"] : []
336
+ end
117
337
 
118
- lines.concat(render_plugin_tags(plugin_tags, indent))
119
- lines.map { |l| "#{l}\n" }.join
120
- rescue StandardError => e
121
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build')
122
- nil
338
+ # Merge all tag lines into a line array.
339
+ #
340
+ # @note module_function: when included, also defines #merge_all_tag_lines (instance visibility: private)
341
+ # @param [Array<String>] base_ary
342
+ # @param [Hash] ctx context hash with setup, config, info, insertion, param_types
343
+ # @return [Array<String>]
344
+ def merge_all_tag_lines(base_ary, **ctx)
345
+ line_ary = base_ary.dup
346
+ merge_tag_lines_core(line_ary, ctx)
347
+ line_ary.concat(merge_rescue_return_lines(ctx[:i], ctx[:s][:rescue_specs], ctx[:config], ctx[:info]))
348
+ line_ary
123
349
  end
124
350
 
125
- # Build only the missing doc lines that should be merged into an existing
126
- # doc-like block.
351
+ # Core tag line merging.
127
352
  #
128
- # This is used by safe mode for non-destructive updates.
353
+ # @note module_function: when included, also defines #merge_tag_lines_core (instance visibility: private)
354
+ # @param [Array<String>] line_ary
355
+ # @param [Hash] ctx
356
+ # @return [void]
357
+ def merge_tag_lines_core(line_ary, ctx)
358
+ append_merge_tag_lines(line_ary, ctx)
359
+ merge_return_line(line_ary, ctx[:i], ctx[:s], ctx[:config], ctx[:info])
360
+ end
361
+
362
+ # Append merge tag lines into line_ary.
129
363
  #
130
- # @note module_function: when included, also defines #build_merge_additions (instance visibility: private)
131
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
132
- # @param [Array<String>] existing_lines
133
- # @param [Docscribe::Config] config
134
- # @param [Object, nil] signature_provider
135
- # @param [nil] core_rbs_provider Param documentation.
136
- # @param [nil] param_types Param documentation.
137
- # @param [nil] return_type_override Param documentation.
138
- # @raise [StandardError]
139
- # @return [String, nil]
140
- def build_merge_additions(insertion, existing_lines:, config:, signature_provider: nil, core_rbs_provider: nil,
141
- param_types: nil, return_type_override: nil)
142
- node = insertion.node
143
- name = SourceHelpers.node_name(node)
144
- return '' unless name
364
+ # @note module_function: when included, also defines #append_merge_tag_lines (instance visibility: private)
365
+ # @param [Array<String>] line_ary
366
+ # @param [Hash] ctx
367
+ # @return [void]
368
+ def append_merge_tag_lines(line_ary, ctx)
369
+ line_ary.concat(build_all_merge_tags(ctx))
370
+ end
145
371
 
146
- indent = SourceHelpers.line_indent(node)
147
- info = parse_existing_doc_tags(existing_lines)
148
- scope = insertion.scope
149
- visibility = insertion.visibility
372
+ # Build an array of all merge tag lines.
373
+ #
374
+ # @note module_function: when included, also defines #build_all_merge_tags (instance visibility: private)
375
+ # @param [Hash] ctx
376
+ # @return [Array<String>]
377
+ def build_all_merge_tags(ctx)
378
+ i = ctx[:i]
379
+ s = ctx[:s]
380
+ c = ctx[:config]
381
+ info = ctx[:info]
382
+ [merge_visibility_tag_lines(i, s[:visibility], c, info),
383
+ merge_module_function_note_lines(i, ctx[:insertion], s[:name], info),
384
+ merge_param_lines(s[:node], i, config: c, external_sig: s[:external_sig],
385
+ param_types: ctx[:param_types], info: info),
386
+ merge_raise_tag_lines(s[:node], i, c, info)].flatten
387
+ end
150
388
 
151
- external_sig = signature_provider&.signature_for(
152
- container: insertion.container,
153
- scope: scope,
154
- name: name
155
- )
389
+ # Merge return tag line into line_ary.
390
+ #
391
+ # @note module_function: when included, also defines #merge_return_line (instance visibility: private)
392
+ # @param [Array<String>] line_ary
393
+ # @param [Object] config
394
+ # @param [Object] info
395
+ # @param [Object] indent indentation string for doc comment lines
396
+ # @param [Object] setup method setup hash with node, name, types, scope
397
+ # @return [void]
398
+ def merge_return_line(line_ary, indent, setup, config, info)
399
+ emit_ret = config.emit_return_tag?(setup[:scope], setup[:visibility])
400
+ ret_line = merge_return_tag_line(indent, setup[:normal_type], config: config, scope: setup[:scope],
401
+ visibility: setup[:visibility], info: info)
156
402
 
157
- returns_spec = Docscribe::Infer.returns_spec_from_node(
158
- node,
159
- fallback_type: config.fallback_type,
160
- nil_as_optional: config.nil_as_optional?,
161
- param_types: param_types,
162
- core_rbs_provider: core_rbs_provider
163
- )
403
+ line_ary << ret_line if emit_ret && ret_line
404
+ end
164
405
 
165
- normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
166
- rescue_specs = returns_spec[:rescues] || []
406
+ # Collect all missing doc elements and return { lines:, reasons: }.
407
+ # Delegates to collect_missing_all with a merged context hash.
408
+ #
409
+ # @note module_function: when included, also defines #collect_all_missing (instance visibility: private)
410
+ # @param [Object] setup resolved setup hash with node, name, indent, types
411
+ # @param [Object] info parsed existing doc tag information
412
+ # @param [Object] insertion the collected method insertion object
413
+ # @param [Object] config Docscribe configuration object
414
+ # @param [Object] options additional options hash forwarded to missing collector
415
+ # @return [Hash]
416
+ def collect_all_missing(setup, info, insertion, config, options)
417
+ s = setup
418
+ ctx = { node: s[:node], indent: s[:indent], config: config, external_sig: s[:external_sig],
419
+ info: info, strategy: options[:strategy], scope: s[:scope], visibility: s[:visibility],
420
+ normal_type: s[:normal_type], rescue_specs: s[:rescue_specs], insertion: insertion,
421
+ param_types: options[:param_types], override_tags: options[:override_tags] }
422
+ collect_missing_all(ctx)
423
+ end
167
424
 
168
- lines = []
169
- lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
425
+ # Collect all missing elements via context hash.
426
+ #
427
+ # @note module_function: when included, also defines #collect_missing_all (instance visibility: private)
428
+ # @param [Hash] ctx
429
+ # @return [Hash]
430
+ def collect_missing_all(ctx)
431
+ lines = [] #: Array[String]
432
+ reasons = [] #: Array[Hash]
433
+ collect_missing_visibility!(lines, reasons, **ctx)
434
+ collect_missing_module_function_note!(lines, reasons, **ctx)
435
+ collect_missing_params!(lines, reasons, **ctx)
436
+ collect_missing_raises!(lines, reasons, **ctx)
437
+ collect_missing_return!(lines, reasons, **ctx)
438
+ collect_missing_rescue_returns!(lines, reasons, **ctx)
439
+ collect_missing_plugin_tags!(lines, reasons, **ctx)
440
+ { lines: lines, reasons: reasons }
441
+ end
170
442
 
171
- if config.emit_visibility_tags?
172
- if visibility == :private && !info[:has_private]
173
- lines << "#{indent}# @private"
174
- elsif visibility == :protected && !info[:has_protected]
175
- lines << "#{indent}# @protected"
176
- end
443
+ # Extract param info from a doc line.
444
+ # Parses @param lines and populates param_names and param_types hashes.
445
+ #
446
+ # @note module_function: when included, also defines #extract_param_info (instance visibility: private)
447
+ # @param [Object] line a single doc comment line to parse
448
+ # @param [Object] param_names hash tracking existing @param names
449
+ # @param [Object] param_types hash tracking existing @param types
450
+ # @return [Object]
451
+ def extract_param_info(line, param_names, param_types)
452
+ return unless (pname = extract_param_name_from_param_line(line))
453
+
454
+ param_names[pname] = true
455
+ unless (type_match = line.match(/@param\s+\[([^\]]+)\]\s+\S+/) || line.match(/@param\s+\S+\s+\[([^\]]+)\]/))
456
+ return
177
457
  end
178
458
 
179
- if insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
180
- included_vis = insertion.included_instance_visibility || :private
181
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
182
- "(instance visibility: #{included_vis})"
459
+ param_types[pname] = type_match[1] || 'untyped'
460
+ end
461
+
462
+ # Extract return info from a doc line.
463
+ # Detects @return tags and records type and presence in info hash.
464
+ #
465
+ # @note module_function: when included, also defines #extract_return_info (instance visibility: private)
466
+ # @param [Object] line a single doc comment line to parse
467
+ # @param [Object] info parse info hash to update with return data
468
+ # @return [Object]
469
+ def extract_return_info(line, info)
470
+ return unless line.match?(/^\s*#\s*@return\b/)
471
+
472
+ info[:has_return] = true
473
+ return unless (m = line.match(/@return\s+\[([^\]]+)\]/))
474
+
475
+ info[:return_type] = m[1]
476
+ end
477
+
478
+ # Extract visibility info from a doc line.
479
+ # Detects @private, @protected, and @note module_function tags.
480
+ #
481
+ # @note module_function: when included, also defines #extract_visibility_info (instance visibility: private)
482
+ # @param [Object] line a single doc comment line to parse
483
+ # @param [Object] info parse info hash to update with visibility flags
484
+ # @return [Object]
485
+ def extract_visibility_info(line, info)
486
+ info[:has_private] ||= line.match?(/^\s*#\s*@private\b/)
487
+ info[:has_protected] ||= line.match?(/^\s*#\s*@protected\b/)
488
+ info[:has_module_function_note] ||= line.match?(/^\s*#\s*@note\s+module_function:/)
489
+ end
490
+
491
+ # Extract raise info from a doc line.
492
+ # Parses @raise tags and records exception types in raise_types hash.
493
+ #
494
+ # @note module_function: when included, also defines #extract_raise_info (instance visibility: private)
495
+ # @param [Object] line a single doc comment line to parse
496
+ # @param [Object] raise_types hash tracking existing @raise types
497
+ # @return [Object]
498
+ def extract_raise_info(line, raise_types)
499
+ extract_raise_types_from_line(line).each { |t| raise_types[t || ''] = true }
500
+ end
501
+
502
+ # Extract plugin tag info from a doc line.
503
+ # Captures any @tag_name from the line into the plugin_tags hash.
504
+ #
505
+ # @note module_function: when included, also defines #extract_plugin_info (instance visibility: private)
506
+ # @param [Object] line a single doc comment line to parse
507
+ # @param [Object] plugin_tags hash tracking existing plugin tag names
508
+ # @return [Object]
509
+ def extract_plugin_info(line, plugin_tags)
510
+ return unless (m = line.match(/^\s*#\s*@(\w+)\b/))
511
+
512
+ plugin_tags[m[1] || ''] = true
513
+ end
514
+
515
+ # Extract exception names from a `@raise` doc line.
516
+ #
517
+ # @note module_function: when included, also defines #extract_raise_types_from_line (instance visibility: private)
518
+ # @param [String] line a `@raise` doc line
519
+ # @raise [StandardError]
520
+ # @return [String, nil] the exception name or nil
521
+ # @return [Array] if StandardError or line not matched
522
+ def extract_raise_types_from_line(line)
523
+ return [] unless line.match?(/^\s*#\s*@raise\b/)
524
+
525
+ if (m = line.match(/^\s*#\s*@raise\s*\[([^\]]+)\]/))
526
+ parse_raise_bracket_list(m[1])
527
+ elsif (m = line.match(/^\s*#\s*@raise\s+([A-Z]\w*(?:::[A-Z]\w*)*)/))
528
+ [m[1]]
529
+ else
530
+ []
183
531
  end
532
+ rescue StandardError
533
+ []
534
+ end
184
535
 
185
- if config.emit_param_tags?
186
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: param_types)
187
- all_params&.each do |pl|
188
- pname = extract_param_name_from_param_line(pl)
189
- next if pname.nil? || info[:param_names].include?(pname)
536
+ # Parse exception names from a `@raise [ExceptionA, ExceptionB]` line.
537
+ #
538
+ # @note module_function: when included, also defines #parse_raise_bracket_list (instance visibility: private)
539
+ # @param [Object] str comma-separated exception names string from @raise brackets
540
+ # @return [Array<String>, nil] the exception names or nil
541
+ def parse_raise_bracket_list(str)
542
+ str.to_s.split(',').map(&:strip).reject(&:empty?)
543
+ end
190
544
 
191
- lines << pl
192
- end
545
+ # Build a param name => type map from a method node.
546
+ #
547
+ # @note module_function: when included, also defines #build_param_types_from_node (instance visibility: private)
548
+ # @private
549
+ # @param [Parser::AST::Node] node def or defs node
550
+ # @param [Object, nil] external_sig external signature if available
551
+ # @param [Docscribe::Config] config
552
+ # @return [Hash{String => String}, nil]
553
+ def build_param_types_from_node(node, external_sig:, config:)
554
+ return unless node
555
+
556
+ args = extract_args_from_node(node)
557
+ return unless args
558
+
559
+ param_types = {} #: Hash[String, String]
560
+ collect_all_param_types(args, param_types, external_sig, config)
561
+ param_types.empty? ? nil : param_types
562
+ end
563
+
564
+ # Collect param types for all args using dispatch hash.
565
+ #
566
+ # @note module_function: when included, also defines #collect_all_param_types (instance visibility: private)
567
+ # @param [Object] args
568
+ # @param [Hash] param_types
569
+ # @param [Object] external_sig
570
+ # @param [Object] config
571
+ # @return [void]
572
+ def collect_all_param_types(args, param_types, external_sig, config)
573
+ (args.children || []).each do |a|
574
+ collector = PARAM_TYPE_COLLECTORS[a.type]
575
+ collector&.call(a, param_types, external_sig, config)
193
576
  end
577
+ end
194
578
 
195
- if config.emit_raise_tags?
196
- inferred = Docscribe::Infer.infer_raises_from_node(node)
197
- existing = info[:raise_types] || {}
198
- missing = inferred.reject { |rt| existing[rt] }
199
- missing.each { |rt| lines << "#{indent}# @raise [#{rt}]" }
579
+ # Collect param type for a required/keyword argument.
580
+ # @note module_function: when included, also defines # (instance visibility: private)
581
+ # @private
582
+ # @param [Object] param_types hash accumulating parameter name-to-type mappings
583
+ # @param [Object] external_sig external method signature for type overrides
584
+ # @param [Object] config Docscribe configuration for fallback type options
585
+ # @param [Object] infer_name lambda to transform parameter name for inference
586
+ # @param [Object] arg_node AST node for the required/keyword argument
587
+ # @return [Object]
588
+ def collect_param_type(arg_node, param_types, external_sig, config, infer_name:)
589
+ pname = arg_node.children.first.to_s
590
+ infer_pname = resolve_infer_name(pname, infer_name)
591
+ ty = external_sig&.param_types&.[](pname) ||
592
+ Infer.infer_param_type(infer_pname, nil,
593
+ fallback_type: config.fallback_type,
594
+ treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?)
595
+ param_types[pname] = ty
596
+ end
597
+
598
+ # Collect param type for an optional/keyword optional argument.
599
+ # @note module_function: when included, also defines # (instance visibility: private)
600
+ # @private
601
+ # @param [Object] param_types hash accumulating parameter name-to-type mappings
602
+ # @param [Object] external_sig external method signature for type overrides
603
+ # @param [Object] config Docscribe configuration for fallback type options
604
+ # @param [Object] infer_name lambda to transform parameter name for inference
605
+ # @param [Object] arg_node AST node for the optional/keyword optional argument
606
+ # @return [Object]
607
+ def collect_optarg_param_type(arg_node, param_types, external_sig, config, infer_name:)
608
+ pname, default = *arg_node
609
+ pname = pname.to_s
610
+ default_src = source_from_node(default)
611
+ infer_pname = resolve_infer_name(pname, infer_name)
612
+ ty = external_sig&.param_types&.[](pname) ||
613
+ Infer.infer_param_type(infer_pname, default_src,
614
+ fallback_type: config.fallback_type,
615
+ treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?)
616
+ param_types[pname] = ty
617
+ end
618
+
619
+ # Merge visibility tag lines for safe merge mode.
620
+ #
621
+ # @note module_function: when included, also defines #merge_visibility_tag_lines (instance visibility: private)
622
+ # @param [String] indent
623
+ # @param [Symbol] visibility
624
+ # @param [Docscribe::Config] config
625
+ # @param [Hash] info
626
+ # @return [Array<String>]
627
+ def merge_visibility_tag_lines(indent, visibility, config, info)
628
+ return [] unless config.emit_visibility_tags?
629
+
630
+ if visibility == :private && !info[:has_private]
631
+ ["#{indent}# @private"]
632
+ elsif visibility == :protected && !info[:has_protected]
633
+ ["#{indent}# @protected"]
634
+ else
635
+ []
200
636
  end
637
+ end
201
638
 
202
- if config.emit_return_tag?(scope, visibility) && !info[:has_return]
203
- lines << "#{indent}# @return [#{normal_type}]"
639
+ # Merge module_function note line for safe merge mode.
640
+ #
641
+ # @note also defines #merge_module_function_note_lines (instance: private)
642
+ # @note module_function: when included, also defines # (instance visibility: private)
643
+ # @param [String] indent
644
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
645
+ # @param [String] name
646
+ # @param [Hash] info
647
+ # @return [Array<String>]
648
+ def merge_module_function_note_lines(indent, insertion, name, info)
649
+ unless insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
650
+ return []
204
651
  end
205
652
 
206
- if config.emit_rescue_conditional_returns? && !info[:has_return]
207
- rescue_specs.each do |exceptions, rtype|
208
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
209
- end
653
+ included_vis = insertion.included_instance_visibility || :private
654
+ ["#{indent}# @note module_function: when included, also defines ##{name} " \
655
+ "(instance visibility: #{included_vis})"]
656
+ end
657
+
658
+ # Merge param lines for safe merge mode.
659
+ #
660
+ # @note module_function: when included, also defines #merge_param_lines (instance visibility: private)
661
+ # @param [Parser::AST::Node] node
662
+ # @param [String] indent
663
+ # @param [Docscribe::Config] config
664
+ # @param [Hash] opts additional options including external_sig, param_types, info
665
+ # @return [Array<String>]
666
+ def merge_param_lines(node, indent, config:, **opts)
667
+ return [] unless config.emit_param_tags?
668
+
669
+ all_params = build_params_lines(node, indent, external_sig: opts[:external_sig], config: config,
670
+ param_types_override: opts[:param_types])
671
+ return [] unless all_params
672
+
673
+ info = opts[:info]
674
+ all_params.each_with_object([]) do |pl, result|
675
+ pname = extract_param_name_from_param_line(pl)
676
+ next if pname.nil? || info[:param_names].include?(pname)
677
+
678
+ result << pl
210
679
  end
680
+ end
211
681
 
212
- useful = lines.reject { |l| l.strip == '#' }
213
- return '' if useful.empty?
682
+ # Merge raise tag lines for safe merge mode.
683
+ #
684
+ # @note module_function: when included, also defines #merge_raise_tag_lines (instance visibility: private)
685
+ # @param [Parser::AST::Node] node
686
+ # @param [String] indent
687
+ # @param [Docscribe::Config] config
688
+ # @param [Hash] info
689
+ # @return [Array<String>]
690
+ def merge_raise_tag_lines(node, indent, config, info)
691
+ return [] unless config.emit_raise_tags?
214
692
 
215
- lines.map { |l| "#{l}\n" }.join
216
- rescue StandardError => e
217
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build_merge_additions')
218
- nil
693
+ inferred = Docscribe::Infer.infer_raises_from_node(node)
694
+ existing = info[:raise_types] || {}
695
+ inferred.reject { |rt| existing[rt] }
696
+ .map { |rt| "#{indent}# @raise [#{rt}]" }
219
697
  end
220
698
 
221
- # Build structured missing-line information for safe merge mode.
699
+ # Merge return tag line for safe merge mode.
222
700
  #
223
- # Returns both:
224
- # - generated missing lines
225
- # - structured reasons used by `--explain`
701
+ # @note module_function: when included, also defines #merge_return_tag_line (instance visibility: private)
702
+ # @param [String] indent
703
+ # @param [String] normal_type
704
+ # @param [Docscribe::Config] config
705
+ # @param [Hash] opts additional options including scope, visibility, info
706
+ # @return [String, nil]
707
+ def merge_return_tag_line(indent, normal_type, config:, **opts)
708
+ return unless config.emit_return_tag?(opts[:scope], opts[:visibility])
709
+ return if opts[:info][:has_return]
710
+
711
+ "#{indent}# @return [#{normal_type}]"
712
+ end
713
+
714
+ # Merge rescue conditional return lines for safe merge mode.
226
715
  #
227
- # @note module_function: when included, also defines #build_missing_merge_result (instance visibility: private)
228
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
229
- # @param [Array<String>] existing_lines
716
+ # @note module_function: when included, also defines #merge_rescue_return_lines (instance visibility: private)
717
+ # @param [String] indent
718
+ # @param [Array] rescue_specs
230
719
  # @param [Docscribe::Config] config
231
- # @param [Object, nil] signature_provider
232
- # @param [nil] core_rbs_provider Param documentation.
233
- # @param [nil] param_types Param documentation.
234
- # @param [nil] strategy Param documentation.
235
- # @param [nil] return_type_override Param documentation.
236
- # @param [nil] override_tags Param documentation.
237
- # @raise [StandardError]
238
- # @return [Hash]
239
- def build_missing_merge_result(insertion, existing_lines:, config:, signature_provider: nil,
240
- core_rbs_provider: nil, param_types: nil, strategy: nil, return_type_override: nil, override_tags: nil)
241
- node = insertion.node
242
- name = SourceHelpers.node_name(node)
243
- return { lines: [], reasons: [] } unless name
720
+ # @param [Hash] info
721
+ # @return [Array<String>]
722
+ def merge_rescue_return_lines(indent, rescue_specs, config, info)
723
+ return [] unless config.emit_rescue_conditional_returns?
724
+ return [] if info[:has_return]
244
725
 
245
- indent = SourceHelpers.line_indent(node)
246
- info = parse_existing_doc_tags(existing_lines)
247
- scope = insertion.scope
248
- visibility = insertion.visibility
726
+ rescue_specs.map do |exceptions, rtype|
727
+ "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
728
+ end
729
+ end
249
730
 
250
- external_sig = signature_provider&.signature_for(
251
- container: insertion.container,
252
- scope: scope,
253
- name: name
254
- )
731
+ # Collect missing visibility tag for build_missing_merge_result.
732
+ #
733
+ # @note module_function: when included, also defines #collect_missing_visibility! (instance visibility: private)
734
+ # @param [Array<String>] lines
735
+ # @param [Array<Hash>] reasons
736
+ # @param [Hash] ctx
737
+ # @return [void]
738
+ def collect_missing_visibility!(lines, reasons, **ctx)
739
+ return unless ctx[:config].emit_visibility_tags?
255
740
 
256
- returns_spec = Docscribe::Infer.returns_spec_from_node(
257
- node,
258
- fallback_type: config.fallback_type,
259
- nil_as_optional: config.nil_as_optional?,
260
- param_types: param_types,
261
- core_rbs_provider: core_rbs_provider
262
- )
741
+ add_missing_private(lines, reasons, ctx)
742
+ add_missing_protected(lines, reasons, ctx)
743
+ end
263
744
 
264
- normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
265
- rescue_specs = returns_spec[:rescues] || []
745
+ # Add @private tag if missing.
746
+ #
747
+ # @note module_function: when included, also defines #add_missing_private (instance visibility: private)
748
+ # @param [Array] lines
749
+ # @param [Array] reasons
750
+ # @param [Hash] ctx
751
+ # @return [void]
752
+ def add_missing_private(lines, reasons, ctx)
753
+ return unless ctx[:visibility] == :private && !ctx[:info][:has_private]
266
754
 
267
- lines = []
268
- reasons = []
755
+ lines << "#{ctx[:indent]}# @private\n"
756
+ reasons << { type: :missing_visibility, message: 'missing @private' }
757
+ end
269
758
 
270
- if config.emit_visibility_tags?
271
- if visibility == :private && !info[:has_private]
272
- lines << "#{indent}# @private\n"
273
- reasons << { type: :missing_visibility, message: 'missing @private' }
274
- elsif visibility == :protected && !info[:has_protected]
275
- lines << "#{indent}# @protected\n"
276
- reasons << { type: :missing_visibility, message: 'missing @protected' }
277
- end
759
+ # Add @protected tag if missing.
760
+ #
761
+ # @note module_function: when included, also defines #add_missing_protected (instance visibility: private)
762
+ # @param [Array] lines
763
+ # @param [Array] reasons
764
+ # @param [Hash] ctx
765
+ # @return [void]
766
+ def add_missing_protected(lines, reasons, ctx)
767
+ return unless ctx[:visibility] == :protected && !ctx[:info][:has_protected]
768
+
769
+ lines << "#{ctx[:indent]}# @protected\n"
770
+ reasons << { type: :missing_visibility, message: 'missing @protected' }
771
+ end
772
+
773
+ # Collect missing module_function note for build_missing_merge_result.
774
+ #
775
+ # @note also defines #collect_missing_module_function_note! (instance: private)
776
+ # @note module_function: when included, also defines # (instance visibility: private)
777
+ # @param [Array<String>] lines
778
+ # @param [Array<Hash>] reasons
779
+ # @param [Hash] ctx
780
+ # @return [void]
781
+ def collect_missing_module_function_note!(lines, reasons, **ctx)
782
+ insertion = ctx[:insertion]
783
+ unless insertion.respond_to?(:module_function) && insertion.module_function &&
784
+ !ctx[:info][:has_module_function_note]
785
+ return
278
786
  end
279
787
 
280
- if insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
281
- included_vis = insertion.included_instance_visibility || :private
282
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
283
- "(instance visibility: #{included_vis})\n"
284
- reasons << { type: :missing_module_function_note, message: 'missing module_function note' }
788
+ included_vis = insertion.included_instance_visibility || :private
789
+ lines << "#{ctx[:indent]}# @note module_function: when included, also defines ##{ctx[:name]} " \
790
+ "(instance visibility: #{included_vis})\n"
791
+ reasons << { type: :missing_module_function_note, message: 'missing module_function note' }
792
+ end
793
+
794
+ # Collect missing/updated param lines for build_missing_merge_result.
795
+ #
796
+ # @note module_function: when included, also defines #collect_missing_params! (instance visibility: private)
797
+ # @param [Array<String>] lines
798
+ # @param [Array<Hash>] reasons
799
+ # @param [Hash] ctx
800
+ # @return [void]
801
+ def collect_missing_params!(lines, reasons, **ctx)
802
+ return unless ctx[:config].emit_param_tags?
803
+
804
+ all_params = build_params_lines(ctx[:node], ctx[:indent],
805
+ external_sig: ctx[:external_sig], config: ctx[:config],
806
+ param_types_override: ctx[:param_types])
807
+ return unless all_params
808
+
809
+ all_params.each { |pl| collect_param_from_line(pl, lines, reasons, ctx) }
810
+ end
811
+
812
+ # Collect a single param line for build_missing_merge_result.
813
+ # @note module_function: when included, also defines # (instance visibility: private)
814
+ # @private
815
+ # @param [Object] lines array of output doc lines being accumulated
816
+ # @param [Object] reasons array of reason hashes for --explain output
817
+ # @param [Object] ctx merged context hash with build parameters
818
+ # @param [Object] param_line a single @param tag line to evaluate
819
+ # @return [Object]
820
+ def collect_param_from_line(param_line, lines, reasons, ctx)
821
+ pname = extract_param_name_from_param_line(param_line)
822
+ return unless pname
823
+
824
+ if !ctx[:info][:param_names].include?(pname)
825
+ lines << "#{param_line}\n"
826
+ reasons << { type: :missing_param, message: "missing @param #{pname}", extra: { param: pname } }
827
+ elsif ctx[:external_sig] && ctx[:info][:param_types][pname]
828
+ collect_updated_param(param_line, pname, lines, reasons, ctx)
285
829
  end
830
+ end
286
831
 
287
- if config.emit_param_tags?
288
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: param_types)
289
-
290
- all_params&.each do |pl|
291
- pname = extract_param_name_from_param_line(pl)
292
- next unless pname
293
-
294
- if !info[:param_names].include?(pname)
295
- lines << "#{pl}\n"
296
- reasons << { type: :missing_param, message: "missing @param #{pname}", extra: { param: pname } }
297
- elsif external_sig && info[:param_types][pname]
298
- new_type = extract_param_type_from_param_line(pl)
299
- if new_type && info[:param_types][pname] != new_type
300
- lines << "#{pl}\n" unless strategy == :safe
301
- reasons << {
302
- type: :updated_param,
303
- message: "updated @param #{pname} from #{info[:param_types][pname]} to #{new_type}",
304
- extra: { param: pname }
305
- }
306
- end
307
- end
308
- end
832
+ # Collect an updated param line.
833
+ # @note module_function: when included, also defines # (instance visibility: private)
834
+ # @private
835
+ # @param [Object] pname the parameter name string
836
+ # @param [Object] lines array of output doc lines being accumulated
837
+ # @param [Object] reasons array of reason hashes for --explain output
838
+ # @param [Object] ctx merged context hash with build parameters
839
+ # @param [Object] param_line a single @param tag line to evaluate
840
+ # @return [Object]
841
+ def collect_updated_param(param_line, pname, lines, reasons, ctx)
842
+ new_type = extract_param_type_from_param_line(param_line)
843
+ return unless new_type && ctx[:info][:param_types][pname] != new_type
844
+
845
+ lines << "#{param_line}\n" unless ctx[:strategy] == :safe
846
+ reasons << {
847
+ type: :updated_param,
848
+ message: "updated @param #{pname} from #{ctx[:info][:param_types][pname]} to #{new_type}",
849
+ extra: { param: pname }
850
+ }
851
+ end
852
+
853
+ # Build generated `@param` / `@option` lines for a method node.
854
+ #
855
+ # External signatures take precedence over inferred parameter types.
856
+ #
857
+ # @note module_function: when included, also defines #build_params_lines (instance visibility: private)
858
+ # @param [Parser::AST::Node] node
859
+ # @param [String] indent
860
+ # @param [Docscribe::Types::MethodSignature, nil] external_sig
861
+ # @param [Docscribe::Config] config
862
+ # @param [nil] param_types_override parameter name -> type map override
863
+ # @return [Array<String>, nil]
864
+ def build_params_lines(node, indent, external_sig:, config:, param_types_override: nil)
865
+ args = extract_args_from_node(node)
866
+ return nil unless args
867
+
868
+ build_all_param_lines(args, indent, external_sig, param_types_override, config)
869
+ end
870
+
871
+ # Build all param lines for args.
872
+ #
873
+ # @note module_function: when included, also defines #build_all_param_lines (instance visibility: private)
874
+ # @param [Object] args
875
+ # @param [String] indent
876
+ # @param [Object] external_sig
877
+ # @param [Object] param_types_override
878
+ # @param [Docscribe::Config] config
879
+ # @return [Array<String>, nil]
880
+ def build_all_param_lines(args, indent, external_sig, param_types_override, config)
881
+ fb = config.fallback_type
882
+ tk = config.treat_options_keyword_as_hash?
883
+ ts = config.param_tag_style
884
+ pd = config.include_param_documentation? ? config.param_documentation : ''
885
+ params = (args.children || []).each_with_object([]) do |a, p|
886
+ p.concat(build_param_line(a, indent, external_sig, param_types_override,
887
+ fallback_type: fb, treat_options_keyword_as_hash: tk,
888
+ param_documentation: pd, param_tag_style: ts))
309
889
  end
890
+ params.empty? ? nil : params
891
+ end
310
892
 
311
- if config.emit_raise_tags?
312
- inferred = Docscribe::Infer.infer_raises_from_node(node)
313
- existing = info[:raise_types] || {}
314
- missing = inferred.reject { |rt| existing[rt] }
893
+ # Build doc lines for a full doc block.
894
+ # Delegates to assemble_doc_lines with setup and context.
895
+ #
896
+ # @note module_function: when included, also defines #build_doc_lines (instance visibility: private)
897
+ # @param [Object] setup method setup hash with indent, name, types, scope
898
+ # @param [Object] config Docscribe configuration object
899
+ # @param [Hash] kwargs additional keyword args including insertion, params_lines, raise_types, override_tags
900
+ # @return [Object]
901
+ def build_doc_lines(setup, config:, **kwargs)
902
+ i = setup[:indent]
903
+ assemble_doc_lines(i, setup, config: config, insertion: kwargs[:insertion],
904
+ params_lines: kwargs[:params_lines],
905
+ raise_types: kwargs[:raise_types], override_tags: kwargs[:override_tags])
906
+ end
315
907
 
316
- missing.each do |rt|
317
- lines << "#{indent}# @raise [#{rt}]\n"
318
- reasons << { type: :missing_raise, message: "missing @raise [#{rt}]", extra: { raise_type: rt } }
319
- end
908
+ # Assemble all doc lines into a single array.
909
+ #
910
+ # @note module_function: when included, also defines #assemble_doc_lines (instance visibility: private)
911
+ # @param [String] indent indent
912
+ # @param [Hash] setup setup
913
+ # @param [Hash] ctx context hash with config, insertion, params_lines, raise_types, override_tags
914
+ # @return [Array<String>]
915
+ def assemble_doc_lines(indent, setup, **ctx)
916
+ line_ary = build_header_lines(
917
+ indent,
918
+ config: ctx[:config],
919
+ container: setup[:container], method_symbol: setup[:method_symbol], name: setup[:name],
920
+ normal_type: setup[:normal_type]
921
+ )
922
+
923
+ append_assemble_body_lines(line_ary, indent, setup, ctx)
924
+ line_ary
925
+ end
926
+
927
+ # Append body lines to a doc line array.
928
+ #
929
+ # @note module_function: when included, also defines #append_assemble_body_lines (instance visibility: private)
930
+ # @param [Array<String>] line_ary
931
+ # @param [Hash] ctx
932
+ # @param [Object] indent indentation string for doc comment lines
933
+ # @param [Object] setup method setup hash with name, types, scope
934
+ # @return [void]
935
+ def append_assemble_body_lines(line_ary, indent, setup, ctx)
936
+ line_ary.concat(build_all_body_tags(indent, setup, ctx))
937
+ end
938
+
939
+ # Build all body tag lines for a doc block.
940
+ #
941
+ # @note module_function: when included, also defines #build_all_body_tags (instance visibility: private)
942
+ # @param [Hash] ctx
943
+ # @param [Object] indent indentation string for doc comment lines
944
+ # @param [Object] setup method setup hash with name, types, scope
945
+ # @return [Array<String>]
946
+ def build_all_body_tags(indent, setup, ctx)
947
+ result = core_body_tags(indent, setup, ctx)
948
+ result.insert(3, ctx[:params_lines]) if ctx[:params_lines]
949
+ result.flatten
950
+ end
951
+
952
+ # Core body tags without optional params_lines.
953
+ #
954
+ # @note module_function: when included, also defines #core_body_tags (instance visibility: private)
955
+ # @param [Hash] ctx
956
+ # @param [Object] indent indentation string for doc comment lines
957
+ # @param [Object] setup method setup hash with name, types, scope
958
+ # @return [Array]
959
+ def core_body_tags(indent, setup, ctx)
960
+ config = ctx[:config]
961
+ [
962
+ defaults_and_visibility(indent, config, setup[:scope], setup[:visibility]),
963
+ build_module_function_note_lines(indent, ctx[:insertion], setup[:name]),
964
+ build_raise_tag_lines(indent, ctx[:raise_types], config),
965
+ build_return_line_if_needed(indent, setup, config),
966
+ build_rescue_return_lines(indent, setup[:rescue_specs], config),
967
+ build_plugin_tag_lines(ctx[:insertion], indent, setup[:normal_type], ctx[:override_tags])
968
+ ]
969
+ end
970
+
971
+ # Build default msg and visibility tags.
972
+ #
973
+ # @note module_function: when included, also defines #defaults_and_visibility (instance visibility: private)
974
+ # @param [Object] config
975
+ # @param [Symbol] scope
976
+ # @param [Symbol] visibility
977
+ # @param [Object] indent indentation string for doc comment lines
978
+ # @return [Array<String>]
979
+ def defaults_and_visibility(indent, config, scope, visibility)
980
+ [
981
+ build_default_msg_lines(indent, config, scope, visibility),
982
+ build_visibility_tag_lines(indent, visibility, config)
983
+ ].flatten
984
+ end
985
+
986
+ # Build return tag line if emit condition is met.
987
+ #
988
+ # @note module_function: when included, also defines #build_return_line_if_needed (instance visibility: private)
989
+ # @param [Docscribe::Config] config
990
+ # @param [Object] indent indentation string for doc comment lines
991
+ # @param [Object] setup method setup hash with name, normal_type, scope, visibility
992
+ # @return [Array<String>]
993
+ def build_return_line_if_needed(indent, setup, config)
994
+ emit_ret = config.emit_return_tag?(setup[:scope], setup[:visibility])
995
+ ret_line = build_return_tag_line(indent, setup[:normal_type], config, setup[:scope], setup[:visibility])
996
+ emit_ret && ret_line ? [ret_line] : []
997
+ end
998
+
999
+ # Extract args sub-node from a def or defs node.
1000
+ # @note module_function: when included, also defines # (instance visibility: private)
1001
+ # @private
1002
+ # @param [Parser::AST::Node] node
1003
+ # @return [Parser::AST::Node, nil]
1004
+ def extract_args_from_node(node)
1005
+ case node.type
1006
+ when :def then node.children[1]
1007
+ when :defs then node.children[2]
320
1008
  end
1009
+ end
321
1010
 
322
- if config.emit_return_tag?(scope, visibility)
323
- if !info[:has_return]
324
- lines << "#{indent}# @return [#{normal_type}]\n"
325
- reasons << { type: :missing_return, message: 'missing @return' }
326
- elsif external_sig && info[:return_type] && info[:return_type] != normal_type
327
- lines << "#{indent}# @return [#{normal_type}]\n" unless strategy == :safe
328
- reasons << {
329
- type: :updated_return,
330
- message: "updated @return from #{info[:return_type]} to #{normal_type}"
331
- }
332
- end
1011
+ # Build a param line for a single argument node.
1012
+ # Dispatches to the appropriate builder via PARAM_BUILDERS by arg type.
1013
+ #
1014
+ # @note module_function: when included, also defines #build_param_line (instance visibility: private)
1015
+ # @param [Object] arg_node AST node for the argument
1016
+ # @param [Object] indent indentation string for doc comment lines
1017
+ # @param [Object] external_sig external method signature for type overrides
1018
+ # @param [Object] param_types_override map of parameter name to override type
1019
+ # @param [Hash] opts additional options for param formatting (fallback_type, param_tag_style, etc.)
1020
+ # @return [Object]
1021
+ def build_param_line(arg_node, indent, external_sig, param_types_override, **opts)
1022
+ PARAM_BUILDERS.fetch(arg_node.type, lambda { |*|
1023
+ [] #: Array[String]
1024
+ }).call(arg_node, indent, external_sig, param_types_override, **opts)
1025
+ end
1026
+
1027
+ # Build header line(s) for a doc block.
1028
+ #
1029
+ # @note module_function: when included, also defines #build_header_lines (instance visibility: private)
1030
+ # @param [String] indent
1031
+ # @param [Docscribe::Config] config
1032
+ # @param [Hash] opts additional options including container, method_symbol, name, normal_type
1033
+ # @return [Array<String>]
1034
+ def build_header_lines(indent, config:, **opts)
1035
+ if config.emit_header?
1036
+ c = opts[:container]
1037
+ ms = opts[:method_symbol]
1038
+ n = opts[:name]
1039
+ nt = opts[:normal_type]
1040
+ ["#{indent}# +#{c}#{ms}#{n}+ -> #{nt}", "#{indent}#"]
1041
+ else
1042
+ []
333
1043
  end
1044
+ end
334
1045
 
335
- if config.emit_rescue_conditional_returns? && !info[:has_return]
336
- rescue_specs.each do |exceptions, rtype|
337
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}\n"
338
- reasons << {
339
- type: :missing_return,
340
- message: "missing conditional @return for #{exceptions.join(', ')}"
341
- }
342
- end
1046
+ # Build default message lines for a doc block.
1047
+ #
1048
+ # @note module_function: when included, also defines #build_default_msg_lines (instance visibility: private)
1049
+ # @param [String] indent
1050
+ # @param [Docscribe::Config] config
1051
+ # @param [Symbol] scope
1052
+ # @param [Symbol] visibility
1053
+ # @return [Array<String>]
1054
+ def build_default_msg_lines(indent, config, scope, visibility)
1055
+ if config.include_default_message?
1056
+ ["#{indent}# #{config.default_message(scope, visibility)}", "#{indent}#"]
1057
+ else
1058
+ []
343
1059
  end
344
- plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(insertion, normal_type: normal_type))
345
- plugin_tags.concat(Array(override_tags)) if override_tags
1060
+ end
346
1061
 
347
- plugin_tags.each do |tag|
348
- next if info[:plugin_tags]&.[](tag.name)
1062
+ # Build visibility tag line(s) for a full doc block.
1063
+ #
1064
+ # @note module_function: when included, also defines #build_visibility_tag_lines (instance visibility: private)
1065
+ # @param [String] indent
1066
+ # @param [Symbol] visibility
1067
+ # @param [Docscribe::Config] config
1068
+ # @return [Array<String>]
1069
+ def build_visibility_tag_lines(indent, visibility, config)
1070
+ return [] unless config.emit_visibility_tags?
349
1071
 
350
- rendered = render_plugin_tags([tag], indent).first
351
- lines << "#{rendered}\n"
352
- reasons << { type: :missing_plugin_tag, message: "missing @#{tag.name}" }
1072
+ case visibility
1073
+ when :private then ["#{indent}# @private"]
1074
+ when :protected then ["#{indent}# @protected"]
1075
+ else []
353
1076
  end
354
- { lines: lines, reasons: reasons }
355
- rescue StandardError => e
356
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build_missing_merge_result')
357
- { lines: [], reasons: [] }
358
1077
  end
359
1078
 
360
- # Parse existing doc comment lines and extract known YARD tags.
361
- #
362
- # Extracts: `@param` names, `@return`, `@raise`, `@private`, `@protected`,
363
- # `@module_function` notes, and `@option` lines.
1079
+ # Build module_function note line(s) for a full doc block.
364
1080
  #
365
- # @note module_function: when included, also defines #parse_existing_doc_tags (instance visibility: private)
366
- # @param [Array<String>] lines existing doc comment lines
367
- # @return [Hash] parsed tag info
368
- def parse_existing_doc_tags(lines)
369
- param_names = {}
370
- param_types = {}
371
- has_return = false
372
- return_type = nil
373
- has_private = false
374
- has_protected = false
375
- has_module_function_note = false
376
- raise_types = {}
377
- plugin_tags = {}
378
-
379
- Array(lines).each do |line|
380
- if (m = line.match(/^\s*#\s*@(\w+)\b/))
381
- plugin_tags[m[1]] = true
382
- end
383
- if (pname = extract_param_name_from_param_line(line))
384
- param_names[pname] = true
385
- if (type_match = line.match(/@param\s+\[([^\]]+)\]\s+\S+/) || line.match(/@param\s+\S+\s+\[([^\]]+)\]/))
386
- param_types[pname] = type_match[1]
387
- end
388
- end
389
-
390
- if line.match?(/^\s*#\s*@return\b/)
391
- has_return = true
392
- if (m = line.match(/@return\s+\[([^\]]+)\]/))
393
- return_type = m[1]
394
- end
1081
+ # @note also defines #build_module_function_note_lines (instance: private)
1082
+ # @note module_function: when included, also defines # (instance visibility: private)
1083
+ # @param [String] indent
1084
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
1085
+ # @param [String] name
1086
+ # @return [Array<String>]
1087
+ def build_module_function_note_lines(indent, insertion, name)
1088
+ return [] unless insertion.respond_to?(:module_function) && insertion.module_function
1089
+
1090
+ included_vis =
1091
+ if insertion.respond_to?(:included_instance_visibility) && insertion.included_instance_visibility
1092
+ insertion.included_instance_visibility
1093
+ else
1094
+ :private
395
1095
  end
396
- has_private ||= line.match?(/^\s*#\s*@private\b/)
397
- has_protected ||= line.match?(/^\s*#\s*@protected\b/)
398
- has_module_function_note ||= line.match?(/^\s*#\s*@note\s+module_function:/)
399
-
400
- extract_raise_types_from_line(line).each { |t| raise_types[t] = true }
401
- end
402
1096
 
403
- {
404
- param_names: param_names,
405
- param_types: param_types,
406
- has_return: has_return,
407
- return_type: return_type,
408
- raise_types: raise_types,
409
- has_private: has_private,
410
- has_protected: has_protected,
411
- has_module_function_note: has_module_function_note,
412
- plugin_tags: plugin_tags
413
- }
1097
+ ["#{indent}# @note module_function: when included, also defines ##{name} " \
1098
+ "(instance visibility: #{included_vis})"]
414
1099
  end
415
1100
 
416
- # Extract exception names from a `@raise` doc line.
1101
+ # Build raise tag lines for a full doc block.
417
1102
  #
418
- # @note module_function: when included, also defines #extract_raise_types_from_line (instance visibility: private)
419
- # @param [String] line a `@raise` doc line
420
- # @raise [StandardError]
421
- # @return [String, nil] the exception name or nil
422
- # @return [Array] if StandardError or line not matched
423
- def extract_raise_types_from_line(line)
424
- return [] unless line.match?(/^\s*#\s*@raise\b/)
1103
+ # @note module_function: when included, also defines #build_raise_tag_lines (instance visibility: private)
1104
+ # @param [String] indent
1105
+ # @param [Array<String>] raise_types
1106
+ # @param [Docscribe::Config] config
1107
+ # @return [Array<String>]
1108
+ def build_raise_tag_lines(indent, raise_types, config)
1109
+ return [] unless config.emit_raise_tags?
425
1110
 
426
- if (m = line.match(/^\s*#\s*@raise\s*\[([^\]]+)\]/))
427
- parse_raise_bracket_list(m[1])
428
- elsif (m = line.match(/^\s*#\s*@raise\s+([A-Z]\w*(?:::[A-Z]\w*)*)/))
429
- [m[1]]
430
- else
431
- []
432
- end
433
- rescue StandardError
434
- []
1111
+ raise_types.map { |rt| "#{indent}# @raise [#{rt}]" }
435
1112
  end
436
1113
 
437
- # Parse exception names from a `@raise [ExceptionA, ExceptionB]` line.
1114
+ # Build return tag line for a full doc block.
438
1115
  #
439
- # @note module_function: when included, also defines #parse_raise_bracket_list (instance visibility: private)
440
- # @param [String] s the `@raise` line text
441
- # @return [Array<String>, nil] the exception names or nil
442
- def parse_raise_bracket_list(s)
443
- s.to_s.split(',').map(&:strip).reject(&:empty?)
1116
+ # @note module_function: when included, also defines #build_return_tag_line (instance visibility: private)
1117
+ # @param [String] indent
1118
+ # @param [String] normal_type
1119
+ # @param [Docscribe::Config] config
1120
+ # @param [Symbol] scope
1121
+ # @param [Symbol] visibility
1122
+ # @return [String, nil]
1123
+ def build_return_tag_line(indent, normal_type, config, scope, visibility)
1124
+ return unless config.emit_return_tag?(scope, visibility)
1125
+
1126
+ "#{indent}# @return [#{normal_type}]"
444
1127
  end
445
1128
 
446
- # Build a param name => type map from a method node.
1129
+ # Build rescue conditional return lines for a full doc block.
447
1130
  #
448
- # @note module_function: when included, also defines #build_param_types_from_node (instance visibility: private)
449
- # @private
450
- # @param [Parser::AST::Node] node def or defs node
451
- # @param [Object, nil] external_sig external signature if available
1131
+ # @note module_function: when included, also defines #build_rescue_return_lines (instance visibility: private)
1132
+ # @param [String] indent
1133
+ # @param [Array] rescue_specs
452
1134
  # @param [Docscribe::Config] config
453
- # @return [Hash{String => String}, nil]
454
- def build_param_types_from_node(node, external_sig:, config:)
455
- return nil unless node
1135
+ # @return [Array<String>]
1136
+ def build_rescue_return_lines(indent, rescue_specs, config)
1137
+ return [] unless config.emit_rescue_conditional_returns?
456
1138
 
457
- args =
458
- case node.type
459
- when :def then node.children[1]
460
- when :defs then node.children[2]
461
- end
1139
+ rescue_specs.map do |exceptions, rtype|
1140
+ "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
1141
+ end
1142
+ end
462
1143
 
463
- return nil unless args
1144
+ # Build plugin tag lines for a full doc block.
1145
+ #
1146
+ # @note module_function: when included, also defines #build_plugin_tag_lines (instance visibility: private)
1147
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
1148
+ # @param [String] indent
1149
+ # @param [String] normal_type
1150
+ # @param [Array, nil] override_tags
1151
+ # @return [Array<String>]
1152
+ def build_plugin_tag_lines(insertion, indent, normal_type, override_tags)
1153
+ plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(insertion, normal_type: normal_type))
1154
+ plugin_tags.concat(Array(override_tags)) if override_tags
1155
+ render_plugin_tags(plugin_tags, indent)
1156
+ end
464
1157
 
465
- param_types = {}
1158
+ # Build a param line for a required argument.
1159
+ # @note module_function: when included, also defines # (instance visibility: private)
1160
+ # @private
1161
+ # @param [Object] indent indentation string for doc comment lines
1162
+ # @param [Object] external_sig external method signature for type overrides
1163
+ # @param [Object] param_types_override map of parameter name to override type
1164
+ # @param [Object] arg_node AST node for the required argument
1165
+ # @param [Hash] opts additional options for param formatting
1166
+ # @return [Object]
1167
+ def build_arg_line(arg_node, indent, external_sig, param_types_override, **opts)
1168
+ pname = arg_node.children.first.to_s
1169
+ ty = lookup_param_type(external_sig, param_types_override, pname, pname,
1170
+ infer_default: nil,
1171
+ fallback_type: opts[:fallback_type],
1172
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
1173
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
1174
+ end
466
1175
 
467
- (args.children || []).each do |a|
468
- case a.type
469
- when :arg
470
- pname = a.children.first.to_s
471
- ty = external_sig&.param_types&.[](pname) ||
472
- Infer.infer_param_type(
473
- pname,
474
- nil,
475
- fallback_type: config.fallback_type,
476
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
477
- )
478
- param_types[pname] = ty
479
-
480
- when :optarg
481
- pname, default = *a
482
- pname = pname.to_s
483
- default_src = default&.loc&.expression&.source
484
- ty = external_sig&.param_types&.[](pname) ||
485
- Infer.infer_param_type(
486
- pname,
487
- default_src,
488
- fallback_type: config.fallback_type,
489
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
490
- )
491
- param_types[pname] = ty
492
-
493
- when :kwarg
494
- pname = a.children.first.to_s
495
- ty = external_sig&.param_types&.[](pname) ||
496
- Infer.infer_param_type(
497
- "#{pname}:",
498
- nil,
499
- fallback_type: config.fallback_type,
500
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
501
- )
502
- param_types[pname] = ty
503
-
504
- when :kwoptarg
505
- pname, default = *a
506
- pname = pname.to_s
507
- default_src = default&.loc&.expression&.source
508
- ty = external_sig&.param_types&.[](pname) ||
509
- Infer.infer_param_type(
510
- "#{pname}:",
511
- default_src,
512
- fallback_type: config.fallback_type,
513
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
514
- )
515
- param_types[pname] = ty
516
- end
517
- end
1176
+ # Build param lines for an optional argument (including @option lines).
1177
+ # @note module_function: when included, also defines # (instance visibility: private)
1178
+ # @private
1179
+ # @param [Object] indent indentation string for doc comment lines
1180
+ # @param [Object] external_sig external method signature for type overrides
1181
+ # @param [Object] param_types_override map of parameter name to override type
1182
+ # @param [Object] arg_node AST node for the optional argument
1183
+ # @param [Hash] opts additional options for param formatting
1184
+ # @return [Object]
1185
+ def build_optarg_lines(arg_node, indent, external_sig, param_types_override, **opts)
1186
+ pname, default = *arg_node
1187
+ pname = pname.to_s
1188
+ ty = optarg_type(pname, default, external_sig, param_types_override, opts)
1189
+ lines = [format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])]
1190
+
1191
+ append_option_lines(lines, default, indent, pname, opts[:fallback_type])
1192
+ lines
1193
+ end
518
1194
 
519
- param_types.empty? ? nil : param_types
1195
+ # Resolve optarg type.
1196
+ #
1197
+ # @note module_function: when included, also defines #optarg_type (instance visibility: private)
1198
+ # @param [String] pname
1199
+ # @param [Object] default
1200
+ # @param [Object] external_sig
1201
+ # @param [Object] param_types_override
1202
+ # @param [Hash] opts
1203
+ # @return [String]
1204
+ def optarg_type(pname, default, external_sig, param_types_override, opts)
1205
+ default_src = source_from_node(default)
1206
+ lookup_param_type(external_sig, param_types_override, pname, pname,
1207
+ infer_default: default_src,
1208
+ fallback_type: opts[:fallback_type],
1209
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
520
1210
  end
521
1211
 
522
- # Build generated `@param` / `@option` lines for a method node.
1212
+ # Extract source text from an AST node.
523
1213
  #
524
- # External signatures take precedence over inferred parameter types.
1214
+ # @note module_function: when included, also defines #source_from_node (instance visibility: private)
1215
+ # @param [Object] node
1216
+ # @return [String, nil]
1217
+ def source_from_node(node)
1218
+ loc = node&.loc
1219
+ loc&.expression&.source
1220
+ end
1221
+
1222
+ # Resolve the infer name string from a param name and infer_name lambda.
525
1223
  #
526
- # @note module_function: when included, also defines #build_params_lines (instance visibility: private)
527
- # @param [Parser::AST::Node] node
528
- # @param [String] indent
529
- # @param [Docscribe::Types::MethodSignature, nil] external_sig
530
- # @param [Docscribe::Config] config
531
- # @param [nil] param_types_override Param documentation.
532
- # @return [Array<String>, nil]
533
- def build_params_lines(node, indent, external_sig:, config:, param_types_override: nil)
534
- fallback_type = config.fallback_type
535
- treat_options_keyword_as_hash = config.treat_options_keyword_as_hash?
536
- param_tag_style = config.param_tag_style
537
- param_documentation = config.include_param_documentation? ? config.param_documentation : ''
538
-
539
- args =
540
- case node.type
541
- when :def then node.children[1]
542
- when :defs then node.children[2]
543
- end
1224
+ # @note module_function: when included, also defines #resolve_infer_name (instance visibility: private)
1225
+ # @param [String] pname
1226
+ # @param [Proc, nil] infer_name
1227
+ # @return [String]
1228
+ def resolve_infer_name(pname, infer_name)
1229
+ infer_name ? infer_name.call(pname) : pname
1230
+ end
544
1231
 
545
- return nil unless args
1232
+ # Build a param line for a keyword argument.
1233
+ # @note module_function: when included, also defines # (instance visibility: private)
1234
+ # @private
1235
+ # @param [Object] indent indentation string for doc comment lines
1236
+ # @param [Object] external_sig external method signature for type overrides
1237
+ # @param [Object] param_types_override map of parameter name to override type
1238
+ # @param [Object] arg_node AST node for the keyword argument
1239
+ # @param [Hash] opts additional options for param formatting
1240
+ # @return [Object]
1241
+ def build_kwarg_line(arg_node, indent, external_sig, param_types_override, **opts)
1242
+ pname = arg_node.children.first.to_s
1243
+ ty = lookup_param_type(external_sig, param_types_override, pname, "#{pname}:",
1244
+ infer_default: nil,
1245
+ fallback_type: opts[:fallback_type],
1246
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
1247
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
1248
+ end
546
1249
 
547
- params = []
1250
+ # Build a param line for an optional keyword argument.
1251
+ # @note module_function: when included, also defines # (instance visibility: private)
1252
+ # @private
1253
+ # @param [Object] indent indentation string for doc comment lines
1254
+ # @param [Object] external_sig external method signature for type overrides
1255
+ # @param [Object] param_types_override map of parameter name to override type
1256
+ # @param [Object] arg_node AST node for the optional keyword argument
1257
+ # @param [Hash] opts additional options for param formatting
1258
+ # @return [Object]
1259
+ def build_kwoptarg_line(arg_node, indent, external_sig, param_types_override, **opts)
1260
+ pname, default = *arg_node
1261
+ pname = pname.to_s
1262
+ default_loc = default&.loc
1263
+ default_src = default_loc&.expression&.source
1264
+ ty = lookup_param_type(external_sig, param_types_override, pname, "#{pname}:",
1265
+ infer_default: default_src,
1266
+ fallback_type: opts[:fallback_type],
1267
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
1268
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
1269
+ end
548
1270
 
549
- (args.children || []).each do |a|
550
- case a.type
551
- when :arg
552
- pname = a.children.first.to_s
553
- ty = external_sig&.param_types&.[](pname) ||
554
- override_param_type_for(pname, param_types_override) ||
555
- Infer.infer_param_type(
556
- pname,
557
- nil,
558
- fallback_type: fallback_type,
559
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
560
- )
561
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
562
-
563
- when :optarg
564
- pname, default = *a
565
- pname = pname.to_s
566
- default_src = default&.loc&.expression&.source
567
- ty = external_sig&.param_types&.[](pname) ||
568
- override_param_type_for(pname, param_types_override) ||
569
- Infer.infer_param_type(
570
- pname,
571
- default_src,
572
- fallback_type: fallback_type,
573
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
574
- )
575
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
576
-
577
- hash_option_pairs(default).each do |pair|
578
- key_node, value_node = pair.children
579
- option_key = option_key_name(key_node)
580
- option_type = Infer::Literals.type_from_literal(value_node, fallback_type: fallback_type)
581
- option_default = node_default_literal(value_node)
582
-
583
- line = "#{indent}# @option #{pname} [#{option_type}] :#{option_key}"
584
- line += " (#{option_default})" if option_default
585
- line += ' Option documentation.'
586
- params << line
587
- end
588
-
589
- when :kwarg
590
- pname = a.children.first.to_s
591
- ty = external_sig&.param_types&.[](pname) ||
592
- override_param_type_for(pname, param_types_override) ||
593
- Infer.infer_param_type(
594
- "#{pname}:",
595
- nil,
596
- fallback_type: fallback_type,
597
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
598
- )
599
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
600
-
601
- when :kwoptarg
602
- pname, default = *a
603
- pname = pname.to_s
604
- default_src = default&.loc&.expression&.source
605
- ty = external_sig&.param_types&.[](pname) ||
606
- override_param_type_for(pname, param_types_override) ||
607
- Infer.infer_param_type(
608
- "#{pname}:",
609
- default_src,
610
- fallback_type: fallback_type,
611
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
612
- )
613
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
614
-
615
- when :restarg
616
- pname = (a.children.first || 'args').to_s
617
- ty =
618
- if external_sig&.rest_positional&.element_type
619
- "Array<#{external_sig.rest_positional.element_type}>"
620
- else
621
- override_param_type_for(pname, param_types_override) ||
622
- Infer.infer_param_type(
623
- "*#{pname}",
624
- nil,
625
- fallback_type: fallback_type,
626
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
627
- )
628
- end
629
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
630
-
631
- when :kwrestarg
632
- pname = (a.children.first || 'kwargs').to_s
633
- ty = external_sig&.rest_keywords&.type ||
634
- override_param_type_for(pname, param_types_override) ||
635
- Infer.infer_param_type(
636
- "**#{pname}",
637
- nil,
638
- fallback_type: fallback_type,
639
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
640
- )
641
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
642
-
643
- when :blockarg
644
- pname = (a.children.first || 'block').to_s
645
- ty = external_sig&.param_types&.[](pname) ||
646
- override_param_type_for(pname, param_types_override) ||
647
- Infer.infer_param_type(
648
- "&#{pname}",
649
- nil,
650
- fallback_type: fallback_type,
651
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
652
- )
653
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
654
-
655
- when :forward_arg
656
- # skip
657
- end
658
- end
1271
+ # Build a param line for a rest argument (*args).
1272
+ # @note module_function: when included, also defines # (instance visibility: private)
1273
+ # @private
1274
+ # @param [Object] indent indentation string for doc comment lines
1275
+ # @param [Object] external_sig external method signature for type overrides
1276
+ # @param [Object] param_types_override map of parameter name to override type
1277
+ # @param [Object] arg_node AST node for the rest argument (*args)
1278
+ # @param [Hash] opts additional options for param formatting
1279
+ # @return [Object]
1280
+ def build_restarg_line(arg_node, indent, external_sig, param_types_override, **opts)
1281
+ pname = (arg_node.children.first || 'args').to_s
1282
+ ty = if external_sig&.rest_positional&.element_type
1283
+ "Array<#{external_sig.rest_positional.element_type}>"
1284
+ else
1285
+ lookup_param_type_by_infer(param_types_override, pname, "*#{pname}",
1286
+ opts[:fallback_type], opts[:treat_options_keyword_as_hash])
1287
+ end
1288
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
1289
+ end
659
1290
 
660
- params.empty? ? nil : params
1291
+ # Build a param line for a keyword rest argument (**kwargs).
1292
+ # @note module_function: when included, also defines # (instance visibility: private)
1293
+ # @private
1294
+ # @param [Object] indent indentation string for doc comment lines
1295
+ # @param [Object] external_sig external method signature for type overrides
1296
+ # @param [Object] param_types_override map of parameter name to override type
1297
+ # @param [Object] arg_node AST node for the keyword rest argument (**kwargs)
1298
+ # @param [Hash] opts additional options for param formatting
1299
+ # @return [Object]
1300
+ def build_kwrestarg_line(arg_node, indent, external_sig, param_types_override, **opts)
1301
+ pname = (arg_node.children.first || 'kwargs').to_s
1302
+ ty = external_sig&.rest_keywords&.type ||
1303
+ lookup_param_type_by_infer(param_types_override, pname, "**#{pname}",
1304
+ opts[:fallback_type], opts[:treat_options_keyword_as_hash])
1305
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
661
1306
  end
662
1307
 
663
- # Method documentation.
664
- #
665
- # @note module_function: when included, also defines #override_param_type_for (instance visibility: private)
666
- # @param [Object] pname Param documentation.
667
- # @param [Object] override_map Param documentation.
1308
+ # Build a param line for a block argument (&block).
1309
+ # @note module_function: when included, also defines # (instance visibility: private)
1310
+ # @private
1311
+ # @param [Object] indent indentation string for doc comment lines
1312
+ # @param [Object] external_sig external method signature for type overrides
1313
+ # @param [Object] param_types_override map of parameter name to override type
1314
+ # @param [Object] arg_node AST node for the block argument (&block)
1315
+ # @param [Hash] opts additional options for param formatting
668
1316
  # @return [Object]
669
- def override_param_type_for(pname, override_map)
670
- return nil unless override_map
1317
+ def build_blockarg_line(arg_node, indent, external_sig, param_types_override, **opts)
1318
+ pname = (arg_node.children.first || 'block').to_s
1319
+ ty = lookup_param_type(external_sig, param_types_override, pname, "&#{pname}",
1320
+ infer_default: nil,
1321
+ fallback_type: opts[:fallback_type],
1322
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
1323
+ format_param_tag(indent, pname, ty, opts[:param_documentation], style: opts[:param_tag_style])
1324
+ end
671
1325
 
672
- key = pname.to_s
673
- override_map[key] || override_map[:"#{key}"] || override_map["#{key}:"] || override_map[:"#{key}:"]
1326
+ # Three-tier type lookup: external_sig -> override -> inference.
1327
+ # @note module_function: when included, also defines # (instance visibility: private)
1328
+ # @private
1329
+ # @param [Object] external_sig external method signature for type overrides
1330
+ # @param [Object] param_types_override map of parameter name to override type
1331
+ # @param [Object] pname the parameter name string
1332
+ # @param [Object] infer_name parameter name string or transformed version for inference
1333
+ # @param [Hash] opts additional options including infer_default, fallback_type, treat_options_keyword_as_hash
1334
+ # @return [Object]
1335
+ def lookup_param_type(external_sig, param_types_override, pname, infer_name, **opts)
1336
+ external_sig&.param_types&.[](pname) ||
1337
+ override_param_type_for(pname, param_types_override) ||
1338
+ Infer.infer_param_type(infer_name, opts[:infer_default],
1339
+ fallback_type: opts[:fallback_type],
1340
+ treat_options_keyword_as_hash: opts[:treat_options_keyword_as_hash])
1341
+ end
1342
+
1343
+ # Two-tier type lookup: override -> inference (for rest/kwrest types).
1344
+ # @note module_function: when included, also defines # (instance visibility: private)
1345
+ # @private
1346
+ # @param [Object] param_types_override map of parameter name to override type
1347
+ # @param [Object] pname the parameter name string
1348
+ # @param [Object] infer_name parameter name string or transformed version for inference
1349
+ # @param [Object] fallback_type default type string when inference fails
1350
+ # @param [Object] treat_options_keyword_as_hash whether to treat options keyword as Hash type
1351
+ # @return [Object]
1352
+ def lookup_param_type_by_infer(param_types_override, pname, infer_name, fallback_type,
1353
+ treat_options_keyword_as_hash)
1354
+ override_param_type_for(pname, param_types_override) ||
1355
+ Infer.infer_param_type(infer_name, nil,
1356
+ fallback_type: fallback_type,
1357
+ treat_options_keyword_as_hash: treat_options_keyword_as_hash || false)
674
1358
  end
675
1359
 
676
- # Format a `@param` tag line using the configured param tag style.
1360
+ # Format a YARD @param tag line with optional documentation text.
677
1361
  #
678
1362
  # @note module_function: when included, also defines #format_param_tag (instance visibility: private)
679
- # @param [String] indent leading whitespace
680
- # @param [String] name parameter name
681
- # @param [String] type parameter type
682
- # @param [String] documentation optional documentation text
683
- # @param [String, Symbol] style param tag style (`"name_type"` or `"type_name"`)
684
- # @return [String]
1363
+ # @param [Object] indent indentation string for the doc line
1364
+ # @param [Object] name the parameter name
1365
+ # @param [Object] type the parameter type string
1366
+ # @param [Object] documentation optional documentation text appended to the tag
1367
+ # @param [Object] style param tag style (:type_name or :name_type)
1368
+ # @return [Object]
685
1369
  def format_param_tag(indent, name, type, documentation, style:)
686
1370
  doc = documentation.to_s.strip
687
1371
  type = type.to_s
@@ -696,45 +1380,96 @@ module Docscribe
696
1380
  doc.empty? ? line : "#{line} #{doc}"
697
1381
  end
698
1382
 
699
- # Extract keyword argument option pairs from a hash default value.
1383
+ # Append @option lines for hash defaults.
1384
+ #
1385
+ # @note module_function: when included, also defines #append_option_lines (instance visibility: private)
1386
+ # @param [Array] lines
1387
+ # @param [Object] default
1388
+ # @param [String] indent
1389
+ # @param [String] pname
1390
+ # @param [Object] fallback_type
1391
+ # @return [void]
1392
+ def append_option_lines(lines, default, indent, pname, fallback_type)
1393
+ hash_option_pairs(default).each do |pair|
1394
+ lines << build_option_line(pair, indent, pname, fallback_type)
1395
+ end
1396
+ end
1397
+
1398
+ # Extract hash option pairs from a default value node.
700
1399
  #
701
1400
  # @note module_function: when included, also defines #hash_option_pairs (instance visibility: private)
702
- # @param [Parser::AST::Node, nil] node a `:hash` node
703
- # @return [Array<Parser::AST::Node>] the `:pair` children
1401
+ # @param [Object] node AST node for the default value, expected to be :hash type
1402
+ # @return [Object]
704
1403
  def hash_option_pairs(node)
705
1404
  return [] unless node&.type == :hash
706
1405
 
707
1406
  node.children.select { |child| child.is_a?(Parser::AST::Node) && child.type == :pair }
708
1407
  end
709
1408
 
710
- # Get the symbol name from an option key node.
1409
+ # Build an @option line from a hash pair node.
1410
+ # @note module_function: when included, also defines # (instance visibility: private)
1411
+ # @private
1412
+ # @param [Object] pair AST pair node containing key and value
1413
+ # @param [Object] indent indentation string for the doc line
1414
+ # @param [Object] pname the parent parameter name for @option scope
1415
+ # @param [Object] fallback_type default type string when inference fails
1416
+ # @return [Object]
1417
+ def build_option_line(pair, indent, pname, fallback_type)
1418
+ key_node, value_node = pair.children
1419
+ option_key = option_key_name(key_node)
1420
+ option_type = Infer::Literals.type_from_literal(value_node, fallback_type: fallback_type)
1421
+ option_default = node_default_literal(value_node)
1422
+
1423
+ line = "#{indent}# @option #{pname} [#{option_type}] :#{option_key}"
1424
+ line += " (#{option_default})" if option_default
1425
+ line += ' Description of this option.'
1426
+ line
1427
+ end
1428
+
1429
+ # Extract the option key name from a hash key node.
711
1430
  #
712
1431
  # @note module_function: when included, also defines #option_key_name (instance visibility: private)
713
- # @param [Parser::AST::Node] key_node a `:sym` node
714
- # @return [String] the option key name
1432
+ # @param [Object] key_node AST node for the hash key (:sym or :str type)
1433
+ # @return [Object]
715
1434
  def option_key_name(key_node)
716
1435
  case key_node&.type
717
1436
  when :sym, :str
718
1437
  key_node.children.first.to_s
719
1438
  else
720
- key_node&.loc&.expression&.source.to_s.sub(/\A:/, '')
1439
+ expression = key_node&.loc&.expression
1440
+ expression&.source.to_s.sub(/\A:/, '')
721
1441
  end
722
1442
  end
723
1443
 
724
- # Get the raw source literal for a default value node.
1444
+ # Extract the source text of a default value node.
725
1445
  #
726
1446
  # @note module_function: when included, also defines #node_default_literal (instance visibility: private)
727
- # @param [Parser::AST::Node, nil] node a default value node
728
- # @return [String, nil] the source literal or nil
1447
+ # @param [Object] node AST node whose source text to extract
1448
+ # @return [Object]
729
1449
  def node_default_literal(node)
730
- node&.loc&.expression&.source
1450
+ expression = node&.loc&.expression
1451
+ expression&.source
1452
+ end
1453
+
1454
+ # Look up a parameter type from an override map.
1455
+ #
1456
+ # @note module_function: when included, also defines #override_param_type_for (instance visibility: private)
1457
+ # @param [Object] pname the parameter name to look up
1458
+ # @param [Object] override_map hash map of parameter name to override type
1459
+ # @return [Object]
1460
+ def override_param_type_for(pname, override_map)
1461
+ return nil unless override_map
1462
+
1463
+ key = pname.to_s
1464
+ override_map[key] || override_map[:"#{key}"] || override_map["#{key}:"] || override_map[:"#{key}:"]
731
1465
  end
732
1466
 
733
1467
  # Extract the parameter name from a `@param` doc line.
734
1468
  #
735
1469
  # Handles both `"@param [Type] name"` and `"@param name [Type]"` styles.
736
1470
  #
737
- # @note module_function: when included, also defines #extract_param_name_from_param_line (instance visibility: private)
1471
+ # @note also defines #extract_param_name_from_param_line (instance: private)
1472
+ # @note module_function: when included, also defines # (instance visibility: private)
738
1473
  # @param [String] line a `@param` doc line
739
1474
  # @return [String, nil] the parameter name or nil
740
1475
  def extract_param_name_from_param_line(line)
@@ -744,17 +1479,182 @@ module Docscribe
744
1479
  nil
745
1480
  end
746
1481
 
747
- # Method documentation.
1482
+ # Extract the type from a `@param` tag line.
748
1483
  #
749
- # @note module_function: when included, also defines #extract_param_type_from_param_line (instance visibility: private)
750
- # @param [Object] line Param documentation.
751
- # @return [Object]
1484
+ # @note also defines #extract_param_type_from_param_line (instance: private)
1485
+ # @note module_function: when included, also defines # (instance visibility: private)
1486
+ # @param [String] line a `@param` tag line
1487
+ # @return [String, nil]
752
1488
  def extract_param_type_from_param_line(line)
753
1489
  if (m = line.match(/@param\s+\[([^\]]+)\]\s+\S+/) || line.match(/@param\s+\S+\s+\[([^\]]+)\]/))
754
1490
  m[1]
755
1491
  end
756
1492
  end
757
1493
 
1494
+ # Collect missing raise tags for build_missing_merge_result.
1495
+ #
1496
+ # @note module_function: when included, also defines #collect_missing_raises! (instance visibility: private)
1497
+ # @param [Array<String>] lines
1498
+ # @param [Array<Hash>] reasons
1499
+ # @param [Hash] ctx
1500
+ # @return [void]
1501
+ def collect_missing_raises!(lines, reasons, **ctx)
1502
+ return unless ctx[:config].emit_raise_tags?
1503
+
1504
+ inferred = Docscribe::Infer.infer_raises_from_node(ctx[:node])
1505
+ existing = ctx[:info][:raise_types] || {}
1506
+ missing = inferred.reject { |rt| existing[rt] }
1507
+
1508
+ missing.each do |rt|
1509
+ lines << "#{ctx[:indent]}# @raise [#{rt}]\n"
1510
+ reasons << { type: :missing_raise, message: "missing @raise [#{rt}]", extra: { raise_type: rt } }
1511
+ end
1512
+ end
1513
+
1514
+ # Collect missing/updated return tag for build_missing_merge_result.
1515
+ #
1516
+ # @note module_function: when included, also defines #collect_missing_return! (instance visibility: private)
1517
+ # @param [Array<String>] lines
1518
+ # @param [Array<Hash>] reasons
1519
+ # @param [Hash] ctx
1520
+ # @return [void]
1521
+ def collect_missing_return!(lines, reasons, **ctx)
1522
+ return unless ctx[:config].emit_return_tag?(ctx[:scope], ctx[:visibility])
1523
+
1524
+ if !ctx[:info][:has_return]
1525
+ record_missing_return(lines, reasons, ctx)
1526
+ elsif return_type_changed?(ctx)
1527
+ record_updated_return(lines, reasons, ctx)
1528
+ end
1529
+ end
1530
+
1531
+ # Record a missing @return tag and its reason.
1532
+ #
1533
+ # @note module_function: when included, also defines #record_missing_return (instance visibility: private)
1534
+ # @param [Object] lines array of output doc lines being accumulated
1535
+ # @param [Object] reasons array of reason hashes for --explain output
1536
+ # @param [Object] ctx merged context hash with normal_type and indent
1537
+ # @return [Object]
1538
+ def record_missing_return(lines, reasons, ctx)
1539
+ lines << "#{ctx[:indent]}# @return [#{ctx[:normal_type]}]\n"
1540
+ reasons << { type: :missing_return, message: 'missing @return' }
1541
+ end
1542
+
1543
+ # Record an updated @return tag and its reason.
1544
+ #
1545
+ # @note module_function: when included, also defines #record_updated_return (instance visibility: private)
1546
+ # @param [Object] lines array of output doc lines being accumulated
1547
+ # @param [Object] reasons array of reason hashes for --explain output
1548
+ # @param [Object] ctx merged context hash with normal_type and info
1549
+ # @return [Object]
1550
+ def record_updated_return(lines, reasons, ctx)
1551
+ lines << "#{ctx[:indent]}# @return [#{ctx[:normal_type]}]\n" unless ctx[:strategy] == :safe
1552
+ reasons << { type: :updated_return,
1553
+ message: "updated @return from #{ctx[:info][:return_type]} to #{ctx[:normal_type]}" }
1554
+ end
1555
+
1556
+ # Check if the return type changed between existing doc and inferred/signature type.
1557
+ # Compares existing return type to the resolved normal type.
1558
+ #
1559
+ # @note module_function: when included, also defines #return_type_changed? (instance visibility: private)
1560
+ # @param [Object] ctx merged context hash with external_sig, info, and normal_type
1561
+ # @return [Object]
1562
+ def return_type_changed?(ctx)
1563
+ ctx[:external_sig] && ctx[:info][:return_type] && ctx[:info][:return_type] != ctx[:normal_type]
1564
+ end
1565
+
1566
+ # Collect missing rescue conditional returns for build_missing_merge_result.
1567
+ #
1568
+ # @note also defines #collect_missing_rescue_returns! (instance: private)
1569
+ # @note module_function: when included, also defines # (instance visibility: private)
1570
+ # @param [Array<String>] lines
1571
+ # @param [Array<Hash>] reasons
1572
+ # @param [Hash] ctx
1573
+ # @return [void]
1574
+ def collect_missing_rescue_returns!(lines, reasons, **ctx)
1575
+ return unless ctx[:config].emit_rescue_conditional_returns?
1576
+ return if ctx[:info][:has_return]
1577
+
1578
+ ctx[:rescue_specs].each do |exceptions, rtype|
1579
+ lines << "#{ctx[:indent]}# @return [#{rtype}] if #{exceptions.join(', ')}\n"
1580
+ reasons << {
1581
+ type: :missing_return,
1582
+ message: "missing conditional @return for #{exceptions.join(', ')}"
1583
+ }
1584
+ end
1585
+ end
1586
+
1587
+ # Collect missing plugin tags for build_missing_merge_result.
1588
+ #
1589
+ # @note module_function: when included, also defines #collect_missing_plugin_tags! (instance visibility: private)
1590
+ # @param [Array<String>] lines
1591
+ # @param [Array<Hash>] reasons
1592
+ # @param [Hash] ctx
1593
+ # @return [void]
1594
+ def collect_missing_plugin_tags!(lines, reasons, **ctx)
1595
+ plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(ctx[:insertion],
1596
+ normal_type: ctx[:normal_type]))
1597
+ plugin_tags.concat(Array(ctx[:override_tags])) if ctx[:override_tags]
1598
+
1599
+ plugin_tags.each { |tag| record_plugin_tag(tag, lines, reasons, ctx) }
1600
+ end
1601
+
1602
+ # Record a missing plugin tag and its reason.
1603
+ #
1604
+ # @note module_function: when included, also defines #record_plugin_tag (instance visibility: private)
1605
+ # @param [Object] tag plugin tag object to render and record
1606
+ # @param [Object] lines array of output doc lines being accumulated
1607
+ # @param [Object] reasons array of reason hashes for --explain output
1608
+ # @param [Object] ctx merged context hash with info and indent
1609
+ # @return [Object]
1610
+ def record_plugin_tag(tag, lines, reasons, ctx)
1611
+ return if ctx[:info][:plugin_tags]&.[](tag.name)
1612
+
1613
+ rendered = render_plugin_tags([tag], ctx[:indent]).first
1614
+ lines << "#{rendered}\n"
1615
+ reasons << { type: :missing_plugin_tag, message: "missing @#{tag.name}" }
1616
+ end
1617
+
1618
+ # Print a debug warning for a failed doc build phase.
1619
+ #
1620
+ # @note module_function: when included, also defines #debug_warn (instance visibility: private)
1621
+ # @param [StandardError] error the error that occurred
1622
+ # @param [Collector::Insertion] insertion the method insertion being processed
1623
+ # @param [String] name the method name
1624
+ # @param [String] phase the processing phase
1625
+ # @return [void]
1626
+ def debug_warn(error, insertion:, name:, phase:)
1627
+ return unless debug?
1628
+
1629
+ where = build_debug_location(insertion, name)
1630
+ warn "Docscribe DEBUG: #{phase} failed at #{where}: #{error.class}: #{error.message}"
1631
+ end
1632
+
1633
+ # Build a human-readable location string for debug output.
1634
+ # Formats as "file.rb:line Container#method" for error reporting.
1635
+ #
1636
+ # @note module_function: when included, also defines #build_debug_location (instance visibility: private)
1637
+ # @param [Object] insertion the collected method insertion object
1638
+ # @param [Object] name the method name string
1639
+ # @return [Object]
1640
+ def build_debug_location(insertion, name)
1641
+ return name.to_s unless insertion
1642
+
1643
+ expr = insertion.node.loc.expression
1644
+ buf = expr.source_buffer.name
1645
+ sym = insertion.scope == :class ? '.' : '#'
1646
+ ctr = insertion.container || 'Object'
1647
+ +"#{buf}:#{expr.line} #{ctr}#{sym}#{name}"
1648
+ end
1649
+
1650
+ # Check whether debug mode is enabled.
1651
+ #
1652
+ # @note module_function: when included, also defines #debug? (instance visibility: private)
1653
+ # @return [Boolean]
1654
+ def debug?
1655
+ ENV['DOCSCRIBE_DEBUG'] == '1'
1656
+ end
1657
+
758
1658
  # Build a Plugin::Context from a collected insertion.
759
1659
  #
760
1660
  # @note module_function
@@ -765,12 +1665,19 @@ module Docscribe
765
1665
  # @return [Docscribe::Plugin::Context]
766
1666
  def build_plugin_context(insertion, normal_type:)
767
1667
  node = insertion.node
768
- source = begin
769
- node.loc.expression.source
770
- rescue StandardError
771
- ''
772
- end
1668
+ source = safe_node_source(node)
1669
+ new_plugin_context(insertion, node, source, normal_type)
1670
+ end
773
1671
 
1672
+ # Build a Plugin::Context from parts.
1673
+ #
1674
+ # @note module_function: when included, also defines #new_plugin_context (instance visibility: private)
1675
+ # @param [Object] insertion
1676
+ # @param [Object] node
1677
+ # @param [String] source
1678
+ # @param [String] normal_type
1679
+ # @return [Docscribe::Plugin::Context]
1680
+ def new_plugin_context(insertion, node, source, normal_type)
774
1681
  Docscribe::Plugin::Context.new(
775
1682
  node: node,
776
1683
  container: insertion.container,
@@ -783,6 +1690,18 @@ module Docscribe
783
1690
  )
784
1691
  end
785
1692
 
1693
+ # Safely extract source text from a node.
1694
+ #
1695
+ # @note module_function: when included, also defines #safe_node_source (instance visibility: private)
1696
+ # @param [Parser::AST::Node] node
1697
+ # @raise [StandardError]
1698
+ # @return [String]
1699
+ def safe_node_source(node)
1700
+ node.loc.expression.source
1701
+ rescue StandardError
1702
+ ''
1703
+ end
1704
+
786
1705
  # Render plugin tags as indented comment lines.
787
1706
  #
788
1707
  # @note module_function
@@ -797,39 +1716,6 @@ module Docscribe
797
1716
  "#{indent}# @#{tag.name}#{type_part}#{text_part}"
798
1717
  end
799
1718
  end
800
-
801
- # Print a debug warning for a failed doc build phase.
802
- #
803
- # @note module_function: when included, also defines #debug_warn (instance visibility: private)
804
- # @param [StandardError] e the error that occurred
805
- # @param [Collector::Insertion] insertion the method insertion being processed
806
- # @param [String] name the method name
807
- # @param [String] phase the processing phase
808
- # @return [void]
809
- def debug_warn(e, insertion:, name:, phase:)
810
- return unless debug?
811
-
812
- node = insertion&.node
813
- buf_name = node&.loc&.expression&.source_buffer&.name || '(unknown)'
814
- line = node&.loc&.expression&.line
815
- scope = insertion&.scope
816
- method_symbol = scope == :class ? '.' : '#'
817
- container = insertion&.container || 'Object'
818
-
819
- where = +buf_name.to_s
820
- where << ":#{line}" if line
821
- where << " #{container}#{method_symbol}#{name}"
822
-
823
- warn "Docscribe DEBUG: #{phase} failed at #{where}: #{e.class}: #{e.message}"
824
- end
825
-
826
- # Check whether debug mode is enabled.
827
- #
828
- # @note module_function: when included, also defines #debug? (instance visibility: private)
829
- # @return [Boolean]
830
- def debug?
831
- ENV['DOCSCRIBE_DEBUG'] == '1'
832
- end
833
1719
  end
834
1720
  end
835
1721
  end