docscribe 1.5.1 → 1.6.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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ module Infer
5
+ # Analyzes method behavior (predicates, side effects, mutating calls).
6
+ module Behavior
7
+ MUTATING_METHODS = %i[<< push pop delete merge update write save create destroy
8
+ insert update_all delete_all].freeze
9
+
10
+ class << self
11
+ # @param [Object] body
12
+ # @param [Object] method_name
13
+ # @return [Hash<Symbol, Object>]
14
+ def analyze(body, method_name)
15
+ result = default_result(method_name)
16
+
17
+ return result unless body.is_a?(Parser::AST::Node)
18
+
19
+ analyze_body(body, result)
20
+ result
21
+ end
22
+
23
+ # @param [Object] method_name
24
+ # @return [Hash<Symbol, Object>]
25
+ def default_result(method_name)
26
+ {
27
+ predicate: method_name&.to_s&.end_with?('?') || false,
28
+ bang: method_name&.to_s&.end_with?('!') || false,
29
+ has_side_effects: false,
30
+ delegates: nil,
31
+ returns_self: false,
32
+ returns_boolean: false
33
+ }
34
+ end
35
+
36
+ # @param [Hash<Symbol, Object>] analysis
37
+ # @param [Object] _method_name
38
+ # @return [String?]
39
+ def infer_description(analysis, _method_name)
40
+ return nil unless analysis[:has_side_effects] || analysis[:predicate]
41
+
42
+ if analysis[:predicate]
43
+ 'Returns true if the condition is met, false otherwise'
44
+ elsif analysis[:returns_self]
45
+ 'Returns self to allow method chaining'
46
+ elsif analysis[:has_side_effects]
47
+ nil
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # @private
54
+ # @param [Object] node
55
+ # @param [Hash<Symbol, Object>] result
56
+ # @return [void]
57
+ def analyze_body(node, result)
58
+ case node.type
59
+ when :ivasgn, :ivar
60
+ result[:has_side_effects] = true
61
+ when :send
62
+ analyze_send(node, result)
63
+ when :self
64
+ result[:returns_self] = true if result[:returns_self]
65
+ end
66
+
67
+ recurse_children(node, result)
68
+ end
69
+
70
+ # @private
71
+ # @param [Object] node
72
+ # @param [Hash<Symbol, Object>] result
73
+ # @return [void]
74
+ def recurse_children(node, result)
75
+ node.children.each do |child|
76
+ analyze_body(child, result) if child.is_a?(Parser::AST::Node)
77
+ end
78
+ end
79
+
80
+ # @private
81
+ # @param [Object] node
82
+ # @param [Hash<Symbol, Object>] result
83
+ # @return [void]
84
+ def analyze_send(node, result)
85
+ _receiver, method_name = *node
86
+ method_sym = method_name.is_a?(Symbol) ? method_name : nil
87
+ return unless method_sym
88
+
89
+ return unless MUTATING_METHODS.include?(method_sym)
90
+
91
+ result[:has_side_effects] = true
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -48,9 +48,7 @@ module Docscribe
48
48
  # @param [Boolean] treat_options_keyword_as_hash whether to treat 'options:' as Hash
49
49
  # @return [String]
50
50
  def inferred_param_type(name, default_str, fallback_type, treat_options_keyword_as_hash:)
51
- if name.end_with?(':') && default_str.nil?
52
- return options_keyword_type(name, treat_options_keyword_as_hash, fallback_type)
53
- end
51
+ return options_keyword_type(name, treat_options_keyword_as_hash, fallback_type) if name.end_with?(':') && default_str.nil?
54
52
 
55
53
  node = parse_expr(default_str)
56
54
  ty = Literals.type_from_literal(node, fallback_type: fallback_type)
@@ -98,7 +96,7 @@ module Docscribe
98
96
  buffer = Parser::Source::Buffer.new('(param)')
99
97
  buffer.source = src
100
98
  Docscribe::Parsing.parse_buffer(buffer)
101
- rescue Parser::SyntaxError # steep:ignore
99
+ rescue Parser::SyntaxError
102
100
  nil
103
101
  end
104
102
  end
@@ -26,7 +26,7 @@ module Docscribe
26
26
  local_var_types = build_local_variable_types(body)
27
27
  run_last_expr_type(body, fallback_type: FALLBACK_TYPE, nil_as_optional: true,
28
28
  local_var_types: local_var_types) || FALLBACK_TYPE
29
- rescue Parser::SyntaxError # steep:ignore
29
+ rescue Parser::SyntaxError
30
30
  FALLBACK_TYPE
31
31
  end
32
32
 
@@ -16,6 +16,7 @@ require_relative 'infer/literals'
16
16
  require_relative 'infer/params'
17
17
  require_relative 'infer/returns'
18
18
  require_relative 'infer/raises'
19
+ require_relative 'infer/behavior'
19
20
 
20
21
  module Docscribe
21
22
  # Best-effort inference utilities used to generate YARD tags.
@@ -37,6 +38,40 @@ module Docscribe
37
38
  Raises.infer_raises_from_node(node)
38
39
  end
39
40
 
41
+ # Analyze method behavior from AST node and method name.
42
+ #
43
+ # @param [Object] node def or defs node
44
+ # @param [Object] method_name
45
+ # @return [Hash<Symbol, Object>]
46
+ def analyze_behavior(node, method_name)
47
+ body = extract_body(node)
48
+ Behavior.analyze(body, method_name)
49
+ end
50
+
51
+ # Get behavioral description for a method.
52
+ #
53
+ # @param [Object] node def or defs node
54
+ # @param [Object] method_name
55
+ # @return [String?]
56
+ def infer_behavior_description(node, method_name)
57
+ body = extract_body(node)
58
+ return nil unless body
59
+
60
+ analysis = Behavior.analyze(body, method_name)
61
+ Behavior.infer_description(analysis, method_name)
62
+ end
63
+
64
+ # Extract method body from def/defs node.
65
+ #
66
+ # @param [Object] node def or defs node
67
+ # @return [Object?]
68
+ def extract_body(node)
69
+ return nil unless node.is_a?(Parser::AST::Node)
70
+
71
+ body_idx = node.type == :def ? 2 : 3
72
+ node.children[body_idx]
73
+ end
74
+
40
75
  # Infer a parameter type from its internal name form and optional default
41
76
  # expression.
42
77
  #
@@ -186,7 +186,7 @@ module Docscribe
186
186
  # @param [Hash<Symbol, Object>] opts additional options including
187
187
  # @return [Hash<Symbol, Object>]
188
188
  def resolve_doc_setup!(setup, node, name, config, opts)
189
- external_sig = resolve_external_sig(setup[:container], setup[:scope], name, opts[:signature_provider])
189
+ external_sig = resolve_external_sig(setup[:container], setup[:scope], name, opts[:signature_provider], node)
190
190
  returns_spec = compute_returns_spec(node, config, opts[:param_types], opts[:core_rbs_provider],
191
191
  signature_provider: opts[:signature_provider],
192
192
  container: setup[:container])
@@ -219,9 +219,26 @@ module Docscribe
219
219
  # @param [Symbol] scope method scope symbol
220
220
  # @param [Symbol] name the method name string
221
221
  # @param [Docscribe::Types::ProviderChain, nil] signature_provider external sig provider
222
+ # @param [Parser::AST::Node] node
222
223
  # @return [Docscribe::Types::MethodSignature, nil]
223
- def resolve_external_sig(container, scope, name, signature_provider)
224
- signature_provider&.signature_for(container: container, scope: scope, name: name)
224
+ def resolve_external_sig(container, scope, name, signature_provider, node = nil)
225
+ param_count, param_names = extract_sig_param_info(node)
226
+ signature_provider&.signature_for(container: container, scope: scope, name: name,
227
+ param_count: param_count, param_names: param_names)
228
+ end
229
+
230
+ # @note module_function: defines #extract_sig_param_info (visibility: private)
231
+ # @param [Parser::AST::Node?] node
232
+ # @return [(Integer?, Array<String>)]
233
+ def extract_sig_param_info(node)
234
+ empty = [] #: Array[String]
235
+ return [nil, empty] unless node
236
+
237
+ args = extract_args_from_node(node)
238
+ empty = [] #: Array[String]
239
+ return [nil, empty] unless args
240
+
241
+ [args.children.length, args.children.map { |a| a.children.first.to_s if a.respond_to?(:children) }.compact]
225
242
  end
226
243
 
227
244
  # Compute returns spec
@@ -594,7 +611,7 @@ module Docscribe
594
611
 
595
612
  return_type = rest[1...type_end] #: String
596
613
  desc = rest[(type_end + 1)..]&.strip
597
- [return_type, desc&.empty? ? nil : desc]
614
+ [return_type, desc && desc.empty? ? nil : desc]
598
615
  end
599
616
 
600
617
  # Extract all comment tags from line
@@ -832,9 +849,7 @@ module Docscribe
832
849
  # @param [Hash<Symbol, Object>] info parse info hash to update with visibility flags
833
850
  # @return [Array<String>]
834
851
  def merge_module_function_note_lines(indent, insertion, name, info)
835
- unless insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
836
- return []
837
- end
852
+ return [] unless insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
838
853
 
839
854
  included_vis = insertion.included_instance_visibility || :private
840
855
  ["#{indent}# @note module_function: defines ##{name} (visibility: #{included_vis})"]
@@ -1228,9 +1243,7 @@ module Docscribe
1228
1243
  end
1229
1244
 
1230
1245
  method_name = :"build_#{arg_node.type}_lines"
1231
- if respond_to?(method_name, true)
1232
- return send(method_name, arg_node, indent, external_sig, param_types_override, **opts)
1233
- end
1246
+ return send(method_name, arg_node, indent, external_sig, param_types_override, **opts) if respond_to?(method_name, true)
1234
1247
 
1235
1248
  []
1236
1249
  end
@@ -158,7 +158,8 @@ module Docscribe
158
158
  # @param [String] code the Ruby source code string to parse and rewrite
159
159
  # @param [Hash<Symbol, Object>] options hash containing :config, :file, and :core_rbs_provider
160
160
  # @raise [Docscribe::ParseError]
161
- # @return [Hash<Symbol, Docscribe::Config, String, Parser::Source::Buffer, Parser::AST::Node, Docscribe::Types::ProviderChain, nil, Object, nil>]
161
+ # @return [Hash<Symbol, Docscribe::Config, String, Parser::Source::Buffer, Parser::AST::Node, Docscribe::Types::ProviderChain, nil, Object, nil>] rewrite environment with keys
162
+ # :config, :file, :buffer, :ast, :core_rbs_provider
162
163
  def setup_rewrite_env(code, options)
163
164
  config = options[:config] || Docscribe::Config.load
164
165
  file = (options[:file] || '(inline)').to_s
@@ -454,7 +455,8 @@ module Docscribe
454
455
  # Plugin insertion priority
455
456
  #
456
457
  # @private
457
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
458
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
459
+ # method insertion (plugin hash or collected insertion object)
458
460
  # @raise [StandardError]
459
461
  # @return [Integer]
460
462
  # @return [Integer] if StandardError
@@ -469,7 +471,8 @@ module Docscribe
469
471
  # Plugin insertion label
470
472
  #
471
473
  # @private
472
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
474
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
475
+ # method insertion (plugin hash or collected insertion object)
473
476
  # @raise [StandardError]
474
477
  # @return [String]
475
478
  # @return [String] if StandardError
@@ -485,7 +488,8 @@ module Docscribe
485
488
  # Plugin insertion line
486
489
  #
487
490
  # @private
488
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
491
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
492
+ # method insertion (plugin hash or collected insertion object)
489
493
  # @raise [StandardError]
490
494
  # @return [Integer, nil]
491
495
  # @return [nil] if StandardError
@@ -630,9 +634,7 @@ module Docscribe
630
634
  def normalize_plugin_doc(doc, indent, config:, anchor_node:)
631
635
  doc = normalize_plugin_doc_indent(doc, indent)
632
636
  doc = trim_trailing_blank_lines(doc)
633
- if anchor_node && %i[def defs].include?(anchor_node.type) && config.include_default_message?
634
- doc = prepend_default_message_if_no_prose(doc, anchor_node, indent, config)
635
- end
637
+ doc = prepend_default_message_if_no_prose(doc, anchor_node, indent, config) if anchor_node && %i[def defs].include?(anchor_node.type) && config.include_default_message?
636
638
  doc
637
639
  end
638
640
 
@@ -794,9 +796,7 @@ module Docscribe
794
796
  # @return [void]
795
797
  def merge_existing_descriptions!(params, parsed)
796
798
  params[:param_descriptions] = parsed[:param_descriptions] if parsed[:param_descriptions].any?
797
- if parsed[:return_description] && !parsed[:return_description].start_with?('if ')
798
- params[:return_description] = parsed[:return_description]
799
- end
799
+ params[:return_description] = parsed[:return_description] if parsed[:return_description] && !parsed[:return_description].start_with?('if ')
800
800
  params[:description] = parsed[:description] if parsed[:description].any?
801
801
  end
802
802
 
@@ -841,13 +841,27 @@ module Docscribe
841
841
  # @return [Docscribe::Types::MethodSignature, nil]
842
842
  def resolve_external_signature(insertion, signature_provider)
843
843
  node_name = SourceHelpers.node_name(insertion.node) #: Symbol
844
+ param_count, param_names = extract_param_info(insertion)
844
845
  signature_provider&.signature_for(
845
846
  container: insertion.container,
846
847
  scope: insertion.scope,
847
- name: node_name
848
+ name: node_name,
849
+ param_count: param_count,
850
+ param_names: param_names
848
851
  )
849
852
  end
850
853
 
854
+ # @private
855
+ # @param [Object] insertion
856
+ # @return [(Integer?, Array<Object>)]
857
+ def extract_param_info(insertion)
858
+ args = DocBuilder.extract_args_from_node(insertion.node)
859
+ empty = [] #: Array[untyped]
860
+ return [nil, empty] unless args
861
+
862
+ [args.children.length, args.children.map { |a| a.children.first.to_s if a.respond_to?(:children) }.compact]
863
+ end
864
+
851
865
  # Resolve param types
852
866
  #
853
867
  # @private
@@ -1079,7 +1093,8 @@ module Docscribe
1079
1093
  # @param [Docscribe::Config] config the active Docscribe::Config
1080
1094
  # @param [Docscribe::Types::ProviderChain, nil] signature_provider external RBS signature provider
1081
1095
  # @param [Parser::Source::Range] bol_range the beginning-of-line range for the attribute node
1082
- # @return [Hash<Symbol, Docscribe::InlineRewriter::Collector::AttrInsertion, Docscribe::Config, Docscribe::Types::ProviderChain, nil, Parser::Source::Range>]
1096
+ # @return [Hash<Symbol, Docscribe::InlineRewriter::Collector::AttrInsertion, Docscribe::Config, Docscribe::Types::ProviderChain, nil, Parser::Source::Range>] attr insertion params with keys
1097
+ # :insertion, :config, :signature_provider, :bol_range
1083
1098
  def attr_insertion_params(insertion, config, signature_provider, bol_range)
1084
1099
  {
1085
1100
  insertion: insertion, config: config,
@@ -1237,19 +1252,52 @@ module Docscribe
1237
1252
  # @return [String, nil]
1238
1253
  # @return [nil] if StandardError
1239
1254
  def build_attr_merge_additions(ins:, existing_lines:, config:, signature_provider:)
1240
- missing = missing_attr_names(ins, existing_lines)
1241
- return '' if missing.empty?
1242
-
1243
1255
  indent = SourceHelpers.line_indent(ins.node)
1244
- lines = [] #: Array[String]
1245
- lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1246
- lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1247
- signature_provider: signature_provider, names: missing))
1248
- lines.map { |l| "#{l}\n" }.join
1256
+ lines = build_attr_merge_missing_lines(ins, existing_lines, indent, config, signature_provider)
1257
+ lines.concat(build_attr_merge_tag_lines(ins, existing_lines, indent, config, signature_provider))
1258
+
1259
+ lines.empty? ? '' : lines.map { |l| "#{l}\n" }.join
1249
1260
  rescue StandardError
1250
1261
  nil
1251
1262
  end
1252
1263
 
1264
+ # @private
1265
+ # @param [Object] ins
1266
+ # @param [Array<String>] existing_lines
1267
+ # @param [String] indent
1268
+ # @param [Docscribe::Config] config
1269
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider
1270
+ # @return [Array<String>]
1271
+ def build_attr_merge_missing_lines(ins, existing_lines, indent, config, signature_provider)
1272
+ missing = missing_attr_names(ins, existing_lines)
1273
+ lines = [] #: Array[String]
1274
+
1275
+ unless missing.empty?
1276
+ lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1277
+ lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1278
+ signature_provider: signature_provider, names: missing))
1279
+ end
1280
+
1281
+ lines
1282
+ end
1283
+
1284
+ # @private
1285
+ # @param [Object] ins
1286
+ # @param [Array<String>] existing_lines
1287
+ # @param [String] indent
1288
+ # @param [Docscribe::Config] config
1289
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider
1290
+ # @return [Array<String>]
1291
+ def build_attr_merge_tag_lines(ins, existing_lines, indent, config, signature_provider)
1292
+ tag_additions = build_missing_tag_additions(ins, existing_lines, indent, config, signature_provider)
1293
+ return [] if tag_additions.empty?
1294
+
1295
+ result = [] #: Array[String]
1296
+ result << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1297
+ result.concat(tag_additions)
1298
+ result
1299
+ end
1300
+
1253
1301
  # Missing attr names
1254
1302
  #
1255
1303
  # @private
@@ -1278,6 +1326,80 @@ module Docscribe
1278
1326
  names
1279
1327
  end
1280
1328
 
1329
+ # Build missing tag additions for existing @!attribute lines
1330
+ #
1331
+ # @private
1332
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins the attribute insertion object
1333
+ # @param [Array<String>] existing_lines array of existing doc comment lines
1334
+ # @param [String] indent whitespace indentation prefix
1335
+ # @param [Docscribe::Config] config the active Docscribe::Config
1336
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider external RBS signature provider
1337
+ # @return [Array<String>]
1338
+ def build_missing_tag_additions(ins, existing_lines, indent, config, signature_provider)
1339
+ ins.names.each_with_object([]) do |name_sym, additions|
1340
+ idx = existing_attr_line_index(existing_lines, name_sym.to_s)
1341
+ next unless idx
1342
+
1343
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
1344
+ additions << "#{indent}# @return [#{attr_type}]" if attr_return_missing?(ins.access, existing_lines, idx)
1345
+ additions << format_attribute_param_tag(indent, 'value', attr_type, style: config.param_tag_style) if attr_param_missing?(ins.access, existing_lines, idx)
1346
+ end
1347
+ end
1348
+
1349
+ # @private
1350
+ # @param [Symbol] access
1351
+ # @param [Array<String>] existing_lines
1352
+ # @param [Integer] idx
1353
+ # @return [Boolean]
1354
+ def attr_return_missing?(access, existing_lines, idx)
1355
+ %i[r rw].include?(access) && !existing_lines_contain_tag?(existing_lines, idx, 'return')
1356
+ end
1357
+
1358
+ # @private
1359
+ # @param [Symbol] access
1360
+ # @param [Array<String>] existing_lines
1361
+ # @param [Integer] idx
1362
+ # @return [Boolean]
1363
+ def attr_param_missing?(access, existing_lines, idx)
1364
+ %i[w rw].include?(access) && !existing_lines_contain_tag?(existing_lines, idx, 'param')
1365
+ end
1366
+
1367
+ # Find index of line containing @!attribute for a specific name
1368
+ #
1369
+ # @private
1370
+ # @param [Array<String>] lines array of existing doc comment lines
1371
+ # @param [String] name the attribute name to search for
1372
+ # @return [Integer?]
1373
+ def existing_attr_line_index(lines, name)
1374
+ Array(lines).each_with_index do |line, idx|
1375
+ return idx if line.match?(/\A\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+#{Regexp.escape(name)}\b/)
1376
+ end
1377
+ nil
1378
+ end
1379
+
1380
+ # Check if lines after an @!attribute line contain a specific tag
1381
+ #
1382
+ # Walks forward from attr_line_idx + 1, stopping at the next top-level
1383
+ # directive or non-comment line.
1384
+ #
1385
+ # @private
1386
+ # @param [Array<String>] lines array of existing doc comment lines
1387
+ # @param [Integer] attr_line_idx index of the @!attribute line
1388
+ # @param [String] tag_name the tag name to search for (e.g. "return", "param")
1389
+ # @return [Boolean]
1390
+ def existing_lines_contain_tag?(lines, attr_line_idx, tag_name)
1391
+ i = attr_line_idx + 1
1392
+ while i < lines.length
1393
+ line = lines[i]
1394
+ break unless line.match?(/\A\s*#/) # not a comment line
1395
+ break if line.match?(/\A\s*# @(?!@)/) # next top-level directive (single space)
1396
+ return true if line.match?(/\A\s*#\s{2,}@#{tag_name}\b/)
1397
+
1398
+ i += 1
1399
+ end
1400
+ false
1401
+ end
1402
+
1281
1403
  # Attribute allowed
1282
1404
  #
1283
1405
  # @private