yard-lint 1.11.0 → 1.13.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -498
  3. data/README.md +103 -1
  4. data/bin/yard-lint +100 -40
  5. data/lib/yard/lint/config.rb +81 -5
  6. data/lib/yard/lint/config_validator.rb +7 -191
  7. data/lib/yard/lint/contractable/contract.rb +219 -0
  8. data/lib/yard/lint/contractable/result.rb +93 -0
  9. data/lib/yard/lint/contractable/rule.rb +10 -0
  10. data/lib/yard/lint/contracts/base.rb +57 -0
  11. data/lib/yard/lint/contracts/category.rb +71 -0
  12. data/lib/yard/lint/contracts/config.rb +81 -0
  13. data/lib/yard/lint/contracts/validator.rb +72 -0
  14. data/lib/yard/lint/errors.rb +3 -0
  15. data/lib/yard/lint/executor/in_process_registry.rb +1 -1
  16. data/lib/yard/lint/explainer.rb +205 -0
  17. data/lib/yard/lint/formatters/sarif.rb +218 -0
  18. data/lib/yard/lint/git.rb +5 -3
  19. data/lib/yard/lint/result_builder.rb +7 -6
  20. data/lib/yard/lint/runner.rb +2 -21
  21. data/lib/yard/lint/templates/default_config.yml +10 -0
  22. data/lib/yard/lint/templates/strict_config.yml +10 -0
  23. data/lib/yard/lint/todo_generator.rb +143 -12
  24. data/lib/yard/lint/validators/base.rb +63 -0
  25. data/lib/yard/lint/validators/documentation/reference_typo/config.rb +20 -0
  26. data/lib/yard/lint/validators/documentation/reference_typo/messages_builder.rb +24 -0
  27. data/lib/yard/lint/validators/documentation/reference_typo/parser.rb +44 -0
  28. data/lib/yard/lint/validators/documentation/reference_typo/result.rb +27 -0
  29. data/lib/yard/lint/validators/documentation/reference_typo/validator.rb +214 -0
  30. data/lib/yard/lint/validators/documentation/reference_typo.rb +39 -0
  31. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/parser.rb +13 -12
  32. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/validator.rb +84 -5
  33. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb +16 -3
  34. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +3 -2
  35. data/lib/yard/lint/validators/tags/example_style/validator.rb +4 -4
  36. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +4 -2
  37. data/lib/yard/lint/validators/tags/invalid_types/messages_builder.rb +15 -4
  38. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +18 -7
  39. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +22 -12
  40. data/lib/yard/lint/validators/tags/overload_consistency/config.rb +20 -0
  41. data/lib/yard/lint/validators/tags/overload_consistency/messages_builder.rb +32 -0
  42. data/lib/yard/lint/validators/tags/overload_consistency/parser.rb +49 -0
  43. data/lib/yard/lint/validators/tags/overload_consistency/result.rb +27 -0
  44. data/lib/yard/lint/validators/tags/overload_consistency/validator.rb +71 -0
  45. data/lib/yard/lint/validators/tags/overload_consistency.rb +53 -0
  46. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +3 -3
  47. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +8 -0
  48. data/lib/yard/lint/validators/tags/tag_separator/validator.rb +8 -0
  49. data/lib/yard/lint/validators/tags/type_syntax/parser.rb +13 -2
  50. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +11 -1
  51. data/lib/yard/lint/version.rb +1 -1
  52. data/lib/yard/lint.rb +3 -57
  53. metadata +23 -2
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'did_you_mean'
4
+
5
+ module Yard
6
+ module Lint
7
+ module Validators
8
+ module Documentation
9
+ module ReferenceTypo
10
+ # Validates that references in documentation are not probable typos
11
+ class Validator < Base
12
+ # Same matching rules YARD uses for `{name}` / `{name title}` links
13
+ LINK_PATTERN = /(?<![\\!])\{(?!\})(\S+?)(?:\s[^}]*)?\}/
14
+
15
+ # Code spans, fenced and indented code blocks hold code, not links
16
+ CODE_PATTERN = /```.*?```|`[^`\n]*`|^(?: {4}|\t)[^\n]*/m
17
+
18
+ # `Foo::Bar#baz`, `Foo.baz` or a namespace-relative `#baz`
19
+ METHOD_REFERENCE = /\A(?<namespace>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)?(?<separator>[#.])(?<name>[A-Za-z_]\w*[?!=]?)\z/
20
+
21
+ # `Foo`, `Foo::Bar` or `::Foo::BAR`
22
+ CONSTANT_REFERENCE = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
23
+
24
+ in_process visibility: :all
25
+
26
+ # Execute query for a single object during in-process execution.
27
+ # @param object [YARD::CodeObjects::Base] the code object to query
28
+ # @param collector [Executor::ResultCollector] collector for output
29
+ def in_process_query(object, collector)
30
+ return if object.type == :method && object.is_alias?
31
+ return if module_function_copy?(object)
32
+ return if duplicate_docstring?(object)
33
+
34
+ references(object).uniq.each do |reference|
35
+ suggestion = suggest(object, reference)
36
+ next unless suggestion
37
+
38
+ collector.puts "#{object.file}:#{object.line}: #{object.title}"
39
+ collector.puts "#{reference}|#{suggestion}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # @param object [YARD::CodeObjects::Base] documented object
46
+ # @return [Array<String>] link targets and `@see` names in its docs
47
+ def references(object)
48
+ overloads = object.tags(:overload)
49
+ tags = object.tags + overloads.flat_map { |overload| overload.docstring.tags }
50
+
51
+ texts = [object.docstring.to_s] + overloads.map { |overload| overload.docstring.to_s }
52
+ tags.each do |tag|
53
+ texts << tag.text.to_s unless tag.tag_name == 'example'
54
+ end
55
+
56
+ links = texts.flat_map do |text|
57
+ text.gsub(CODE_PATTERN, '').scan(LINK_PATTERN).map(&:first)
58
+ end
59
+
60
+ links + tags.select { |tag| tag.tag_name == 'see' }.map { |tag| tag.name.to_s }
61
+ end
62
+
63
+ # @param object [YARD::CodeObjects::Base] documented object
64
+ # @param reference [String] referenced name
65
+ # @return [String, nil] corrected reference for a probable typo
66
+ def suggest(object, reference)
67
+ if (match = METHOD_REFERENCE.match(reference))
68
+ return if resolve(object, reference)
69
+
70
+ suggest_method(object, match)
71
+ elsif CONSTANT_REFERENCE.match?(reference)
72
+ suggest_constant(object, reference)
73
+ end
74
+ end
75
+
76
+ # @param object [YARD::CodeObjects::Base] documented object
77
+ # @param match [MatchData] parts of a method reference
78
+ # @return [String, nil] corrected method reference
79
+ def suggest_method(object, match)
80
+ namespace_name = match[:namespace]
81
+ namespace = namespace_name ? resolve(object, namespace_name) : lexical_namespace(object)
82
+
83
+ unless namespace
84
+ suggestion = namespace_name && suggest_constant(object, namespace_name)
85
+
86
+ return suggestion && "#{suggestion}#{match[:separator]}#{match[:name]}"
87
+ end
88
+
89
+ return unless namespace.is_a?(YARD::CodeObjects::NamespaceObject)
90
+
91
+ scope = match[:separator] == '#' ? :instance : :class
92
+ return if external_method?(namespace, match[:name], scope)
93
+
94
+ candidates = namespace.meths(scope: scope).map { |meth| meth.name.to_s }
95
+ # YARD does not resolve a method found more than one namespace up
96
+ return if candidates.include?(match[:name])
97
+
98
+ suggestion = spell_check(match[:name], candidates)
99
+
100
+ suggestion && "#{namespace_name}#{match[:separator]}#{suggestion}"
101
+ end
102
+
103
+ # @param object [YARD::CodeObjects::Base] documented object
104
+ # @param reference [String] constant reference
105
+ # @return [String, nil] corrected constant reference
106
+ def suggest_constant(object, reference)
107
+ return if resolve(object, reference)
108
+ return if runtime_constant?(reference)
109
+
110
+ parent_name, _, name = reference.rpartition('::')
111
+
112
+ if parent_name.empty?
113
+ return if reference.start_with?('::')
114
+
115
+ candidates = visible_constants(lexical_namespace(object))
116
+ else
117
+ parent = resolve(object, parent_name)
118
+ return unless parent.is_a?(YARD::CodeObjects::NamespaceObject)
119
+
120
+ candidates = constant_names(parent)
121
+ end
122
+
123
+ suggestion = spell_check(name, candidates)
124
+ return unless suggestion
125
+
126
+ parent_name.empty? ? suggestion : "#{parent_name}::#{suggestion}"
127
+ end
128
+
129
+ # @param object [YARD::CodeObjects::Base] lookup context
130
+ # @param name [String] name to resolve
131
+ # @return [YARD::CodeObjects::Base, nil] resolved registry object
132
+ def resolve(object, name)
133
+ YARD::Registry.resolve(object, name, true)
134
+ end
135
+
136
+ # @param object [YARD::CodeObjects::Base] documented object
137
+ # @return [YARD::CodeObjects::Base, YARD::CodeObjects::Proxy] namespace links resolve against
138
+ def lexical_namespace(object)
139
+ object.is_a?(YARD::CodeObjects::NamespaceObject) ? object : object.namespace
140
+ end
141
+
142
+ # Root-level names are left out: a bare name near one of them is as
143
+ # likely a constant of a gem YARD did not parse as it is a typo
144
+ # @param namespace [YARD::CodeObjects::Base, YARD::CodeObjects::Proxy] innermost namespace
145
+ # @return [Array<String>] constant names visible from it
146
+ def visible_constants(namespace)
147
+ names = []
148
+
149
+ # `def CONST.name` and `class Unknown::Name` leave unresolved
150
+ # namespaces, which have no children to list
151
+ while namespace.is_a?(YARD::CodeObjects::NamespaceObject) && !namespace.root?
152
+ names.concat(constant_names(namespace))
153
+ namespace = namespace.namespace
154
+ end
155
+
156
+ names.uniq
157
+ end
158
+
159
+ # @param namespace [YARD::CodeObjects::NamespaceObject] namespace to list
160
+ # @return [Array<String>] names of its constants, classes and modules
161
+ def constant_names(namespace)
162
+ namespace.children.filter_map do |child|
163
+ child.name.to_s if %i[constant class module].include?(child.type)
164
+ end
165
+ end
166
+
167
+ # A method may come from an ancestor YARD does not know about, so it
168
+ # is checked against the loaded Ruby ancestors as well
169
+ # @param namespace [YARD::CodeObjects::NamespaceObject] referenced namespace
170
+ # @param name [String] referenced method name
171
+ # @param scope [Symbol] :instance or :class
172
+ # @return [Boolean] true when the method may exist outside the registry
173
+ def external_method?(namespace, name, scope)
174
+ ancestors = namespace.inheritance_tree(true).grep(YARD::CodeObjects::Proxy)
175
+ ancestors << YARD::CodeObjects::Proxy.new(:root, 'Object')
176
+ ancestors << YARD::CodeObjects::Proxy.new(:root, 'Class') if scope == :class
177
+
178
+ ancestors.any? do |proxy|
179
+ constant = runtime_constant(proxy.path)
180
+ # An ancestor that is not loaded here may define anything
181
+ next true unless constant.is_a?(Module)
182
+
183
+ constant.method_defined?(name) || constant.private_method_defined?(name)
184
+ end
185
+ end
186
+
187
+ # @param path [String] constant path
188
+ # @return [Boolean] true when the constant is defined in this Ruby process
189
+ def runtime_constant?(path)
190
+ !runtime_constant(path).nil?
191
+ end
192
+
193
+ # @param path [String] constant path
194
+ # @return [Object, nil] the constant's value, if it is loaded
195
+ def runtime_constant(path)
196
+ Object.const_get(path.delete_prefix('::'))
197
+ rescue NameError
198
+ nil
199
+ end
200
+
201
+ # @param name [String] misspelled name
202
+ # @param candidates [Array<String>] known names
203
+ # @return [String, nil] closest known name
204
+ def spell_check(name, candidates)
205
+ return if candidates.empty?
206
+
207
+ DidYouMean::SpellChecker.new(dictionary: candidates).correct(name).first
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ # ReferenceTypo validator
8
+ #
9
+ # Detects probable typos in method and constant names referenced from
10
+ # documentation: `{Foo#bar}` links in docstring and tag text, and `@see`
11
+ # targets. A reference is reported only when it does not resolve in the
12
+ # YARD registry, its namespace does, and a close match exists there.
13
+ # References into namespaces YARD does not know (stdlib, other gems) are
14
+ # skipped, as are methods a loaded Ruby ancestor may define. A bare
15
+ # constant is compared only with constants of its enclosing namespaces,
16
+ # not with top-level ones. This validator is enabled by default.
17
+ #
18
+ # @example Bad - link to a misspelled method
19
+ # # Delegates to {Parser#prase}
20
+ # def call
21
+ # end
22
+ #
23
+ # @example Good - link to an existing method
24
+ # # Delegates to {Parser#parse}
25
+ # def call
26
+ # end
27
+ #
28
+ # ## Configuration
29
+ #
30
+ # To disable this validator:
31
+ #
32
+ # Documentation/ReferenceTypo:
33
+ # Enabled: false
34
+ module ReferenceTypo
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -8,10 +8,15 @@ module Yard
8
8
  module UndocumentedBooleanMethods
9
9
  # Class used to extract details about undocumented boolean methods
10
10
  # @example Output format (skip-lint)
11
- # Platform::Analysis::Authors#valid?
11
+ # lib/platform/analysis/authors.rb:12: Platform::Analysis::Authors#valid?
12
12
  class Parser < Parsers::Base
13
- # Regex to extract location and method name from yard list output
14
- LOCATION_REGEX = /^(.+)#(.+)$|^(.+)\.(.+)$/
13
+ # Regex to extract file, line and method title from the validator output
14
+ # Format: file.rb:LINE: Namespace#method?, Namespace.method?, or #method? and
15
+ # ::method? at the root
16
+ LINE_REGEX = /^(.+):(\d+): (.+)$/
17
+
18
+ # Method name: the last segment of the title
19
+ METHOD_NAME_REGEX = /[^#.:]+\z/
15
20
 
16
21
  # @param yard_list [String] raw yard list results string
17
22
  # @return [Array<Hash>] Array with undocumented boolean methods details
@@ -20,18 +25,14 @@ module Yard
20
25
  .split("\n")
21
26
  .reject(&:empty?)
22
27
  .filter_map do |line|
23
- match_data = line.match(LOCATION_REGEX)
28
+ match_data = line.match(LINE_REGEX)
24
29
  next unless match_data
25
30
 
26
- # Handle both instance (#) and class (.) methods
27
- location = match_data[1] || match_data[3]
28
- method_name = match_data[2] || match_data[4]
29
-
30
31
  {
31
- location: location,
32
- element: "#{location}##{method_name}",
33
- method_name: method_name,
34
- line: 0 # YARD list doesn't provide line numbers
32
+ location: match_data[1],
33
+ line: match_data[2].to_i,
34
+ element: match_data[3],
35
+ method_name: match_data[3][METHOD_NAME_REGEX]
35
36
  }
36
37
  end
37
38
  end
@@ -6,13 +6,22 @@ module Yard
6
6
  module Documentation
7
7
  module UndocumentedBooleanMethods
8
8
  # Runs a query that will pick all the boolean methods (ending with ?) that
9
- # do not have a return type or return description documented
9
+ # do not have a return documented
10
10
  class Validator < Base
11
+ # A top-level tag line; indented lines belong to the tag above them
12
+ TAG_REGEX = /\A@(!?[\w.]+)/
13
+ # A macro use or definition, capturing the macro name
14
+ MACRO_REGEX = /\A@!?macro(?:\s+\[[^\]]*\])?\s+(\S+)/
15
+ # Macros may reference other macros; stop at this depth
16
+ MAX_MACRO_DEPTH = 5
17
+
18
+ private_constant :TAG_REGEX, :MACRO_REGEX, :MAX_MACRO_DEPTH
19
+
11
20
  # Enable in-process execution for this validator
12
21
  in_process visibility: :public
13
22
 
14
23
  # Execute query for a single object during in-process execution.
15
- # Finds boolean methods (ending with ?) without @return tag or return types.
24
+ # Finds boolean methods (ending with ?) without a user-written @return tag.
16
25
  # @param object [YARD::CodeObjects::Base] the code object to query
17
26
  # @param collector [Executor::ResultCollector] collector for output
18
27
  # @return [void]
@@ -26,12 +35,82 @@ module Yard
26
35
  # Only check boolean methods (ending with ?)
27
36
  return unless object.name.to_s.end_with?('?')
28
37
 
29
- # Check if @return tag is missing or has no types
30
- return_tag = object.tag(:return)
31
- return unless return_tag.nil? || return_tag.types.to_a.empty?
38
+ # A module_function pair is reported once, on its public class method
39
+ twin = module_function_twin(object)
40
+ return if twin && object.scope == :instance
41
+
42
+ # The copied half's raw text includes YARD's inferred `@return`
43
+ docstring = module_function_copy?(object) ? twin.docstring : object.docstring
44
+
45
+ # Undocumented methods are reported by UndocumentedObjects
46
+ return if docstring.all.empty?
47
+ # YARD adds `@return [Boolean]` to every `?` method without one, so the
48
+ # tags cannot tell; read the raw docstring instead
49
+ return if user_return?(docstring.all)
32
50
 
33
51
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
34
52
  end
53
+
54
+ private
55
+
56
+ # Tells if the raw docstring text has a `@return` written by the user: at the
57
+ # top level, inside an `@overload`, or carried by a macro. Text inside other
58
+ # tags (e.g. an `@example` body) never counts.
59
+ # @param text [String] raw docstring text
60
+ # @param depth [Integer] macro nesting depth
61
+ # @return [Boolean] true when a user-written `@return` is present
62
+ def user_return?(text, depth = 0)
63
+ tag_blocks(text).any? do |line, body|
64
+ case line[TAG_REGEX, 1]
65
+ when 'return'
66
+ true
67
+ when 'overload'
68
+ user_return?(dedent(body), depth)
69
+ when 'macro', '!macro'
70
+ macro_return?(line, depth)
71
+ else
72
+ false
73
+ end
74
+ end
75
+ end
76
+
77
+ # Tells if the macro named in the line carries a `@return`
78
+ # @param line [String] the `@macro` or `@!macro` line
79
+ # @param depth [Integer] macro nesting depth
80
+ # @return [Boolean] true when the macro text has a `@return` or the macro
81
+ # is not in the registry
82
+ def macro_return?(line, depth)
83
+ return false if depth >= MAX_MACRO_DEPTH
84
+
85
+ name = line[MACRO_REGEX, 1]
86
+ macro = name && YARD::CodeObjects::MacroObject.find(name)
87
+ # Only the selected files are parsed, so a macro from another file is
88
+ # unknown here; count it as written to report the same in every run
89
+ return true unless macro
90
+
91
+ user_return?(macro.macro_data.to_s, depth + 1)
92
+ end
93
+
94
+ # Splits the text into top-level tag lines and the indented lines below them
95
+ # @param text [String] raw docstring text
96
+ # @return [Array<Array(String, Array<String>)>] tag line and body lines
97
+ def tag_blocks(text)
98
+ text.lines.map(&:chomp).each_with_object([]) do |line, blocks|
99
+ if line.match?(TAG_REGEX)
100
+ blocks << [line, []]
101
+ elsif blocks.any? && (line.start_with?(/\s/) || line.empty?)
102
+ blocks.last.last << line
103
+ end
104
+ end
105
+ end
106
+
107
+ # Removes the common leading whitespace from the lines
108
+ # @param lines [Array<String>] lines to dedent
109
+ # @return [String] dedented text
110
+ def dedent(lines)
111
+ indent = lines.reject { |l| l.strip.empty? }.map { |l| l[/\A\s*/].size }.min || 0
112
+ lines.map { |l| l[indent..] || '' }.join("\n")
113
+ end
35
114
  end
36
115
  end
37
116
  end
@@ -6,9 +6,13 @@ module Yard
6
6
  module Documentation
7
7
  # UndocumentedBooleanMethods validator
8
8
  #
9
- # Ensures that boolean methods (methods ending with `?`) have an explicit
10
- # `@return [Boolean]` tag. Boolean methods should clearly document that they
11
- # return true or false values. This validator is enabled by default.
9
+ # Ensures that documented boolean methods (methods ending with `?`) have an
10
+ # explicit `@return` tag, at the top level or inside an `@overload`. Boolean
11
+ # methods should clearly document that they return true or false values.
12
+ # YARD adds `@return [Boolean]` to such methods on its own, so this validator
13
+ # reads the tags written in the comment. Methods without any documentation
14
+ # are reported by `Documentation/UndocumentedObjects`. This validator is
15
+ # enabled by default.
12
16
  #
13
17
  # @example Bad - Missing @return tag on boolean method
14
18
  # # Checks if the user is active
@@ -23,6 +27,15 @@ module Yard
23
27
  # @active
24
28
  # end
25
29
  #
30
+ # @example Good - Boolean return documented inside @overload
31
+ # # Checks if the user is active
32
+ # # @overload active?(strict)
33
+ # # @param strict [Boolean] whether to require a confirmed account
34
+ # # @return [Boolean] true if the user is active
35
+ # def active?(strict)
36
+ # @active
37
+ # end
38
+ #
26
39
  # ## Configuration
27
40
  #
28
41
  # To disable this validator:
@@ -37,8 +37,9 @@ module Yard
37
37
  # does not need option tags.
38
38
  return if options_param_documented_as_non_hash?(object)
39
39
 
40
- # Check if @option tags are missing
41
- option_tags = object.tags(:option)
40
+ # Check if @option tags are missing; tags nested inside overload
41
+ # blocks live on the overload's own docstring, so check those too
42
+ option_tags = all_typed_tags(object.docstring, %w[option])
42
43
  return unless option_tags.empty?
43
44
 
44
45
  # Output method location and parameter info
@@ -16,14 +16,14 @@ module Yard
16
16
  # @param collector [Executor::ResultCollector] collector for output
17
17
  # @return [void]
18
18
  def in_process_query(object, collector)
19
- return unless object.has_tag?(:example)
19
+ # Tags nested inside overload blocks live on the overload's own
20
+ # docstring, so check those too
21
+ example_tags = all_typed_tags(object.docstring, %w[example])
22
+ return if example_tags.empty?
20
23
 
21
24
  # Get or initialize runner (memoized for performance)
22
25
  return unless runner
23
26
 
24
- # Process each example
25
- example_tags = object.tags(:example)
26
-
27
27
  example_tags.each_with_index do |example, index|
28
28
  code = example.text
29
29
  next if code.nil? || code.empty?
@@ -16,10 +16,12 @@ module Yard
16
16
  # @param collector [Executor::ResultCollector] collector for output
17
17
  # @return [void]
18
18
  def in_process_query(object, collector)
19
- return unless object.has_tag?(:example)
19
+ # Tags nested inside overload blocks live on the overload's own
20
+ # docstring, so check those too
21
+ example_tags = all_typed_tags(object.docstring, %w[example])
22
+ return if example_tags.empty?
20
23
 
21
24
  skip_non_ruby = config_or_default('SkipNonRuby')
22
- example_tags = object.tags(:example)
23
25
 
24
26
  example_tags.each_with_index do |example, index|
25
27
  code = example.text
@@ -15,10 +15,9 @@ module Yard
15
15
  violations = offense[:tag_violations]
16
16
 
17
17
  if violations&.any?
18
- details = violations.map do |v|
19
- label = v[:param] ? "#{v[:tag]} #{v[:param]}" : v[:tag]
20
- types = v[:types].map { |t| "`#{t}`" }.join(', ')
21
- "#{label}: #{types}"
18
+ details = violations.group_by { |v| v[:param] ? "#{v[:tag]} #{v[:param]}" : v[:tag] }
19
+ .map do |label, entries|
20
+ "#{label}: #{entries.map { |v| describe_types(v) }.join(', ')}"
22
21
  end.join('; ')
23
22
 
24
23
  "The `#{offense[:method_name]}` has invalid type(s): #{details}"
@@ -26,6 +25,18 @@ module Yard
26
25
  "The `#{offense[:method_name]}` has at least one tag with an invalid type definition."
27
26
  end
28
27
  end
28
+
29
+ private
30
+
31
+ # @param violation [Hash] entry with :types (invalid names) and :type_string (full type, may be nil)
32
+ # @return [String] the invalid names, plus the full type when it differs from them
33
+ def describe_types(violation)
34
+ names = violation[:types].map { |t| "`#{t}`" }.join(', ')
35
+ type_string = violation[:type_string]
36
+ return names if type_string.nil? || violation[:types] == [type_string]
37
+
38
+ "#{names} in `#{type_string}`"
39
+ end
29
40
  end
30
41
  end
31
42
  end
@@ -8,7 +8,9 @@ module Yard
8
8
  # Parser for invalid tag types output.
9
9
  # The validator emits two lines per offense:
10
10
  # /path/to/file.rb:10: ClassName#method_name
11
- # @tagname param_name:BadType1,BadType2|@tagname2:BadType3
11
+ # @tagname param_name:BadType1,BadType2:Type|@tagname2:BadType3:Type2
12
+ # One entry per type with invalid names. '%', '|', ',', ':' and newlines
13
+ # inside tags, params, names and types are escaped as %25, %7C, %2C, %3A and %0A
12
14
  class Parser < Parsers::Base
13
15
  # @return [Regexp] matches "file:line: ObjectTitle"
14
16
  LOCATION_REGEX = /^(.+):(\d+):\s+(.+)$/
@@ -71,20 +73,29 @@ module Yard
71
73
  match ? [match[1], match[2]] : [title, title]
72
74
  end
73
75
 
74
- # Parse "tagname param:Type1,Type2|tagname2:Type3" into structured data
76
+ # Parse "tagname param:Bad1,Bad2:Type|tagname2:Bad3:Type2" into structured data
75
77
  # @param line [String] the violations line
76
78
  # @return [Array<Hash>] each entry has :tag, :param (may be nil), :types
79
+ # (the invalid names) and :type_string (the full type, may be nil)
77
80
  def parse_tag_violations(line)
78
81
  line.split('|').map do |entry|
79
- label, types_str = entry.split(':', 2)
80
- parts = label.split(' ', 2)
82
+ label, types_str, type_string = entry.split(':', 3)
83
+ tag, param = label.split(' ', 2)
81
84
  {
82
- tag: parts[0],
83
- param: parts[1],
84
- types: (types_str || '').split(',').reject(&:empty?)
85
+ tag: unescape_field(tag),
86
+ param: param && unescape_field(param),
87
+ types: (types_str || '').split(',').reject(&:empty?).map { |name| unescape_field(name) },
88
+ type_string: type_string && unescape_field(type_string)
85
89
  }
86
90
  end
87
91
  end
92
+
93
+ # Reverts the validator's escaping
94
+ # @param text [String] escaped field text
95
+ # @return [String] raw field text
96
+ def unescape_field(text)
97
+ text.gsub(/%(?:25|7C|2C|3A|0A)/, '%25' => '%', '%7C' => '|', '%2C' => ',', '%3A' => ':', '%0A' => "\n")
98
+ end
88
99
  end
89
100
  end
90
101
  end
@@ -38,20 +38,22 @@ module Yard
38
38
  strict = config_or_default('StrictConstantNames')
39
39
  allowed_types = ALLOWED_DEFAULTS + extra_types
40
40
 
41
- # Collect per-tag violations to surface in the offense message.
42
- # Each entry is "tagname param_name:Type1,Type2" (param_name omitted when nil).
43
- tag_violations = all_typed_tags(object.docstring, checked_tags).filter_map do |tag|
44
- bad = (tag_data(tag).types || [])
45
- .compact
46
- .flat_map { |type| extract_type_names(type) }
41
+ # Collect per-type violations to surface in the offense message.
42
+ # Each entry is "tagname param_name:Bad1,Bad2:Type" (param_name omitted when nil).
43
+ tag_violations = all_typed_tags(object.docstring, checked_tags).flat_map do |tag|
44
+ label = escape_field("@#{tag.tag_name}")
45
+ label = "#{label} #{escape_field(tag.name)}" if tag.name
46
+
47
+ (tag_data(tag).types || []).compact.uniq.filter_map do |type|
48
+ bad = extract_type_names(type)
47
49
  .uniq
48
- .reject { |type| allowed_types.include?(type) }
49
- .reject { |type| type_defined?(type, strict: strict) }
50
- .reject { |type| type.include?('#') }
51
- next if bad.empty?
50
+ .reject { |name| allowed_types.include?(name) }
51
+ .reject { |name| type_defined?(name, strict: strict) }
52
+ .reject { |name| name.include?('#') }
53
+ next if bad.empty?
52
54
 
53
- label = tag.name ? "@#{tag.tag_name} #{tag.name}" : "@#{tag.tag_name}"
54
- "#{label}:#{bad.join(',')}"
55
+ "#{label}:#{bad.map { |name| escape_field(name) }.join(',')}:#{escape_field(type)}"
56
+ end
55
57
  end
56
58
 
57
59
  return if tag_violations.empty?
@@ -62,6 +64,14 @@ module Yard
62
64
 
63
65
  private
64
66
 
67
+ # Escapes the separators of the output format, newlines (YARD keeps
68
+ # them in multi-line types) and '%' (the escape character)
69
+ # @param text [String] raw field text
70
+ # @return [String] escaped field text
71
+ def escape_field(text)
72
+ text.gsub(/[%|,:\n]/, '%' => '%25', '|' => '%7C', ',' => '%2C', ':' => '%3A', "\n" => '%0A')
73
+ end
74
+
65
75
  # Extract individual type names from a compound type string.
66
76
  # Instead of stripping all syntax characters and concatenating (which
67
77
  # turns "Array<self>" into "Arrayself"), this splits on syntax boundaries
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Tags
7
+ module OverloadConsistency
8
+ # Configuration for OverloadConsistency validator
9
+ class Config < ::Yard::Lint::Validators::Config
10
+ self.id = :overload_consistency
11
+ self.defaults = {
12
+ 'Enabled' => true,
13
+ 'Severity' => 'warning'
14
+ }.freeze
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end