docscribe 1.3.3 → 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: 755d272674fe454b80cc008acb50a879da920def321442cdc8cdd7c450b91f52
4
- data.tar.gz: 92aa2320a36162272acfa2b430bfa9cccf5a4582770885a996bc61d7a4198bb6
3
+ metadata.gz: e59e0f95a3faa67d4d9d7829ed32e2acd30f3fa5b3236f93d4b5f8bb63ae7616
4
+ data.tar.gz: cae706be421e11e31c6124a5e918c407b09a0cdd4435c2ad2d427b63ea4a9f80
5
5
  SHA512:
6
- metadata.gz: 9268725904b6644b4d46a89c72cda271c4d1b5fedc5066396af1515fa955fecb864fad10406db81582391f81d5265595c7c2bb6fe5793bfc1c7b465395937577
7
- data.tar.gz: e8012b3c09ec75e8bd7bbed32f51b297edb2d081b65b2bdf849667e1c7cdb5469862697ddc060c8194b4bf08cc671cb61b6b94507ca382841745e27923963513
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
@@ -900,8 +935,9 @@ class DefineMethodPlugin < Docscribe::Plugin::Base::CollectorPlugin
900
935
  end
901
936
  end
902
937
 
903
- Docscribe::Plugin::Registry.register(SincePlugin.new)
904
- Docscribe::Plugin::Registry.register(DefineMethodPlugin.new)
938
+ # You can optionally set priority (default: 0). Higher number => higher priority.
939
+ Docscribe::Plugin::Registry.register(SincePlugin.new, priority: 10)
940
+ Docscribe::Plugin::Registry.register(DefineMethodPlugin.new, priority: 0)
905
941
  ```
906
942
 
907
943
  **`docscribe.yml`**:
@@ -924,12 +960,36 @@ Docscribe::Plugin::Registry.register(
924
960
  )
925
961
  ```
926
962
 
963
+ ### Plugin priority
964
+
965
+ `Registry.register(plugin, priority: N)` accepts an optional integer priority (default: `0`).
966
+ Higher number means higher priority.
967
+
968
+ **CollectorPlugin priority (conflicts at the same source position):**
969
+
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)
975
+ are kept (ties are kept).
976
+ - Multiple insertions from the winning plugin(s) at the same position are preserved (e.g. one `@!attribute` per column).
977
+
978
+ **TagPlugin priority:**
979
+
980
+ - TagPlugins run in descending priority order (higher priority runs earlier).
981
+ - Multiple TagPlugins may emit the same tag name (e.g. two `@since` tags) — duplicates in the same run are allowed.
982
+
983
+ This allows plugins like `ModelAttributes` to supply more accurate `@return`
984
+ types for ActiveRecord model methods, replacing the generic docs the standard
985
+ collector would have produced for the same `def`.
986
+
927
987
  ### Idempotency
928
988
 
929
989
  Docscribe handles idempotency for plugins automatically.
930
990
 
931
- **TagPlugin**: before appending a tag, Docscribe checks whether a tag with that name already exists in the current doc
932
- block. If it does, the tag is skipped.
991
+ **TagPlugin**: in safe merge mode, Docscribe will not add a plugin tag if the existing doc block already contains a tag
992
+ with that name. (Multiple TagPlugins can still emit the same tag name in a single run; duplicates are allowed.)
933
993
 
934
994
  **CollectorPlugin**: idempotency depends on the selected strategy.
935
995
 
@@ -943,7 +1003,17 @@ on aggressive runs.
943
1003
 
944
1004
  ### Plugin examples
945
1005
 
946
- Sample plugin available at [examples](examples/plugins)
1006
+ Sample plugins available at [examples](examples/plugins):
1007
+
1008
+ - **`ApiTagPlugin`** (`tag_plugin/`): TagPlugin that appends `@api public` / `@api private`
1009
+ based on method visibility.
1010
+ - **`RailsAssociations`** (`collector_plugin/rails_associations/`): CollectorPlugin
1011
+ that documents ActiveRecord `belongs_to`, `has_many`, etc.
1012
+ - **`SchemaAttributes`** (`collector_plugin/schema_attributes/`): CollectorPlugin
1013
+ that generates `@!attribute` blocks by reading `db/schema.rb`.
1014
+ - **`ModelAttributes`** (`collector_plugin/model_attributes/`): CollectorPlugin
1015
+ that generates accurate `@return` types for model methods using `db/schema.rb`
1016
+ or `db/structure.sql`.
947
1017
 
948
1018
  ## Configuration
949
1019
 
@@ -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,6 +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
+
106
+ method_overrides_by_pos = {}
107
+ all = deduplicate_insertions(all, method_overrides_by_pos: method_overrides_by_pos)
105
108
  rewriter = Parser::Source::TreeRewriter.new(buffer)
106
109
  merge_inserts = Hash.new { |h, k| h[k] = [] }
107
110
  changes = []
@@ -110,6 +113,9 @@ module Docscribe
110
113
  .reverse_each do |kind, ins|
111
114
  case kind
112
115
  when :method
116
+ pos = plugin_insertion_pos(:method, ins)
117
+ method_override = method_overrides_by_pos[pos]
118
+
113
119
  apply_method_insertion!(
114
120
  rewriter: rewriter,
115
121
  buffer: buffer,
@@ -119,7 +125,8 @@ module Docscribe
119
125
  core_rbs_provider: core_rbs_provider,
120
126
  strategy: strategy,
121
127
  changes: changes,
122
- file: file.to_s
128
+ file: file.to_s,
129
+ method_override: method_override
123
130
  )
124
131
  when :attr
125
132
  apply_attr_insertion!(
@@ -136,7 +143,8 @@ module Docscribe
136
143
  rewriter: rewriter,
137
144
  buffer: buffer,
138
145
  insertion: ins,
139
- strategy: strategy
146
+ strategy: strategy,
147
+ config: config
140
148
  )
141
149
  end
142
150
  end
@@ -148,6 +156,181 @@ module Docscribe
148
156
 
149
157
  private
150
158
 
159
+ # Deduplicate insertions by source position.
160
+ #
161
+ # Rules:
162
+ # 1. Plugin insertions override method insertions at the same position
163
+ # (CollectorPlugin knows more than the standard collector for that node).
164
+ # 2. If multiple CollectorPlugins target the same position, only insertions
165
+ # from the highest priority plugin(s) are kept (ties are kept).
166
+ # 3. Multiple plugin insertions at the same position are allowed
167
+ # (a single plugin may generate multiple doc blocks, e.g. one per column).
168
+ #
169
+ # @private
170
+ # @param [Array<Array(Symbol,Object)>] insertions tagged insertion list
171
+ # @param [nil] method_overrides_by_pos Param documentation.
172
+ # @return [Array<Array(Symbol,Object)>]
173
+ def deduplicate_insertions(insertions, method_overrides_by_pos: nil)
174
+ groups = {}
175
+
176
+ insertions.each do |kind, ins|
177
+ pos = plugin_insertion_pos(kind, ins)
178
+ (groups[pos] ||= []) << [kind, ins]
179
+ end
180
+
181
+ result = []
182
+
183
+ groups.each do |pos, items|
184
+ # plugin insertions at this pos
185
+ plugin_items = items.select { |k, _| k == :plugin }
186
+
187
+ # no plugins -> keep as-is
188
+ if plugin_items.empty?
189
+ result.concat(items)
190
+ next
191
+ end
192
+
193
+ method_items = items.select { |k, _| k == :method }
194
+
195
+ plugin_doc_items =
196
+ plugin_items.select do |_k, ins|
197
+ ins.is_a?(Hash) && ins[:doc] && !ins[:doc].empty?
198
+ end
199
+
200
+ override_items =
201
+ plugin_items.select do |_k, ins|
202
+ ins.is_a?(Hash) && ins[:method_override].is_a?(Hash)
203
+ end
204
+
205
+ # --- Case A: raw plugin doc exists => legacy behavior: plugin replaces method ---
206
+ if plugin_doc_items.any?
207
+ # drop standard method insertions at same pos
208
+ items = items.reject { |k, _| k == :method }
209
+
210
+ # drop method_overrides too (they have nothing to patch)
211
+ items = items.reject { |k, ins| k == :plugin && ins.is_a?(Hash) && ins.key?(:method_override) }
212
+
213
+ # keep only highest-priority plugin docs
214
+ max_prio = plugin_doc_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
215
+
216
+ dropped = items.select { |k, ins| k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio }
217
+
218
+ items = items.reject do |k, ins|
219
+ k == :plugin && ins.is_a?(Hash) && ins[:doc] && plugin_insertion_priority(ins) < max_prio
220
+ end
221
+
222
+ if Docscribe::Plugin.debug? && dropped.any?
223
+ kept_labels = items.select { |k, _| k == :plugin }
224
+ .map { |_k, ins| plugin_insertion_label(ins) }
225
+ .uniq
226
+ dropped_labels = dropped.map { |_k, ins| plugin_insertion_label(ins) }.uniq
227
+ line = plugin_insertion_line(plugin_items.first[1])
228
+ loc = +"pos=#{pos}"
229
+ loc << " line=#{line}" if line
230
+ warn "Docscribe: CollectorPlugin conflict at #{loc} — " \
231
+ "#{dropped_labels.join(', ')} (pri=#{dropped.map { |_k, ins| plugin_insertion_priority(ins) }.max}) " \
232
+ "dropped in favor of #{kept_labels.join(', ')} (pri=#{max_prio}). " \
233
+ 'Set explicit priority or adjust anchor_node to avoid collision.'
234
+ end
235
+
236
+ result.concat(items)
237
+ next
238
+ end
239
+
240
+ # --- Case B: method_override exists and there is a method insertion => patch method ---
241
+ if override_items.any? && method_items.any?
242
+ if method_overrides_by_pos
243
+ winning_ins = pick_highest_priority_override_insertion(override_items, pos: pos)
244
+ method_overrides_by_pos[pos] = winning_ins[:method_override] if winning_ins
245
+ end
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
252
+ end
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) }
256
+ result.concat(items)
257
+ end
258
+
259
+ result
260
+ end
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
+
297
+ # @private
298
+ # @param [Hash] insertion
299
+ # @raise [StandardError]
300
+ # @return [Integer]
301
+ def plugin_insertion_priority(insertion)
302
+ return 0 unless insertion.is_a?(Hash)
303
+
304
+ Integer(insertion[:__docscribe_priority] || 0)
305
+ rescue StandardError
306
+ 0
307
+ end
308
+
309
+ # @private
310
+ # @param [Hash] insertion
311
+ # @raise [StandardError]
312
+ # @return [String]
313
+ def plugin_insertion_label(insertion)
314
+ return 'unknown' unless insertion.is_a?(Hash)
315
+
316
+ label = insertion[:__docscribe_plugin_class].to_s
317
+ label.empty? ? 'unknown' : label
318
+ rescue StandardError
319
+ 'unknown'
320
+ end
321
+
322
+ # @private
323
+ # @param [Hash] insertion
324
+ # @raise [StandardError]
325
+ # @return [Integer, nil]
326
+ def plugin_insertion_line(insertion)
327
+ return nil unless insertion.is_a?(Hash)
328
+
329
+ insertion[:anchor_node]&.loc&.expression&.line
330
+ rescue StandardError
331
+ nil
332
+ end
333
+
151
334
  # Resolve the source begin_pos for sorting, handling both Struct-based
152
335
  # insertions (method/attr) and Hash-based insertions (plugin).
153
336
  #
@@ -174,14 +357,15 @@ module Docscribe
174
357
  # @param [Parser::Source::Buffer] buffer
175
358
  # @param [Hash] insertion { anchor_node:, doc: }
176
359
  # @param [Symbol] strategy
360
+ # @param [Docscribe::Config] config
177
361
  # @return [void]
178
- def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:)
362
+ def apply_plugin_insertion!(rewriter:, buffer:, insertion:, strategy:, config:)
179
363
  anchor_node = insertion[:anchor_node]
180
364
  doc = insertion[:doc]
181
365
  return unless anchor_node && doc && !doc.empty?
182
366
 
183
367
  indent = SourceHelpers.line_indent(anchor_node)
184
- doc = normalize_plugin_doc_indent(doc, indent)
368
+ doc = normalize_plugin_doc(doc, indent, config: config, anchor_node: anchor_node)
185
369
  bol_range = SourceHelpers.line_start_range(buffer, anchor_node)
186
370
 
187
371
  case strategy
@@ -239,7 +423,51 @@ module Docscribe
239
423
  Parser::Source::Range.new(buffer, start_pos, bol_pos)
240
424
  end
241
425
 
242
- # Normalise indentation of a plugin-generated doc block.
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
+
470
+ # Normalize indentation of a plugin-generated doc block.
243
471
  #
244
472
  # Plugins produce doc strings without knowledge of the surrounding
245
473
  # indentation. We strip leading whitespace from each non-empty line
@@ -309,9 +537,10 @@ module Docscribe
309
537
  # @param [Array<Hash>] changes structured change records
310
538
  # @param [String] file
311
539
  # @param [Object] core_rbs_provider Param documentation.
540
+ # @param [nil] method_override Param documentation.
312
541
  # @return [void]
313
542
  def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, core_rbs_provider:,
314
- strategy:, changes:, file:)
543
+ strategy:, changes:, file:, method_override: nil)
315
544
  name = SourceHelpers.node_name(insertion.node)
316
545
 
317
546
  return unless config.process_method?(
@@ -329,6 +558,24 @@ module Docscribe
329
558
  scope: insertion.scope,
330
559
  name: SourceHelpers.node_name(insertion.node)
331
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
332
579
 
333
580
  case strategy
334
581
  when :aggressive
@@ -342,8 +589,20 @@ module Docscribe
342
589
  config: config
343
590
  )
344
591
 
345
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
346
- 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
+
347
606
  return if doc.nil? || doc.empty?
348
607
 
349
608
  rewriter.insert_before(anchor_bol_range, doc)
@@ -359,6 +618,16 @@ module Docscribe
359
618
  when :safe
360
619
  info = method_doc_comment_info(buffer, insertion)
361
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
+
362
631
  if info
363
632
  merge_result = build_missing_method_merge_result(
364
633
  insertion,
@@ -366,8 +635,10 @@ module Docscribe
366
635
  config: config,
367
636
  signature_provider: signature_provider,
368
637
  core_rbs_provider: core_rbs_provider,
369
- param_types: external_sig&.param_types,
370
- strategy: strategy
638
+ param_types: effective_param_types,
639
+ strategy: strategy,
640
+ return_type_override: override_return_type,
641
+ override_tags: override_tags
371
642
  )
372
643
 
373
644
  missing_lines = merge_result[:lines]
@@ -424,9 +695,15 @@ module Docscribe
424
695
  return
425
696
  end
426
697
 
427
- doc = build_method_doc(insertion, config: config, signature_provider: signature_provider,
428
- core_rbs_provider: core_rbs_provider,
429
- 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
+ )
430
707
  return if doc.nil? || doc.empty?
431
708
 
432
709
  rewriter.insert_before(anchor_bol_range, doc)
@@ -773,14 +1050,19 @@ module Docscribe
773
1050
  # @param [Object, nil] signature_provider external signature provider
774
1051
  # @param [Object, nil] core_rbs_provider RBS core type provider
775
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.
776
1055
  # @return [String, nil] generated doc block or nil
777
- 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:)
778
1058
  DocBuilder.build(
779
1059
  insertion,
780
1060
  config: config,
781
1061
  signature_provider: signature_provider,
782
1062
  core_rbs_provider: core_rbs_provider,
783
- param_types: param_types
1063
+ param_types: param_types,
1064
+ return_type_override: return_type_override,
1065
+ override_tags: override_tags
784
1066
  )
785
1067
  end
786
1068
 
@@ -794,9 +1076,11 @@ module Docscribe
794
1076
  # @param [Object, nil] core_rbs_provider RBS core type provider
795
1077
  # @param [Hash, nil] param_types parameter name -> type map
796
1078
  # @param [Object] strategy Param documentation.
1079
+ # @param [Object] return_type_override Param documentation.
1080
+ # @param [nil] override_tags Param documentation.
797
1081
  # @return [Hash] result with `:lines` and `:reasons` keys
798
1082
  def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:,
799
- core_rbs_provider:, param_types:, strategy:)
1083
+ core_rbs_provider:, param_types:, strategy:, return_type_override:, override_tags: nil)
800
1084
  DocBuilder.build_missing_merge_result(
801
1085
  insertion,
802
1086
  existing_lines: existing_lines,
@@ -804,7 +1088,9 @@ module Docscribe
804
1088
  signature_provider: signature_provider,
805
1089
  core_rbs_provider: core_rbs_provider,
806
1090
  param_types: param_types,
807
- strategy: strategy
1091
+ strategy: strategy,
1092
+ return_type_override: return_type_override,
1093
+ override_tags: override_tags
808
1094
  )
809
1095
  end
810
1096
 
@@ -11,8 +11,22 @@ module Docscribe
11
11
  # Thread safety: registration is expected to happen before any parallel
12
12
  # rewriting begins.
13
13
  module Registry
14
- @tag_plugins = []
15
- @collector_plugins = []
14
+ # @!attribute [rw] plugin
15
+ # @return [Object]
16
+ # @param [Object] value
17
+ #
18
+ # @!attribute [rw] priority
19
+ # @return [Object]
20
+ # @param [Object] value
21
+ #
22
+ # @!attribute [rw] order
23
+ # @return [Object]
24
+ # @param [Object] value
25
+ Entry = Struct.new(:plugin, :priority, :order, keyword_init: true)
26
+
27
+ @tag_entries = []
28
+ @collector_entries = []
29
+ @order_seq = 0
16
30
 
17
31
  module_function
18
32
 
@@ -26,13 +40,25 @@ module Docscribe
26
40
  #
27
41
  # @note module_function: when included, also defines #register (instance visibility: private)
28
42
  # @param [Object] plugin plugin instance
43
+ # @param [Integer] priority plugin priority (higher wins for conflicts)
29
44
  # @raise [ArgumentError] if plugin type cannot be determined
45
+ # @raise [StandardError]
30
46
  # @return [void]
31
- def register(plugin)
47
+ def register(plugin, priority: 0)
48
+ prio =
49
+ begin
50
+ Integer(priority)
51
+ rescue StandardError
52
+ raise ArgumentError, "priority must be an Integer-like value, got: #{priority.inspect}"
53
+ end
54
+
55
+ @order_seq += 1
56
+ entry = Entry.new(plugin: plugin, priority: prio, order: @order_seq)
57
+
32
58
  if plugin.is_a?(Base::CollectorPlugin) || plugin.respond_to?(:collect)
33
- @collector_plugins << plugin
59
+ @collector_entries << entry
34
60
  elsif plugin.is_a?(Base::TagPlugin) || plugin.respond_to?(:call)
35
- @tag_plugins << plugin
61
+ @tag_entries << entry
36
62
  else
37
63
  raise ArgumentError, 'Plugin must respond to #call (TagPlugin) or #collect (CollectorPlugin)'
38
64
  end
@@ -43,7 +69,7 @@ module Docscribe
43
69
  # @note module_function: when included, also defines #tag_plugins (instance visibility: private)
44
70
  # @return [Array<#call>]
45
71
  def tag_plugins
46
- @tag_plugins.dup
72
+ @tag_entries.map(&:plugin)
47
73
  end
48
74
 
49
75
  # All registered collector plugins in registration order.
@@ -51,7 +77,23 @@ module Docscribe
51
77
  # @note module_function: when included, also defines #collector_plugins (instance visibility: private)
52
78
  # @return [Array<#collect>]
53
79
  def collector_plugins
54
- @collector_plugins.dup
80
+ @collector_entries.map(&:plugin)
81
+ end
82
+
83
+ # All registered tag plugin entries (plugin + priority metadata).
84
+ #
85
+ # @note module_function: when included, also defines #tag_entries (instance visibility: private)
86
+ # @return [Array<Entry>]
87
+ def tag_entries
88
+ @tag_entries.dup
89
+ end
90
+
91
+ # All registered collector plugin entries (plugin + priority metadata).
92
+ #
93
+ # @note module_function: when included, also defines #collector_entries (instance visibility: private)
94
+ # @return [Array<Entry>]
95
+ def collector_entries
96
+ @collector_entries.dup
55
97
  end
56
98
 
57
99
  # Remove all registered plugins.
@@ -61,8 +103,9 @@ module Docscribe
61
103
  # @note module_function: when included, also defines #clear! (instance visibility: private)
62
104
  # @return [void]
63
105
  def clear!
64
- @tag_plugins.clear
65
- @collector_plugins.clear
106
+ @tag_entries.clear
107
+ @collector_entries.clear
108
+ @order_seq = 0
66
109
  end
67
110
  end
68
111
  end
@@ -27,7 +27,13 @@ module Docscribe
27
27
  # @raise [StandardError]
28
28
  # @return [Array<Docscribe::Plugin::Tag>]
29
29
  def self.run_tag_plugins(context)
30
- Registry.tag_plugins.flat_map do |plugin|
30
+ Registry.tag_entries
31
+ # Higher number => higher priority (run earlier).
32
+ # This matters when multiple TagPlugins emit the same tag name
33
+ # and Docscribe deduplicates tags by name.
34
+ .sort_by { |entry| [-entry.priority, entry.order] }
35
+ .flat_map do |entry|
36
+ plugin = entry.plugin
31
37
  plugin.call(context)
32
38
  rescue StandardError => e
33
39
  warn "Docscribe: TagPlugin #{plugin.class} raised #{e.class}: #{e.message}" if debug?
@@ -42,8 +48,21 @@ module Docscribe
42
48
  # @raise [StandardError]
43
49
  # @return [Array<Hash>]
44
50
  def self.run_collector_plugins(ast, buffer)
45
- Registry.collector_plugins.flat_map do |plugin|
46
- plugin.collect(ast, buffer)
51
+ Registry.collector_entries.flat_map do |entry|
52
+ plugin = entry.plugin
53
+
54
+ Array(plugin.collect(ast, buffer)).map do |insertion|
55
+ unless insertion.is_a?(Hash)
56
+ warn "Docscribe: CollectorPlugin #{plugin.class} returned #{insertion.class}, expected Hash" if debug?
57
+ next nil
58
+ end
59
+
60
+ insertion.merge(
61
+ __docscribe_priority: entry.priority,
62
+ __docscribe_plugin_class: plugin.class.name,
63
+ __docscribe_plugin_order: entry.order
64
+ )
65
+ end.compact
47
66
  rescue StandardError => e
48
67
  warn "Docscribe: CollectorPlugin #{plugin.class} raised #{e.class}: #{e.message}" if debug?
49
68
  []
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docscribe
4
- VERSION = '1.3.3'
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.3.3
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite