yard-lint 1.6.1 → 1.7.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -3
  4. data/bin/yard-lint +35 -4
  5. data/lib/yard/lint/config.rb +21 -4
  6. data/lib/yard/lint/config_loader.rb +31 -6
  7. data/lib/yard/lint/config_updater.rb +22 -1
  8. data/lib/yard/lint/config_validator.rb +2 -1
  9. data/lib/yard/lint/executor/in_process_registry.rb +58 -23
  10. data/lib/yard/lint/executor/warning_dispatcher.rb +1 -0
  11. data/lib/yard/lint/git.rb +44 -3
  12. data/lib/yard/lint/path_grouper.rb +4 -1
  13. data/lib/yard/lint/results/aggregate.rb +15 -7
  14. data/lib/yard/lint/stats_calculator.rb +8 -2
  15. data/lib/yard/lint/templates/default_config.yml +34 -1
  16. data/lib/yard/lint/templates/strict_config.yml +34 -1
  17. data/lib/yard/lint/todo_generator.rb +35 -14
  18. data/lib/yard/lint/validators/base.rb +39 -1
  19. data/lib/yard/lint/validators/documentation/blank_line_before_definition/validator.rb +17 -2
  20. data/lib/yard/lint/validators/documentation/empty_comment_line/validator.rb +5 -2
  21. data/lib/yard/lint/validators/documentation/line_length/validator.rb +1 -0
  22. data/lib/yard/lint/validators/documentation/markdown_syntax/validator.rb +66 -9
  23. data/lib/yard/lint/validators/documentation/missing_return/validator.rb +3 -3
  24. data/lib/yard/lint/validators/documentation/orphaned_doc_comment/validator.rb +79 -11
  25. data/lib/yard/lint/validators/documentation/orphaned_doc_comment.rb +4 -4
  26. data/lib/yard/lint/validators/documentation/text_substitution/validator.rb +10 -2
  27. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/config.rb +10 -1
  28. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/parser.rb +18 -4
  29. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +38 -4
  30. data/lib/yard/lint/validators/documentation/undocumented_objects/parser.rb +6 -0
  31. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -0
  32. data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +1 -0
  33. data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +43 -4
  34. data/lib/yard/lint/validators/tags/api_tags/validator.rb +11 -1
  35. data/lib/yard/lint/validators/tags/collection_type/messages_builder.rb +36 -5
  36. data/lib/yard/lint/validators/tags/collection_type/validator.rb +10 -6
  37. data/lib/yard/lint/validators/tags/example_style/validator.rb +1 -1
  38. data/lib/yard/lint/validators/tags/example_syntax/config.rb +6 -1
  39. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +74 -3
  40. data/lib/yard/lint/validators/tags/forbidden_tags/validator.rb +7 -3
  41. data/lib/yard/lint/validators/tags/informal_notation/config.rb +6 -1
  42. data/lib/yard/lint/validators/tags/informal_notation/validator.rb +24 -3
  43. data/lib/yard/lint/validators/tags/invalid_types/config.rb +7 -1
  44. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +22 -5
  45. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +24 -10
  46. data/lib/yard/lint/validators/tags/missing_yield/validator.rb +44 -4
  47. data/lib/yard/lint/validators/tags/missing_yield.rb +8 -8
  48. data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +13 -1
  49. data/lib/yard/lint/validators/tags/option_tags/result.rb +1 -0
  50. data/lib/yard/lint/validators/tags/option_tags/validator.rb +30 -2
  51. data/lib/yard/lint/validators/tags/order/parser.rb +12 -5
  52. data/lib/yard/lint/validators/tags/order/validator.rb +7 -2
  53. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +21 -8
  54. data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +12 -5
  55. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +9 -7
  56. data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +8 -2
  57. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +1 -1
  58. data/lib/yard/lint/validators/warnings/invalid_directive_format/parser.rb +2 -2
  59. data/lib/yard/lint/validators/warnings/invalid_tag_format/parser.rb +2 -2
  60. data/lib/yard/lint/validators/warnings/syntax_error/config.rb +22 -0
  61. data/lib/yard/lint/validators/warnings/syntax_error/parser.rb +28 -0
  62. data/lib/yard/lint/validators/warnings/syntax_error/result.rb +27 -0
  63. data/lib/yard/lint/validators/warnings/syntax_error/validator.rb +15 -0
  64. data/lib/yard/lint/validators/warnings/syntax_error.rb +34 -0
  65. data/lib/yard/lint/validators/warnings/unknown_directive/parser.rb +2 -2
  66. data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +51 -46
  67. data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +20 -3
  68. data/lib/yard/lint/validators/warnings/unknown_tag/parser.rb +2 -2
  69. data/lib/yard/lint/version.rb +1 -1
  70. data/lib/yard/lint.rb +4 -1
  71. metadata +6 -1
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ripper'
4
+ require 'set'
5
+
3
6
  module Yard
4
7
  module Lint
5
8
  module Validators
@@ -31,6 +34,8 @@ module Yard
31
34
  (def |class |module |attr_reader|attr_writer|attr_accessor|attr_internal|attr\b|alias_method\b|alias\b|define_method\b)
32
35
  |
33
36
  \A\s*[A-Z][A-Za-z0-9_:]*\s*=
37
+ |
38
+ \A\s*\w+\s+def\b
34
39
  /x.freeze
35
40
 
36
41
  # Matches a DSL-style method call whose first argument is a symbol or string literal
@@ -38,7 +43,13 @@ module Yard
38
43
  # YARD's DSL handler turns such a call into a documentable method object when the
39
44
  # preceding comment carries an implicit-docstring tag, so a doc comment in front of
40
45
  # one of these is NOT orphaned.
41
- DSL_CALL_PATTERN = /\A\s*(?<method>[a-z_]\w*[!?]?)(?:\s+|\s*\(\s*)(?::\w|:["']|["'])/.freeze
46
+ DSL_CALL_PATTERN = %r{\A\s*(?:[A-Za-z_][\w:]*\.)?(?<method>[a-z_]\w*[!?]?)(?:\s+|\s*\(\s*)(?::\w|:["']|["'])}.freeze
47
+ # Matches a plain method call (optionally with a receiver), used when
48
+ # the comment carries a @method/@attribute tag that names the object
49
+ # YARD creates - then the call's argument shape does not matter.
50
+ METHOD_CALL_PATTERN = /\A\s*(?:[A-Za-z_][\w:]*\.)?[a-z_]\w*[!?]?(?:[\s(]|\z)/.freeze
51
+ # Matches a @method/@attribute tag that names a created object.
52
+ NAMED_OBJECT_TAG_PATTERN = /\A\s*#\s*@(?:method|attribute)\s+\S/.freeze
42
53
  # Mirror of YARD::Handlers::Ruby::DSLHandlerMethods::IGNORE_METHODS - calls to these
43
54
  # are skipped by YARD's DSL handler, so a preceding doc comment really is dropped.
44
55
  # (The `attr*`/`alias*` entries are already covered by DEFINITION_PATTERN.)
@@ -74,19 +85,26 @@ module Yard
74
85
  # @param collector [Executor::ResultCollector] collector for output lines
75
86
  # @return [void]
76
87
  def scan_file(file, collector)
77
- lines = File.readlines(file, chomp: true)
88
+ source = File.read(file)
89
+ lines = source.lines.map(&:chomp)
90
+ # Line indices (0-based) that hold a real, full-line Ruby comment.
91
+ # Derived from the lexer so that '#'-leading lines inside heredocs
92
+ # and string literals are not mistaken for documentation comments.
93
+ comment_lines = full_line_comment_indices(source, lines)
78
94
  i = 0
79
95
 
80
96
  while i < lines.length
81
- if comment_line?(lines[i])
97
+ if comment_line?(lines[i], i, comment_lines)
82
98
  block_start = i
83
99
  tags = []
84
100
 
85
101
  has_directive = false
86
102
  has_implicit_tag = false
87
- while i < lines.length && comment_line?(lines[i])
103
+ has_named_object_tag = false
104
+ while i < lines.length && comment_line?(lines[i], i, comment_lines)
88
105
  has_directive = true if directive_line?(lines[i])
89
106
  has_implicit_tag = true if implicit_docstring_tag?(lines[i])
107
+ has_named_object_tag = true if lines[i].match?(NAMED_OBJECT_TAG_PATTERN)
90
108
  tag = extract_yard_tag(lines[i])
91
109
  tags << tag if tag
92
110
  i += 1
@@ -99,7 +117,7 @@ module Yard
99
117
  # Skip trailing blank lines after the comment block
100
118
  i += 1 while i < lines.length && lines[i].strip.empty?
101
119
 
102
- unless documentable?(lines[i], has_implicit_tag)
120
+ unless documentable?(lines[i], has_implicit_tag, has_named_object_tag)
103
121
  collector.puts "#{file}:#{block_start + 1}: #{tags.uniq.join(',')}"
104
122
  end
105
123
  else
@@ -108,17 +126,60 @@ module Yard
108
126
  end
109
127
  end
110
128
 
129
+ # Lex the source and collect the 0-based indices of lines whose first
130
+ # non-whitespace content is a Ruby comment. Heredoc bodies and string
131
+ # literals lex as content tokens (not `:on_comment`), so their
132
+ # `#`-leading lines are excluded - fixing the false positives where
133
+ # tag-looking text inside a heredoc was treated as a doc comment.
134
+ # @param source [String] the full file source
135
+ # @param lines [Array<String>] the source split into chomped lines
136
+ # @return [Set<Integer>] 0-based indices of full-line comments
137
+ def full_line_comment_indices(source, lines)
138
+ indices = Set.new
139
+ ::Ripper.lex(source).each do |(position, type, _token, _state)|
140
+ next unless type == :on_comment
141
+
142
+ line_no, column = position
143
+ index = line_no - 1
144
+ current = lines[index]
145
+ next unless current
146
+
147
+ # Only a full-line comment (nothing but whitespace before the '#').
148
+ indices << index if current[0...column].to_s.strip.empty?
149
+ end
150
+ indices
151
+ rescue StandardError
152
+ # If the source cannot be lexed, fall back to the previous regex
153
+ # behaviour so a single unparseable file does not lose detection.
154
+ fallback = Set.new
155
+ lines.each_with_index do |line, index|
156
+ fallback << index if line.strip.start_with?('#')
157
+ end
158
+ fallback
159
+ end
160
+
111
161
  # @param line [String] a raw source line
162
+ # @param index [Integer] 0-based line index
163
+ # @param comment_lines [Set<Integer>] indices of real full-line comments
112
164
  # @return [Boolean] true if the line is a Ruby comment (excluding magic comments)
113
- def comment_line?(line)
114
- stripped = line.strip
115
- stripped.start_with?('#') && !magic_comment?(stripped)
165
+ def comment_line?(line, index, comment_lines)
166
+ return false unless comment_lines.include?(index)
167
+
168
+ !magic_comment?(line.strip)
116
169
  end
117
170
 
118
171
  # @param stripped_line [String] a comment line with leading/trailing whitespace removed
119
172
  # @return [Boolean] true if the line is a Ruby magic comment (frozen_string_literal, encoding, etc.)
120
173
  def magic_comment?(stripped_line)
121
- stripped_line.match?(/\A#\s*(frozen[_-]string[_-]literal|encoding|warn[_-]indent|shareable[_-]constant[_-]value)\s*:/i)
174
+ # A real magic comment has a single-token value (e.g. `true`,
175
+ # `utf-8`), optionally followed by `;` (combined directives) or an
176
+ # emacs `-*-` wrapper. Requiring that avoids treating prose that
177
+ # merely starts with a magic-comment word - like
178
+ # `# encoding: UTF-8 is assumed for all inputs` - as a magic
179
+ # comment, which would split a documentation block.
180
+ stripped_line.match?(
181
+ /\A#\s*(?:-\*-\s*)?(frozen[_-]string[_-]literal|encoding|warn[_-]indent|shareable[_-]constant[_-]value)\s*:\s*\S+\s*(?:;|-\*-|\z)/i
182
+ )
122
183
  end
123
184
 
124
185
  # @param line [String] a raw source line
@@ -137,11 +198,18 @@ module Yard
137
198
  # @param line [String, nil] the source line following the comment block, or nil at EOF
138
199
  # @param has_implicit_tag [Boolean] whether the comment block carries a tag that makes
139
200
  # YARD's DSL handler emit a method object (see IMPLICIT_DOCSTRING_TAG_PATTERN)
201
+ # @param has_named_object_tag [Boolean] whether the comment block carries a
202
+ # @method/@attribute tag naming the object YARD creates, so any following
203
+ # method call attaches the docstring regardless of its arguments
140
204
  # @return [Boolean] true if YARD will attach the comment to a documentable construct
141
- def documentable?(line, has_implicit_tag)
205
+ def documentable?(line, has_implicit_tag, has_named_object_tag = false)
142
206
  return false if line.nil?
143
207
 
144
- definition_line?(line) || (has_implicit_tag && dsl_method_line?(line))
208
+ definition_line?(line) ||
209
+ (has_implicit_tag && dsl_method_line?(line)) ||
210
+ # A @method/@attribute tag names the object YARD creates, so any
211
+ # following method call (regardless of its arguments) attaches it.
212
+ (has_named_object_tag && line.match?(METHOD_CALL_PATTERN))
145
213
  end
146
214
 
147
215
  # @param line [String] a raw source line
@@ -15,10 +15,6 @@ module Yard
15
15
  # non-documentable statement (variable assignment, `require`, `include`, etc.)
16
16
  # or sits at the end of a file.
17
17
  #
18
- # @note This validator is complementary to `Documentation/BlankLineBeforeDefinition`,
19
- # which catches doc blocks separated from a `def` by blank lines.
20
- # `OrphanedDocComment` catches doc blocks that lead to non-definition code entirely.
21
- #
22
18
  # @example Bad - comment before variable assignment
23
19
  # # @param name [String] the name
24
20
  # # @return [void]
@@ -39,6 +35,10 @@ module Yard
39
35
  # def process(name)
40
36
  # end
41
37
  #
38
+ # @note This validator is complementary to `Documentation/BlankLineBeforeDefinition`,
39
+ # which catches doc blocks separated from a `def` by blank lines.
40
+ # `OrphanedDocComment` catches doc blocks that lead to non-definition code entirely.
41
+ #
42
42
  # ## Configuration
43
43
  #
44
44
  # To disable this validator:
@@ -16,6 +16,7 @@ module Yard
16
16
  def in_process_query(object, collector)
17
17
  docstring_text = object.docstring.to_s
18
18
  return if docstring_text.empty?
19
+ return if duplicate_docstring?(object)
19
20
 
20
21
  substitutions = config_or_default('Substitutions')
21
22
  return if substitutions.nil? || substitutions.empty?
@@ -24,7 +25,8 @@ module Yard
24
25
  return if violations.empty?
25
26
 
26
27
  violations.each do |violation|
27
- collector.puts "#{object.file}:#{object.line}: #{object.title}"
28
+ line = docstring_line(object, violation[:line_offset])
29
+ collector.puts "#{object.file}:#{line}: #{object.title}"
28
30
  collector.puts violation[:forbidden]
29
31
  collector.puts violation[:replacement]
30
32
  collector.puts "#{violation[:line_offset]}|#{violation[:line_text]}"
@@ -48,10 +50,16 @@ module Yard
48
50
  end
49
51
  next if in_code_block
50
52
 
53
+ # Match against the line with inline code spans (`...`) removed,
54
+ # so a forbidden string that only appears inside code is not
55
+ # flagged - it is literal code, not prose to be substituted. The
56
+ # reported line_text remains the original line.
57
+ scannable = line.gsub(/`[^`]*`/, '')
58
+
51
59
  substitutions.each do |forbidden, replacement|
52
60
  next if forbidden.nil? || forbidden.empty?
53
61
  next if replacement.nil? || replacement.empty?
54
- next unless line.include?(forbidden)
62
+ next unless scannable.include?(forbidden)
55
63
 
56
64
  violations << {
57
65
  forbidden: forbidden,
@@ -12,7 +12,16 @@ module Yard
12
12
  'Enabled' => true,
13
13
  'Severity' => 'warning',
14
14
  'AllowedParentClasses' => [],
15
- 'AllowedMethods' => []
15
+ 'AllowedMethods' => [],
16
+ # Match each parameter to a @param tag by name (default). Catches a
17
+ # misnamed @param (e.g. `@param wrong` for `def push(item)`) that a
18
+ # count-only check would accept. Set to false to fall back to the
19
+ # lenient count-only comparison.
20
+ 'CheckParameterNames' => true,
21
+ # Opt-in: skip methods with no documentation at all and let
22
+ # Documentation/UndocumentedObjects report them, avoiding a second
23
+ # offense for the same fully-undocumented method. Off by default.
24
+ 'SkipFullyUndocumented' => false
16
25
  }.freeze
17
26
  end
18
27
  end
@@ -9,9 +9,13 @@ module Yard
9
9
  # @example Output format (skip-lint)
10
10
  # /path/to/file.rb:10: Platform::Analysis::Authors#initialize
11
11
  class Parser < Parsers::Base
12
- # Regex to extract file, line, and method name from yard list output
12
+ # Regex to extract file, line, and object title from yard list output
13
13
  # Format: /path/to/file.rb:10: ClassName#method_name
14
- LOCATION_REGEX = /^(.+):(\d+):\s+(.+)[#.](.+)$/
14
+ LOCATION_REGEX = /^(.+):(\d+):\s+(.+)$/
15
+ # Splits an object title into namespace and method name on the last
16
+ # # or . separator. Top-level methods (#foo) have an empty namespace;
17
+ # titles without a separator (e.g. Foo::Bar, CONST) are kept whole.
18
+ TITLE_REGEX = /\A(.*)[#.]([^#.]+)\z/
15
19
 
16
20
  # @param yard_list [String] raw yard list results string
17
21
  # @return [Array<Hash>] Array with undocumented method arguments details
@@ -26,8 +30,7 @@ module Yard
26
30
  # Extract: file path, line number, class name, method name
27
31
  file_path = match_data[1]
28
32
  line_number = match_data[2].to_i
29
- class_name = match_data[3]
30
- method_name = match_data[4]
33
+ class_name, method_name = split_title(match_data[3])
31
34
 
32
35
  {
33
36
  location: file_path,
@@ -37,6 +40,17 @@ module Yard
37
40
  }
38
41
  end
39
42
  end
43
+
44
+ private
45
+
46
+ # Splits a YARD object title into namespace and method name parts
47
+ # @param title [String] object title (e.g. "Foo#bar", "#bar", "CONST")
48
+ # @return [Array(String, String)] namespace and method name; for titles
49
+ # without a separator both parts are the full title
50
+ def split_title(title)
51
+ match = title.match(TITLE_REGEX)
52
+ match ? [match[1], match[2]] : [title, title]
53
+ end
40
54
  end
41
55
  end
42
56
  end
@@ -27,14 +27,48 @@ module Yard
27
27
  # doesn't need explicit @param documentation, matching attr_accessor behavior
28
28
  return if object.is_attribute?
29
29
 
30
- # Check if parameters count exceeds @param tags count
31
- param_count = object.parameters.size
32
- param_tags_count = object.tags(:param).size
30
+ # Splat (*args, **opts) and block (&block) parameters are excluded -
31
+ # blocks are documented with @yield rather than @param, matching the
32
+ # arity convention used everywhere else in the gem (e.g. method_allowed?).
33
+ params = object.parameters.reject { |p| p[0].to_s.start_with?('*', '&') }
34
+ return if params.empty?
33
35
 
34
- return unless param_count > param_tags_count
36
+ # Opt-in: defer fully-undocumented methods to
37
+ # Documentation/UndocumentedObjects instead of reporting them here too.
38
+ return if config_or_default('SkipFullyUndocumented') && object.docstring.blank?
39
+
40
+ return unless arguments_missing_docs?(object, params)
35
41
 
36
42
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
37
43
  end
44
+
45
+ private
46
+
47
+ # Decide whether a method's parameters are under-documented.
48
+ # @param object [YARD::CodeObjects::MethodObject] the method to check
49
+ # @param params [Array<Array>] non-splat/block parameters
50
+ # @return [Boolean] true if documentation is missing
51
+ def arguments_missing_docs?(object, params)
52
+ # Tags nested inside @overload blocks live on the overload's own
53
+ # docstring, so all_typed_tags collects those @param tags too.
54
+ param_tags = all_typed_tags(object.docstring, %w[param])
55
+
56
+ if config_or_default('CheckParameterNames')
57
+ documented = param_tags.map { |tag| normalize_param_name(tag.name) }
58
+ params.any? { |param| !documented.include?(normalize_param_name(param[0])) }
59
+ else
60
+ params.size > param_tags.size
61
+ end
62
+ end
63
+
64
+ # Normalize a parameter or @param name for comparison by stripping a
65
+ # trailing `:` - keyword parameters appear as `name:` in
66
+ # object.parameters but `name` in @param tag names.
67
+ # @param name [String, Symbol, nil] the raw name
68
+ # @return [String] the normalized name
69
+ def normalize_param_name(name)
70
+ name.to_s.sub(/:\z/, '')
71
+ end
38
72
  end
39
73
  end
40
74
  end
@@ -62,6 +62,12 @@ module Yard
62
62
  # @param excluded_methods [Array<String>] list of exclusion patterns
63
63
  # @return [Boolean] true if method should be excluded
64
64
  def method_excluded?(element, arity, excluded_methods)
65
+ # ExcludedMethods only applies to methods. A class, module, or
66
+ # constant element has no #/. separator, so never derive a
67
+ # "method name" from it - otherwise a pattern like /cache/ would
68
+ # silently suppress the offense for a class such as Memcached.
69
+ return false unless element.match?(/[#.]/)
70
+
65
71
  # Extract method name from element (e.g., "Foo::Bar#baz" -> "baz")
66
72
  method_name = element.split(/[#.]/).last
67
73
  return false unless method_name
@@ -32,6 +32,11 @@ module Yard
32
32
 
33
33
  return unless has_options_param
34
34
 
35
+ # A named (non-splat) parameter documented with a concrete
36
+ # non-Hash type (via its param tag) is not an options hash, so it
37
+ # does not need option tags.
38
+ return if options_param_documented_as_non_hash?(object)
39
+
35
40
  # Check if @option tags are missing
36
41
  option_tags = object.tags(:option)
37
42
  return unless option_tags.empty?
@@ -40,6 +45,31 @@ module Yard
40
45
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
41
46
  collector.puts params.map { |p| p.join(' ') }.join(', ')
42
47
  end
48
+
49
+ private
50
+
51
+ # Whether a named options-style parameter is documented with a
52
+ # concrete non-Hash @param type (e.g. `@param option [Symbol]`).
53
+ # Double-splat (`**opts`) collectors are always hashes and excluded.
54
+ # @param object [YARD::CodeObjects::MethodObject] the method
55
+ # @return [Boolean]
56
+ def options_param_documented_as_non_hash?(object)
57
+ param_tags = all_typed_tags(object.docstring, %w[param])
58
+
59
+ object.parameters.any? do |param|
60
+ name = param[0].to_s
61
+ next false if name.start_with?('**')
62
+
63
+ bare = name.gsub(/[*:]/, '')
64
+ next false unless bare.match?(/\A(options?|opts?|kwargs)\z/)
65
+
66
+ tag = param_tags.find { |t| t.name == bare }
67
+ types = tag&.types
68
+ next false if types.nil? || types.empty?
69
+
70
+ types.none? { |type| type.to_s.match?(/\AHash\b|\AHash[<{(]/) }
71
+ end
72
+ end
43
73
  end
44
74
  end
45
75
  end
@@ -28,6 +28,7 @@ module Yard
28
28
  severity: configured_severity,
29
29
  type: self.class.offense_type,
30
30
  name: offense_data[:name] || self.class.offense_name,
31
+ validator: validator_name,
31
32
  message: build_message(offense_data),
32
33
  location: offense_data[:location] || offense_data[:file],
33
34
  location_line: offense_data[:line] || offense_data[:location_line] || 0
@@ -32,11 +32,21 @@ module Yard
32
32
  # Skip def line and end
33
33
  body_lines = lines[1...-1] || []
34
34
 
35
+ # Merge statement continuations (a line ending in a comma or
36
+ # backslash continues on the next line) so a multi-line
37
+ # `raise NotImplementedError, "message"` is judged as one
38
+ # statement rather than leaving a dangling argument line that
39
+ # looks like a real implementation.
40
+ body_lines = merge_continuations(body_lines)
41
+
42
+ # A line is an allowed (non-)implementation if it is a comment,
43
+ # the closing `end`, or matches one of the configured
44
+ # AllowedImplementations patterns (e.g. raising NotImplementedError).
45
+ allowed = allowed_implementation_patterns
35
46
  has_real_implementation = body_lines.any? do |line|
36
- !line.start_with?('#') &&
37
- !line.include?('NotImplementedError') &&
38
- !line.include?('raise') &&
39
- line != 'end'
47
+ next false if line.start_with?('#') || line == 'end'
48
+
49
+ allowed.none? { |pattern| line.match?(pattern) }
40
50
  end
41
51
 
42
52
  return unless has_real_implementation
@@ -44,6 +54,35 @@ module Yard
44
54
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
45
55
  collector.puts 'has_implementation'
46
56
  end
57
+
58
+ private
59
+
60
+ # The configured AllowedImplementations patterns compiled to
61
+ # regexps. A body line matching any of these does not count as a
62
+ # real implementation. Invalid patterns are ignored.
63
+ # @return [Array<Regexp>]
64
+ def allowed_implementation_patterns
65
+ Array(config_or_default('AllowedImplementations')).filter_map do |pattern|
66
+ Regexp.new(pattern.to_s)
67
+ rescue RegexpError
68
+ nil
69
+ end
70
+ end
71
+
72
+ # Joins lines that are continuations of the previous statement (the
73
+ # previous line ends with a comma or a backslash) into a single
74
+ # logical line.
75
+ # @param lines [Array<String>] stripped body lines
76
+ # @return [Array<String>] body lines with continuations merged
77
+ def merge_continuations(lines)
78
+ lines.each_with_object([]) do |line, merged|
79
+ if !merged.empty? && merged.last.match?(/[,\\]\z/)
80
+ merged[-1] = "#{merged.last.chomp('\\').rstrip} #{line}"
81
+ else
82
+ merged << line
83
+ end
84
+ end
85
+ end
47
86
  end
48
87
  end
49
88
  end
@@ -19,7 +19,12 @@ module Yard
19
19
  allowed_list = allowed_apis
20
20
 
21
21
  if object.has_tag?(:api)
22
- api_value = object.tag(:api).text
22
+ # YARD tag text includes indented continuation/description lines
23
+ # (e.g. "private\nfor internal use only"). The @api value is a
24
+ # single token, so validate only the first word - otherwise a
25
+ # documented `@api private` is flagged as invalid, and the
26
+ # newline in the emitted value corrupts the parser's pairing.
27
+ api_value = object.tag(:api).text.to_s.split(/\s+/).first.to_s
23
28
  unless allowed_list.include?(api_value)
24
29
  collector.puts "#{object.file}:#{object.line}: #{object.title}"
25
30
  collector.puts "invalid:#{api_value}"
@@ -37,6 +42,11 @@ module Yard
37
42
  # hit the branch above and get their value validated. See issue #128.
38
43
  return if object.type == :method && object.is_attribute?
39
44
 
45
+ # An api tag is expected on classes, modules, and methods (per
46
+ # this validator's documentation) - not on constants or other
47
+ # object types, which were being flagged as missing the tag.
48
+ return unless %i[class module method].include?(object.type)
49
+
40
50
  # Only check public methods/classes if require_api_tags is enabled
41
51
  visibility = object.visibility.to_s
42
52
  if visibility == 'public' && !object.root?
@@ -54,13 +54,44 @@ module Yard
54
54
  # (String, Integer) -> Array(String, Integer)
55
55
  "Array#{type_string}"
56
56
  else
57
- # Hash<K, V> -> Hash{K => V}
58
- type_string.gsub(/Hash<(.+?)>/) do
59
- types = ::Regexp.last_match(1)
60
- # Split on comma, handle nested types
61
- "Hash{#{types.sub(/,\s*/, ' => ')}}"
57
+ # Hash<K, V> -> Hash{K => V}, handling nested generics with
58
+ # balanced-bracket splitting so the suggestion stays valid.
59
+ convert_hash_short_to_long(type_string)
60
+ end
61
+ end
62
+
63
+ # Converts Hash<K, V> to Hash{K => V}, recursing into the key and
64
+ # value and splitting on the top-level comma so nested types like
65
+ # Hash<Symbol, Hash<String, Integer>> are not mangled.
66
+ # @param type_string [String] the type string
67
+ # @return [String] the converted type string
68
+ def convert_hash_short_to_long(type_string)
69
+ match = type_string.match(/\AHash<(.+)>\z/m)
70
+ return type_string unless match
71
+
72
+ key, value = split_top_level(match[1])
73
+ return type_string unless value
74
+
75
+ "Hash{#{convert_hash_short_to_long(key.strip)} => " \
76
+ "#{convert_hash_short_to_long(value.strip)}}"
77
+ end
78
+
79
+ # Splits a generic body on its first top-level comma, respecting
80
+ # nested <>, {}, and () so nested generics are kept intact.
81
+ # @param str [String] the inside of a generic (without the brackets)
82
+ # @return [Array] [key, value], or [str, nil] when there is no
83
+ # top-level comma
84
+ def split_top_level(str)
85
+ depth = 0
86
+ str.each_char.with_index do |char, index|
87
+ case char
88
+ when '<', '{', '(' then depth += 1
89
+ when '>', '}', ')' then depth -= 1
90
+ when ','
91
+ return [str[0...index], str[(index + 1)..]] if depth.zero?
62
92
  end
63
93
  end
94
+ [str, nil]
64
95
  end
65
96
 
66
97
  # Converts long syntax to short syntax
@@ -20,9 +20,10 @@ module Yard
20
20
  style = enforced_style
21
21
 
22
22
  all_typed_tags(object.docstring, validated_tags).each do |tag|
23
- next unless tag.types
23
+ types = tag_data(tag).types
24
+ next unless types
24
25
 
25
- tag.types.each do |type_str|
26
+ types.each do |type_str|
26
27
  detected_style = detect_style(type_str)
27
28
 
28
29
  # Report violations based on enforced style
@@ -41,22 +42,25 @@ module Yard
41
42
  # @param type_str [String] the type string to check
42
43
  # @return [String, nil] 'long' or 'short', or nil if not a collection type
43
44
  def detect_style(type_str)
45
+ # The Hash/Array prefixes are anchored with a negative lookbehind
46
+ # so only the built-in classes match - not custom classes whose
47
+ # names merely contain them (MyHash, ByteArray).
44
48
  # Hash types
45
49
  # Hash<...> is short style (should be Hash{K => V})
46
- if type_str =~ /Hash<.*>/
50
+ if type_str =~ /(?<![A-Za-z0-9_])Hash<.*>/
47
51
  'short'
48
52
  # Hash{...} is long style
49
- elsif type_str =~ /Hash\{.*\}/
53
+ elsif type_str =~ /(?<![A-Za-z0-9_])Hash\{.*\}/
50
54
  'long'
51
55
  # {...} without Hash prefix is short style
52
56
  elsif type_str =~ /^\{.*\}$/
53
57
  'short'
54
58
  # Array types
55
59
  # Array<...> is long style
56
- elsif type_str =~ /Array<.*>/
60
+ elsif type_str =~ /(?<![A-Za-z0-9_])Array<.*>/
57
61
  'long'
58
62
  # Array(...) is long style
59
- elsif type_str =~ /Array\(.*\)/
63
+ elsif type_str =~ /(?<![A-Za-z0-9_])Array\(.*\)/
60
64
  'long'
61
65
  # <...> without Array prefix is short style
62
66
  elsif type_str =~ /^<.*>$/
@@ -28,7 +28,7 @@ module Yard
28
28
  code = example.text
29
29
  next if code.nil? || code.empty?
30
30
 
31
- example_name = example.name || "Example #{index + 1}"
31
+ example_name = example.name.to_s.empty? ? "Example #{index + 1}" : example.name
32
32
 
33
33
  # Run linter (pass file path for context/config discovery)
34
34
  offenses = runner.run(code, example_name, file_path: object.file)
@@ -10,7 +10,12 @@ module Yard
10
10
  self.id = :example_syntax
11
11
  self.defaults = {
12
12
  'Enabled' => true,
13
- 'Severity' => 'warning'
13
+ 'Severity' => 'warning',
14
+ # Opt-in: skip @example blocks that are interactive console
15
+ # transcripts (irb/pry sessions, their `=>` output, or shell `$`
16
+ # prompts) rather than runnable Ruby. Off by default so a real
17
+ # syntax error in a normal example is not accidentally hidden.
18
+ 'SkipNonRuby' => false
14
19
  }.freeze
15
20
  end
16
21
  end