docscribe 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 755d272674fe454b80cc008acb50a879da920def321442cdc8cdd7c450b91f52
4
- data.tar.gz: 92aa2320a36162272acfa2b430bfa9cccf5a4582770885a996bc61d7a4198bb6
3
+ metadata.gz: 50c74b921731749cbf32726e24e557e526e1f65966e704171a652d10cc4ce945
4
+ data.tar.gz: 2e254911cf9d81f5d5f7b5df29576c3c5c3c3f0433cfb387c0d22c1ce30eeb60
5
5
  SHA512:
6
- metadata.gz: 9268725904b6644b4d46a89c72cda271c4d1b5fedc5066396af1515fa955fecb864fad10406db81582391f81d5265595c7c2bb6fe5793bfc1c7b465395937577
7
- data.tar.gz: e8012b3c09ec75e8bd7bbed32f51b297edb2d081b65b2bdf849667e1c7cdb5469862697ddc060c8194b4bf08cc671cb61b6b94507ca382841745e27923963513
6
+ metadata.gz: 824133cf33505568edc84fe99bd51ced4451616552b2ba33e5584fcbd68bdd2ede0e4df365510a4e6be73ef9bf191a66e37746a5331e2ccb8abae2a80a3f69b4
7
+ data.tar.gz: 6662f828e5d324c226432df0e00359a36a97cd47dfeedefd4ead014752fbebe51b93e6cb693d6a342ca3d7e24683b74c189576792e558bf12e28bcc6365e2ed2
data/README.md CHANGED
@@ -900,8 +900,9 @@ class DefineMethodPlugin < Docscribe::Plugin::Base::CollectorPlugin
900
900
  end
901
901
  end
902
902
 
903
- Docscribe::Plugin::Registry.register(SincePlugin.new)
904
- Docscribe::Plugin::Registry.register(DefineMethodPlugin.new)
903
+ # You can optionally set priority (default: 0). Higher number => higher priority.
904
+ Docscribe::Plugin::Registry.register(SincePlugin.new, priority: 10)
905
+ Docscribe::Plugin::Registry.register(DefineMethodPlugin.new, priority: 0)
905
906
  ```
906
907
 
907
908
  **`docscribe.yml`**:
@@ -924,12 +925,34 @@ Docscribe::Plugin::Registry.register(
924
925
  )
925
926
  ```
926
927
 
928
+ ### Plugin priority
929
+
930
+ `Registry.register(plugin, priority: N)` accepts an optional integer priority (default: `0`).
931
+ Higher number means higher priority.
932
+
933
+ **CollectorPlugin priority (conflicts at the same source position):**
934
+
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)
938
+ are kept (ties are kept).
939
+ - Multiple insertions from the winning plugin(s) at the same position are preserved (e.g. one `@!attribute` per column).
940
+
941
+ **TagPlugin priority:**
942
+
943
+ - TagPlugins run in descending priority order (higher priority runs earlier).
944
+ - Multiple TagPlugins may emit the same tag name (e.g. two `@since` tags) — duplicates in the same run are allowed.
945
+
946
+ This allows plugins like `ModelAttributes` to supply more accurate `@return`
947
+ types for ActiveRecord model methods, replacing the generic docs the standard
948
+ collector would have produced for the same `def`.
949
+
927
950
  ### Idempotency
928
951
 
929
952
  Docscribe handles idempotency for plugins automatically.
930
953
 
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.
954
+ **TagPlugin**: in safe merge mode, Docscribe will not add a plugin tag if the existing doc block already contains a tag
955
+ with that name. (Multiple TagPlugins can still emit the same tag name in a single run; duplicates are allowed.)
933
956
 
934
957
  **CollectorPlugin**: idempotency depends on the selected strategy.
935
958
 
@@ -943,7 +966,17 @@ on aggressive runs.
943
966
 
944
967
  ### Plugin examples
945
968
 
946
- Sample plugin available at [examples](examples/plugins)
969
+ Sample plugins available at [examples](examples/plugins):
970
+
971
+ - **`ApiTagPlugin`** (`tag_plugin/`): TagPlugin that appends `@api public` / `@api private`
972
+ based on method visibility.
973
+ - **`RailsAssociations`** (`collector_plugin/rails_associations/`): CollectorPlugin
974
+ that documents ActiveRecord `belongs_to`, `has_many`, etc.
975
+ - **`SchemaAttributes`** (`collector_plugin/schema_attributes/`): CollectorPlugin
976
+ that generates `@!attribute` blocks by reading `db/schema.rb`.
977
+ - **`ModelAttributes`** (`collector_plugin/model_attributes/`): CollectorPlugin
978
+ that generates accurate `@return` types for model methods using `db/schema.rb`
979
+ or `db/structure.sql`.
947
980
 
948
981
  ## Configuration
949
982
 
@@ -102,6 +102,7 @@ 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
  rewriter = Parser::Source::TreeRewriter.new(buffer)
106
107
  merge_inserts = Hash.new { |h, k| h[k] = [] }
107
108
  changes = []
@@ -148,6 +149,110 @@ module Docscribe
148
149
 
149
150
  private
150
151
 
152
+ # Deduplicate insertions by source position.
153
+ #
154
+ # Rules:
155
+ # 1. Plugin insertions override method insertions at the same position
156
+ # (CollectorPlugin knows more than the standard collector for that node).
157
+ # 2. If multiple CollectorPlugins target the same position, only insertions
158
+ # from the highest priority plugin(s) are kept (ties are kept).
159
+ # 3. Multiple plugin insertions at the same position are allowed
160
+ # (a single plugin may generate multiple doc blocks, e.g. one per column).
161
+ #
162
+ # @private
163
+ # @param [Array<Array(Symbol,Object)>] insertions tagged insertion list
164
+ # @return [Array<Array(Symbol,Object)>]
165
+ def deduplicate_insertions(insertions)
166
+ groups = {}
167
+
168
+ insertions.each do |item|
169
+ kind, ins = item
170
+ pos = plugin_insertion_pos(kind, ins)
171
+ (groups[pos] ||= []) << item
172
+ end
173
+
174
+ result = []
175
+
176
+ groups.each do |pos, items|
177
+ plugin_items = items.select { |k, _| k == :plugin }
178
+
179
+ # No plugins at this position -> keep as-is
180
+ if plugin_items.empty?
181
+ result.concat(items)
182
+ next
183
+ end
184
+
185
+ # Rule 1: plugin overrides method insertion at the same position
186
+ items = items.reject { |k, _| k == :method }
187
+
188
+ # Rule 2: keep only highest-priority plugin insertions
189
+ max_prio = plugin_items.map { |_k, ins| plugin_insertion_priority(ins) }.max || 0
190
+
191
+ items = items.reject do |k, ins|
192
+ k == :plugin && plugin_insertion_priority(ins) < max_prio
193
+ end
194
+
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
202
+
203
+ if kept_plugin_labels.size > 1
204
+ line = plugin_insertion_line(plugin_items.first[1])
205
+ loc = +"pos=#{pos}"
206
+ loc << " line=#{line}" if line
207
+
208
+ warn "Docscribe: CollectorPlugin conflict at #{loc} (priority=#{max_prio}): " \
209
+ "#{kept_plugin_labels.join(', ')} — keeping all. Set explicit priority to resolve."
210
+ end
211
+ end
212
+
213
+ result.concat(items)
214
+ end
215
+
216
+ result
217
+ end
218
+
219
+ # @private
220
+ # @param [Hash] insertion
221
+ # @raise [StandardError]
222
+ # @return [Integer]
223
+ def plugin_insertion_priority(insertion)
224
+ return 0 unless insertion.is_a?(Hash)
225
+
226
+ Integer(insertion[:__docscribe_priority] || 0)
227
+ rescue StandardError
228
+ 0
229
+ end
230
+
231
+ # @private
232
+ # @param [Hash] insertion
233
+ # @raise [StandardError]
234
+ # @return [String]
235
+ def plugin_insertion_label(insertion)
236
+ return 'unknown' unless insertion.is_a?(Hash)
237
+
238
+ label = insertion[:__docscribe_plugin_class].to_s
239
+ label.empty? ? 'unknown' : label
240
+ rescue StandardError
241
+ 'unknown'
242
+ end
243
+
244
+ # @private
245
+ # @param [Hash] insertion
246
+ # @raise [StandardError]
247
+ # @return [Integer, nil]
248
+ def plugin_insertion_line(insertion)
249
+ return nil unless insertion.is_a?(Hash)
250
+
251
+ insertion[:anchor_node]&.loc&.expression&.line
252
+ rescue StandardError
253
+ nil
254
+ end
255
+
151
256
  # Resolve the source begin_pos for sorting, handling both Struct-based
152
257
  # insertions (method/attr) and Hash-based insertions (plugin).
153
258
  #
@@ -239,7 +344,7 @@ module Docscribe
239
344
  Parser::Source::Range.new(buffer, start_pos, bol_pos)
240
345
  end
241
346
 
242
- # Normalise indentation of a plugin-generated doc block.
347
+ # Normalize indentation of a plugin-generated doc block.
243
348
  #
244
349
  # Plugins produce doc strings without knowledge of the surrounding
245
350
  # indentation. We strip leading whitespace from each non-empty line
@@ -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.0'
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite