yard-lint 1.2.3 → 1.3.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -1
  3. data/README.md +98 -4
  4. data/Rakefile +20 -0
  5. data/bin/yard-lint +71 -38
  6. data/lib/yard/lint/config.rb +5 -0
  7. data/lib/yard/lint/config_updater.rb +222 -0
  8. data/lib/yard/lint/errors.rb +6 -0
  9. data/lib/yard/lint/executor/in_process_registry.rb +130 -0
  10. data/lib/yard/lint/executor/query_executor.rb +109 -0
  11. data/lib/yard/lint/executor/result_collector.rb +55 -0
  12. data/lib/yard/lint/executor/warning_dispatcher.rb +79 -0
  13. data/lib/yard/lint/ext/irb_notifier_shim.rb +19 -6
  14. data/lib/yard/lint/results/base.rb +2 -1
  15. data/lib/yard/lint/runner.rb +50 -38
  16. data/lib/yard/lint/templates/default_config.yml +105 -0
  17. data/lib/yard/lint/templates/strict_config.yml +105 -0
  18. data/lib/yard/lint/validators/base.rb +52 -118
  19. data/lib/yard/lint/validators/documentation/blank_line_before_definition/config.rb +25 -0
  20. data/lib/yard/lint/validators/documentation/blank_line_before_definition/messages_builder.rb +39 -0
  21. data/lib/yard/lint/validators/documentation/blank_line_before_definition/parser.rb +59 -0
  22. data/lib/yard/lint/validators/documentation/blank_line_before_definition/result.rb +61 -0
  23. data/lib/yard/lint/validators/documentation/blank_line_before_definition/validator.rb +94 -0
  24. data/lib/yard/lint/validators/documentation/blank_line_before_definition.rb +63 -0
  25. data/lib/yard/lint/validators/documentation/empty_comment_line/config.rb +24 -0
  26. data/lib/yard/lint/validators/documentation/empty_comment_line/messages_builder.rb +34 -0
  27. data/lib/yard/lint/validators/documentation/empty_comment_line/parser.rb +60 -0
  28. data/lib/yard/lint/validators/documentation/empty_comment_line/result.rb +25 -0
  29. data/lib/yard/lint/validators/documentation/empty_comment_line/validator.rb +109 -0
  30. data/lib/yard/lint/validators/documentation/empty_comment_line.rb +58 -0
  31. data/lib/yard/lint/validators/documentation/markdown_syntax/validator.rb +36 -21
  32. data/lib/yard/lint/validators/documentation/markdown_syntax.rb +0 -1
  33. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods/validator.rb +19 -29
  34. data/lib/yard/lint/validators/documentation/undocumented_boolean_methods.rb +0 -1
  35. data/lib/yard/lint/validators/documentation/undocumented_method_arguments/validator.rb +18 -34
  36. data/lib/yard/lint/validators/documentation/undocumented_method_arguments.rb +0 -1
  37. data/lib/yard/lint/validators/documentation/undocumented_objects/validator.rb +17 -25
  38. data/lib/yard/lint/validators/documentation/undocumented_objects.rb +4 -5
  39. data/lib/yard/lint/validators/documentation/undocumented_options/validator.rb +30 -21
  40. data/lib/yard/lint/validators/documentation/undocumented_options.rb +0 -1
  41. data/lib/yard/lint/validators/semantic/abstract_methods/result.rb +2 -2
  42. data/lib/yard/lint/validators/semantic/abstract_methods/validator.rb +31 -43
  43. data/lib/yard/lint/validators/semantic/abstract_methods.rb +0 -1
  44. data/lib/yard/lint/validators/tags/api_tags/validator.rb +24 -39
  45. data/lib/yard/lint/validators/tags/api_tags.rb +0 -1
  46. data/lib/yard/lint/validators/tags/collection_type/validator.rb +37 -66
  47. data/lib/yard/lint/validators/tags/collection_type.rb +0 -1
  48. data/lib/yard/lint/validators/tags/example_syntax/validator.rb +51 -64
  49. data/lib/yard/lint/validators/tags/example_syntax.rb +0 -1
  50. data/lib/yard/lint/validators/tags/informal_notation/config.rb +40 -0
  51. data/lib/yard/lint/validators/tags/informal_notation/messages_builder.rb +35 -0
  52. data/lib/yard/lint/validators/tags/informal_notation/parser.rb +55 -0
  53. data/lib/yard/lint/validators/tags/informal_notation/result.rb +26 -0
  54. data/lib/yard/lint/validators/tags/informal_notation/validator.rb +133 -0
  55. data/lib/yard/lint/validators/tags/informal_notation.rb +45 -0
  56. data/lib/yard/lint/validators/tags/invalid_types/validator.rb +57 -70
  57. data/lib/yard/lint/validators/tags/invalid_types.rb +0 -1
  58. data/lib/yard/lint/validators/tags/meaningless_tag/validator.rb +22 -54
  59. data/lib/yard/lint/validators/tags/meaningless_tag.rb +0 -1
  60. data/lib/yard/lint/validators/tags/non_ascii_type/config.rb +21 -0
  61. data/lib/yard/lint/validators/tags/non_ascii_type/messages_builder.rb +29 -0
  62. data/lib/yard/lint/validators/tags/non_ascii_type/parser.rb +59 -0
  63. data/lib/yard/lint/validators/tags/non_ascii_type/result.rb +25 -0
  64. data/lib/yard/lint/validators/tags/non_ascii_type/validator.rb +50 -0
  65. data/lib/yard/lint/validators/tags/non_ascii_type.rb +39 -0
  66. data/lib/yard/lint/validators/tags/option_tags/result.rb +2 -2
  67. data/lib/yard/lint/validators/tags/option_tags/validator.rb +25 -40
  68. data/lib/yard/lint/validators/tags/option_tags.rb +0 -1
  69. data/lib/yard/lint/validators/tags/order/validator.rb +28 -55
  70. data/lib/yard/lint/validators/tags/order.rb +0 -1
  71. data/lib/yard/lint/validators/tags/redundant_param_description/config.rb +15 -1
  72. data/lib/yard/lint/validators/tags/redundant_param_description/messages_builder.rb +5 -0
  73. data/lib/yard/lint/validators/tags/redundant_param_description/validator.rb +134 -100
  74. data/lib/yard/lint/validators/tags/redundant_param_description.rb +0 -1
  75. data/lib/yard/lint/validators/tags/tag_group_separator/config.rb +29 -0
  76. data/lib/yard/lint/validators/tags/tag_group_separator/messages_builder.rb +49 -0
  77. data/lib/yard/lint/validators/tags/tag_group_separator/parser.rb +67 -0
  78. data/lib/yard/lint/validators/tags/tag_group_separator/result.rb +28 -0
  79. data/lib/yard/lint/validators/tags/tag_group_separator/validator.rb +117 -0
  80. data/lib/yard/lint/validators/tags/tag_group_separator.rb +49 -0
  81. data/lib/yard/lint/validators/tags/tag_type_position/validator.rb +53 -84
  82. data/lib/yard/lint/validators/tags/tag_type_position.rb +0 -1
  83. data/lib/yard/lint/validators/tags/type_syntax/parser.rb +7 -2
  84. data/lib/yard/lint/validators/tags/type_syntax/validator.rb +29 -59
  85. data/lib/yard/lint/validators/tags/type_syntax.rb +0 -1
  86. data/lib/yard/lint/validators/warnings/duplicated_parameter_name/validator.rb +1 -18
  87. data/lib/yard/lint/validators/warnings/invalid_directive_format/validator.rb +1 -18
  88. data/lib/yard/lint/validators/warnings/invalid_tag_format/validator.rb +1 -18
  89. data/lib/yard/lint/validators/warnings/unknown_directive/validator.rb +1 -18
  90. data/lib/yard/lint/validators/warnings/unknown_parameter_name/messages_builder.rb +243 -0
  91. data/lib/yard/lint/validators/warnings/unknown_parameter_name/result.rb +4 -3
  92. data/lib/yard/lint/validators/warnings/unknown_parameter_name/validator.rb +1 -18
  93. data/lib/yard/lint/validators/warnings/unknown_tag/messages_builder.rb +144 -0
  94. data/lib/yard/lint/validators/warnings/unknown_tag/result.rb +4 -3
  95. data/lib/yard/lint/validators/warnings/unknown_tag/validator.rb +1 -18
  96. data/lib/yard/lint/validators/warnings/unknown_tag.rb +10 -0
  97. data/lib/yard/lint/version.rb +1 -1
  98. data/lib/yard/lint.rb +81 -13
  99. data/lib/yard-lint.rb +1 -1
  100. data/renovate.json +1 -8
  101. metadata +38 -2
  102. data/lib/yard/lint/command_cache.rb +0 -93
@@ -9,100 +9,69 @@ module Yard
9
9
  # YARD standard (type_after_name): @param name [String] description
10
10
  # Alternative (type_first): @param name [String] description
11
11
  #
12
- # Note: @return tags are not checked as they don't have parameter names
12
+ # @note @return tags are not checked as they don't have parameter names
13
13
  class Validator < Base
14
- private
15
-
16
- # Runs YARD query to check type position in tags
17
- # @param dir [String] directory where YARD database is stored
18
- # @param file_list_path [String] path to temp file containing file paths (one per line)
19
- # @return [Hash] shell command execution results
20
- def yard_cmd(dir, file_list_path)
21
- # Write query to a temporary file to avoid shell escaping issues
22
- cmd = "cat #{Shellwords.escape(file_list_path)} | xargs yard list --query #{query} "
23
-
24
- Tempfile.create(['yard_query', '.sh']) do |f|
25
- f.write("#!/bin/bash\n")
26
- f.write(cmd)
27
- f.write("#{shell_arguments} -b #{Shellwords.escape(dir)}\n")
28
- f.flush
29
- f.chmod(0o755)
30
-
31
- shell("bash #{Shellwords.escape(f.path)}")
32
- end
33
- end
34
-
35
- # YARD query that checks source code directly instead of docstring.all
36
- # Detects patterns based on configured style
37
- # @return [String] YARD query string
38
- def query
39
- <<~QUERY.strip
40
- '
41
- require "ripper"
42
-
43
- checked_tags = #{checked_tags_array}
44
- enforced_style = "#{enforced_style}"
45
-
46
- # Read the source file and find comment lines for this object
47
- return false unless object.file && File.exist?(object.file)
48
-
49
- source_lines = File.readlines(object.file)
50
- start_line = [object.line - 50, 0].max
51
- end_line = [object.line, source_lines.length - 1].min
52
-
53
- # Look for comments before the object definition
54
- # Start just before the object line and scan backward
55
- (start_line...(end_line - 1)).reverse_each do |line_num|
56
- line = source_lines[line_num].to_s.strip
57
-
58
- # Skip empty lines
59
- next if line.empty?
60
-
61
- # Stop if we hit code (non-comment line)
62
- break unless line.start_with?("#")
63
-
64
- # Skip comment-only lines without tags
65
- next unless line.include?("@")
66
-
67
- checked_tags.each do |tag_name|
68
- if enforced_style == "type_first"
69
- # Detect: @tag_name word [Type] (violation when type_first is enforced)
70
- pattern = /@\#{tag_name}\\s+(\\w+)\\s+\\[([^\\]]+)\\]/
71
- if line =~ pattern
72
- param_name = $1
73
- type_info = $2
74
- puts object.file + ":" + (line_num + 1).to_s + ": " + object.title
75
- puts tag_name + "|" + param_name + "|" + type_info + "|type_after_name"
76
- end
77
- else
78
- # Detect: @tag_name [Type] word (violation when type_after_name is enforced)
79
- pattern = /@\#{tag_name}\\s+\\[([^\\]]+)\\]\\s+(\\w+)/
80
- if line =~ pattern
81
- type_info = $1
82
- param_name = $2
83
- puts object.file + ":" + (line_num + 1).to_s + ": " + object.title
84
- puts tag_name + "|" + param_name + "|" + type_info + "|type_first"
85
- end
14
+ # Enable in-process execution
15
+ in_process visibility: :public
16
+
17
+ # Execute query for a single object during in-process execution.
18
+ # Checks type annotation position in @param and @option tags.
19
+ # @param object [YARD::CodeObjects::Base] the code object to query
20
+ # @param collector [Executor::ResultCollector] collector for output
21
+ # @return [void]
22
+ def in_process_query(object, collector)
23
+ return unless object.file && File.exist?(object.file)
24
+
25
+ checked_tags = config_or_default('CheckedTags')
26
+ style = enforced_style
27
+
28
+ source_lines = File.readlines(object.file)
29
+ start_line = [object.line - 50, 0].max
30
+ end_line = [object.line, source_lines.length - 1].min
31
+
32
+ # Look for comments before the object definition
33
+ (start_line...(end_line - 1)).reverse_each do |line_num|
34
+ line = source_lines[line_num].to_s.strip
35
+
36
+ # Skip empty lines
37
+ next if line.empty?
38
+
39
+ # Stop if we hit code (non-comment line)
40
+ break unless line.start_with?('#')
41
+
42
+ # Skip comment-only lines without tags
43
+ next unless line.include?('@')
44
+
45
+ checked_tags.each do |tag_name|
46
+ if style == 'type_first'
47
+ # Detect: @tag_name word [Type] (violation when type_first is enforced)
48
+ pattern = /@#{tag_name}\s+(\w+)\s+\[([^\]]+)\]/
49
+ if line =~ pattern
50
+ param_name = ::Regexp.last_match(1)
51
+ type_info = ::Regexp.last_match(2)
52
+ collector.puts "#{object.file}:#{line_num + 1}: #{object.title}"
53
+ collector.puts "#{tag_name}|#{param_name}|#{type_info}|type_after_name"
54
+ end
55
+ else
56
+ # Detect: @tag_name [Type] word (violation when type_after_name is enforced)
57
+ pattern = /@#{tag_name}\s+\[([^\]]+)\]\s+(\w+)/
58
+ if line =~ pattern
59
+ type_info = ::Regexp.last_match(1)
60
+ param_name = ::Regexp.last_match(2)
61
+ collector.puts "#{object.file}:#{line_num + 1}: #{object.title}"
62
+ collector.puts "#{tag_name}|#{param_name}|#{type_info}|type_first"
86
63
  end
87
64
  end
88
65
  end
89
-
90
- false
91
- '
92
- QUERY
66
+ end
93
67
  end
94
68
 
69
+ private
70
+
95
71
  # @return [String] the enforced style ('type_after_name' (standard) or 'type_first')
96
72
  def enforced_style
97
73
  config_or_default('EnforcedStyle')
98
74
  end
99
-
100
- # Array of tag names to check, formatted for YARD query
101
- # @return [String] Ruby array literal string
102
- def checked_tags_array
103
- tags = config_or_default('CheckedTags')
104
- "[#{tags.map { |t| "\"#{t}\"" }.join(',')}]"
105
- end
106
75
  end
107
76
  end
108
77
  end
@@ -42,7 +42,6 @@ module Yard
42
42
  #
43
43
  # Tags/TagTypePosition:
44
44
  # Enabled: false
45
- #
46
45
  module TagTypePosition
47
46
  end
48
47
  end
@@ -16,9 +16,14 @@ module Yard
16
16
  # @option _kwargs [Object] :unused this parameter accepts no options (reserved for future use)
17
17
  # @return [Array<Hash>] array with violation details
18
18
  def call(yard_output, **_kwargs)
19
- return [] if yard_output.nil? || yard_output.strip.empty?
19
+ return [] if yard_output.nil?
20
20
 
21
- lines = yard_output.split("\n").map(&:strip).reject(&:empty?)
21
+ # Handle encoding issues from YARD output that may contain invalid UTF-8 sequences
22
+ # This can happen when YARD processes files with non-ASCII characters in type specs
23
+ sanitized = yard_output.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
24
+ return [] if sanitized.strip.empty?
25
+
26
+ lines = sanitized.split("\n").map(&:strip).reject(&:empty?)
22
27
  violations = []
23
28
 
24
29
  lines.each_slice(2) do |location_line, details_line|
@@ -7,66 +7,36 @@ module Yard
7
7
  module TypeSyntax
8
8
  # Runs YARD to validate type syntax using TypesExplainer::Parser
9
9
  class Validator < Base
10
- private
11
-
12
- # Runs YARD query to validate type syntax on given files
13
- # @param dir [String] directory where YARD database is stored
14
- # @param file_list_path [String] path to temp file containing file paths (one per line)
15
- # @return [Hash] shell command execution results
16
- def yard_cmd(dir, file_list_path)
17
- # Write query to a temporary file to avoid shell escaping issues
18
- cmd = "cat #{Shellwords.escape(file_list_path)} | xargs yard list --query #{query} "
19
-
20
- Tempfile.create(['yard_query', '.sh']) do |f|
21
- f.write("#!/bin/bash\n")
22
- f.write(cmd)
23
- f.write("#{shell_arguments} -b #{Shellwords.escape(dir)}\n")
24
- f.flush
25
- f.chmod(0o755)
26
-
27
- shell("bash #{Shellwords.escape(f.path)}")
28
- end
29
- end
30
-
31
- # YARD query that validates type syntax for each tag
32
- # Format output as two lines per violation:
33
- # Line 1: file.rb:LINE: ClassName#method_name
34
- # Line 2: tag_name|type_string|error_message
35
- # @return [String] YARD query string
36
- def query
37
- <<~QUERY.strip
38
- '
39
- require "yard"
40
-
41
- docstring
42
- .tags
43
- .select { |tag| #{validated_tags_array}.include?(tag.tag_name) }
44
- .each do |tag|
45
- next unless tag.types
46
-
47
- tag.types.each do |type_str|
48
- begin
49
- YARD::Tags::TypesExplainer::Parser.parse(type_str)
50
- rescue SyntaxError => e
51
- puts object.file + ":" + object.line.to_s + ": " + object.title
52
- puts tag.tag_name + "|" + type_str + "|" + e.message
53
- break
54
- end
55
- end
10
+ # Enable in-process execution
11
+ in_process visibility: :public
12
+
13
+ # Execute query for a single object during in-process execution.
14
+ # Validates type syntax in tags using YARD's TypesExplainer::Parser.
15
+ # @param object [YARD::CodeObjects::Base] the code object to query
16
+ # @param collector [Executor::ResultCollector] collector for output
17
+ # @return [void]
18
+ def in_process_query(object, collector)
19
+ validated_tags = config.validator_config('Tags/TypeSyntax', 'ValidatedTags') ||
20
+ %w[param option return yieldreturn]
21
+
22
+ object.docstring.tags
23
+ .select { |tag| validated_tags.include?(tag.tag_name) }
24
+ .each do |tag|
25
+ next unless tag.types
26
+
27
+ tag.types.each do |type_str|
28
+ begin
29
+ YARD::Tags::TypesExplainer::Parser.parse(type_str)
30
+ rescue SyntaxError => e
31
+ # Sanitize error message to handle invalid UTF-8 sequences
32
+ # YARD's parser may generate malformed error messages for non-ASCII input
33
+ error_msg = e.message.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
34
+ collector.puts "#{object.file}:#{object.line}: #{object.title}"
35
+ collector.puts "#{tag.tag_name}|#{type_str}|#{error_msg}"
36
+ break
56
37
  end
57
-
58
- false
59
- '
60
- QUERY
61
- end
62
-
63
- # Array of tag names to validate, formatted for YARD query
64
- # @return [String] Ruby array literal string
65
- def validated_tags_array
66
- tags = config.validator_config('Tags/TypeSyntax', 'ValidatedTags') || %w[
67
- param option return yieldreturn
68
- ]
69
- "[#{tags.map { |t| "\"#{t}\"" }.join(',')}]"
38
+ end
39
+ end
70
40
  end
71
41
  end
72
42
  end
@@ -29,7 +29,6 @@ module Yard
29
29
  #
30
30
  # Tags/TypeSyntax:
31
31
  # Enabled: false
32
- #
33
32
  module TypeSyntax
34
33
  end
35
34
  end
@@ -5,25 +5,8 @@ module Yard
5
5
  module Validators
6
6
  module Warnings
7
7
  module DuplicatedParameterName
8
- # Runs YARD stats command to check for duplicatedparametername
8
+ # Validator for detecting duplicated parameter name warnings from YARD
9
9
  class Validator < Base
10
- private
11
-
12
- # Runs YARD stats command with proper settings on a given dir and files
13
- # @param dir [String] dir where we should generate the temp docs
14
- # @param file_list_path [String] path to temp file containing file paths (one per line)
15
- # @return [Hash] shell command execution hash results
16
- def yard_cmd(dir, file_list_path)
17
- cmd = <<~CMD
18
- cat #{Shellwords.escape(file_list_path)} | xargs yard stats \
19
- #{shell_arguments} \
20
- --compact \
21
- -b #{Shellwords.escape(dir)}
22
- CMD
23
- cmd = cmd.tr("\n", ' ')
24
-
25
- shell(cmd)
26
- end
27
10
  end
28
11
  end
29
12
  end
@@ -5,25 +5,8 @@ module Yard
5
5
  module Validators
6
6
  module Warnings
7
7
  module InvalidDirectiveFormat
8
- # Runs YARD stats command to check for invaliddirectiveformat
8
+ # Validator for detecting invalid directive format warnings from YARD
9
9
  class Validator < Base
10
- private
11
-
12
- # Runs YARD stats command with proper settings on a given dir and files
13
- # @param dir [String] dir where we should generate the temp docs
14
- # @param file_list_path [String] path to temp file containing file paths (one per line)
15
- # @return [Hash] shell command execution hash results
16
- def yard_cmd(dir, file_list_path)
17
- cmd = <<~CMD
18
- cat #{Shellwords.escape(file_list_path)} | xargs yard stats \
19
- #{shell_arguments} \
20
- --compact \
21
- -b #{Shellwords.escape(dir)}
22
- CMD
23
- cmd = cmd.tr("\n", ' ')
24
-
25
- shell(cmd)
26
- end
27
10
  end
28
11
  end
29
12
  end
@@ -5,25 +5,8 @@ module Yard
5
5
  module Validators
6
6
  module Warnings
7
7
  module InvalidTagFormat
8
- # Runs YARD stats command to check for invalidtagformat
8
+ # Validator for detecting invalid tag format warnings from YARD
9
9
  class Validator < Base
10
- private
11
-
12
- # Runs YARD stats command with proper settings on a given dir and files
13
- # @param dir [String] dir where we should generate the temp docs
14
- # @param file_list_path [String] path to temp file containing file paths (one per line)
15
- # @return [Hash] shell command execution hash results
16
- def yard_cmd(dir, file_list_path)
17
- cmd = <<~CMD
18
- cat #{Shellwords.escape(file_list_path)} | xargs yard stats \
19
- #{shell_arguments} \
20
- --compact \
21
- -b #{Shellwords.escape(dir)}
22
- CMD
23
- cmd = cmd.tr("\n", ' ')
24
-
25
- shell(cmd)
26
- end
27
10
  end
28
11
  end
29
12
  end
@@ -5,25 +5,8 @@ module Yard
5
5
  module Validators
6
6
  module Warnings
7
7
  module UnknownDirective
8
- # Runs YARD stats command to check for unknowndirective
8
+ # Validator for detecting unknown directive warnings from YARD
9
9
  class Validator < Base
10
- private
11
-
12
- # Runs YARD stats command with proper settings on a given dir and files
13
- # @param dir [String] dir where we should generate the temp docs
14
- # @param file_list_path [String] path to temp file containing file paths (one per line)
15
- # @return [Hash] shell command execution hash results
16
- def yard_cmd(dir, file_list_path)
17
- cmd = <<~CMD
18
- cat #{Shellwords.escape(file_list_path)} | xargs yard stats \
19
- #{shell_arguments} \
20
- --compact \
21
- -b #{Shellwords.escape(dir)}
22
- CMD
23
- cmd = cmd.tr("\n", ' ')
24
-
25
- shell(cmd)
26
- end
27
10
  end
28
11
  end
29
12
  end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'did_you_mean'
4
+ require 'shellwords'
5
+
6
+ module Yard
7
+ module Lint
8
+ module Validators
9
+ module Warnings
10
+ module UnknownParameterName
11
+ # Builds enhanced messages with "did you mean" suggestions
12
+ class MessagesBuilder
13
+ class << self
14
+ # Build message with suggestion for unknown parameter
15
+ # @param offense [Hash] offense data with :message, :location (file), :line keys
16
+ # @return [String] formatted message with suggestion if available
17
+ def call(offense)
18
+ message = offense[:message] || 'UnknownParameterName detected'
19
+
20
+ # Extract the unknown parameter name from the message
21
+ # Format: "@param tag has unknown parameter name: param_name"
22
+ match = message.match(/@param tag has unknown parameter name: (\w+)/)
23
+ return message unless match
24
+
25
+ unknown_param = match[1]
26
+
27
+ # Get actual parameters for the method at this location
28
+ # Note: offense[:location] contains the file path
29
+ file = offense[:location]
30
+ line = offense[:line]
31
+ actual_params = fetch_actual_parameters(file, line)
32
+ return message if actual_params.empty?
33
+
34
+ # Find best suggestion using did_you_mean
35
+ suggestion = find_suggestion(unknown_param, actual_params)
36
+
37
+ if suggestion
38
+ "#{message} (did you mean '#{suggestion}'?)"
39
+ else
40
+ message
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Fetch actual method parameters from YARD at the given location
47
+ # @param file [String] file path
48
+ # @param line [Integer, String] line number
49
+ # @return [Array<String>] array of actual parameter names
50
+ def fetch_actual_parameters(file, line)
51
+ return [] unless file && line
52
+
53
+ line_num = line.to_i
54
+
55
+ # First, try to parse directly from the Ruby source file
56
+ # This is faster and doesn't require YARD to be fully loaded
57
+ params = parse_parameters_from_source(file, line_num)
58
+ return params unless params.empty?
59
+
60
+ # Fallback: Query YARD list for the method
61
+ # This requires YARD to parse the file first
62
+ fetch_parameters_via_yard(file, line_num)
63
+ rescue StandardError => e
64
+ # If anything goes wrong, just return empty array (no suggestion)
65
+ warn "Failed to fetch parameters: #{e.message}" if ENV['DEBUG']
66
+ []
67
+ end
68
+
69
+ # Parse method parameters directly from Ruby source file
70
+ # @param file [String] file path
71
+ # @param line [Integer] line number (approximate location of method)
72
+ # @return [Array<String>] array of parameter names
73
+ def parse_parameters_from_source(file, line)
74
+ return [] unless File.exist?(file)
75
+
76
+ # Calculate the search range (line numbers are 1-indexed)
77
+ start_line = [(line - 15), 1].max
78
+ end_line = line + 5
79
+
80
+ # Only read the lines in the relevant range to avoid loading the whole file
81
+ lines = []
82
+ current_line_num = 1
83
+ File.foreach(file) do |source_line|
84
+ lines << source_line if current_line_num.between?(start_line, end_line)
85
+ break if current_line_num > end_line
86
+
87
+ current_line_num += 1
88
+ end
89
+
90
+ # Search for method definition in the collected lines
91
+ in_multiline_def = false
92
+ param_lines = []
93
+
94
+ lines.each do |source_line|
95
+ # Match single-line method definitions: def method_name(param1, param2)
96
+ if source_line =~ /^\s*def\s+\w+\s*\((.*?)\)/
97
+ params_str = ::Regexp.last_match(1)
98
+ return extract_parameter_names(params_str)
99
+ # Match start of multi-line method definition: def method_name(
100
+ elsif source_line =~ /^\s*def\s+\w+\s*\((.*)$/
101
+ in_multiline_def = true
102
+ param_lines << ::Regexp.last_match(1)
103
+ next
104
+ elsif in_multiline_def
105
+ param_lines << source_line.strip
106
+ # Check if this line closes the parameter list
107
+ if source_line.include?(')')
108
+ # Join all lines and extract params
109
+ params_str = param_lines.join(' ')
110
+ # Remove trailing ')' and anything after it
111
+ params_str = params_str[/\A(.*?)\)/, 1] || params_str
112
+ return extract_parameter_names(params_str)
113
+ end
114
+ elsif source_line.match?(/^\s*def\s+\w+\s*$/)
115
+ # Method with no parameters
116
+ return []
117
+ end
118
+ end
119
+
120
+ []
121
+ rescue StandardError => e
122
+ warn "Failed to parse source: #{e.message}" if ENV['DEBUG']
123
+ []
124
+ end
125
+
126
+ # Extract parameter names from a parameter string
127
+ # Handles various parameter formats: regular, default values, splat, keyword, block
128
+ # @param params_str [String] parameter string from method signature
129
+ # @return [Array<String>] array of parameter names
130
+ def extract_parameter_names(params_str)
131
+ return [] if params_str.nil? || params_str.strip.empty?
132
+
133
+ params_str.split(',').map do |param|
134
+ # Remove default values: "name = 'default'" => "name"
135
+ param = param.split('=').first
136
+ # Remove type annotations: "name:" => "name"
137
+ param = param.delete(':')
138
+ # Remove splat and block symbols: "*args", "**kwargs", "&block"
139
+ param = param.delete('*&')
140
+ # Strip whitespace
141
+ param.strip
142
+ end.reject(&:empty?)
143
+ end
144
+
145
+ # Fetch parameters via YARD list command (fallback method)
146
+ # @param file [String] file path
147
+ # @param line [Integer] line number
148
+ # @return [Array<String>] array of parameter names
149
+ def fetch_parameters_via_yard(file, line)
150
+ # Query YARD for the method at this location
151
+ # Use Shellwords.escape to prevent command injection
152
+ escaped_file = Shellwords.escape(file)
153
+ query = "'type == :method && file == \"#{escaped_file}\" && line >= #{line - 15} && line <= #{line + 5}'"
154
+ cmd = "yard list --query #{query} 2>/dev/null"
155
+
156
+ output = `#{cmd}`.strip
157
+ return [] if output.empty?
158
+
159
+ # YARD list doesn't show parameters, we'd need to parse the source
160
+ # So this fallback is just for validation - use source parsing instead
161
+ []
162
+ rescue StandardError => e
163
+ warn "Failed to query YARD: #{e.message}" if ENV['DEBUG']
164
+ []
165
+ end
166
+
167
+ # Find the best suggestion using DidYouMean spell checker
168
+ # @param unknown_param [String] the unknown parameter name
169
+ # @param actual_params [Array<String>] array of actual parameter names
170
+ # @return [String, nil] suggested parameter name or nil
171
+ def find_suggestion(unknown_param, actual_params)
172
+ return nil if actual_params.empty?
173
+
174
+ # Use DidYouMean::SpellChecker for smart suggestions
175
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: actual_params)
176
+ suggestions = spell_checker.correct(unknown_param)
177
+
178
+ # If DidYouMean found suggestions, return the best one
179
+ return suggestions.first unless suggestions.empty?
180
+
181
+ # Otherwise, fallback to Levenshtein distance
182
+ find_suggestion_fallback(unknown_param, actual_params)
183
+ rescue StandardError => e
184
+ # Fallback to simple Levenshtein distance if DidYouMean fails
185
+ warn "DidYouMean failed: #{e.message}, using fallback" if ENV['DEBUG']
186
+ find_suggestion_fallback(unknown_param, actual_params)
187
+ end
188
+
189
+ # Fallback suggestion finder using simple Levenshtein distance
190
+ # @param unknown_param [String] the unknown parameter name
191
+ # @param actual_params [Array<String>] array of actual parameter names
192
+ # @return [String, nil] suggested parameter name or nil
193
+ def find_suggestion_fallback(unknown_param, actual_params)
194
+ # Calculate Levenshtein distance for each parameter
195
+ distances = actual_params.map do |param|
196
+ [param, levenshtein_distance(unknown_param, param)]
197
+ end
198
+
199
+ # Sort by distance and get the closest match
200
+ best_match = distances.min_by { |_param, distance| distance }
201
+
202
+ # Only suggest if the distance is reasonable (less than half the length)
203
+ return nil unless best_match
204
+
205
+ param, distance = best_match
206
+ max_distance = [unknown_param.length, param.length].max / 2
207
+
208
+ distance <= max_distance ? param : nil
209
+ end
210
+
211
+ # Calculate Levenshtein distance between two strings
212
+ # @param str1 [String] first string
213
+ # @param str2 [String] second string
214
+ # @return [Integer] Levenshtein distance
215
+ def levenshtein_distance(str1, str2)
216
+ return str2.length if str1.empty?
217
+ return str1.length if str2.empty?
218
+
219
+ matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1) }
220
+
221
+ (0..str1.length).each { |i| matrix[i][0] = i }
222
+ (0..str2.length).each { |j| matrix[0][j] = j }
223
+
224
+ (1..str1.length).each do |i|
225
+ (1..str2.length).each do |j|
226
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
227
+ matrix[i][j] = [
228
+ matrix[i - 1][j] + 1, # deletion
229
+ matrix[i][j - 1] + 1, # insertion
230
+ matrix[i - 1][j - 1] + cost # substitution
231
+ ].min
232
+ end
233
+ end
234
+
235
+ matrix[str1.length][str2.length]
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -12,10 +12,11 @@ module Yard
12
12
  self.offense_name = 'UnknownParameterName'
13
13
 
14
14
  # Build human-readable message for UnknownParameterName offense
15
- # @param offense [Hash] offense data with :message key
16
- # @return [String] formatted message
15
+ # Uses MessagesBuilder to add "did you mean" suggestions
16
+ # @param offense [Hash] offense data with :message, :location, :line keys
17
+ # @return [String] formatted message with suggestion if available
17
18
  def build_message(offense)
18
- offense[:message] || 'UnknownParameterName detected'
19
+ MessagesBuilder.call(offense)
19
20
  end
20
21
  end
21
22
  end