docscribe 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +194 -8
  3. data/lib/docscribe/cli/config_builder.rb +125 -35
  4. data/lib/docscribe/cli/generate.rb +288 -117
  5. data/lib/docscribe/cli/init.rb +49 -13
  6. data/lib/docscribe/cli/options.rb +303 -127
  7. data/lib/docscribe/cli/run.rb +391 -135
  8. data/lib/docscribe/cli.rb +23 -5
  9. data/lib/docscribe/config/defaults.rb +11 -11
  10. data/lib/docscribe/config/emit.rb +1 -0
  11. data/lib/docscribe/config/filtering.rb +24 -11
  12. data/lib/docscribe/config/loader.rb +7 -4
  13. data/lib/docscribe/config/plugin.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +31 -22
  15. data/lib/docscribe/config/sorbet.rb +41 -15
  16. data/lib/docscribe/config/sorting.rb +1 -0
  17. data/lib/docscribe/config/template.rb +1 -0
  18. data/lib/docscribe/config/utils.rb +3 -2
  19. data/lib/docscribe/config.rb +1 -0
  20. data/lib/docscribe/infer/constants.rb +15 -0
  21. data/lib/docscribe/infer/literals.rb +43 -25
  22. data/lib/docscribe/infer/names.rb +24 -15
  23. data/lib/docscribe/infer/params.rb +52 -6
  24. data/lib/docscribe/infer/raises.rb +24 -14
  25. data/lib/docscribe/infer/returns.rb +365 -182
  26. data/lib/docscribe/infer.rb +10 -9
  27. data/lib/docscribe/inline_rewriter/collector.rb +766 -375
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +217 -74
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +1495 -583
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +100 -52
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +109 -48
  32. data/lib/docscribe/inline_rewriter.rb +1052 -457
  33. data/lib/docscribe/plugin/base/collector_plugin.rb +2 -3
  34. data/lib/docscribe/plugin/base/tag_plugin.rb +1 -1
  35. data/lib/docscribe/plugin/registry.rb +34 -7
  36. data/lib/docscribe/plugin.rb +48 -17
  37. data/lib/docscribe/types/rbs/collection_loader.rb +0 -1
  38. data/lib/docscribe/types/rbs/provider.rb +75 -26
  39. data/lib/docscribe/types/rbs/type_formatter.rb +127 -59
  40. data/lib/docscribe/types/sorbet/base_provider.rb +31 -12
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +2 -2
@@ -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,636 +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.
96
+ # @param [Hash] opts additional keyword options forwarded to doc_setup
97
+ # @raise [StandardError]
98
+ # @return [String, 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
33
119
  # @raise [StandardError]
34
120
  # @return [String, nil]
35
- def build(insertion, config:, signature_provider: nil, core_rbs_provider: nil, param_types: 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)
36
167
  node = insertion.node
37
168
  name = SourceHelpers.node_name(node)
38
169
  return nil unless name
39
170
 
40
- indent = SourceHelpers.line_indent(node)
41
- scope = insertion.scope
42
- visibility = insertion.visibility
43
- container = insertion.container
44
- method_symbol = scope == :instance ? '#' : '.'
171
+ setup = extract_base_setup(insertion, name)
172
+ resolve_doc_setup!(setup, node, name, config, opts)
173
+ end
45
174
 
46
- external_sig = signature_provider&.signature_for(
47
- container: container,
48
- scope: scope,
49
- name: name
50
- )
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
51
188
 
52
- effective_param_types =
53
- 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
54
205
 
55
- if config.emit_param_tags?
56
- params_lines = build_params_lines(node, indent, external_sig: external_sig, config: config)
57
- end
58
- raise_types = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(node) : []
59
-
60
- returns_spec = Docscribe::Infer.returns_spec_from_node(
61
- node,
62
- fallback_type: config.fallback_type,
63
- nil_as_optional: config.nil_as_optional?,
64
- param_types: effective_param_types,
65
- 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] || []
66
223
  )
224
+ end
67
225
 
68
- normal_type = external_sig&.return_type || returns_spec[:normal]
69
- 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
70
238
 
71
- 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
72
250
 
73
- if config.emit_header?
74
- lines << "#{indent}# +#{container}#{method_symbol}#{name}+ -> #{normal_type}"
75
- lines << "#{indent}#"
76
- 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
77
265
 
78
- if config.include_default_message?
79
- lines << "#{indent}# #{config.default_message(scope, visibility)}"
80
- 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])
81
282
  end
283
+ end
82
284
 
83
- if config.emit_visibility_tags?
84
- case visibility
85
- when :private
86
- lines << "#{indent}# @private"
87
- when :protected
88
- lines << "#{indent}# @protected"
89
- end
90
- 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
91
296
 
92
- if insertion.respond_to?(:module_function) && insertion.module_function
93
- included_vis =
94
- if insertion.respond_to?(:included_instance_visibility) && insertion.included_instance_visibility
95
- insertion.included_instance_visibility
96
- else
97
- :private
98
- 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
99
307
 
100
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
101
- "(instance visibility: #{included_vis})"
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?
324
+
325
+ line_ary.map { |l| "#{l}\n" }.join
326
+ end
327
+
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
337
+
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
349
+ end
350
+
351
+ # Core tag line merging.
352
+ #
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.
363
+ #
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
371
+
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
388
+
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)
402
+
403
+ line_ary << ret_line if emit_ret && ret_line
404
+ end
405
+
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
424
+
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
442
+
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
102
457
  end
103
458
 
104
- lines.concat(params_lines) if params_lines
105
- raise_types.each { |rt| lines << "#{indent}# @raise [#{rt}]" } if config.emit_raise_tags?
106
- lines << "#{indent}# @return [#{normal_type}]" if config.emit_return_tag?(scope, visibility)
459
+ param_types[pname] = type_match[1] || 'untyped'
460
+ end
107
461
 
108
- if config.emit_rescue_conditional_returns?
109
- rescue_specs.each do |exceptions, rtype|
110
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
111
- end
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
+ []
112
531
  end
113
- plugin_tags = Docscribe::Plugin.run_tag_plugins(
114
- build_plugin_context(insertion, normal_type: normal_type)
115
- )
116
- lines.concat(render_plugin_tags(plugin_tags, indent))
117
- lines.map { |l| "#{l}\n" }.join
118
- rescue StandardError => e
119
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build')
120
- nil
532
+ rescue StandardError
533
+ []
121
534
  end
122
535
 
123
- # Build only the missing doc lines that should be merged into an existing
124
- # doc-like block.
536
+ # Parse exception names from a `@raise [ExceptionA, ExceptionB]` line.
125
537
  #
126
- # This is used by safe mode for non-destructive updates.
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
544
+
545
+ # Build a param name => type map from a method node.
127
546
  #
128
- # @note module_function: when included, also defines #build_merge_additions (instance visibility: private)
129
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
130
- # @param [Array<String>] existing_lines
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
131
551
  # @param [Docscribe::Config] config
132
- # @param [Object, nil] signature_provider
133
- # @param [nil] core_rbs_provider Param documentation.
134
- # @param [nil] param_types Param documentation.
135
- # @raise [StandardError]
136
- # @return [String, nil]
137
- def build_merge_additions(insertion, existing_lines:, config:, signature_provider: nil, core_rbs_provider: nil,
138
- param_types: nil)
139
- node = insertion.node
140
- name = SourceHelpers.node_name(node)
141
- return '' unless name
552
+ # @return [Hash{String => String}, nil]
553
+ def build_param_types_from_node(node, external_sig:, config:)
554
+ return unless node
142
555
 
143
- indent = SourceHelpers.line_indent(node)
144
- info = parse_existing_doc_tags(existing_lines)
145
- scope = insertion.scope
146
- visibility = insertion.visibility
556
+ args = extract_args_from_node(node)
557
+ return unless args
147
558
 
148
- external_sig = signature_provider&.signature_for(
149
- container: insertion.container,
150
- scope: scope,
151
- name: name
152
- )
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
153
563
 
154
- returns_spec = Docscribe::Infer.returns_spec_from_node(
155
- node,
156
- fallback_type: config.fallback_type,
157
- nil_as_optional: config.nil_as_optional?,
158
- param_types: param_types,
159
- core_rbs_provider: core_rbs_provider
160
- )
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)
576
+ end
577
+ end
161
578
 
162
- normal_type = external_sig&.return_type || returns_spec[:normal]
163
- rescue_specs = returns_spec[:rescues] || []
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
164
597
 
165
- lines = []
166
- lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
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
167
618
 
168
- if config.emit_visibility_tags?
169
- if visibility == :private && !info[:has_private]
170
- lines << "#{indent}# @private"
171
- elsif visibility == :protected && !info[:has_protected]
172
- lines << "#{indent}# @protected"
173
- end
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
+ []
174
636
  end
637
+ end
175
638
 
176
- if insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
177
- included_vis = insertion.included_instance_visibility || :private
178
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
179
- "(instance visibility: #{included_vis})"
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 []
180
651
  end
181
652
 
182
- if config.emit_param_tags?
183
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config)
184
- all_params&.each do |pl|
185
- pname = extract_param_name_from_param_line(pl)
186
- next if pname.nil? || info[:param_names].include?(pname)
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
187
657
 
188
- lines << pl
189
- end
190
- end
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)
191
677
 
192
- if config.emit_raise_tags?
193
- inferred = Docscribe::Infer.infer_raises_from_node(node)
194
- existing = info[:raise_types] || {}
195
- missing = inferred.reject { |rt| existing[rt] }
196
- missing.each { |rt| lines << "#{indent}# @raise [#{rt}]" }
678
+ result << pl
197
679
  end
680
+ end
681
+
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?
692
+
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}]" }
697
+ end
198
698
 
199
- if config.emit_return_tag?(scope, visibility) && !info[:has_return]
200
- lines << "#{indent}# @return [#{normal_type}]"
699
+ # Merge return tag line for safe merge mode.
700
+ #
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.
715
+ #
716
+ # @note module_function: when included, also defines #merge_rescue_return_lines (instance visibility: private)
717
+ # @param [String] indent
718
+ # @param [Array] rescue_specs
719
+ # @param [Docscribe::Config] config
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]
725
+
726
+ rescue_specs.map do |exceptions, rtype|
727
+ "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
201
728
  end
729
+ end
202
730
 
203
- if config.emit_rescue_conditional_returns? && !info[:has_return]
204
- rescue_specs.each do |exceptions, rtype|
205
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
206
- end
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?
740
+
741
+ add_missing_private(lines, reasons, ctx)
742
+ add_missing_protected(lines, reasons, ctx)
743
+ end
744
+
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]
754
+
755
+ lines << "#{ctx[:indent]}# @private\n"
756
+ reasons << { type: :missing_visibility, message: 'missing @private' }
757
+ end
758
+
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
207
786
  end
208
787
 
209
- useful = lines.reject { |l| l.strip == '#' }
210
- return '' if useful.empty?
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
211
793
 
212
- lines.map { |l| "#{l}\n" }.join
213
- rescue StandardError => e
214
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build_merge_additions')
215
- nil
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) }
216
810
  end
217
811
 
218
- # Build structured missing-line information for safe merge mode.
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)
829
+ end
830
+ end
831
+
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.
219
854
  #
220
- # Returns both:
221
- # - generated missing lines
222
- # - structured reasons used by `--explain`
855
+ # External signatures take precedence over inferred parameter types.
223
856
  #
224
- # @note module_function: when included, also defines #build_missing_merge_result (instance visibility: private)
225
- # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
226
- # @param [Array<String>] existing_lines
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
227
861
  # @param [Docscribe::Config] config
228
- # @param [Object, nil] signature_provider
229
- # @param [nil] core_rbs_provider Param documentation.
230
- # @param [nil] param_types Param documentation.
231
- # @param [nil] strategy Param documentation.
232
- # @raise [StandardError]
233
- # @return [Hash]
234
- def build_missing_merge_result(insertion, existing_lines:, config:, signature_provider: nil,
235
- core_rbs_provider: nil, param_types: nil, strategy: nil)
236
- node = insertion.node
237
- name = SourceHelpers.node_name(node)
238
- return { lines: [], reasons: [] } unless name
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
239
867
 
240
- indent = SourceHelpers.line_indent(node)
241
- info = parse_existing_doc_tags(existing_lines)
242
- scope = insertion.scope
243
- visibility = insertion.visibility
868
+ build_all_param_lines(args, indent, external_sig, param_types_override, config)
869
+ end
244
870
 
245
- external_sig = signature_provider&.signature_for(
246
- container: insertion.container,
247
- scope: scope,
248
- name: name
249
- )
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))
889
+ end
890
+ params.empty? ? nil : params
891
+ end
892
+
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
250
907
 
251
- returns_spec = Docscribe::Infer.returns_spec_from_node(
252
- node,
253
- fallback_type: config.fallback_type,
254
- nil_as_optional: config.nil_as_optional?,
255
- param_types: param_types,
256
- core_rbs_provider: core_rbs_provider
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]
257
921
  )
258
922
 
259
- normal_type = external_sig&.return_type || returns_spec[:normal]
260
- rescue_specs = returns_spec[:rescues] || []
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
261
938
 
262
- lines = []
263
- reasons = []
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
264
951
 
265
- if config.emit_visibility_tags?
266
- if visibility == :private && !info[:has_private]
267
- lines << "#{indent}# @private\n"
268
- reasons << { type: :missing_visibility, message: 'missing @private' }
269
- elsif visibility == :protected && !info[:has_protected]
270
- lines << "#{indent}# @protected\n"
271
- reasons << { type: :missing_visibility, message: 'missing @protected' }
272
- end
273
- end
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
274
970
 
275
- if insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
276
- included_vis = insertion.included_instance_visibility || :private
277
- lines << "#{indent}# @note module_function: when included, also defines ##{name} " \
278
- "(instance visibility: #{included_vis})\n"
279
- reasons << { type: :missing_module_function_note, message: 'missing module_function note' }
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]
280
1008
  end
1009
+ end
281
1010
 
282
- if config.emit_param_tags?
283
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config)
284
-
285
- all_params&.each do |pl|
286
- pname = extract_param_name_from_param_line(pl)
287
- next unless pname
288
-
289
- if !info[:param_names].include?(pname)
290
- lines << "#{pl}\n"
291
- reasons << { type: :missing_param, message: "missing @param #{pname}", extra: { param: pname } }
292
- elsif external_sig && info[:param_types][pname]
293
- new_type = extract_param_type_from_param_line(pl)
294
- if new_type && info[:param_types][pname] != new_type
295
- lines << "#{pl}\n" unless strategy == :safe
296
- reasons << {
297
- type: :updated_param,
298
- message: "updated @param #{pname} from #{info[:param_types][pname]} to #{new_type}",
299
- extra: { param: pname }
300
- }
301
- end
302
- end
303
- 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
+ []
304
1043
  end
1044
+ end
305
1045
 
306
- if config.emit_raise_tags?
307
- inferred = Docscribe::Infer.infer_raises_from_node(node)
308
- existing = info[:raise_types] || {}
309
- missing = inferred.reject { |rt| existing[rt] }
310
-
311
- missing.each do |rt|
312
- lines << "#{indent}# @raise [#{rt}]\n"
313
- reasons << { type: :missing_raise, message: "missing @raise [#{rt}]", extra: { raise_type: rt } }
314
- 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
+ []
315
1059
  end
1060
+ end
316
1061
 
317
- if config.emit_return_tag?(scope, visibility)
318
- if !info[:has_return]
319
- lines << "#{indent}# @return [#{normal_type}]\n"
320
- reasons << { type: :missing_return, message: 'missing @return' }
321
- elsif external_sig && info[:return_type] && info[:return_type] != normal_type
322
- lines << "#{indent}# @return [#{normal_type}]\n" unless strategy == :safe
323
- reasons << {
324
- type: :updated_return,
325
- message: "updated @return from #{info[:return_type]} to #{normal_type}"
326
- }
327
- end
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?
1071
+
1072
+ case visibility
1073
+ when :private then ["#{indent}# @private"]
1074
+ when :protected then ["#{indent}# @protected"]
1075
+ else []
328
1076
  end
1077
+ end
329
1078
 
330
- if config.emit_rescue_conditional_returns? && !info[:has_return]
331
- rescue_specs.each do |exceptions, rtype|
332
- lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}\n"
333
- reasons << {
334
- type: :missing_return,
335
- message: "missing conditional @return for #{exceptions.join(', ')}"
336
- }
1079
+ # Build module_function note line(s) for a full doc block.
1080
+ #
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
337
1095
  end
338
- end
339
- plugin_tags = Docscribe::Plugin.run_tag_plugins(
340
- build_plugin_context(insertion, normal_type: normal_type)
341
- )
342
- plugin_tags.each do |tag|
343
- next if info[:plugin_tags]&.[](tag.name)
344
1096
 
345
- rendered = render_plugin_tags([tag], indent).first
346
- lines << "#{rendered}\n"
347
- reasons << { type: :missing_plugin_tag, message: "missing @#{tag.name}" }
348
- end
349
- { lines: lines, reasons: reasons }
350
- rescue StandardError => e
351
- debug_warn(e, insertion: insertion, name: name || '(unknown)', phase: 'DocBuilder.build_missing_merge_result')
352
- { lines: [], reasons: [] }
1097
+ ["#{indent}# @note module_function: when included, also defines ##{name} " \
1098
+ "(instance visibility: #{included_vis})"]
353
1099
  end
354
1100
 
355
- # Parse existing doc comment lines and extract known YARD tags.
356
- #
357
- # Extracts: `@param` names, `@return`, `@raise`, `@private`, `@protected`,
358
- # `@module_function` notes, and `@option` lines.
1101
+ # Build raise tag lines for a full doc block.
359
1102
  #
360
- # @note module_function: when included, also defines #parse_existing_doc_tags (instance visibility: private)
361
- # @param [Array<String>] lines existing doc comment lines
362
- # @return [Hash] parsed tag info
363
- def parse_existing_doc_tags(lines)
364
- param_names = {}
365
- param_types = {}
366
- has_return = false
367
- return_type = nil
368
- has_private = false
369
- has_protected = false
370
- has_module_function_note = false
371
- raise_types = {}
372
- plugin_tags = {}
373
-
374
- Array(lines).each do |line|
375
- if (m = line.match(/^\s*#\s*@(\w+)\b/))
376
- plugin_tags[m[1]] = true
377
- end
378
- if (pname = extract_param_name_from_param_line(line))
379
- param_names[pname] = true
380
- if (type_match = line.match(/@param\s+\[([^\]]+)\]\s+\S+/) || line.match(/@param\s+\S+\s+\[([^\]]+)\]/))
381
- param_types[pname] = type_match[1]
382
- end
383
- end
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?
384
1110
 
385
- if line.match?(/^\s*#\s*@return\b/)
386
- has_return = true
387
- if (m = line.match(/@return\s+\[([^\]]+)\]/))
388
- return_type = m[1]
389
- end
390
- end
391
- has_private ||= line.match?(/^\s*#\s*@private\b/)
392
- has_protected ||= line.match?(/^\s*#\s*@protected\b/)
393
- has_module_function_note ||= line.match?(/^\s*#\s*@note\s+module_function:/)
1111
+ raise_types.map { |rt| "#{indent}# @raise [#{rt}]" }
1112
+ end
394
1113
 
395
- extract_raise_types_from_line(line).each { |t| raise_types[t] = true }
396
- end
1114
+ # Build return tag line for a full doc block.
1115
+ #
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)
397
1125
 
398
- {
399
- param_names: param_names,
400
- param_types: param_types,
401
- has_return: has_return,
402
- return_type: return_type,
403
- raise_types: raise_types,
404
- has_private: has_private,
405
- has_protected: has_protected,
406
- has_module_function_note: has_module_function_note,
407
- plugin_tags: plugin_tags
408
- }
1126
+ "#{indent}# @return [#{normal_type}]"
409
1127
  end
410
1128
 
411
- # Extract exception names from a `@raise` doc line.
1129
+ # Build rescue conditional return lines for a full doc block.
412
1130
  #
413
- # @note module_function: when included, also defines #extract_raise_types_from_line (instance visibility: private)
414
- # @param [String] line a `@raise` doc line
415
- # @raise [StandardError]
416
- # @return [String, nil] the exception name or nil
417
- # @return [Array] if StandardError or line not matched
418
- def extract_raise_types_from_line(line)
419
- return [] unless line.match?(/^\s*#\s*@raise\b/)
1131
+ # @note module_function: when included, also defines #build_rescue_return_lines (instance visibility: private)
1132
+ # @param [String] indent
1133
+ # @param [Array] rescue_specs
1134
+ # @param [Docscribe::Config] config
1135
+ # @return [Array<String>]
1136
+ def build_rescue_return_lines(indent, rescue_specs, config)
1137
+ return [] unless config.emit_rescue_conditional_returns?
420
1138
 
421
- if (m = line.match(/^\s*#\s*@raise\s*\[([^\]]+)\]/))
422
- parse_raise_bracket_list(m[1])
423
- elsif (m = line.match(/^\s*#\s*@raise\s+([A-Z]\w*(?:::[A-Z]\w*)*)/))
424
- [m[1]]
425
- else
426
- []
1139
+ rescue_specs.map do |exceptions, rtype|
1140
+ "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
427
1141
  end
428
- rescue StandardError
429
- []
430
1142
  end
431
1143
 
432
- # Parse exception names from a `@raise [ExceptionA, ExceptionB]` line.
1144
+ # Build plugin tag lines for a full doc block.
433
1145
  #
434
- # @note module_function: when included, also defines #parse_raise_bracket_list (instance visibility: private)
435
- # @param [String] s the `@raise` line text
436
- # @return [Array<String>, nil] the exception names or nil
437
- def parse_raise_bracket_list(s)
438
- s.to_s.split(',').map(&:strip).reject(&:empty?)
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)
439
1156
  end
440
1157
 
441
- # Build a param name => type map from a method node.
442
- #
443
- # @note module_function: when included, also defines #build_param_types_from_node (instance visibility: private)
1158
+ # Build a param line for a required argument.
1159
+ # @note module_function: when included, also defines # (instance visibility: private)
444
1160
  # @private
445
- # @param [Parser::AST::Node] node def or defs node
446
- # @param [Object, nil] external_sig external signature if available
447
- # @param [Docscribe::Config] config
448
- # @return [Hash{String => String}, nil]
449
- def build_param_types_from_node(node, external_sig:, config:)
450
- return nil unless node
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
451
1175
 
452
- args =
453
- case node.type
454
- when :def then node.children[1]
455
- when :defs then node.children[2]
456
- 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
457
1194
 
458
- return nil unless args
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])
1210
+ end
459
1211
 
460
- param_types = {}
1212
+ # Extract source text from an AST node.
1213
+ #
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
461
1221
 
462
- (args.children || []).each do |a|
463
- case a.type
464
- when :arg
465
- pname = a.children.first.to_s
466
- ty = external_sig&.param_types&.[](pname) ||
467
- Infer.infer_param_type(
468
- pname,
469
- nil,
470
- fallback_type: config.fallback_type,
471
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
472
- )
473
- param_types[pname] = ty
474
-
475
- when :optarg
476
- pname, default = *a
477
- pname = pname.to_s
478
- default_src = default&.loc&.expression&.source
479
- ty = external_sig&.param_types&.[](pname) ||
480
- Infer.infer_param_type(
481
- pname,
482
- default_src,
483
- fallback_type: config.fallback_type,
484
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
485
- )
486
- param_types[pname] = ty
487
-
488
- when :kwarg
489
- pname = a.children.first.to_s
490
- ty = external_sig&.param_types&.[](pname) ||
491
- Infer.infer_param_type(
492
- "#{pname}:",
493
- nil,
494
- fallback_type: config.fallback_type,
495
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
496
- )
497
- param_types[pname] = ty
498
-
499
- when :kwoptarg
500
- pname, default = *a
501
- pname = pname.to_s
502
- default_src = default&.loc&.expression&.source
503
- ty = external_sig&.param_types&.[](pname) ||
504
- Infer.infer_param_type(
505
- "#{pname}:",
506
- default_src,
507
- fallback_type: config.fallback_type,
508
- treat_options_keyword_as_hash: config.treat_options_keyword_as_hash?
509
- )
510
- param_types[pname] = ty
511
- end
512
- end
1222
+ # Resolve the infer name string from a param name and infer_name lambda.
1223
+ #
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
513
1231
 
514
- param_types.empty? ? nil : param_types
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])
515
1248
  end
516
1249
 
517
- # Build generated `@param` / `@option` lines for a method node.
518
- #
519
- # External signatures take precedence over inferred parameter types.
520
- #
521
- # @note module_function: when included, also defines #build_params_lines (instance visibility: private)
522
- # @param [Parser::AST::Node] node
523
- # @param [String] indent
524
- # @param [Docscribe::Types::MethodSignature, nil] external_sig
525
- # @param [Docscribe::Config] config
526
- # @return [Array<String>, nil]
527
- def build_params_lines(node, indent, external_sig:, config:)
528
- fallback_type = config.fallback_type
529
- treat_options_keyword_as_hash = config.treat_options_keyword_as_hash?
530
- param_tag_style = config.param_tag_style
531
- param_documentation = config.include_param_documentation? ? config.param_documentation : ''
532
-
533
- args =
534
- case node.type
535
- when :def then node.children[1]
536
- when :defs then node.children[2]
537
- end
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
538
1270
 
539
- return nil unless args
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
540
1290
 
541
- 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])
1306
+ end
542
1307
 
543
- (args.children || []).each do |a|
544
- case a.type
545
- when :arg
546
- pname = a.children.first.to_s
547
- ty = external_sig&.param_types&.[](pname) ||
548
- Infer.infer_param_type(
549
- pname,
550
- nil,
551
- fallback_type: fallback_type,
552
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
553
- )
554
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
555
-
556
- when :optarg
557
- pname, default = *a
558
- pname = pname.to_s
559
- default_src = default&.loc&.expression&.source
560
- ty = external_sig&.param_types&.[](pname) ||
561
- Infer.infer_param_type(
562
- pname,
563
- default_src,
564
- fallback_type: fallback_type,
565
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
566
- )
567
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
568
-
569
- hash_option_pairs(default).each do |pair|
570
- key_node, value_node = pair.children
571
- option_key = option_key_name(key_node)
572
- option_type = Infer::Literals.type_from_literal(value_node, fallback_type: fallback_type)
573
- option_default = node_default_literal(value_node)
574
-
575
- line = "#{indent}# @option #{pname} [#{option_type}] :#{option_key}"
576
- line += " (#{option_default})" if option_default
577
- line += ' Option documentation.'
578
- params << line
579
- end
580
-
581
- when :kwarg
582
- pname = a.children.first.to_s
583
- ty = external_sig&.param_types&.[](pname) ||
584
- Infer.infer_param_type(
585
- "#{pname}:",
586
- nil,
587
- fallback_type: fallback_type,
588
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
589
- )
590
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
591
-
592
- when :kwoptarg
593
- pname, default = *a
594
- pname = pname.to_s
595
- default_src = default&.loc&.expression&.source
596
- ty = external_sig&.param_types&.[](pname) ||
597
- Infer.infer_param_type(
598
- "#{pname}:",
599
- default_src,
600
- fallback_type: fallback_type,
601
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
602
- )
603
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
604
-
605
- when :restarg
606
- pname = (a.children.first || 'args').to_s
607
- ty =
608
- if external_sig&.rest_positional&.element_type
609
- "Array<#{external_sig.rest_positional.element_type}>"
610
- else
611
- Infer.infer_param_type(
612
- "*#{pname}",
613
- nil,
614
- fallback_type: fallback_type,
615
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
616
- )
617
- end
618
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
619
-
620
- when :kwrestarg
621
- pname = (a.children.first || 'kwargs').to_s
622
- ty = external_sig&.rest_keywords&.type ||
623
- Infer.infer_param_type(
624
- "**#{pname}",
625
- nil,
626
- fallback_type: fallback_type,
627
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
628
- )
629
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
630
-
631
- when :blockarg
632
- pname = (a.children.first || 'block').to_s
633
- ty = external_sig&.param_types&.[](pname) ||
634
- Infer.infer_param_type(
635
- "&#{pname}",
636
- nil,
637
- fallback_type: fallback_type,
638
- treat_options_keyword_as_hash: treat_options_keyword_as_hash
639
- )
640
- params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
641
-
642
- when :forward_arg
643
- # skip
644
- end
645
- end
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
1316
+ # @return [Object]
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
646
1325
 
647
- params.empty? ? nil : params
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)
648
1358
  end
649
1359
 
650
- # Format a `@param` tag line using the configured param tag style.
1360
+ # Format a YARD @param tag line with optional documentation text.
651
1361
  #
652
1362
  # @note module_function: when included, also defines #format_param_tag (instance visibility: private)
653
- # @param [String] indent leading whitespace
654
- # @param [String] name parameter name
655
- # @param [String] type parameter type
656
- # @param [String] documentation optional documentation text
657
- # @param [String, Symbol] style param tag style (`"name_type"` or `"type_name"`)
658
- # @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]
659
1369
  def format_param_tag(indent, name, type, documentation, style:)
660
1370
  doc = documentation.to_s.strip
661
1371
  type = type.to_s
@@ -670,45 +1380,96 @@ module Docscribe
670
1380
  doc.empty? ? line : "#{line} #{doc}"
671
1381
  end
672
1382
 
673
- # 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.
674
1399
  #
675
1400
  # @note module_function: when included, also defines #hash_option_pairs (instance visibility: private)
676
- # @param [Parser::AST::Node, nil] node a `:hash` node
677
- # @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]
678
1403
  def hash_option_pairs(node)
679
1404
  return [] unless node&.type == :hash
680
1405
 
681
1406
  node.children.select { |child| child.is_a?(Parser::AST::Node) && child.type == :pair }
682
1407
  end
683
1408
 
684
- # 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.
685
1430
  #
686
1431
  # @note module_function: when included, also defines #option_key_name (instance visibility: private)
687
- # @param [Parser::AST::Node] key_node a `:sym` node
688
- # @return [String] the option key name
1432
+ # @param [Object] key_node AST node for the hash key (:sym or :str type)
1433
+ # @return [Object]
689
1434
  def option_key_name(key_node)
690
1435
  case key_node&.type
691
1436
  when :sym, :str
692
1437
  key_node.children.first.to_s
693
1438
  else
694
- key_node&.loc&.expression&.source.to_s.sub(/\A:/, '')
1439
+ expression = key_node&.loc&.expression
1440
+ expression&.source.to_s.sub(/\A:/, '')
695
1441
  end
696
1442
  end
697
1443
 
698
- # Get the raw source literal for a default value node.
1444
+ # Extract the source text of a default value node.
699
1445
  #
700
1446
  # @note module_function: when included, also defines #node_default_literal (instance visibility: private)
701
- # @param [Parser::AST::Node, nil] node a default value node
702
- # @return [String, nil] the source literal or nil
1447
+ # @param [Object] node AST node whose source text to extract
1448
+ # @return [Object]
703
1449
  def node_default_literal(node)
704
- 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}:"]
705
1465
  end
706
1466
 
707
1467
  # Extract the parameter name from a `@param` doc line.
708
1468
  #
709
1469
  # Handles both `"@param [Type] name"` and `"@param name [Type]"` styles.
710
1470
  #
711
- # @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)
712
1473
  # @param [String] line a `@param` doc line
713
1474
  # @return [String, nil] the parameter name or nil
714
1475
  def extract_param_name_from_param_line(line)
@@ -718,17 +1479,182 @@ module Docscribe
718
1479
  nil
719
1480
  end
720
1481
 
721
- # Method documentation.
1482
+ # Extract the type from a `@param` tag line.
722
1483
  #
723
- # @note module_function: when included, also defines #extract_param_type_from_param_line (instance visibility: private)
724
- # @param [Object] line Param documentation.
725
- # @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]
726
1488
  def extract_param_type_from_param_line(line)
727
1489
  if (m = line.match(/@param\s+\[([^\]]+)\]\s+\S+/) || line.match(/@param\s+\S+\s+\[([^\]]+)\]/))
728
1490
  m[1]
729
1491
  end
730
1492
  end
731
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
+
732
1658
  # Build a Plugin::Context from a collected insertion.
733
1659
  #
734
1660
  # @note module_function
@@ -739,12 +1665,19 @@ module Docscribe
739
1665
  # @return [Docscribe::Plugin::Context]
740
1666
  def build_plugin_context(insertion, normal_type:)
741
1667
  node = insertion.node
742
- source = begin
743
- node.loc.expression.source
744
- rescue StandardError
745
- ''
746
- end
1668
+ source = safe_node_source(node)
1669
+ new_plugin_context(insertion, node, source, normal_type)
1670
+ end
747
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)
748
1681
  Docscribe::Plugin::Context.new(
749
1682
  node: node,
750
1683
  container: insertion.container,
@@ -757,6 +1690,18 @@ module Docscribe
757
1690
  )
758
1691
  end
759
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
+
760
1705
  # Render plugin tags as indented comment lines.
761
1706
  #
762
1707
  # @note module_function
@@ -771,39 +1716,6 @@ module Docscribe
771
1716
  "#{indent}# @#{tag.name}#{type_part}#{text_part}"
772
1717
  end
773
1718
  end
774
-
775
- # Print a debug warning for a failed doc build phase.
776
- #
777
- # @note module_function: when included, also defines #debug_warn (instance visibility: private)
778
- # @param [StandardError] e the error that occurred
779
- # @param [Collector::Insertion] insertion the method insertion being processed
780
- # @param [String] name the method name
781
- # @param [String] phase the processing phase
782
- # @return [void]
783
- def debug_warn(e, insertion:, name:, phase:)
784
- return unless debug?
785
-
786
- node = insertion&.node
787
- buf_name = node&.loc&.expression&.source_buffer&.name || '(unknown)'
788
- line = node&.loc&.expression&.line
789
- scope = insertion&.scope
790
- method_symbol = scope == :class ? '.' : '#'
791
- container = insertion&.container || 'Object'
792
-
793
- where = +buf_name.to_s
794
- where << ":#{line}" if line
795
- where << " #{container}#{method_symbol}#{name}"
796
-
797
- warn "Docscribe DEBUG: #{phase} failed at #{where}: #{e.class}: #{e.message}"
798
- end
799
-
800
- # Check whether debug mode is enabled.
801
- #
802
- # @note module_function: when included, also defines #debug? (instance visibility: private)
803
- # @return [Boolean]
804
- def debug?
805
- ENV['DOCSCRIBE_DEBUG'] == '1'
806
- end
807
1719
  end
808
1720
  end
809
1721
  end