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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ class MarkdownDocument < Document
6
+ FRONT_MATTER = /\A(?:\uFEFF)?---[ \t]*\r?\n.*?^(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m
7
+ BLOCK_START = /\A(?:[>#]|[-+*](?:\s|\z)|\d+[.)](?:\s|\z)|`{3}|~{3}|[=-]+[\t ]*\z)/
8
+
9
+ attr_reader :front_matter
10
+
11
+ def self.file?(path, configuration = nil)
12
+ Document.format(path, configuration) == :markdown
13
+ end
14
+
15
+ def initialize(buffer, ignore_front_matter: true, configuration: {})
16
+ ignore_front_matter = configuration.fetch('Markdown', {}).fetch('IgnoreFrontMatter', ignore_front_matter)
17
+ unless [true, false].include?(ignore_front_matter)
18
+ raise ValidationError, 'Markdown: IgnoreFrontMatter must be true or false.'
19
+ end
20
+
21
+ super(buffer)
22
+ @front_matter = source[FRONT_MATTER]
23
+ @ignore_front_matter = ignore_front_matter
24
+ end
25
+
26
+ def root
27
+ parsed.first
28
+ end
29
+
30
+ def warnings
31
+ parsed.last
32
+ end
33
+
34
+ def nodes(type)
35
+ @nodes ||= walk(root).group_by(&:type)
36
+ @nodes.fetch(type, [])
37
+ end
38
+
39
+ def paragraphs
40
+ @paragraphs ||= nodes(:p).select { |node| node.options[:prose_source] }
41
+ end
42
+
43
+ def hard_breaks
44
+ @hard_breaks ||= paragraphs.flat_map { |paragraph| find_hard_breaks(paragraph) }
45
+ end
46
+
47
+ def link_rules
48
+ @link_rules ||= LinkRules.new(self)
49
+ end
50
+
51
+ def emphasis_ranges(type)
52
+ @emphasis_ranges ||= InlineRules.emphasis(self)
53
+ @emphasis_ranges.fetch(type, [])
54
+ end
55
+
56
+ def multiline_atoms
57
+ @multiline_atoms ||= paragraphs.flat_map { |paragraph| atomic_ranges(paragraph) }
58
+ .reject { |range| range.line == range.last_line }
59
+ end
60
+
61
+ def headings
62
+ nodes(:header).map { |node| [node.options[:level], plain_text(node), node.options[:location]] }
63
+ end
64
+
65
+ def plain_text(node)
66
+ return node.value.to_s if %i[text codespan].include?(node.type)
67
+ return node.value.char if node.type == :entity
68
+
69
+ node.children.map { |child| plain_text(child) }.join
70
+ end
71
+
72
+ def front_matter_line?(number)
73
+ @ignore_front_matter && front_matter && number <= front_matter.lines.length
74
+ end
75
+
76
+ def code_line?(number)
77
+ @code_lines ||= nodes(:codeblock).flat_map do |node|
78
+ first = node.options.fetch(:location)
79
+ length = node.value.lines.length + (node.options[:fenced] ? 2 : 0)
80
+ (first...(first + length)).to_a
81
+ end.to_set
82
+ @code_lines.include?(number)
83
+ end
84
+
85
+ def ignored_line?(number)
86
+ code_line?(number) || front_matter_line?(number)
87
+ end
88
+
89
+ def breakable_space?(text, first, last)
90
+ !text[last..].match?(BLOCK_START) && text[0...first][/\\+\z/].to_s.length.even?
91
+ end
92
+
93
+ def prose_lines
94
+ return enum_for(__method__) unless block_given?
95
+
96
+ lines.each_with_index do |text, index|
97
+ number = index + 1
98
+ yield text, number unless code_line?(number) || front_matter_line?(number)
99
+ end
100
+ end
101
+
102
+ def content_start(paragraph, number)
103
+ text = source_lines(paragraph).fetch(number - paragraph.options[:location])
104
+ line(number).rindex(text) || raise("Cannot map Markdown paragraph on line #{number}")
105
+ end
106
+
107
+ def line_numbers(paragraph)
108
+ first = paragraph.options[:location]
109
+ first...(first + source_lines(paragraph).length)
110
+ end
111
+
112
+ def continuation_prefix(paragraph)
113
+ number = paragraph.options[:location]
114
+ prefix = line(number)[0...content_start(paragraph, number)]
115
+ prefix.gsub(/[-+*]|\d+[.)]/) { |marker| ' ' * marker.length }
116
+ end
117
+
118
+ def soft_breaks(paragraph)
119
+ line_numbers(paragraph).to_a[0...-1].reject do |number|
120
+ text = line(number)
121
+ join = range(number, text.rstrip.length, number + 1, content_start(paragraph, number + 1))
122
+ following = line(number + 1)[content_start(paragraph, number + 1)..]
123
+ text.end_with?(' ') || text[/\\+\z/].to_s.length.odd? ||
124
+ text.match?(/<br\b[^>]*>\s*\z/i) || following.match?(/\A<br\b[^>]*>/i) ||
125
+ atomic_ranges(paragraph).any? { |atom| atom.overlaps?(join) }
126
+ end
127
+ end
128
+
129
+ def atomic_ranges(paragraph)
130
+ 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|
133
+ start_line, start_column = inline.decompose_position(first)
134
+ end_line, end_column = inline.decompose_position(last)
135
+ first_line = paragraph.options[:location] + start_line - 1
136
+ last_line = paragraph.options[:location] + end_line - 1
137
+ range(first_line, content_start(paragraph, first_line) + start_column,
138
+ last_line, content_start(paragraph, last_line) + end_column)
139
+ end
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def source_lines(paragraph)
146
+ paragraph.options[:prose_lines] ||= paragraph.options[:prose_source].split("\n", -1)
147
+ end
148
+
149
+ def parsed
150
+ @parsed ||= begin
151
+ text = @ignore_front_matter ? source.sub(FRONT_MATTER) { |header| header.gsub(/[^\r\n]/, ' ') } : source
152
+ Parser.parse(text, hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic])
153
+ end
154
+ end
155
+
156
+ def walk(node)
157
+ [node] + node.children.flat_map { |child| walk(child) }
158
+ end
159
+
160
+ def find_hard_breaks(node)
161
+ node.children.flat_map do |child|
162
+ child.type == :br ? [child] : find_hard_breaks(child)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kramdown'
4
+ require 'kramdown-parser-gfm'
5
+
6
+ module RuboCop
7
+ module Prose
8
+ class Parser < Kramdown::Parser::GFM
9
+ protected
10
+
11
+ def parse_codeblock_fenced
12
+ result = super
13
+ if result
14
+ block = @tree.children.last
15
+ block.options[:fenced] = true
16
+ block.options[:code_start_line] = block.options[:location] + 1
17
+ end
18
+ result
19
+ end
20
+
21
+ def parse_codeblock
22
+ result = super
23
+ @tree.children.last.options[:code_start_line] = @tree.children.last.options[:location] if result
24
+ result
25
+ end
26
+
27
+ def update_tree(element)
28
+ if element.type == :p && (raw = element.children.find { |child| child.type == :raw_text })
29
+ raw.value.sub!(/\n+\z/, '')
30
+ element.options[:prose_source] = raw.value.dup
31
+ element.options[:prose_atoms] = raw.options[:prose_atoms] = []
32
+ raw.options[:prose_atoms] << [0, 3] if raw.value.match?(/\A\[[ xX]\]/)
33
+ end
34
+ result = super
35
+ if element.type == :p && element.children.all? do |child|
36
+ child.type == :html_element || (child.type == :text && child.value.strip.empty?)
37
+ end
38
+ element.options.delete(:prose_source)
39
+ end
40
+ result
41
+ end
42
+
43
+ def parse_spans(element, *args, &)
44
+ previous = @prose_atoms
45
+ @prose_atoms = element.options[:prose_atoms] if element.type == :raw_text
46
+ super
47
+ ensure
48
+ @prose_atoms = previous
49
+ end
50
+
51
+ def parse_list
52
+ Kramdown::Parser::Kramdown.instance_method(:parse_list).bind_call(self)
53
+ end
54
+
55
+ def parse_link
56
+ if @src.pos.zero? && @src.check(/\[[ xX]\]/)
57
+ add_text(@src.scan(/\[[ xX]\]/))
58
+ else
59
+ capture_atom { super }
60
+ end
61
+ end
62
+
63
+ %i[parse_codespan parse_autolink parse_span_html].each do |method|
64
+ define_method(method) { capture_atom { super() } }
65
+ end
66
+
67
+ def capture_atom
68
+ scanner = @src
69
+ first = scanner.pos
70
+ atoms = @prose_atoms
71
+ result = yield
72
+ if atoms
73
+ atoms << [scanner.string.byteslice(0, first).length,
74
+ scanner.string.byteslice(0, scanner.pos).length]
75
+ end
76
+ result
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+ require 'tempfile'
5
+
6
+ module RuboCop
7
+ module Prose
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: 'rubocop-prose', version: VERSION,
12
+ homepage: 'https://github.com/dblock/rubocop-prose',
13
+ description: 'Lint prose in Markdown and plain-text documents.'
14
+ )
15
+ end
16
+
17
+ def supported?(context)
18
+ context.engine == :rubocop
19
+ end
20
+
21
+ def rules(_context)
22
+ @configuration ||= Tempfile.new(['rubocop-prose', '.yml']).tap do |file|
23
+ file.write(Prose.default_configuration.to_yaml)
24
+ file.flush
25
+ end
26
+ LintRoller::Rules.new(type: :path, config_format: :rubocop, value: Pathname.new(@configuration.path))
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ module Runner
6
+ private
7
+
8
+ def get_processed_source(file, *arguments, **keywords)
9
+ configuration = @config_store.for_file(file)
10
+ format = configuration.prose_file_types.format(file)
11
+ return super if format == Document.format(file)
12
+
13
+ # The native factory normally initializes this optional preprocessor context.
14
+ if defined?(RuboCop::Markdown) && RuboCop::Markdown.respond_to?(:config_store=)
15
+ RuboCop::Markdown.config_store = @config_store
16
+ end
17
+
18
+ source = if keywords[:source]
19
+ RuboCop::Util.emulate_write_read_cycle(keywords[:source])
20
+ else
21
+ prose_source(file)
22
+ end
23
+ processed = ProcessedSource.new(
24
+ source, configuration.target_ruby_version, file,
25
+ parser_engine: configuration.parser_engine, prose_configuration: configuration
26
+ )
27
+ processed.config = configuration
28
+ processed.registry = mobilized_cop_classes(configuration)
29
+ processed
30
+ end
31
+
32
+ def prose_source(file)
33
+ @options[:stdin] || File.binread(file)
34
+ rescue Errno::ENOENT
35
+ raise RuboCop::Error, "No such file or directory: #{file}"
36
+ end
37
+ end
38
+
39
+ module TargetFinder
40
+ def find(...)
41
+ @config_store.for_pwd.prose_file_types
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def ruby_file?(file, ...)
48
+ types = @config_store.for_file(file).prose_file_types
49
+ super || !types.format(file).nil?
50
+ end
51
+
52
+ def to_inspect?(file, ...)
53
+ configuration = @config_store.for_file(file)
54
+ return false if configuration.prose_file_types.format(file) && configuration.file_to_exclude?(file)
55
+
56
+ super
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ RuboCop::Runner.prepend(RuboCop::Prose::Runner)
63
+ RuboCop::TargetFinder.prepend(RuboCop::Prose::TargetFinder)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ module Source
6
+ DIRECTIVE = %r{(?:<!--|<span\b[^>]*>)\s*(#\s*rubocop:[^\n]*?)\s*(?:-->|</span>)}
7
+
8
+ module MarkdownMapping
9
+ def markdown_document
10
+ super unless prose_format == :text
11
+ end
12
+ end
13
+
14
+ module Parsing
15
+ private
16
+
17
+ def parse(source, *)
18
+ return super unless prose_format
19
+
20
+ if prose_format == :markdown && respond_to?(:markdown_document)
21
+ super
22
+ return if prose_markdown_document
23
+
24
+ @diagnostics.clear
25
+ @parser_error = @tokens = nil
26
+ end
27
+
28
+ super('', *)
29
+ @buffer = ::Parser::Source::Buffer.new(path, source: raw_source)
30
+ @prose_documents = @prose_comments = nil
31
+ end
32
+ end
33
+
34
+ attr_reader :prose_format
35
+
36
+ def initialize(source, ruby_version, path = nil, prose_configuration: nil, **)
37
+ @prose_format = Document.format(path, prose_configuration)
38
+ extend Parsing if prose_format
39
+ extend MarkdownMapping if prose_format == :text && respond_to?(:markdown_document)
40
+ super(source, ruby_version, path, **)
41
+ end
42
+
43
+ def prose_document(configuration: nil)
44
+ @prose_documents ||= {}.compare_by_identity
45
+ @prose_documents[configuration] ||= Document.build(
46
+ path,
47
+ prose_markdown_document ? prose_markdown_document.buffer : buffer,
48
+ configuration: configuration, format: prose_format
49
+ )
50
+ end
51
+
52
+ def valid_syntax?
53
+ prose_format && !prose_markdown_document ? false : super
54
+ end
55
+
56
+ def comments
57
+ return [] if prose_format == :text
58
+ return super unless prose_format && !prose_markdown_document
59
+
60
+ prose_comments
61
+ end
62
+
63
+ def prose_markdown_document
64
+ markdown_document if prose_format == :markdown && respond_to?(:markdown_document)
65
+ end
66
+
67
+ private
68
+
69
+ def prose_comments
70
+ @prose_comments ||= buffer.source.to_enum(:scan, DIRECTIVE).filter_map do
71
+ match = Regexp.last_match
72
+ range = ::Parser::Source::Range.new(buffer, match.begin(1), match.end(1))
73
+ next if prose_document.code_line?(range.first_line) || prose_document.front_matter_line?(range.first_line)
74
+
75
+ ::Parser::Source::Comment.new(range)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ RuboCop::ProcessedSource.prepend(RuboCop::Prose::Source)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'heading_rules'
4
+ require_relative 'code_rules'
5
+ require_relative 'list_rules'
6
+ require_relative 'table_rules'
7
+
8
+ module RuboCop
9
+ module Prose
10
+ class StructuralBlankLines
11
+ include HeadingRules
12
+ include CodeRules
13
+ include ListRules
14
+
15
+ def initialize(document, configuration, processed_source)
16
+ @document = document
17
+ @configuration = configuration
18
+ @processed_source = processed_source
19
+ end
20
+
21
+ def protected_lines
22
+ @protected_lines = Set.new
23
+ heading_boundaries if enabled?(Cop::Markdown::HeadingBlankLines)
24
+ fence_boundaries if enabled?(Cop::Markdown::FenceBlankLines)
25
+ list_boundaries if enabled?(Cop::Markdown::ListBlankLines)
26
+ table_boundaries if enabled?(Cop::Markdown::TableBlankLines)
27
+ @protected_lines
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :document
33
+
34
+ def enabled?(cop)
35
+ @configuration.cop_enabled?(cop.cop_name) &&
36
+ cop.new(@configuration).relevant_file?(@processed_source.file_path)
37
+ end
38
+
39
+ def heading_boundaries
40
+ settings = @configuration.for_cop('Markdown/HeadingBlankLines')
41
+ heading_entries.each do |heading|
42
+ protect(heading[:line] - 1, -1, settings.fetch('LinesAbove', 1), blank: method(:heading_blank?))
43
+ protect(heading[:last_line] + 1, 1, settings.fetch('LinesBelow', 1), blank: method(:heading_blank?))
44
+ end
45
+ end
46
+
47
+ def fence_boundaries
48
+ settings = @configuration.for_cop('Markdown/FenceBlankLines')
49
+ fenced_blocks(list_items: settings.fetch('ListItems', true)) do |details|
50
+ unless details[:opening][:prefix].match?(/[-+*]|\d+[.)]/)
51
+ protect(details[:first] - 1, -1, settings.fetch('LinesAbove', 1))
52
+ end
53
+ protect(details[:last] + 1, 1, settings.fetch('LinesBelow', 1))
54
+ end
55
+ end
56
+
57
+ def list_boundaries
58
+ list_groups do |list, marks, parent|
59
+ next if parent
60
+
61
+ protect(marks.first[:line] - 1, -1, 1)
62
+ protect(node_last_line(list) + 1, 1, 1)
63
+ end
64
+ end
65
+
66
+ def table_boundaries
67
+ TableRules.tables(document).each do |table|
68
+ protect(table.rows.first.number - 1, -1, 1, front_matter: false)
69
+ protect(table.rows.last.number + 1, 1, 1)
70
+ end
71
+ end
72
+
73
+ def protect(number, direction, required, blank: method(:container_blank?), front_matter: true)
74
+ return if required.zero?
75
+
76
+ blanks = []
77
+ while number.between?(1, document.lines.length)
78
+ return if front_matter && direction.negative? && document.front_matter_line?(number)
79
+ break unless blank.call(number)
80
+
81
+ blanks << number
82
+ number += direction
83
+ end
84
+ return unless number.between?(1, document.lines.length)
85
+
86
+ @protected_lines.merge(blanks.sort.first(required))
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'inline_rules'
4
+
5
+ module RuboCop
6
+ module Prose
7
+ module TableRules
8
+ Row = Struct.new(:number, :text, :begin_pos, :end_pos, :pipes, :cells, :leading, :trailing, keyword_init: true)
9
+ Cell = Struct.new(:begin_pos, :end_pos, :text, keyword_init: true)
10
+ Table = Struct.new(:rows, keyword_init: true)
11
+
12
+ def self.row(document, number)
13
+ return if number > document.lines.length || document.code_line?(number) || document.front_matter_line?(number)
14
+
15
+ text = document.line(number)
16
+ first = text[/\A[ \t]*(?:>[ \t]*)*/].length
17
+ last = text.rstrip.length
18
+ return if first >= last
19
+
20
+ protected = InlineRules.spans(text, links: false, html: false)
21
+ pipes = (first...last).select do |index|
22
+ text[index] == '|' && protected.none? { |span| index >= span.begin_pos && index < span.end_pos }
23
+ end
24
+ leading = pipes.first == first
25
+ trailing = pipes.last == last - 1
26
+ boundaries = pipes.dup
27
+ boundaries.shift if leading
28
+ boundaries.pop if trailing
29
+ start = leading ? first + 1 : first
30
+ finish = trailing ? last - 1 : last
31
+ cells = []
32
+ boundaries.each do |position|
33
+ cells << Cell.new(begin_pos: start, end_pos: position, text: text[start...position])
34
+ start = position + 1
35
+ end
36
+ cells << Cell.new(begin_pos: start, end_pos: finish, text: text[start...finish])
37
+ Row.new(number: number, text: text, begin_pos: first, end_pos: last, pipes: pipes, cells: cells,
38
+ leading: leading, trailing: trailing)
39
+ end
40
+
41
+ def self.tables(document)
42
+ # A malformed body can make GFM reject the entire table. Its header and delimiter
43
+ # still identify the intended table, but only inside parser-recognized prose.
44
+ eligible = InlineRules.lines(document).to_set { |_, number, _| number }
45
+ result = []
46
+ number = 1
47
+ while number < document.lines.length
48
+ header = row(document, number) if eligible.include?(number)
49
+ separator = row(document, number + 1) if header && !header.pipes.empty?
50
+ if separator&.cells&.all? { |cell| cell.text.strip.match?(/\A:?-{3,}:?\z/) }
51
+ rows = [header, separator]
52
+ cursor = number + 2
53
+ while cursor <= document.lines.length && eligible.include?(cursor)
54
+ body = row(document, cursor)
55
+ break unless body && !body.pipes.empty?
56
+
57
+ rows << body
58
+ cursor += 1
59
+ end
60
+ result << Table.new(rows: rows)
61
+ number = cursor
62
+ else
63
+ number += 1
64
+ end
65
+ header = separator = nil
66
+ end
67
+ result
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ class TextDocument < Document
6
+ Paragraph = Struct.new(:first_line, :last_line)
7
+
8
+ def self.file?(path, configuration = nil)
9
+ Document.format(path, configuration) == :text
10
+ end
11
+
12
+ def paragraphs
13
+ @paragraphs ||= begin
14
+ result = []
15
+ lines.each_with_index do |text, index|
16
+ next if text.strip.empty?
17
+
18
+ number = index + 1
19
+ if result.last && result.last.last_line == number - 1
20
+ result.last.last_line = number
21
+ else
22
+ result << Paragraph.new(number, number)
23
+ end
24
+ end
25
+ result
26
+ end
27
+ end
28
+
29
+ def line_numbers(paragraph)
30
+ paragraph.first_line..paragraph.last_line
31
+ end
32
+
33
+ def content_start(_paragraph, _number)
34
+ 0
35
+ end
36
+
37
+ def continuation_prefix(_paragraph)
38
+ ''
39
+ end
40
+
41
+ def atomic_ranges(_paragraph)
42
+ []
43
+ end
44
+
45
+ def soft_breaks(paragraph)
46
+ (paragraph.first_line...paragraph.last_line).to_a
47
+ end
48
+
49
+ def reflowable?(paragraph)
50
+ line_numbers(paragraph).none? { |number| line(number).match?(/\A[ \t]/) }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+ require 'yaml'
5
+ require_relative 'rubocop/prose/version'
6
+ require_relative 'rubocop/prose/parser'
7
+ require_relative 'rubocop/prose/file_types'
8
+ require_relative 'rubocop/prose/document'
9
+ require_relative 'rubocop/prose/markdown_document'
10
+ require_relative 'rubocop/prose/text_document'
11
+ require_relative 'rubocop/prose/source'
12
+ require_relative 'rubocop/prose/runner'
13
+ require_relative 'rubocop/prose/plugin'
14
+ require_relative 'rubocop/cop/prose/base'
15
+ require_relative 'rubocop/cop/markdown/base'
16
+ require_relative 'rubocop/cop/text/base'
17
+
18
+ Dir[File.join(__dir__, 'rubocop/cop/{prose,markdown,text}/*.rb')].each { |path| require path }
19
+
20
+ module RuboCop
21
+ module Prose
22
+ def self.default_configuration
23
+ paths = Dir[File.expand_path('../config/default.yml', __dir__),
24
+ File.expand_path('../config/cops/**/*.yml', __dir__)]
25
+ paths.each_with_object({}) do |path, config|
26
+ config.merge!(YAML.safe_load_file(path)) { |_, previous, current| previous.merge(current) }
27
+ end
28
+ end
29
+ end
30
+ end