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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +52 -8
- data/bin/yard-lint +35 -4
- data/lib/yard/lint/config.rb +21 -4
- data/lib/yard/lint/config_loader.rb +31 -6
- data/lib/yard/lint/config_updater.rb +22 -1
- data/lib/yard/lint/config_validator.rb +2 -1
- data/lib/yard/lint/executor/in_process_registry.rb +58 -23
- data/lib/yard/lint/executor/warning_dispatcher.rb +1 -0
- data/lib/yard/lint/git.rb +44 -3
- data/lib/yard/lint/path_grouper.rb +4 -1
- data/lib/yard/lint/results/aggregate.rb +15 -7
- data/lib/yard/lint/stats_calculator.rb +8 -2
- data/lib/yard/lint/templates/default_config.yml +49 -1
- data/lib/yard/lint/templates/strict_config.yml +44 -1
- data/lib/yard/lint/todo_generator.rb +35 -14
- data/lib/yard/lint/validators/base.rb +50 -1
- data/lib/yard/lint/validators/documentation/blank_line_before_definition/validator.rb +17 -2
- data/lib/yard/lint/validators/documentation/empty_comment_line/validator.rb +5 -2
- data/lib/yard/lint/validators/documentation/line_length/validator.rb +1 -11
- data/lib/yard/lint/validators/documentation/markdown_syntax/validator.rb +66 -9
- data/lib/yard/lint/validators/documentation/missing_return/validator.rb +3 -3
- data/lib/yard/lint/validators/documentation/orphaned_doc_comment/validator.rb +79 -11
- data/lib/yard/lint/validators/documentation/orphaned_doc_comment.rb +4 -4
- data/lib/yard/lint/validators/documentation/text_substitution/validator.rb +10 -2
- data/lib/yard/lint/validators/documentation/underfilled_lines/config.rb +36 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/messages_builder.rb +31 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/parser.rb +64 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/result.rb +26 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines/validator.rb +266 -0
- data/lib/yard/lint/validators/documentation/underfilled_lines.rb +74 -0
- data/lib/yard/lint/validators/documentation/undocumented_method_arguments/config.rb +10 -1
- data/lib/yard/lint/validators/documentation/undocumented_method_arguments/parser.rb +18 -4
- data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +38 -4
- data/lib/yard/lint/validators/documentation/undocumented_objects/parser.rb +6 -0
- data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -0
- data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +1 -0
- data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +43 -4
- data/lib/yard/lint/validators/tags/api_tags/validator.rb +11 -1
- data/lib/yard/lint/validators/tags/collection_type/messages_builder.rb +36 -5
- data/lib/yard/lint/validators/tags/collection_type/validator.rb +10 -6
- data/lib/yard/lint/validators/tags/example_style/validator.rb +1 -1
- data/lib/yard/lint/validators/tags/example_syntax/config.rb +6 -1
- data/lib/yard/lint/validators/tags/example_syntax/validator.rb +74 -3
- data/lib/yard/lint/validators/tags/forbidden_tags/validator.rb +7 -3
- data/lib/yard/lint/validators/tags/informal_notation/config.rb +6 -1
- data/lib/yard/lint/validators/tags/informal_notation/validator.rb +24 -3
- data/lib/yard/lint/validators/tags/invalid_types/config.rb +7 -1
- data/lib/yard/lint/validators/tags/invalid_types/parser.rb +22 -5
- data/lib/yard/lint/validators/tags/invalid_types/validator.rb +24 -10
- data/lib/yard/lint/validators/tags/missing_yield/validator.rb +44 -4
- data/lib/yard/lint/validators/tags/missing_yield.rb +8 -8
- data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +13 -1
- data/lib/yard/lint/validators/tags/option_tags/result.rb +1 -0
- data/lib/yard/lint/validators/tags/option_tags/validator.rb +30 -2
- data/lib/yard/lint/validators/tags/order/parser.rb +12 -5
- data/lib/yard/lint/validators/tags/order/validator.rb +7 -2
- data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +21 -8
- data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +12 -5
- data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +9 -7
- data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +8 -2
- data/lib/yard/lint/validators/tags/type_syntax/validator.rb +1 -1
- data/lib/yard/lint/validators/warnings/invalid_directive_format/parser.rb +2 -2
- data/lib/yard/lint/validators/warnings/invalid_tag_format/parser.rb +2 -2
- data/lib/yard/lint/validators/warnings/syntax_error/config.rb +22 -0
- data/lib/yard/lint/validators/warnings/syntax_error/parser.rb +28 -0
- data/lib/yard/lint/validators/warnings/syntax_error/result.rb +27 -0
- data/lib/yard/lint/validators/warnings/syntax_error/validator.rb +15 -0
- data/lib/yard/lint/validators/warnings/syntax_error.rb +34 -0
- data/lib/yard/lint/validators/warnings/unknown_directive/parser.rb +2 -2
- data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +51 -46
- data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +20 -3
- data/lib/yard/lint/validators/warnings/unknown_tag/parser.rb +2 -2
- data/lib/yard/lint/version.rb +1 -1
- data/lib/yard/lint.rb +4 -1
- 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
|
|
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
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|