rubocop-prose 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.
Files changed (228) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +13 -0
  3. data/CHANGELOG.md +82 -0
  4. data/CONTRIBUTING.md +77 -0
  5. data/DESIGN.md +51 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +152 -0
  8. data/RELEASING.md +32 -0
  9. data/assets/README.md +3 -0
  10. data/assets/RUBOCOP-LICENSE +20 -0
  11. data/assets/logo.gif +0 -0
  12. data/assets/logo.png +0 -0
  13. data/assets/logo.svg +80 -0
  14. data/assets/rubocop-logo.svg +1 -0
  15. data/config/cops/markdown/atx_heading_space.yml +3 -0
  16. data/config/cops/markdown/atx_heading_spacing.yml +3 -0
  17. data/config/cops/markdown/bare_url.yml +7 -0
  18. data/config/cops/markdown/blockquote_blank_lines.yml +3 -0
  19. data/config/cops/markdown/blockquote_spacing.yml +3 -0
  20. data/config/cops/markdown/closed_heading_space.yml +3 -0
  21. data/config/cops/markdown/closed_heading_spacing.yml +3 -0
  22. data/config/cops/markdown/code_block_style.yml +8 -0
  23. data/config/cops/markdown/code_span_spacing.yml +3 -0
  24. data/config/cops/markdown/compact_lists.yml +3 -0
  25. data/config/cops/markdown/descriptive_link_text.yml +8 -0
  26. data/config/cops/markdown/duplicate_headings.yml +4 -0
  27. data/config/cops/markdown/emphasis_heading.yml +3 -0
  28. data/config/cops/markdown/emphasis_spacing.yml +3 -0
  29. data/config/cops/markdown/emphasis_style.yml +4 -0
  30. data/config/cops/markdown/empty_link.yml +3 -0
  31. data/config/cops/markdown/fence_blank_lines.yml +6 -0
  32. data/config/cops/markdown/fence_language.yml +4 -0
  33. data/config/cops/markdown/fence_style.yml +8 -0
  34. data/config/cops/markdown/first_heading.yml +6 -0
  35. data/config/cops/markdown/front_matter.yml +2 -0
  36. data/config/cops/markdown/hard_break_style.yml +4 -0
  37. data/config/cops/markdown/hard_tabs.yml +5 -0
  38. data/config/cops/markdown/heading_blank_lines.yml +5 -0
  39. data/config/cops/markdown/heading_increment.yml +3 -0
  40. data/config/cops/markdown/heading_indentation.yml +3 -0
  41. data/config/cops/markdown/heading_punctuation.yml +4 -0
  42. data/config/cops/markdown/heading_structure.yml +4 -0
  43. data/config/cops/markdown/heading_style.yml +9 -0
  44. data/config/cops/markdown/image_alt_text.yml +3 -0
  45. data/config/cops/markdown/image_path_separators.yml +3 -0
  46. data/config/cops/markdown/link_fragment.yml +6 -0
  47. data/config/cops/markdown/link_style.yml +10 -0
  48. data/config/cops/markdown/link_text_spacing.yml +3 -0
  49. data/config/cops/markdown/list_blank_lines.yml +3 -0
  50. data/config/cops/markdown/list_continuation_indentation.yml +3 -0
  51. data/config/cops/markdown/list_indentation.yml +3 -0
  52. data/config/cops/markdown/list_marker_spacing.yml +7 -0
  53. data/config/cops/markdown/ordered_list_numbering.yml +7 -0
  54. data/config/cops/markdown/outside_spacing.yml +3 -0
  55. data/config/cops/markdown/proper_names.yml +5 -0
  56. data/config/cops/markdown/raw_html.yml +4 -0
  57. data/config/cops/markdown/reference_definitions.yml +8 -0
  58. data/config/cops/markdown/reversed_link.yml +3 -0
  59. data/config/cops/markdown/shell_prompts.yml +13 -0
  60. data/config/cops/markdown/single_top_level_heading.yml +4 -0
  61. data/config/cops/markdown/strong_style.yml +4 -0
  62. data/config/cops/markdown/structural_line_length.yml +9 -0
  63. data/config/cops/markdown/syntax.yml +3 -0
  64. data/config/cops/markdown/table_blank_lines.yml +3 -0
  65. data/config/cops/markdown/table_columns.yml +3 -0
  66. data/config/cops/markdown/table_pipes.yml +4 -0
  67. data/config/cops/markdown/table_spacing.yml +4 -0
  68. data/config/cops/markdown/thematic_break_blank_lines.yml +5 -0
  69. data/config/cops/markdown/thematic_break_style.yml +4 -0
  70. data/config/cops/markdown/trailing_whitespace.yml +5 -0
  71. data/config/cops/markdown/unclosed_fenced_code_block.yml +3 -0
  72. data/config/cops/markdown/undefined_reference.yml +4 -0
  73. data/config/cops/markdown/unordered_list_indentation.yml +4 -0
  74. data/config/cops/markdown/unordered_list_marker.yml +9 -0
  75. data/config/cops/prose/max_length.yml +5 -0
  76. data/config/cops/prose/multiple_blank_lines.yml +4 -0
  77. data/config/cops/prose/trailing_newline.yml +3 -0
  78. data/config/cops/text/trailing_whitespace.yml +3 -0
  79. data/config/default.yml +16 -0
  80. data/docs/cops/markdown/atx_heading_space.md +26 -0
  81. data/docs/cops/markdown/atx_heading_spacing.md +24 -0
  82. data/docs/cops/markdown/bare_url.md +30 -0
  83. data/docs/cops/markdown/blockquote_blank_lines.md +30 -0
  84. data/docs/cops/markdown/blockquote_spacing.md +30 -0
  85. data/docs/cops/markdown/closed_heading_space.md +26 -0
  86. data/docs/cops/markdown/closed_heading_spacing.md +24 -0
  87. data/docs/cops/markdown/code_block_style.md +37 -0
  88. data/docs/cops/markdown/code_span_spacing.md +26 -0
  89. data/docs/cops/markdown/compact_lists.md +29 -0
  90. data/docs/cops/markdown/descriptive_link_text.md +31 -0
  91. data/docs/cops/markdown/duplicate_headings.md +35 -0
  92. data/docs/cops/markdown/emphasis_heading.md +26 -0
  93. data/docs/cops/markdown/emphasis_spacing.md +24 -0
  94. data/docs/cops/markdown/emphasis_style.md +34 -0
  95. data/docs/cops/markdown/empty_link.md +26 -0
  96. data/docs/cops/markdown/fence_blank_lines.md +43 -0
  97. data/docs/cops/markdown/fence_language.md +31 -0
  98. data/docs/cops/markdown/fence_style.md +38 -0
  99. data/docs/cops/markdown/first_heading.md +31 -0
  100. data/docs/cops/markdown/hard_break_style.md +50 -0
  101. data/docs/cops/markdown/hard_tabs.md +30 -0
  102. data/docs/cops/markdown/heading_blank_lines.md +36 -0
  103. data/docs/cops/markdown/heading_increment.md +30 -0
  104. data/docs/cops/markdown/heading_indentation.md +26 -0
  105. data/docs/cops/markdown/heading_punctuation.md +27 -0
  106. data/docs/cops/markdown/heading_structure.md +41 -0
  107. data/docs/cops/markdown/heading_style.md +36 -0
  108. data/docs/cops/markdown/image_alt_text.md +26 -0
  109. data/docs/cops/markdown/image_path_separators.md +32 -0
  110. data/docs/cops/markdown/link_fragment.md +35 -0
  111. data/docs/cops/markdown/link_style.md +49 -0
  112. data/docs/cops/markdown/link_text_spacing.md +26 -0
  113. data/docs/cops/markdown/list_blank_lines.md +31 -0
  114. data/docs/cops/markdown/list_continuation_indentation.md +28 -0
  115. data/docs/cops/markdown/list_indentation.md +28 -0
  116. data/docs/cops/markdown/list_marker_spacing.md +32 -0
  117. data/docs/cops/markdown/ordered_list_numbering.md +29 -0
  118. data/docs/cops/markdown/outside_spacing.md +26 -0
  119. data/docs/cops/markdown/proper_names.md +39 -0
  120. data/docs/cops/markdown/raw_html.md +27 -0
  121. data/docs/cops/markdown/reference_definitions.md +36 -0
  122. data/docs/cops/markdown/reversed_link.md +26 -0
  123. data/docs/cops/markdown/shell_prompts.md +57 -0
  124. data/docs/cops/markdown/single_top_level_heading.md +31 -0
  125. data/docs/cops/markdown/strong_style.md +34 -0
  126. data/docs/cops/markdown/structural_line_length.md +40 -0
  127. data/docs/cops/markdown/syntax.md +28 -0
  128. data/docs/cops/markdown/table_blank_lines.md +34 -0
  129. data/docs/cops/markdown/table_columns.md +30 -0
  130. data/docs/cops/markdown/table_pipes.md +38 -0
  131. data/docs/cops/markdown/table_spacing.md +38 -0
  132. data/docs/cops/markdown/thematic_break_blank_lines.md +31 -0
  133. data/docs/cops/markdown/thematic_break_style.md +48 -0
  134. data/docs/cops/markdown/trailing_whitespace.md +30 -0
  135. data/docs/cops/markdown/unclosed_fenced_code_block.md +29 -0
  136. data/docs/cops/markdown/undefined_reference.md +29 -0
  137. data/docs/cops/markdown/unordered_list_indentation.md +45 -0
  138. data/docs/cops/markdown/unordered_list_marker.md +36 -0
  139. data/docs/cops/prose/max_length.md +33 -0
  140. data/docs/cops/prose/multiple_blank_lines.md +50 -0
  141. data/docs/cops/prose/trailing_newline.md +26 -0
  142. data/docs/cops/text/trailing_whitespace.md +26 -0
  143. data/docs/settings/files.md +33 -0
  144. data/docs/settings/front_matter.md +12 -0
  145. data/lib/rubocop/cop/markdown/atx_heading_space.rb +22 -0
  146. data/lib/rubocop/cop/markdown/atx_heading_spacing.rb +27 -0
  147. data/lib/rubocop/cop/markdown/bare_url.rb +22 -0
  148. data/lib/rubocop/cop/markdown/base.rb +17 -0
  149. data/lib/rubocop/cop/markdown/blockquote_blank_lines.rb +49 -0
  150. data/lib/rubocop/cop/markdown/blockquote_spacing.rb +76 -0
  151. data/lib/rubocop/cop/markdown/closed_heading_space.rb +26 -0
  152. data/lib/rubocop/cop/markdown/closed_heading_spacing.rb +27 -0
  153. data/lib/rubocop/cop/markdown/code_block_style.rb +80 -0
  154. data/lib/rubocop/cop/markdown/code_span_spacing.rb +51 -0
  155. data/lib/rubocop/cop/markdown/compact_lists.rb +43 -0
  156. data/lib/rubocop/cop/markdown/descriptive_link_text.rb +27 -0
  157. data/lib/rubocop/cop/markdown/duplicate_headings.rb +28 -0
  158. data/lib/rubocop/cop/markdown/emphasis_heading.rb +18 -0
  159. data/lib/rubocop/cop/markdown/emphasis_spacing.rb +30 -0
  160. data/lib/rubocop/cop/markdown/emphasis_style.rb +34 -0
  161. data/lib/rubocop/cop/markdown/empty_link.rb +25 -0
  162. data/lib/rubocop/cop/markdown/fence_blank_lines.rb +72 -0
  163. data/lib/rubocop/cop/markdown/fence_language.rb +32 -0
  164. data/lib/rubocop/cop/markdown/fence_style.rb +39 -0
  165. data/lib/rubocop/cop/markdown/first_heading.rb +44 -0
  166. data/lib/rubocop/cop/markdown/hard_break_style.rb +65 -0
  167. data/lib/rubocop/cop/markdown/hard_tabs.rb +32 -0
  168. data/lib/rubocop/cop/markdown/heading_blank_lines.rb +37 -0
  169. data/lib/rubocop/cop/markdown/heading_increment.rb +23 -0
  170. data/lib/rubocop/cop/markdown/heading_indentation.rb +37 -0
  171. data/lib/rubocop/cop/markdown/heading_punctuation.rb +23 -0
  172. data/lib/rubocop/cop/markdown/heading_structure.rb +33 -0
  173. data/lib/rubocop/cop/markdown/heading_style.rb +24 -0
  174. data/lib/rubocop/cop/markdown/image_alt_text.rb +24 -0
  175. data/lib/rubocop/cop/markdown/image_path_separators.rb +32 -0
  176. data/lib/rubocop/cop/markdown/link_fragment.rb +36 -0
  177. data/lib/rubocop/cop/markdown/link_style.rb +29 -0
  178. data/lib/rubocop/cop/markdown/link_text_spacing.rb +34 -0
  179. data/lib/rubocop/cop/markdown/list_blank_lines.rb +38 -0
  180. data/lib/rubocop/cop/markdown/list_continuation_indentation.rb +59 -0
  181. data/lib/rubocop/cop/markdown/list_indentation.rb +28 -0
  182. data/lib/rubocop/cop/markdown/list_marker_spacing.rb +44 -0
  183. data/lib/rubocop/cop/markdown/ordered_list_numbering.rb +35 -0
  184. data/lib/rubocop/cop/markdown/outside_spacing.rb +88 -0
  185. data/lib/rubocop/cop/markdown/proper_names.rb +51 -0
  186. data/lib/rubocop/cop/markdown/raw_html.rb +60 -0
  187. data/lib/rubocop/cop/markdown/reference_definitions.rb +37 -0
  188. data/lib/rubocop/cop/markdown/reversed_link.rb +20 -0
  189. data/lib/rubocop/cop/markdown/shell_prompts.rb +72 -0
  190. data/lib/rubocop/cop/markdown/single_top_level_heading.rb +19 -0
  191. data/lib/rubocop/cop/markdown/strong_style.rb +33 -0
  192. data/lib/rubocop/cop/markdown/structural_line_length.rb +51 -0
  193. data/lib/rubocop/cop/markdown/syntax.rb +33 -0
  194. data/lib/rubocop/cop/markdown/table_blank_lines.rb +46 -0
  195. data/lib/rubocop/cop/markdown/table_columns.rb +23 -0
  196. data/lib/rubocop/cop/markdown/table_pipes.rb +45 -0
  197. data/lib/rubocop/cop/markdown/table_spacing.rb +62 -0
  198. data/lib/rubocop/cop/markdown/thematic_break_blank_lines.rb +115 -0
  199. data/lib/rubocop/cop/markdown/thematic_break_style.rb +74 -0
  200. data/lib/rubocop/cop/markdown/trailing_whitespace.rb +55 -0
  201. data/lib/rubocop/cop/markdown/unclosed_fenced_code_block.rb +82 -0
  202. data/lib/rubocop/cop/markdown/undefined_reference.rb +25 -0
  203. data/lib/rubocop/cop/markdown/unordered_list_indentation.rb +37 -0
  204. data/lib/rubocop/cop/markdown/unordered_list_marker.rb +45 -0
  205. data/lib/rubocop/cop/prose/base.rb +55 -0
  206. data/lib/rubocop/cop/prose/max_length.rb +93 -0
  207. data/lib/rubocop/cop/prose/multiple_blank_lines.rb +96 -0
  208. data/lib/rubocop/cop/prose/trailing_newline.rb +26 -0
  209. data/lib/rubocop/cop/text/base.rb +17 -0
  210. data/lib/rubocop/cop/text/trailing_whitespace.rb +25 -0
  211. data/lib/rubocop/prose/code_rules.rb +79 -0
  212. data/lib/rubocop/prose/document.rb +62 -0
  213. data/lib/rubocop/prose/file_types.rb +56 -0
  214. data/lib/rubocop/prose/heading_rules.rb +54 -0
  215. data/lib/rubocop/prose/inline_rules.rb +213 -0
  216. data/lib/rubocop/prose/link_rules.rb +630 -0
  217. data/lib/rubocop/prose/list_rules.rb +65 -0
  218. data/lib/rubocop/prose/markdown_document.rb +167 -0
  219. data/lib/rubocop/prose/parser.rb +80 -0
  220. data/lib/rubocop/prose/plugin.rb +30 -0
  221. data/lib/rubocop/prose/runner.rb +63 -0
  222. data/lib/rubocop/prose/source.rb +82 -0
  223. data/lib/rubocop/prose/structural_blank_lines.rb +90 -0
  224. data/lib/rubocop/prose/table_rules.rb +71 -0
  225. data/lib/rubocop/prose/text_document.rb +54 -0
  226. data/lib/rubocop/prose/version.rb +7 -0
  227. data/lib/rubocop-prose.rb +30 -0
  228. metadata +335 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class BlockquoteBlankLines < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::ListRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ inspect_children(document.root)
16
+ end
17
+
18
+ def inspect_children(parent)
19
+ previous = nil
20
+ parent.children.each do |child|
21
+ if child.type == :blockquote
22
+ connect_quotes(previous, child) if previous
23
+ previous = child
24
+ elsif child.type != :blank
25
+ previous = nil
26
+ end
27
+ inspect_children(child)
28
+ end
29
+ end
30
+
31
+ def connect_quotes(first, last)
32
+ start = last.options[:location]
33
+ prefix = document.line(start)[/\A[ \t]*(?:>[ \t]*)+/]
34
+ return unless prefix
35
+
36
+ ((node_last_line(first) + 1)...start).each do |number|
37
+ next unless container_blank?(number)
38
+
39
+ range = document.line_range(number)
40
+ prose_offense(range,
41
+ message: 'Keep blockquote markers on blank lines between quoted blocks.') do |corrector|
42
+ corrector.replace(processed_range(range), prefix.rstrip)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class BlockquoteSpacing < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::ListRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ quote_depths = Hash.new(0)
16
+ document.nodes(:blockquote).each do |node|
17
+ (node.options[:location]..node_last_line(node)).each { |number| quote_depths[number] += 1 }
18
+ end
19
+ list_lines = []
20
+ list_groups do |_list, marks, _parent|
21
+ marks.each { |mark| list_lines.concat((mark[:line]..node_last_line(mark[:item])).to_a) }
22
+ end
23
+ document.prose_lines do |text, number|
24
+ next unless quote_depths[number].positive?
25
+
26
+ prefix = text[/\A[ \t]*(?:>[ \t]*)+/]
27
+ next unless prefix
28
+
29
+ markers = prefix.to_enum(:scan, />/).map { Regexp.last_match.begin(0) }
30
+ prefix.to_enum(:scan, />[ \t]{2,}/).each do
31
+ match = Regexp.last_match
32
+ next if match.end(0) == prefix.length && (list_lines.include?(number) || text[prefix.length..].empty?)
33
+
34
+ range = document.range(number, match.begin(0) + 1, number, match.end(0))
35
+ prose_offense(range, message: 'Use one space after a blockquote marker.') do |corrector|
36
+ corrector.replace(processed_range(range), ' ')
37
+ end
38
+ end
39
+
40
+ next unless markers.length == quote_depths[number]
41
+
42
+ markers.each do |column|
43
+ next if text[column + 1]&.match?(/[ \t]/)
44
+ next if text[(column + 1)..].sub(/\A(?:[ \t]*>)*[ \t]*/, '').empty?
45
+
46
+ range = document.range(number, column, number, column + 1)
47
+ prose_offense(range, message: 'Use one space after a blockquote marker.') do |corrector|
48
+ next unless correction_preserves_structure?(number, column)
49
+
50
+ corrector.insert_after(processed_range(range), ' ')
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def correction_preserves_structure?(number, column)
57
+ source = document.source.dup
58
+ line_start = document.buffer.line_range(number).begin_pos
59
+ position = source.byteslice(0, line_start).length + column + 1
60
+ source.insert(position, ' ')
61
+ candidate = RuboCop::Prose::MarkdownDocument.new(
62
+ ::Parser::Source::Buffer.new(document.buffer.name, source: source),
63
+ configuration: config
64
+ )
65
+ tree_signature(document.root) == tree_signature(candidate.root) &&
66
+ document.warnings == candidate.warnings
67
+ end
68
+
69
+ def tree_signature(node)
70
+ options = node.options.except(:prose_lines, :original_ranges)
71
+ [node.type, node.value, options, node.children.map { |child| tree_signature(child) }]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/heading_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class ClosedHeadingSpace < Base
9
+ include RuboCop::Prose::HeadingRules
10
+
11
+ def investigate_document
12
+ heading_entries.each do |heading|
13
+ opening = heading[:opening]
14
+ next unless opening && !heading[:closing]
15
+
16
+ # An escaped final hash is text, not an intended closing marker.
17
+ ending = /(?<slashes>\\*)(?<hashes>#+)[ \t]*\z/.match(opening[:rest])
18
+ next unless ending && ending[:slashes].length.even?
19
+
20
+ prose_offense(heading_range(heading), message: 'Separate closing heading hashes from text with a space.')
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/heading_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class ClosedHeadingSpacing < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::HeadingRules
11
+
12
+ def investigate_document
13
+ heading_entries.each do |heading|
14
+ closing = heading[:closing]
15
+ next unless closing && closing[:space].length > 1
16
+
17
+ column = heading[:opening].begin(:rest) + closing.begin(:space)
18
+ range = document.range(heading[:line], column, heading[:line], column + closing[:space].length)
19
+ prose_offense(range, message: 'Use one space before closing heading hashes.') do |corrector|
20
+ corrector.replace(processed_range(range), ' ')
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/code_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class CodeBlockStyle < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::CodeRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ expected = cop_config.fetch('EnforcedStyle', 'consistent')
16
+ code_blocks.each do |node|
17
+ actual = node.options[:fenced] ? 'fenced' : 'indented'
18
+ expected = actual if expected == 'consistent'
19
+ next if actual == expected
20
+
21
+ range = document.line_range(node.options[:location])
22
+ prose_offense(range, message: "Use #{expected} code blocks.") do |corrector|
23
+ conversion = safe_conversion(node, expected)
24
+ corrector.replace(processed_range(conversion.first), conversion.last) if conversion
25
+ end
26
+ end
27
+ end
28
+
29
+ def safe_conversion(node, expected)
30
+ return unless code_ancestors(node).all? { |ancestor| ancestor.type == :root }
31
+
32
+ expected == 'fenced' ? to_fenced(node) : to_indented(node)
33
+ end
34
+
35
+ def to_fenced(node)
36
+ first = node.options.fetch(:code_start_line)
37
+ last = first + node.value.lines.length - 1
38
+ return if last < first || !code_blank?(first - 1) || !code_blank?(last + 1)
39
+
40
+ raw = document.lines[(first - 1)..(last - 1)]
41
+ return unless raw.all? { |line| line.start_with?(' ') || line.empty? }
42
+ return unless "#{raw.map { |line| line.delete_prefix(' ') }.join("\n")}\n" == node.value
43
+
44
+ length = [3, node.value.scan(/`+/).map(&:length).max.to_i + 1].max
45
+ fence = '`' * length
46
+ [document.range(first, 0, last), "#{fence}\n#{node.value}#{fence}"]
47
+ end
48
+
49
+ def to_indented(node)
50
+ details = fence_details(node)
51
+ return unless details && details[:opening][:prefix].empty? && details[:opening][:info].strip.empty?
52
+ return unless details[:closing][:prefix].empty?
53
+ return unless code_blank?(details[:first] - 1) && code_blank?(details[:last] + 1)
54
+ return if adjacent_code_block?(node, details)
55
+
56
+ body = document.lines[details[:first]...(details[:last] - 1)]
57
+ return if body.empty? || body.first.strip.empty? || body.last.strip.empty?
58
+ return unless "#{body.join("\n")}\n" == node.value
59
+
60
+ [document.range(details[:first], 0, details[:last]),
61
+ body.map { |line| line.empty? ? '' : " #{line}" }.join("\n")]
62
+ end
63
+
64
+ def adjacent_code_block?(node, details)
65
+ blocks = code_blocks
66
+ index = blocks.index(node)
67
+ neighbors = []
68
+ neighbors << blocks[index - 1] if index.positive?
69
+ neighbors << blocks[index + 1] if index + 1 < blocks.length
70
+ neighbors.any? do |other|
71
+ first = other.options[:location]
72
+ last = other.options[:code_start_line] + other.value.lines.length - (other.options[:fenced] ? 0 : 1)
73
+ gap = last < details[:first] ? ((last + 1)...details[:first]) : ((details[:last] + 1)...first)
74
+ gap.all? { |number| document.line(number).strip.empty? }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/inline_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class CodeSpanSpacing < Base
9
+ extend AutoCorrector
10
+
11
+ def investigate_document
12
+ multiline = RuboCop::Prose::InlineRules.multiline_atoms(document)
13
+ .select { |range| range.source.start_with?('`') }
14
+ RuboCop::Prose::InlineRules.lines(document).each do |text, number, column|
15
+ RuboCop::Prose::InlineRules.spans(text).each do |span|
16
+ next unless span.kind == :code && span.begin_pos >= column
17
+
18
+ position = document.range(number, span.begin_pos, number, span.end_pos)
19
+ next if multiline.any? { |range| range.overlaps?(position) }
20
+
21
+ width = span.delimiter.length
22
+ content = text[(span.begin_pos + width)...(span.end_pos - width)]
23
+ next if content.strip.empty? || content == content.strip
24
+ # One space on each side is the Markdown escape for literal edge backticks.
25
+ next if content.start_with?(' ') && content.end_with?(' ') &&
26
+ (content[1] == '`' || content[-2] == '`')
27
+
28
+ range = document.range(number, span.begin_pos, number, span.end_pos)
29
+ prose_offense(range, message: 'Avoid unnecessary padding inside code spans.') do |corrector|
30
+ # Only symmetric single-space padding can be removed without changing the value.
31
+ next unless content.start_with?(' ') && content.end_with?(' ')
32
+ next if content[1] == ' ' || content[-2] == ' '
33
+
34
+ corrector.replace(processed_range(range), "#{span.delimiter}#{content[1...-1]}#{span.delimiter}")
35
+ end
36
+ end
37
+ multiline.each do |range|
38
+ delimiter = range.source[/\A`+/]
39
+ content = range.source[delimiter.length...-delimiter.length]
40
+ next if content.strip.empty? || content == content.strip
41
+ next if content.start_with?(' ') && content.end_with?(' ') &&
42
+ (content[1] == '`' || content[-2] == '`')
43
+
44
+ prose_offense(range, message: 'Avoid unnecessary padding inside code spans.')
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class CompactLists < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::ListRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ list_groups do |list, marks, _parent|
16
+ items = list.children.select { |child| child.type == :li }
17
+ next unless marks.length == items.length
18
+
19
+ marks.each_cons(2) do |previous, following|
20
+ first_blank = node_last_line(previous[:item]) + 1
21
+ last_blank = following[:line] - 1
22
+ next unless first_blank <= last_blank
23
+
24
+ blank_lines = (first_blank..last_blank).to_a
25
+ next unless blank_lines.all? { |number| container_blank?(number) }
26
+
27
+ range = document.range(first_blank, 0, following[:line], 0)
28
+ prose_offense(range, message: 'Avoid blank lines between list items.') do |corrector|
29
+ next unless simple_list_item?(previous[:item]) && simple_list_item?(following[:item])
30
+
31
+ corrector.remove(processed_range(range))
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def simple_list_item?(item)
38
+ item.children.one? && item.children.first.type == :p
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/link_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class DescriptiveLinkText < Base
9
+ MSG = 'Use descriptive link text instead of a generic label.'
10
+
11
+ private
12
+
13
+ def investigate_document
14
+ scanner = document.link_rules
15
+ generic = cop_config.fetch('GenericLabels', ['click here', 'here', 'link', 'more'])
16
+ .map { |label| scanner.normalize_text(label) }
17
+ scanner.links.each do |link|
18
+ next if link.image || link.style == 'autolink' || !scanner.resolved?(link)
19
+ next unless generic.include?(scanner.normalize_text(scanner.display_text(link)))
20
+
21
+ prose_offense(scanner.label_range(link))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/heading_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class DuplicateHeadings < Base
9
+ include RuboCop::Prose::HeadingRules
10
+
11
+ def investigate_document
12
+ seen = Set.new
13
+ parents = []
14
+ heading_entries.each do |heading|
15
+ parents.pop while parents.any? && parents.last[:level] >= heading[:level]
16
+ scope = cop_config.fetch('Scope', 'siblings') == 'siblings' ? parents.last&.fetch(:line) : nil
17
+ key = [scope, heading[:text]]
18
+ if seen.include?(key)
19
+ prose_offense(heading_range(heading), message: 'Use unique heading text within the configured scope.')
20
+ end
21
+ seen.add(key)
22
+ parents << heading
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Markdown
6
+ class EmphasisHeading < Base
7
+ def investigate_paragraph(paragraph)
8
+ children = paragraph.children.reject { |child| child.type == :text && child.value.strip.empty? }
9
+ return unless children.one? && %i[em strong].include?(children.first.type)
10
+ return unless document.line_numbers(paragraph).size == 1
11
+
12
+ number = paragraph.options.fetch(:location)
13
+ prose_offense(document.line_range(number), message: 'Use a heading instead of an emphasis-only paragraph.')
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/inline_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class EmphasisSpacing < Base
9
+ extend AutoCorrector
10
+
11
+ def investigate_document
12
+ RuboCop::Prose::InlineRules.lines(document).each do |text, number, column|
13
+ masked = RuboCop::Prose::InlineRules.mask(text, document: document, number: number)
14
+ masked.to_enum(:scan, /(?<![\w*_])(\*{1,3}|_{1,3})(?![*_])(.+?)\1(?![\w*_])/).each do
15
+ match = Regexp.last_match
16
+ content = text[match.begin(2)...match.end(2)]
17
+ next if match.begin(0) < column || content.strip.empty? || content == content.strip
18
+
19
+ range = document.range(number, match.begin(0), number, match.end(0))
20
+ replacement = "#{match[1]}#{text[match.begin(2)...match.end(2)].strip}#{match[1]}"
21
+ prose_offense(range, message: 'Remove spaces inside emphasis delimiters.') do |corrector|
22
+ corrector.replace(processed_range(range), replacement)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/inline_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class EmphasisStyle < Base
9
+ extend AutoCorrector
10
+
11
+ def investigate_document
12
+ expected = cop_config.fetch('Style', 'consistent')
13
+ document.emphasis_ranges(:em).each do |opening, closing, char|
14
+ actual = char == '*' ? 'asterisk' : 'underscore'
15
+ expected = actual if expected == 'consistent'
16
+ next if actual == expected
17
+
18
+ replacement = expected == 'asterisk' ? '*' : '_'
19
+ # Underscores cannot delimit intraword emphasis.
20
+ before = opening.begin_pos.positive? ? document.source[opening.begin_pos - 1] : nil
21
+ after = document.source[closing.end_pos]
22
+
23
+ prose_offense(opening, message: "Use #{expected} emphasis.") do |corrector|
24
+ next if replacement == '_' && (before&.match?(/[[:alnum:]_]/) || after&.match?(/[[:alnum:]_]/))
25
+
26
+ corrector.replace(processed_range(opening), replacement)
27
+ corrector.replace(processed_range(closing), replacement)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/link_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class EmptyLink < Base
9
+ MSG = 'Provide a nonempty link destination.'
10
+
11
+ private
12
+
13
+ def investigate_document
14
+ scanner = document.link_rules
15
+ scanner.links.each do |link|
16
+ next if link.image || !scanner.resolved?(link)
17
+ next unless ['', '#'].include?(scanner.destination(link))
18
+
19
+ prose_offense(scanner.link_range(link))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/code_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class FenceBlankLines < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::CodeRules
11
+
12
+ def validate_config
13
+ %w[LinesAbove LinesBelow].each do |name|
14
+ value = cop_config[name]
15
+ next if value.is_a?(Integer) && value >= 0
16
+
17
+ raise ValidationError, "Markdown/FenceBlankLines: #{name} must be a nonnegative integer."
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def investigate_document
24
+ @insertions = {}
25
+ fenced_blocks(list_items: cop_config.fetch('ListItems', true)) do |details|
26
+ first = details[:first]
27
+ last = details[:last]
28
+ prefix = code_continuation_prefix(details[:opening][:prefix]).rstrip
29
+ inline_item = details[:opening][:prefix].match?(/[-+*]|\d+[.)]/)
30
+ require_blanks(first, prefix, cop_config.fetch('LinesAbove', 1), -1) unless inline_item
31
+ require_blanks(last + 1, prefix, cop_config.fetch('LinesBelow', 1), 1)
32
+ end
33
+ @insertions.each do |number, insertion|
34
+ add_blank(number, insertion[:prefix], insertion[:count])
35
+ end
36
+ end
37
+
38
+ def require_blanks(number, prefix, required, direction)
39
+ return if required.zero?
40
+
41
+ existing = blank_count(direction.negative? ? number - 1 : number, direction)
42
+ missing = required - existing
43
+ return unless missing.positive?
44
+
45
+ # Adjacent fences must share one insertion, even when some padding already exists.
46
+ number -= existing if direction.negative?
47
+ insertion = @insertions[number] ||= { prefix: prefix, count: missing }
48
+ insertion[:count] = [insertion[:count], missing].max
49
+ end
50
+
51
+ def blank_count(number, direction)
52
+ count = 0
53
+ while number.between?(1, document.lines.length)
54
+ return Float::INFINITY if direction.negative? && document.front_matter_line?(number)
55
+ break unless code_blank?(number)
56
+
57
+ count += 1
58
+ number += direction
59
+ end
60
+ number.between?(1, document.lines.length) ? count : Float::INFINITY
61
+ end
62
+
63
+ def add_blank(number, prefix, count)
64
+ range = document.line_range(number)
65
+ prose_offense(range, message: 'Surround fenced code blocks with blank lines.') do |corrector|
66
+ corrector.insert_before(processed_range(range), "#{prefix}\n" * count)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/code_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class FenceLanguage < Base
9
+ include RuboCop::Prose::CodeRules
10
+
11
+ private
12
+
13
+ def investigate_document
14
+ allowed = cop_config.fetch('AllowedLanguages', [])
15
+ code_blocks.each do |node|
16
+ next unless node.options[:fenced]
17
+
18
+ language = node.options[:lang].to_s
19
+ next unless language.empty? || (!allowed.empty? && !allowed.include?(language))
20
+
21
+ message = if language.empty?
22
+ 'Specify a language for this fenced code block.'
23
+ else
24
+ "Use an allowed fence language (#{allowed.join(', ')})."
25
+ end
26
+ prose_offense(document.line_range(node.options[:location]), message: message)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/code_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class FenceStyle < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::CodeRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ expected = { 'backtick' => '`', 'tilde' => '~' }[cop_config.fetch('EnforcedStyle', 'consistent')]
16
+ code_blocks.each do |node|
17
+ details = fence_details(node)
18
+ next unless details
19
+
20
+ expected ||= details[:opening][:fence][0]
21
+ next if details[:opening][:fence][0] == expected
22
+
23
+ range = fence_range(details[:first], details[:opening])
24
+ prose_offense(range, message: "Use #{expected == '`' ? 'backtick' : 'tilde'} fences.") do |corrector|
25
+ next if expected == '`' && details[:opening][:info].include?('`')
26
+
27
+ length = [details[:opening][:fence].length, details[:closing][:fence].length,
28
+ node.value.scan(/#{Regexp.escape(expected)}+/).map(&:length).max.to_i + 1].max
29
+ replacement = expected * length
30
+ corrector.replace(processed_range(range), replacement)
31
+ closing = fence_range(details[:last], details[:closing])
32
+ corrector.replace(processed_range(closing), replacement)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end