docscribe 1.6.1 → 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 (38) 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 +46 -15
  27. data/lib/docscribe/server/client.rb +20 -11
  28. data/lib/docscribe/server/daemon.rb +200 -23
  29. data/lib/docscribe/server/protocol.rb +4 -4
  30. data/lib/docscribe/types/primitive.rb +160 -0
  31. data/lib/docscribe/types/sorbet/base_provider.rb +33 -1
  32. data/lib/docscribe/types/yard/formatter.rb +35 -6
  33. data/lib/docscribe/types/yard/parser.rb +25 -20
  34. data/lib/docscribe/types/yard/validator.rb +131 -0
  35. data/lib/docscribe/validator/generic_compatibility.rb +698 -0
  36. data/lib/docscribe/validator/type_mismatch_validator.rb +287 -0
  37. data/lib/docscribe/version.rb +1 -1
  38. metadata +8 -3
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NOTE: no top-level `require 'rbs'` here on purpose — the rbs gem is
4
+ # optional (excluded on old rubies via BUNDLE_WITHOUT). It is required
5
+ # lazily in `.load_rbs_core`, which falls back to a hardcoded list when
6
+ # the gem is unavailable.
7
+ #
8
+ # `set` is required eagerly: it is a default gem on all supported rubies
9
+ # and `load_core_primitives` cannot run without it.
10
+ require 'set'
11
+
12
+ module Docscribe
13
+ module Types
14
+ # Dynamic primitive type detection via RBS core + YARD.
15
+ #
16
+ # Replaces hardcoded %w[String Integer ...] lists in
17
+ # Returns and GenericCompatibility with RBS environment inspection.
18
+ module Primitive
19
+ module_function
20
+
21
+ # Whether token matches alias pattern (lowercase after :: or capitalized not in primitives)
22
+ #
23
+ # @note module_function: defines #alias_pattern? (visibility: private)
24
+ # @param [String] token
25
+ # @return [Boolean]
26
+ def alias_pattern?(token)
27
+ base = normalized_base(token)
28
+ return true if base =~ /\A[a-z]/ || base.include?('::')
29
+ return true if base =~ /\A[A-Z]\z/
30
+
31
+ !!(base =~ /\A[A-Z][A-Za-z0-9_]*\z/ && !core_primitives.include?(base))
32
+ end
33
+
34
+ # Whether a type string is an alias (contains alias token inside generic)
35
+ #
36
+ # @note module_function: defines #alias_type? (visibility: private)
37
+ # @param [String] type_str
38
+ # @return [Boolean]
39
+ def alias_type?(type_str)
40
+ # Check if any comma-separated inner is alias
41
+ # For "Array<Elem>" or "MyAlias" etc
42
+ type_str.split(',').any? { |part| alias_token?(part.strip) }
43
+ end
44
+
45
+ # Whether token is an alias/generic placeholder (Elem, U, ParamTag, MyAlias)
46
+ # vs primitive. Inverse of primitive?.
47
+ #
48
+ # @note module_function: defines #alias_token? (visibility: private)
49
+ # @param [String] token single type token
50
+ # @return [Boolean] true if alias
51
+ def alias_token?(token)
52
+ base = normalized_base(token)
53
+ return false if primitive?(base)
54
+ return true if base =~ /\A[a-z]/ || base.include?('::')
55
+ return true if base =~ /\A[A-Z]\z/
56
+
57
+ !!(base =~ /\A[A-Z][A-Za-z0-9_]*\z/ && !core_primitives.include?(base))
58
+ end
59
+
60
+ # Whether token is a primitive type (String, Integer, etc) vs alias (Elem, U, ParamTag).
61
+ #
62
+ # Uses RBS core class declarations + YARD pseudo types (Boolean, void, untyped)
63
+ # to avoid hardcoding the full list. Falls back to a minimal list if RBS unavailable.
64
+ #
65
+ # @note module_function: defines #primitive? (visibility: private)
66
+ # @param [String] token type token (e.g., "String", "Elem", "ParamTag", "untyped")
67
+ # @return [Boolean] true if primitive, false if alias/generic placeholder
68
+ def primitive?(token)
69
+ base = normalized_base(token)
70
+ return false if base.empty?
71
+ return true if %w[untyped void nil].include?(base)
72
+ # YARD pseudo types that are not real RBS classes but considered primitives
73
+ return true if %w[Boolean void untyped nil].include?(base)
74
+
75
+ core_primitives.include?(base)
76
+ end
77
+
78
+ # @note module_function: defines #normalized_base (visibility: private)
79
+ # @param [String] token
80
+ # @return [String]
81
+ def normalized_base(token)
82
+ token.split('<').first.split('[').first.strip.delete_suffix('?').strip
83
+ end
84
+
85
+ # All core primitive class/module names from RBS environment (String, Array, etc)
86
+ # plus YARD primitives. Computed once and cached.
87
+ #
88
+ # @note module_function: defines #core_primitives (visibility: private)
89
+ # @return [Array<String>] primitive names
90
+ def core_primitives
91
+ @core_primitives ||= load_core_primitives
92
+ end
93
+
94
+ # Load core primitives from RBS environment. Falls back to minimal hardcoded list
95
+ # if RBS not available (e.g., in test env without rbs gem).
96
+ #
97
+ # @note module_function: defines #load_core_primitives (visibility: private)
98
+ # @return [Array<String>]
99
+ def load_core_primitives
100
+ primitives = Set.new
101
+ load_yard_primitives(primitives)
102
+ load_rbs_core(primitives)
103
+ merge_primitives(primitives)
104
+ primitives.to_a
105
+ end
106
+
107
+ # @note module_function: defines #load_yard_primitives (visibility: private)
108
+ # @param [Set<String>] primitives
109
+ # @return [Set<String>]
110
+ def load_yard_primitives(primitives)
111
+ primitives.merge(%w[Boolean void untyped nil true false])
112
+ end
113
+
114
+ # @note module_function: defines #load_rbs_core (visibility: private)
115
+ # @param [Set<String>] primitives
116
+ # @raise [LoadError]
117
+ # @raise [StandardError]
118
+ # @return [Set<String>]
119
+ # @return [Set<String>] if LoadError, StandardError
120
+ def load_rbs_core(primitives)
121
+ require 'rbs'
122
+ loader = RBS::EnvironmentLoader.new
123
+ env = RBS::Environment.new
124
+ loader.load(env: env)
125
+ populate_class_decls(env, primitives)
126
+ populate_interface_decls(env, primitives)
127
+ rescue LoadError, StandardError
128
+ primitives.merge(%w[String Integer Float Numeric Symbol Array Hash Range Regexp Proc Method NilClass TrueClass FalseClass BasicObject Kernel Object Class Module IO File Dir Time Date Enumerator
129
+ Set Enumerable])
130
+ end
131
+
132
+ # @note module_function: defines #populate_class_decls (visibility: private)
133
+ # @param [RBS::Environment] env
134
+ # @param [Set<String>] primitives
135
+ # @return [void]
136
+ def populate_class_decls(env, primitives)
137
+ return unless env.respond_to?(:class_decls)
138
+
139
+ env.class_decls.each_key { |k| primitives.merge([k.to_s.split('::').last, k.to_s]) }
140
+ end
141
+
142
+ # @note module_function: defines #populate_interface_decls (visibility: private)
143
+ # @param [RBS::Environment] env
144
+ # @param [Set<String>] primitives
145
+ # @return [void]
146
+ def populate_interface_decls(env, primitives)
147
+ return unless env.respond_to?(:interface_decls)
148
+
149
+ env.interface_decls.each_key { |k| primitives << k.to_s.split('::').last }
150
+ end
151
+
152
+ # @note module_function: defines #merge_primitives (visibility: private)
153
+ # @param [Set<String>] primitives
154
+ # @return [Set<String>]
155
+ def merge_primitives(primitives)
156
+ primitives.merge(%w[String Integer Float Numeric Symbol Array Hash Range Regexp Proc Method NilClass TrueClass FalseClass BasicObject Kernel Object])
157
+ end
158
+ end
159
+ end
160
+ end
@@ -59,7 +59,7 @@ module Docscribe
59
59
  return unless defined?(RubyVM::AbstractSyntaxTree)
60
60
 
61
61
  parser = ::RBS::Prototype::RBI.new
62
- parser.parse(source)
62
+ parse(parser, source)
63
63
  index_decls(parser.decls)
64
64
  rescue LoadError
65
65
  nil
@@ -68,6 +68,38 @@ module Docscribe
68
68
  nil
69
69
  end
70
70
 
71
+ # Parse the source with the RBS RBI prototype, suppressing the parser's
72
+ # "Unexpected type_node" STDERR noise unless DOCSCRIBE_RBS_DEBUG=1.
73
+ #
74
+ # @private
75
+ # @param [::RBS::Prototype::RBI] parser RBI parser instance
76
+ # @param [String] source source text to parse
77
+ # @return [void]
78
+ def parse(parser, source)
79
+ if ENV['DOCSCRIBE_RBS_DEBUG'] == '1'
80
+ parser.parse(source)
81
+ else
82
+ suppress_rbs_noise { parser.parse(source) }
83
+ end
84
+ end
85
+
86
+ # Run the block with STDERR discarded. RBS's RBI prototype prints
87
+ # "Unexpected type_node" directly to STDERR for constructs it doesn't
88
+ # model (e.g. Herb-typed RBIs); the fallback to `Any` is already handled
89
+ # by the parser, so the noise is safe to drop. `IO#reopen` needs a real
90
+ # IO or path, so redirect to File::NULL rather than a StringIO.
91
+ #
92
+ # @private
93
+ # @return [T] the block's return value
94
+ def suppress_rbs_noise
95
+ original_stderr = $stderr.dup
96
+ $stderr.reopen(File::NULL, 'w')
97
+ yield
98
+ ensure
99
+ $stderr.reopen(original_stderr)
100
+ original_stderr.close
101
+ end
102
+
71
103
  # Index parsed declarations into the provider lookup table.
72
104
  #
73
105
  # @private
@@ -10,24 +10,53 @@ module Docscribe
10
10
  class << self
11
11
  # @param [Docscribe::Types::Yard::node?] node
12
12
  # @return [String]
13
- def to_rbs(node) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
13
+ def to_rbs(node)
14
14
  return 'untyped' if node.nil?
15
15
 
16
+ rbs_for_node(node) || 'untyped'
17
+ end
18
+
19
+ private
20
+
21
+ # @private
22
+ # @param [Docscribe::Types::Yard::node] node
23
+ # @return [String, nil]
24
+ def rbs_for_node(node)
25
+ simple_type(node) || composite_type(node) || collection_type(node)
26
+ end
27
+
28
+ # @private
29
+ # @param [Docscribe::Types::Yard::node] node
30
+ # @return [String, nil]
31
+ def simple_type(node)
16
32
  case node
17
33
  when Named then format_named(node)
18
- when Generic then format_generic(node)
34
+ when Literal then format_literal(node)
35
+ end
36
+ end
37
+
38
+ # @private
39
+ # @param [Docscribe::Types::Yard::node] node
40
+ # @return [String, nil]
41
+ def composite_type(node)
42
+ case node
19
43
  when Union then format_union(node)
20
44
  when Intersection then format_intersection(node)
21
45
  when Optional then format_optional(node)
46
+ end
47
+ end
48
+
49
+ # @private
50
+ # @param [Docscribe::Types::Yard::node] node
51
+ # @return [String, nil]
52
+ def collection_type(node)
53
+ case node
54
+ when Generic then format_generic(node)
22
55
  when Tuple then format_tuple(node)
23
56
  when HashMap then format_hash_map(node)
24
- when Literal then format_literal(node)
25
- else 'untyped'
26
57
  end
27
58
  end
28
59
 
29
- private
30
-
31
60
  # @private
32
61
  # @param [Docscribe::Types::Yard::Named] node
33
62
  # @return [String]
@@ -40,7 +40,7 @@ module Docscribe
40
40
  def parse_union
41
41
  types = [parse_intersection]
42
42
  skip_space
43
- while @i < @s.length && @s[@i] == ','
43
+ while peek == ','
44
44
  @i += 1
45
45
  skip_space
46
46
  types << parse_intersection
@@ -54,7 +54,7 @@ module Docscribe
54
54
  def parse_intersection
55
55
  types = [parse_optional]
56
56
  skip_space
57
- while @i < @s.length && @s[@i] == '&'
57
+ while peek == '&'
58
58
  @i += 1
59
59
  skip_space
60
60
  types << parse_optional
@@ -68,7 +68,7 @@ module Docscribe
68
68
  def parse_optional
69
69
  type = parse_primary
70
70
  skip_space
71
- if @i < @s.length && @s[@i] == '?'
71
+ if peek == '?'
72
72
  @i += 1
73
73
  Optional.new(type: type)
74
74
  else
@@ -96,9 +96,9 @@ module Docscribe
96
96
  return Literal.new(value: name) if literal?(name)
97
97
 
98
98
  skip_space
99
- if @i < @s.length && @s[@i] == '<'
99
+ if peek == '<'
100
100
  parse_generic(Named.new(name: name))
101
- elsif @i < @s.length && @s[@i] == '{'
101
+ elsif peek == '{'
102
102
  parse_named_hash_map
103
103
  else
104
104
  Named.new(name: name)
@@ -111,7 +111,7 @@ module Docscribe
111
111
  def parse_generic(base)
112
112
  @i += 1
113
113
  args = parse_generic_args
114
- @i += 1 if @i < @s.length && @s[@i] == '>'
114
+ @i += 1 if peek == '>'
115
115
  Generic.new(base: base.name, args: args)
116
116
  end
117
117
 
@@ -120,7 +120,7 @@ module Docscribe
120
120
  def parse_generic_arg
121
121
  types = [parse_intersection]
122
122
  skip_space
123
- while @i < @s.length && @s[@i] == '|'
123
+ while peek == '|'
124
124
  @i += 1
125
125
  skip_space
126
126
  types << parse_intersection
@@ -134,10 +134,10 @@ module Docscribe
134
134
  def parse_generic_args
135
135
  args = [] #: Array[untyped]
136
136
  skip_space
137
- while @i < @s.length && @s[@i] != '>'
137
+ while peek && peek != '>'
138
138
  args << parse_generic_arg
139
139
  skip_space
140
- next unless @i < @s.length && @s[@i] == ','
140
+ next unless peek == ','
141
141
 
142
142
  @i += 1
143
143
  skip_space
@@ -150,11 +150,11 @@ module Docscribe
150
150
  def parse_tuple
151
151
  @i += 1
152
152
  types = [] #: Array[untyped]
153
- while @i < @s.length && @s[@i] != ')'
153
+ while peek && peek != ')'
154
154
  types << parse_tuple_element
155
- @i += 1 and skip_space if @s[@i] == ','
155
+ @i += 1 and skip_space if peek == ','
156
156
  end
157
- @i += 1 if @s[@i] == ')'
157
+ @i += 1 if peek == ')'
158
158
  Tuple.new(types: types)
159
159
  end
160
160
 
@@ -163,7 +163,7 @@ module Docscribe
163
163
  def parse_tuple_element
164
164
  type = parse_intersection
165
165
  skip_space
166
- if @i < @s.length && @s[@i] == '?'
166
+ if peek == '?'
167
167
  @i += 1
168
168
  Optional.new(type: type)
169
169
  else
@@ -176,9 +176,9 @@ module Docscribe
176
176
  def parse_hash_map
177
177
  @i += 1
178
178
  key = parse_union
179
- @i += 2 if @s[@i..(@i + 1)] == '=>'
179
+ @i += 2 if @s[@i, 2] == '=>'
180
180
  value = parse_union
181
- @i += 1 if @s[@i] == '}'
181
+ @i += 1 if peek == '}'
182
182
  HashMap.new(key_type: key, value_type: value)
183
183
  end
184
184
 
@@ -192,7 +192,7 @@ module Docscribe
192
192
  # @return [Docscribe::Types::Yard::Duck]
193
193
  def parse_duck_type
194
194
  methods = [] #: Array[String]
195
- while @i < @s.length && @s[@i] == '#'
195
+ while peek == '#'
196
196
  @i += 1
197
197
  name = scan_name
198
198
  methods << name
@@ -205,7 +205,12 @@ module Docscribe
205
205
  # @return [String]
206
206
  def scan_name
207
207
  start = @i
208
- @i += 1 while @i < @s.length && name_char?(@s[@i])
208
+ loop do
209
+ c = peek
210
+ break unless c && name_char?(c)
211
+
212
+ @i += 1
213
+ end
209
214
  @s[start...@i]
210
215
  end
211
216
 
@@ -226,13 +231,13 @@ module Docscribe
226
231
  # @private
227
232
  # @return [void]
228
233
  def skip_space
229
- @i += 1 while @i < @s.length && @s[@i].match?(/\s/)
234
+ @i += 1 while peek&.match?(/\s/)
230
235
  end
231
236
 
232
237
  # @private
233
238
  # @return [String?]
234
- def peek
235
- @i < @s.length ? @s[@i] : nil
239
+ def peek #: String?
240
+ @s[@i]
236
241
  end
237
242
  end
238
243
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'parser'
4
+
5
+ module Docscribe
6
+ module Types
7
+ module Yard
8
+ # Validates YARD type strings for syntax errors.
9
+ #
10
+ # This validator is intentionally lightweight and does not perform
11
+ # semantic type-existence checks (e.g. `Symbol2l` vs `Symbol`).
12
+ # Semantic checks are handled by `Docscribe::Validator::TypeMismatchValidator`
13
+ # which compares a YARD tag against the inferred / RBS type.
14
+ module Validator
15
+ module_function
16
+
17
+ # Check if a YARD type string is syntactically valid.
18
+ #
19
+ # Criteria:
20
+ # - non-empty after strip
21
+ # - brackets are balanced (`<>`, `()`, `{}`, `[]`)
22
+ # - no `,,`, `<>`, `,]` artefacts
23
+ # - `Yard::Parser` consumes the entire string (catches `Sym bol` leftover)
24
+ #
25
+ # @note module_function: defines #valid? (visibility: private)
26
+ # @param [String, nil] type_str the YARD type string to validate (e.g. "String", "Array<String>", "Sym bol")
27
+ # @raise [StandardError]
28
+ # @return [Boolean]
29
+ # @return [Boolean] if StandardError
30
+ def valid?(type_str)
31
+ syntax_valid?(type_str)
32
+ rescue StandardError
33
+ false
34
+ end
35
+
36
+ # Strict syntax validation with balanced brackets and full consumption.
37
+ #
38
+ # @note module_function: defines #syntax_valid? (visibility: private)
39
+ # @param [String, nil] type_str
40
+ # @raise [StandardError]
41
+ # @return [Boolean]
42
+ # @return [Boolean] if StandardError
43
+ def syntax_valid?(type_str)
44
+ return false if blank_type?(type_str)
45
+ return false unless balanced_brackets?(type_str)
46
+ return false if artefact_type?(type_str)
47
+
48
+ fully_consumed?(type_str)
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # @note module_function: defines #blank_type? (visibility: private)
54
+ # @param [String, nil] type_str
55
+ # @return [Boolean]
56
+ def blank_type?(type_str)
57
+ type_str.nil? || type_str.strip.empty?
58
+ end
59
+
60
+ # @note module_function: defines #artefact_type? (visibility: private)
61
+ # @param [String, nil] type_str
62
+ # @return [Boolean]
63
+ def artefact_type?(type_str)
64
+ return false if type_str.nil?
65
+
66
+ type_str.include?(',,') || type_str.include?('<>') || type_str.include?(',]')
67
+ end
68
+
69
+ # Check balanced brackets for `<>`, `()`, `{}`, `[]`.
70
+ #
71
+ # @note module_function: defines #balanced_brackets? (visibility: private)
72
+ # @param [String, nil] type_str
73
+ # @return [Boolean]
74
+ def balanced_brackets?(type_str)
75
+ return false if type_str.nil?
76
+
77
+ stack = [] #: Array[String]
78
+ pairs = { '>' => '<', ')' => '(', '}' => '{', ']' => '[' }
79
+ opens = pairs.values
80
+
81
+ type_str.each_char do |ch|
82
+ return false unless process_bracket_char?(ch, stack, pairs, opens)
83
+ end
84
+
85
+ stack.empty?
86
+ end
87
+
88
+ # @note module_function: defines #process_bracket_char? (visibility: private)
89
+ # @param [String] char
90
+ # @param [Array<String>] stack
91
+ # @param [Hash<String, String>] pairs
92
+ # @param [Array<String>] opens
93
+ # @return [Boolean]
94
+ def process_bracket_char?(char, stack, pairs, opens)
95
+ if opens.include?(char)
96
+ stack << char
97
+ elsif pairs.key?(char)
98
+ return false if stack.empty? || stack.pop != pairs[char]
99
+ end
100
+ true
101
+ end
102
+
103
+ # Whether `Yard::Parser` consumes the entire string.
104
+ #
105
+ # Catches cases like `Sym bol` where parser would return `Sym` and
106
+ # leave ` bol` unconsumed.
107
+ #
108
+ # @note module_function: defines #fully_consumed? (visibility: private)
109
+ # @param [String, nil] type_str
110
+ # @raise [StandardError]
111
+ # @return [Boolean]
112
+ # @return [Boolean] if StandardError
113
+ def fully_consumed?(type_str)
114
+ return false if type_str.nil?
115
+
116
+ stripped = type_str.strip
117
+ parser = Parser.new(stripped)
118
+ node = parser.parse
119
+ return false unless node
120
+
121
+ idx = parser.instance_variable_get(:@i)
122
+ # `Parser#parse` calls `skip_space` before and after `parse_union`,
123
+ # so `idx` should be at the end if fully consumed.
124
+ idx == stripped.length
125
+ rescue StandardError
126
+ false
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end