yard-lint 1.6.1 → 1.8.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +52 -8
  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 +49 -1
  16. data/lib/yard/lint/templates/strict_config.yml +44 -1
  17. data/lib/yard/lint/todo_generator.rb +35 -14
  18. data/lib/yard/lint/validators/base.rb +50 -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 -11
  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/underfilled_lines/config.rb +36 -0
  28. data/lib/yard/lint/validators/documentation/underfilled_lines/messages_builder.rb +31 -0
  29. data/lib/yard/lint/validators/documentation/underfilled_lines/parser.rb +64 -0
  30. data/lib/yard/lint/validators/documentation/underfilled_lines/result.rb +26 -0
  31. data/lib/yard/lint/validators/documentation/underfilled_lines/validator.rb +266 -0
  32. data/lib/yard/lint/validators/documentation/underfilled_lines.rb +74 -0
  33. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/config.rb +10 -1
  34. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/parser.rb +18 -4
  35. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +38 -4
  36. data/lib/yard/lint/validators/documentation/undocumented_objects/parser.rb +6 -0
  37. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -0
  38. data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +1 -0
  39. data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +43 -4
  40. data/lib/yard/lint/validators/tags/api_tags/validator.rb +11 -1
  41. data/lib/yard/lint/validators/tags/collection_type/messages_builder.rb +36 -5
  42. data/lib/yard/lint/validators/tags/collection_type/validator.rb +10 -6
  43. data/lib/yard/lint/validators/tags/example_style/validator.rb +1 -1
  44. data/lib/yard/lint/validators/tags/example_syntax/config.rb +6 -1
  45. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +74 -3
  46. data/lib/yard/lint/validators/tags/forbidden_tags/validator.rb +7 -3
  47. data/lib/yard/lint/validators/tags/informal_notation/config.rb +6 -1
  48. data/lib/yard/lint/validators/tags/informal_notation/validator.rb +24 -3
  49. data/lib/yard/lint/validators/tags/invalid_types/config.rb +7 -1
  50. data/lib/yard/lint/validators/tags/invalid_types/parser.rb +22 -5
  51. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +24 -10
  52. data/lib/yard/lint/validators/tags/missing_yield/validator.rb +44 -4
  53. data/lib/yard/lint/validators/tags/missing_yield.rb +8 -8
  54. data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +13 -1
  55. data/lib/yard/lint/validators/tags/option_tags/result.rb +1 -0
  56. data/lib/yard/lint/validators/tags/option_tags/validator.rb +30 -2
  57. data/lib/yard/lint/validators/tags/order/parser.rb +12 -5
  58. data/lib/yard/lint/validators/tags/order/validator.rb +7 -2
  59. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +21 -8
  60. data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +12 -5
  61. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +9 -7
  62. data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +8 -2
  63. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +1 -1
  64. data/lib/yard/lint/validators/warnings/invalid_directive_format/parser.rb +2 -2
  65. data/lib/yard/lint/validators/warnings/invalid_tag_format/parser.rb +2 -2
  66. data/lib/yard/lint/validators/warnings/syntax_error/config.rb +22 -0
  67. data/lib/yard/lint/validators/warnings/syntax_error/parser.rb +28 -0
  68. data/lib/yard/lint/validators/warnings/syntax_error/result.rb +27 -0
  69. data/lib/yard/lint/validators/warnings/syntax_error/validator.rb +15 -0
  70. data/lib/yard/lint/validators/warnings/syntax_error.rb +34 -0
  71. data/lib/yard/lint/validators/warnings/unknown_directive/parser.rb +2 -2
  72. data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +51 -46
  73. data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +20 -3
  74. data/lib/yard/lint/validators/warnings/unknown_tag/parser.rb +2 -2
  75. data/lib/yard/lint/version.rb +1 -1
  76. data/lib/yard/lint.rb +4 -1
  77. metadata +12 -1
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ module UnderfilledLines
8
+ # Detects documentation prose that wraps before using the available line width.
9
+ #
10
+ # Uses YARD's `docstring.line_range` to locate the exact source lines belonging
11
+ # to the docstring block (mirroring {LineLength}), classifies each line as prose
12
+ # or structural, groups contiguous prose into paragraphs, and reports a
13
+ # paragraph when greedily re-wrapping it at `MaxLength` would use fewer lines.
14
+ class Validator < Validators::Base
15
+ in_process visibility: :all
16
+
17
+ # Characters that may trail a sentence boundary char without changing the
18
+ # fact that the line ends a clause (closing bracket/quote/backtick).
19
+ TRAILING_CLOSERS = /[)\]"'`]+\z/
20
+
21
+ # Execute query for a single object during in-process execution.
22
+ # @param object [YARD::CodeObjects::Base] the code object to query
23
+ # @param collector [Executor::ResultCollector] collector for output
24
+ # @return [void]
25
+ def in_process_query(object, collector)
26
+ return unless object.file && File.exist?(object.file)
27
+ return if object.docstring.all.empty?
28
+
29
+ line_range = object.docstring.line_range
30
+ return unless line_range
31
+ return if duplicate_docstring?(object)
32
+
33
+ max_length = config_or_default('MaxLength').to_i
34
+ min_trailing = config_or_default('MinTrailingSpace').to_i
35
+ min_lines = [config_or_default('MinParagraphLines').to_i, 2].max
36
+ boundary = Array(config_or_default('SentenceEndChars'))
37
+ skip_non_ascii = config_or_default('SkipNonAscii')
38
+
39
+ source_lines = cached_lines(object.file)
40
+ classified = classify_lines(line_range, source_lines, skip_non_ascii)
41
+
42
+ violations = []
43
+ group_paragraphs(classified).each do |paragraph|
44
+ next if paragraph.size < min_lines
45
+ next if ventilated?(paragraph, boundary)
46
+
47
+ actual = paragraph.size
48
+ reflowed = reflow_count(paragraph, max_length)
49
+ next unless reflowed < actual
50
+
51
+ widest = paragraph[0..-2].map { |line| line[:length] }.max
52
+ next if (max_length - widest) < min_trailing
53
+
54
+ violations << "#{paragraph.first[:line_no]}:#{actual}:#{reflowed}:#{widest}"
55
+ end
56
+
57
+ return if violations.empty?
58
+
59
+ collector.puts "#{object.file}:#{object.line}: #{object.title}"
60
+ collector.puts "#{max_length}|#{violations.join('|')}"
61
+ end
62
+
63
+ private
64
+
65
+ # Classify each source line in the docstring range. Plain-prose lines become
66
+ # hashes; everything else (blank lines, tags, code, markdown structure) is a
67
+ # `:break` marker that separates paragraphs.
68
+ # @param line_range [Range] absolute source line numbers of the docstring
69
+ # @param source_lines [Array<String>] raw lines of the source file
70
+ # @param skip_non_ascii [Boolean] treat non-ASCII lines as structural
71
+ # @return [Array<Hash, Symbol>] prose hashes interleaved with :break markers
72
+ def classify_lines(line_range, source_lines, skip_non_ascii)
73
+ base_indent = base_indent_for(line_range, source_lines)
74
+ result = []
75
+ in_fence = false
76
+ in_tag_region = false
77
+
78
+ line_range.each do |line_no|
79
+ raw = source_lines[line_no - 1].to_s.rstrip
80
+ marker = raw.match(/\A(\s*#+\s?)(.*)\z/)
81
+
82
+ # Not a comment line (e.g. the definition itself, or a block comment body)
83
+ unless marker
84
+ result << :break
85
+ in_tag_region = false
86
+ next
87
+ end
88
+
89
+ content = marker[2]
90
+ stripped = content.strip
91
+
92
+ # Fenced code block delimiters toggle the fence; the delimiter line and
93
+ # everything inside it is structural.
94
+ if stripped.start_with?('```', '~~~')
95
+ in_fence = !in_fence
96
+ result << :break
97
+ next
98
+ end
99
+ if in_fence
100
+ result << :break
101
+ next
102
+ end
103
+
104
+ if stripped.empty?
105
+ result << :break
106
+ in_tag_region = false
107
+ next
108
+ end
109
+
110
+ # A real YARD tag/directive begins at column 0 of the comment content.
111
+ # It and its indented continuation lines are skipped; only the free-text
112
+ # description body is checked.
113
+ if content.start_with?('@')
114
+ in_tag_region = true
115
+ result << :break
116
+ next
117
+ end
118
+ if in_tag_region
119
+ if content.match?(/\A[ \t]/)
120
+ result << :break
121
+ next
122
+ end
123
+ in_tag_region = false
124
+ end
125
+
126
+ # Lines indented past the block's base indentation are code samples,
127
+ # ASCII diagrams or nested list continuations - not flowing prose.
128
+ indent = content.length - content.lstrip.length
129
+ if indent > base_indent || structural_content?(stripped, skip_non_ascii)
130
+ result << :break
131
+ next
132
+ end
133
+
134
+ result << {
135
+ line_no: line_no,
136
+ prefix_width: raw.length - stripped.length,
137
+ content: stripped,
138
+ length: raw.length
139
+ }
140
+ end
141
+
142
+ result
143
+ end
144
+
145
+ # The smallest content indentation among non-empty comment lines in the
146
+ # range. Flowing prose sits at this base; anything deeper is treated as a
147
+ # nested, code or diagram line.
148
+ # @param line_range [Range] absolute source line numbers of the docstring
149
+ # @param source_lines [Array<String>] raw lines of the source file
150
+ # @return [Integer] base indentation in columns (0 for normal `# ` comments)
151
+ def base_indent_for(line_range, source_lines)
152
+ indents = []
153
+ line_range.each do |line_no|
154
+ raw = source_lines[line_no - 1].to_s.rstrip
155
+ marker = raw.match(/\A(\s*#+\s?)(.*)\z/)
156
+ next unless marker
157
+
158
+ content = marker[2]
159
+ next if content.strip.empty?
160
+
161
+ indents << (content.length - content.lstrip.length)
162
+ end
163
+ indents.min || 0
164
+ end
165
+
166
+ # Whether a prose-looking comment line is actually structural and must not
167
+ # take part in reflow (markdown, code, diagrams, RDoc directives, ...).
168
+ # @param stripped [String] comment content, stripped of the marker and whitespace
169
+ # @param skip_non_ascii [Boolean] treat non-ASCII content as structural
170
+ # @return [Boolean]
171
+ def structural_content?(stripped, skip_non_ascii)
172
+ return true if skip_non_ascii && !stripped.ascii_only?
173
+ # Markdown heading, blockquote
174
+ return true if stripped.start_with?('#', '>')
175
+ # Table row
176
+ return true if stripped.include?('|')
177
+ # List items (bulleted or ordered)
178
+ return true if stripped.match?(/\A[-*+]\s/)
179
+ return true if stripped.match?(/\A\d+[.)]\s/)
180
+ # Thematic break (---, ***, ___)
181
+ return true if stripped.match?(/\A([-*_])\1\1+\z/)
182
+ # RDoc directives
183
+ return true if stripped.match?(/\A:\w+:/)
184
+ return true if stripped.end_with?(':nodoc:', ':doc:')
185
+ # Intentional hard break / hyphenation
186
+ return true if stripped.end_with?('-')
187
+ # Column alignment (2+ internal spaces) or box-drawing diagrams
188
+ return true if stripped.match?(/\S {2,}\S/)
189
+ return true if stripped.match?(/[─-╿▀-▟]/)
190
+
191
+ false
192
+ end
193
+
194
+ # Group the classified entries into paragraphs: maximal runs of contiguous
195
+ # prose lines sharing the same comment-marker indentation.
196
+ # @param classified [Array<Hash, Symbol>] output of {#classify_lines}
197
+ # @return [Array<Array<Hash>>] paragraphs, each an array of prose line hashes
198
+ def group_paragraphs(classified)
199
+ paragraphs = []
200
+ current = []
201
+
202
+ flush = lambda do
203
+ paragraphs << current unless current.empty?
204
+ current = []
205
+ end
206
+
207
+ classified.each do |entry|
208
+ if entry == :break
209
+ flush.call
210
+ elsif current.empty? || current.last[:prefix_width] == entry[:prefix_width]
211
+ current << entry
212
+ else
213
+ flush.call
214
+ current << entry
215
+ end
216
+ end
217
+ flush.call
218
+
219
+ paragraphs
220
+ end
221
+
222
+ # Whether a paragraph is deliberately broken one sentence/clause per line.
223
+ # True when every non-final line ends at a sentence boundary character.
224
+ # @param paragraph [Array<Hash>] prose line hashes
225
+ # @param boundary [Array<String>] sentence-ending characters
226
+ # @return [Boolean]
227
+ def ventilated?(paragraph, boundary)
228
+ return false if boundary.empty?
229
+
230
+ paragraph[0..-2].all? do |line|
231
+ last = line[:content].sub(TRAILING_CLOSERS, '')[-1]
232
+ last && boundary.include?(last)
233
+ end
234
+ end
235
+
236
+ # Number of lines the paragraph's words occupy when greedily wrapped at
237
+ # `max_length`, using the paragraph's comment-marker prefix on every line.
238
+ # A word that does not fit starts a new line, so breaks forced by an
239
+ # unbreakable long token (URL, namespaced constant) are reproduced and do
240
+ # not count as savings.
241
+ # @param paragraph [Array<Hash>] prose line hashes
242
+ # @param max_length [Integer] target width
243
+ # @return [Integer] number of wrapped lines
244
+ def reflow_count(paragraph, max_length)
245
+ prefix_width = paragraph.first[:prefix_width]
246
+ words = paragraph.flat_map { |line| line[:content].split }
247
+ return paragraph.size if words.empty?
248
+
249
+ lines = 1
250
+ current = prefix_width + words.first.length
251
+ words.drop(1).each do |word|
252
+ if current + 1 + word.length <= max_length
253
+ current += 1 + word.length
254
+ else
255
+ lines += 1
256
+ current = prefix_width + word.length
257
+ end
258
+ end
259
+ lines
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yard
4
+ module Lint
5
+ module Validators
6
+ module Documentation
7
+ # UnderfilledLines validator
8
+ #
9
+ # The inverse of {LineLength}: instead of flagging documentation lines that are
10
+ # too *long*, it flags documentation prose paragraphs that wrap **too early** -
11
+ # text that uses only a fraction of the available width and spills onto extra
12
+ # lines, wasting vertical space. This pattern is common in AI-generated docs.
13
+ #
14
+ # Only the free-text description body is checked. YARD tags (`@param`, `@return`,
15
+ # `@example`, ...), fenced and indented code, lists, tables, headings,
16
+ # blockquotes and non-ASCII text are left untouched. A paragraph is reported
17
+ # only when *all* of these hold, so ambiguous cases are never flagged:
18
+ #
19
+ # * greedily re-wrapping its words at `MaxLength` would genuinely use fewer lines;
20
+ # * the unused space on its widest line is at least `MinTrailingSpace` columns;
21
+ # * it is not deliberately broken one sentence/clause per line (every non-final
22
+ # line ending at a `SentenceEndChars` boundary is treated as an intentional
23
+ # semantic line break and skipped).
24
+ #
25
+ # Disabled by default - this is a stylistic, opinionated check. Enable it to
26
+ # tighten draft or AI-written documentation.
27
+ #
28
+ # @example Bad - prose wraps at ~55 columns when 120 are available
29
+ # # Processes the incoming payload and returns a normalized
30
+ # # hash that downstream consumers can rely on for routing.
31
+ # def process(payload)
32
+ # end
33
+ #
34
+ # @example Good - prose fills the available width before wrapping
35
+ # # Processes the incoming payload and returns a normalized hash that downstream
36
+ # # consumers can rely on for routing.
37
+ # def process(payload)
38
+ # end
39
+ #
40
+ # @example Good - semantic line breaks (one sentence per line) are never flagged
41
+ # # Validates the input.
42
+ # # Normalizes the casing.
43
+ # # Persists the record.
44
+ # def call
45
+ # end
46
+ #
47
+ # ## Configuration
48
+ #
49
+ # To enable with custom settings:
50
+ #
51
+ # Documentation/UnderfilledLines:
52
+ # Enabled: true
53
+ # MaxLength: 120
54
+ # MinTrailingSpace: 20
55
+ #
56
+ # ## Reliability
57
+ #
58
+ # Unlike {LineLength} - which measures an objective property (characters over a
59
+ # limit) - "this line should have been longer" is a stylistic judgement. The
60
+ # validator is deliberately conservative and biased toward silence: it would
61
+ # rather miss a case than emit a false positive. Projects that use semantic line
62
+ # breaks (one sentence or clause per line, see https://sembr.org) should leave it
63
+ # off, or add `,` to `SentenceEndChars`.
64
+ #
65
+ # To disable:
66
+ #
67
+ # Documentation/UnderfilledLines:
68
+ # Enabled: false
69
+ module UnderfilledLines
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -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