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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/structural_blank_lines'
4
+ require_relative '../../prose/list_rules'
5
+
6
+ module RuboCop
7
+ module Cop
8
+ module Prose
9
+ class MultipleBlankLines < Base
10
+ extend AutoCorrector
11
+ include RuboCop::Prose::ListRules
12
+
13
+ def validate_config
14
+ value = cop_config['Max']
15
+ return if value.is_a?(Integer) && value >= 0
16
+
17
+ raise ValidationError, 'Prose/MultipleBlankLines: Max must be a non-negative integer.'
18
+ end
19
+
20
+ private
21
+
22
+ def investigate_document
23
+ consecutive = 0
24
+ sequence = nil
25
+ quote_blank_lines = markdown_quote_blank_lines
26
+ source_lines = document.source.lines
27
+ protected_lines = structural_blank_lines
28
+ document.lines.each_with_index do |text, index|
29
+ number = index + 1
30
+ quote = quote_blank_lines[number]
31
+ blank = quote || (text.strip.empty? && :root)
32
+ if document.ignored_line?(number) || !blank
33
+ consecutive = 0
34
+ sequence = nil
35
+ next
36
+ end
37
+ if sequence != blank
38
+ consecutive = 0
39
+ sequence = blank
40
+ end
41
+ consecutive += 1
42
+ next if consecutive <= cop_config['Max'] || protected_lines.include?(number)
43
+
44
+ first = document.buffer.line_range(number).begin_pos
45
+ removal = ::Parser::Source::Range.new(document.buffer, first, first + source_lines.fetch(index).length)
46
+ prose_offense(document.line_range(number), message: 'Too many consecutive blank lines.') do |corrector|
47
+ corrector.remove(processed_range(removal)) if cop_config['Max'].positive?
48
+ end
49
+ end
50
+ end
51
+
52
+ def structural_blank_lines
53
+ return Set.new unless processed_source.prose_format == :markdown
54
+
55
+ RuboCop::Prose::StructuralBlankLines.new(document, config, processed_source).protected_lines
56
+ end
57
+
58
+ def markdown_quote_blank_lines
59
+ return {} unless document.is_a?(RuboCop::Prose::MarkdownDocument)
60
+
61
+ quote_blank_lines_in(document.root, nil, 0, {})
62
+ end
63
+
64
+ def quote_blank_lines_in(node, quote, depth, lines)
65
+ if node.type == :blockquote
66
+ quote = node
67
+ depth += 1
68
+ end
69
+
70
+ if quote
71
+ node.children.each_with_index do |child, index|
72
+ next unless child.type == :blank
73
+
74
+ previous = node.children[0...index].reverse.find { |sibling| sibling.type != :blank }
75
+ following = node.children[(index + 1)..].find { |sibling| sibling.type != :blank }
76
+ next unless previous && following && following.options[:location]
77
+
78
+ ((node_last_line(previous) + 1)...following.options[:location]).each do |number|
79
+ lines[number] = quote if quoted_blank_line?(number, depth)
80
+ end
81
+ end
82
+ end
83
+
84
+ node.children.each { |child| quote_blank_lines_in(child, quote, depth, lines) }
85
+ lines
86
+ end
87
+
88
+ def quoted_blank_line?(number, depth)
89
+ text = document.line(number)
90
+ prefix = text[/\A[ \t]*(?:>[ \t]*)+/]
91
+ prefix && prefix.count('>') == depth && text[prefix.length..].strip.empty?
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Prose
6
+ class TrailingNewline < Base
7
+ extend AutoCorrector
8
+
9
+ private
10
+
11
+ def investigate_document
12
+ source = document.source
13
+ return if source.empty?
14
+
15
+ suffix = source.match(/\n*\z/)
16
+ return if suffix[0] == "\n"
17
+
18
+ range = ::Parser::Source::Range.new(document.buffer, suffix.begin(0), source.length)
19
+ prose_offense(range, message: 'End the document with exactly one newline.') do |corrector|
20
+ corrector.replace(processed_range(range), "\n")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Text
6
+ class Base < Prose::Base
7
+ exclude_from_registry
8
+
9
+ private
10
+
11
+ def applicable_document?
12
+ processed_source.prose_format == :text
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Text
6
+ class TrailingWhitespace < Base
7
+ extend AutoCorrector
8
+
9
+ private
10
+
11
+ def investigate_document
12
+ document.lines.each_with_index do |text, index|
13
+ match = text.match(/[ \t]+\z/)
14
+ next unless match
15
+
16
+ range = document.range(index + 1, match.begin(0))
17
+ prose_offense(range, message: 'Remove trailing whitespace.') do |corrector|
18
+ corrector.remove(processed_range(range))
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ module CodeRules
6
+ def code_blocks
7
+ document.nodes(:codeblock)
8
+ end
9
+
10
+ def fenced_blocks(list_items: true)
11
+ return enum_for(__method__, list_items: list_items) unless block_given?
12
+
13
+ code_blocks.each do |node|
14
+ next if !list_items && code_ancestors(node).any? { |ancestor| ancestor.type == :li }
15
+
16
+ details = fence_details(node)
17
+ yield details if details
18
+ end
19
+ end
20
+
21
+ def code_ancestors(target, node = document.root, parents = [])
22
+ return parents if node.equal?(target)
23
+
24
+ node.children.each do |child|
25
+ found = code_ancestors(target, child, parents + [node])
26
+ return found if found
27
+ end
28
+ nil
29
+ end
30
+
31
+ def fence_details(node)
32
+ return unless node.options[:fenced]
33
+
34
+ first = node.options[:location]
35
+ opening = fence_match(document.line(first))
36
+ return unless opening
37
+
38
+ last = node.options.fetch(:code_start_line) + node.value.lines.length
39
+ return if last > document.lines.length
40
+
41
+ closing = fence_match(document.line(last))
42
+ return unless closing && closing[:fence][0] == opening[:fence][0] &&
43
+ closing[:fence].length >= opening[:fence].length && closing[:info].strip.empty?
44
+
45
+ { first: first, last: last, opening: opening, closing: closing }
46
+ end
47
+
48
+ def fence_match(text)
49
+ /
50
+ \A(?<prefix>(?:[ \t]*>[ \t]?|[ \t]*[-+*][ \t]+|[ \t]*\d+[.)][ \t]+|[ \t]+)*)
51
+ (?<fence>`{3,}|~{3,})(?<info>.*)\z
52
+ /x.match(text)
53
+ end
54
+
55
+ def fence_range(number, match)
56
+ document.range(number, match.begin(:fence), number, match.end(:fence))
57
+ end
58
+
59
+ def code_blank?(number)
60
+ number < 1 || number > document.lines.length ||
61
+ document.line(number).match?(/\A[ \t]*(?:>[ \t]*)*\z/)
62
+ end
63
+
64
+ def code_continuation_prefix(prefix)
65
+ prefix.gsub(/[-+*]|\d+[.)]/) { |marker| ' ' * marker.length }
66
+ end
67
+
68
+ def code_content_range(node, index, length)
69
+ number = node.options.fetch(:code_start_line) + index
70
+ content = node.value.lines[index].chomp
71
+ raw = document.line(number)
72
+ return unless raw.end_with?(content)
73
+
74
+ first = raw.length - content.length
75
+ document.range(number, first, number, first + length)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ class Document
6
+ attr_reader :buffer, :lines
7
+
8
+ def self.format(path, configuration = nil)
9
+ (configuration ? configuration.prose_file_types : FileTypes.default).format(path)
10
+ end
11
+
12
+ def self.file?(path, configuration = nil)
13
+ !format(path, configuration).nil?
14
+ end
15
+
16
+ def self.build(path, buffer, configuration: nil, format: self.format(path, configuration))
17
+ case format
18
+ when :markdown then MarkdownDocument.new(buffer, configuration: configuration || {})
19
+ when :text then TextDocument.new(buffer)
20
+ else raise ArgumentError, "Unsupported prose document: #{path.inspect}"
21
+ end
22
+ end
23
+
24
+ def initialize(buffer)
25
+ @buffer = buffer
26
+ @lines = source.lines(chomp: true)
27
+ end
28
+
29
+ def source
30
+ buffer.source
31
+ end
32
+
33
+ def line(number)
34
+ lines.fetch(number - 1)
35
+ end
36
+
37
+ def range(first_line, first_column = 0, last_line = first_line, last_column = nil)
38
+ last_column ||= line(last_line).length
39
+ ::Parser::Source::Range.new(
40
+ buffer, buffer.line_range(first_line).begin_pos + first_column,
41
+ buffer.line_range(last_line).begin_pos + last_column
42
+ )
43
+ end
44
+
45
+ def line_range(number)
46
+ range(number)
47
+ end
48
+
49
+ def ignored_line?(_number)
50
+ false
51
+ end
52
+
53
+ def reflowable?(_paragraph)
54
+ true
55
+ end
56
+
57
+ def breakable_space?(_text, _first, _last)
58
+ true
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ class FileTypes
6
+ attr_reader :patterns
7
+
8
+ def self.default
9
+ @default ||= new(Prose.default_configuration)
10
+ end
11
+
12
+ def initialize(configuration)
13
+ @patterns = %i[markdown text].to_h do |format|
14
+ name = format.to_s.capitalize
15
+ settings = configuration.fetch(name, {})
16
+ raise ValidationError, "#{name} must be a mapping." unless settings.is_a?(Hash)
17
+
18
+ values = settings.fetch('Include') { self.class.default.patterns.fetch(format) }
19
+ [format, validate(name, values)]
20
+ end.freeze
21
+ loaded_path = configuration.loaded_path if configuration.respond_to?(:loaded_path)
22
+ @matchers = patterns.transform_values do |values|
23
+ RuboCop::Config.new({ 'AllCops' => { 'Include' => values } }, loaded_path)
24
+ end
25
+ end
26
+
27
+ def format(path)
28
+ return unless path
29
+
30
+ matches = @matchers.select { |_, matcher| matcher.file_to_include?(path.to_s) }.keys
31
+ raise ValidationError, "#{path.inspect} matches both Markdown/Include and Text/Include." if matches.length > 1
32
+
33
+ matches.first
34
+ end
35
+
36
+ private
37
+
38
+ def validate(name, values)
39
+ valid = values.is_a?(Array) && values.all? do |value|
40
+ value.is_a?(Regexp) || (value.is_a?(String) && !value.empty?)
41
+ end
42
+ raise ValidationError, "#{name}/Include must be an array of nonempty file patterns." unless valid
43
+
44
+ values.map { |value| value.dup.freeze }.uniq.freeze
45
+ end
46
+ end
47
+
48
+ module Configuration
49
+ def prose_file_types
50
+ @prose_file_types ||= FileTypes.new(self)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ RuboCop::Config.prepend(RuboCop::Prose::Configuration)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ module HeadingRules
6
+ # Container markers are retained so source columns refer to the original line.
7
+ ATX = /\A(?<prefix>(?: {0,3}>[ \t]?| {0,3}(?:[-+*]|\d+[.)])[ \t]+)* {0,3})(?<marks>\#{1,6})(?!#)(?<rest>.*)\z/
8
+
9
+ def heading_entries
10
+ document.nodes(:header).map do |node|
11
+ number = node.options.fetch(:location)
12
+ text = document.line(number)
13
+ opening = ATX.match(text)
14
+ closing = opening && closing_heading_match(opening[:rest])
15
+ {
16
+ level: node.options.fetch(:level), text: document.plain_text(node), line: number,
17
+ last_line: opening ? number : number + 1, opening: opening, closing: closing,
18
+ style: if opening
19
+ closing ? 'atx_closed' : 'atx'
20
+ else
21
+ 'setext'
22
+ end
23
+ }
24
+ end
25
+ end
26
+
27
+ def closing_heading_match(rest)
28
+ match = /(?<space>[ \t]+)(?<marks>#+)[ \t]*\z/.match(rest)
29
+ return match if match
30
+
31
+ /\A(?<space>)(?<marks>#+)[ \t]*\z/.match(rest)
32
+ end
33
+
34
+ def heading_candidates
35
+ document.prose_lines.filter_map do |text, number|
36
+ match = ATX.match(text)
37
+ [match, number] if match
38
+ end
39
+ end
40
+
41
+ def heading_range(entry)
42
+ document.line_range(entry.fetch(:line))
43
+ end
44
+
45
+ def heading_blank?(number)
46
+ document.line(number).match?(/\A(?: {0,3}>[ \t]?)*[ \t]*\z/)
47
+ end
48
+
49
+ def heading_end_range
50
+ ::Parser::Source::Range.new(document.buffer, document.source.length, document.source.length)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ # Source positions are character offsets, like Document#range.
6
+ module InlineRules
7
+ Span = Struct.new(:begin_pos, :end_pos, :kind, :delimiter, keyword_init: true)
8
+
9
+ class PositionedParser < Parser
10
+ protected
11
+
12
+ def parse_emphasis
13
+ scanner = @src
14
+ parent = @tree
15
+ count = parent.children.length
16
+ first = scanner.pos
17
+ number = scanner.current_line_number
18
+ super
19
+ node = parent.children.last
20
+ return unless parent.children.length > count && %i[em strong].include?(node.type)
21
+
22
+ node.options[:prose_delimiters] = [scanner.string, first, scanner.pos, number]
23
+ end
24
+ end
25
+
26
+ def self.lines(document)
27
+ found = {}
28
+ document.paragraphs.each do |paragraph|
29
+ document.line_numbers(paragraph).each do |number|
30
+ found[number] = document.content_start(paragraph, number)
31
+ end
32
+ end
33
+ document.nodes(:header).each { |node| found[node.options[:location]] = 0 }
34
+ document.nodes(:table).each do |node|
35
+ number = node.options[:location]
36
+ while number <= document.lines.length && !document.line(number).strip.empty?
37
+ found[number] = 0
38
+ number += 1
39
+ end
40
+ end
41
+ found.sort.filter_map do |number, column|
42
+ next if document.code_line?(number) || document.front_matter_line?(number)
43
+
44
+ [document.line(number), number, column]
45
+ end
46
+ end
47
+
48
+ def self.escaped?(text, index)
49
+ count = 0
50
+ index -= 1
51
+ while index >= 0 && text[index] == '\\'
52
+ count += 1
53
+ index -= 1
54
+ end
55
+ count.odd?
56
+ end
57
+
58
+ def self.spans(text, links: true, html: true)
59
+ result = []
60
+ index = 0
61
+ while index < text.length
62
+ if text[index] == '\\' && text[index + 1]&.match?(/[[:punct:]]/)
63
+ result << Span.new(begin_pos: index, end_pos: [index + 2, text.length].min, kind: :escape)
64
+ index += 2
65
+ next
66
+ end
67
+ if text[index] == '`'
68
+ delimiter = text[index..][/\A`+/]
69
+ finish = index + delimiter.length
70
+ closed = false
71
+ while (match = /`+/.match(text, finish))
72
+ if match[0] == delimiter
73
+ result << Span.new(begin_pos: index, end_pos: match.end(0), kind: :code, delimiter: delimiter)
74
+ index = match.end(0)
75
+ closed = true
76
+ break
77
+ end
78
+ finish = match.end(0)
79
+ end
80
+ next if closed
81
+
82
+ index += delimiter.length
83
+ next
84
+ elsif html && text[index] == '<'
85
+ rest = text[index..]
86
+ html = %r{\A<([A-Za-z][\w:-]*)\b[^>]*>.*?</\1\s*>}i.match(rest) ||
87
+ /\A(?:<!--.*?-->|<[^>]+>)/.match(rest)
88
+ if html
89
+ result << Span.new(begin_pos: index, end_pos: index + html[0].length, kind: :html)
90
+ index += html[0].length
91
+ next
92
+ end
93
+ elsif links && (text[index] == '[' || (text[index] == '!' && text[index + 1] == '['))
94
+ finish = link_end(text, index)
95
+ if finish
96
+ result << Span.new(begin_pos: index, end_pos: finish, kind: :link)
97
+ index = finish
98
+ next
99
+ end
100
+ end
101
+ index += 1
102
+ end
103
+ result
104
+ end
105
+
106
+ def self.link_end(text, index)
107
+ index += 1 if text[index] == '!'
108
+ close = balanced_end(text, index, '[', ']')
109
+ return unless close
110
+ return balanced_end(text, close, '(', ')') if text[close] == '('
111
+ return balanced_end(text, close, '[', ']') if text[close] == '['
112
+
113
+ # A shortcut reference may resolve elsewhere in the document.
114
+ close
115
+ end
116
+
117
+ def self.balanced_end(text, index, opening, closing)
118
+ depth = 0
119
+ (index...text.length).each do |position|
120
+ next if escaped?(text, position)
121
+
122
+ depth += 1 if text[position] == opening
123
+ depth -= 1 if text[position] == closing
124
+ return position + 1 if depth.zero?
125
+ end
126
+ nil
127
+ end
128
+
129
+ def self.multiline_atoms(document)
130
+ document.multiline_atoms
131
+ end
132
+
133
+ def self.mask(text, include_code: false, document: nil, number: nil)
134
+ result = text.dup
135
+ spans(text).each do |span|
136
+ next if include_code && span.kind == :code
137
+
138
+ result[span.begin_pos...span.end_pos] = ' ' * (span.end_pos - span.begin_pos)
139
+ end
140
+ if document
141
+ line = document.line_range(number)
142
+ multiline_atoms(document).each do |range|
143
+ next if include_code && range.source.start_with?('`')
144
+ next unless range.overlaps?(line)
145
+
146
+ first = [range.begin_pos, line.begin_pos].max - line.begin_pos
147
+ last = [range.end_pos, line.end_pos].min - line.begin_pos
148
+ result[first...last] = ' ' * (last - first)
149
+ end
150
+ end
151
+ result
152
+ end
153
+
154
+ def self.emphasis(document)
155
+ source = document.source.dup
156
+ document.lines.each_with_index do |_, index|
157
+ next unless document.front_matter_line?(index + 1)
158
+
159
+ range = document.buffer.line_range(index + 1)
160
+ source[range.begin_pos...range.end_pos] = ' ' * range.size
161
+ end
162
+ root = PositionedParser.parse(source, hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic]).first
163
+ nodes = lambda do |node|
164
+ [node] + (node.type == :table ? [] : node.children.flat_map { |child| nodes.call(child) })
165
+ end
166
+ records = nodes.call(root).map { |node| [node, 0, nil] }
167
+ document.nodes(:table).each do |table|
168
+ number = table.options[:location]
169
+ while number <= document.lines.length && !document.line(number).strip.empty?
170
+ # Cell locations are not document-relative; parsing the row as inline prose
171
+ # recovers offsets without changing the table parser or its AST.
172
+ row = PositionedParser.parse("prose #{document.line(number).tr('|', ';')}",
173
+ hard_wrap: false, gfm_quirks: %i[paragraph_end no_auto_typographic]).first
174
+ records.concat(nodes.call(row).map { |node| [node, number - 1, -6] })
175
+ number += 1
176
+ end
177
+ end
178
+ ranges = { em: [], strong: [] }
179
+ records.each do |node, line_offset, column_offset|
180
+ next unless ranges.key?(node.type) && node.options[:prose_delimiters]
181
+
182
+ text, first, last, number = node.options[:prose_delimiters]
183
+ next unless number
184
+
185
+ number += line_offset
186
+ before = text.byteslice(0, first)
187
+ through = text.byteslice(0, last)
188
+ local_line = before.count("\n")
189
+ last_number = number + through.count("\n") - local_line
190
+ first_text = text.lines(chomp: true)[local_line]
191
+ last_text = text.lines(chomp: true)[through.count("\n")]
192
+ first_prefix = column_offset || document.line(number).rindex(first_text)
193
+ last_prefix = column_offset || document.line(last_number).rindex(last_text)
194
+ next unless first_prefix && last_prefix
195
+
196
+ column = first_prefix + before.rpartition("\n").last.length
197
+ last_column = last_prefix + through.rpartition("\n").last.length
198
+ next if spans(document.line(number)).any? { |span| span.begin_pos <= column && span.end_pos > column }
199
+
200
+ position = document.range(number, column, number, column + 1)
201
+ next if multiline_atoms(document).any? { |range| range.contains?(position) }
202
+
203
+ width = node.type == :strong ? 2 : 1
204
+ ranges.fetch(node.type) << [
205
+ document.range(number, column, number, column + width),
206
+ document.range(last_number, last_column - width, last_number, last_column), node.options[:char]
207
+ ]
208
+ end
209
+ ranges.transform_values { |entries| entries.sort_by { |opening, _, _| opening.begin_pos } }
210
+ end
211
+ end
212
+ end
213
+ end