rubocop-spellcheck 0.1.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +236 -0
- data/config/default.yml +17 -0
- data/data/en/LICENSE +347 -0
- data/data/en/README.md +14 -0
- data/data/en/en.aff +205 -0
- data/data/en/en.dic +49569 -0
- data/lib/rubocop/cop/spellcheck/spelling.rb +131 -0
- data/lib/rubocop/spellcheck/checker.rb +139 -0
- data/lib/rubocop/spellcheck/front_matter.rb +111 -0
- data/lib/rubocop/spellcheck/markdown.rb +90 -0
- data/lib/rubocop/spellcheck/plugin.rb +30 -0
- data/lib/rubocop/spellcheck/prose_source.rb +35 -0
- data/lib/rubocop/spellcheck/version.rb +7 -0
- data/lib/rubocop-spellcheck.rb +10 -0
- metadata +128 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Spellcheck
|
|
6
|
+
# Checks Ruby comments, Markdown prose, and plain text with shared dictionaries.
|
|
7
|
+
class Spelling < Base
|
|
8
|
+
BUNDLED_DATA = File.expand_path('../../../../data', __dir__).freeze
|
|
9
|
+
DIRECTIVE = /
|
|
10
|
+
\A\#\s*(?:!|(?:rubocop|standard|coding|encoding|frozen_string_literal|
|
|
11
|
+
warn_indent|shareable_constant_value|typed)\s*:)|\A\#.*-\*-\s*(?:coding|encoding):
|
|
12
|
+
/x
|
|
13
|
+
ADDRESS = %r{(?:https?://|www\.)[^\s<>]+|[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}}
|
|
14
|
+
|
|
15
|
+
def on_new_investigation
|
|
16
|
+
super
|
|
17
|
+
if RuboCop::Spellcheck::Markdown.file?(processed_source.path)
|
|
18
|
+
check_markdown
|
|
19
|
+
else
|
|
20
|
+
processed_source.comments.each do |comment|
|
|
21
|
+
next if DIRECTIVE.match?(comment.text)
|
|
22
|
+
|
|
23
|
+
check_comment(comment)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_other_file
|
|
29
|
+
if RuboCop::Spellcheck::ProseSource.text_file?(processed_source.path)
|
|
30
|
+
check_text
|
|
31
|
+
elsif RuboCop::Spellcheck::Markdown.file?(processed_source.path)
|
|
32
|
+
check_markdown
|
|
33
|
+
else
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def relevant_file?(file)
|
|
39
|
+
return false unless super
|
|
40
|
+
return false if File.expand_path(file).start_with?("#{BUNDLED_DATA}/")
|
|
41
|
+
return true unless RuboCop::Spellcheck::ProseSource.text_file?(file)
|
|
42
|
+
|
|
43
|
+
!checker.dictionary_file?(file)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def external_dependency_checksum
|
|
47
|
+
checker.checksum
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def front_matter_enabled?
|
|
53
|
+
cop_config.fetch('Markdown', {}).fetch('FrontMatter', {}).fetch('Enabled', true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_markdown
|
|
57
|
+
if front_matter_enabled?
|
|
58
|
+
RuboCop::Spellcheck::FrontMatter.new(prose_buffer).each_span do |text, positions|
|
|
59
|
+
check_markdown_span(text, positions)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
RuboCop::Spellcheck::Markdown.new(prose_buffer).each_span do |text, positions|
|
|
63
|
+
check_markdown_span(text, positions)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_text
|
|
68
|
+
checker.misspellings(mask_addresses(processed_source.buffer.source)).each do |word, position|
|
|
69
|
+
next unless word.match?(/\p{L}/)
|
|
70
|
+
|
|
71
|
+
report_word(word, position, position + word.length)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def checker
|
|
76
|
+
@checker ||= RuboCop::Spellcheck::Checker.new(
|
|
77
|
+
dictionary_files: cop_config['DictionaryFiles'],
|
|
78
|
+
base_directory: config.base_dir_for_path_parameters
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_comment(comment)
|
|
83
|
+
checker.misspellings(prose(comment.text)).each do |word, position|
|
|
84
|
+
first = comment.source_range.begin_pos + position
|
|
85
|
+
report_word(word, first, first + word.length)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def check_markdown_span(text, positions)
|
|
90
|
+
text = text.sub(/\A['’]s\b/, ' ')
|
|
91
|
+
checker.misspellings(mask_addresses(text)).each do |word, position|
|
|
92
|
+
next unless word.match?(/\p{L}/)
|
|
93
|
+
|
|
94
|
+
report_word(word, positions.fetch(position).first, positions.fetch(position + word.length - 1).last)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def report_word(word, first, last)
|
|
99
|
+
range = Parser::Source::Range.new(prose_buffer, first, last)
|
|
100
|
+
range = markdown_document.processed_range(range) if markdown_document
|
|
101
|
+
add_offense(range, message: spelling_message(word))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def markdown_document
|
|
105
|
+
processed_source.markdown_document if processed_source.respond_to?(:markdown_document)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def prose_buffer
|
|
109
|
+
markdown_document ? markdown_document.buffer : processed_source.buffer
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prose(text)
|
|
113
|
+
# Preserve character offsets while removing non-prose addresses and block delimiters.
|
|
114
|
+
mask_addresses(text).gsub(/^=(?:begin|end)\b[^\n]*/) { |marker| ' ' * marker.length }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def mask_addresses(text)
|
|
118
|
+
text.gsub(ADDRESS) { |address| ' ' * address.length }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def spelling_message(word)
|
|
122
|
+
suggestions = checker.suggestions(word)
|
|
123
|
+
message = "Misspelled word: `#{word}`."
|
|
124
|
+
return message if suggestions.empty?
|
|
125
|
+
|
|
126
|
+
"#{message} Did you mean #{suggestions.map { |suggestion| "`#{suggestion}`" }.join(', ')}?"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'kotoshu'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Spellcheck
|
|
9
|
+
# Checks words against explicitly selected, local language dictionaries.
|
|
10
|
+
class Checker
|
|
11
|
+
DEFAULT_FILES = { 'en' => ['gem:rubocop-spellcheck/data/en/en.dic'].freeze }.freeze
|
|
12
|
+
ENGLISH_WORD = /(?<![\p{L}\p{M}\p{N}_])[\p{Latin}\p{M}]+(?:['’][\p{Latin}\p{M}]+)*(?![\p{L}\p{M}\p{N}_])/
|
|
13
|
+
|
|
14
|
+
def initialize(dictionary_files: nil, base_directory: Dir.pwd)
|
|
15
|
+
@base_directory = base_directory
|
|
16
|
+
@paths = []
|
|
17
|
+
@checkers = build_checkers(dictionary_files.nil? ? DEFAULT_FILES : dictionary_files)
|
|
18
|
+
@verdicts = {}
|
|
19
|
+
@suggestions = {}
|
|
20
|
+
@checksum = Digest::SHA256.hexdigest(
|
|
21
|
+
[Kotoshu::VERSION, *@paths.map { |path| Digest::SHA256.file(path).hexdigest }].join("\0")
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :checksum
|
|
26
|
+
|
|
27
|
+
def dictionary_file?(path)
|
|
28
|
+
return false unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
@paths.any? { |dictionary| File.identical?(dictionary, path) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def misspellings(text)
|
|
34
|
+
@checkers.flat_map { |checker| tokens(checker, text) }.uniq
|
|
35
|
+
.sort_by(&:last).reject { |word, _position| correct?(word) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def suggestions(word)
|
|
39
|
+
@suggestions[word] ||= @checkers.flat_map do |checker|
|
|
40
|
+
checker.suggest(word, max_suggestions: 3).to_words
|
|
41
|
+
end.uniq.first(3)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def tokens(checker, text)
|
|
47
|
+
return checker.tokenize(text) unless checker.config.language.match?(/\Aen(?:[-_]|\z)/i)
|
|
48
|
+
|
|
49
|
+
text.to_enum(:scan, ENGLISH_WORD).map { [Regexp.last_match[0], Regexp.last_match.begin(0)] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_checkers(files)
|
|
53
|
+
validate!(files)
|
|
54
|
+
files.flat_map do |language, paths|
|
|
55
|
+
paths.map { |path| build_checker(language, resolve_path(path)) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate!(files)
|
|
60
|
+
valid = files.is_a?(Hash) && !files.empty? &&
|
|
61
|
+
files.all? { |language, paths| valid_language?(language) && valid_paths?(paths) }
|
|
62
|
+
return if valid
|
|
63
|
+
|
|
64
|
+
raise RuboCop::ValidationError, 'Spellcheck/DictionaryFiles must map language codes to nonempty lists of paths.'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def valid_language?(language)
|
|
68
|
+
language.is_a?(String) && language.match?(/\A[a-zA-Z]+(?:[-_][a-zA-Z0-9]+)*\z/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid_paths?(paths)
|
|
72
|
+
paths.is_a?(Array) && !paths.empty? && paths.all? { |path| path.is_a?(String) && !path.empty? }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_path(path)
|
|
76
|
+
return File.expand_path(path, @base_directory) unless path.start_with?('gem:')
|
|
77
|
+
|
|
78
|
+
match = %r{\Agem:([^/]+)/(.+)\z}.match(path)
|
|
79
|
+
raise RuboCop::ValidationError, "Invalid gem dictionary path: #{path}" unless match
|
|
80
|
+
|
|
81
|
+
root = Gem::Specification.find_by_name(match[1]).full_gem_path
|
|
82
|
+
resolved = File.expand_path(match[2], root)
|
|
83
|
+
raise RuboCop::ValidationError, "Path escapes gem: #{path}" unless resolved.start_with?("#{root}/")
|
|
84
|
+
|
|
85
|
+
resolved
|
|
86
|
+
rescue Gem::LoadError => e
|
|
87
|
+
raise RuboCop::ValidationError, "Cannot resolve dictionary #{path}: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_checker(language, path)
|
|
91
|
+
dictionary = load_dictionary(language, path)
|
|
92
|
+
settings = Kotoshu::Configuration.new(
|
|
93
|
+
language: language, offline: true, personal_dictionary: false, backend: 'ruby'
|
|
94
|
+
)
|
|
95
|
+
Kotoshu::Spellchecker.new(dictionary: dictionary, config: settings)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_dictionary(language, path)
|
|
99
|
+
case File.extname(path)
|
|
100
|
+
when '.dic'
|
|
101
|
+
hunspell_dictionary(language, path)
|
|
102
|
+
when '.txt'
|
|
103
|
+
words = read_file(path).lines.filter_map { |line| dictionary_word(line, path) }
|
|
104
|
+
Kotoshu::Dictionary::PlainText.from_words(words, language_code: language)
|
|
105
|
+
else
|
|
106
|
+
raise RuboCop::ValidationError, "Expected a .dic or .txt dictionary: #{path}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def hunspell_dictionary(language, path)
|
|
111
|
+
aff = path.sub(/\.dic\z/, '.aff')
|
|
112
|
+
[path, aff].each { |file| read_file(file) }
|
|
113
|
+
Kotoshu::Dictionary::Hunspell.new(dic_path: path, aff_path: aff, language_code: language)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read_file(path)
|
|
117
|
+
content = File.read(path, encoding: 'UTF-8')
|
|
118
|
+
@paths << path
|
|
119
|
+
content
|
|
120
|
+
rescue SystemCallError => e
|
|
121
|
+
raise RuboCop::ValidationError, "Cannot read spelling dictionary #{path}: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def dictionary_word(line, path)
|
|
125
|
+
word = line.strip
|
|
126
|
+
return if word.empty? || word.start_with?('#')
|
|
127
|
+
raise RuboCop::ValidationError, "Expected one word per line in #{path}: #{word.inspect}" if word.match?(/\s/)
|
|
128
|
+
|
|
129
|
+
word
|
|
130
|
+
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
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'psych'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module RuboCop
|
|
7
|
+
module Spellcheck
|
|
8
|
+
# Reads YAML string values without constructing application objects.
|
|
9
|
+
class FrontMatter
|
|
10
|
+
HEADER = /\A(?:\uFEFF)?---[ \t]*\n.*?^(?:---|\.\.\.)[ \t]*(?:\n|\z)/m
|
|
11
|
+
ESCAPE = /\\(?:U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/m
|
|
12
|
+
|
|
13
|
+
def initialize(buffer)
|
|
14
|
+
@buffer = buffer
|
|
15
|
+
@scanner = Psych::ScalarScanner.new(Psych::ClassLoader.new)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each_span
|
|
19
|
+
return enum_for(__method__) unless block_given?
|
|
20
|
+
|
|
21
|
+
root = yaml_root
|
|
22
|
+
string_values(root) { |node| yield scalar_span(node) } if root
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def yaml_root
|
|
28
|
+
return unless (header = HEADER.match(@buffer.source))
|
|
29
|
+
|
|
30
|
+
yaml = header[0].sub(/^(?:---|\.\.\.)[ \t]*\n?\z/, '')
|
|
31
|
+
Psych.parse_stream(yaml).children.first&.root
|
|
32
|
+
rescue Psych::SyntaxError => e
|
|
33
|
+
raise RuboCop::ValidationError, "Invalid YAML front matter in #{@buffer.name}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def string_values(node, &block)
|
|
37
|
+
case node
|
|
38
|
+
when Psych::Nodes::Mapping
|
|
39
|
+
node.children.each_slice(2) { |_key, value| string_values(value, &block) }
|
|
40
|
+
when Psych::Nodes::Sequence
|
|
41
|
+
node.children.each { |value| string_values(value, &block) }
|
|
42
|
+
when Psych::Nodes::Scalar
|
|
43
|
+
yield node if string_scalar?(node)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def string_scalar?(node)
|
|
48
|
+
return node.tag == 'tag:yaml.org,2002:str' if node.tag
|
|
49
|
+
return true unless node.plain
|
|
50
|
+
|
|
51
|
+
@scanner.tokenize(node.value).is_a?(String)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def position(line, column)
|
|
55
|
+
@buffer.line_range(line + 1).begin_pos + column
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scalar_span(node)
|
|
59
|
+
first = position(node.start_line, node.start_column)
|
|
60
|
+
last = position(node.end_line, node.end_column)
|
|
61
|
+
raw = @buffer.source[first...last]
|
|
62
|
+
prefix = raw[/\A(?:(?:&\S+|!\S+)[ \t]+)+/].to_s.length
|
|
63
|
+
map_pieces(raw[prefix..], node.style, first + prefix)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def map_pieces(raw, style, first)
|
|
67
|
+
text = +''
|
|
68
|
+
positions = []
|
|
69
|
+
scalar_pieces(raw, style).each do |piece, offset, decoded|
|
|
70
|
+
text << decoded
|
|
71
|
+
decoded.length.times { positions << [first + offset, first + offset + piece.length] }
|
|
72
|
+
end
|
|
73
|
+
[text, positions]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scalar_pieces(raw, style)
|
|
77
|
+
return enum_for(__method__, raw, style) unless block_given?
|
|
78
|
+
|
|
79
|
+
pattern = style == Psych::Nodes::Scalar::DOUBLE_QUOTED ? /#{ESCAPE}|./m : /''|./m
|
|
80
|
+
raw.to_enum(:scan, pattern).each do
|
|
81
|
+
match = Regexp.last_match
|
|
82
|
+
piece = match[0]
|
|
83
|
+
decoded = decode(piece, style)
|
|
84
|
+
decoded = ' ' * piece.length if ignored_piece?(raw, match.begin(0), style)
|
|
85
|
+
yield piece, match.begin(0), decoded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ignored_piece?(raw, offset, style)
|
|
90
|
+
case style
|
|
91
|
+
when Psych::Nodes::Scalar::LITERAL, Psych::Nodes::Scalar::FOLDED
|
|
92
|
+
offset < raw.index("\n").to_i
|
|
93
|
+
when Psych::Nodes::Scalar::SINGLE_QUOTED, Psych::Nodes::Scalar::DOUBLE_QUOTED
|
|
94
|
+
offset.zero? || offset == raw.length - 1
|
|
95
|
+
else
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def decode(piece, style)
|
|
101
|
+
if style == Psych::Nodes::Scalar::DOUBLE_QUOTED && piece.start_with?('\\')
|
|
102
|
+
Psych.safe_load("\"#{piece}\"") || ''
|
|
103
|
+
elsif style == Psych::Nodes::Scalar::SINGLE_QUOTED && piece == "''"
|
|
104
|
+
"'"
|
|
105
|
+
else
|
|
106
|
+
piece
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'commonmarker'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Spellcheck
|
|
7
|
+
# Extracts visible text while retaining character ranges in the original source.
|
|
8
|
+
class Markdown
|
|
9
|
+
EXTENSIONS = %w[.md .markdown].freeze
|
|
10
|
+
ENTITY = /&(?:\#x[0-9a-f]+|\#\d+|[a-z][a-z0-9]+);/i
|
|
11
|
+
PIECE = /#{ENTITY}|\\[[:punct:]]|./m
|
|
12
|
+
LIQUID = /\{\{.*?\}\}|\{%.*?%\}/m
|
|
13
|
+
|
|
14
|
+
def self.file?(path)
|
|
15
|
+
path && EXTENSIONS.include?(File.extname(path).downcase)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(buffer)
|
|
19
|
+
@buffer = buffer
|
|
20
|
+
@lines = buffer.source.lines
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each_span
|
|
24
|
+
return enum_for(__method__) unless block_given?
|
|
25
|
+
|
|
26
|
+
document = Commonmarker.parse(parser_source, options: {
|
|
27
|
+
parse: { smart: false },
|
|
28
|
+
extension: { shortcodes: false, footnotes: true }
|
|
29
|
+
})
|
|
30
|
+
document.walk do |node|
|
|
31
|
+
next unless node.type == :text
|
|
32
|
+
|
|
33
|
+
text, positions = mapped_text(node)
|
|
34
|
+
yield text, positions unless text.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parser_source
|
|
41
|
+
without_front_matter.gsub(LIQUID) do |expression|
|
|
42
|
+
expression.gsub(/[^\n]/) { |character| 'x' * character.bytesize }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def without_front_matter
|
|
47
|
+
@buffer.source.sub(/\A(?:\uFEFF)?---[ \t]*\n.*?^(?:---|\.\.\.)[ \t]*(?:\n|\z)/m) do |header|
|
|
48
|
+
header.gsub(/[^\n]/, ' ')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def character_position(line, byte_column)
|
|
53
|
+
prefix = @lines.fetch(line - 1).byteslice(0, byte_column)
|
|
54
|
+
@buffer.line_range(line).begin_pos + prefix.length
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mapped_text(node)
|
|
58
|
+
location = node.source_position
|
|
59
|
+
first = character_position(location.fetch(:start_line), location.fetch(:start_column) - 1)
|
|
60
|
+
last = character_position(location.fetch(:end_line), location.fetch(:end_column))
|
|
61
|
+
decode_text(@buffer.source[first...last], first)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def decode_text(raw, first)
|
|
65
|
+
raw = without_liquid(raw)
|
|
66
|
+
text = +''
|
|
67
|
+
positions = []
|
|
68
|
+
raw.to_enum(:scan, PIECE).each do
|
|
69
|
+
match = Regexp.last_match
|
|
70
|
+
decoded = decode(match[0])
|
|
71
|
+
text << decoded
|
|
72
|
+
decoded.length.times { positions << [first + match.begin(0), first + match.end(0)] }
|
|
73
|
+
end
|
|
74
|
+
[text, positions]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def without_liquid(raw)
|
|
78
|
+
raw.gsub(LIQUID) { |expression| expression.gsub(/[^\n]/, ' ') }
|
|
79
|
+
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
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lint_roller'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Spellcheck
|
|
7
|
+
# Registers the extension and its defaults with RuboCop.
|
|
8
|
+
class Plugin < LintRoller::Plugin
|
|
9
|
+
def about
|
|
10
|
+
LintRoller::About.new(
|
|
11
|
+
name: 'rubocop-spellcheck',
|
|
12
|
+
version: VERSION,
|
|
13
|
+
homepage: 'https://github.com/dblock/rubocop-spellcheck',
|
|
14
|
+
description: 'Spellcheck Ruby comments, Markdown prose, and plain text.'
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def supported?(context)
|
|
19
|
+
context.engine == :rubocop
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rules(_context)
|
|
23
|
+
LintRoller::Rules.new(
|
|
24
|
+
type: :path, config_format: :rubocop,
|
|
25
|
+
value: Pathname.new(__dir__).join('../../../config/default.yml')
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Spellcheck
|
|
5
|
+
# Prose files use the non-Ruby callback, with an untouched source buffer.
|
|
6
|
+
module ProseSource
|
|
7
|
+
def self.file?(path)
|
|
8
|
+
Markdown.file?(path) || text_file?(path)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.text_file?(path)
|
|
12
|
+
path && File.extname(path).downcase == '.txt'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def valid_syntax?
|
|
16
|
+
ProseSource.file?(path) && !markdown_preprocessor? ? false : super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse(source, *)
|
|
22
|
+
return super if !ProseSource.file?(path) || markdown_preprocessor?
|
|
23
|
+
|
|
24
|
+
super('', *)
|
|
25
|
+
@buffer = Parser::Source::Buffer.new(path, source: source)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def markdown_preprocessor?
|
|
29
|
+
Markdown.file?(path) && respond_to?(:markdown_document)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RuboCop::ProcessedSource.prepend(RuboCop::Spellcheck::ProseSource)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
require_relative 'rubocop/spellcheck/version'
|
|
5
|
+
require_relative 'rubocop/spellcheck/plugin'
|
|
6
|
+
require_relative 'rubocop/spellcheck/checker'
|
|
7
|
+
require_relative 'rubocop/spellcheck/front_matter'
|
|
8
|
+
require_relative 'rubocop/spellcheck/markdown'
|
|
9
|
+
require_relative 'rubocop/spellcheck/prose_source'
|
|
10
|
+
require_relative 'rubocop/cop/spellcheck/spelling'
|
metadata
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubocop-spellcheck
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Daniel (dB.) Doubrovkine
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: commonmarker
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: kotoshu
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: 1.0.8
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: 1.0.8
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: lint_roller
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.1'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.1'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.72'
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 1.72.1
|
|
65
|
+
type: :runtime
|
|
66
|
+
prerelease: false
|
|
67
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - "~>"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '1.72'
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 1.72.1
|
|
75
|
+
description: RuboCop spelling checks for Ruby comments, Markdown, and plain text,
|
|
76
|
+
using offline dictionaries.
|
|
77
|
+
email:
|
|
78
|
+
- dblock@dblock.org
|
|
79
|
+
executables: []
|
|
80
|
+
extensions: []
|
|
81
|
+
extra_rdoc_files: []
|
|
82
|
+
files:
|
|
83
|
+
- CHANGELOG.md
|
|
84
|
+
- LICENSE.txt
|
|
85
|
+
- README.md
|
|
86
|
+
- config/default.yml
|
|
87
|
+
- data/en/LICENSE
|
|
88
|
+
- data/en/README.md
|
|
89
|
+
- data/en/en.aff
|
|
90
|
+
- data/en/en.dic
|
|
91
|
+
- lib/rubocop-spellcheck.rb
|
|
92
|
+
- lib/rubocop/cop/spellcheck/spelling.rb
|
|
93
|
+
- lib/rubocop/spellcheck/checker.rb
|
|
94
|
+
- lib/rubocop/spellcheck/front_matter.rb
|
|
95
|
+
- lib/rubocop/spellcheck/markdown.rb
|
|
96
|
+
- lib/rubocop/spellcheck/plugin.rb
|
|
97
|
+
- lib/rubocop/spellcheck/prose_source.rb
|
|
98
|
+
- lib/rubocop/spellcheck/version.rb
|
|
99
|
+
homepage: https://github.com/dblock/rubocop-spellcheck
|
|
100
|
+
licenses:
|
|
101
|
+
- MIT
|
|
102
|
+
metadata:
|
|
103
|
+
allowed_push_host: https://rubygems.org
|
|
104
|
+
source_code_uri: https://github.com/dblock/rubocop-spellcheck
|
|
105
|
+
changelog_uri: https://github.com/dblock/rubocop-spellcheck/blob/master/CHANGELOG.md
|
|
106
|
+
bug_tracker_uri: https://github.com/dblock/rubocop-spellcheck/issues
|
|
107
|
+
rubygems_mfa_required: 'true'
|
|
108
|
+
default_lint_roller_plugin: RuboCop::Spellcheck::Plugin
|
|
109
|
+
post_install_message:
|
|
110
|
+
rdoc_options: []
|
|
111
|
+
require_paths:
|
|
112
|
+
- lib
|
|
113
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '3.2'
|
|
118
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - ">="
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '0'
|
|
123
|
+
requirements: []
|
|
124
|
+
rubygems_version: 3.5.16
|
|
125
|
+
signing_key:
|
|
126
|
+
specification_version: 4
|
|
127
|
+
summary: Spellcheck Ruby comments, Markdown, and plain text with RuboCop.
|
|
128
|
+
test_files: []
|