docscribe 1.1.0 → 1.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +662 -187
  3. data/exe/docscribe +2 -126
  4. data/lib/docscribe/cli/config_builder.rb +62 -0
  5. data/lib/docscribe/cli/init.rb +58 -0
  6. data/lib/docscribe/cli/options.rb +204 -0
  7. data/lib/docscribe/cli/run.rb +415 -0
  8. data/lib/docscribe/cli.rb +31 -0
  9. data/lib/docscribe/config/defaults.rb +71 -0
  10. data/lib/docscribe/config/emit.rb +142 -0
  11. data/lib/docscribe/config/filtering.rb +160 -0
  12. data/lib/docscribe/config/loader.rb +59 -0
  13. data/lib/docscribe/config/rbs.rb +51 -0
  14. data/lib/docscribe/config/sorbet.rb +87 -0
  15. data/lib/docscribe/config/sorting.rb +23 -0
  16. data/lib/docscribe/config/template.rb +184 -0
  17. data/lib/docscribe/config/utils.rb +102 -0
  18. data/lib/docscribe/config.rb +20 -230
  19. data/lib/docscribe/infer/ast_walk.rb +28 -0
  20. data/lib/docscribe/infer/constants.rb +11 -0
  21. data/lib/docscribe/infer/literals.rb +55 -0
  22. data/lib/docscribe/infer/names.rb +43 -0
  23. data/lib/docscribe/infer/params.rb +62 -0
  24. data/lib/docscribe/infer/raises.rb +68 -0
  25. data/lib/docscribe/infer/returns.rb +171 -0
  26. data/lib/docscribe/infer.rb +104 -258
  27. data/lib/docscribe/inline_rewriter/collector.rb +845 -0
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +383 -0
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +607 -0
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +228 -0
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +244 -0
  32. data/lib/docscribe/inline_rewriter.rb +599 -428
  33. data/lib/docscribe/parsing.rb +55 -44
  34. data/lib/docscribe/types/provider_chain.rb +37 -0
  35. data/lib/docscribe/types/rbs/provider.rb +213 -0
  36. data/lib/docscribe/types/rbs/type_formatter.rb +132 -0
  37. data/lib/docscribe/types/signature.rb +65 -0
  38. data/lib/docscribe/types/sorbet/base_provider.rb +217 -0
  39. data/lib/docscribe/types/sorbet/rbi_provider.rb +35 -0
  40. data/lib/docscribe/types/sorbet/source_provider.rb +25 -0
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +37 -3
@@ -1,540 +1,711 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'racc/parser'
4
3
  require 'ast'
5
4
  require 'parser/deprecation'
6
5
  require 'parser/source/buffer'
7
6
  require 'parser/source/range'
8
7
  require 'parser/source/tree_rewriter'
9
- require 'parser/ast/processor'
10
8
 
11
9
  require 'docscribe/config'
12
- require 'docscribe/infer'
13
10
  require 'docscribe/parsing'
14
11
 
12
+ require 'docscribe/inline_rewriter/source_helpers'
13
+ require 'docscribe/inline_rewriter/doc_builder'
14
+ require 'docscribe/inline_rewriter/collector'
15
+ require 'docscribe/inline_rewriter/doc_block'
16
+
15
17
  module Docscribe
18
+ # Raised when source cannot be parsed before rewriting.
19
+ class ParseError < StandardError; end
20
+
21
+ # Rewrite Ruby source to insert or update inline YARD-style documentation.
22
+ #
23
+ # Supported strategies:
24
+ # - `:safe`
25
+ # - insert missing docs
26
+ # - merge into existing doc-like blocks
27
+ # - normalize configured sortable tags
28
+ # - preserve existing prose and directives where possible
29
+ # - `:aggressive`
30
+ # - replace existing doc blocks with freshly generated docs
31
+ #
32
+ # Compatibility note:
33
+ # - `merge: true` maps to `strategy: :safe`
34
+ # - `rewrite: true` maps to `strategy: :aggressive`
16
35
  module InlineRewriter
17
- # +Docscribe::InlineRewriter.insert_comments+ -> Object
18
- #
19
- # Method documentation.
20
- #
21
- # @param [Object] code Param documentation.
22
- # @param [Boolean] rewrite Param documentation.
23
- # @param [nil] config Param documentation.
24
- # @return [Object]
25
- def self.insert_comments(code, rewrite: false, config: nil)
26
- buffer = Parser::Source::Buffer.new('(inline)')
27
- buffer.source = code
28
- ast = Docscribe::Parsing.parse_buffer(buffer)
29
- return code unless ast
30
-
31
- config ||= Docscribe::Config.load
32
-
33
- collector = Collector.new(buffer)
34
- collector.process(ast)
35
-
36
- rewriter = Parser::Source::TreeRewriter.new(buffer)
37
-
38
- collector.insertions
39
- .sort_by { |ins| ins.node.loc.expression.begin_pos }
40
- .reverse_each do |ins|
41
- bol_range = line_start_range(buffer, ins.node)
42
-
43
- if rewrite
44
- # If there is a comment block immediately above, remove it (and its trailing blank lines)
45
- if (range = comment_block_removal_range(buffer, bol_range.begin_pos))
46
- rewriter.remove(range)
47
- end
48
- elsif already_has_doc_immediately_above?(buffer, bol_range.begin_pos)
49
- # Skip if a doc already exists immediately above
50
- next
51
- end
52
-
53
- doc = build_doc_for_node(buffer, ins, config)
54
- next unless doc && !doc.empty?
55
-
56
- rewriter.insert_before(bol_range, doc)
36
+ class << self
37
+ # Rewrite source and return only the rewritten output string.
38
+ #
39
+ # This is the main convenience entry point for library usage.
40
+ #
41
+ # @param [String] code Ruby source
42
+ # @param [Symbol, nil] strategy :safe or :aggressive
43
+ # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
44
+ # @param [Boolean, nil] merge compatibility alias for safe strategy
45
+ # @param [Docscribe::Config, nil] config config object (defaults to loaded config)
46
+ # @param [String] file source name used for parser locations/debugging
47
+ # @return [String]
48
+ def insert_comments(code, strategy: nil, rewrite: nil, merge: nil, config: nil, file: '(inline)')
49
+ strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
50
+
51
+ rewrite_with_report(
52
+ code,
53
+ strategy: strategy,
54
+ config: config,
55
+ file: file
56
+ )[:output]
57
57
  end
58
58
 
59
- rewriter.process
60
- end
59
+ # Rewrite source and return both output and structured change information.
60
+ #
61
+ # The result hash includes:
62
+ # - `:output` => rewritten source
63
+ # - `:changes` => structured change records used by CLI explanation output
64
+ #
65
+ # @param [String] code Ruby source
66
+ # @param [Symbol, nil] strategy :safe or :aggressive
67
+ # @param [Boolean, nil] rewrite compatibility alias for aggressive strategy
68
+ # @param [Boolean, nil] merge compatibility alias for safe strategy
69
+ # @param [Docscribe::Config, nil] config config object (defaults to loaded config)
70
+ # @param [String] file source name used for parser locations/debugging
71
+ # @raise [Docscribe::ParseError]
72
+ # @return [Hash]
73
+ def rewrite_with_report(code, strategy: nil, rewrite: nil, merge: nil, config: nil, file: '(inline)')
74
+ strategy = normalize_strategy(strategy: strategy, rewrite: rewrite, merge: merge)
75
+ validate_strategy!(strategy)
76
+
77
+ buffer = Parser::Source::Buffer.new(file.to_s, source: code)
78
+ ast = Docscribe::Parsing.parse_buffer(buffer)
79
+ raise Docscribe::ParseError, "Failed to parse #{file}" unless ast
80
+
81
+ config ||= Docscribe::Config.load
82
+ signature_provider = build_signature_provider(config, code, file.to_s)
83
+
84
+ collector = Docscribe::InlineRewriter::Collector.new(buffer)
85
+ collector.process(ast)
86
+
87
+ method_insertions = collector.insertions
88
+ attr_insertions = collector.respond_to?(:attr_insertions) ? collector.attr_insertions : []
89
+
90
+ all = method_insertions.map { |i| [:method, i] } + attr_insertions.map { |i| [:attr, i] }
91
+
92
+ rewriter = Parser::Source::TreeRewriter.new(buffer)
93
+ merge_inserts = Hash.new { |h, k| h[k] = [] }
94
+ changes = []
95
+
96
+ all.sort_by { |(_kind, ins)| ins.node.loc.expression.begin_pos }
97
+ .reverse_each do |kind, ins|
98
+ case kind
99
+ when :method
100
+ apply_method_insertion!(
101
+ rewriter: rewriter,
102
+ buffer: buffer,
103
+ insertion: ins,
104
+ config: config,
105
+ signature_provider: signature_provider,
106
+ strategy: strategy,
107
+ changes: changes,
108
+ file: file.to_s
109
+ )
110
+ when :attr
111
+ apply_attr_insertion!(
112
+ rewriter: rewriter,
113
+ buffer: buffer,
114
+ insertion: ins,
115
+ config: config,
116
+ signature_provider: signature_provider,
117
+ strategy: strategy,
118
+ merge_inserts: merge_inserts
119
+ )
120
+ end
121
+ end
61
122
 
62
- # +Docscribe::InlineRewriter.line_start_range+ -> Range
63
- #
64
- # Method documentation.
65
- #
66
- # @param [Object] buffer Param documentation.
67
- # @param [Object] node Param documentation.
68
- # @return [Range]
69
- def self.line_start_range(buffer, node)
70
- start_pos = node.loc.expression.begin_pos
71
- src = buffer.source
72
- bol = src.rindex("\n", start_pos - 1) || -1
73
- Parser::Source::Range.new(buffer, bol + 1, bol + 1)
74
- end
123
+ apply_merge_inserts!(rewriter: rewriter, buffer: buffer, merge_inserts: merge_inserts)
75
124
 
76
- # +Docscribe::InlineRewriter.node_name+ -> Object
77
- #
78
- # Method documentation.
79
- #
80
- # @param [Object] node Param documentation.
81
- # @return [Object]
82
- def self.node_name(node)
83
- case node.type
84
- when :def
85
- node.children[0]
86
- when :defs
87
- node.children[1] # method name symbol
125
+ { output: rewriter.process, changes: changes }
88
126
  end
89
- end
90
127
 
91
- # +Docscribe::InlineRewriter.comment_block_removal_range+ -> Range
92
- #
93
- # Method documentation.
94
- #
95
- # @param [Object] buffer Param documentation.
96
- # @param [Object] def_bol_pos Param documentation.
97
- # @return [Range]
98
- def self.comment_block_removal_range(buffer, def_bol_pos)
99
- src = buffer.source
100
- lines = src.lines
101
- # Find def line index
102
- def_line_idx = src[0...def_bol_pos].count("\n")
103
- i = def_line_idx - 1
104
-
105
- # Walk up and skip blank lines directly above def
106
- i -= 1 while i >= 0 && lines[i].strip.empty?
107
- # Now if the nearest non-blank line isn't a comment, nothing to remove
108
- return nil unless i >= 0 && lines[i] =~ /^\s*#/
109
-
110
- # Find the start of the contiguous comment block
111
- start_idx = i
112
- start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
113
- start_idx += 1
114
-
115
- # End position is at def_bol_pos; start position is BOL of start_idx
116
- # Compute absolute buffer positions
117
- # Position of BOL for start_idx:
118
- start_pos = 0
119
- if start_idx.positive?
120
- # Sum lengths of all preceding lines
121
- start_pos = lines[0...start_idx].join.length
122
- end
128
+ private
123
129
 
124
- Parser::Source::Range.new(buffer, start_pos, def_bol_pos)
125
- end
130
+ # Normalize strategy inputs, including compatibility booleans.
131
+ #
132
+ # Precedence:
133
+ # - explicit `strategy`
134
+ # - `rewrite: true` => `:aggressive`
135
+ # - `merge: true` => `:safe`
136
+ # - default => `:safe`
137
+ #
138
+ # @private
139
+ # @param [Symbol, nil] strategy
140
+ # @param [Boolean, nil] rewrite
141
+ # @param [Boolean, nil] merge
142
+ # @return [Symbol]
143
+ def normalize_strategy(strategy:, rewrite:, merge:)
144
+ return strategy if strategy
145
+ return :aggressive if rewrite
146
+ return :safe if merge
147
+
148
+ :safe
149
+ end
126
150
 
127
- # +Docscribe::InlineRewriter.already_has_doc_immediately_above?+ -> Object
128
- #
129
- # Method documentation.
130
- #
131
- # @param [Object] buffer Param documentation.
132
- # @param [Object] insert_pos Param documentation.
133
- # @return [Object]
134
- def self.already_has_doc_immediately_above?(buffer, insert_pos)
135
- src = buffer.source
136
- lines = src.lines
137
- current_line_index = src[0...insert_pos].count("\n")
138
- i = current_line_index - 1
139
- i -= 1 while i >= 0 && lines[i].strip.empty?
140
- return false if i.negative?
141
-
142
- !!(lines[i] =~ /^\s*#/)
143
- end
151
+ # Validate a normalized rewrite strategy.
152
+ #
153
+ # @private
154
+ # @param [Symbol] strategy
155
+ # @raise [ArgumentError]
156
+ # @return [void]
157
+ def validate_strategy!(strategy)
158
+ return if %i[safe aggressive].include?(strategy)
144
159
 
145
- # +Docscribe::InlineRewriter.build_doc_for_node+ -> Object
146
- #
147
- # Method documentation.
148
- #
149
- # @param [Object] _buffer Param documentation.
150
- # @param [Object] insertion Param documentation.
151
- # @param [Object] config Param documentation.
152
- # @raise [StandardError]
153
- # @return [Object]
154
- # @return [nil] if StandardError
155
- def self.build_doc_for_node(_buffer, insertion, config)
156
- node = insertion.node
157
- indent = ' ' * node.loc.expression.column
158
-
159
- name =
160
- case node.type
161
- when :def then node.children[0]
162
- when :defs then node.children[1]
163
- end
160
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}"
161
+ end
164
162
 
165
- scope = insertion.scope
166
- visibility = insertion.visibility
163
+ # Apply one method insertion according to the selected strategy.
164
+ #
165
+ # Safe strategy:
166
+ # - merge into existing doc-like blocks when present
167
+ # - otherwise insert a full doc block non-destructively
168
+ #
169
+ # Aggressive strategy:
170
+ # - remove the existing doc block (if any)
171
+ # - insert a fresh regenerated block
172
+ #
173
+ # @private
174
+ # @param [Parser::Source::TreeRewriter] rewriter
175
+ # @param [Parser::Source::Buffer] buffer
176
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
177
+ # @param [Docscribe::Config] config
178
+ # @param [Object, nil] signature_provider
179
+ # @param [Symbol] strategy
180
+ # @param [Array<Hash>] changes structured change records
181
+ # @param [String] file
182
+ # @return [void]
183
+ def apply_method_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, changes:,
184
+ file:)
185
+ name = SourceHelpers.node_name(insertion.node)
186
+
187
+ return unless config.process_method?(
188
+ container: insertion.container,
189
+ scope: insertion.scope,
190
+ visibility: insertion.visibility,
191
+ name: name
192
+ )
193
+
194
+ anchor_bol_range, = method_bol_ranges(buffer, insertion)
195
+
196
+ case strategy
197
+ when :aggressive
198
+ if (range = method_comment_block_removal_range(buffer, insertion))
199
+ rewriter.remove(range)
200
+ end
167
201
 
168
- method_symbol = scope == :instance ? '#' : '.'
169
- container = insertion.container
202
+ doc = build_method_doc(insertion, config: config, signature_provider: signature_provider)
203
+ return if doc.nil? || doc.empty?
204
+
205
+ rewriter.insert_before(anchor_bol_range, doc)
206
+
207
+ add_change(
208
+ changes,
209
+ type: :insert_full_doc_block,
210
+ insertion: insertion,
211
+ file: file,
212
+ message: 'missing docs'
213
+ )
214
+
215
+ when :safe
216
+ info = method_doc_comment_info(buffer, insertion)
217
+
218
+ if info
219
+ merge_result = build_missing_method_merge_result(
220
+ insertion,
221
+ existing_lines: info[:doc_lines],
222
+ config: config,
223
+ signature_provider: signature_provider
224
+ )
225
+
226
+ missing_lines = merge_result[:lines]
227
+ reason_specs = merge_result[:reasons] || []
228
+
229
+ sorted_existing_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
230
+ info[:doc_lines],
231
+ missing_lines: [],
232
+ sort_tags: config.sort_tags?,
233
+ tag_order: config.tag_order
234
+ )
235
+
236
+ merged_doc_lines = Docscribe::InlineRewriter::DocBlock.merge(
237
+ info[:doc_lines],
238
+ missing_lines: missing_lines,
239
+ sort_tags: config.sort_tags?,
240
+ tag_order: config.tag_order
241
+ )
242
+
243
+ existing_order_changed = sorted_existing_doc_lines != info[:doc_lines]
244
+ new_block = (info[:preserved_lines] + merged_doc_lines).join
245
+ old_block = info[:lines].join
246
+
247
+ if new_block != old_block
248
+ range = Parser::Source::Range.new(buffer, info[:start_pos], info[:end_pos])
249
+ rewriter.replace(range, new_block)
250
+
251
+ reason_specs.each do |reason|
252
+ add_change(
253
+ changes,
254
+ type: reason[:type],
255
+ insertion: insertion,
256
+ file: file,
257
+ message: reason[:message],
258
+ extra: reason[:extra] || {}
259
+ )
260
+ end
261
+
262
+ if existing_order_changed
263
+ add_change(
264
+ changes,
265
+ type: :unsorted_tags,
266
+ insertion: insertion,
267
+ file: file,
268
+ message: 'unsorted tags'
269
+ )
270
+ end
271
+ end
170
272
 
171
- # Params
172
- params_block = config.emit_param_tags? ? build_params_block(node, indent) : nil
273
+ return
274
+ end
173
275
 
174
- # Raises (rescue and/or raise calls)
175
- raise_types = config.emit_raise_tags? ? Docscribe::Infer.infer_raises_from_node(node) : []
276
+ doc = build_method_doc(insertion, config: config, signature_provider: signature_provider)
277
+ return if doc.nil? || doc.empty?
176
278
 
177
- # Returns: normal + conditional rescue returns
178
- spec = Docscribe::Infer.returns_spec_from_node(node)
179
- normal_type = spec[:normal]
180
- rescue_specs = spec[:rescues]
279
+ rewriter.insert_before(anchor_bol_range, doc)
181
280
 
182
- lines = []
183
- if config.emit_header?
184
- lines << "#{indent}# +#{container}#{method_symbol}#{name}+ -> #{normal_type}"
185
- lines << "#{indent}#"
281
+ add_change(
282
+ changes,
283
+ type: :insert_full_doc_block,
284
+ insertion: insertion,
285
+ file: file,
286
+ message: 'missing docs'
287
+ )
288
+ end
186
289
  end
187
290
 
188
- # Default doc text (configurable per scope/vis)
189
- lines << "#{indent}# #{config.default_message(scope, visibility)}"
190
- lines << "#{indent}#"
291
+ # Append a structured change record.
292
+ #
293
+ # @private
294
+ # @param [Array<Hash>] changes
295
+ # @param [Symbol] type
296
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
297
+ # @param [String] file
298
+ # @param [String] message
299
+ # @param [Integer, nil] line
300
+ # @param [Hash] extra
301
+ # @return [void]
302
+ def add_change(changes, type:, insertion:, file:, message:, line: nil, extra: {})
303
+ changes << {
304
+ type: type,
305
+ file: file,
306
+ line: line || method_line_for(insertion),
307
+ method: method_id_for(insertion),
308
+ message: message
309
+ }.merge(extra)
310
+ end
191
311
 
192
- if config.emit_visibility_tags?
193
- case visibility
194
- when :private then lines << "#{indent}# @private"
195
- when :protected then lines << "#{indent}# @protected"
196
- end
312
+ # Build a printable method identifier from a collected insertion.
313
+ #
314
+ # @private
315
+ # @param [Docscribe::InlineRewriter::Collector::Insertion] insertion
316
+ # @return [String]
317
+ def method_id_for(insertion)
318
+ name = SourceHelpers.node_name(insertion.node)
319
+ "#{insertion.container}#{insertion.scope == :instance ? '#' : '.'}#{name}"
197
320
  end
198
321
 
199
- lines.concat(params_block) if params_block
322
+ # Apply one attribute insertion according to the selected strategy.
323
+ #
324
+ # @private
325
+ # @param [Parser::Source::TreeRewriter] rewriter
326
+ # @param [Parser::Source::Buffer] buffer
327
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] insertion
328
+ # @param [Docscribe::Config] config
329
+ # @param [Object, nil] signature_provider
330
+ # @param [Symbol] strategy
331
+ # @param [Hash] merge_inserts
332
+ # @return [void]
333
+ def apply_attr_insertion!(rewriter:, buffer:, insertion:, config:, signature_provider:, strategy:, merge_inserts:)
334
+ return unless config.respond_to?(:emit_attributes?) && config.emit_attributes?
335
+ return unless attribute_allowed?(config, insertion)
336
+
337
+ bol_range = SourceHelpers.line_start_range(buffer, insertion.node)
338
+
339
+ case strategy
340
+ when :aggressive
341
+ if (range = SourceHelpers.comment_block_removal_range(buffer, bol_range.begin_pos))
342
+ rewriter.remove(range)
343
+ end
200
344
 
201
- raise_types.each { |rt| lines << "#{indent}# @raise [#{rt}]" } if config.emit_raise_tags?
345
+ doc = build_attr_doc_for_node(
346
+ insertion,
347
+ config: config,
348
+ signature_provider: signature_provider
349
+ )
350
+ return if doc.nil? || doc.empty?
202
351
 
203
- lines << "#{indent}# @return [#{normal_type}]" if config.emit_return_tag?(scope, visibility)
352
+ rewriter.insert_before(bol_range, doc)
204
353
 
205
- if config.emit_rescue_conditional_returns?
206
- rescue_specs.each do |(exceptions, rtype)|
207
- ex_display = exceptions.join(', ')
208
- lines << "#{indent}# @return [#{rtype}] if #{ex_display}"
209
- end
210
- end
354
+ when :safe
355
+ info = SourceHelpers.doc_comment_block_info(buffer, bol_range.begin_pos)
211
356
 
212
- lines.map { |l| "#{l}\n" }.join
213
- rescue StandardError
214
- nil
215
- end
357
+ if info
358
+ additions = build_attr_merge_additions(
359
+ insertion,
360
+ existing_lines: info[:lines],
361
+ config: config,
362
+ signature_provider: signature_provider
363
+ )
216
364
 
217
- # +Docscribe::InlineRewriter.build_params_block+ -> Object?
218
- #
219
- # Method documentation.
220
- #
221
- # @param [Object] node Param documentation.
222
- # @param [Object] indent Param documentation.
223
- # @return [Object?]
224
- def self.build_params_block(node, indent)
225
- args =
226
- case node.type
227
- when :def then node.children[1]
228
- when :defs then node.children[2] # FIX: args is children[2], not [3]
229
- end
230
- return nil unless args
231
-
232
- params = []
233
- (args.children || []).each do |a|
234
- case a.type
235
- when :arg
236
- name = a.children.first.to_s
237
- ty = Infer.infer_param_type(name, nil)
238
- params << "#{indent}# @param [#{ty}] #{name} Param documentation."
239
- when :optarg
240
- name, default = *a
241
- ty = Infer.infer_param_type(name.to_s, default&.loc&.expression&.source)
242
- params << "#{indent}# @param [#{ty}] #{name} Param documentation."
243
- when :kwarg
244
- name = "#{a.children.first}:"
245
- ty = Infer.infer_param_type(name, nil)
246
- pname = name.sub(/:$/, '')
247
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
248
- when :kwoptarg
249
- name, default = *a
250
- name = "#{name}:"
251
- ty = Infer.infer_param_type(name, default&.loc&.expression&.source)
252
- pname = name.sub(/:$/, '')
253
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
254
- when :restarg
255
- name = "*#{a.children.first}"
256
- ty = Infer.infer_param_type(name, nil)
257
- pname = a.children.first.to_s
258
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
259
- when :kwrestarg
260
- name = "**#{a.children.first || 'kwargs'}"
261
- ty = Infer.infer_param_type(name, nil)
262
- pname = (a.children.first || 'kwargs').to_s
263
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
264
- when :blockarg
265
- name = "&#{a.children.first}"
266
- ty = Infer.infer_param_type(name, nil)
267
- pname = a.children.first.to_s
268
- params << "#{indent}# @param [#{ty}] #{pname} Param documentation."
269
- when :forward_arg
270
- # Ruby 3 '...' forwarding; skip
271
- end
272
- end
273
- params.empty? ? nil : params
274
- end
365
+ if additions && !additions.empty?
366
+ merge_inserts[info[:end_pos]] << [insertion.node.loc.expression.begin_pos, additions]
367
+ end
275
368
 
276
- class VisibilityCtx
277
- attr_accessor :default_instance_vis, :default_class_vis, :inside_sclass
278
- attr_reader :explicit_instance, :explicit_class
369
+ return
370
+ end
279
371
 
280
- # +Docscribe::InlineRewriter::VisibilityCtx#initialize+ -> Object
281
- #
282
- # Method documentation.
283
- #
284
- # @return [Object]
285
- def initialize
286
- @default_instance_vis = :public
287
- @default_class_vis = :public
288
- @explicit_instance = {} # { name_sym => :private|:protected|:public }
289
- @explicit_class = {} # { name_sym => :private|:protected|:public }
290
- @inside_sclass = false
372
+ doc = build_attr_doc_for_node(
373
+ insertion,
374
+ config: config,
375
+ signature_provider: signature_provider
376
+ )
377
+ return if doc.nil? || doc.empty?
378
+
379
+ rewriter.insert_before(bol_range, doc)
380
+ end
291
381
  end
292
382
 
293
- # +Docscribe::InlineRewriter::VisibilityCtx#dup+ -> Object
383
+ # Apply aggregated merge inserts at shared end positions.
294
384
  #
295
- # Method documentation.
385
+ # Used primarily for attribute merge behavior where multiple additions may target the same block end.
296
386
  #
297
- # @return [Object]
298
- def dup
299
- c = VisibilityCtx.new
300
- c.default_instance_vis = default_instance_vis
301
- c.default_class_vis = default_class_vis
302
- c.inside_sclass = inside_sclass
303
- c.explicit_instance.merge!(explicit_instance)
304
- c.explicit_class.merge!(explicit_class)
305
- c
306
- end
307
- end
387
+ # @private
388
+ # @param [Parser::Source::TreeRewriter] rewriter
389
+ # @param [Parser::Source::Buffer] buffer
390
+ # @param [Hash{Integer=>Array<(Integer,String)>}] merge_inserts
391
+ # @return [void]
392
+ def apply_merge_inserts!(rewriter:, buffer:, merge_inserts:)
393
+ sep_re = /^\s*#\s*\r?\n$/
308
394
 
309
- # Walks nodes and records where to insert docstrings
310
- class Collector < Parser::AST::Processor
311
- Insertion = Struct.new(:node, :scope, :visibility, :container)
395
+ merge_inserts.keys.sort.reverse_each do |end_pos|
396
+ chunks = merge_inserts[end_pos]
397
+ next if chunks.empty?
312
398
 
313
- attr_reader :insertions
399
+ chunks = chunks.sort_by { |(sort_key, _s)| sort_key }
314
400
 
315
- # +Docscribe::InlineRewriter::Collector#initialize+ -> Object
316
- #
317
- # Method documentation.
318
- #
319
- # @param [Object] buffer Param documentation.
320
- # @return [Object]
321
- def initialize(buffer)
322
- super()
323
- @buffer = buffer
324
- @insertions = []
325
- @name_stack = [] # e.g., ['Demo']
326
- end
401
+ out_lines = []
327
402
 
328
- # +Docscribe::InlineRewriter::Collector#on_class+ -> Object
329
- #
330
- # Method documentation.
331
- #
332
- # @param [Object] node Param documentation.
333
- # @return [Object]
334
- def on_class(node)
335
- cname_node, _super_node, body = *node
336
- @name_stack.push(const_name(cname_node))
337
- ctx = VisibilityCtx.new
338
- process_body(body, ctx)
339
- @name_stack.pop
340
- node
403
+ chunks.each do |(_k, chunk)|
404
+ next if chunk.nil? || chunk.empty?
405
+
406
+ lines = chunk.lines
407
+ seps = []
408
+ seps << lines.shift while !lines.empty? && lines.first.match?(sep_re)
409
+
410
+ sep = seps.first
411
+ out_lines << sep if sep && (out_lines.empty? || !out_lines.last.match?(sep_re))
412
+ out_lines.concat(lines)
413
+ end
414
+
415
+ text = out_lines.join
416
+ next if text.empty?
417
+
418
+ range = Parser::Source::Range.new(buffer, end_pos, end_pos)
419
+ rewriter.insert_before(range, text)
420
+ end
341
421
  end
342
422
 
343
- # +Docscribe::InlineRewriter::Collector#on_module+ -> Object
423
+ # Build plain-text merge additions for an attribute doc block.
344
424
  #
345
- # Method documentation.
346
- #
347
- # @param [Object] node Param documentation.
348
- # @return [Object]
349
- def on_module(node)
350
- cname_node, body = *node
351
- @name_stack.push(const_name(cname_node))
352
- ctx = VisibilityCtx.new
353
- process_body(body, ctx)
354
- @name_stack.pop
355
- node
425
+ # @private
426
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
427
+ # @param [Array<String>] existing_lines
428
+ # @param [Docscribe::Config] config
429
+ # @param [Object] signature_provider Param documentation.
430
+ # @raise [StandardError]
431
+ # @return [String, nil]
432
+ def build_attr_merge_additions(ins, existing_lines:, config:, signature_provider:)
433
+ indent = SourceHelpers.line_indent(ins.node)
434
+ param_tag_style = config.param_tag_style
435
+ existing = existing_attr_names(existing_lines)
436
+ missing = ins.names.reject { |name_sym| existing[name_sym.to_s] }
437
+ return '' if missing.empty?
438
+
439
+ lines = []
440
+ lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
441
+
442
+ missing.each_with_index do |name_sym, idx|
443
+ attr_name = name_sym.to_s
444
+ mode = ins.access.to_s
445
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
446
+
447
+ lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
448
+
449
+ if config.emit_visibility_tags?
450
+ lines << "#{indent}# @private" if ins.visibility == :private
451
+ lines << "#{indent}# @protected" if ins.visibility == :protected
452
+ end
453
+
454
+ lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
455
+ if %i[w rw].include?(ins.access)
456
+ lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
457
+ end
458
+ lines << "#{indent}#" if idx < missing.length - 1
459
+ end
460
+
461
+ lines.map { |l| "#{l}\n" }.join
462
+ rescue StandardError
463
+ nil
356
464
  end
357
465
 
358
- # +Docscribe::InlineRewriter::Collector#on_def+ -> Object
466
+ # Extract already documented attribute names from existing `@!attribute` lines.
359
467
  #
360
- # Method documentation.
361
- #
362
- # @param [Object] node Param documentation.
363
- # @return [Object]
364
- def on_def(node)
365
- @insertions << Insertion.new(node, :instance, :public, current_container)
366
- node
468
+ # @private
469
+ # @param [Array<String>] lines
470
+ # @return [Hash{String=>Boolean}]
471
+ def existing_attr_names(lines)
472
+ names = {}
473
+
474
+ Array(lines).each do |line|
475
+ if (m = line.match(/^\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+(\S+)/))
476
+ names[m[1]] = true
477
+ end
478
+ end
479
+
480
+ names
367
481
  end
368
482
 
369
- # +Docscribe::InlineRewriter::Collector#on_defs+ -> Object
483
+ # Decide whether an attribute macro should be emitted according to method filters.
370
484
  #
371
- # Method documentation.
372
- #
373
- # @param [Object] node Param documentation.
374
- # @return [Object]
375
- def on_defs(node)
376
- @insertions << Insertion.new(node, :class, :public, current_container)
377
- node
378
- end
485
+ # @private
486
+ # @param [Docscribe::Config] config
487
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
488
+ # @return [Boolean]
489
+ def attribute_allowed?(config, ins)
490
+ ins.names.any? do |name_sym|
491
+ ok = false
492
+
493
+ if %i[r rw].include?(ins.access)
494
+ ok ||= config.process_method?(
495
+ container: ins.container,
496
+ scope: ins.scope,
497
+ visibility: ins.visibility,
498
+ name: name_sym
499
+ )
500
+ end
379
501
 
380
- private
502
+ if %i[w rw].include?(ins.access)
503
+ ok ||= config.process_method?(
504
+ container: ins.container,
505
+ scope: ins.scope,
506
+ visibility: ins.visibility,
507
+ name: :"#{name_sym}="
508
+ )
509
+ end
381
510
 
382
- # +Docscribe::InlineRewriter::Collector#process_stmt+ -> Object
383
- #
384
- # Method documentation.
511
+ ok
512
+ end
513
+ end
514
+
515
+ # Build a full `@!attribute` documentation block for one attribute insertion.
385
516
  #
386
517
  # @private
387
- # @param [Object] node Param documentation.
388
- # @param [Object] ctx Param documentation.
389
- # @return [Object]
390
- def process_stmt(node, ctx)
391
- return unless node
392
-
393
- case node.type
394
- when :def
395
- name, _args, _body = *node
396
- if ctx.inside_sclass
397
- vis = ctx.explicit_class[name] || ctx.default_class_vis
398
- scope = :class
399
- else
400
- vis = ctx.explicit_instance[name] || ctx.default_instance_vis
401
- scope = :instance
518
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
519
+ # @param [Docscribe::Config] config
520
+ # @param [Object] signature_provider Param documentation.
521
+ # @raise [StandardError]
522
+ # @return [String, nil]
523
+ def build_attr_doc_for_node(ins, config:, signature_provider:)
524
+ indent = SourceHelpers.line_indent(ins.node)
525
+ param_tag_style = config.param_tag_style
526
+ lines = []
527
+
528
+ ins.names.each_with_index do |name_sym, idx|
529
+ attr_name = name_sym.to_s
530
+ mode = ins.access.to_s
531
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
532
+
533
+ lines << "#{indent}# @!attribute [#{mode}] #{attr_name}"
534
+
535
+ if config.emit_visibility_tags?
536
+ lines << "#{indent}# @private" if ins.visibility == :private
537
+ lines << "#{indent}# @protected" if ins.visibility == :protected
402
538
  end
403
- @insertions << Insertion.new(node, scope, vis, current_container)
404
539
 
405
- when :defs
406
- _, name, _args, _body = *node
407
- vis = ctx.explicit_class[name] || ctx.default_class_vis
408
- @insertions << Insertion.new(node, :class, vis, current_container)
540
+ lines << "#{indent}# @return [#{attr_type}]" if %i[r rw].include?(ins.access)
541
+ if %i[w rw].include?(ins.access)
542
+ lines << format_attribute_param_tag(indent, 'value', attr_type, style: param_tag_style)
543
+ end
409
544
 
410
- when :sclass
411
- recv, body = *node
412
- inner_ctx = ctx.dup
413
- inner_ctx.inside_sclass = self_node?(recv)
414
- inner_ctx.default_class_vis = :public
415
- process_body(body, inner_ctx)
545
+ lines << "#{indent}#" if idx < ins.names.length - 1
546
+ end
416
547
 
417
- when :send
418
- process_visibility_send(node, ctx)
548
+ lines.map { |l| "#{l}\n" }.join
549
+ rescue StandardError
550
+ nil
551
+ end
419
552
 
553
+ # Method documentation.
554
+ #
555
+ # @private
556
+ # @param [Object] indent Param documentation.
557
+ # @param [Object] name Param documentation.
558
+ # @param [Object] type Param documentation.
559
+ # @param [Object] style Param documentation.
560
+ # @return [String]
561
+ def format_attribute_param_tag(indent, name, type, style:)
562
+ type = type.to_s
563
+
564
+ case style.to_s
565
+ when 'name_type'
566
+ "#{indent}# @param #{name} [#{type}]"
420
567
  else
421
- process(node)
568
+ "#{indent}# @param [#{type}] #{name}"
422
569
  end
423
570
  end
424
571
 
425
- # +Docscribe::InlineRewriter::Collector#process_visibility_send+ -> Object
572
+ # Determine the attribute type for one attr name.
573
+ #
574
+ # Prefers the RBS reader signature when available; otherwise falls back to the config fallback type.
426
575
  #
576
+ # @private
577
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins
578
+ # @param [Symbol] name_sym
579
+ # @param [Docscribe::Config] config
580
+ # @param [Object] signature_provider Param documentation.
581
+ # @raise [StandardError]
582
+ # @return [String]
583
+ def attribute_type(ins, name_sym, config, signature_provider:)
584
+ ty = config.fallback_type
585
+ return ty unless signature_provider
586
+
587
+ reader_sig = signature_provider.signature_for(container: ins.container, scope: ins.scope, name: name_sym)
588
+ reader_sig&.return_type || ty
589
+ rescue StandardError
590
+ config.fallback_type
591
+ end
592
+
427
593
  # Method documentation.
428
594
  #
429
595
  # @private
430
- # @param [Object] node Param documentation.
431
- # @param [Object] ctx Param documentation.
596
+ # @param [Object] config Param documentation.
597
+ # @param [Object] code Param documentation.
598
+ # @param [Object] file Param documentation.
599
+ # @raise [StandardError]
432
600
  # @return [Object]
433
- def process_visibility_send(node, ctx)
434
- recv, meth, *args = *node
435
- return unless recv.nil? && %i[private protected public].include?(meth)
436
-
437
- if args.empty?
438
- # bare keyword: affects current def-target
439
- if ctx.inside_sclass
440
- ctx.default_class_vis = meth
441
- else
442
- ctx.default_instance_vis = meth
443
- end
444
- else
445
- # explicit list: affects current def-target
446
- args.each do |arg|
447
- sym = extract_name_sym(arg)
448
- next unless sym
449
-
450
- if ctx.inside_sclass
451
- ctx.explicit_class[sym] = meth
452
- else
453
- ctx.explicit_instance[sym] = meth
454
- end
455
-
456
- target = ctx.inside_sclass ? 'class' : 'instance'
457
- if args.empty?
458
- puts "[vis] bare #{meth} -> default_#{target}_vis=#{meth}"
459
- else
460
- puts "[vis] explicit #{meth} -> #{target} names=#{args.map { |a| extract_name_sym(a) }.inspect}"
461
- end
462
- end
601
+ # @return [Object?] if StandardError
602
+ def build_signature_provider(config, code, file)
603
+ if config.respond_to?(:signature_provider_for)
604
+ config.signature_provider_for(source: code, file: file)
605
+ elsif config.respond_to?(:signature_provider)
606
+ config.signature_provider
607
+ elsif config.respond_to?(:rbs_provider)
608
+ config.rbs_provider
463
609
  end
610
+ rescue StandardError
611
+ config.respond_to?(:rbs_provider) ? config.rbs_provider : nil
464
612
  end
465
613
 
466
- # +Docscribe::InlineRewriter::Collector#extract_name_sym+ -> Object
467
- #
468
614
  # Method documentation.
469
615
  #
470
616
  # @private
471
- # @param [Object] arg Param documentation.
617
+ # @param [Object] insertion Param documentation.
618
+ # @param [Object] config Param documentation.
619
+ # @param [Object] signature_provider Param documentation.
472
620
  # @return [Object]
473
- def extract_name_sym(arg)
474
- case arg.type
475
- when :sym then arg.children.first
476
- when :str then arg.children.first.to_sym
477
- end
621
+ def build_method_doc(insertion, config:, signature_provider:)
622
+ DocBuilder.build(
623
+ insertion,
624
+ config: config,
625
+ signature_provider: signature_provider
626
+ )
478
627
  end
479
628
 
480
- # +Docscribe::InlineRewriter::Collector#self_node?+ -> Object
481
- #
482
629
  # Method documentation.
483
630
  #
484
631
  # @private
485
- # @param [Object] node Param documentation.
632
+ # @param [Object] insertion Param documentation.
633
+ # @param [Object] existing_lines Param documentation.
634
+ # @param [Object] config Param documentation.
635
+ # @param [Object] signature_provider Param documentation.
486
636
  # @return [Object]
487
- def self_node?(node)
488
- node && node.type == :self
637
+ def build_missing_method_merge_result(insertion, existing_lines:, config:, signature_provider:)
638
+ DocBuilder.build_missing_merge_result(
639
+ insertion,
640
+ existing_lines: existing_lines,
641
+ config: config,
642
+ signature_provider: signature_provider
643
+ )
489
644
  end
490
645
 
491
- # +Docscribe::InlineRewriter::Collector#current_container+ -> Object
492
- #
493
646
  # Method documentation.
494
647
  #
495
648
  # @private
649
+ # @param [Object] buffer Param documentation.
650
+ # @param [Object] insertion Param documentation.
496
651
  # @return [Object]
497
- def current_container
498
- @name_stack.empty? ? 'Object' : @name_stack.join('::')
652
+ def method_doc_comment_info(buffer, insertion)
653
+ anchor_bol_range, def_bol_range = method_bol_ranges(buffer, insertion)
654
+
655
+ SourceHelpers.doc_comment_block_info(buffer, anchor_bol_range.begin_pos) ||
656
+ SourceHelpers.doc_comment_block_info(buffer, def_bol_range.begin_pos)
499
657
  end
500
658
 
501
- # +Docscribe::InlineRewriter::Collector#const_name+ -> Object
502
- #
503
659
  # Method documentation.
504
660
  #
505
661
  # @private
506
- # @param [Object] node Param documentation.
662
+ # @param [Object] buffer Param documentation.
663
+ # @param [Object] insertion Param documentation.
507
664
  # @return [Object]
508
- def const_name(node)
509
- return 'Object' unless node
510
-
511
- case node.type
512
- when :const
513
- scope, name = *node
514
- scope_name = scope ? const_name(scope) : nil
515
- [scope_name, name].compact.join('::')
516
- when :cbase
517
- '' # leading ::
518
- else
519
- node.loc.expression.source # fallback
520
- end
665
+ def method_comment_block_removal_range(buffer, insertion)
666
+ anchor_bol_range, def_bol_range = method_bol_ranges(buffer, insertion)
667
+
668
+ SourceHelpers.comment_block_removal_range(buffer, anchor_bol_range.begin_pos) ||
669
+ SourceHelpers.comment_block_removal_range(buffer, def_bol_range.begin_pos)
521
670
  end
522
671
 
523
- # +Docscribe::InlineRewriter::Collector#process_body+ -> Object
672
+ # Method documentation.
524
673
  #
674
+ # @private
675
+ # @param [Object] buffer Param documentation.
676
+ # @param [Object] insertion Param documentation.
677
+ # @return [Array]
678
+ def method_bol_ranges(buffer, insertion)
679
+ anchor_node = anchor_node_for(insertion)
680
+ [
681
+ SourceHelpers.line_start_range(buffer, anchor_node),
682
+ SourceHelpers.line_start_range(buffer, insertion.node)
683
+ ]
684
+ end
685
+
525
686
  # Method documentation.
526
687
  #
527
688
  # @private
528
- # @param [Object] body Param documentation.
529
- # @param [Object] ctx Param documentation.
689
+ # @param [Object] insertion Param documentation.
690
+ # @raise [StandardError]
530
691
  # @return [Object]
531
- def process_body(body, ctx)
532
- return unless body
692
+ # @return [Object] if StandardError
693
+ def method_line_for(insertion)
694
+ anchor_node_for(insertion).loc.expression.line
695
+ rescue StandardError
696
+ insertion.node.loc.expression.line
697
+ end
533
698
 
534
- if body.type == :begin
535
- body.children.each { |child| process_stmt(child, ctx) }
699
+ # Method documentation.
700
+ #
701
+ # @private
702
+ # @param [Object] insertion Param documentation.
703
+ # @return [Object]
704
+ def anchor_node_for(insertion)
705
+ if insertion.respond_to?(:anchor_node) && insertion.anchor_node
706
+ insertion.anchor_node
536
707
  else
537
- process_stmt(body, ctx)
708
+ insertion.node
538
709
  end
539
710
  end
540
711
  end