mdl 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,72 @@
1
+ require 'mdl/cli'
2
+ require 'mdl/config'
3
+ require 'mdl/doc'
4
+ require 'mdl/kramdown_parser'
5
+ require 'mdl/ruleset'
6
+ require 'mdl/style'
7
+ require 'mdl/version'
8
+
9
+ require 'kramdown'
10
+
11
+ module MarkdownLint
12
+ def self.run
13
+ cli = MarkdownLint::CLI.new
14
+ cli.run
15
+ rules = RuleSet.load_default
16
+ style = Style.load(Config[:style], rules)
17
+ # Rule option filter
18
+ rules.select! {|r| Config[:rules].include?(r) } if Config[:rules]
19
+ # Tag option filter
20
+ rules.select! {|r, v| not (v.tags & Config[:tags]).empty? } if Config[:tags]
21
+
22
+ if Config[:list_rules]
23
+ puts "Enabled rules:"
24
+ rules.each do |id, rule|
25
+ if Config[:verbose]
26
+ puts "#{id} (#{rule.tags.join(', ')}) - #{rule.description}"
27
+ else
28
+ puts "#{id} - #{rule.description}"
29
+ end
30
+ end
31
+ exit 0
32
+ end
33
+
34
+ # Recurse into directories
35
+ cli.cli_arguments.each_with_index do |filename, i|
36
+ if Dir.exist?(filename)
37
+ pattern = "#{filename}/**/*.md" # This works for both Dir and ls-files
38
+ if Config[:git_recurse]
39
+ Dir.chdir(filename) do
40
+ cli.cli_arguments[i] = %x(git ls-files '*.md').split("\n")
41
+ end
42
+ else
43
+ cli.cli_arguments[i] = Dir["#{filename}/**/*.md"]
44
+ end
45
+ end
46
+ end
47
+ cli.cli_arguments.flatten!
48
+
49
+ status = 0
50
+ cli.cli_arguments.each do |filename|
51
+ puts "Checking #{filename}..." if Config[:verbose]
52
+ doc = Doc.new_from_file(filename)
53
+ filename = '(stdin)' if filename == "-"
54
+ if Config[:show_kramdown_warnings]
55
+ status = 2 if not doc.parsed.warnings.empty?
56
+ doc.parsed.warnings.each do |w|
57
+ puts "#{filename}: Kramdown Warning: #{w}"
58
+ end
59
+ end
60
+ rules.sort.each do |id, rule|
61
+ puts "Processing rule #{id}" if Config[:verbose]
62
+ error_lines = rule.check.call(doc)
63
+ next if error_lines.nil? or error_lines.empty?
64
+ status = 1
65
+ error_lines.each do |line|
66
+ puts "#{filename}:#{line}: #{id} #{rule.description}"
67
+ end
68
+ end
69
+ end
70
+ exit status
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ require 'mixlib/cli'
2
+
3
+ module MarkdownLint
4
+ class CLI
5
+ include Mixlib::CLI
6
+
7
+ banner "Usage: #{File.basename($0)} [options] [FILE.md|DIR ...]"
8
+
9
+ option :config_file,
10
+ :short => '-c',
11
+ :long => '--config FILE',
12
+ :description => 'The configuration file to use',
13
+ :default => '~/.mdlrc'
14
+
15
+ option :verbose,
16
+ :short => '-v',
17
+ :long => '--[no-]verbose',
18
+ :description => 'Increase verbosity',
19
+ :boolean => true
20
+
21
+ option :show_kramdown_warnings,
22
+ :short => '-w',
23
+ :long => '--[no-]warnings',
24
+ :description => 'Show kramdown warnings',
25
+ :boolean => true
26
+
27
+ option :tags,
28
+ :short => '-t',
29
+ :long => '--tags TAG1,TAG2',
30
+ :description => 'Only process rules with these tags',
31
+ :proc => Proc.new { |v| v.split(',').map { |t| t.to_sym } }
32
+
33
+ option :rules,
34
+ :short => '-r',
35
+ :long => '--rules RULE1,RULE2',
36
+ :description => 'Only process these rules',
37
+ :proc => Proc.new { |v| v.split(',') }
38
+
39
+ option :style,
40
+ :short => '-s',
41
+ :long => '--style STYLE',
42
+ :description => "Load the given style"
43
+
44
+ option :list_rules,
45
+ :short => '-l',
46
+ :long => '--list-rules',
47
+ :boolean => true,
48
+ :description => "Don't process any files, just list enabled rules"
49
+
50
+ option :git_recurse,
51
+ :short => '-g',
52
+ :long => '--git-recurse',
53
+ :boolean => true,
54
+ :description => "Only process files known to git when given a directory"
55
+
56
+ option :help,
57
+ :on => :tail,
58
+ :short => '-h',
59
+ :long => '--help',
60
+ :description => 'Show this message',
61
+ :boolean => true,
62
+ :show_options => true,
63
+ :exit => 0
64
+
65
+ option :version,
66
+ :on => :tail,
67
+ :short => "-V",
68
+ :long => "--version",
69
+ :description => "Show version",
70
+ :boolean => true,
71
+ :proc => Proc.new { puts MarkdownLint::VERSION },
72
+ :exit => 0
73
+
74
+ def run(argv=ARGV)
75
+ parse_options(argv)
76
+ # Load the config file if it's present
77
+ filename = File.expand_path(config[:config_file])
78
+ MarkdownLint::Config.from_file(filename) if File.exists?(filename)
79
+
80
+ # Put values in the config file
81
+ MarkdownLint::Config.merge!(config)
82
+
83
+ # Read from stdin if we didn't provide a filename
84
+ if cli_arguments.empty? and not config[:list_rules]
85
+ cli_arguments << "-"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,9 @@
1
+ require 'mixlib/config'
2
+
3
+ module MarkdownLint
4
+ module Config
5
+ extend Mixlib::Config
6
+
7
+ default :style, "default"
8
+ end
9
+ end
@@ -0,0 +1,252 @@
1
+ require 'kramdown'
2
+ require 'mdl/kramdown_parser'
3
+
4
+ module MarkdownLint
5
+ ##
6
+ # Representation of the markdown document passed to rule checks
7
+
8
+ class Doc
9
+ ##
10
+ # A list of raw markdown source lines. Note that the list is 0-indexed,
11
+ # while line numbers in the parsed source are 1-indexed, so you need to
12
+ # subtract 1 from a line number to get the correct line. The element_line*
13
+ # methods take care of this for you.
14
+
15
+ attr_reader :lines
16
+
17
+ ##
18
+ # A Kramdown::Document object containing the parsed markdown document.
19
+
20
+ attr_reader :parsed
21
+
22
+ ##
23
+ # A list of top level Kramdown::Element objects from the parsed document.
24
+
25
+ attr_reader :elements
26
+
27
+ ##
28
+ # Create a new document given a string containing the markdown source
29
+
30
+ def initialize(text)
31
+ # Workaround for the following two issues:
32
+ # https://github.com/mivok/markdownlint/issues/52
33
+ # https://github.com/gettalong/kramdown/issues/158
34
+ # Unfortunately this forces all input text back into ascii, which may
35
+ # be problematic for any rules that make use of non-ascii characters, so
36
+ # we should remove this if it no longer becomes necessary to do so.
37
+ text.encode!("ASCII", invalid: :replace, undef: :replace, replace: '')
38
+
39
+ @lines = text.split("\n")
40
+ @parsed = Kramdown::Document.new(text, :input => 'MarkdownLint')
41
+ @elements = @parsed.root.children
42
+ add_levels(@elements)
43
+ end
44
+
45
+ ##
46
+ # Alternate 'constructor' passing in a filename
47
+
48
+ def self.new_from_file(filename)
49
+ if filename == "-"
50
+ self.new(STDIN.read)
51
+ else
52
+ self.new(File.read(filename))
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Find all elements of a given type, returning their options hash. The
58
+ # options hash has most of the useful data about an element and often you
59
+ # can just use this in your rules.
60
+ #
61
+ # # Returns [ { :location => 1, :element_level => 2 }, ... ]
62
+ # elements = find_type(:li)
63
+ #
64
+ # If +nested+ is set to false, this returns only top level elements of a
65
+ # given type.
66
+
67
+ def find_type(type, nested=true)
68
+ find_type_elements(type, nested).map { |e| e.options }
69
+ end
70
+
71
+ ##
72
+ # Find all elements of a given type, returning a list of the element
73
+ # objects themselves.
74
+ #
75
+ # Instead of a single type, a list of types can be provided instead to
76
+ # find all types.
77
+ #
78
+ # If +nested+ is set to false, this returns only top level elements of a
79
+ # given type.
80
+
81
+ def find_type_elements(type, nested=true, elements=@elements)
82
+ results = []
83
+ if type.class == Symbol
84
+ type = [type]
85
+ end
86
+ elements.each do |e|
87
+ results.push(e) if type.include?(e.type)
88
+ if nested and not e.children.empty?
89
+ results.concat(find_type_elements(type, nested, e.children))
90
+ end
91
+ end
92
+ results
93
+ end
94
+
95
+ ##
96
+ # Returns the line number a given element is located on in the source
97
+ # file. You can pass in either an element object or an options hash here.
98
+
99
+ def element_linenumber(element)
100
+ element = element.options if element.is_a?(Kramdown::Element)
101
+ element[:location]
102
+ end
103
+
104
+ ##
105
+ # Returns the actual source line for a given element. You can pass in an
106
+ # element object or an options hash here. This is useful if you need to
107
+ # examine the source line directly for your rule to make use of
108
+ # information that isn't present in the parsed document.
109
+
110
+ def element_line(element)
111
+ @lines[element_linenumber(element) - 1]
112
+ end
113
+
114
+ ##
115
+ # Returns a list of line numbers for all elements passed in. You can pass
116
+ # in a list of element objects or a list of options hashes here.
117
+
118
+ def element_linenumbers(elements)
119
+ elements.map { |e| element_linenumber(e) }
120
+ end
121
+
122
+ ##
123
+ # Returns the actual source lines for a list of elements. You can pass in
124
+ # a list of elements objects or a list of options hashes here.
125
+
126
+ def element_lines(elements)
127
+ elements.map { |e| element_line(e) }
128
+ end
129
+
130
+ ##
131
+ # Returns the header 'style' - :atx (hashes at the beginning), :atx_closed
132
+ # (atx header style, but with hashes at the end of the line also), :setext
133
+ # (underlined). You can pass in the element object or an options hash
134
+ # here.
135
+
136
+ def header_style(header)
137
+ if header.type != :header
138
+ raise "header_style called with non-header element"
139
+ end
140
+ line = element_line(header)
141
+ if line.start_with?("#")
142
+ if line.strip.end_with?("#")
143
+ :atx_closed
144
+ else
145
+ :atx
146
+ end
147
+ else
148
+ :setext
149
+ end
150
+ end
151
+
152
+ ##
153
+ # Returns the list style for a list: :asterisk, :plus, :dash, :ordered or
154
+ # :ordered_paren depending on which symbol is used to denote the list
155
+ # item. You can pass in either the element itself or an options hash here.
156
+
157
+ def list_style(item)
158
+ if item.type != :li
159
+ raise "list_style called with non-list element"
160
+ end
161
+ line = element_line(item).strip
162
+ if line.start_with?("*")
163
+ :asterisk
164
+ elsif line.start_with?("+")
165
+ :plus
166
+ elsif line.start_with?("-")
167
+ :dash
168
+ elsif line.match("[0-9]+\.")
169
+ :ordered
170
+ elsif line.match("[0-9]+\)")
171
+ :ordered_paren
172
+ else
173
+ :unknown
174
+ end
175
+ end
176
+
177
+ ##
178
+ # Returns how much a given line is indented. Hard tabs are treated as an
179
+ # indent of 8 spaces. You need to pass in the raw string here.
180
+
181
+ def indent_for(line)
182
+ return line.match(/^\s*/)[0].gsub("\t", " " * 8).length
183
+ end
184
+
185
+ ##
186
+ # Returns line numbers for lines that match the given regular expression
187
+
188
+ def matching_lines(re)
189
+ @lines.each_with_index.select{|text, linenum| re.match(text)}.map{
190
+ |i| i[1]+1}
191
+ end
192
+
193
+ ##
194
+ # Returns line numbers for lines that match the given regular expression.
195
+ # Only considers text inside of 'text' elements (i.e. regular markdown
196
+ # text and not code/links or other elements).
197
+ def matching_text_element_lines(re)
198
+ matches = []
199
+ find_type_elements(:text).each do |e|
200
+ first_line = e.options[:location]
201
+ lines = e.value.split("\n")
202
+ lines.each_with_index do |l, i|
203
+ matches << first_line + i if re.match(l)
204
+ end
205
+ end
206
+ matches
207
+ end
208
+
209
+ ##
210
+ # Extracts the text from an element whose children consist of text
211
+ # elements and other things
212
+
213
+ def extract_text(element, prefix="")
214
+ quotes = {
215
+ :rdquo => '"',
216
+ :ldquo => '"',
217
+ :lsquo => "'",
218
+ :rsquo => "'"
219
+ }
220
+ # If anything goes amiss here, e.g. unknown type, then nil will be
221
+ # returned and we'll just not catch that part of the text, which seems
222
+ # like a sensible failure mode.
223
+ lines = element.children.map { |e|
224
+ if e.type == :text
225
+ e.value
226
+ elsif [:strong, :em, :p].include?(e.type)
227
+ extract_text(e, prefix).join("\n")
228
+ elsif e.type == :smart_quote
229
+ quotes[e.value]
230
+ end
231
+ }.join.split("\n")
232
+ # Text blocks have whitespace stripped, so we need to add it back in at
233
+ # the beginning. Because this might be in something like a blockquote,
234
+ # we optionally strip off a prefix given to the function.
235
+ lines[0] = element_line(element).sub(prefix, "")
236
+ lines
237
+ end
238
+
239
+ private
240
+
241
+ ##
242
+ # Adds a 'level' option to all elements to show how nested they are
243
+
244
+ def add_levels(elements, level=1)
245
+ elements.each do |e|
246
+ e.options[:element_level] = level
247
+ add_levels(e.children, level+1)
248
+ end
249
+ end
250
+
251
+ end
252
+ end
@@ -0,0 +1,29 @@
1
+ # Modified version of the kramdown parser to add in features/changes
2
+ # appropriate for markdownlint, but which don't make sense to try to put
3
+ # upstream.
4
+ require 'kramdown/parser/gfm'
5
+
6
+ module Kramdown
7
+ module Parser
8
+ class MarkdownLint < Kramdown::Parser::Kramdown
9
+
10
+ def initialize(source, options)
11
+ super
12
+ i = @block_parsers.index(:codeblock_fenced)
13
+ @block_parsers.delete(:codeblock_fenced)
14
+ @block_parsers.insert(i, :codeblock_fenced_gfm)
15
+ end
16
+
17
+ # Add location information to text elements
18
+ def add_text(text, tree = @tree, type = @text_type)
19
+ super
20
+ if tree.children.last
21
+ tree.children.last.options[:location] = @src.current_line_number
22
+ end
23
+ end
24
+
25
+ # Regular kramdown parser, but with GFM style fenced code blocks
26
+ FENCED_CODEBLOCK_MATCH = Kramdown::Parser::GFM::FENCED_CODEBLOCK_MATCH
27
+ end
28
+ end
29
+ end