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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Masks Asciidoctor inline syntax without substituting attributes or generating HTML.
|
|
6
|
+
class AsciiDocInline
|
|
7
|
+
def initialize(source, positions)
|
|
8
|
+
@source = source.dup
|
|
9
|
+
@positions = positions
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def span
|
|
13
|
+
mask_code
|
|
14
|
+
mask_macros
|
|
15
|
+
mask_quotes
|
|
16
|
+
decoded_span
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def decoded_span
|
|
22
|
+
text = +''
|
|
23
|
+
positions = []
|
|
24
|
+
@source.to_enum(:scan, Markdown::PIECE).each do
|
|
25
|
+
decoded, mapped = decoded_piece(Regexp.last_match)
|
|
26
|
+
text << decoded
|
|
27
|
+
positions.concat(mapped)
|
|
28
|
+
end
|
|
29
|
+
Span.new(text, positions)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def decoded_piece(match)
|
|
33
|
+
decoded = Markdown.decode(match[0])
|
|
34
|
+
range = [@positions.fetch(match.begin(0)).first, @positions.fetch(match.end(0) - 1).last]
|
|
35
|
+
[decoded, Array.new(decoded.length, range)]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mask_code
|
|
39
|
+
mask_matches(::Asciidoctor::InlinePassMacroRx)
|
|
40
|
+
mask_matches(::Asciidoctor::InlinePassRx[false][2], 6)
|
|
41
|
+
mask_matches(::Asciidoctor::InlineStemMacroRx)
|
|
42
|
+
mask_matches(::Asciidoctor::InlineKbdBtnMacroRx)
|
|
43
|
+
mask_matches(::Asciidoctor::InlineImageMacroRx)
|
|
44
|
+
mask_matches(::Asciidoctor::InlineIndextermMacroRx)
|
|
45
|
+
mask_matches(::Asciidoctor::InlineMenuMacroRx)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mask_macros
|
|
49
|
+
keep_capture(::Asciidoctor::InlineLinkMacroRx, 3)
|
|
50
|
+
keep_capture(::Asciidoctor::InlineLinkRx, 5)
|
|
51
|
+
keep_capture(/<<[^>,]+(?:,([^>]+))?>>|xref:[^\s\[]+\[([^\]]*)\]/, 1, 2)
|
|
52
|
+
keep_capture(::Asciidoctor::InlineFootnoteMacroRx, 3)
|
|
53
|
+
mask_matches(::Asciidoctor::InlineAnchorRx)
|
|
54
|
+
mask_matches(::Asciidoctor::AttributeReferenceRx)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mask_quotes
|
|
58
|
+
::Asciidoctor::QUOTE_SUBS[false].each do |type, scope, pattern|
|
|
59
|
+
capture = scope == :constrained ? 3 : 2
|
|
60
|
+
type == :monospaced ? mask_matches(pattern) : keep_capture(pattern, capture)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mask_matches(pattern, capture = 0)
|
|
65
|
+
@source.dup.to_enum(:scan, pattern).each do
|
|
66
|
+
match = Regexp.last_match
|
|
67
|
+
mask(match.begin(capture), match.end(capture))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def keep_capture(pattern, *captures)
|
|
72
|
+
@source.dup.to_enum(:scan, pattern).each do
|
|
73
|
+
match = Regexp.last_match
|
|
74
|
+
capture = captures.find { |index| match[index] }
|
|
75
|
+
if capture
|
|
76
|
+
mask(match.begin(0), match.begin(capture))
|
|
77
|
+
mask(match.end(capture), match.end(0))
|
|
78
|
+
else
|
|
79
|
+
mask(match.begin(0), match.end(0))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mask(first, last)
|
|
85
|
+
@source[first...last] = @source[first...last].gsub(/[^\n]/, ' ')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Cell line numbers can repeat in CSV tables; locate cells in source order within their table.
|
|
6
|
+
class AsciiDocTable
|
|
7
|
+
def initialize(buffer, table)
|
|
8
|
+
@buffer = buffer
|
|
9
|
+
@cursor = buffer.line_range(table.source_location.lineno).end_pos + 1
|
|
10
|
+
@last = table_end(table)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def span(cell)
|
|
14
|
+
match = locate(cell.source)
|
|
15
|
+
first = @cursor + match.begin(0)
|
|
16
|
+
@cursor += match.end(0)
|
|
17
|
+
positions = match[0].length.times.map { |index| [first + index, first + index + 1] }
|
|
18
|
+
AsciiDocInline.new(match[0], positions).span
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def table_end(table)
|
|
24
|
+
lines = @buffer.source_lines
|
|
25
|
+
line = table.source_location.lineno
|
|
26
|
+
delimiter = lines.fetch(line - 1)
|
|
27
|
+
last = (line...lines.length).find { |index| lines[index] == delimiter }
|
|
28
|
+
last ? @buffer.line_range(last + 1).begin_pos : @buffer.source.length
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def locate(text)
|
|
32
|
+
match = pattern(text).match(@buffer.source[@cursor...@last])
|
|
33
|
+
return match if match
|
|
34
|
+
|
|
35
|
+
raise RuboCop::ValidationError, "Cannot map AsciiDoc table cell in #{@buffer.name}: #{text.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pattern(text)
|
|
39
|
+
Regexp.new(text.each_char.map do |character|
|
|
40
|
+
case character
|
|
41
|
+
when '|' then /\\?\|/.source
|
|
42
|
+
when '"' then /"{1,2}/.source
|
|
43
|
+
else Regexp.escape(character)
|
|
44
|
+
end
|
|
45
|
+
end.join)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -41,6 +41,12 @@ module RuboCop
|
|
|
41
41
|
end.uniq.first(3)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def correct?(word)
|
|
45
|
+
return @verdicts[word] if @verdicts.key?(word)
|
|
46
|
+
|
|
47
|
+
@verdicts[word] = @checkers.any? { |checker| checker.correct?(word) }
|
|
48
|
+
end
|
|
49
|
+
|
|
44
50
|
private
|
|
45
51
|
|
|
46
52
|
def tokens(checker, text)
|
|
@@ -128,12 +134,6 @@ module RuboCop
|
|
|
128
134
|
|
|
129
135
|
word
|
|
130
136
|
end
|
|
131
|
-
|
|
132
|
-
def correct?(word)
|
|
133
|
-
return @verdicts[word] if @verdicts.key?(word)
|
|
134
|
-
|
|
135
|
-
@verdicts[word] = @checkers.any? { |checker| checker.correct?(word) }
|
|
136
|
-
end
|
|
137
137
|
end
|
|
138
138
|
end
|
|
139
139
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kotoshu/algorithms/edit_distance'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Spellcheck
|
|
7
|
+
# Restricts unsafe corrections to single-edit lowercase and title-case typos.
|
|
8
|
+
class Correction
|
|
9
|
+
WORD = /\A[\p{L}\p{M}]+(?:['’][\p{L}\p{M}]+)*\z/
|
|
10
|
+
|
|
11
|
+
def self.standalone?(text, position, length)
|
|
12
|
+
before = text[0...position]
|
|
13
|
+
after = text[(position + length)..]
|
|
14
|
+
!before.match?(/[\p{L}\p{M}\p{N}_](?:[.-])?\z/) &&
|
|
15
|
+
!after.match?(/\A(?:[.-])?[\p{L}\p{M}\p{N}_]/)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.whole_word?(positions, position, length)
|
|
19
|
+
first = positions.fetch(position).first
|
|
20
|
+
last = positions.fetch(position + length - 1).last
|
|
21
|
+
(position.zero? || positions[position - 1].last <= first) &&
|
|
22
|
+
(position + length == positions.length || positions[position + length].first >= last)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(word, suggestions)
|
|
26
|
+
@word = word
|
|
27
|
+
@suggestions = suggestions
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replacement
|
|
31
|
+
replacement_word
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def replacement_word
|
|
35
|
+
return if @suggestions.empty? || !correctable_case?
|
|
36
|
+
|
|
37
|
+
suggestion = @suggestions.first.downcase
|
|
38
|
+
return unless WORD.match?(suggestion)
|
|
39
|
+
|
|
40
|
+
return unless Kotoshu::Algorithms::EditDistance.distance_with_threshold(@word.downcase, suggestion, 1) == 1
|
|
41
|
+
|
|
42
|
+
@word == @word.downcase ? suggestion : suggestion.capitalize
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def replacement_in(source, first, last, checker:)
|
|
46
|
+
word = replacement_word
|
|
47
|
+
return unless word && checker.correct?(word)
|
|
48
|
+
|
|
49
|
+
replacement if Correction.standalone?(source, first, last - first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message
|
|
53
|
+
message = "Misspelled word: `#{@word}`."
|
|
54
|
+
return message if @suggestions.empty?
|
|
55
|
+
|
|
56
|
+
"#{message} Did you mean #{@suggestions.map { |suggestion| "`#{suggestion}`" }.join(', ')}?"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def correctable_case?
|
|
62
|
+
@word == @word.downcase || (@word == @word.capitalize && @word != @word.upcase)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -7,12 +7,47 @@ module RuboCop
|
|
|
7
7
|
module Spellcheck
|
|
8
8
|
# Reads YAML string values without constructing application objects.
|
|
9
9
|
class FrontMatter
|
|
10
|
+
# Retains scalar syntax at extraction time rather than in the reporting code.
|
|
11
|
+
class Span < Spellcheck::Span
|
|
12
|
+
def initialize(text, positions, style)
|
|
13
|
+
super(text, positions)
|
|
14
|
+
@style = style
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def correction(word, suggestions)
|
|
18
|
+
Correction.new(word, suggestions, @style)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Applies YAML quoting and scalar-type constraints to a spelling replacement.
|
|
23
|
+
class Correction < Spellcheck::Correction
|
|
24
|
+
def initialize(word, suggestions, style)
|
|
25
|
+
super(word, suggestions)
|
|
26
|
+
@style = style
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def replacement
|
|
30
|
+
suggestion = super
|
|
31
|
+
return unless suggestion
|
|
32
|
+
|
|
33
|
+
case @style
|
|
34
|
+
when Psych::Nodes::Scalar::SINGLE_QUOTED
|
|
35
|
+
suggestion.gsub("'", "''")
|
|
36
|
+
when Psych::Nodes::Scalar::PLAIN
|
|
37
|
+
suggestion if Psych::ScalarScanner.new(Psych::ClassLoader.new).tokenize(suggestion).is_a?(String)
|
|
38
|
+
else
|
|
39
|
+
suggestion
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
10
44
|
HEADER = /\A(?:\uFEFF)?---[ \t]*\n.*?^(?:---|\.\.\.)[ \t]*(?:\n|\z)/m
|
|
11
45
|
ESCAPE = /\\(?:U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/m
|
|
12
46
|
|
|
13
|
-
def initialize(buffer)
|
|
47
|
+
def initialize(buffer, config: {})
|
|
14
48
|
@buffer = buffer
|
|
15
49
|
@scanner = Psych::ScalarScanner.new(Psych::ClassLoader.new)
|
|
50
|
+
@paths = FrontMatterPaths.new(config)
|
|
16
51
|
end
|
|
17
52
|
|
|
18
53
|
def each_span
|
|
@@ -33,14 +68,21 @@ module RuboCop
|
|
|
33
68
|
raise RuboCop::ValidationError, "Invalid YAML front matter in #{@buffer.name}: #{e.message}"
|
|
34
69
|
end
|
|
35
70
|
|
|
36
|
-
def string_values(node, &block)
|
|
71
|
+
def string_values(node, path = [], &block)
|
|
37
72
|
case node
|
|
38
73
|
when Psych::Nodes::Mapping
|
|
39
|
-
node
|
|
74
|
+
mapping_values(node, path, &block)
|
|
40
75
|
when Psych::Nodes::Sequence
|
|
41
|
-
node.children.each { |value| string_values(value, &block) }
|
|
76
|
+
node.children.each { |value| string_values(value, path, &block) }
|
|
42
77
|
when Psych::Nodes::Scalar
|
|
43
|
-
yield node if string_scalar?(node)
|
|
78
|
+
yield node if @paths.include?(path) && string_scalar?(node)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mapping_values(node, path, &block)
|
|
83
|
+
node.children.each_slice(2) do |key, value|
|
|
84
|
+
name = key.is_a?(Psych::Nodes::Scalar) ? key.value : nil
|
|
85
|
+
string_values(value, [*path, name], &block)
|
|
44
86
|
end
|
|
45
87
|
end
|
|
46
88
|
|
|
@@ -60,7 +102,8 @@ module RuboCop
|
|
|
60
102
|
last = position(node.end_line, node.end_column)
|
|
61
103
|
raw = @buffer.source[first...last]
|
|
62
104
|
prefix = raw[/\A(?:(?:&\S+|!\S+)[ \t]+)+/].to_s.length
|
|
63
|
-
|
|
105
|
+
style = node.style
|
|
106
|
+
Span.new(*map_pieces(raw[prefix..], style, first + prefix), style)
|
|
64
107
|
end
|
|
65
108
|
|
|
66
109
|
def map_pieces(raw, style, first)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Selects YAML values by exact dotted paths, including selected descendants.
|
|
6
|
+
class FrontMatterPaths
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@include = paths(config.fetch('Include', nil), 'Include', nullable: true)
|
|
9
|
+
@exclude = paths(config.fetch('Exclude', []), 'Exclude')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def include?(path)
|
|
13
|
+
(@include.nil? || matches?(@include, path)) && !matches?(@exclude, path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def paths(value, setting, nullable: false)
|
|
19
|
+
return if nullable && value.nil?
|
|
20
|
+
return value.map { |path| path.split('.') } if valid?(value)
|
|
21
|
+
|
|
22
|
+
raise RuboCop::ValidationError,
|
|
23
|
+
"Spellcheck/Spelling Markdown.FrontMatter.#{setting} must be a list of nonempty dotted paths."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def valid?(value)
|
|
27
|
+
value.is_a?(Array) && value.all? do |path|
|
|
28
|
+
path.is_a?(String) && !path.empty? && path.split('.', -1).none?(&:empty?)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def matches?(patterns, path)
|
|
33
|
+
patterns.any? { |pattern| path.first(pattern.length) == pattern }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -11,10 +11,20 @@ module RuboCop
|
|
|
11
11
|
PIECE = /#{ENTITY}|\\[[:punct:]]|./m
|
|
12
12
|
LIQUID = /\{\{.*?\}\}|\{%.*?%\}/m
|
|
13
13
|
|
|
14
|
+
attr_reader :buffer, :directives
|
|
15
|
+
|
|
14
16
|
def self.file?(path)
|
|
15
17
|
path && EXTENSIONS.include?(File.extname(path).downcase)
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
def self.decode(piece)
|
|
21
|
+
return piece.delete_prefix('\\') if piece.start_with?('\\')
|
|
22
|
+
return piece unless ENTITY.match?(piece)
|
|
23
|
+
|
|
24
|
+
document = Commonmarker.parse(piece, options: { parse: { smart: false } })
|
|
25
|
+
document.walk.select { |node| node.type == :text }.map(&:string_content).join
|
|
26
|
+
end
|
|
27
|
+
|
|
18
28
|
def initialize(buffer)
|
|
19
29
|
@buffer = buffer
|
|
20
30
|
@lines = buffer.source.lines
|
|
@@ -23,10 +33,7 @@ module RuboCop
|
|
|
23
33
|
def each_span
|
|
24
34
|
return enum_for(__method__) unless block_given?
|
|
25
35
|
|
|
26
|
-
document =
|
|
27
|
-
parse: { smart: false },
|
|
28
|
-
extension: { shortcodes: false, footnotes: true }
|
|
29
|
-
})
|
|
36
|
+
document = parsed_document
|
|
30
37
|
document.walk do |node|
|
|
31
38
|
next unless node.type == :text
|
|
32
39
|
|
|
@@ -35,8 +42,29 @@ module RuboCop
|
|
|
35
42
|
end
|
|
36
43
|
end
|
|
37
44
|
|
|
45
|
+
def each_ruby_span(version, config:, &)
|
|
46
|
+
parsed_document.walk do |node|
|
|
47
|
+
next unless node.type == :code_block && node.fence_info.to_s.split.first == 'ruby'
|
|
48
|
+
|
|
49
|
+
MarkdownRuby.new(self, node, version, config: config).each_span(&)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parser_lines
|
|
54
|
+
@parser_lines ||= parser_source.lines(chomp: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
private
|
|
39
58
|
|
|
59
|
+
def parsed_document
|
|
60
|
+
@parsed_document ||= Commonmarker.parse(parser_source, options: {
|
|
61
|
+
parse: { smart: false },
|
|
62
|
+
extension: { shortcodes: false, footnotes: true }
|
|
63
|
+
}).tap do |document|
|
|
64
|
+
@directives = MarkdownDirectives.new(@buffer, document)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
40
68
|
def parser_source
|
|
41
69
|
without_front_matter.gsub(LIQUID) do |expression|
|
|
42
70
|
expression.gsub(/[^\n]/) { |character| 'x' * character.bytesize }
|
|
@@ -62,12 +90,12 @@ module RuboCop
|
|
|
62
90
|
end
|
|
63
91
|
|
|
64
92
|
def decode_text(raw, first)
|
|
65
|
-
raw = without_liquid(raw)
|
|
93
|
+
raw = without_liquid(@directives.mask(raw, first))
|
|
66
94
|
text = +''
|
|
67
95
|
positions = []
|
|
68
96
|
raw.to_enum(:scan, PIECE).each do
|
|
69
97
|
match = Regexp.last_match
|
|
70
|
-
decoded = decode(match[0])
|
|
98
|
+
decoded = Markdown.decode(match[0])
|
|
71
99
|
text << decoded
|
|
72
100
|
decoded.length.times { positions << [first + match.begin(0), first + match.end(0)] }
|
|
73
101
|
end
|
|
@@ -77,14 +105,6 @@ module RuboCop
|
|
|
77
105
|
def without_liquid(raw)
|
|
78
106
|
raw.gsub(LIQUID) { |expression| expression.gsub(/[^\n]/, ' ') }
|
|
79
107
|
end
|
|
80
|
-
|
|
81
|
-
def decode(piece)
|
|
82
|
-
return piece.delete_prefix('\\') if piece.start_with?('\\')
|
|
83
|
-
return piece unless ENTITY.match?(piece)
|
|
84
|
-
|
|
85
|
-
document = Commonmarker.parse(piece, options: { parse: { smart: false } })
|
|
86
|
-
document.walk.select { |node| node.type == :text }.map(&:string_content).join
|
|
87
|
-
end
|
|
88
108
|
end
|
|
89
109
|
end
|
|
90
110
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Recognizes spelling directives only in Markdown HTML comment nodes.
|
|
6
|
+
class MarkdownDirectives
|
|
7
|
+
COMMENT = /<!--(.*?)-->/m
|
|
8
|
+
DIRECTIVE = /\A\s*rubocop:(disable|enable|todo)\s+(.+?)\s*\z/m
|
|
9
|
+
|
|
10
|
+
def initialize(buffer, document)
|
|
11
|
+
@buffer = buffer
|
|
12
|
+
@lines = buffer.source.lines
|
|
13
|
+
@disabled = Set.new
|
|
14
|
+
events = document.walk.filter_map { |node| comment_event(node) }.flatten(1).sort_by(&:first)
|
|
15
|
+
collect_lines(events)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def mask(raw, first)
|
|
19
|
+
line = @buffer.decompose_position(first).first
|
|
20
|
+
raw.lines.map.with_index do |text, index|
|
|
21
|
+
@disabled.include?(line + index) ? text.gsub(/[^\n]/, ' ') : text
|
|
22
|
+
end.join
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def disabled?(line)
|
|
26
|
+
@disabled.include?(line)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def comment_event(node)
|
|
32
|
+
return unless %i[html_inline html_block].include?(node.type)
|
|
33
|
+
|
|
34
|
+
raw, first = node_source(node)
|
|
35
|
+
return unless raw.gsub(COMMENT, '').strip.empty?
|
|
36
|
+
|
|
37
|
+
raw.to_enum(:scan, COMMENT).filter_map { event(Regexp.last_match, first) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def node_source(node)
|
|
41
|
+
location = node.source_position
|
|
42
|
+
first = position(location.fetch(:start_line), location.fetch(:start_column) - 1)
|
|
43
|
+
last = position(location.fetch(:end_line), location.fetch(:end_column))
|
|
44
|
+
[@buffer.source[first...last], first]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def event(match, first)
|
|
48
|
+
directive = DIRECTIVE.match(match[1])
|
|
49
|
+
return unless directive && relevant?(directive[2])
|
|
50
|
+
|
|
51
|
+
line, column = @buffer.decompose_position(first + match.begin(0))
|
|
52
|
+
prefix = @lines.fetch(line - 1)[0...column]
|
|
53
|
+
[line, directive[1], !prefix.strip.empty?]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def position(line, column)
|
|
57
|
+
@buffer.line_range(line).begin_pos + @lines.fetch(line - 1).byteslice(0, column).length
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def relevant?(names)
|
|
61
|
+
names.split(/\s*--\s*/, 2).first.split(/[,\s]+/).intersect?(%w[all Spellcheck/Spelling])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def collect_lines(events)
|
|
65
|
+
events = events.group_by(&:first)
|
|
66
|
+
@lines.each_index do |index|
|
|
67
|
+
line = index + 1
|
|
68
|
+
@disabled << line if @block_disabled
|
|
69
|
+
events.fetch(line, []).each { |event| apply_event(*event) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_event(line, action, inline)
|
|
74
|
+
if inline || action == 'todo'
|
|
75
|
+
@disabled << line unless action == 'enable'
|
|
76
|
+
else
|
|
77
|
+
@block_disabled = action == 'disable'
|
|
78
|
+
@disabled << line if @block_disabled
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Maps explicitly tagged Ruby fences back through Markdown container prefixes.
|
|
6
|
+
class MarkdownRuby
|
|
7
|
+
# Retains Ruby string escaping and report-only identifier corrections.
|
|
8
|
+
class Span < Spellcheck::Span
|
|
9
|
+
def initialize(original, text, positions)
|
|
10
|
+
super(text, positions)
|
|
11
|
+
@original = original
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def correction(word, suggestions)
|
|
15
|
+
@original.correction(word, suggestions)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(markdown, node, version, config:)
|
|
20
|
+
@buffer = markdown.buffer
|
|
21
|
+
@node = node
|
|
22
|
+
@config = config
|
|
23
|
+
@directives = markdown.directives
|
|
24
|
+
@parser_lines = markdown.parser_lines
|
|
25
|
+
@positions = []
|
|
26
|
+
@source = RuboCop::ProcessedSource.new(mapped_source, version, "#{@buffer.name}.rb")
|
|
27
|
+
@source.config = RuboCop::Config.new
|
|
28
|
+
@source.registry = RuboCop::Cop::Registry.global
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each_span
|
|
32
|
+
return enum_for(__method__) unless block_given?
|
|
33
|
+
return unless @source.valid_syntax?
|
|
34
|
+
|
|
35
|
+
RubyCode.new(@source, config: @config).each_code_span do |span|
|
|
36
|
+
yield mapped_span(span)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def mapped_span(span)
|
|
43
|
+
positions = span.positions.map { |first, last| [@positions.fetch(first), @positions.fetch(last - 1) + 1] }
|
|
44
|
+
Span.new(span, enabled_text(span, positions), positions)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enabled_text(span, positions)
|
|
48
|
+
span.text.each_char.with_index.map do |character, index|
|
|
49
|
+
disabled?(span.positions[index].first, positions[index].first) ? ' ' : character
|
|
50
|
+
end.join
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mapped_source
|
|
54
|
+
first = @node.source_position.fetch(:start_line) + 1
|
|
55
|
+
@node.string_content.lines.each_with_index.map do |line, index|
|
|
56
|
+
mapped_line(line, first + index)
|
|
57
|
+
end.join
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mapped_line(line, number)
|
|
61
|
+
content = line.delete_suffix("\n")
|
|
62
|
+
suffix = mapped_suffix(content, number)
|
|
63
|
+
prefix = @parser_lines.fetch(number - 1).bytesize - suffix.bytesize
|
|
64
|
+
raw = @buffer.source_line(number).byteslice(prefix..)
|
|
65
|
+
indentation = content.delete_suffix(suffix)
|
|
66
|
+
offset = @buffer.line_range(number).end_pos - raw.length
|
|
67
|
+
map_positions(indentation, raw, offset, number)
|
|
68
|
+
"#{indentation}#{raw}\n"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mapped_suffix(content, number)
|
|
72
|
+
parsed = @parser_lines.fetch(number - 1)
|
|
73
|
+
suffix = parsed.end_with?(content) ? content : content.lstrip
|
|
74
|
+
unless parsed.end_with?(suffix)
|
|
75
|
+
raise RuboCop::ValidationError, "Cannot map Ruby fence in #{@buffer.name}:#{number}."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
suffix
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def map_positions(indentation, raw, offset, number)
|
|
82
|
+
indentation.length.times { @positions << [offset - 1, @buffer.line_range(number).begin_pos].max }
|
|
83
|
+
(raw.length + 1).times { |column| @positions << (offset + column) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def disabled?(ruby_position, original_position)
|
|
87
|
+
line = @source.buffer.decompose_position(ruby_position).first
|
|
88
|
+
original_line = @buffer.decompose_position(original_position).first
|
|
89
|
+
@directives.disabled?(original_line) ||
|
|
90
|
+
!@source.comment_config.cop_enabled_at_line?('Spellcheck/Spelling', line)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -11,7 +11,7 @@ module RuboCop
|
|
|
11
11
|
name: 'rubocop-spellcheck',
|
|
12
12
|
version: VERSION,
|
|
13
13
|
homepage: 'https://github.com/dblock/rubocop-spellcheck',
|
|
14
|
-
description: 'Spellcheck Ruby comments,
|
|
14
|
+
description: 'Spellcheck Ruby comments, strings, identifiers, and documentation.'
|
|
15
15
|
)
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -5,7 +5,7 @@ module RuboCop
|
|
|
5
5
|
# Prose files use the non-Ruby callback, with an untouched source buffer.
|
|
6
6
|
module ProseSource
|
|
7
7
|
def self.file?(path)
|
|
8
|
-
Markdown.file?(path) || text_file?(path)
|
|
8
|
+
Markdown.file?(path) || RDoc.file?(path) || AsciiDoc.file?(path) || text_file?(path)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def self.text_file?(path)
|