rubocop-spellcheck 0.1.0 → 0.2.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 +17 -0
- data/README.md +152 -45
- data/config/default.yml +11 -1
- data/lib/rubocop/cop/spellcheck/spelling.rb +49 -45
- data/lib/rubocop/spellcheck/asciidoc.rb +120 -0
- data/lib/rubocop/spellcheck/asciidoc_inline.rb +89 -0
- data/lib/rubocop/spellcheck/asciidoc_table.rb +49 -0
- data/lib/rubocop/spellcheck/checker.rb +6 -6
- data/lib/rubocop/spellcheck/correction.rb +66 -0
- data/lib/rubocop/spellcheck/front_matter.rb +49 -6
- data/lib/rubocop/spellcheck/front_matter_paths.rb +37 -0
- data/lib/rubocop/spellcheck/markdown.rb +34 -14
- data/lib/rubocop/spellcheck/markdown_directives.rb +83 -0
- data/lib/rubocop/spellcheck/markdown_ruby.rb +94 -0
- data/lib/rubocop/spellcheck/plugin.rb +1 -1
- data/lib/rubocop/spellcheck/prose_source.rb +1 -1
- data/lib/rubocop/spellcheck/rdoc.rb +140 -0
- data/lib/rubocop/spellcheck/rdoc_comments.rb +66 -0
- data/lib/rubocop/spellcheck/ruby_code.rb +37 -0
- data/lib/rubocop/spellcheck/ruby_identifiers.rb +54 -0
- data/lib/rubocop/spellcheck/ruby_string_map.rb +89 -0
- data/lib/rubocop/spellcheck/ruby_strings.rb +163 -0
- data/lib/rubocop/spellcheck/span.rb +35 -0
- data/lib/rubocop/spellcheck/version.rb +1 -1
- data/lib/rubocop/spellcheck/vocabulary.rb +94 -0
- data/lib/rubocop/spellcheck/vocabulary_config.rb +72 -0
- data/lib/rubocop/spellcheck/vocabulary_formatter.rb +47 -0
- data/lib/rubocop-spellcheck.rb +17 -0
- metadata +50 -5
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rdoc'
|
|
4
|
+
require 'rdoc/markup'
|
|
5
|
+
require 'rdoc/markup/inline_parser'
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Spellcheck
|
|
9
|
+
# Reads RDoc prose without checking verbatim examples or inline code.
|
|
10
|
+
class RDoc
|
|
11
|
+
# Records the original tokens consumed by paragraph and heading parsing.
|
|
12
|
+
class Parser < ::RDoc::Markup::Parser
|
|
13
|
+
attr_reader :prose
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
super
|
|
17
|
+
@prose = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_paragraph(margin)
|
|
21
|
+
@margin = margin
|
|
22
|
+
super
|
|
23
|
+
ensure
|
|
24
|
+
@margin = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_heading(level)
|
|
28
|
+
@heading = true
|
|
29
|
+
super
|
|
30
|
+
ensure
|
|
31
|
+
@heading = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get
|
|
35
|
+
token = super
|
|
36
|
+
type, text, column, line = token
|
|
37
|
+
@prose[[line, column]] = text.dup if type == :TEXT && (@heading || column == @margin)
|
|
38
|
+
token
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Uses consumed inline token ranges to retain original source positions.
|
|
43
|
+
class Inline < ::RDoc::Markup::InlineParser
|
|
44
|
+
def initialize(source)
|
|
45
|
+
super
|
|
46
|
+
@source = source
|
|
47
|
+
@masked = source.dup
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def prose
|
|
51
|
+
parse
|
|
52
|
+
@masked.gsub(/(?:link:|rdoc-(?:ref|label|image):)\S+/) { |link| ' ' * link.length }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def scan_token
|
|
58
|
+
first = @scanner.pos
|
|
59
|
+
result = super
|
|
60
|
+
raw = @source.byteslice(first...@scanner.pos)
|
|
61
|
+
offset = @source.byteslice(0...first).length
|
|
62
|
+
mask_token(result, raw, offset)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def mask_token(result, raw, offset)
|
|
67
|
+
type, _token, node = result
|
|
68
|
+
case type
|
|
69
|
+
when :node
|
|
70
|
+
mask_node(node, raw, offset)
|
|
71
|
+
when :open, :close, :tidylink_open, :tidylink_close, :invalidated_tidylink_close
|
|
72
|
+
mask(offset, raw.length)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mask_node(node, raw, offset)
|
|
77
|
+
return mask(offset, raw.length) if %i[TT HARD_BREAK].include?(node[:type])
|
|
78
|
+
|
|
79
|
+
text = node[:children].join
|
|
80
|
+
first = raw.index(text)
|
|
81
|
+
mask(offset, first)
|
|
82
|
+
mask(offset + first + text.length, raw.length - first - text.length)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def mask(first, length)
|
|
86
|
+
@masked[first, length] = @masked[first, length].gsub(/[^\n]/, ' ')
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.file?(path)
|
|
91
|
+
path && File.extname(path).casecmp?('.rdoc')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initialize(buffer, source: buffer.source, positions: nil)
|
|
95
|
+
@buffer = buffer
|
|
96
|
+
@source = source
|
|
97
|
+
@positions = positions || source.length.times.map { |index| [index, index + 1] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def each_span
|
|
101
|
+
return enum_for(__method__) unless block_given?
|
|
102
|
+
|
|
103
|
+
yield Span.new(Inline.new(prose_source).prose, @positions)
|
|
104
|
+
rescue ::RDoc::Markup::Parser::ParseError => e
|
|
105
|
+
raise RuboCop::ValidationError, "Invalid RDoc in #{@buffer.name}: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def prose_source
|
|
111
|
+
source = @source.gsub(/[^\n]/, ' ')
|
|
112
|
+
offsets = line_offsets
|
|
113
|
+
prose.each do |(line, column), text|
|
|
114
|
+
source[offsets.fetch(line) + column, text.length] = text
|
|
115
|
+
end
|
|
116
|
+
source
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def prose
|
|
120
|
+
parser = Parser.new
|
|
121
|
+
parser.tokenize(mask_directives)
|
|
122
|
+
parser.parse(::RDoc::Markup::Document.new)
|
|
123
|
+
parser.prose
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def mask_directives
|
|
127
|
+
@source.gsub(/^[ \t]*:[a-z][\w-]*:.*$/i) { |text| ' ' * text.length }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def line_offsets
|
|
131
|
+
offset = 0
|
|
132
|
+
@source.lines.map do |line|
|
|
133
|
+
first = offset
|
|
134
|
+
offset += line.length
|
|
135
|
+
first
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Combines adjacent Ruby comment lines before parsing their RDoc structure.
|
|
6
|
+
class RDocComments
|
|
7
|
+
def initialize(buffer, comments)
|
|
8
|
+
@buffer = buffer
|
|
9
|
+
@comments = comments
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each_span(&block)
|
|
13
|
+
return enum_for(__method__) unless block_given?
|
|
14
|
+
|
|
15
|
+
groups.each do |comments|
|
|
16
|
+
source, positions = comment_source(comments)
|
|
17
|
+
RDoc.new(@buffer, source: source, positions: positions).each_span(&block)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def groups
|
|
24
|
+
@comments.slice_when do |previous, current|
|
|
25
|
+
current.loc.line != previous.loc.last_line + 1 || current.text.start_with?('=begin') ||
|
|
26
|
+
previous.text.start_with?('=begin')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def comment_source(comments)
|
|
31
|
+
source = +''
|
|
32
|
+
positions = []
|
|
33
|
+
comments.each do |comment|
|
|
34
|
+
text, offsets = mapped_comment(comment)
|
|
35
|
+
source << text
|
|
36
|
+
positions.concat(offsets)
|
|
37
|
+
source << "\n"
|
|
38
|
+
positions << [comment.source_range.end_pos, comment.source_range.end_pos]
|
|
39
|
+
end
|
|
40
|
+
[source, positions]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mapped_comment(comment)
|
|
44
|
+
first = comment.source_range.begin_pos
|
|
45
|
+
pieces = comment.text.lines.map do |line|
|
|
46
|
+
text, removed = strip_marker(line)
|
|
47
|
+
offsets = positions(first + removed, text.length)
|
|
48
|
+
first += line.length
|
|
49
|
+
[text, offsets]
|
|
50
|
+
end
|
|
51
|
+
[pieces.map(&:first).join, pieces.flat_map(&:last)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def positions(first, length)
|
|
55
|
+
length.times.map { |index| [first + index, first + index + 1] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def strip_marker(line)
|
|
59
|
+
return [line.gsub(/[^\n]/, ' '), 0] if line.match?(/\A=(?:begin|end)\b/)
|
|
60
|
+
|
|
61
|
+
marker = line[/\A# ?/].to_s.length
|
|
62
|
+
[line[marker..], marker]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Applies independent Ruby scope settings to source-aware extractors.
|
|
6
|
+
class RubyCode
|
|
7
|
+
DIRECTIVE = /
|
|
8
|
+
\A\#\s*(?:!|(?:rubocop|standard|coding|encoding|frozen_string_literal|
|
|
9
|
+
warn_indent|shareable_constant_value|typed)\s*:)|\A\#.*-\*-\s*(?:coding|encoding):
|
|
10
|
+
/x
|
|
11
|
+
|
|
12
|
+
def initialize(source, config: {})
|
|
13
|
+
valid = config.is_a?(Hash) &&
|
|
14
|
+
%w[Strings Identifiers].all? { |key| [true, false].include?(config.fetch(key, true)) }
|
|
15
|
+
unless valid
|
|
16
|
+
raise RuboCop::ValidationError, 'Spellcheck/Spelling Ruby Strings and Identifiers must be true or false.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@source = source
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each_span(&)
|
|
24
|
+
comments = @source.comments.reject { |comment| DIRECTIVE.match?(comment.text) }
|
|
25
|
+
RDocComments.new(@source.buffer, comments).each_span(&)
|
|
26
|
+
each_code_span(&)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each_code_span(&)
|
|
30
|
+
return unless @source.ast
|
|
31
|
+
|
|
32
|
+
RubyStrings.new(@source).each_span(&) if @config.fetch('Strings', true)
|
|
33
|
+
RubyIdentifiers.new(@source).each_span(&) if @config.fetch('Identifiers', false)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Splits Ruby names without treating symbols, keywords, or operators as identifiers.
|
|
6
|
+
class RubyIdentifiers
|
|
7
|
+
# Identifier occurrences cannot be renamed independently of their references.
|
|
8
|
+
class Span < Spellcheck::Span
|
|
9
|
+
def correction(word, suggestions)
|
|
10
|
+
Correction.new(word, suggestions)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Keeps diagnostics and suggestions without offering identifier replacements.
|
|
15
|
+
class Correction < Spellcheck::Correction
|
|
16
|
+
def replacement; end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
TYPES = %i[tIDENTIFIER tFID tCONSTANT tIVAR tCVAR tGVAR].freeze
|
|
20
|
+
BOUNDARY = /(?<=\p{Ll})(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})/
|
|
21
|
+
|
|
22
|
+
def initialize(source)
|
|
23
|
+
@source = source
|
|
24
|
+
@parameters = source.ast.each_node(:kwarg, :kwoptarg).to_set { |node| node.loc.name.begin_pos }
|
|
25
|
+
@tokens = source.tokens.select { |token| identifier?(token) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def each_span(&block)
|
|
29
|
+
return enum_for(__method__) unless block_given?
|
|
30
|
+
|
|
31
|
+
@tokens.each do |token|
|
|
32
|
+
token.text.to_enum(:scan, /[\p{L}\p{M}]+/).each do
|
|
33
|
+
match = Regexp.last_match
|
|
34
|
+
split(match[0], token.pos.begin_pos + match.begin(0), &block)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def identifier?(token)
|
|
42
|
+
TYPES.include?(token.type) || (token.type == :tLABEL && @parameters.include?(token.pos.begin_pos))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def split(name, first)
|
|
46
|
+
name.split(BOUNDARY).each do |word|
|
|
47
|
+
positions = word.length.times.map { |index| [first + index, first + index + 1] }
|
|
48
|
+
yield Span.new(word, positions)
|
|
49
|
+
first += word.length
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Uses Ruby's parser, never evaluation, to decode escape sequences and retain source ranges.
|
|
6
|
+
class RubyStringMap
|
|
7
|
+
ESCAPE = /\\(?:u\{[0-9a-fA-F\s]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2}|
|
|
8
|
+
[0-7]{1,3}|(?:M-|C-|c)(?:\\(?:M-|C-|c))*\\?.|.)/mx
|
|
9
|
+
PIECE = /#{ESCAPE}|./m
|
|
10
|
+
|
|
11
|
+
def initialize(version)
|
|
12
|
+
@version = version
|
|
13
|
+
@escapes = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def positions(raw, first, expected, single:)
|
|
17
|
+
decoded, bytes = decoded_bytes(raw, first, single)
|
|
18
|
+
bytes = without_indentation(decoded, bytes, expected.b)
|
|
19
|
+
character_positions(expected, bytes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def decoded_bytes(raw, first, single)
|
|
25
|
+
decoded = +''.b
|
|
26
|
+
bytes = []
|
|
27
|
+
raw.scan(pattern(single)) do |piece|
|
|
28
|
+
match = Regexp.last_match
|
|
29
|
+
piece = decode(piece, single)
|
|
30
|
+
decoded << piece.b
|
|
31
|
+
piece.bytesize.times { bytes << [first + match.begin(0), first + match.end(0)] }
|
|
32
|
+
end
|
|
33
|
+
[decoded, bytes]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pattern(single)
|
|
37
|
+
return PIECE unless single
|
|
38
|
+
return /./m if single.empty?
|
|
39
|
+
|
|
40
|
+
/\\[#{Regexp.escape(single)}]|./m
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def decode(piece, single)
|
|
44
|
+
return piece unless piece.start_with?('\\') && piece.length > 1
|
|
45
|
+
return piece.delete_prefix('\\') if single&.include?(piece[1])
|
|
46
|
+
return piece if single
|
|
47
|
+
|
|
48
|
+
@escapes[piece] ||= parsed_escape(piece)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parsed_escape(piece)
|
|
52
|
+
source = RuboCop::ProcessedSource.new("\"#{piece}\"", @version)
|
|
53
|
+
return source.ast.value if source.valid_syntax? && source.ast&.str_type?
|
|
54
|
+
|
|
55
|
+
raise RuboCop::ValidationError, "Cannot map Ruby string escape #{piece.inspect}."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def without_indentation(decoded, bytes, expected)
|
|
59
|
+
return bytes if decoded == expected
|
|
60
|
+
|
|
61
|
+
offset = 0
|
|
62
|
+
lines = expected.lines
|
|
63
|
+
decoded.lines.flat_map.with_index do |line, index|
|
|
64
|
+
target = lines.fetch(index, '')
|
|
65
|
+
removed = indentation(line, target)
|
|
66
|
+
mapped = bytes.slice(offset + removed, target.bytesize)
|
|
67
|
+
offset += line.bytesize
|
|
68
|
+
mapped
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def indentation(line, target)
|
|
73
|
+
removed = line.bytesize - target.bytesize
|
|
74
|
+
return removed if removed >= 0 && line.end_with?(target) && line.byteslice(0, removed).match?(/\A[ \t]*\z/)
|
|
75
|
+
|
|
76
|
+
raise RuboCop::ValidationError, "Cannot map decoded Ruby string #{target.inspect}."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def character_positions(text, bytes)
|
|
80
|
+
offset = 0
|
|
81
|
+
text.each_char.map do |character|
|
|
82
|
+
range = [bytes.fetch(offset).first, bytes.fetch(offset + character.bytesize - 1).last]
|
|
83
|
+
offset += character.bytesize
|
|
84
|
+
range
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Extracts string tokens selected by the syntax tree, excluding symbols and executable strings.
|
|
6
|
+
class RubyStrings
|
|
7
|
+
PATH_ARGUMENTS = { require: 0, require_relative: 0, load: 0, autoload: 1 }.freeze
|
|
8
|
+
|
|
9
|
+
# Retains delimiters and heredoc boundaries for source-safe replacement.
|
|
10
|
+
class Span < Spellcheck::Span
|
|
11
|
+
def initialize(text, positions, delimiters, terminator)
|
|
12
|
+
super(text, positions)
|
|
13
|
+
@delimiters = delimiters
|
|
14
|
+
@terminator = terminator
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def correction(word, suggestions)
|
|
18
|
+
Correction.new(word, suggestions, @delimiters, @terminator)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Escapes replacement delimiters and never introduces a heredoc terminator.
|
|
23
|
+
class Correction < Spellcheck::Correction
|
|
24
|
+
def initialize(word, suggestions, delimiters, terminator)
|
|
25
|
+
super(word, suggestions)
|
|
26
|
+
@delimiters = delimiters
|
|
27
|
+
@terminator = terminator
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replacement
|
|
31
|
+
result = super
|
|
32
|
+
return unless result && result != @terminator
|
|
33
|
+
|
|
34
|
+
result.each_char.map { |character| @delimiters.include?(character) ? "\\#{character}" : character }.join
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(source)
|
|
39
|
+
@source = source
|
|
40
|
+
@map = RubyStringMap.new(source.ruby_version)
|
|
41
|
+
@tokens = source.tokens.select { |token| %i[tSTRING tSTRING_CONTENT tCHARACTER].include?(token.type) }
|
|
42
|
+
.sort_by { |token| token.pos.begin_pos }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each_span
|
|
46
|
+
return enum_for(__method__) unless block_given?
|
|
47
|
+
|
|
48
|
+
@source.ast.each_node(:str) do |node|
|
|
49
|
+
next if excluded?(node)
|
|
50
|
+
|
|
51
|
+
range = content_range(node)
|
|
52
|
+
tokens_in(range).each { |token| yield mapped_span(node, token, range) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def mapped_span(node, token, range)
|
|
59
|
+
raw = token.type == :tSTRING_CONTENT ? token.pos : range
|
|
60
|
+
positions = @map.positions(raw.source, raw.begin_pos, token.text, single: single_escapes(node))
|
|
61
|
+
text = token.text.each_char.map { |character| character.valid_encoding? ? character : ' ' }.join
|
|
62
|
+
Span.new(text, positions, delimiters(node), terminator(node))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tokens_in(range)
|
|
66
|
+
first = @tokens.bsearch_index { |token| token.pos.end_pos > range.begin_pos }
|
|
67
|
+
return [] unless first
|
|
68
|
+
|
|
69
|
+
last = @tokens.bsearch_index { |token| token.pos.begin_pos >= range.end_pos } || @tokens.length
|
|
70
|
+
@tokens[first...last]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def excluded?(node)
|
|
74
|
+
return true unless node.heredoc? || node.loc.respond_to?(:begin) # __FILE__ has no literal delimiters.
|
|
75
|
+
return true if dependency_string?(node)
|
|
76
|
+
|
|
77
|
+
node.each_ancestor do |ancestor|
|
|
78
|
+
return false if ancestor.begin_type?
|
|
79
|
+
return true if %i[regexp dsym xstr].include?(ancestor.type)
|
|
80
|
+
end
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dependency_string?(node)
|
|
85
|
+
node = node.parent while node.parent && %i[dstr array].include?(node.parent.type)
|
|
86
|
+
call = node.parent
|
|
87
|
+
return false unless call&.send_type?
|
|
88
|
+
|
|
89
|
+
dependency_argument?(call, node) &&
|
|
90
|
+
(call.method?(:autoload) ? module_receiver?(call.receiver) : kernel_receiver?(call.receiver))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dependency_argument?(call, node)
|
|
94
|
+
return call.arguments.include?(node) if call.method?(:gem)
|
|
95
|
+
return false unless PATH_ARGUMENTS.key?(call.method_name)
|
|
96
|
+
|
|
97
|
+
call.arguments[PATH_ARGUMENTS.fetch(call.method_name)].equal?(node)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def module_receiver?(receiver)
|
|
101
|
+
receiver.nil? || receiver.self_type? || receiver.const_type?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def kernel_receiver?(receiver)
|
|
105
|
+
receiver.nil? || receiver.self_type? ||
|
|
106
|
+
(receiver.const_type? && %w[Kernel ::Kernel].include?(receiver.const_name))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def content_range(node)
|
|
110
|
+
return node.loc.heredoc_body if node.heredoc?
|
|
111
|
+
|
|
112
|
+
location = node.loc
|
|
113
|
+
range = location.expression
|
|
114
|
+
range.with(begin_pos: location.begin&.end_pos || range.begin_pos,
|
|
115
|
+
end_pos: location.end&.begin_pos || range.end_pos)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def single_escapes(node)
|
|
119
|
+
opening = literal_opening(node)
|
|
120
|
+
return '' if opening.match?(/\A<<[-~]?'/)
|
|
121
|
+
return "\\'" if opening == "'"
|
|
122
|
+
return unless opening.start_with?('%q', '%w')
|
|
123
|
+
|
|
124
|
+
whitespace = opening.start_with?('%w') ? " \t\r\n" : ''
|
|
125
|
+
"\\#{whitespace}#{paired_delimiters(opening[-1])}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def delimiters(node)
|
|
129
|
+
opening = literal_opening(node)
|
|
130
|
+
return '' if opening.start_with?('<<')
|
|
131
|
+
|
|
132
|
+
paired_delimiters(opening[-1])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def paired_delimiters(delimiter)
|
|
136
|
+
return '' unless delimiter
|
|
137
|
+
|
|
138
|
+
"#{delimiter}#{{ '(' => ')', '[' => ']', '{' => '}', '<' => '>' }.fetch(delimiter, delimiter)}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def terminator(node)
|
|
142
|
+
container = node.parent&.dstr_type? ? node.parent : node
|
|
143
|
+
container.loc.heredoc_end.source.strip if container.heredoc?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def literal_opening(node)
|
|
147
|
+
return node.loc.expression.source if node.heredoc?
|
|
148
|
+
return node.loc.begin.source if node.loc.begin
|
|
149
|
+
|
|
150
|
+
parent = node.parent
|
|
151
|
+
return literal_opening(parent) if parent&.dstr_type?
|
|
152
|
+
|
|
153
|
+
parent_opening(parent)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parent_opening(parent)
|
|
157
|
+
return '' unless parent&.loc.respond_to?(:begin)
|
|
158
|
+
|
|
159
|
+
parent.loc.begin&.source || ''
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Associates extracted prose with its original positions and correction policy.
|
|
6
|
+
class Span
|
|
7
|
+
attr_reader :text, :positions
|
|
8
|
+
|
|
9
|
+
def initialize(text, positions)
|
|
10
|
+
@text = text
|
|
11
|
+
@positions = positions
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def correction(word, suggestions)
|
|
15
|
+
Correction.new(word, suggestions)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each_misspelling(checker, text)
|
|
19
|
+
checker.misspellings(text).each do |word, position|
|
|
20
|
+
next unless word.match?(/\p{L}/)
|
|
21
|
+
|
|
22
|
+
whole = Correction.whole_word?(positions, position, word.length) &&
|
|
23
|
+
Correction.standalone?(text, position, word.length)
|
|
24
|
+
yield word, *range(position, word.length), correction(word, checker.suggestions(word)), whole
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def range(position, length)
|
|
31
|
+
[positions.fetch(position).first, positions.fetch(position + length - 1).last]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Rebuilds vocabulary from current violations while retaining comments and needed word spelling.
|
|
6
|
+
class Vocabulary
|
|
7
|
+
# Still fingerprints and excludes the vocabulary file, but does not accept its words.
|
|
8
|
+
class Checker < Spellcheck::Checker
|
|
9
|
+
def suggestions(_word)
|
|
10
|
+
# Vocabulary generation only collects words, so skip expensive suggestions.
|
|
11
|
+
[]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def dictionary_word(line, path)
|
|
17
|
+
super unless path == File.join(@base_directory, '.spellcheck.txt')
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
@path = File.join(config.base_dir_for_path_parameters, '.spellcheck.txt')
|
|
24
|
+
@words = Set.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add(offense)
|
|
28
|
+
word = offense.message[/Misspelled word: `([^`]+)`\./, 1]
|
|
29
|
+
unless word
|
|
30
|
+
raise RuboCop::ValidationError, "Cannot extract spelling vocabulary from #{offense.message.inspect}."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@words << word.downcase
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write
|
|
37
|
+
content = File.exist?(@path) ? File.read(@path, encoding: 'UTF-8') : ''
|
|
38
|
+
additions = (@words - existing_words(content)).sort
|
|
39
|
+
config = VocabularyConfig.new(@config.loaded_path, section: section).updated(dictionaries)
|
|
40
|
+
updated = extended(retained(content), additions)
|
|
41
|
+
File.write(@path, updated) unless content == updated
|
|
42
|
+
write_configuration(config)
|
|
43
|
+
puts "Updated #{RuboCop::PathUtil.relative_path(@path)}. " \
|
|
44
|
+
'Review generated vocabulary: reported typos are accepted too.'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def retained(content)
|
|
50
|
+
content.lines.select do |line|
|
|
51
|
+
word = line.strip
|
|
52
|
+
word.empty? || word.start_with?('#') || @words.include?(word.downcase)
|
|
53
|
+
end.join
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write_configuration(content)
|
|
57
|
+
File.write(@config.loaded_path, content) unless File.read(@config.loaded_path, encoding: 'UTF-8') == content
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dictionaries
|
|
61
|
+
badge = RuboCop::Cop::Badge.parse(VocabularyConfig::COP)
|
|
62
|
+
files = @config.for_badge(badge)['DictionaryFiles'] || Checker::DEFAULT_FILES
|
|
63
|
+
files.transform_values do |paths|
|
|
64
|
+
paths.any? { |path| vocabulary_path?(path) } ? paths : [*paths, '.spellcheck.txt']
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def vocabulary_path?(path)
|
|
69
|
+
!path.start_with?('gem:') && File.expand_path(path, @config.base_dir_for_path_parameters) == @path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def section
|
|
73
|
+
@config.for_cop(VocabularyConfig::COP).key?('DictionaryFiles') ? VocabularyConfig::COP : 'Spellcheck'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def existing_words(content)
|
|
77
|
+
content.lines.filter_map do |line|
|
|
78
|
+
word = line.strip
|
|
79
|
+
next if word.empty? || word.start_with?('#')
|
|
80
|
+
raise RuboCop::ValidationError, "Expected one word per line in #{@path}: #{word.inspect}" if word.match?(/\s/)
|
|
81
|
+
|
|
82
|
+
word.downcase
|
|
83
|
+
end.to_set
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extended(content, additions)
|
|
87
|
+
return content if additions.empty?
|
|
88
|
+
|
|
89
|
+
separator = content.empty? || content.end_with?("\n") ? '' : "\n"
|
|
90
|
+
"#{content}#{separator}#{additions.join("\n")}\n"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|