mdl 0.0.1

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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.travis.yml +7 -0
  4. data/Gemfile +2 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +79 -0
  7. data/Rakefile +8 -0
  8. data/bin/mdl +10 -0
  9. data/docs/RULES.md +609 -0
  10. data/docs/creating_rules.md +83 -0
  11. data/docs/creating_styles.md +47 -0
  12. data/example/markdown_spec.md +897 -0
  13. data/lib/mdl.rb +72 -0
  14. data/lib/mdl/cli.rb +89 -0
  15. data/lib/mdl/config.rb +9 -0
  16. data/lib/mdl/doc.rb +252 -0
  17. data/lib/mdl/kramdown_parser.rb +29 -0
  18. data/lib/mdl/rules.rb +393 -0
  19. data/lib/mdl/ruleset.rb +47 -0
  20. data/lib/mdl/style.rb +50 -0
  21. data/lib/mdl/styles/all.rb +1 -0
  22. data/lib/mdl/styles/cirosantilli.rb +6 -0
  23. data/lib/mdl/styles/default.rb +1 -0
  24. data/lib/mdl/styles/relaxed.rb +6 -0
  25. data/lib/mdl/version.rb +3 -0
  26. data/mdl.gemspec +31 -0
  27. data/test/rule_tests/atx_closed_header_spacing.md +17 -0
  28. data/test/rule_tests/atx_header_spacing.md +5 -0
  29. data/test/rule_tests/blockquote_blank_lines.md +31 -0
  30. data/test/rule_tests/blockquote_spaces.md +21 -0
  31. data/test/rule_tests/bulleted_list_2_space_indent.md +6 -0
  32. data/test/rule_tests/bulleted_list_2_space_indent_style.rb +2 -0
  33. data/test/rule_tests/bulleted_list_4_space_indent.md +3 -0
  34. data/test/rule_tests/bulleted_list_not_at_beginning_of_line.md +14 -0
  35. data/test/rule_tests/code_block_dollar.md +22 -0
  36. data/test/rule_tests/consecutive_blank_lines.md +11 -0
  37. data/test/rule_tests/consistent_bullet_styles_asterisk.md +3 -0
  38. data/test/rule_tests/consistent_bullet_styles_dash.md +3 -0
  39. data/test/rule_tests/consistent_bullet_styles_plus.md +3 -0
  40. data/test/rule_tests/empty_doc.md +0 -0
  41. data/test/rule_tests/fenced_code_blocks.md +21 -0
  42. data/test/rule_tests/first_header_bad_atx.md +1 -0
  43. data/test/rule_tests/first_header_bad_setext.md +2 -0
  44. data/test/rule_tests/first_header_good_atx.md +1 -0
  45. data/test/rule_tests/first_header_good_setext.md +2 -0
  46. data/test/rule_tests/header_duplicate_content.md +11 -0
  47. data/test/rule_tests/header_multiple_toplevel.md +3 -0
  48. data/test/rule_tests/header_mutliple_h1_no_toplevel.md +5 -0
  49. data/test/rule_tests/header_trailing_punctuation.md +11 -0
  50. data/test/rule_tests/header_trailing_punctuation_customized.md +14 -0
  51. data/test/rule_tests/header_trailing_punctuation_customized_style.rb +2 -0
  52. data/test/rule_tests/headers_bad.md +7 -0
  53. data/test/rule_tests/headers_good.md +5 -0
  54. data/test/rule_tests/headers_surrounding_space_atx.md +9 -0
  55. data/test/rule_tests/headers_surrounding_space_setext.md +15 -0
  56. data/test/rule_tests/headers_with_spaces_at_the_beginning.md +9 -0
  57. data/test/rule_tests/inconsistent_bullet_indent_same_level.md +4 -0
  58. data/test/rule_tests/inconsistent_bullet_styles_asterisk.md +3 -0
  59. data/test/rule_tests/inconsistent_bullet_styles_dash.md +3 -0
  60. data/test/rule_tests/inconsistent_bullet_styles_plus.md +3 -0
  61. data/test/rule_tests/incorrect_bullet_style_asterisk.md +3 -0
  62. data/test/rule_tests/incorrect_bullet_style_asterisk_style.rb +2 -0
  63. data/test/rule_tests/incorrect_bullet_style_dash.md +3 -0
  64. data/test/rule_tests/incorrect_bullet_style_dash_style.rb +2 -0
  65. data/test/rule_tests/incorrect_bullet_style_plus.md +3 -0
  66. data/test/rule_tests/incorrect_bullet_style_plus_style.rb +2 -0
  67. data/test/rule_tests/incorrect_header_atx.md +6 -0
  68. data/test/rule_tests/incorrect_header_atx_closed.md +6 -0
  69. data/test/rule_tests/incorrect_header_atx_closed_style.rb +2 -0
  70. data/test/rule_tests/incorrect_header_atx_style.rb +2 -0
  71. data/test/rule_tests/incorrect_header_setext.md +6 -0
  72. data/test/rule_tests/incorrect_header_setext_style.rb +2 -0
  73. data/test/rule_tests/long_lines.md +3 -0
  74. data/test/rule_tests/long_lines_100.md +7 -0
  75. data/test/rule_tests/long_lines_100_style.rb +2 -0
  76. data/test/rule_tests/mixed_header_types_atx.md +6 -0
  77. data/test/rule_tests/mixed_header_types_atx_closed.md +6 -0
  78. data/test/rule_tests/mixed_header_types_setext.md +6 -0
  79. data/test/rule_tests/ordered_list_item_prefix.md +13 -0
  80. data/test/rule_tests/ordered_list_item_prefix_ordered.md +13 -0
  81. data/test/rule_tests/ordered_list_item_prefix_ordered_style.rb +2 -0
  82. data/test/rule_tests/reversed_link.md +7 -0
  83. data/test/rule_tests/spaces_after_list_marker.md +74 -0
  84. data/test/rule_tests/spaces_after_list_marker_style.rb +3 -0
  85. data/test/rule_tests/whitespace issues.md +3 -0
  86. data/test/setup_tests.rb +5 -0
  87. data/test/test_ruledocs.rb +45 -0
  88. data/test/test_rules.rb +56 -0
  89. data/tools/README.md +3 -0
  90. data/tools/test_location.rb +20 -0
  91. data/tools/view_markdown.rb +11 -0
  92. metadata +314 -0
@@ -0,0 +1,393 @@
1
+ rule "MD001", "Header levels should only increment by one level at a time" do
2
+ tags :headers
3
+ check do |doc|
4
+ headers = doc.find_type(:header)
5
+ old_level = nil
6
+ errors = []
7
+ headers.each do |h|
8
+ if old_level and h[:level] > old_level + 1
9
+ errors << h[:location]
10
+ end
11
+ old_level = h[:level]
12
+ end
13
+ errors
14
+ end
15
+ end
16
+
17
+ rule "MD002", "First header should be a h1 header" do
18
+ tags :headers
19
+ check do |doc|
20
+ first_header = doc.find_type(:header).first
21
+ [first_header[:location]] if first_header and first_header[:level] != 1
22
+ end
23
+ end
24
+
25
+ rule "MD003", "Header style" do
26
+ # Header styles are things like ### and adding underscores
27
+ # See http://daringfireball.net/projects/markdown/syntax#header
28
+ tags :headers
29
+ # :style can be one of :consistent, :atx, :atx_closed, :setext
30
+ params :style => :consistent
31
+ check do |doc|
32
+ headers = doc.find_type_elements(:header)
33
+ if headers.empty?
34
+ nil
35
+ else
36
+ if @params[:style] == :consistent
37
+ doc_style = doc.header_style(headers.first)
38
+ else
39
+ doc_style = @params[:style]
40
+ end
41
+ headers.map { |h| doc.element_linenumber(h) \
42
+ if doc.header_style(h) != doc_style }.compact
43
+ end
44
+ end
45
+ end
46
+
47
+ rule "MD004", "Unordered list style" do
48
+ tags :bullet, :ul
49
+ # :style can be one of :consistent, :asterisk, :plus, :dash
50
+ params :style => :consistent
51
+ check do |doc|
52
+ bullets = doc.find_type_elements(:ul).map {|l|
53
+ doc.find_type_elements(:li, false, l.children)}.flatten
54
+ if bullets.empty?
55
+ nil
56
+ else
57
+ if @params[:style] == :consistent
58
+ doc_style = doc.list_style(bullets.first)
59
+ else
60
+ doc_style = @params[:style]
61
+ end
62
+ bullets.map { |b| doc.element_linenumber(b) \
63
+ if doc.list_style(b) != doc_style }.compact
64
+ end
65
+ end
66
+ end
67
+
68
+ rule "MD005", "Inconsistent indentation for list items at the same level" do
69
+ tags :bullet, :ul, :indentation
70
+ check do |doc|
71
+ bullets = doc.find_type(:li)
72
+ errors = []
73
+ indent_levels = []
74
+ bullets.each do |b|
75
+ indent_level = doc.indent_for(doc.element_line(b))
76
+ if indent_levels[b[:element_level]].nil?
77
+ indent_levels[b[:element_level]] = indent_level
78
+ end
79
+ if indent_level != indent_levels[b[:element_level]]
80
+ errors << doc.element_linenumber(b)
81
+ end
82
+ end
83
+ errors
84
+ end
85
+ end
86
+
87
+ rule "MD006", "Consider starting bulleted lists at the beginning of the line" do
88
+ # Starting at the beginning of the line means that indendation for each
89
+ # bullet level can be identical.
90
+ tags :bullet, :ul, :indentation
91
+ check do |doc|
92
+ doc.find_type(:ul, false).select{
93
+ |e| doc.indent_for(doc.element_line(e)) != 0 }.map{ |e| e[:location] }
94
+ end
95
+ end
96
+
97
+ rule "MD007", "Unordered list indentation" do
98
+ tags :bullet, :ul, :indentation
99
+ params :indent => 2
100
+ check do |doc|
101
+ indents = []
102
+ errors = []
103
+ indents = doc.find_type(:ul).map {
104
+ |e| [doc.indent_for(doc.element_line(e)), doc.element_linenumber(e)] }
105
+ curr_indent = indents[0][0] unless indents.empty?
106
+ indents.each do |indent, linenum|
107
+ if indent > curr_indent and indent - curr_indent != @params[:indent]
108
+ errors << linenum
109
+ end
110
+ curr_indent = indent
111
+ end
112
+ errors
113
+ end
114
+ end
115
+
116
+ rule "MD009", "Trailing spaces" do
117
+ tags :whitespace
118
+ check do |doc|
119
+ doc.matching_lines(/\s$/)
120
+ end
121
+ end
122
+
123
+ rule "MD010", "Hard tabs" do
124
+ tags :whitespace, :hard_tab
125
+ check do |doc|
126
+ doc.matching_lines(/\t/)
127
+ end
128
+ end
129
+
130
+ rule "MD011", "Reversed link syntax" do
131
+ tags :links
132
+ check do |doc|
133
+ doc.matching_text_element_lines(/\([^)]+\)\[[^\]]+\]/)
134
+ end
135
+ end
136
+
137
+ rule "MD012", "Multiple consecutive blank lines" do
138
+ tags :whitespace, :blank_lines
139
+ check do |doc|
140
+ # Every line in the document that is part of a code block. Blank lines
141
+ # inside of a code block are acceptable.
142
+ codeblock_lines = doc.find_type_elements(:codeblock).map{
143
+ |e| (doc.element_linenumber(e)..
144
+ doc.element_linenumber(e) + e.value.count('\n') - 1).to_a }.flatten
145
+ blank_lines = doc.matching_lines(/^\s*$/)
146
+ cons_blank_lines = blank_lines.each_cons(2).select{
147
+ |p, n| n - p == 1}.map{|p, n| n}
148
+ cons_blank_lines - codeblock_lines
149
+ end
150
+ end
151
+
152
+ rule "MD013", "Line length" do
153
+ tags :line_length
154
+ params :line_length => 80
155
+ check do |doc|
156
+ doc.matching_lines(/^.{#{@params[:line_length]}}.*\s/)
157
+ end
158
+ end
159
+
160
+ rule "MD014", "Dollar signs used before commands without showing output" do
161
+ tags :code
162
+ check do |doc|
163
+ doc.find_type_elements(:codeblock).select{
164
+ |e| not e.value.split(/\n+/).map{|l| l.match(/^\$\s/)}.include?(nil)
165
+ }.map{|e| doc.element_linenumber(e)}
166
+ end
167
+ end
168
+
169
+ rule "MD018", "No space after hash on atx style header" do
170
+ tags :headers, :atx, :spaces
171
+ check do |doc|
172
+ doc.find_type_elements(:header).select do |h|
173
+ doc.header_style(h) == :atx and doc.element_line(h).match(/^#+[^#\s]/)
174
+ end.map { |h| doc.element_linenumber(h) }
175
+ end
176
+ end
177
+
178
+ rule "MD019", "Multiple spaces after hash on atx style header" do
179
+ tags :headers, :atx, :spaces
180
+ check do |doc|
181
+ doc.find_type_elements(:header).select do |h|
182
+ doc.header_style(h) == :atx and doc.element_line(h).match(/^#+\s\s/)
183
+ end.map { |h| doc.element_linenumber(h) }
184
+ end
185
+ end
186
+
187
+ rule "MD020", "No space inside hashes on closed atx style header" do
188
+ tags :headers, :atx_closed, :spaces
189
+ check do |doc|
190
+ doc.find_type_elements(:header).select do |h|
191
+ doc.header_style(h) == :atx_closed \
192
+ and (doc.element_line(h).match(/^#+[^#\s]/) \
193
+ or doc.element_line(h).match(/[^#\s\\]#+$/))
194
+ end.map { |h| doc.element_linenumber(h) }
195
+ end
196
+ end
197
+
198
+ rule "MD021", "Multiple spaces inside hashes on closed atx style header" do
199
+ tags :headers, :atx_closed, :spaces
200
+ check do |doc|
201
+ doc.find_type_elements(:header).select do |h|
202
+ doc.header_style(h) == :atx_closed \
203
+ and (doc.element_line(h).match(/^#+\s\s/) \
204
+ or doc.element_line(h).match(/\s\s#+$/))
205
+ end.map { |h| doc.element_linenumber(h) }
206
+ end
207
+ end
208
+
209
+ rule "MD022", "Headers should be surrounded by blank lines" do
210
+ tags :headers, :blank_lines
211
+ check do |doc|
212
+ errors = []
213
+ doc.find_type_elements(:header).each do |h|
214
+ header_bad = false
215
+ linenum = doc.element_linenumber(h)
216
+ # Check previous line
217
+ if linenum > 1 and not doc.lines[linenum - 2].empty?
218
+ header_bad = true
219
+ end
220
+ # Check next line
221
+ next_line_idx = doc.header_style(h) == :setext ? linenum + 1 : linenum
222
+ next_line = doc.lines[next_line_idx]
223
+ header_bad = true if not next_line.nil? and not next_line.empty?
224
+ errors << linenum if header_bad
225
+ end
226
+ # Kramdown requires that headers start on a block boundary, so in most
227
+ # cases it won't pick up a header without a blank line before it. We need
228
+ # to check regular text and pick out headers ourselves too
229
+ doc.find_type_elements(:p).each do |p|
230
+ linenum = doc.element_linenumber(p)
231
+ text = p.children.select { |e| e.type == :text }.map {|e| e.value }.join
232
+ lines = text.split("\n")
233
+ prev_lines = ["", ""]
234
+ lines.each do |line|
235
+ # First look for ATX style headers without blank lines before
236
+ if line.match(/^\#{1,6}/) and not prev_lines[1].empty?
237
+ errors << linenum
238
+ end
239
+ # Next, look for setext style
240
+ if line.match(/^(-+|=+)\s*$/) and not prev_lines[0].empty?
241
+ errors << linenum - 1
242
+ end
243
+ linenum += 1
244
+ prev_lines << line
245
+ prev_lines.shift
246
+ end
247
+ end
248
+ errors.sort
249
+ end
250
+ end
251
+
252
+ rule "MD023", "Headers must start at the beginning of the line" do
253
+ tags :headers, :spaces
254
+ check do |doc|
255
+ errors = []
256
+ # The only type of header with spaces actually parsed as such is setext
257
+ # style where only the text is indented. We check for that first.
258
+ doc.find_type_elements(:header).each do |h|
259
+ errors << doc.element_linenumber(h) if doc.element_line(h).match(/^\s/)
260
+ end
261
+ # Next we have to look for things that aren't parsed as headers because
262
+ # they start with spaces.
263
+ doc.find_type_elements(:p).each do |p|
264
+ linenum = doc.element_linenumber(p)
265
+ lines = doc.extract_text(p)
266
+ prev_line = ""
267
+ lines.each do |line|
268
+ # First look for ATX style headers
269
+ if line.match(/^\s+\#{1,6}/)
270
+ errors << linenum
271
+ end
272
+ # Next, look for setext style
273
+ if line.match(/^\s+(-+|=+)\s*$/) and not prev_line.empty?
274
+ errors << linenum - 1
275
+ end
276
+ linenum += 1
277
+ prev_line = line
278
+ end
279
+ end
280
+ errors.sort
281
+ end
282
+ end
283
+
284
+ rule "MD024", "Multiple headers with the same content" do
285
+ tags :headers
286
+ check do |doc|
287
+ header_content = Set.new
288
+ doc.find_type(:header).select do |h|
289
+ not header_content.add?(h[:raw_text])
290
+ end.map { |h| doc.element_linenumber(h) }
291
+ end
292
+ end
293
+
294
+ rule "MD025", "Multiple top level headers in the same document" do
295
+ tags :headers
296
+ check do |doc|
297
+ headers = doc.find_type(:header).select { |h| h[:level] == 1 }
298
+ if not headers.empty? and doc.element_linenumber(headers[0]) == 1
299
+ headers[1..-1].map { |h| doc.element_linenumber(h) }
300
+ end
301
+ end
302
+ end
303
+
304
+ rule "MD026", "Trailing punctuation in header" do
305
+ tags :headers
306
+ params :punctuation => '.,;:!?'
307
+ check do |doc|
308
+ doc.find_type(:header).select {
309
+ |h| h[:raw_text].match(/[#{params[:punctuation]}]$/) }.map {
310
+ |h| doc.element_linenumber(h) }
311
+ end
312
+ end
313
+
314
+ rule "MD027", "Multiple spaces after blockquote symbol" do
315
+ tags :blockquote, :whitespace, :indentation
316
+ check do |doc|
317
+ errors = []
318
+ doc.find_type_elements(:blockquote).each do |e|
319
+ linenum = doc.element_linenumber(e)
320
+ lines = doc.extract_text(e, /^\s*> /)
321
+ lines.each do |line|
322
+ errors << linenum if line.start_with?(" ")
323
+ linenum += 1
324
+ end
325
+ end
326
+ errors
327
+ end
328
+ end
329
+
330
+ rule "MD028", "Blank line inside blockquote" do
331
+ tags :blockquote, :whitespace
332
+ check do |doc|
333
+ def check_blockquote(errors, elements)
334
+ prev = [nil, nil, nil]
335
+ elements.each do |e|
336
+ prev.shift
337
+ prev << e.type
338
+ if prev == [:blockquote, :blank, :blockquote]
339
+ # The current location is the start of the second blockquote, so the
340
+ # line before will be a blank line in between the two, or at least the
341
+ # lowest blank line if there are more than one.
342
+ errors << e.options[:location] - 1
343
+ end
344
+ check_blockquote(errors, e.children)
345
+ end
346
+ end
347
+ errors = []
348
+ check_blockquote(errors, doc.elements)
349
+ errors
350
+ end
351
+ end
352
+
353
+ rule "MD029", "Ordered list item prefix" do
354
+ tags :ol
355
+ # Style can be :one or :ordered
356
+ params :style => :one
357
+ check do |doc|
358
+ if params[:style] == :ordered
359
+ doc.find_type_elements(:ol).map { |l|
360
+ doc.find_type_elements(:li, false, l.children).map.with_index { |i, idx|
361
+ doc.element_linenumber(i) \
362
+ unless doc.element_line(i).strip.start_with?("#{idx+1}. ")
363
+ }
364
+ }.flatten.compact
365
+ elsif params[:style] == :one
366
+ doc.find_type_elements(:ol).map { |l|
367
+ doc.find_type_elements(:li, false, l.children) }.flatten.map { |i|
368
+ doc.element_linenumber(i) \
369
+ unless doc.element_line(i).strip.start_with?('1. ') }.compact
370
+ end
371
+ end
372
+ end
373
+
374
+ rule "MD030", "Spaces after list markers" do
375
+ tags :ol, :ul, :whitespace
376
+ params :ul_single => 1, :ol_single => 1, :ul_multi => 1, :ol_multi => 1
377
+ check do |doc|
378
+ errors = []
379
+ doc.find_type_elements([:ul, :ol]).each do |l|
380
+ list_type = l.type.to_s
381
+ items = doc.find_type_elements(:li, false, l.children)
382
+ # The entire list is to use the multi-paragraph spacing rule if any of
383
+ # the items in it have multiple paragraphs/other block items.
384
+ srule = items.map { |i| i.children.length }.max > 1 ? "multi" : "single"
385
+ items.each do |i|
386
+ actual_spaces = doc.element_line(i).match(/^\s*\S+(\s+)/)[1].length
387
+ required_spaces = params["#{list_type}_#{srule}".to_sym]
388
+ errors << doc.element_linenumber(i) if required_spaces != actual_spaces
389
+ end
390
+ end
391
+ errors
392
+ end
393
+ end
@@ -0,0 +1,47 @@
1
+ module MarkdownLint
2
+ class Rule
3
+ attr_accessor :id, :description
4
+
5
+ def initialize(id, description, block)
6
+ @id, @description = id, description
7
+ @tags = []
8
+ @params = {}
9
+ instance_eval &block
10
+ end
11
+
12
+
13
+ def check(&block)
14
+ @check = block unless block.nil?
15
+ @check
16
+ end
17
+
18
+ def tags(*t)
19
+ @tags = t.flatten.map {|i| i.to_sym} unless t.empty?
20
+ @tags
21
+ end
22
+
23
+ def params(p = nil)
24
+ @params.update(p) unless p.nil?
25
+ @params
26
+ end
27
+ end
28
+
29
+ class RuleSet
30
+ attr_reader :rules
31
+
32
+ def rule(id, description, &block)
33
+ @rules = {} if @rules.nil?
34
+ @rules[id] = Rule.new(id, description, block)
35
+ end
36
+
37
+ def self.load_default
38
+ self.load(File.expand_path("../rules.rb", __FILE__))
39
+ end
40
+
41
+ def self.load(rules_file)
42
+ ruleset = new
43
+ ruleset.instance_eval(File.read(rules_file), rules_file)
44
+ ruleset.rules
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,50 @@
1
+ require 'set'
2
+
3
+ module MarkdownLint
4
+ class Style
5
+ attr_reader :rules
6
+
7
+ def initialize(all_rules)
8
+ @tagged_rules = {}
9
+ all_rules.each do |id, r|
10
+ r.tags.each do |t|
11
+ @tagged_rules[t] ||= Set.new
12
+ @tagged_rules[t] << id
13
+ end
14
+ end
15
+ @all_rules = all_rules
16
+ @rules = Set.new
17
+ end
18
+
19
+ def all
20
+ @rules.merge(@all_rules.keys)
21
+ end
22
+
23
+ def rule(id, params={})
24
+ @rules << id
25
+ @all_rules[id].params(params)
26
+ end
27
+
28
+ def exclude_rule(id)
29
+ @rules.delete(id)
30
+ end
31
+
32
+ def tag(t)
33
+ @rules.merge(@tagged_rules[t])
34
+ end
35
+
36
+ def exclude_tag(t)
37
+ @rules.subtract(@tagged_rules[t])
38
+ end
39
+
40
+ def self.load(style_file, rules)
41
+ unless style_file.include?("/") or style_file.end_with?(".rb")
42
+ style_file = File.expand_path("../styles/#{style_file}.rb", __FILE__)
43
+ end
44
+ style = new(rules)
45
+ style.instance_eval(File.read(style_file), style_file)
46
+ rules.select! {|r| style.rules.include?(r)}
47
+ style
48
+ end
49
+ end
50
+ end