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,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