docscribe 1.6.0 → 1.6.2

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -193
  3. data/exe/docscribe-client +26 -7
  4. data/lib/docscribe/cli/config_builder.rb +37 -2
  5. data/lib/docscribe/cli/coverage.rb +5 -5
  6. data/lib/docscribe/cli/formatters/json.rb +74 -29
  7. data/lib/docscribe/cli/formatters/sarif.rb +20 -3
  8. data/lib/docscribe/cli/options.rb +17 -2
  9. data/lib/docscribe/cli/rbs_gen.rb +4 -4
  10. data/lib/docscribe/cli/run.rb +107 -24
  11. data/lib/docscribe/cli/update_types.rb +61 -17
  12. data/lib/docscribe/cli.rb +19 -13
  13. data/lib/docscribe/config/defaults.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +22 -1
  15. data/lib/docscribe/config/template.rb +3 -0
  16. data/lib/docscribe/config/validation.rb +19 -0
  17. data/lib/docscribe/config.rb +1 -0
  18. data/lib/docscribe/infer/behavior.rb +13 -13
  19. data/lib/docscribe/infer/params.rb +2 -2
  20. data/lib/docscribe/infer/raises.rb +5 -6
  21. data/lib/docscribe/infer/returns.rb +1612 -151
  22. data/lib/docscribe/infer.rb +7 -7
  23. data/lib/docscribe/inline_rewriter/doc_builder.rb +485 -102
  24. data/lib/docscribe/inline_rewriter.rb +263 -97
  25. data/lib/docscribe/plugin/registry.rb +1 -0
  26. data/lib/docscribe/server/base.rb +255 -0
  27. data/lib/docscribe/server/client.rb +95 -0
  28. data/lib/docscribe/server/daemon.rb +678 -0
  29. data/lib/docscribe/server/protocol.rb +50 -0
  30. data/lib/docscribe/server.rb +4 -835
  31. data/lib/docscribe/types/primitive.rb +160 -0
  32. data/lib/docscribe/types/sorbet/base_provider.rb +33 -1
  33. data/lib/docscribe/types/yard/formatter.rb +35 -6
  34. data/lib/docscribe/types/yard/parser.rb +25 -20
  35. data/lib/docscribe/types/yard/validator.rb +131 -0
  36. data/lib/docscribe/validator/generic_compatibility.rb +698 -0
  37. data/lib/docscribe/validator/type_mismatch_validator.rb +287 -0
  38. data/lib/docscribe/version.rb +1 -1
  39. metadata +12 -3
@@ -0,0 +1,698 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../types/primitive'
4
+ require 'docscribe/types/yard/validator'
5
+
6
+ module Docscribe
7
+ module Validator
8
+ # Service object answering `generic_compatible?` without hardcoding alias names.
9
+ #
10
+ # Dynamically decides if two type strings are compatible via generics/aliases.
11
+ # Uses hash dispatch to avoid long if/else chains and to allow easy extension.
12
+ module GenericCompatibility
13
+ # Check registry: name => checker method symbol
14
+ CHECKS = {
15
+ generic_base: :generic_base_compatible?,
16
+ short_name: :short_name_compatible?,
17
+ alias_hash: :alias_hash_compatible?,
18
+ tuple_array: :tuple_array_compatible?,
19
+ optional_nil: :optional_nil_compatible?,
20
+ union_containment: :union_containment?,
21
+ optional_suffix: :optional_suffix_compatible?,
22
+ generic_inner_alias: :generic_inner_alias_compatible?,
23
+ bare_alias: :bare_alias_compatible?,
24
+ union_optional: :union_vs_optional_compatible?,
25
+ object_compatible: :object_compatible?,
26
+ fallback_union: :fallback_union_check?
27
+ }.freeze
28
+
29
+ class << self
30
+ # Whether two type strings are generic compatible (any checker true).
31
+ #
32
+ # @param [String, nil] yard_type YARD type string
33
+ # @param [String, nil] expected_type inferred/RBS type string
34
+ # @param [String] fallback_type fallback type for union checks (default "Object")
35
+ # @param [String, Symbol, nil] method_name method name for void compatibility (e.g., "initialize")
36
+ # @return [Boolean] true if any compatibility checker matches
37
+ def compatible?(yard_type, expected_type, fallback_type: 'Object', method_name: nil)
38
+ return true if void_compatible?(yard_type, expected_type, fallback_type, method_name: method_name)
39
+
40
+ return true if union_parts_compatible?(yard_type, expected_type, fallback_type, method_name)
41
+
42
+ CHECKS.any? do |name, checker|
43
+ if name == :fallback_union
44
+ send(checker, yard_type, expected_type, fallback_type)
45
+ else
46
+ send(checker, yard_type, expected_type)
47
+ end
48
+ end
49
+ end
50
+
51
+ # @param [String, nil] yard_type
52
+ # @param [String, nil] expected_type
53
+ # @param [String] fallback_type
54
+ # @param [String, Symbol, nil] _method_name
55
+ # @return [Boolean]
56
+ def union_parts_compatible?(yard_type, expected_type, fallback_type, _method_name)
57
+ yt = yard_type.to_s
58
+ et = expected_type.to_s
59
+ return false unless yt.include?(',') || et.include?(',')
60
+
61
+ parts_yard = union_parts(yt)
62
+ parts_expected = union_parts(et)
63
+
64
+ return true if single_vs_union?(parts_yard, parts_expected, fallback_type)
65
+ return true if single_vs_union?(parts_expected, parts_yard, fallback_type)
66
+
67
+ false
68
+ end
69
+
70
+ # @param [String] str
71
+ # @return [Array<String>]
72
+ def union_parts(str)
73
+ str.include?(',') ? split_top_level_commas_local(str).map { |p| normalize(p) } : [normalize(str)]
74
+ end
75
+
76
+ # @param [Array<String>] single_parts
77
+ # @param [Array<String>] union_parts
78
+ # @param [String] fallback_type
79
+ # @return [Boolean]
80
+ def single_vs_union?(single_parts, union_parts, fallback_type)
81
+ return false unless single_parts.size == 1 && union_parts.size > 1
82
+
83
+ single = single_parts.first
84
+ union_parts.any? { |part| part_compatible_with_single?(single, part, fallback_type) }
85
+ end
86
+
87
+ # @param [String] single
88
+ # @param [String] part
89
+ # @param [String] fallback_type
90
+ # @return [Boolean]
91
+ def part_compatible_with_single?(single, part, fallback_type)
92
+ CHECKS.any? do |name, checker|
93
+ next if %i[union_containment fallback_union].include?(name)
94
+
95
+ compatible = name == :fallback_union ? send(checker, single, part, fallback_type) : send(checker, single, part)
96
+ return true if compatible || single == part
97
+ end
98
+ false
99
+ end
100
+
101
+ # Whether void YARD type is compatible with expected type.
102
+ #
103
+ # Handles fallback unions and dynamic `initialize`/`setup` compatibility
104
+ # where `void` is treated as compatible with `Hash`, `self`, or `Boolean`
105
+ # for initializers per Ruby idiom.
106
+ #
107
+ # @param [String, nil] yard_type YARD type string
108
+ # @param [String, nil] expected_type inferred/RBS type string
109
+ # @param [String] fallback_type fallback type for union checks
110
+ # @param [String, Symbol, nil] method_name method name for dynamic check
111
+ # @return [Boolean] true if void compatibility holds
112
+ def void_compatible?(yard_type, expected_type, fallback_type = 'Object', method_name: nil)
113
+ return false unless normalize(yard_type) == 'void'
114
+ return true if void_fallback_or_nil?(expected_type, fallback_type)
115
+ return true if void_initialize_compatible?(expected_type, method_name)
116
+ return true if void_predicate_compatible?(expected_type, method_name)
117
+
118
+ false
119
+ end
120
+
121
+ # @param [String, nil] expected_type
122
+ # @param [String] fallback_type
123
+ # @return [Boolean]
124
+ def void_fallback_or_nil?(expected_type, fallback_type)
125
+ fallback_union?(expected_type, fallback_type) || %w[nil void].include?(normalize(expected_type))
126
+ end
127
+
128
+ # @param [String, nil] expected_type
129
+ # @param [String, Symbol, nil] method_name
130
+ # @return [Boolean]
131
+ def void_initialize_compatible?(expected_type, method_name)
132
+ return false unless method_name.to_s =~ /initialize|setup/
133
+
134
+ norm = normalize(expected_type).delete_suffix('?').strip
135
+ norm == 'Hash' || norm.start_with?('Hash<') || norm.start_with?('Hash[') || %w[self Boolean].include?(norm)
136
+ end
137
+
138
+ # @param [String, nil] expected_type
139
+ # @param [String, Symbol, nil] method_name
140
+ # @return [Boolean]
141
+ def void_predicate_compatible?(expected_type, method_name)
142
+ return false unless method_name.to_s.end_with?('?')
143
+
144
+ normalize(expected_type).delete_suffix('?').strip == 'Boolean'
145
+ end
146
+
147
+ # Whether either type is a fallback-only union for the given fallback type.
148
+ #
149
+ # Delegates to {#fallback_union?} for both yard_type and expected_type.
150
+ #
151
+ # @param [String, nil] yard_type YARD type string
152
+ # @param [String, nil] expected_type inferred/RBS type string
153
+ # @param [String] fallback_type fallback type to match (e.g., "Object")
154
+ # @return [Boolean] true if either type contains only fallback parts
155
+ def fallback_union_check?(yard_type, expected_type, fallback_type)
156
+ fallback_union?(yard_type, fallback_type) || fallback_union?(expected_type, fallback_type)
157
+ end
158
+
159
+ # Whether a type string contains only the fallback type (comma-separated, ignoring trailing `?`).
160
+ #
161
+ # @param [String, nil] type_str type string to test, may be comma-separated union
162
+ # @param [String] fallback fallback type name (e.g., "Object")
163
+ # @return [Boolean] true if every comma-separated part equals the normalized fallback
164
+ def fallback_union?(type_str, fallback)
165
+ return false if type_str.nil? || type_str.strip.empty?
166
+
167
+ fallback_norm = normalize(fallback)
168
+ parts = type_str.to_s.split(',').map { |part| normalize(part.strip.delete_suffix('?').strip) }
169
+ parts.all? { |part| part == fallback_norm || part.empty? }
170
+ end
171
+
172
+ # Whether generic base matches: Hash vs Hash<Symbol,String> or Array vs Array<String>.
173
+ #
174
+ # True when normalized types equal or one is bare base of the other's generic.
175
+ #
176
+ # @param [String, nil] yard_type YARD type string
177
+ # @param [String, nil] expected_type inferred/RBS type string
178
+ # @return [Boolean] true if generic base matches (bare vs generic or identical)
179
+ def generic_base_compatible?(yard_type, expected_type)
180
+ norm_yard = normalize(yard_type)
181
+ norm_expected = normalize(expected_type)
182
+ return true if norm_yard == norm_expected
183
+
184
+ yard_generic = norm_expected !~ /[<\[]/ && (norm_yard.start_with?("#{norm_expected}<") || norm_yard.start_with?("#{norm_expected}["))
185
+ expected_generic = norm_yard !~ /[<\[]/ && (norm_expected.start_with?("#{norm_yard}<") || norm_expected.start_with?("#{norm_yard}["))
186
+ yard_generic || expected_generic
187
+ end
188
+
189
+ # Whether short names equal: Docscribe::Config vs Config, Parser::Source::Range vs Range.
190
+ #
191
+ # Ignores generic brackets and checks namespace-elided compatibility via {#short_compatible?}.
192
+ #
193
+ # @param [String, nil] yard_type YARD type string
194
+ # @param [String, nil] expected_type inferred/RBS type string
195
+ # @return [Boolean] true if short names match with namespace variation
196
+ def short_name_compatible?(yard_type, expected_type)
197
+ norm_yard = normalize(yard_type)
198
+ norm_expected = normalize(expected_type)
199
+ return false if generic_string?(norm_yard) || generic_string?(norm_expected)
200
+
201
+ short_yard = short_name(norm_yard)
202
+ short_expected = short_name(norm_expected)
203
+ short_compatible?(short_yard, short_expected, norm_yard, norm_expected)
204
+ end
205
+
206
+ # Short name without namespace or generic args.
207
+ #
208
+ # Strips module prefix and generic suffix. E.g., "Docscribe::Config<String>" => "Config".
209
+ #
210
+ # @param [String] type_str raw type string, may be nil
211
+ # @return [String] short name (last namespace segment without < or [)
212
+ def short_name(type_str)
213
+ normalize(type_str).split('::').last.to_s.split('<').first.split('[').first.strip
214
+ end
215
+
216
+ # Whether a normalized type string contains generic brackets.
217
+ #
218
+ # @param [String] normalized normalized type string (after {#normalize})
219
+ # @return [Boolean] true if string includes "<" or "[" indicating generic
220
+ def generic_string?(normalized)
221
+ normalized.include?('<') || normalized.include?('[')
222
+ end
223
+
224
+ # Whether short names are compatible given full normalized forms.
225
+ #
226
+ # Allows Docscribe::Config vs Config and cross-checks short vs full forms.
227
+ #
228
+ # @param [String] short_yard short name derived from YARD type
229
+ # @param [String] short_expected short name derived from expected type
230
+ # @param [String] norm_yard full normalized YARD type
231
+ # @param [String] norm_expected full normalized expected type
232
+ # @return [Boolean] true if short names align via namespace elision
233
+ def short_compatible?(short_yard, short_expected, norm_yard, norm_expected)
234
+ return true if short_yard == short_expected && short_yard != norm_yard && short_expected != norm_expected
235
+ return true if short_yard == norm_expected
236
+ return true if short_expected == norm_yard
237
+
238
+ false
239
+ end
240
+
241
+ # Whether alias (lowercase after ::) vs Hash/Array/Range is compatible.
242
+ #
243
+ # Checks if one side is a namespaced alias starting with lowercase and the other is Hash, Array, or Range.
244
+ #
245
+ # @param [String, nil] yard_type YARD type string
246
+ # @param [String, nil] expected_type inferred/RBS type string
247
+ # @return [Boolean] true if alias vs Hash/Array/Range pair detected
248
+ def alias_hash_compatible?(yard_type, expected_type)
249
+ norm_yard = normalize(yard_type)
250
+ norm_expected = normalize(expected_type)
251
+ [[norm_yard, norm_expected], [norm_expected, norm_yard]].any? do |alias_type, hash_type|
252
+ alias_hash_pair?(alias_type, hash_type)
253
+ end
254
+ end
255
+
256
+ # @param [String] alias_type potential alias side
257
+ # @param [String] hash_type potential Hash/Array side
258
+ # @return [Boolean]
259
+ def alias_hash_pair?(alias_type, hash_type)
260
+ base = base_name(hash_type)
261
+ return false unless %w[Hash Array Range].include?(base) ||
262
+ hash_type.start_with?('Hash') ||
263
+ hash_type.start_with?('Array') ||
264
+ hash_type == 'Range'
265
+
266
+ short_alias = alias_type.split('::').last.to_s
267
+ return false if %w[node Node].include?(short_alias)
268
+
269
+ short_alias =~ /\A[a-z]/ && alias_type.include?('::')
270
+ end
271
+
272
+ # Base name before generic or paren.
273
+ #
274
+ # Strips "<", "[", "(" suffixes. E.g., "Hash<Symbol,String>" => "Hash".
275
+ #
276
+ # @param [String] type_str raw type string, may be nil
277
+ # @return [String] base type name without generic arguments
278
+ def base_name(type_str)
279
+ normalize(type_str).split('<').first.split('[').first.split('(').first.strip
280
+ end
281
+
282
+ # Whether tuple "(String, Integer)" vs Array is compatible.
283
+ #
284
+ # True when one side is bare "Array" and the other is parenthesized tuple.
285
+ #
286
+ # @param [String, nil] yard_type YARD type string
287
+ # @param [String, nil] expected_type inferred/RBS type string
288
+ # @return [Boolean] true if tuple vs Array pair
289
+ def tuple_array_compatible?(yard_type, expected_type)
290
+ norm_yard = normalize(yard_type)
291
+ norm_expected = normalize(expected_type)
292
+ (norm_expected == 'Array' && norm_yard =~ /\A\(.*\)\z/) || (norm_yard == 'Array' && norm_expected =~ /\A\(.*\)\z/)
293
+ end
294
+
295
+ # Whether optional vs nil pair is compatible.
296
+ #
297
+ # Delegates to {#question_nil_pair?}, {#comma_nil_pair?}, and {#node_nil_pair?}.
298
+ #
299
+ # @param [String, nil] yard_type YARD type string
300
+ # @param [String, nil] expected_type inferred/RBS type string
301
+ # @return [Boolean] true if any nil-optional pairing matches
302
+ def optional_nil_compatible?(yard_type, expected_type)
303
+ norm_yard = normalize(yard_type)
304
+ norm_expected = normalize(expected_type)
305
+ return true if question_nil_pair?(norm_yard, norm_expected)
306
+ return true if comma_nil_pair?(norm_yard, norm_expected)
307
+
308
+ node_nil_pair?(norm_yard, norm_expected)
309
+ end
310
+
311
+ # Whether one side is "nil" and the other uses trailing "?" optional syntax.
312
+ #
313
+ # E.g., "nil" vs "String?" is considered compatible.
314
+ #
315
+ # @param [String] norm_yard normalized YARD type
316
+ # @param [String] norm_expected normalized expected type
317
+ # @return [Boolean] true if nil vs "?" optional pair
318
+ def question_nil_pair?(norm_yard, norm_expected)
319
+ (norm_yard == 'nil' && norm_expected.end_with?('?')) || (norm_expected == 'nil' && norm_yard.end_with?('?'))
320
+ end
321
+
322
+ # Whether one side is "nil" and the other contains ", nil" union.
323
+ #
324
+ # E.g., "nil" vs "String, nil" is considered compatible.
325
+ #
326
+ # @param [String] norm_yard normalized YARD type
327
+ # @param [String] norm_expected normalized expected type
328
+ # @return [Boolean] true if nil vs comma-nil union pair
329
+ def comma_nil_pair?(norm_yard, norm_expected)
330
+ (norm_yard.include?(', nil') && norm_expected == 'nil') || (norm_expected.include?(', nil') && norm_yard == 'nil')
331
+ end
332
+
333
+ # Whether Parser::AST::Node vs nil is considered compatible.
334
+ #
335
+ # Special-case for AST nodes where nil represents absent node.
336
+ #
337
+ # @param [String] norm_yard normalized YARD type
338
+ # @param [String] norm_expected normalized expected type
339
+ # @return [Boolean] true if Parser::AST::Node vs nil pair
340
+ def node_nil_pair?(norm_yard, norm_expected)
341
+ (norm_yard == 'Parser::AST::Node' && norm_expected == 'nil') || (norm_expected == 'Parser::AST::Node' && norm_yard == 'nil')
342
+ end
343
+
344
+ # Whether one type is contained in the other's comma-separated union.
345
+ #
346
+ # Checks both directions after normalization (e.g., "String" in "String, Integer").
347
+ #
348
+ # @param [String, nil] yard_type YARD type string
349
+ # @param [String, nil] expected_type inferred/RBS type string
350
+ # @return [Boolean] true if one type appears in the other's union parts
351
+ def union_containment?(yard_type, expected_type)
352
+ return false if yard_type.nil? || expected_type.nil?
353
+
354
+ norm_yard = normalize(yard_type)
355
+ expected_type.split(',').any? { |part| normalize(part) == norm_yard } ||
356
+ yard_type.split(',').any? { |part| normalize(part) == normalize(expected_type) }
357
+ end
358
+
359
+ # Whether types match after stripping trailing "?".
360
+ #
361
+ # E.g., "String" vs "String?" considered compatible.
362
+ #
363
+ # @param [String, nil] yard_type YARD type string
364
+ # @param [String, nil] expected_type inferred/RBS type string
365
+ # @return [Boolean] true if types equal ignoring optional "?" suffix
366
+ def optional_suffix_compatible?(yard_type, expected_type)
367
+ normalize(yard_type).delete_suffix('?') == normalize(expected_type).delete_suffix('?')
368
+ end
369
+
370
+ # Whether Array/Hash generic inners contain alias tokens.
371
+ #
372
+ # True when both are Array/Hash generics and either inner contains an alias (lowercase or "::").
373
+ # E.g., "Array<my_alias>" vs "Array<String>".
374
+ #
375
+ # @param [String, nil] yard_type YARD type string
376
+ # @param [String, nil] expected_type inferred/RBS type string
377
+ # @return [Boolean] true if generic inners alias-compatible
378
+ def generic_inner_alias_compatible?(yard_type, expected_type)
379
+ norm_yard = normalize(yard_type)
380
+ norm_expected = normalize(expected_type)
381
+ return false unless generic_pair?(norm_yard, norm_expected)
382
+
383
+ inner_yard = extract_inner(norm_yard)
384
+ inner_expected = extract_inner(norm_expected)
385
+ return false unless inner_yard && inner_expected
386
+
387
+ inner_has_alias?(inner_yard) || inner_has_alias?(inner_expected)
388
+ end
389
+
390
+ # Whether both normalized types are Array or Hash generics.
391
+ #
392
+ # @param [String] norm_yard normalized YARD type
393
+ # @param [String] norm_expected normalized expected type
394
+ # @return [Boolean] true if both match Array/Hash generic pattern
395
+ def generic_pair?(norm_yard, norm_expected)
396
+ norm_yard =~ /\A(?:Array|Hash)[<\[]/ && norm_expected =~ /\A(?:Array|Hash)[<\[]/
397
+ end
398
+
399
+ # Extracts inner generic arguments from normalized Array/Hash type.
400
+ #
401
+ # E.g., "Array<String>" => "String", "Hash<Symbol, String>" => "Symbol, String".
402
+ #
403
+ # @param [String] normalized normalized generic type string
404
+ # @return [String, nil] inner content or nil if not generic
405
+ def extract_inner(normalized)
406
+ normalized[/\A(?:Array|Hash)[<\[](.*)[>\]]\z/, 1]
407
+ end
408
+
409
+ # Whether any comma-separated part of generic inner is an alias token.
410
+ #
411
+ # @param [String] inner inner generic string (comma-separated)
412
+ # @return [Boolean] true if any part satisfies {#alias_token?}
413
+ def inner_has_alias?(inner)
414
+ inner.split(',').any? { |part| alias_token?(part.strip) }
415
+ end
416
+
417
+ # Whether token looks like an alias (dynamic, not hardcoded Elem/U).
418
+ #
419
+ # Any token that is not a known Ruby/YARD primitive is considered alias.
420
+ # E.g., `Elem`, `U`, `my_alias`, `Foo::Bar`, `change` vs `String`, `Integer`, `Hash`.
421
+ #
422
+ # @param [String] token single type token (trimmed inner part, may include ?)
423
+ # @return [Boolean] true if token is alias (unknown primitive or namespaced/lowercase)
424
+ def alias_token?(token) # rubocop:disable SortedMethodsByCall/Waterfall
425
+ base = token.split('<').first.split('[').first.strip.delete_suffix('?').strip
426
+ return false if Docscribe::Types::Primitive.primitive?(base)
427
+ return true if base =~ /\A[a-z]/ || base.include?('::')
428
+ return true if base =~ /\A[A-Z]\z/
429
+
430
+ !!(base =~ /\A[A-Z][A-Za-z0-9_]*\z/)
431
+ end
432
+
433
+ # Whether one side is a bare alias (e.g., V, U, T, Elem) vs concrete type.
434
+ #
435
+ # Bare alias means single token without generic brackets or commas that
436
+ # satisfies {#alias_token?}. E.g., "V" vs "Array<String>" => true via alias.
437
+ # Handles single capital letter A-Z explicitly for RBS type params.
438
+ #
439
+ # @param [String, nil] yard_type YARD type string
440
+ # @param [String, nil] expected_type inferred/RBS type string
441
+ # @return [Boolean] true if either side is bare alias
442
+ def bare_alias_compatible?(yard_type, expected_type)
443
+ norm_yard = normalize(yard_type)
444
+ norm_expected = normalize(expected_type)
445
+ return false if norm_yard.empty? || norm_expected.empty?
446
+
447
+ bare_single_cap?(norm_yard) || bare_single_cap?(norm_expected)
448
+ end
449
+
450
+ # Whether normalized string is a bare single-capital alias (V, U, T, K, Elem? no, single A-Z only).
451
+ #
452
+ # Single capital letters are RBS type parameters (generic placeholders) that should
453
+ # be compatible with concrete types like Array<String> or String. Multi-letter aliases
454
+ # like Config are not considered bare aliases (handled via other checks).
455
+ #
456
+ # @param [String] normalized normalized type string
457
+ # @return [Boolean] true if bare single capital
458
+ def bare_single_cap?(normalized)
459
+ return false if normalized.include?('<') || normalized.include?('[') || normalized.include?(',')
460
+
461
+ stripped = normalized.strip.delete_suffix('?').strip
462
+ stripped =~ /\A[A-Z]\z/ && alias_token?(stripped)
463
+ end
464
+
465
+ # Whether normalized string is a bare alias token (no generics, no union).
466
+ #
467
+ # @param [String] normalized normalized type string
468
+ # @return [Boolean] true if bare alias
469
+ def bare_alias?(normalized)
470
+ return false if normalized.include?('<') || normalized.include?('[') || normalized.include?(',')
471
+
472
+ alias_token?(normalized.strip)
473
+ end
474
+
475
+ # Whether union `?, nil` forms are compatible: `Object?` vs `Object, nil` vs `Object|nil`.
476
+ #
477
+ # Handles all three forms `SomeType? == SomeType|nil == SomeType, nil` plus
478
+ # bare `SomeType` vs optional equivalence via pipe/comma normalization.
479
+ #
480
+ # @param [String, nil] yard_type YARD type string
481
+ # @param [String, nil] expected_type inferred/RBS type string
482
+ # @return [Boolean] true if one is `Type?` and other is `Type, nil` / `Type|nil`
483
+ def union_vs_optional_compatible?(yard_type, expected_type)
484
+ norm_yard = normalize(yard_type).gsub('|', ',')
485
+ norm_expected = normalize(expected_type).gsub('|', ',')
486
+ return true if question_vs_comma_nil?(norm_yard, norm_expected)
487
+ return true if suffix_vs_union_first?(yard_type, expected_type)
488
+ return true if pipe_aware_suffix_vs_union_first?(yard_type, expected_type)
489
+
490
+ optional_forms_equal?(yard_type, expected_type)
491
+ end
492
+
493
+ # Pipe-aware variant of {#suffix_vs_union_first?} normalizing `|` to `,`.
494
+ #
495
+ # @param [String, nil] yard_type YARD type string
496
+ # @param [String, nil] expected_type inferred/RBS type string
497
+ # @return [Boolean] true if suffix vs union matches after pipe normalization
498
+ def pipe_aware_suffix_vs_union_first?(yard_type, expected_type)
499
+ # Normalize both by converting pipe to comma before comparison
500
+ y = yard_type.to_s.gsub('|', ',')
501
+ e = expected_type.to_s.gsub('|', ',')
502
+ left_match = normalize(y).delete_suffix('?') ==
503
+ normalize(split_top_level_commas_local(e).first || '')
504
+ right_match = normalize(e).delete_suffix('?') ==
505
+ normalize(split_top_level_commas_local(y).first || '')
506
+ left_match || right_match
507
+ end
508
+
509
+ # Whether optional forms `T?`, `T, nil`, `T|nil` are equivalent (same canonical parts).
510
+ #
511
+ # Canonicalizes `T?` => `[T, nil]`, `T, nil` / `T|nil` => `[T, nil]`, `T` => `[T]`.
512
+ # Generic-aware split keeps `Hash<String, Integer>, nil` intact.
513
+ #
514
+ # @param [String, nil] first first type string
515
+ # @param [String, nil] second second type string
516
+ # @return [Boolean] true if canonical optional parts equal and non-empty
517
+ def optional_forms_equal?(first, second)
518
+ pa = optional_canonical_parts(first)
519
+ pb = optional_canonical_parts(second)
520
+ !pa.empty? && pa == pb
521
+ end
522
+
523
+ # Whether Object supertype compatibility holds: e.g., String vs Object, String, nil vs Object, nil.
524
+ #
525
+ # If expected is Object (or Object, nil, Object? etc) and yard is a concrete type
526
+ # (String, Array<String>, etc) with same nil presence, then yard is considered
527
+ # compatible with expected since Object is supertype of all. Handles
528
+ # receiver_or_and_type: String, nil vs Object, nil dynamically.
529
+ #
530
+ # @param [String, nil] yard_type YARD type string
531
+ # @param [String, nil] expected_type inferred/RBS type string
532
+ # @return [Boolean] true if Object supertype compatibility holds
533
+ def object_compatible?(yard_type, expected_type)
534
+ yard_canonical = optional_canonical_parts(yard_type)
535
+ exp_canonical = optional_canonical_parts(expected_type)
536
+ return false if yard_canonical.empty? || exp_canonical.empty?
537
+
538
+ yard_without_nil = canonical_without_nil(yard_canonical)
539
+ exp_without_nil = canonical_without_nil(exp_canonical)
540
+ return false unless object_supertype_pair?(yard_without_nil, exp_without_nil)
541
+
542
+ yard_canonical.include?('nil') == exp_canonical.include?('nil')
543
+ end
544
+
545
+ # @param [Array<String>] yard_without_nil
546
+ # @param [Array<String>] exp_without_nil
547
+ # @return [Boolean]
548
+ def object_supertype_pair?(yard_without_nil, exp_without_nil)
549
+ (exp_without_nil == ['Object'] && yard_without_nil != ['Object'] && !yard_without_nil.empty?) ||
550
+ (yard_without_nil == ['Object'] && exp_without_nil != ['Object'] && !exp_without_nil.empty?)
551
+ end
552
+
553
+ # @param [Array<String>] canonical
554
+ # @return [Array<String>]
555
+ def canonical_without_nil(canonical)
556
+ canonical.reject { |p| p == 'nil' }
557
+ end
558
+
559
+ # Canonical optional parts for `T?` / `T, nil` / `T|nil` / `T` forms.
560
+ #
561
+ # @param [String, nil] str raw type string
562
+ # @return [Array<String>] sorted unique normalized parts
563
+ def optional_canonical_parts(str)
564
+ return [] if str.nil? || empty_str?(str)
565
+
566
+ s = normalized_union_str(str)
567
+ return single_optional_parts(s) if single_optional_form?(s)
568
+
569
+ canonicalize_parts(split_top_level_commas_local(s))
570
+ end
571
+
572
+ # @param [String, nil] str
573
+ # @return [Boolean]
574
+ def empty_str?(str)
575
+ str.to_s.strip.empty?
576
+ end
577
+
578
+ # @param [String, nil] str
579
+ # @return [String]
580
+ def normalized_union_str(str)
581
+ str.to_s.gsub('|', ',').strip
582
+ end
583
+
584
+ # @param [String] str
585
+ # @return [Boolean]
586
+ def single_optional_form?(str)
587
+ str.end_with?('?') && !str.include?(',')
588
+ end
589
+
590
+ # @param [Array<String>] parts
591
+ # @return [Array<String>]
592
+ def canonicalize_parts(parts)
593
+ expand_optional_parts(parts).map { |part| normalize(part) }.reject(&:empty?).uniq.sort
594
+ end
595
+
596
+ # @param [String] str
597
+ # @return [Array<String>]
598
+ def single_optional_parts(str)
599
+ base = normalize(str.delete_suffix('?').strip)
600
+ [base, 'nil'].sort
601
+ end
602
+
603
+ # @param [Array<String>] parts
604
+ # @return [Array<String>]
605
+ def expand_optional_parts(parts)
606
+ parts.flat_map do |part|
607
+ stripped = part.strip
608
+ if stripped.end_with?('?')
609
+ [normalize(stripped.delete_suffix('?').strip), 'nil']
610
+ else
611
+ [normalize(stripped)]
612
+ end
613
+ end
614
+ end
615
+
616
+ # Split by top-level commas outside `< > [ ] ( )` (generic-aware).
617
+ #
618
+ # Mirrors `Docscribe::Infer::Returns.split_top_level_commas` but local to avoid cross-dep.
619
+ #
620
+ # @param [String] str type string to split
621
+ # @return [Array<String>] parts split on top-level commas
622
+ def split_top_level_commas_local(str)
623
+ state = { parts: [], cur: +'', da: 0, db: 0, dp: 0 } #: Hash[Symbol, untyped]
624
+ str.each_char { |chr| handle_split_char(chr, state) }
625
+ state[:parts] << state[:cur] unless state[:cur].empty?
626
+ state[:parts]
627
+ end
628
+
629
+ # @param [String] chr
630
+ # @param [Hash<Symbol, Object>] state
631
+ # @return [void]
632
+ def handle_split_char(chr, state)
633
+ case chr
634
+ when '<', '>', '[', ']', '(', ')'
635
+ update_split_depth(chr, state)
636
+ when ','
637
+ handle_split_comma(state)
638
+ else
639
+ state[:cur] << chr
640
+ end
641
+ end
642
+
643
+ # @param [String] chr
644
+ # @param [Hash<Symbol, Object>] state
645
+ # @return [void]
646
+ def update_split_depth(chr, state)
647
+ deltas = { '<' => [:da, 1], '>' => [:da, -1], '[' => [:db, 1], ']' => [:db, -1], '(' => [:dp, 1], ')' => [:dp, -1] }
648
+ key, delta = deltas[chr]
649
+ state[key] += delta if key
650
+ state[:cur] << chr
651
+ end
652
+
653
+ # @param [Hash<Symbol, Object>] state
654
+ # @return [void]
655
+ def handle_split_comma(state)
656
+ if state[:da].zero? && state[:db].zero? && state[:dp].zero?
657
+ state[:parts] << state[:cur]
658
+ state[:cur] = +''
659
+ else
660
+ state[:cur] << ','
661
+ end
662
+ end
663
+
664
+ # Method documentation.
665
+ #
666
+ # @param [String] norm_yard Param documentation.
667
+ # @param [String] norm_expected Param documentation.
668
+ # @return [Boolean]
669
+ def question_vs_comma_nil?(norm_yard, norm_expected)
670
+ norm_yard.delete(' ') == "#{norm_expected.delete(' ').delete_suffix('?')},nil" ||
671
+ norm_expected.delete(' ') == "#{norm_yard.delete(' ').delete_suffix('?')},nil"
672
+ end
673
+
674
+ # Method documentation.
675
+ #
676
+ # @param [String, nil] yard_type Param documentation.
677
+ # @param [String, nil] expected_type Param documentation.
678
+ # @return [Boolean]
679
+ def suffix_vs_union_first?(yard_type, expected_type)
680
+ normalize(yard_type).delete_suffix('?') == normalize(expected_type).split(',').first&.strip ||
681
+ normalize(expected_type).delete_suffix('?') == normalize(yard_type).split(',').first&.strip
682
+ end
683
+
684
+ # Normalizes type string for comparison.
685
+ #
686
+ # Strips, squeezes spaces, converts "["/"]" to "<"/">", replaces "untyped"/"FALLBACK_TYPE" with "Object".
687
+ #
688
+ # @param [String, nil] type_str raw type string, may be nil
689
+ # @return [String] normalized type string
690
+ def normalize(type_str)
691
+ s = type_str.to_s
692
+ s = s.sub(/#.*\z/m, '').strip unless s.lstrip.start_with?('#')
693
+ s.strip.squeeze(' ').gsub('[', '<').gsub(']', '>').gsub(/\buntyped\b/, 'Object').gsub(/\bFALLBACK_TYPE\b/, 'Object')
694
+ end
695
+ end
696
+ end
697
+ end
698
+ end