docscribe 1.3.2 → 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: 765dd81d49b3cbf47c90f83f9e533a367da6590f84b15ca7e49de66c6140b5e1
4
- data.tar.gz: b7c0607de2fb6e2344be24d4da58c1e03ab33755175783e1e6de8c934a8f2cdc
3
+ metadata.gz: 50c74b921731749cbf32726e24e557e526e1f65966e704171a652d10cc4ce945
4
+ data.tar.gz: 2e254911cf9d81f5d5f7b5df29576c3c5c3c3f0433cfb387c0d22c1ce30eeb60
5
5
  SHA512:
6
- metadata.gz: f30b33860317c5b9914e6b6c633df75c35f11883d7524c3b832365431a5f47760eaaeb4daab3b20f358bad8cb088015b173d6848bf65b3d7cbba18a69fda5154
7
- data.tar.gz: 66ac534ba8452880ee778ae1bf72ce571e9ea4bba481e5595172314df1f809eb62db87ec8b77cd4efb8c8cd37c85dd0c937b7fa8c263938ab46734ae03f5acda
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
 
@@ -53,7 +53,7 @@ module Docscribe
53
53
  require 'docscribe/types/rbs/collection_loader'
54
54
  collection_path = Docscribe::Types::RBS::CollectionLoader.resolve
55
55
  if collection_path
56
- raw['rbs']['sig_dirs'] = Array(raw['rbs']['sig_dirs']) + [collection_path]
56
+ raw['rbs']['collection_dirs'] = Array(raw['rbs']['collection_dirs']) + [collection_path]
57
57
  else
58
58
  warn 'Docscribe: rbs_collection.lock.yaml not found or collection not installed. ' \
59
59
  'Run `bundle exec rbs collection install` first.'
@@ -62,6 +62,7 @@ module Docscribe
62
62
  'enabled' => false,
63
63
  'collection' => false,
64
64
  'sig_dirs' => ['sig'],
65
+ 'collection_dirs' => [],
65
66
  'collapse_generics' => false
66
67
  },
67
68
  'sorbet' => {
@@ -17,6 +17,7 @@ module Docscribe
17
17
  require 'docscribe/types/rbs/provider'
18
18
  Docscribe::Types::RBS::Provider.new(
19
19
  sig_dirs: rbs_sig_dirs,
20
+ collection_dirs: rbs_collection_dirs,
20
21
  collapse_generics: rbs_collapse_generics?
21
22
  )
22
23
  rescue LoadError
@@ -73,6 +74,18 @@ module Docscribe
73
74
  Array(raw.dig('rbs', 'sig_dirs') || DEFAULT.dig('rbs', 'sig_dirs')).map(&:to_s)
74
75
  end
75
76
 
77
+ # RBS collection directories (auto-discovered from rbs_collection.lock.yaml).
78
+ #
79
+ # Loaded separately from user sig_dirs so that collection-related
80
+ # RBS environment errors (e.g. duplicate declarations against core
81
+ # stdlib types) do not silence all RBS lookups.
82
+ #
83
+ # @private
84
+ # @return [Array<String>]
85
+ def rbs_collection_dirs
86
+ Array(raw.dig('rbs', 'collection_dirs')).map(&:to_s)
87
+ end
88
+
76
89
  # Whether generic RBS types should be collapsed to simpler container names.
77
90
  #
78
91
  # Examples:
@@ -86,6 +86,7 @@ module Docscribe
86
86
  # Use RBS signatures for better types (requires `gem "rbs"`)
87
87
  enabled: false
88
88
  sig_dirs: ["sig"]
89
+ collection_dirs: [] # auto-discovered from --rbs-collection
89
90
  collapse_generics: false # Hash<Symbol, String> => Hash
90
91
  collection: false # auto-discover from rbs_collection.lock.yaml
91
92
 
@@ -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
  []
@@ -15,16 +15,21 @@ module Docscribe
15
15
  # the pipeline can stay independent of the underlying signature source.
16
16
  class Provider
17
17
  # @param [Array<String>] sig_dirs directories containing `.rbs` files
18
+ # @param [Array<String>] collection_dirs RBS collection directories
19
+ # (loaded separately; on error they are silently dropped and only
20
+ # user sig_dirs are used)
18
21
  # @param [Boolean] collapse_generics whether generic container types
19
22
  # should be simplified during formatting
20
23
  # @return [Object]
21
- def initialize(sig_dirs:, collapse_generics: false)
24
+ def initialize(sig_dirs:, collection_dirs: [], collapse_generics: false)
22
25
  require 'rbs'
23
26
  @sig_dirs = Array(sig_dirs).map(&:to_s)
27
+ @collection_dirs = Array(collection_dirs).map(&:to_s)
24
28
  @collapse_generics = !!collapse_generics
25
29
  @env = nil
26
30
  @builder = nil
27
31
  @warned = false
32
+ @collection_dropped = false
28
33
  end
29
34
 
30
35
  # Look up a normalized method signature from loaded RBS definitions.
@@ -64,22 +69,48 @@ module Docscribe
64
69
 
65
70
  # Lazily load and resolve the RBS environment.
66
71
  #
72
+ # Tries to load collection dirs together with user sig_dirs.
73
+ # If the combined environment raises a load error (e.g. duplicate
74
+ # declarations between collection and core stdlib types), collection
75
+ # dirs are dropped and only user sig_dirs are used.
76
+ #
67
77
  # @private
78
+ # @raise [::RBS::BaseError]
79
+ # @raise [StandardError]
68
80
  # @return [void]
69
81
  def load_env!
70
82
  return if @env && @builder
71
83
 
84
+ @env = build_env(@sig_dirs + @collection_dirs)
85
+ rescue ::RBS::BaseError => e
86
+ raise unless @collection_dirs.any? && !@collection_dropped
87
+
88
+ @collection_dropped = true
89
+ if ENV['DOCSCRIBE_RBS_DEBUG'] == '1'
90
+ warn "Docscribe: RBS collection error (#{e.class}), dropping collection dirs. " \
91
+ 'Set DOCSCRIBE_RBS_DEBUG=1 for details.'
92
+ end
93
+ @env = build_env(@sig_dirs)
94
+ end
95
+
96
+ # Build an RBS environment from the given directories.
97
+ #
98
+ # @private
99
+ # @param [Array<String>] dirs
100
+ # @return [::RBS::Environment]
101
+ def build_env(dirs)
72
102
  loader = ::RBS::EnvironmentLoader.new
73
103
  # Load core types transitively
74
104
  loader.add(library: 'rbs')
75
105
 
76
- @sig_dirs.each do |dir|
106
+ dirs.each do |dir|
77
107
  path = Pathname(dir)
78
108
  loader.add(path: path) if path.directory?
79
109
  end
80
110
 
81
- @env = ::RBS::Environment.from_loader(loader).resolve_type_names
82
- @builder = ::RBS::DefinitionBuilder.new(env: @env)
111
+ env = ::RBS::Environment.from_loader(loader).resolve_type_names
112
+ @builder = ::RBS::DefinitionBuilder.new(env: env)
113
+ env
83
114
  end
84
115
 
85
116
  # Build the appropriate instance or singleton definition for a container.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docscribe
4
- VERSION = '1.3.2'
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.2
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite