docscribe 1.4.0 → 1.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50c74b921731749cbf32726e24e557e526e1f65966e704171a652d10cc4ce945
4
- data.tar.gz: 2e254911cf9d81f5d5f7b5df29576c3c5c3c3f0433cfb387c0d22c1ce30eeb60
3
+ metadata.gz: e59e0f95a3faa67d4d9d7829ed32e2acd30f3fa5b3236f93d4b5f8bb63ae7616
4
+ data.tar.gz: cae706be421e11e31c6124a5e918c407b09a0cdd4435c2ad2d427b63ea4a9f80
5
5
  SHA512:
6
- metadata.gz: 824133cf33505568edc84fe99bd51ced4451616552b2ba33e5584fcbd68bdd2ede0e4df365510a4e6be73ef9bf191a66e37746a5331e2ccb8abae2a80a3f69b4
7
- data.tar.gz: 6662f828e5d324c226432df0e00359a36a97cd47dfeedefd4ead014752fbebe51b93e6cb693d6a342ca3d7e24683b74c189576792e558bf12e28bcc6365e2ed2
6
+ metadata.gz: c864edd9a99ad81667030ccfc13936ebb8d13948528bc288235aa2e4ff0de349b16eb3bf3f2b9606d33c8af9129357639a29db319e0c62bddd6ec1df997a7fea
7
+ data.tar.gz: 4c62242b2455c5a6117ba4e1f02335b911b4894c6f15aa852c704d9583cd89ca6e0439fcc4cee6d6b3decb6e33afb75c90effa71a5a24c9e39f3fe56e0245a35
data/README.md CHANGED
@@ -68,7 +68,10 @@ Common workflows:
68
68
  * [Plugin system](#plugin-system)
69
69
  * [TagPlugin](#tagplugin)
70
70
  * [CollectorPlugin](#collectorplugin)
71
+ * [Plugin doc normalization (CollectorPlugin)](#plugin-doc-normalization-collectorplugin)
72
+ * [`method_override` (structured patch)](#method_override-structured-patch)
71
73
  * [Registering plugins](#registering-plugins)
74
+ * [Plugin priority](#plugin-priority)
72
75
  * [Idempotency](#idempotency)
73
76
  * [Plugin examples](#plugin-examples)
74
77
  * [Configuration](#configuration)
@@ -865,17 +868,49 @@ class DefineMethodPlugin < Docscribe::Plugin::Base::CollectorPlugin
865
868
  end
866
869
  ```
867
870
 
868
- Each result hash must have:
871
+ Each result hash must have `:anchor_node` plus either `:doc` or `:method_override`:
869
872
 
870
- | Key | Type | Description |
871
- |----------------|---------------------|--------------------------------------------|
872
- | `:anchor_node` | `Parser::AST::Node` | Node above which to insert the doc block |
873
- | `:doc` | `String` | Complete doc block text including newlines |
873
+ | Key | Type | Description |
874
+ |--------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------|
875
+ | `:anchor_node` | `Parser::AST::Node` | Node above which to insert the doc block |
876
+ | `:doc` | `String` | Complete doc block text including newlines (Docscribe may normalize indentation and default message for method anchors) |
877
+ | `:method_override` | `Hash` | Structured overrides that patch the standard DocBuilder output instead of replacing it (see below) |
874
878
 
875
879
  > [!NOTE]
876
880
  > You do not need to handle indentation manually. Docscribe reads the indentation from `anchor_node` and applies it to
877
881
  > every line of `:doc` automatically.
878
882
 
883
+ #### Plugin doc normalization (CollectorPlugin)
884
+
885
+ When returning `doc:`, CollectorPlugins provide raw strings that Docscribe inserts without running the standard
886
+ method DocBuilder pipeline. Docscribe does normalize indentation and may prepend the default method message for
887
+ `def/defs` anchors, but headers (`emit.header`) and param/return tags must be included explicitly.
888
+
889
+ #### `method_override` (structured patch)
890
+
891
+ When a CollectorPlugin targets a `def` method, it can return `method_override:` instead of `doc:` to patch the
892
+ standard DocBuilder output rather than replace it entirely:
893
+
894
+ ```ruby
895
+ {
896
+ anchor_node: node,
897
+ method_override: {
898
+ return_type: 'ActiveRecord::Relation', # overrides @return
899
+ param_types: { 'period' => 'Integer' }, # merges into @param types
900
+ tags: [Docscribe::Plugin::Tag.new(name: 'since', text: '2.0')] # additional tags
901
+ }
902
+ }
903
+ ```
904
+
905
+ | Key | Type | Description |
906
+ |----------------|------------------------|-------------------------------------------------------------|
907
+ | `:return_type` | `String` | Overrides the `@return` type name |
908
+ | `:param_types` | `Hash{String=>String}` | Merges into inferred param types (external sig wins) |
909
+ | `:tags` | `Array<Tag/Hash>` | Tags appended to the doc block (Hash auto-converted to Tag) |
910
+
911
+ `method_override` merges at the data level before rendering, so the standard pipeline still generates headers,
912
+ `@param` tags, default messages, and tag sorting. Only the specified fields are overridden.
913
+
879
914
  ### Registering plugins
880
915
 
881
916
  Plugins are registered at load time. The recommended pattern is to put registrations in a dedicated file and reference
@@ -932,9 +967,11 @@ Higher number means higher priority.
932
967
 
933
968
  **CollectorPlugin priority (conflicts at the same source position):**
934
969
 
935
- - If a plugin insertion and a standard *method* insertion share the same source position (
936
- `anchor_node.loc.expression.begin_pos`), the standard insertion is dropped and the plugin insertion is kept.
937
- - If multiple CollectorPlugins insert at the same source position, only insertions from the highest-priority plugin(s)
970
+ - For `doc:` insertions: if a plugin insertion and a standard *method* insertion share the same source position
971
+ (`anchor_node.loc.expression.begin_pos`), the standard insertion is dropped and the plugin insertion is kept.
972
+ - For `method_override:` insertions: the method insertion is kept and patched with the override data. The standard
973
+ DocBuilder pipeline still runs (generating `@param`, headers, etc.), and only the specified fields are overridden.
974
+ - If multiple CollectorPlugins target the same source position, only insertions from the highest-priority plugin(s)
938
975
  are kept (ties are kept).
939
976
  - Multiple insertions from the winning plugin(s) at the same position are preserved (e.g. one `@!attribute` per column).
940
977
 
@@ -203,6 +203,7 @@ module Docscribe
203
203
  # @return [Boolean]
204
204
  def looks_like_file_pattern?(pat)
205
205
  return false if pat.start_with?('/') && pat.end_with?('/') && pat.length >= 2
206
+ return false if pat.match?(%r{\A\*/})
206
207
 
207
208
  pat.include?('/') || pat.include?('**') || pat.end_with?('.rb')
208
209
  end
@@ -65,7 +65,7 @@ module Docscribe
65
65
  #
66
66
  # Supports:
67
67
  # - `/regex/`
68
- # - shell-style glob patterns
68
+ # - shell-style glob patterns (with `/` translated to `#` since method IDs use `#`)
69
69
  #
70
70
  # @private
71
71
  # @param [String] pattern
@@ -75,7 +75,7 @@ module Docscribe
75
75
  if pattern.start_with?('/') && pattern.end_with?('/') && pattern.length >= 2
76
76
  Regexp.new(pattern[1..-2]).match?(text)
77
77
  else
78
- File.fnmatch?(pattern, text, File::FNM_EXTGLOB)
78
+ File.fnmatch?(pattern.tr('/', '#'), text, File::FNM_EXTGLOB)
79
79
  end
80
80
  end
81
81
 
@@ -30,9 +30,11 @@ module Docscribe
30
30
  # `signature_for(container:, scope:, name:)`
31
31
  # @param [nil] core_rbs_provider Param documentation.
32
32
  # @param [nil] param_types Param documentation.
33
+ # @param [nil] return_type_override Param documentation.
34
+ # @param [nil] override_tags Param documentation.
33
35
  # @raise [StandardError]
34
36
  # @return [String, nil]
35
- def build(insertion, config:, signature_provider: nil, core_rbs_provider: nil, param_types: nil)
37
+ def build(insertion, config:, signature_provider: nil, core_rbs_provider: nil, param_types: nil, return_type_override: nil, override_tags: nil)
36
38
  node = insertion.node
37
39
  name = SourceHelpers.node_name(node)
38
40
  return nil unless name
@@ -53,7 +55,7 @@ module Docscribe
53
55
  param_types || build_param_types_from_node(node, external_sig: external_sig, config: config)
54
56
 
55
57
  if config.emit_param_tags?
56
- params_lines = build_params_lines(node, indent, external_sig: external_sig, config: config)
58
+ params_lines = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: effective_param_types)
57
59
  end
58
60
  raise_types = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(node) : []
59
61
 
@@ -65,7 +67,7 @@ module Docscribe
65
67
  core_rbs_provider: core_rbs_provider
66
68
  )
67
69
 
68
- normal_type = external_sig&.return_type || returns_spec[:normal]
70
+ normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
69
71
  rescue_specs = returns_spec[:rescues] || []
70
72
 
71
73
  lines = []
@@ -110,9 +112,9 @@ module Docscribe
110
112
  lines << "#{indent}# @return [#{rtype}] if #{exceptions.join(', ')}"
111
113
  end
112
114
  end
113
- plugin_tags = Docscribe::Plugin.run_tag_plugins(
114
- build_plugin_context(insertion, normal_type: normal_type)
115
- )
115
+ plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(insertion, normal_type: normal_type))
116
+ plugin_tags.concat(Array(override_tags)) if override_tags
117
+
116
118
  lines.concat(render_plugin_tags(plugin_tags, indent))
117
119
  lines.map { |l| "#{l}\n" }.join
118
120
  rescue StandardError => e
@@ -132,10 +134,11 @@ module Docscribe
132
134
  # @param [Object, nil] signature_provider
133
135
  # @param [nil] core_rbs_provider Param documentation.
134
136
  # @param [nil] param_types Param documentation.
137
+ # @param [nil] return_type_override Param documentation.
135
138
  # @raise [StandardError]
136
139
  # @return [String, nil]
137
140
  def build_merge_additions(insertion, existing_lines:, config:, signature_provider: nil, core_rbs_provider: nil,
138
- param_types: nil)
141
+ param_types: nil, return_type_override: nil)
139
142
  node = insertion.node
140
143
  name = SourceHelpers.node_name(node)
141
144
  return '' unless name
@@ -159,7 +162,7 @@ module Docscribe
159
162
  core_rbs_provider: core_rbs_provider
160
163
  )
161
164
 
162
- normal_type = external_sig&.return_type || returns_spec[:normal]
165
+ normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
163
166
  rescue_specs = returns_spec[:rescues] || []
164
167
 
165
168
  lines = []
@@ -180,7 +183,7 @@ module Docscribe
180
183
  end
181
184
 
182
185
  if config.emit_param_tags?
183
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config)
186
+ all_params = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: param_types)
184
187
  all_params&.each do |pl|
185
188
  pname = extract_param_name_from_param_line(pl)
186
189
  next if pname.nil? || info[:param_names].include?(pname)
@@ -229,10 +232,12 @@ module Docscribe
229
232
  # @param [nil] core_rbs_provider Param documentation.
230
233
  # @param [nil] param_types Param documentation.
231
234
  # @param [nil] strategy Param documentation.
235
+ # @param [nil] return_type_override Param documentation.
236
+ # @param [nil] override_tags Param documentation.
232
237
  # @raise [StandardError]
233
238
  # @return [Hash]
234
239
  def build_missing_merge_result(insertion, existing_lines:, config:, signature_provider: nil,
235
- core_rbs_provider: nil, param_types: nil, strategy: nil)
240
+ core_rbs_provider: nil, param_types: nil, strategy: nil, return_type_override: nil, override_tags: nil)
236
241
  node = insertion.node
237
242
  name = SourceHelpers.node_name(node)
238
243
  return { lines: [], reasons: [] } unless name
@@ -256,7 +261,7 @@ module Docscribe
256
261
  core_rbs_provider: core_rbs_provider
257
262
  )
258
263
 
259
- normal_type = external_sig&.return_type || returns_spec[:normal]
264
+ normal_type = return_type_override || external_sig&.return_type || returns_spec[:normal]
260
265
  rescue_specs = returns_spec[:rescues] || []
261
266
 
262
267
  lines = []
@@ -280,7 +285,7 @@ module Docscribe
280
285
  end
281
286
 
282
287
  if config.emit_param_tags?
283
- all_params = build_params_lines(node, indent, external_sig: external_sig, config: config)
288
+ all_params = build_params_lines(node, indent, external_sig: external_sig, config: config, param_types_override: param_types)
284
289
 
285
290
  all_params&.each do |pl|
286
291
  pname = extract_param_name_from_param_line(pl)
@@ -336,9 +341,9 @@ module Docscribe
336
341
  }
337
342
  end
338
343
  end
339
- plugin_tags = Docscribe::Plugin.run_tag_plugins(
340
- build_plugin_context(insertion, normal_type: normal_type)
341
- )
344
+ plugin_tags = Docscribe::Plugin.run_tag_plugins(build_plugin_context(insertion, normal_type: normal_type))
345
+ plugin_tags.concat(Array(override_tags)) if override_tags
346
+
342
347
  plugin_tags.each do |tag|
343
348
  next if info[:plugin_tags]&.[](tag.name)
344
349
 
@@ -523,8 +528,9 @@ module Docscribe
523
528
  # @param [String] indent
524
529
  # @param [Docscribe::Types::MethodSignature, nil] external_sig
525
530
  # @param [Docscribe::Config] config
531
+ # @param [nil] param_types_override Param documentation.
526
532
  # @return [Array<String>, nil]
527
- def build_params_lines(node, indent, external_sig:, config:)
533
+ def build_params_lines(node, indent, external_sig:, config:, param_types_override: nil)
528
534
  fallback_type = config.fallback_type
529
535
  treat_options_keyword_as_hash = config.treat_options_keyword_as_hash?
530
536
  param_tag_style = config.param_tag_style
@@ -545,6 +551,7 @@ module Docscribe
545
551
  when :arg
546
552
  pname = a.children.first.to_s
547
553
  ty = external_sig&.param_types&.[](pname) ||
554
+ override_param_type_for(pname, param_types_override) ||
548
555
  Infer.infer_param_type(
549
556
  pname,
550
557
  nil,
@@ -558,6 +565,7 @@ module Docscribe
558
565
  pname = pname.to_s
559
566
  default_src = default&.loc&.expression&.source
560
567
  ty = external_sig&.param_types&.[](pname) ||
568
+ override_param_type_for(pname, param_types_override) ||
561
569
  Infer.infer_param_type(
562
570
  pname,
563
571
  default_src,
@@ -581,6 +589,7 @@ module Docscribe
581
589
  when :kwarg
582
590
  pname = a.children.first.to_s
583
591
  ty = external_sig&.param_types&.[](pname) ||
592
+ override_param_type_for(pname, param_types_override) ||
584
593
  Infer.infer_param_type(
585
594
  "#{pname}:",
586
595
  nil,
@@ -594,6 +603,7 @@ module Docscribe
594
603
  pname = pname.to_s
595
604
  default_src = default&.loc&.expression&.source
596
605
  ty = external_sig&.param_types&.[](pname) ||
606
+ override_param_type_for(pname, param_types_override) ||
597
607
  Infer.infer_param_type(
598
608
  "#{pname}:",
599
609
  default_src,
@@ -608,18 +618,20 @@ module Docscribe
608
618
  if external_sig&.rest_positional&.element_type
609
619
  "Array<#{external_sig.rest_positional.element_type}>"
610
620
  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
- )
621
+ override_param_type_for(pname, param_types_override) ||
622
+ Infer.infer_param_type(
623
+ "*#{pname}",
624
+ nil,
625
+ fallback_type: fallback_type,
626
+ treat_options_keyword_as_hash: treat_options_keyword_as_hash
627
+ )
617
628
  end
618
629
  params << format_param_tag(indent, pname, ty, param_documentation, style: param_tag_style)
619
630
 
620
631
  when :kwrestarg
621
632
  pname = (a.children.first || 'kwargs').to_s
622
633
  ty = external_sig&.rest_keywords&.type ||
634
+ override_param_type_for(pname, param_types_override) ||
623
635
  Infer.infer_param_type(
624
636
  "**#{pname}",
625
637
  nil,
@@ -631,6 +643,7 @@ module Docscribe
631
643
  when :blockarg
632
644
  pname = (a.children.first || 'block').to_s
633
645
  ty = external_sig&.param_types&.[](pname) ||
646
+ override_param_type_for(pname, param_types_override) ||
634
647
  Infer.infer_param_type(
635
648
  "&#{pname}",
636
649
  nil,
@@ -647,6 +660,19 @@ module Docscribe
647
660
  params.empty? ? nil : params
648
661
  end
649
662
 
663
+ # Method documentation.
664
+ #
665
+ # @note module_function: when included, also defines #override_param_type_for (instance visibility: private)
666
+ # @param [Object] pname Param documentation.
667
+ # @param [Object] override_map Param documentation.
668
+ # @return [Object]
669
+ def override_param_type_for(pname, override_map)
670
+ return nil unless override_map
671
+
672
+ key = pname.to_s
673
+ override_map[key] || override_map[:"#{key}"] || override_map["#{key}:"] || override_map[:"#{key}:"]
674
+ end
675
+
650
676
  # Format a `@param` tag line using the configured param tag style.
651
677
  #
652
678
  # @note module_function: when included, also defines #format_param_tag (instance visibility: private)
@@ -72,7 +72,7 @@ module Docscribe
72
72
  # @raise [Docscribe::ParseError]
73
73
  # @raise [StandardError]
74
74
  # @return [Hash]
75
- def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, config: Docscribe::Config.new({}),
75
+ def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, config: nil,
76
76
  core_rbs_provider: nil, file: '(inline)')
77
77
  strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
78
78
  validate_strategy!(strategy)
@@ -102,7 +102,9 @@ module Docscribe
102
102
  all = method_insertions.map { |i| [:method, i] } +
103
103
  attr_insertions.map { |i| [:attr, i] } +
104
104
  plugin_insertions.map { |i| [:plugin, i] }
105
- all = deduplicate_insertions(all)
105
+
106
+ method_overrides_by_pos = {}
107
+ all = deduplicate_insertions(all, method_overrides_by_pos: method_overrides_by_pos)
106
108
  rewriter = Parser::Source::TreeRewriter.new(buffer)
107
109
  merge_inserts = Hash.new { |h, k| h[k] = [] }
108
110
  changes = []
@@ -111,6 +113,9 @@ module Docscribe
111
113
  .reverse_each do |kind, ins|
112
114
  case kind
113
115
  when :method
116
+ pos = plugin_insertion_pos(:method, ins)
117
+ method_override = method_overrides_by_pos[pos]
118
+
114
119
  apply_method_insertion!(
115
120
  rewriter: rewriter,
116
121
  buffer: buffer,
@@ -120,7 +125,8 @@ module Docscribe
120
125
  core_rbs_provider: core_rbs_provider,
121
126
  strategy: strategy,
122
127
  changes: changes,
123
- file: file.to_s
128
+ file: file.to_s,
129
+ method_override: method_override
124
130
  )
125
131
  when :attr
126
132
  apply_attr_insertion!(
@@ -137,7 +143,8 @@ module Docscribe
137
143
  rewriter: rewriter,
138
144
  buffer: buffer,
139
145
  insertion: ins,
140
- strategy: strategy
146
+ strategy: strategy,
147
+ config: config
141
148
  )
142
149
  end
143
150
  end
@@ -161,61 +168,132 @@ module Docscribe
161
168
  #
162
169
  # @private
163
170
  # @param [Array<Array(Symbol,Object)>] insertions tagged insertion list
171
+ # @param [nil] method_overrides_by_pos Param documentation.
164
172
  # @return [Array<Array(Symbol,Object)>]
165
- def deduplicate_insertions(insertions)
173
+ def deduplicate_insertions(insertions, method_overrides_by_pos: nil)
166
174
  groups = {}
167
175
 
168
- insertions.each do |item|
169
- kind, ins = item
176
+ insertions.each do |kind, ins|
170
177
  pos = plugin_insertion_pos(kind, ins)
171
- (groups[pos] ||= []) << item
178
+ (groups[pos] ||= []) << [kind, ins]
172
179
  end
173
180
 
174
181
  result = []
175
182
 
176
183
  groups.each do |pos, items|
184
+ # plugin insertions at this pos
177
185
  plugin_items = items.select { |k, _| k == :plugin }
178
186
 
179
- # No plugins at this position -> keep as-is
187
+ # no plugins -> keep as-is
180
188
  if plugin_items.empty?
181
189
  result.concat(items)
182
190
  next
183
191
  end
184
192
 
185
- # Rule 1: plugin overrides method insertion at the same position
186
- items = items.reject { |k, _| k == :method }
193
+ method_items = items.select { |k, _| k == :method }
194
+
195
+ plugin_doc_items =
196
+ plugin_items.select do |_k, ins|
197
+ ins.is_a?(Hash) && ins[:doc] && !ins[:doc].empty?
198
+ end
199
+
200
+ override_items =
201
+ plugin_items.select do |_k, ins|
202
+ ins.is_a?(Hash) && ins[:method_override].is_a?(Hash)
203
+ end
187
204
 
188
- # Rule 2: keep only highest-priority plugin insertions
189
- max_prio = plugin_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
205
+ # --- Case A: raw plugin doc exists => legacy behavior: plugin replaces method ---
206
+ if plugin_doc_items.any?
207
+ # drop standard method insertions at same pos
208
+ items = items.reject { |k, _| k == :method }
190
209
 
191
- items = items.reject do |k, ins|
192
- k == :plugin && plugin_insertion_priority(ins) < max_prio
193
- end
210
+ # drop method_overrides too (they have nothing to patch)
211
+ items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
194
212
 
195
- # Warn on ties between different plugins at the winning priority
196
- if Docscribe::Plugin.debug?
197
- kept_plugin_labels =
198
- items
199
- .select { |k, _| k == :plugin }
200
- .map { |_k, ins| plugin_insertion_label(ins) }
201
- .uniq
213
+ # keep only highest-priority plugin docs
214
+ max_prio = plugin_doc_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
202
215
 
203
- if kept_plugin_labels.size > 1
216
+ dropped = items.select { |k, ins| k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio }
217
+
218
+ items = items.reject do |k, ins|
219
+ k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio
220
+ end
221
+
222
+ if Docscribe::Plugin.debug? && dropped.any?
223
+ kept_labels = items.select { |k, _| k == :plugin }
224
+ .map { |_k, ins| plugin_insertion_label(ins) }
225
+ .uniq
226
+ dropped_labels = dropped.map { |_k, ins| plugin_insertion_label(ins) }.uniq
204
227
  line = plugin_insertion_line(plugin_items.first[1])
205
228
  loc = +"pos=#{pos}"
206
229
  loc << " line=#{line}" if line
230
+ warn "Docscribe: CollectorPlugin conflict at #{loc} — " \
231
+ "#{dropped_labels.join(', ')} (pri=#{dropped.map { |_k, ins| plugin_insertion_priority(ins) }.max}) " \
232
+ "dropped in favor of #{kept_labels.join(', ')} (pri=#{max_prio}). " \
233
+ 'Set explicit priority or adjust anchor_node to avoid collision.'
234
+ end
235
+
236
+ result.concat(items)
237
+ next
238
+ end
207
239
 
208
- warn "Docscribe: CollectorPlugin conflict at #{loc} (priority=#{max_prio}): " \
209
- "#{kept_plugin_labels.join(', ')} — keeping all. Set explicit priority to resolve."
240
+ # --- Case B: method_override exists and there is a method insertion => patch method ---
241
+ if override_items.any? && method_items.any?
242
+ if method_overrides_by_pos
243
+ winning_ins = pick_highest_priority_override_insertion(override_items, pos: pos)
244
+ method_overrides_by_pos[pos] = winning_ins[:method_override] if winning_ins
210
245
  end
246
+
247
+ # remove override items from final insertions (they are applied via method_overrides_by_pos)
248
+ items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
249
+
250
+ result.concat(items)
251
+ next
211
252
  end
212
253
 
254
+ # --- Case C: override exists, but no method insertion (filtered out, etc.) => ignore override ---
255
+ items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
213
256
  result.concat(items)
214
257
  end
215
258
 
216
259
  result
217
260
  end
218
261
 
262
+ # @private
263
+ # @param override_items [Array<Array(Symbol, Hash)>] list of [:plugin, insertion_hash] that include :method_override
264
+ # @param pos [Integer] begin_pos (used only for debug output)
265
+ # @return [Hash, nil] winning insertion hash (the one whose override will be applied)
266
+ def pick_highest_priority_override_insertion(override_items, pos:)
267
+ return nil if override_items.empty?
268
+
269
+ max_prio =
270
+ override_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
271
+
272
+ winners =
273
+ override_items.select { |_k, ins| plugin_insertion_priority(ins) == max_prio }
274
+
275
+ # Deterministic tie-break: smallest plugin order wins.
276
+ # (We warn in debug if the tie is between different plugins.)
277
+ winners_sorted =
278
+ winners.sort_by do |_k, ins|
279
+ order = ins.is_a?(Hash) ? ins[:__docscribe_plugin_order] : nil
280
+ order.nil? ? 0 : order
281
+ end
282
+
283
+ if Docscribe::Plugin.debug?
284
+ labels = winners_sorted.map { |_k, ins| plugin_insertion_label(ins) }.uniq
285
+ if labels.size > 1
286
+ line = plugin_insertion_line(winners_sorted.first[1])
287
+ loc = +"pos=#{pos}"
288
+ loc << " line=#{line}" if line
289
+ warn "Docscribe: method_override conflict at #{loc} (priority=#{max_prio}): " \
290
+ "#{labels.join(', ')} — using first by registration order."
291
+ end
292
+ end
293
+
294
+ winners_sorted.first[1]
295
+ end
296
+
219
297
  # @private
220
298
  # @param [Hash] insertion
221
299
  # @raise [StandardError]
@@ -279,14 +357,15 @@ module Docscribe
279
357
  # @param [Parser::Source::Buffer] buffer
280
358
  # @param [Hash] insertion { anchor_node:, doc: }
281
359
  # @param [Symbol] strategy
360
+ # @param [Docscribe::Config] config
282
361
  # @return [void]
283
- def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:)
362
+ def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:, config:)
284
363
  anchor_node = insertion[:anchor_node]
285
364
  doc = insertion[:doc]
286
365
  return unless anchor_node && doc && !doc.empty?
287
366
 
288
367
  indent = SourceHelpers.line_indent(anchor_node)
289
- doc = normalize_plugin_doc_indent(doc, indent)
368
+ doc = normalize_plugin_doc(doc, indent, config: config, anchor_node: anchor_node)
290
369
  bol_range = SourceHelpers.line_start_range(buffer, anchor_node)
291
370
 
292
371
  case strategy
@@ -344,6 +423,50 @@ module Docscribe
344
423
  Parser::Source::Range.new(buffer, start_pos, bol_pos)
345
424
  end
346
425
 
426
+ # Normalize a CollectorPlugin-provided doc string before insertion.
427
+ #
428
+ # Responsibilities:
429
+ # - apply indentation based on the anchor node
430
+ # - trim trailing whitespace-only lines
431
+ # - (optionally) prepend the configured default message for `def/defs` anchors
432
+ # when the plugin output contains only tags (no prose)
433
+ #
434
+ # @private
435
+ # @param doc [String] Raw doc string returned by a CollectorPlugin insertion (`:doc`)
436
+ # @param indent [String] Indentation to apply to every doc line
437
+ # @param config [Docscribe::Config] Effective Docscribe config for this run
438
+ # @param anchor_node [Parser::AST::Node, nil] AST node used as insertion anchor
439
+ # @return [String] Normalized doc string ready to be inserted
440
+ def normalize_plugin_doc(doc, indent, config:, anchor_node:)
441
+ doc = normalize_plugin_doc_indent(doc, indent)
442
+
443
+ lines = doc.lines
444
+ lines.pop while lines.any? && lines.last.strip.empty?
445
+
446
+ doc = lines.join
447
+ doc << "\n" unless doc.end_with?("\n")
448
+
449
+ if %i[def defs].include?(anchor_node&.type) && config.include_default_message?
450
+ scope = anchor_node.type == :defs ? :class : :instance
451
+ msg = config.default_message(scope, :public)
452
+
453
+ has_prose = doc.lines.any? do |l|
454
+ s = l.strip
455
+ next false if s.empty? || s == '#'
456
+ next false if s.start_with?('# @') # tag line
457
+ next false if s.start_with?('# +') # header line
458
+
459
+ true
460
+ end
461
+
462
+ unless has_prose
463
+ doc = "#{indent}# #{msg}\n#{indent}#\n" + doc
464
+ end
465
+ end
466
+
467
+ doc
468
+ end
469
+
347
470
  # Normalize indentation of a plugin-generated doc block.
348
471
  #
349
472
  # Plugins produce doc strings without knowledge of the surrounding
@@ -414,9 +537,10 @@ module Docscribe
414
537
  # @param [Array<Hash>] changes structured change records
415
538
  # @param [String] file
416
539
  # @param [Object] core_rbs_provider Param documentation.
540
+ # @param [nil] method_override Param documentation.
417
541
  # @return [void]
418
542
  def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, core_rbs_provider:,
419
- strategy:, changes:, file:)
543
+ strategy:, changes:, file:, method_override: nil)
420
544
  name = SourceHelpers.node_name(insertion.node)
421
545
 
422
546
  return unless config.process_method?(
@@ -434,6 +558,24 @@ module Docscribe
434
558
  scope: insertion.scope,
435
559
  name: SourceHelpers.node_name(insertion.node)
436
560
  )
561
+ override_return_type =
562
+ method_override.is_a?(Hash) ? method_override[:return_type] : nil
563
+
564
+ override_param_types =
565
+ method_override.is_a?(Hash) && method_override[:param_types].is_a?(Hash) ? method_override[:param_types] : nil
566
+
567
+ override_tags =
568
+ if method_override.is_a?(Hash)
569
+ Array(method_override[:tags]).filter_map do |t|
570
+ case t
571
+ when Docscribe::Plugin::Tag then t
572
+ when Hash
573
+ Docscribe::Plugin::Tag.new(**t.transform_keys(&:to_sym))
574
+ end
575
+ end
576
+ else
577
+ []
578
+ end
437
579
 
438
580
  case strategy
439
581
  when :aggressive
@@ -447,8 +589,20 @@ module Docscribe
447
589
  config: config
448
590
  )
449
591
 
450
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
451
- core_rbs_provider: core_rbs_provider, param_types: effective_param_types)
592
+ if override_param_types && !override_param_types.empty?
593
+ effective_param_types = effective_param_types.merge(override_param_types)
594
+ end
595
+
596
+ doc = build_method_doc(
597
+ insertion,
598
+ config: config,
599
+ signature_provider: signature_provider,
600
+ core_rbs_provider: core_rbs_provider,
601
+ param_types: effective_param_types,
602
+ return_type_override: override_return_type,
603
+ override_tags: override_tags
604
+ )
605
+
452
606
  return if doc.nil? || doc.empty?
453
607
 
454
608
  rewriter.insert_before(anchor_bol_range, doc)
@@ -464,6 +618,16 @@ module Docscribe
464
618
  when :safe
465
619
  info = method_doc_comment_info(buffer, insertion)
466
620
 
621
+ effective_param_types = external_sig&.param_types || DocBuilder.build_param_types_from_node(
622
+ insertion.node,
623
+ external_sig: external_sig,
624
+ config: config
625
+ )
626
+
627
+ if override_param_types && !override_param_types.empty?
628
+ effective_param_types = effective_param_types.merge(override_param_types)
629
+ end
630
+
467
631
  if info
468
632
  merge_result = build_missing_method_merge_result(
469
633
  insertion,
@@ -471,8 +635,10 @@ module Docscribe
471
635
  config: config,
472
636
  signature_provider: signature_provider,
473
637
  core_rbs_provider: core_rbs_provider,
474
- param_types: external_sig&.param_types,
475
- strategy: strategy
638
+ param_types: effective_param_types,
639
+ strategy: strategy,
640
+ return_type_override: override_return_type,
641
+ override_tags: override_tags
476
642
  )
477
643
 
478
644
  missing_lines = merge_result[:lines]
@@ -529,9 +695,15 @@ module Docscribe
529
695
  return
530
696
  end
531
697
 
532
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
533
- core_rbs_provider: core_rbs_provider,
534
- param_types: external_sig&.param_types)
698
+ doc = build_method_doc(
699
+ insertion,
700
+ config: config,
701
+ signature_provider: signature_provider,
702
+ core_rbs_provider: core_rbs_provider,
703
+ param_types: effective_param_types,
704
+ return_type_override: override_return_type,
705
+ override_tags: override_tags
706
+ )
535
707
  return if doc.nil? || doc.empty?
536
708
 
537
709
  rewriter.insert_before(anchor_bol_range, doc)
@@ -878,14 +1050,19 @@ module Docscribe
878
1050
  # @param [Object, nil] signature_provider external signature provider
879
1051
  # @param [Object, nil] core_rbs_provider RBS core type provider
880
1052
  # @param [Hash, nil] param_types parameter name -> type map
1053
+ # @param [Object] return_type_override Param documentation.
1054
+ # @param [Object] override_tags Param documentation.
881
1055
  # @return [String, nil] generated doc block or nil
882
- def build_method_doc(insertion, config:, signature_provider:, core_rbs_provider:, param_types:)
1056
+ def build_method_doc(insertion, config:, signature_provider:, core_rbs_provider:, param_types:, return_type_override:,
1057
+ override_tags:)
883
1058
  DocBuilder.build(
884
1059
  insertion,
885
1060
  config: config,
886
1061
  signature_provider: signature_provider,
887
1062
  core_rbs_provider: core_rbs_provider,
888
- param_types: param_types
1063
+ param_types: param_types,
1064
+ return_type_override: return_type_override,
1065
+ override_tags: override_tags
889
1066
  )
890
1067
  end
891
1068
 
@@ -899,9 +1076,11 @@ module Docscribe
899
1076
  # @param [Object, nil] core_rbs_provider RBS core type provider
900
1077
  # @param [Hash, nil] param_types parameter name -> type map
901
1078
  # @param [Object] strategy Param documentation.
1079
+ # @param [Object] return_type_override Param documentation.
1080
+ # @param [nil] override_tags Param documentation.
902
1081
  # @return [Hash] result with `:lines` and `:reasons` keys
903
1082
  def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:,
904
- core_rbs_provider:, param_types:, strategy:)
1083
+ core_rbs_provider:, param_types:, strategy:, return_type_override:, override_tags: nil)
905
1084
  DocBuilder.build_missing_merge_result(
906
1085
  insertion,
907
1086
  existing_lines: existing_lines,
@@ -909,7 +1088,9 @@ module Docscribe
909
1088
  signature_provider: signature_provider,
910
1089
  core_rbs_provider: core_rbs_provider,
911
1090
  param_types: param_types,
912
- strategy: strategy
1091
+ strategy: strategy,
1092
+ return_type_override: return_type_override,
1093
+ override_tags: override_tags
913
1094
  )
914
1095
  end
915
1096
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docscribe
4
- VERSION = '1.4.0'
4
+ VERSION = '1.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docscribe
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite