rubocop-prose 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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +10 -2
  3. data/CHANGELOG.md +20 -0
  4. data/CONTRIBUTING.md +17 -7
  5. data/DESIGN.md +13 -5
  6. data/README.md +37 -20
  7. data/RELEASING.md +8 -8
  8. data/UPGRADING.md +16 -0
  9. data/assets/README.md +1 -1
  10. data/config/cops/markdown/code_block_style.yml +1 -1
  11. data/config/cops/markdown/emphasis_style.yml +4 -0
  12. data/config/cops/markdown/fence_style.yml +1 -1
  13. data/config/cops/markdown/first_heading.yml +1 -2
  14. data/config/cops/markdown/front_matter_keys.yml +5 -0
  15. data/config/cops/markdown/front_matter_syntax.yml +3 -0
  16. data/config/cops/markdown/hard_break_style.yml +3 -0
  17. data/config/cops/markdown/hard_tabs.yml +1 -1
  18. data/config/cops/markdown/heading_capitalization.yml +31 -0
  19. data/config/cops/markdown/heading_style.yml +1 -1
  20. data/config/cops/markdown/{syntax.yml → kramdown_syntax.yml} +1 -1
  21. data/config/cops/markdown/list_item_capitalization.yml +32 -0
  22. data/config/cops/markdown/list_item_punctuation.yml +13 -0
  23. data/config/cops/markdown/list_marker_spacing.yml +6 -5
  24. data/config/cops/markdown/ordered_list_numbering.yml +1 -1
  25. data/config/cops/markdown/reference_definitions.yml +2 -3
  26. data/config/cops/markdown/strong_style.yml +4 -0
  27. data/config/cops/markdown/structural_line_length.yml +1 -4
  28. data/config/cops/markdown/table_pipes.yml +6 -0
  29. data/config/cops/markdown/table_spacing.yml +4 -0
  30. data/config/cops/markdown/trailing_whitespace.yml +1 -0
  31. data/config/cops/markdown/unordered_list_marker.yml +1 -1
  32. data/config/cops/prose/sentence_capitalization.yml +9 -0
  33. data/config/cops/text/hard_tabs.yml +3 -0
  34. data/config/cops/text/proper_names.yml +4 -0
  35. data/config/obsoletion.yml +45 -0
  36. data/docs/cops/markdown/blockquote_spacing.md +2 -0
  37. data/docs/cops/markdown/code_block_style.md +11 -2
  38. data/docs/cops/markdown/duplicate_headings.md +51 -0
  39. data/docs/cops/markdown/emphasis_style.md +5 -1
  40. data/docs/cops/markdown/fence_style.md +11 -2
  41. data/docs/cops/markdown/first_heading.md +29 -3
  42. data/docs/cops/markdown/front_matter_keys.md +57 -0
  43. data/docs/cops/markdown/front_matter_syntax.md +32 -0
  44. data/docs/cops/markdown/hard_break_style.md +10 -5
  45. data/docs/cops/markdown/hard_tabs.md +3 -3
  46. data/docs/cops/markdown/heading_capitalization.md +176 -0
  47. data/docs/cops/markdown/heading_style.md +11 -2
  48. data/docs/cops/markdown/{syntax.md → kramdown_syntax.md} +6 -2
  49. data/docs/cops/markdown/list_item_capitalization.md +115 -0
  50. data/docs/cops/markdown/list_item_punctuation.md +70 -0
  51. data/docs/cops/markdown/list_marker_spacing.md +57 -7
  52. data/docs/cops/markdown/ordered_list_numbering.md +10 -1
  53. data/docs/cops/markdown/proper_names.md +3 -3
  54. data/docs/cops/markdown/raw_html.md +99 -6
  55. data/docs/cops/markdown/reference_definitions.md +30 -4
  56. data/docs/cops/markdown/strong_style.md +5 -1
  57. data/docs/cops/markdown/structural_line_length.md +31 -7
  58. data/docs/cops/markdown/table_pipes.md +7 -1
  59. data/docs/cops/markdown/table_spacing.md +5 -1
  60. data/docs/cops/markdown/thematic_break_style.md +2 -0
  61. data/docs/cops/markdown/trailing_whitespace.md +29 -0
  62. data/docs/cops/markdown/unordered_list_marker.md +11 -2
  63. data/docs/cops/prose/multiple_blank_lines.md +2 -0
  64. data/docs/cops/prose/sentence_capitalization.md +46 -0
  65. data/docs/cops/text/hard_tabs.md +28 -0
  66. data/docs/cops/text/proper_names.md +36 -0
  67. data/docs/settings/files.md +1 -1
  68. data/docs/settings/front_matter.md +2 -2
  69. data/lib/rubocop/cop/markdown/code_block_style.rb +1 -1
  70. data/lib/rubocop/cop/markdown/code_span_spacing.rb +2 -2
  71. data/lib/rubocop/cop/markdown/duplicate_headings.rb +55 -5
  72. data/lib/rubocop/cop/markdown/emphasis_spacing.rb +1 -1
  73. data/lib/rubocop/cop/markdown/emphasis_style.rb +7 -0
  74. data/lib/rubocop/cop/markdown/fence_style.rb +1 -1
  75. data/lib/rubocop/cop/markdown/first_heading.rb +13 -2
  76. data/lib/rubocop/cop/markdown/front_matter_keys.rb +70 -0
  77. data/lib/rubocop/cop/markdown/front_matter_syntax.rb +26 -0
  78. data/lib/rubocop/cop/markdown/hard_tabs.rb +11 -7
  79. data/lib/rubocop/cop/markdown/heading_capitalization.rb +43 -0
  80. data/lib/rubocop/cop/markdown/heading_style.rb +1 -1
  81. data/lib/rubocop/cop/markdown/{syntax.rb → kramdown_syntax.rb} +1 -1
  82. data/lib/rubocop/cop/markdown/list_item_capitalization.rb +90 -0
  83. data/lib/rubocop/cop/markdown/list_item_punctuation.rb +64 -0
  84. data/lib/rubocop/cop/markdown/list_marker_spacing.rb +13 -4
  85. data/lib/rubocop/cop/markdown/ordered_list_numbering.rb +1 -1
  86. data/lib/rubocop/cop/markdown/outside_spacing.rb +6 -6
  87. data/lib/rubocop/cop/markdown/proper_names.rb +20 -31
  88. data/lib/rubocop/cop/markdown/raw_html.rb +62 -7
  89. data/lib/rubocop/cop/markdown/reference_definitions.rb +15 -5
  90. data/lib/rubocop/cop/markdown/strong_style.rb +7 -0
  91. data/lib/rubocop/cop/markdown/structural_line_length.rb +7 -6
  92. data/lib/rubocop/cop/markdown/table_blank_lines.rb +1 -1
  93. data/lib/rubocop/cop/markdown/table_columns.rb +1 -1
  94. data/lib/rubocop/cop/markdown/table_pipes.rb +8 -1
  95. data/lib/rubocop/cop/markdown/table_spacing.rb +8 -1
  96. data/lib/rubocop/cop/markdown/thematic_break_style.rb +4 -0
  97. data/lib/rubocop/cop/markdown/trailing_whitespace.rb +6 -0
  98. data/lib/rubocop/cop/markdown/unordered_list_marker.rb +1 -1
  99. data/lib/rubocop/cop/prose/sentence_capitalization.rb +100 -0
  100. data/lib/rubocop/cop/text/hard_tabs.rb +21 -0
  101. data/lib/rubocop/cop/text/proper_names.rb +20 -0
  102. data/lib/rubocop/prose/capitalization_rules.rb +189 -0
  103. data/lib/rubocop/prose/inline_rules.rb +17 -10
  104. data/lib/rubocop/prose/link_rules.rb +34 -13
  105. data/lib/rubocop/prose/markdown_document.rb +30 -8
  106. data/lib/rubocop/prose/proper_name_rules.rb +31 -0
  107. data/lib/rubocop/prose/structural_blank_lines.rb +1 -1
  108. data/lib/rubocop/prose/table_rules.rb +1 -1
  109. data/lib/rubocop/prose/version.rb +1 -1
  110. data/lib/rubocop-prose.rb +3 -0
  111. metadata +32 -4
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'inline_rules'
4
+
5
+ module RuboCop
6
+ module Prose
7
+ module CapitalizationRules
8
+ WORD = /[\p{L}\p{N}][\p{L}\p{M}\p{N}_]*(?:['’][\p{L}\p{M}\p{N}_]+)*/
9
+ OpeningWord = Struct.new(:word, :begin_pos, :end_pos, :literal, :code, keyword_init: true)
10
+ QUOTES = { '"' => '"', "'" => "'", '“' => '”', '‘' => '’', '«' => '»', '‹' => '›',
11
+ '「' => '」', '『' => '』' }.freeze
12
+
13
+ def opening_word(source, offset = 0, literal: false, depth: 0, base: 0, markdown: true)
14
+ return if depth > 8
15
+
16
+ index = offset
17
+ index += 1 while source[index]&.match?(/[ \t]/)
18
+ character = source[index]
19
+
20
+ if (closing = QUOTES[character]) && source.index(closing, index + 1)
21
+ return opening_word(source, index + 1, literal: true, depth: depth + 1, base: base, markdown: markdown)
22
+ end
23
+
24
+ if markdown && character == '`'
25
+ span = InlineRules.spans(source, links: false, html: false).find do |item|
26
+ item.kind == :code && item.begin_pos == index
27
+ end
28
+ if span
29
+ delimiter = span.delimiter.length
30
+ content_start = index + delimiter
31
+ content_end = span.end_pos - delimiter
32
+ content = source[content_start...content_end]
33
+ padding = content[/\A[ \t]*/].to_s.length
34
+ match = WORD.match(content, padding)
35
+ if match && match.begin(0) == padding && match[0].match?(/\p{L}/)
36
+ return opening_match(match, base + content_start, true, true)
37
+ end
38
+ end
39
+ end
40
+
41
+ if markdown && character == '[' && source[index - 1] != '!'
42
+ close = InlineRules.balanced_end(source, index, '[', ']')
43
+ if close
44
+ return opening_word(source, index + 1, literal: literal, depth: depth + 1, base: base, markdown: markdown)
45
+ end
46
+ end
47
+
48
+ if markdown && ['*', '_'].include?(character)
49
+ delimiter = source[index..][/\A(?:\*{1,3}|_{1,3})/]
50
+ closing = delimiter && source.index(delimiter, index + delimiter.length)
51
+ if closing
52
+ content_start = index + delimiter.length
53
+ inside = source[content_start...closing]
54
+ return opening_word(inside, literal: literal, depth: depth + 1, base: base + content_start,
55
+ markdown: markdown)
56
+ end
57
+ end
58
+
59
+ match = WORD.match(source, index)
60
+ return unless match && match.begin(0) == index && match[0].match?(/\p{L}/)
61
+
62
+ opening_match(match, base, literal, false)
63
+ end
64
+
65
+ def opening_match(match, offset, literal, code)
66
+ OpeningWord.new(word: match[0], begin_pos: offset + match.begin(0), end_pos: offset + match.end(0),
67
+ literal: literal, code: code)
68
+ end
69
+
70
+ def lowercase_opening?(opening)
71
+ return false unless opening&.word&.match?(/\A\p{Ll}/)
72
+ return false if ignored_word?(opening.word)
73
+
74
+ !cop_config['PreserveMixedCase'] || !opening.word.match?(/\A\p{Ll}[\p{L}\p{M}\p{N}_]*[\p{Lu}\p{Lt}]/)
75
+ end
76
+
77
+ def ambiguous_identifier_opening?(source, opening)
78
+ opening.word.include?('_') ||
79
+ source[opening.end_pos..].match?(%r{\A(?:[.:/@=][\p{L}\p{N}]|://)})
80
+ end
81
+
82
+ def identifier_source?(source)
83
+ source.match?(%r{
84
+ https?://
85
+ | www\.
86
+ | [\p{L}\p{N}_]+_[\p{L}\p{N}_]+
87
+ | [\p{L}\p{N}_]+/[\p{L}\p{N}_]+
88
+ | \b[\p{L}\p{N}_]+\.[A-Za-z0-9]{2,}\b
89
+ }ix)
90
+ end
91
+
92
+ def validate_config
93
+ styles = cop_config['SupportedStyles']
94
+ unless styles.include?(cop_config['Style'])
95
+ raise RuboCop::ValidationError, "#{cop_name}: Style must be one of #{styles.join(', ')}."
96
+ end
97
+
98
+ options = %w[IgnoredWords]
99
+ options << 'MinorWords' if cop_config.key?('MinorWords')
100
+ options.each do |option|
101
+ words = cop_config[option]
102
+ next if words.is_a?(Array) && words.all? { |word| word.is_a?(String) && word.match?(/\A#{WORD}\z/) }
103
+
104
+ raise RuboCop::ValidationError, "#{cop_name}: #{option} must be an array of individual words."
105
+ end
106
+ return if [true, false].include?(cop_config['PreserveMixedCase'])
107
+
108
+ raise RuboCop::ValidationError, "#{cop_name}: PreserveMixedCase must be true or false."
109
+ end
110
+
111
+ private
112
+
113
+ def correction_range(text, number)
114
+ return if text.empty? || text.include?("\n") || text.include?("\0")
115
+
116
+ source = document.line(number)
117
+ first = source.index(text)
118
+ return unless first && !source.index(text, first + 1)
119
+
120
+ last = first + text.length
121
+ protected = InlineRules.spans(source)
122
+ return if protected.any? { |span| first < span.end_pos && last > span.begin_pos }
123
+
124
+ document.range(number, first, number, last)
125
+ end
126
+
127
+ def capitalized_text(text, title_case)
128
+ words = text.scan(WORD).grep(/\p{L}/)
129
+ minor_words = cop_config['MinorWords'].map(&:downcase)
130
+ index = -1
131
+ text.gsub(WORD) do |word|
132
+ next word unless word.match?(/\p{L}/)
133
+
134
+ index += 1
135
+ next word if ignored_word?(word) || (!title_case && index.positive?)
136
+
137
+ if title_case && index.positive? && index < words.size - 1 && minor_words.include?(word.downcase)
138
+ word.downcase
139
+ elsif invalid_initial?(word)
140
+ word.sub(/\p{L}/, &:upcase)
141
+ else
142
+ word
143
+ end
144
+ end
145
+ end
146
+
147
+ def invalid_initial?(text)
148
+ first = /[\p{L}\0]/.match(text)
149
+ return false unless first && first[0].match?(/\p{Ll}/)
150
+ return false if ignored_word?(text.scan(WORD).find { |word| word.match?(/\p{L}/) })
151
+
152
+ word = text[first.begin(0)..][/\A[\p{L}\p{N}_]+/]
153
+ !cop_config['PreserveMixedCase'] || !word&.match?(/\p{Lu}|\p{Lt}/)
154
+ end
155
+
156
+ def invalid_title?(text)
157
+ return false if text.include?("\0")
158
+
159
+ words = text.scan(WORD).grep(/\p{L}/)
160
+ minor_words = cop_config['MinorWords'].map(&:downcase)
161
+ words.each_with_index.any? do |word, index|
162
+ next false if ignored_word?(word)
163
+
164
+ if index.positive? && index < words.size - 1 && minor_words.include?(word.downcase)
165
+ word != word.downcase
166
+ else
167
+ invalid_initial?(word)
168
+ end
169
+ end
170
+ end
171
+
172
+ def ignored_word?(word)
173
+ cop_config['IgnoredWords'].include?(word)
174
+ end
175
+
176
+ def prose_text(node)
177
+ case node.type
178
+ when :text then node.value.to_s
179
+ when :entity then node.value.char
180
+ when :header, :p, :em, :strong, :a
181
+ node.children.map { |child| prose_text(child) }.join
182
+ else
183
+ # Do not check a later word after opaque leading content.
184
+ "\0"
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -58,7 +58,7 @@ module RuboCop
58
58
  def self.spans(text, links: true, html: true)
59
59
  result = []
60
60
  index = 0
61
- while index < text.length
61
+ while (index = text.index(/[\\`<!\[]/, index))
62
62
  if text[index] == '\\' && text[index + 1]&.match?(/[[:punct:]]/)
63
63
  result << Span.new(begin_pos: index, end_pos: [index + 2, text.length].min, kind: :escape)
64
64
  index += 2
@@ -132,7 +132,8 @@ module RuboCop
132
132
 
133
133
  def self.mask(text, include_code: false, document: nil, number: nil)
134
134
  result = text.dup
135
- spans(text).each do |span|
135
+ protected = document && text == document.line(number) ? document.inline_spans(number) : spans(text)
136
+ protected.each do |span|
136
137
  next if include_code && span.kind == :code
137
138
 
138
139
  result[span.begin_pos...span.end_pos] = ' ' * (span.end_pos - span.begin_pos)
@@ -160,8 +161,10 @@ module RuboCop
160
161
  source[range.begin_pos...range.end_pos] = ' ' * range.size
161
162
  end
162
163
  root = PositionedParser.parse(source, hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic]).first
163
- nodes = lambda do |node|
164
- [node] + (node.type == :table ? [] : node.children.flat_map { |child| nodes.call(child) })
164
+ nodes = lambda do |node, found = []|
165
+ found << node
166
+ node.children.each { |child| nodes.call(child, found) } unless node.type == :table
167
+ found
165
168
  end
166
169
  records = nodes.call(root).map { |node| [node, 0, nil] }
167
170
  document.nodes(:table).each do |table|
@@ -169,13 +172,16 @@ module RuboCop
169
172
  while number <= document.lines.length && !document.line(number).strip.empty?
170
173
  # Cell locations are not document-relative; parsing the row as inline prose
171
174
  # recovers offsets without changing the table parser or its AST.
172
- row = PositionedParser.parse("prose #{document.line(number).tr('|', ';')}",
173
- hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic]).first
174
- records.concat(nodes.call(row).map { |node| [node, number - 1, -6] })
175
+ if document.line(number).match?(/[*_]/)
176
+ row = PositionedParser.parse("prose #{document.line(number).tr('|', ';')}",
177
+ hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic]).first
178
+ records.concat(nodes.call(row).map { |node| [node, number - 1, -6] })
179
+ end
175
180
  number += 1
176
181
  end
177
182
  end
178
183
  ranges = { em: [], strong: [] }
184
+ source_lines = {}.compare_by_identity
179
185
  records.each do |node, line_offset, column_offset|
180
186
  next unless ranges.key?(node.type) && node.options[:prose_delimiters]
181
187
 
@@ -187,15 +193,16 @@ module RuboCop
187
193
  through = text.byteslice(0, last)
188
194
  local_line = before.count("\n")
189
195
  last_number = number + through.count("\n") - local_line
190
- first_text = text.lines(chomp: true)[local_line]
191
- last_text = text.lines(chomp: true)[through.count("\n")]
196
+ lines = source_lines[text] ||= text.lines(chomp: true)
197
+ first_text = lines[local_line]
198
+ last_text = lines[through.count("\n")]
192
199
  first_prefix = column_offset || document.line(number).rindex(first_text)
193
200
  last_prefix = column_offset || document.line(last_number).rindex(last_text)
194
201
  next unless first_prefix && last_prefix
195
202
 
196
203
  column = first_prefix + before.rpartition("\n").last.length
197
204
  last_column = last_prefix + through.rpartition("\n").last.length
198
- next if spans(document.line(number)).any? { |span| span.begin_pos <= column && span.end_pos > column }
205
+ next if document.inline_spans(number).any? { |span| span.begin_pos <= column && span.end_pos > column }
199
206
 
200
207
  position = document.range(number, column, number, column + 1)
201
208
  next if multiline_atoms(document).any? { |range| range.contains?(position) }
@@ -22,6 +22,9 @@ module RuboCop
22
22
  @links = []
23
23
  @definitions = []
24
24
  @html_anchors = []
25
+ @inline_destinations = {}
26
+ @definition_destinations = {}
27
+ @display_text = {}
25
28
  exclude_blocks
26
29
  exclude_spans
27
30
  scan_definitions
@@ -93,8 +96,10 @@ module RuboCop
93
96
  def display_text(link)
94
97
  return link.destination if link.style == 'autolink'
95
98
 
96
- root = Parser.parse(link.label).first
97
- self.class.plain_text(root).gsub(/[[:space:]]+/, ' ').strip
99
+ @display_text.fetch(link.label) do
100
+ root = Parser.parse(link.label).first
101
+ @display_text[link.label] = self.class.plain_text(root).gsub(/[[:space:]]+/, ' ').strip
102
+ end
98
103
  end
99
104
 
100
105
  def self.plain_text(node)
@@ -281,7 +286,7 @@ module RuboCop
281
286
  end
282
287
 
283
288
  def codespan_end(index)
284
- run = @text[index..][/\A`+/]
289
+ run = /\G`+/.match(@text, index)[0]
285
290
  cursor = index + run.length
286
291
  while (match = /`+/.match(@text, cursor))
287
292
  return match.end(0) if match[0].length == run.length
@@ -373,8 +378,9 @@ module RuboCop
373
378
 
374
379
  depth = 1
375
380
  cursor = index + 1
376
- while cursor < text.length
377
- return if text[cursor] == "\n" && text[cursor..].match?(/\A\n[ \t\r]*\n/)
381
+ candidates = opening == '[' ? /[\[\]\n]/ : /[()\n]/
382
+ while (cursor = text.index(candidates, cursor))
383
+ return if text[cursor] == "\n" && /\G\n[ \t\r]*\n/.match?(text, cursor)
378
384
 
379
385
  unless escaped?(cursor)
380
386
  depth += 1 if text[cursor] == opening
@@ -391,8 +397,9 @@ module RuboCop
391
397
 
392
398
  depth = 1
393
399
  cursor = ending - 1
394
- while cursor >= 0
395
- return if @text[cursor] == "\n" && @text[cursor..ending].match?(/\A\n[ \t\r]*\n/)
400
+ while cursor >= 0 && (cursor = @text.rindex(/[\[\]\n]/, cursor))
401
+ return if @text[cursor] == "\n" && (blank = /\G\n[ \t\r]*\n/.match(@text, cursor)) &&
402
+ blank.end(0) <= ending + 1
396
403
 
397
404
  unless escaped?(cursor)
398
405
  depth += 1 if @text[cursor] == ']'
@@ -405,7 +412,13 @@ module RuboCop
405
412
  end
406
413
 
407
414
  def unescaped_brackets?(first, last)
408
- (first...last).any? { |index| ['[', ']'].include?(@source[index]) && !escaped?(index) }
415
+ cursor = first
416
+ while (cursor = @source.index(/[\[\]]/, cursor)) && cursor < last
417
+ return true unless escaped?(cursor)
418
+
419
+ cursor += 1
420
+ end
421
+ false
409
422
  end
410
423
 
411
424
  def whitespace(index, text = @text)
@@ -413,7 +426,7 @@ module RuboCop
413
426
  while text[cursor]&.match?(/[ \t\r\n]/)
414
427
  newline = text[cursor] == "\n"
415
428
  cursor += 1
416
- cursor += text[cursor..][/\A[ \t]*(?:>[ \t]*)+/].to_s.length if newline
429
+ cursor += /\G[ \t]*(?:>[ \t]*)+/.match(text, cursor).to_s.length if newline
417
430
  end
418
431
  return if text[index...cursor].count("\n") > 1
419
432
 
@@ -469,6 +482,10 @@ module RuboCop
469
482
  end
470
483
 
471
484
  def inline_destination(index)
485
+ @inline_destinations.fetch(index) { @inline_destinations[index] = read_inline_destination(index) }
486
+ end
487
+
488
+ def read_inline_destination(index)
472
489
  first = whitespace(index + 1, @source)
473
490
  return unless first
474
491
 
@@ -490,6 +507,10 @@ module RuboCop
490
507
  end
491
508
 
492
509
  def definition_destination(index)
510
+ @definition_destinations.fetch(index) { @definition_destinations[index] = read_definition_destination(index) }
511
+ end
512
+
513
+ def read_definition_destination(index)
493
514
  start = whitespace(index, @source)
494
515
  result = read_destination(start) if start
495
516
  return unless result
@@ -497,8 +518,8 @@ module RuboCop
497
518
  value, finish, destination_first, destination_last = result
498
519
  gap = whitespace(finish, @source)
499
520
  title = title_end(gap) if gap && gap > finish
500
- finish = title if title && @source[title..].match?(/\A[ \t]*(?:\r?\n|\z)/)
501
- return unless @source[finish..].match?(/\A[ \t]*(?:\r?\n|\z)/)
521
+ finish = title if title && /\G[ \t]*(?:\r?\n|\z)/.match?(@source, title)
522
+ return unless /\G[ \t]*(?:\r?\n|\z)/.match?(@source, finish)
502
523
  return if value.empty? && @source[start] != '<'
503
524
 
504
525
  [value, finish, title && @source[(gap + 1)...(title - 1)], destination_first, destination_last]
@@ -549,7 +570,7 @@ module RuboCop
549
570
  links << link
550
571
  # Images may supply the accessible text of an outer link.
551
572
  nested = cursor + 1
552
- while !link.image && nested < link.label_end_pos
573
+ while !link.image && (nested = @text.index('[', nested)) && nested < link.label_end_pos
553
574
  if @text[nested] == '[' && @text[nested - 1] == '!' && !escaped?(nested - 1)
554
575
  image = parse_link(nested)
555
576
  links << image if image&.image && image.end_pos <= link.label_end_pos
@@ -607,7 +628,7 @@ module RuboCop
607
628
  end
608
629
 
609
630
  cursor = outer.label_begin_pos
610
- while cursor < outer.label_end_pos
631
+ while (cursor = @text.index('[', cursor)) && cursor < outer.label_end_pos
611
632
  if @text[cursor] == '[' && !escaped?(cursor)
612
633
  inner = parse_link(cursor)
613
634
  return true if inner && !inner.image && resolved?(inner) && inner.end_pos <= outer.label_end_pos
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'table_rules'
4
+
3
5
  module RuboCop
4
6
  module Prose
5
7
  class MarkdownDocument < Document
@@ -20,6 +22,7 @@ module RuboCop
20
22
 
21
23
  super(buffer)
22
24
  @front_matter = source[FRONT_MATTER]
25
+ @front_matter_lines = @front_matter&.lines&.length
23
26
  @ignore_front_matter = ignore_front_matter
24
27
  end
25
28
 
@@ -48,6 +51,19 @@ module RuboCop
48
51
  @link_rules ||= LinkRules.new(self)
49
52
  end
50
53
 
54
+ def tables
55
+ @tables ||= TableRules.tables(self)
56
+ end
57
+
58
+ def inline_lines
59
+ @inline_lines ||= InlineRules.lines(self)
60
+ end
61
+
62
+ def inline_spans(number)
63
+ @inline_spans ||= {}
64
+ @inline_spans[number] ||= InlineRules.spans(line(number))
65
+ end
66
+
51
67
  def emphasis_ranges(type)
52
68
  @emphasis_ranges ||= InlineRules.emphasis(self)
53
69
  @emphasis_ranges.fetch(type, [])
@@ -70,7 +86,7 @@ module RuboCop
70
86
  end
71
87
 
72
88
  def front_matter_line?(number)
73
- @ignore_front_matter && front_matter && number <= front_matter.lines.length
89
+ @ignore_front_matter && @front_matter_lines && number <= @front_matter_lines
74
90
  end
75
91
 
76
92
  def code_line?(number)
@@ -128,8 +144,11 @@ module RuboCop
128
144
 
129
145
  def atomic_ranges(paragraph)
130
146
  paragraph.options[:original_ranges] ||= begin
131
- inline = ::Parser::Source::Buffer.new('(paragraph)', source: paragraph.options[:prose_source])
132
- paragraph.options[:prose_atoms].map do |first, last|
147
+ atoms = paragraph.options[:prose_atoms]
148
+ unless atoms.empty?
149
+ inline = ::Parser::Source::Buffer.new('(paragraph)', source: paragraph.options[:prose_source])
150
+ end
151
+ atoms.map do |first, last|
133
152
  start_line, start_column = inline.decompose_position(first)
134
153
  end_line, end_column = inline.decompose_position(last)
135
154
  first_line = paragraph.options[:location] + start_line - 1
@@ -153,14 +172,17 @@ module RuboCop
153
172
  end
154
173
  end
155
174
 
156
- def walk(node)
157
- [node] + node.children.flat_map { |child| walk(child) }
175
+ def walk(node, found = [])
176
+ found << node
177
+ node.children.each { |child| walk(child, found) }
178
+ found
158
179
  end
159
180
 
160
- def find_hard_breaks(node)
161
- node.children.flat_map do |child|
162
- child.type == :br ? [child] : find_hard_breaks(child)
181
+ def find_hard_breaks(node, found = [])
182
+ node.children.each do |child|
183
+ child.type == :br ? found << child : find_hard_breaks(child, found)
163
184
  end
185
+ found
164
186
  end
165
187
  end
166
188
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ module ProperNameRules
6
+ private
7
+
8
+ def investigate_document
9
+ names = Array(cop_config['Names']).reject(&:empty?)
10
+ return if names.empty?
11
+
12
+ alternatives = names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join('|')
13
+ pattern = /(?<![\p{L}\p{N}_])(?:#{alternatives})(?![\p{L}\p{N}_])/i
14
+ name_lines.each do |text, number, column|
15
+ text.to_enum(:scan, pattern).each do
16
+ match = Regexp.last_match
17
+ next if match.begin(0) < column
18
+
19
+ expected = names.find { |name| name.casecmp?(match[0]) }
20
+ next if match[0] == expected
21
+
22
+ range = document.range(number, match.begin(0), number, match.end(0))
23
+ prose_offense(range, message: "Use the capitalization `#{expected}`.") do |corrector|
24
+ corrector.replace(processed_range(range), expected)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -64,7 +64,7 @@ module RuboCop
64
64
  end
65
65
 
66
66
  def table_boundaries
67
- TableRules.tables(document).each do |table|
67
+ document.tables.each do |table|
68
68
  protect(table.rows.first.number - 1, -1, 1, front_matter: false)
69
69
  protect(table.rows.last.number + 1, 1, 1)
70
70
  end
@@ -41,7 +41,7 @@ module RuboCop
41
41
  def self.tables(document)
42
42
  # A malformed body can make GFM reject the entire table. Its header and delimiter
43
43
  # still identify the intended table, but only inside parser-recognized prose.
44
- eligible = InlineRules.lines(document).to_set { |_, number, _| number }
44
+ eligible = document.inline_lines.to_set { |_, number, _| number }
45
45
  result = []
46
46
  number = 1
47
47
  while number < document.lines.length
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Prose
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
data/lib/rubocop-prose.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'rubocop'
4
4
  require 'yaml'
5
+
6
+ RuboCop::ConfigObsoletion.files << File.expand_path('../config/obsoletion.yml', __dir__)
7
+
5
8
  require_relative 'rubocop/prose/version'
6
9
  require_relative 'rubocop/prose/parser'
7
10
  require_relative 'rubocop/prose/file_types'