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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/table_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class TableSpacing < Base
9
+ extend AutoCorrector
10
+
11
+ def investigate_document
12
+ RuboCop::Prose::TableRules.tables(document).each do |table|
13
+ style = cop_config.fetch('Style', 'aligned')
14
+ widths = table.rows.each_with_object({}) do |row, sizes|
15
+ row.cells.each_with_index do |cell, index|
16
+ sizes[index] = [sizes.fetch(index, 0), cell.text.strip.length].max
17
+ end
18
+ end
19
+ table.rows.each do |row|
20
+ row.cells.each_with_index do |cell, index|
21
+ content = cell.text.strip
22
+ left = row.leading || index.positive?
23
+ right = row.trailing || index < row.cells.length - 1
24
+ if content.empty?
25
+ padding = case style
26
+ when 'tight' then ''
27
+ when 'aligned' then ' ' * (widths[index] + (left ? 1 : 0) + (right ? 1 : 0))
28
+ else ' '
29
+ end
30
+ next if cell.text == padding
31
+
32
+ range = document.range(row.number, cell.begin_pos, row.number, cell.end_pos)
33
+ prose_offense(range, message: "Use #{style} table cell spacing.") do |corrector|
34
+ corrector.replace(processed_range(range), padding)
35
+ end
36
+ next
37
+ end
38
+
39
+ before = cell.text[/\A[ \t]*/]
40
+ after = cell.text[/[ \t]*\z/]
41
+ expected_before = left ? ' ' : ''
42
+ padding = style == 'aligned' ? widths[index] - content.length + 1 : 1
43
+ expected_after = right ? ' ' * padding : ''
44
+ valid = case style
45
+ when 'tight' then before.empty? && after.empty?
46
+ else before == expected_before && after == expected_after
47
+ end
48
+ next if valid
49
+
50
+ replacement = style == 'tight' ? content : "#{expected_before}#{content}#{expected_after}"
51
+ range = document.range(row.number, cell.begin_pos, row.number, cell.end_pos)
52
+ prose_offense(range, message: "Use #{style} table cell spacing.") do |corrector|
53
+ corrector.replace(processed_range(range), replacement)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class ThematicBreakBlankLines < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::ListRules
11
+
12
+ THEMATIC_BREAK = /(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}/
13
+
14
+ def validate_config
15
+ %w[LinesAbove LinesBelow].each do |name|
16
+ next unless cop_config.key?(name)
17
+
18
+ value = cop_config[name]
19
+ next if value.is_a?(Integer) && value >= 0
20
+
21
+ raise ValidationError, "Markdown/ThematicBreakBlankLines: #{name} must be a nonnegative integer."
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def investigate_document
28
+ @insertions = {}
29
+ document.nodes(:hr).each do |node|
30
+ number = node.options.fetch(:location)
31
+ next if document.front_matter_line?(number)
32
+
33
+ marker = THEMATIC_BREAK.match(document.line(number))
34
+ next unless marker
35
+
36
+ ancestors = node_path(node)
37
+ prefix = document.line(number)[0...marker.begin(0)]
38
+ in_list = ancestors.any? { |ancestor| ancestor.type == :li }
39
+ prefix = if in_list
40
+ continuation_prefix(prefix)
41
+ else
42
+ prefix.rstrip
43
+ end
44
+ range = document.line_range(number)
45
+ require_blanks(number - 1, -1, prefix, cop_config.fetch('LinesAbove', 1), range)
46
+ require_blanks(number + 1, 1, prefix, cop_config.fetch('LinesBelow', 1), range)
47
+ end
48
+
49
+ @insertions.values.group_by { |insertion| insertion[:offense_range] }.each do |offense_range, insertions|
50
+ if insertions.all? { |insertion| insertion[:safe] }
51
+ prose_offense(offense_range, message: 'Surround thematic breaks with blank lines.') do |corrector|
52
+ insertions.each do |insertion|
53
+ range = document.line_range(insertion[:line])
54
+ corrector.insert_before(processed_range(range), "#{insertion[:prefix]}#{newline}" * insertion[:count])
55
+ end
56
+ end
57
+ else
58
+ prose_offense(offense_range, message: 'Surround thematic breaks with blank lines.')
59
+ end
60
+ end
61
+ end
62
+
63
+ def require_blanks(number, direction, prefix, required, offense_range)
64
+ return if required.zero?
65
+
66
+ existing = blank_count(number, direction, prefix)
67
+ missing = required - existing
68
+ return unless missing.positive?
69
+
70
+ insertion_line = direction.negative? ? number - existing + 1 : number
71
+ insertion = @insertions[insertion_line] ||= {
72
+ line: insertion_line, prefix: prefix, count: 0, offense_range: offense_range, safe: true
73
+ }
74
+ insertion[:count] = [insertion[:count], missing].max
75
+ insertion[:safe] = false unless prefix == insertion[:prefix]
76
+ end
77
+
78
+ def blank_count(number, direction, prefix)
79
+ count = 0
80
+ while number.between?(1, document.lines.length)
81
+ return Float::INFINITY if direction.negative? && document.front_matter_line?(number)
82
+ break unless blank_line?(number, prefix)
83
+
84
+ count += 1
85
+ number += direction
86
+ end
87
+ number.between?(1, document.lines.length) ? count : Float::INFINITY
88
+ end
89
+
90
+ def blank_line?(number, prefix)
91
+ text = document.line(number)
92
+ return text.match?(/\A[ \t]*\z/) if prefix.empty?
93
+
94
+ pattern = /\A#{Regexp.escape(prefix)}[ \t]*\z/
95
+ text.match?(pattern)
96
+ end
97
+
98
+ def node_path(target, node = document.root, path = [])
99
+ current_path = path + [node]
100
+ return current_path if node.equal?(target)
101
+
102
+ node.children.each do |child|
103
+ found = node_path(target, child, current_path)
104
+ return found if found
105
+ end
106
+ []
107
+ end
108
+
109
+ def newline
110
+ document.source.include?("\r\n") ? "\r\n" : "\n"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Markdown
6
+ class ThematicBreakStyle < Base
7
+ extend AutoCorrector
8
+
9
+ def investigate_document
10
+ style = cop_config.fetch('Style', 'consistent')
11
+ styles = configured_styles(style)
12
+ consistent = style == 'consistent'
13
+ expected = nil
14
+
15
+ document.nodes(:hr).each do |node|
16
+ number = node.options[:location]
17
+ next if document.front_matter_line?(number)
18
+
19
+ text = document.line(number)
20
+ match = /(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}/.match(text)
21
+ next unless match
22
+
23
+ actual = match[0].rstrip
24
+ expected ||= actual if consistent
25
+ next if consistent ? actual == expected : styles.include?(actual)
26
+
27
+ range = document.range(number, match.begin(0), number, match.begin(0) + actual.length)
28
+ replacement = if consistent
29
+ expected
30
+ elsif styles.one?
31
+ styles.first
32
+ end
33
+ message = if consistent || styles.one?
34
+ "Use `#{replacement}` for thematic breaks."
35
+ else
36
+ "Use one of #{styles.map { |value| "`#{value}`" }.join(' or ')} for thematic breaks."
37
+ end
38
+ if replacement
39
+ prose_offense(range, message: message) do |corrector|
40
+ corrector.replace(processed_range(range), replacement)
41
+ end
42
+ else
43
+ prose_offense(range, message: message)
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def configured_styles(style)
51
+ return [] if style == 'consistent'
52
+
53
+ styles = style.is_a?(Array) ? style : [style]
54
+ valid = !styles.empty? && styles.all? do |value|
55
+ value.is_a?(String) && thematic_break?(value)
56
+ end
57
+ unless valid
58
+ raise ValidationError, [
59
+ 'Markdown/ThematicBreakStyle: Style must be `consistent`,',
60
+ 'a literal break, or a nonempty array of valid breaks.'
61
+ ].join(' ')
62
+ end
63
+
64
+ styles.uniq
65
+ end
66
+
67
+ def thematic_break?(value)
68
+ match = /\A(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})\z/.match(value)
69
+ match && match[0].rstrip == value
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Markdown
6
+ class TrailingWhitespace < Base
7
+ extend AutoCorrector
8
+
9
+ MSG = 'Remove unexpected trailing whitespace; preserve Markdown hard breaks.'
10
+
11
+ def validate_config
12
+ value = cop_config['HardBreakSpaces']
13
+ return if value.is_a?(Integer) && value >= 2
14
+
15
+ raise ValidationError, 'Markdown/TrailingWhitespace: HardBreakSpaces must be an integer of at least two.'
16
+ end
17
+
18
+ private
19
+
20
+ def investigate_document
21
+ paragraphs = document.paragraphs.flat_map { |paragraph| document.line_numbers(paragraph).to_a }.to_set
22
+ hard_break_lines = document.hard_breaks.to_set { |node| node.options[:location] }
23
+ document.lines.each_with_index do |text, index|
24
+ number = index + 1
25
+ next if document.front_matter_line?(number)
26
+ next if document.code_line?(number) && !cop_config['CodeBlocks']
27
+
28
+ match = text.match(/[ \t]+\z/)
29
+ next unless match
30
+
31
+ spaces = text[/ +\z/].to_s.length
32
+ hard_break = !text.strip.empty? && paragraphs.include?(number) && spaces >= 2
33
+ style = hard_break_style
34
+ handled_by_style = hard_break_lines.include?(number) && hard_break &&
35
+ style && (style['Style'] == 'backslash' || text[0...-spaces].end_with?('\\'))
36
+ next if handled_by_style
37
+
38
+ keep = hard_break ? ' ' * cop_config['HardBreakSpaces'] : ''
39
+ next if match[0] == keep
40
+
41
+ range = document.range(number, match.begin(0))
42
+ prose_offense(range) do |corrector|
43
+ corrector.replace(processed_range(range), keep) unless document.code_line?(number)
44
+ end
45
+ end
46
+ end
47
+
48
+ def hard_break_style
49
+ style = config.for_cop('Markdown/HardBreakStyle')
50
+ style if style && style['Enabled']
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Markdown
6
+ class UnclosedFencedCodeBlock < Base
7
+ private
8
+
9
+ def investigate_document
10
+ opening = nil
11
+ document.lines.each_with_index do |line, index|
12
+ number = index + 1
13
+ next if document.front_matter_line?(number)
14
+
15
+ match = fence_line(line)
16
+ next unless match
17
+
18
+ if opening
19
+ opening = nil if closing_fence?(match, opening)
20
+ elsif opening_fence?(match)
21
+ opening = match.merge(line: number)
22
+ end
23
+ end
24
+ report_unclosed(opening) if opening
25
+ end
26
+
27
+ def fence_line(line)
28
+ index = 0
29
+ quote_depth = 0
30
+ list_indent = 0
31
+ loop do
32
+ start = index
33
+ index += line[index..].to_s[/\A {0,3}/].length
34
+ if line[index] == '>'
35
+ quote_depth += 1
36
+ index += 1
37
+ index += 1 if [' ', "\t"].include?(line[index])
38
+ elsif (list = /\A([-+*]|\d{1,9}[.)])([ \t]+)/.match(line[index..].to_s)) && list[2].length <= 4
39
+ list_indent += index - start + list[1].length + list[2].length
40
+ index += list[0].length
41
+ else
42
+ index = start
43
+ break
44
+ end
45
+ end
46
+
47
+ indent = line[index..].to_s[/\A {0,3}/].length
48
+ index += indent
49
+ fence = /\A(?<fence>`{3,}|~{3,})(?<info>.*)\z/.match(line[index..].to_s)
50
+ return unless fence
51
+
52
+ { column: index, fence: fence[:fence], info: fence[:info],
53
+ quote_depth: quote_depth, list_indent: list_indent, indent: indent }
54
+ end
55
+
56
+ def opening_fence?(match)
57
+ match[:fence][0] != '`' || !match[:info].include?('`')
58
+ end
59
+
60
+ def closing_fence?(match, opening)
61
+ indent = match[:list_indent] + match[:indent]
62
+ container_matches = if opening[:list_indent].zero?
63
+ match[:list_indent].zero? && indent <= 3
64
+ else
65
+ indent.between?(opening[:list_indent], opening[:list_indent] + 3)
66
+ end
67
+ match[:fence][0] == opening[:fence][0] &&
68
+ match[:fence].length >= opening[:fence].length &&
69
+ match[:info].match?(/\A[ \t]*\z/) &&
70
+ match[:quote_depth] == opening[:quote_depth] &&
71
+ container_matches
72
+ end
73
+
74
+ def report_unclosed(opening)
75
+ range = document.range(opening[:line], opening[:column],
76
+ opening[:line], opening[:column] + opening[:fence].length)
77
+ prose_offense(range, message: 'Close this fenced code block explicitly before end of file.')
78
+ end
79
+ end
80
+ end
81
+ end
82
+ 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 UndefinedReference < Base
9
+ MSG = 'Define the link or image reference.'
10
+
11
+ private
12
+
13
+ def investigate_document
14
+ scanner = document.link_rules
15
+ scanner.links.each do |link|
16
+ next unless link.reference && !scanner.resolved?(link)
17
+ next if link.style == 'shortcut_reference' && !cop_config.fetch('ShortcutSyntax', false)
18
+
19
+ prose_offense(scanner.link_range(link))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class UnorderedListIndentation < Base
9
+ include RuboCop::Prose::ListRules
10
+
11
+ private
12
+
13
+ def investigate_document
14
+ width = cop_config.fetch('Indent', 2)
15
+ list_groups do |list, marks, parent|
16
+ next unless list.type == :ul
17
+
18
+ expected = expected_indentation(parent, marks.first, width)
19
+ marks.each do |mark|
20
+ next if mark[:indent] == expected
21
+
22
+ prose_offense(document.range(mark[:line], mark[:column], mark[:line], mark[:column] + 1),
23
+ message: "Use #{expected} spaces of unordered list indentation.")
24
+ end
25
+ end
26
+ end
27
+
28
+ def expected_indentation(parent, mark, width)
29
+ return 0 unless parent && parent[:quote_depth] == mark[:quote_depth]
30
+
31
+ width = [width, parent[:content_column] - parent[:column]].max if parent[:marker].length > 1
32
+ parent[:indent] + width
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class UnorderedListMarker < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::ListRules
11
+
12
+ private
13
+
14
+ def investigate_document
15
+ style = cop_config.fetch('EnforcedStyle', 'consistent')
16
+ expected = { 'asterisk' => '*', 'plus' => '+', 'dash' => '-' }[style]
17
+ changes = []
18
+ list_groups do |list, marks, _parent|
19
+ next unless list.type == :ul
20
+
21
+ expected ||= marks.first[:marker]
22
+ marks.each do |mark|
23
+ next if mark[:marker] == expected
24
+
25
+ changes << [mark, expected]
26
+ end
27
+ end
28
+ candidates = changes.group_by { |mark, _target| mark[:line] }.transform_values do |entries|
29
+ text = document.line(entries.first.first[:line]).dup
30
+ entries.each { |mark, target| text[mark[:column]] = target }
31
+ text
32
+ end
33
+ changes.each do |mark, target|
34
+ range = document.range(mark[:line], mark[:column], mark[:line], mark[:column] + 1)
35
+ prose_offense(range, message: "Use `#{target}` for unordered list markers.") do |corrector|
36
+ next if candidates.fetch(mark[:line]).match?(/(?:\A|[> \t])([*-])(?:[ \t]*\1){2,}[ \t]*\z/)
37
+
38
+ corrector.replace(processed_range(range), target)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Prose
6
+ class Base < RuboCop::Cop::Base
7
+ exclude_from_registry
8
+
9
+ def on_new_investigation
10
+ super
11
+ @document = nil
12
+ investigate_document if applicable_document?
13
+ end
14
+
15
+ def on_other_file
16
+ on_new_investigation
17
+ end
18
+
19
+ def current_corrector
20
+ return super unless processed_source.prose_format
21
+
22
+ @current_corrector ||= Corrector.new(processed_source)
23
+ end
24
+
25
+ private
26
+
27
+ def applicable_document?
28
+ !processed_source.prose_format.nil?
29
+ end
30
+
31
+ def document
32
+ @document ||= processed_source.prose_document(configuration: config)
33
+ end
34
+
35
+ def investigate_document
36
+ document.paragraphs.each { |paragraph| investigate_paragraph(paragraph) }
37
+ end
38
+
39
+ def range_for_original(range)
40
+ mapping = processed_source.prose_markdown_document
41
+ mapping ? mapping.original_range(range) : range
42
+ end
43
+
44
+ def processed_range(range)
45
+ mapping = processed_source.prose_markdown_document
46
+ mapping ? mapping.processed_range(range) : range
47
+ end
48
+
49
+ def prose_offense(range, **, &)
50
+ add_offense(processed_range(range), **, &)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Prose
6
+ class MaxLength < Base
7
+ extend AutoCorrector
8
+
9
+ MSG = 'Prose line is too long. [%<length>d/%<max>d]'
10
+ UNWRAP_MSG = 'Keep paragraph text on a single source line.'
11
+
12
+ def validate_config
13
+ value = cop_config['Max']
14
+ return if value == false || (value.is_a?(Integer) && value.positive?)
15
+
16
+ raise ValidationError, 'Prose/MaxLength: Max must be a positive integer or false.'
17
+ end
18
+
19
+ private
20
+
21
+ def investigate_paragraph(paragraph)
22
+ return unwrap_paragraph(paragraph) if cop_config['Max'] == false
23
+
24
+ document.line_numbers(paragraph).each { |number| inspect_line(paragraph, number) }
25
+ end
26
+
27
+ def unwrap_paragraph(paragraph)
28
+ document.soft_breaks(paragraph).each do |number|
29
+ previous = document.line(number)
30
+ column = document.content_start(paragraph, number + 1)
31
+
32
+ join = document.range(number, previous.rstrip.length, number + 1, column)
33
+ offense = document.range(number + 1, column)
34
+ prose_offense(offense, message: UNWRAP_MSG) do |corrector|
35
+ corrector.replace(processed_range(join), ' ') if document.reflowable?(paragraph)
36
+ end
37
+ end
38
+ end
39
+
40
+ def inspect_line(paragraph, number)
41
+ text = document.line(number)
42
+ max = cop_config['Max']
43
+ return if text.length <= max
44
+
45
+ first = document.content_start(paragraph, number)
46
+ return if cop_config['IgnoreURLs'] && text[first..].strip.match?(%r{\A<?https?://\S+>?\z})
47
+
48
+ range = document.range(number, [first, max].max, number, text.length)
49
+ prefix = document.continuation_prefix(paragraph)
50
+ breaks = if document.reflowable?(paragraph)
51
+ wrap_points(text, number, first, prefix, document.atomic_ranges(paragraph))
52
+ else
53
+ []
54
+ end
55
+ prose_offense(range, message: format(MSG, length: text.length, max: max)) do |corrector|
56
+ breaks.each { |space| corrector.replace(processed_range(space), "\n#{prefix}") }
57
+ end
58
+ end
59
+
60
+ def wrap_points(text, number, first, prefix, atomic)
61
+ spaces = breakable_spaces(text, number, first, atomic)
62
+ breaks = []
63
+ start = 0
64
+ width = cop_config['Max']
65
+ while text.length - start > width
66
+ candidates = spaces.select { |space| space.column > start }
67
+ chosen = candidates.reverse.find { |space| space.column - start <= width }
68
+ chosen ||= candidates.first
69
+ break unless chosen
70
+
71
+ breaks << chosen
72
+ start = chosen.last_column
73
+ width = cop_config['Max'] - prefix.length
74
+ end
75
+ breaks
76
+ end
77
+
78
+ def breakable_spaces(text, number, first, atomic)
79
+ spaces = []
80
+ text.to_enum(:scan, /[ \t]+/).each do
81
+ match = Regexp.last_match
82
+ next if match.begin(0) <= first || match.end(0) == text.length
83
+ next unless document.breakable_space?(text, match.begin(0), match.end(0))
84
+
85
+ range = document.range(number, match.begin(0), number, match.end(0))
86
+ spaces << range unless atomic.any? { |protected| protected.overlaps?(range) }
87
+ end
88
+ spaces
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end