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,630 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'uri'
5
+
6
+ module RuboCop
7
+ module Prose
8
+ # A source-oriented companion to the AST: reference resolution in Kramdown
9
+ # discards duplicate definitions and the spelling and positions of references.
10
+ class LinkRules
11
+ Link = Struct.new(:begin_pos, :end_pos, :label_begin_pos, :label_end_pos, :label, :destination,
12
+ :destination_begin_pos, :destination_end_pos, :style, :reference, :image, keyword_init: true)
13
+ Definition = Struct.new(:begin_pos, :end_pos, :label, :reference, :destination,
14
+ :destination_begin_pos, :destination_end_pos, :title, keyword_init: true)
15
+
16
+ attr_reader :document, :links, :definitions, :html_anchors
17
+
18
+ def initialize(document)
19
+ @document = document
20
+ @source = document.source
21
+ @text = @source.dup
22
+ @links = []
23
+ @definitions = []
24
+ @html_anchors = []
25
+ exclude_blocks
26
+ exclude_spans
27
+ scan_definitions
28
+ @definitions_by_reference = definitions.each_with_object({}) do |definition, index|
29
+ index[definition.reference] ||= definition
30
+ end
31
+ scan_links
32
+ end
33
+
34
+ def range(first, last)
35
+ ::Parser::Source::Range.new(document.buffer, first, last)
36
+ end
37
+
38
+ def link_range(link)
39
+ range(link.begin_pos, link.end_pos)
40
+ end
41
+
42
+ def label_range(link)
43
+ range(link.label_begin_pos, link.label_end_pos)
44
+ end
45
+
46
+ def definition_for(link)
47
+ @definitions_by_reference[link.reference] if link.reference
48
+ end
49
+
50
+ def image_path_separators(link)
51
+ return [] unless link.image && resolved?(link)
52
+
53
+ definition = definition_for(link)
54
+ raw = definition ? definition.destination : link.destination
55
+ first = definition ? definition.destination_begin_pos : link.destination_begin_pos
56
+ last = definition ? definition.destination_end_pos : link.destination_end_pos
57
+ return [] unless raw && first && last && last - first == raw.length
58
+
59
+ decoded = destination(link)
60
+ return [] if external_destination?(decoded)
61
+
62
+ raw.each_char.with_index.filter_map do |character, offset|
63
+ next unless character == '\\'
64
+
65
+ following = raw[offset + 1]
66
+ next unless following && !(following.ascii_only? && following.match?(/[[:punct:]]/))
67
+
68
+ [first + offset, offset.positive? && raw[offset - 1] == '\\']
69
+ end
70
+ end
71
+
72
+ def destination(link)
73
+ raw = link.reference ? definition_for(link)&.destination : link.destination
74
+ decode(raw) if raw
75
+ end
76
+
77
+ def resolved?(link)
78
+ !link.reference || !definition_for(link).nil?
79
+ end
80
+
81
+ def normalize(label)
82
+ normalize_text(decode(label).gsub(/\r?\n[ \t]*(?:>[ \t]*)*/, ' '))
83
+ end
84
+
85
+ def normalize_text(text)
86
+ text.gsub(/[[:space:]]+/, ' ').strip.downcase(:fold)
87
+ end
88
+
89
+ def decode(text)
90
+ CGI.unescapeHTML(text.gsub(/\\([[:punct:]])/, '\1'))
91
+ end
92
+
93
+ def display_text(link)
94
+ return link.destination if link.style == 'autolink'
95
+
96
+ root = Parser.parse(link.label).first
97
+ self.class.plain_text(root).gsub(/[[:space:]]+/, ' ').strip
98
+ end
99
+
100
+ def self.plain_text(node)
101
+ case node.type
102
+ when :text, :codespan then node.value.to_s
103
+ when :entity then node.value.char
104
+ when :img then node.attr['alt'].to_s
105
+ when :br then ' '
106
+ else node.children.map { |child| plain_text(child) }.join
107
+ end
108
+ end
109
+
110
+ def bare_urls(schemes)
111
+ text = @text.dup
112
+ links.each do |link|
113
+ mask(text, link.begin_pos, link.end_pos) unless link.style == 'shortcut_reference' && !resolved?(link)
114
+ end
115
+ return [] if schemes.empty?
116
+
117
+ pattern = %r{(?<![\p{L}\p{N}_])(?:#{schemes.map { |scheme| Regexp.escape(scheme) }.join('|')})://[^\s<>]+}i
118
+ text.to_enum(:scan, pattern).filter_map do
119
+ match = Regexp.last_match
120
+ url = match[0].sub(/[.,;:!?'"]+\z/, '')
121
+ { ')' => '(', ']' => '[', '}' => '{' }.each do |closing, opening|
122
+ url = url[0...-1] while url.end_with?(closing) && url.count(closing) > url.count(opening)
123
+ end
124
+ [match.begin(0), match.begin(0) + url.length] unless url.empty?
125
+ end
126
+ end
127
+
128
+ def reversed_links
129
+ text = @text.dup
130
+ links.each { |link| mask(text, link.begin_pos, link.end_pos) if resolved?(link) }
131
+ results = []
132
+ index = 0
133
+ while (index = text.index('(', index))
134
+ unless !escaped?(index) && (finish = closing(text, index, '(', ')'))
135
+ index += 1
136
+ next
137
+ end
138
+ last = nil
139
+ last = closing(text, finish + 1, '[', ']') if text[finish + 1] == '['
140
+ if last && !text[(index + 1)...finish].strip.empty? &&
141
+ text[(finish + 2)...last].match?(/\A[^\s]+\z/) && !unescaped_brackets?(finish + 2, last) &&
142
+ !citation_reference?(finish + 2, last)
143
+ results << [index, last + 1]
144
+ index = last + 1
145
+ else
146
+ index += 1
147
+ end
148
+ end
149
+ results
150
+ end
151
+
152
+ def anchors(renderer, additional = [])
153
+ used = Set.new
154
+ explicit_ids = explicit_heading_ids
155
+ document.nodes(:header).each do |node|
156
+ explicit = explicit_ids[node.options[:location]]
157
+ if explicit
158
+ used << explicit
159
+ next
160
+ end
161
+ text = self.class.plain_text(node).gsub(/\r\n?|\n|\t/, ' ')
162
+ slug = case renderer
163
+ when 'kramdown'
164
+ value = text.sub(/\A[^a-zA-Z]+/, '').gsub(/[^a-zA-Z0-9 -]/, '').tr(' ', '-').downcase
165
+ value.empty? ? 'section' : value
166
+ when 'gitlab'
167
+ text.downcase.gsub(/[^\p{L}\p{M}\p{N}\p{Pc}\s-]/, '').gsub(/\s/, '-').gsub(/-+/, '-')
168
+ else
169
+ text.downcase.gsub(/[^\p{L}\p{M}\p{N}\p{Pc} -]/, '').tr(' ', '-')
170
+ end
171
+ candidate = slug
172
+ suffix = 0
173
+ while used.include?(candidate)
174
+ suffix += 1
175
+ candidate = "#{slug}-#{suffix}"
176
+ end
177
+ used << candidate
178
+ end
179
+ used.merge(html_anchors).merge(additional)
180
+ end
181
+
182
+ private
183
+
184
+ def citation_reference?(first, last)
185
+ @source[first...last].match?(
186
+ %r{\A-?@[\p{L}\p{N}_](?:[\p{L}\p{M}\p{N}_:.#$%&+?<>~/-]*[\p{L}\p{M}\p{N}_])?\z}
187
+ )
188
+ end
189
+
190
+ def external_destination?(destination)
191
+ return true if destination.start_with?('//')
192
+ return false if destination.match?(%r{\A[A-Za-z]:[\\/]})
193
+
194
+ destination.match?(/\A[a-z][a-z0-9+.-]*:/i)
195
+ end
196
+
197
+ def explicit_heading_ids
198
+ # GFM eagerly inserts its own auto IDs into node.attr. Parse without
199
+ # those IDs so the selected renderer, not GFM, controls generated slugs.
200
+ source = @source.dup
201
+ if document.front_matter_line?(1)
202
+ source[0, document.front_matter.length] = document.front_matter.gsub(/[^\r\n]/, ' ')
203
+ end
204
+ root = Parser.parse(source, auto_ids: false, hard_wrap: false,
205
+ gfm_quirks: %i[paragraph_end no_auto_typographic]).first
206
+ ids = {}
207
+ pending = [root]
208
+ until pending.empty?
209
+ node = pending.shift
210
+ ids[node.options[:location]] = node.attr['id'] if node.type == :header && node.attr['id']
211
+ pending.concat(node.children)
212
+ end
213
+ ids
214
+ end
215
+
216
+ def mask(text, first, last)
217
+ text[first...last] = text[first...last].gsub(/[^\r\n]/, ' ')
218
+ end
219
+
220
+ def exclude_blocks
221
+ allowed = document.prose_lines.to_set { |_text, number| number }
222
+ document.lines.each_index do |index|
223
+ next if allowed.include?(index + 1)
224
+
225
+ line = document.line_range(index + 1)
226
+ mask(@text, line.begin_pos, line.end_pos)
227
+ end
228
+ @raw_html_starts = document.nodes(:html_element).filter_map do |node|
229
+ next unless node.options[:content_model] == :raw && node.options[:location]
230
+
231
+ line = document.line_range(node.options[:location])
232
+ match = /<#{Regexp.escape(node.value)}\b/i.match(@text, line.begin_pos)
233
+ match.begin(0) if match && match.begin(0) < line.end_pos
234
+ end.to_set
235
+ end
236
+
237
+ def escaped?(index)
238
+ count = 0
239
+ index -= 1
240
+ while index >= 0 && @source[index] == '\\'
241
+ count += 1
242
+ index -= 1
243
+ end
244
+ count.odd?
245
+ end
246
+
247
+ def exclude_spans
248
+ index = 0
249
+ while (index = @text.index(/[(:`<]/, index))
250
+ destination = nil
251
+ if @text[index] == '(' && @text[index - 1] == ']' && opening_bracket(index - 1)
252
+ destination = inline_destination(index)
253
+ if destination
254
+ index = destination[1]
255
+ next
256
+ end
257
+ end
258
+ if @text[index] == ':' && @text[index - 1] == ']' &&
259
+ (first = opening_bracket(index - 1)) && @source[first + 1] != '^'
260
+ line_start = first.zero? ? nil : @text.rindex("\n", first - 1)
261
+ prefix = @text[(line_start ? line_start + 1 : 0)...first]
262
+ destination = definition_destination(index + 1) if prefix.match?(/\A[ \t]*(?:>[ \t]*)*\z/)
263
+ if destination
264
+ index = destination[1]
265
+ next
266
+ end
267
+ end
268
+ if @text[index] == '`' && !escaped?(index)
269
+ finish = codespan_end(index)
270
+ elsif @text[index] == '<' && !escaped?(index)
271
+ finish = html_or_autolink_end(index)
272
+ end
273
+ if finish
274
+ mask(@text, index, finish)
275
+ index = finish
276
+ else
277
+ index += 1
278
+ end
279
+ finish = nil
280
+ end
281
+ end
282
+
283
+ def codespan_end(index)
284
+ run = @text[index..][/\A`+/]
285
+ cursor = index + run.length
286
+ while (match = /`+/.match(@text, cursor))
287
+ return match.end(0) if match[0].length == run.length
288
+
289
+ cursor = match.end(0)
290
+ end
291
+ nil
292
+ end
293
+
294
+ def html_or_autolink_end(index)
295
+ rest = @text[index..]
296
+ # Angle-delimited destinations are not autolinks or HTML elements.
297
+ return if @text[0...index].match?(/(?:\]\(|\]:)[ \t\r\n]*\z/)
298
+
299
+ if rest.start_with?('<!--')
300
+ finish = @text.index('-->', index + 4)
301
+ return finish ? finish + 3 : @text.length
302
+ end
303
+ if rest.start_with?('<![CDATA[') || rest.start_with?('<?')
304
+ ending = rest.start_with?('<?') ? '?>' : ']]>'
305
+ finish = @text.index(ending, index + 2)
306
+ return finish ? finish + ending.length : @text.length
307
+ end
308
+ declaration = /\A<![A-Z][^>]*>/m.match(rest)
309
+ return index + declaration[0].length if declaration
310
+
311
+ if (match = /\A<(?:([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)|([^<>\s@]+@[^<>\s@]+\.[^<>\s@]+))>/i.match(rest))
312
+ @links << Link.new(begin_pos: index, end_pos: index + match[0].length,
313
+ label_begin_pos: index + 1, label_end_pos: index + match[0].length - 1,
314
+ label: match[1] || match[2], destination: match[1] || "mailto:#{match[2]}",
315
+ style: 'autolink', image: false)
316
+ return index + match[0].length
317
+ end
318
+ match = %r{\A</?([a-z][\w:-]*)\b(?:[^"'<>]|"[^"]*"|'[^']*')*>}im.match(rest)
319
+ return unless match
320
+
321
+ tag = match[0]
322
+ name = match[1].downcase
323
+ record_html_anchors(tag, name)
324
+ finish = index + tag.length
325
+ void = %w[area base br col embed hr img input link meta param source track wbr].include?(name)
326
+ if tag !~ %r{\A</|/\s*>\z} && !void
327
+ if %w[script style textarea].include?(name)
328
+ ending = %r{</#{Regexp.escape(name)}\s*>}i.match(@text, finish)
329
+ finish = ending ? ending.end(0) : @text.length
330
+ elsif %w[a code pre].include?(name) || @raw_html_starts.include?(index)
331
+ finish = raw_html_end(finish, name)
332
+ end
333
+ end
334
+ finish
335
+ end
336
+
337
+ def record_html_anchors(tag, name)
338
+ # Match complete attributes, not id= appearing inside another value.
339
+ attributes = /\s+([a-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/i
340
+ tag.scan(attributes) do |attribute, double, single, bare|
341
+ next unless attribute.downcase == 'id' || (attribute.downcase == 'name' && name == 'a')
342
+
343
+ value = double || single || bare
344
+ @html_anchors << CGI.unescapeHTML(value) if value
345
+ end
346
+ end
347
+
348
+ def raw_html_end(index, name)
349
+ depth = 1
350
+ cursor = index
351
+ tags = %r{<!--.*?(?:-->|\z)|</?([a-z][\w:-]*)\b(?:[^"'<>]|"[^"]*"|'[^']*')*>}im
352
+ while (match = tags.match(@text, cursor))
353
+ tag = match[0]
354
+ cursor = match.end(0)
355
+ next if tag.start_with?('<!--')
356
+
357
+ record_html_anchors(tag, match[1].downcase)
358
+ if !tag.start_with?('</') && %w[script style textarea].include?(match[1].downcase)
359
+ ending = %r{</#{Regexp.escape(match[1])}\s*>}i.match(@text, cursor)
360
+ cursor = ending ? ending.end(0) : @text.length
361
+ next
362
+ end
363
+ next unless match[1].casecmp?(name)
364
+
365
+ depth += tag.start_with?('</') ? -1 : 1 unless tag.match?(%r{/\s*>\z})
366
+ return cursor if depth.zero?
367
+ end
368
+ @text.length
369
+ end
370
+
371
+ def closing(text, index, opening, ending)
372
+ return unless text[index] == opening
373
+
374
+ depth = 1
375
+ cursor = index + 1
376
+ while cursor < text.length
377
+ return if text[cursor] == "\n" && text[cursor..].match?(/\A\n[ \t\r]*\n/)
378
+
379
+ unless escaped?(cursor)
380
+ depth += 1 if text[cursor] == opening
381
+ depth -= 1 if text[cursor] == ending
382
+ return cursor if depth.zero?
383
+ end
384
+ cursor += 1
385
+ end
386
+ nil
387
+ end
388
+
389
+ def opening_bracket(ending)
390
+ return if escaped?(ending)
391
+
392
+ depth = 1
393
+ cursor = ending - 1
394
+ while cursor >= 0
395
+ return if @text[cursor] == "\n" && @text[cursor..ending].match?(/\A\n[ \t\r]*\n/)
396
+
397
+ unless escaped?(cursor)
398
+ depth += 1 if @text[cursor] == ']'
399
+ depth -= 1 if @text[cursor] == '['
400
+ return cursor if depth.zero?
401
+ end
402
+ cursor -= 1
403
+ end
404
+ nil
405
+ end
406
+
407
+ def unescaped_brackets?(first, last)
408
+ (first...last).any? { |index| ['[', ']'].include?(@source[index]) && !escaped?(index) }
409
+ end
410
+
411
+ def whitespace(index, text = @text)
412
+ cursor = index
413
+ while text[cursor]&.match?(/[ \t\r\n]/)
414
+ newline = text[cursor] == "\n"
415
+ cursor += 1
416
+ cursor += text[cursor..][/\A[ \t]*(?:>[ \t]*)+/].to_s.length if newline
417
+ end
418
+ return if text[index...cursor].count("\n") > 1
419
+
420
+ cursor
421
+ end
422
+
423
+ def read_destination(index)
424
+ if @source[index] == '<'
425
+ cursor = index + 1
426
+ while cursor < @source.length
427
+ if @source[cursor] == '>' && !escaped?(cursor)
428
+ return [@source[(index + 1)...cursor], cursor + 1, index + 1, cursor]
429
+ end
430
+ return if @source[cursor] == "\n" || (@source[cursor] == '<' && !escaped?(cursor))
431
+
432
+ cursor += 1
433
+ end
434
+ return
435
+ end
436
+ cursor = index
437
+ depth = 0
438
+ while cursor < @source.length
439
+ cursor = @source.index(/[\s()<]/, cursor) || @source.length
440
+ break if cursor == @source.length
441
+
442
+ char = @source[cursor]
443
+ break if char.match?(/\s/) || (char == ')' && depth.zero? && !escaped?(cursor))
444
+
445
+ unless escaped?(cursor)
446
+ return if char == '<'
447
+
448
+ depth += 1 if char == '('
449
+ depth -= 1 if char == ')'
450
+ end
451
+ cursor += 1
452
+ end
453
+ [@source[index...cursor], cursor, index, cursor] if depth.zero?
454
+ end
455
+
456
+ def title_end(index)
457
+ opening = @source[index]
458
+ return unless ['"', "'", '('].include?(opening)
459
+
460
+ ending = opening == '(' ? ')' : opening
461
+ cursor = index + 1
462
+ while cursor < @source.length
463
+ return cursor + 1 if @source[cursor] == ending && !escaped?(cursor)
464
+ return if @source[cursor, 2] == "\n\n"
465
+
466
+ cursor += 1
467
+ end
468
+ nil
469
+ end
470
+
471
+ def inline_destination(index)
472
+ first = whitespace(index + 1, @source)
473
+ return unless first
474
+
475
+ if first > index + 1 && ['"', "'"].include?(@source[first]) && (ending = title_end(first))
476
+ finish = whitespace(ending, @source)
477
+ return ['', finish + 1, first, first] if finish && @source[finish] == ')'
478
+ end
479
+ result = read_destination(first)
480
+ return unless result
481
+
482
+ value, ending, destination_first, destination_last = result
483
+ finish = whitespace(ending, @source)
484
+ return unless finish
485
+ return [value, finish + 1, destination_first, destination_last] if @source[finish] == ')'
486
+ return unless finish > ending && (title = title_end(finish))
487
+
488
+ finish = whitespace(title, @source)
489
+ [value, finish + 1, destination_first, destination_last] if finish && @source[finish] == ')'
490
+ end
491
+
492
+ def definition_destination(index)
493
+ start = whitespace(index, @source)
494
+ result = read_destination(start) if start
495
+ return unless result
496
+
497
+ value, finish, destination_first, destination_last = result
498
+ gap = whitespace(finish, @source)
499
+ title = title_end(gap) if gap && gap > finish
500
+ finish = title if title && @source[title..].match?(/\A[ \t]*(?:\r?\n|\z)/)
501
+ return unless @source[finish..].match?(/\A[ \t]*(?:\r?\n|\z)/)
502
+ return if value.empty? && @source[start] != '<'
503
+
504
+ [value, finish, title && @source[(gap + 1)...(title - 1)], destination_first, destination_last]
505
+ end
506
+
507
+ def scan_definitions
508
+ offset = 0
509
+ @text.lines.each do |line|
510
+ prefix = line[/\A[ \t]*(?:>[ \t]*)*/].length
511
+ first = offset + prefix
512
+ ending = closing(@text, first, '[', ']')
513
+ if ending && @text[ending + 1] == ':' && !escaped?(first)
514
+ label = @source[(first + 1)...ending]
515
+ result = nil
516
+ if !label.start_with?('^') && !unescaped_brackets?(first + 1, ending) && !normalize(label).empty?
517
+ result = definition_destination(ending + 2)
518
+ end
519
+ if result
520
+ value, finish, title, destination_first, destination_last = result
521
+ @definitions << Definition.new(begin_pos: first, end_pos: finish, label: label,
522
+ reference: normalize(label), destination: value, title: title,
523
+ destination_begin_pos: destination_first,
524
+ destination_end_pos: destination_last)
525
+ end
526
+ end
527
+ offset += line.length
528
+ end
529
+ definitions.each { |definition| mask(@text, definition.begin_pos, definition.end_pos) }
530
+ links.reject! do |link|
531
+ definitions.any? do |definition|
532
+ link.begin_pos.between?(definition.begin_pos, definition.end_pos - 1)
533
+ end
534
+ end
535
+ end
536
+
537
+ def scan_links
538
+ cursor = 0
539
+ while (cursor = @text.index('[', cursor))
540
+ if escaped?(cursor)
541
+ cursor += 1
542
+ next
543
+ end
544
+ link = parse_link(cursor)
545
+ unless link
546
+ cursor += 1
547
+ next
548
+ end
549
+ links << link
550
+ # Images may supply the accessible text of an outer link.
551
+ nested = cursor + 1
552
+ while !link.image && nested < link.label_end_pos
553
+ if @text[nested] == '[' && @text[nested - 1] == '!' && !escaped?(nested - 1)
554
+ image = parse_link(nested)
555
+ links << image if image&.image && image.end_pos <= link.label_end_pos
556
+ nested = image.end_pos if image
557
+ end
558
+ nested += 1
559
+ end
560
+ cursor = link.end_pos
561
+ end
562
+ links.reject! do |link|
563
+ link.style == 'autolink' && links.any? do |outer|
564
+ outer.style == 'inline' && link.begin_pos > outer.label_end_pos && link.end_pos <= outer.end_pos
565
+ end
566
+ end
567
+ links.sort_by!(&:begin_pos)
568
+ end
569
+
570
+ def parse_link(index)
571
+ ending = closing(@text, index, '[', ']')
572
+ return unless ending
573
+
574
+ label = @source[(index + 1)...ending]
575
+ return if label.start_with?('^') || task_marker?(index, label)
576
+
577
+ image = index.positive? && @text[index - 1] == '!' && !escaped?(index - 1)
578
+ link = Link.new(begin_pos: image ? index - 1 : index, label_begin_pos: index + 1,
579
+ label_end_pos: ending, label: label, image: image)
580
+ if @text[ending + 1] == '(' && (result = inline_destination(ending + 1))
581
+ link.destination, link.end_pos, link.destination_begin_pos, link.destination_end_pos = result
582
+ link.style = 'inline'
583
+ else
584
+ following = whitespace(ending + 1)
585
+ last = closing(@text, following, '[', ']') if following && @text[following] == '['
586
+ if last && !unescaped_brackets?(following + 1, last) && @source[following + 1] != '^'
587
+ reference = @source[(following + 1)...last]
588
+ link.style = reference.empty? ? 'collapsed_reference' : 'full_reference'
589
+ link.reference = normalize(reference.empty? ? label : reference)
590
+ link.end_pos = last + 1
591
+ else
592
+ return if unescaped_brackets?(index + 1, ending) || normalize(label).empty?
593
+
594
+ link.style = 'shortcut_reference'
595
+ link.reference = normalize(label)
596
+ link.end_pos = ending + 1
597
+ end
598
+ end
599
+ return if !link.image && nested_link?(link)
600
+
601
+ link
602
+ end
603
+
604
+ def nested_link?(outer)
605
+ return true if links.any? do |link|
606
+ link.style == 'autolink' && link.begin_pos >= outer.label_begin_pos && link.end_pos <= outer.label_end_pos
607
+ end
608
+
609
+ cursor = outer.label_begin_pos
610
+ while cursor < outer.label_end_pos
611
+ if @text[cursor] == '[' && !escaped?(cursor)
612
+ inner = parse_link(cursor)
613
+ return true if inner && !inner.image && resolved?(inner) && inner.end_pos <= outer.label_end_pos
614
+
615
+ cursor = inner.end_pos - 1 if inner
616
+ end
617
+ cursor += 1
618
+ end
619
+ false
620
+ end
621
+
622
+ def task_marker?(index, label)
623
+ return false unless label.match?(/\A[ xX]\z/)
624
+
625
+ start = @source.rindex("\n", index - 1) unless index.zero?
626
+ @source[(start ? start + 1 : 0)...index].match?(/\A[ \t>]*(?:[-+*]|\d+[.)])[ \t]+\z/)
627
+ end
628
+ end
629
+ end
630
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Prose
5
+ # Source positions come from parsed list items, not marker-shaped prose.
6
+ module ListRules
7
+ def list_groups(node = document.root, parent = nil, &block)
8
+ if %i[ul ol].include?(node.type)
9
+ items = node.children.select { |child| child.type == :li }
10
+ marks = items.filter_map { |item| list_marker(item, parent) }
11
+ yield node, marks, parent unless marks.empty?
12
+ items.each do |item|
13
+ mark = list_marker(item, parent)
14
+ item.children.each { |child| list_groups(child, mark, &block) }
15
+ end
16
+ else
17
+ node.children.each { |child| list_groups(child, parent, &block) }
18
+ end
19
+ end
20
+
21
+ def list_marker(item, parent)
22
+ number = item.options[:location]
23
+ text = document.line(number)
24
+ offset = parent && parent[:line] == number ? parent[:content_column] : 0
25
+ match = /\G(?<prefix>(?:[ \t]*>[ \t]?)*[ \t]*)(?<marker>[-+*]|\d+[.)])(?<gap>[ \t]+|$)/.match(text, offset)
26
+ return unless match && match.begin(0) == offset
27
+
28
+ column = match.begin(:marker)
29
+ prefix = text[0...column]
30
+ quote = prefix.rindex('>')
31
+ quote = if quote
32
+ quote + 1 + (prefix[quote + 1]&.match?(/[ \t]/) ? 1 : 0)
33
+ else
34
+ 0
35
+ end
36
+ { item: item, line: number, column: column, marker: match[:marker],
37
+ gap: match[:gap], content_column: match.end(0), indent: column - quote,
38
+ quote_depth: prefix.count('>') }
39
+ end
40
+
41
+ def node_last_line(node)
42
+ first = node.options[:location] || 0
43
+ own = if node.type == :codeblock
44
+ first + node.value.lines.length + (node.options[:fenced] ? 1 : -1)
45
+ elsif node.options[:prose_source]
46
+ first + node.options[:prose_source].lines.length - 1
47
+ elsif node.value.is_a?(String) && first.positive?
48
+ first + [node.value.lines.length - 1, 0].max
49
+ else
50
+ first
51
+ end
52
+ [own, *node.children.map { |child| node_last_line(child) }].max
53
+ end
54
+
55
+ def container_blank?(number)
56
+ number < 1 || number > document.lines.length ||
57
+ document.line(number).match?(/\A[ \t]*(?:>[ \t]*)*\z/)
58
+ end
59
+
60
+ def continuation_prefix(text)
61
+ text.gsub(/[-+*]|\d+[.)]/) { |marker| ' ' * marker.length }
62
+ end
63
+ end
64
+ end
65
+ end