mdlint 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/.pre-commit-hooks.yaml +6 -0
- data/CHANGELOG.md +33 -0
- data/README.md +136 -4
- data/Rakefile +14 -0
- data/Steepfile +17 -0
- data/action.yml +54 -0
- data/benchmark/compare.rb +49 -0
- data/benchmark/format.rb +29 -0
- data/lib/mdlint/cache_store.rb +85 -0
- data/lib/mdlint/cli/output_formatter.rb +150 -0
- data/lib/mdlint/cli.rb +268 -106
- data/lib/mdlint/config.rb +87 -1
- data/lib/mdlint/dialect.rb +53 -0
- data/lib/mdlint/linter/directive_filter.rb +91 -0
- data/lib/mdlint/linter/rule.rb +25 -5
- data/lib/mdlint/linter/rule_engine.rb +44 -7
- data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
- data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
- data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
- data/lib/mdlint/linter/rules/heading_style.rb +24 -2
- data/lib/mdlint/linter/rules/japanese.rb +201 -0
- data/lib/mdlint/linter/rules/line_length.rb +37 -0
- data/lib/mdlint/linter/rules/link_check.rb +151 -0
- data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
- data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
- data/lib/mdlint/linter/rules/source_style.rb +317 -0
- data/lib/mdlint/linter/violation.rb +16 -1
- data/lib/mdlint/linter.rb +9 -3
- data/lib/mdlint/lsp.rb +176 -0
- data/lib/mdlint/parallel_runner.rb +42 -0
- data/lib/mdlint/parser/block_parser.rb +627 -50
- data/lib/mdlint/parser/inline_parser.rb +259 -27
- data/lib/mdlint/parser/state.rb +21 -2
- data/lib/mdlint/parser.rb +5 -5
- data/lib/mdlint/plugin.rb +31 -0
- data/lib/mdlint/renderer/html_renderer.rb +346 -0
- data/lib/mdlint/renderer/md_renderer.rb +147 -11
- data/lib/mdlint/renderer.rb +5 -0
- data/lib/mdlint/text_width.rb +39 -0
- data/lib/mdlint/toc.rb +80 -0
- data/lib/mdlint/token.rb +3 -1
- data/lib/mdlint/version.rb +1 -1
- data/lib/mdlint.rb +25 -5
- data/script/commonmark_compatibility.rb +24 -0
- data/script/fetch_commonmark_spec.rb +14 -0
- data/sig/internal.rbs +405 -0
- data/sig/mdlint.rbs +107 -0
- metadata +26 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../text_width"
|
|
4
|
+
|
|
5
|
+
module Mdlint
|
|
6
|
+
module Linter
|
|
7
|
+
module Rules
|
|
8
|
+
class LineLength < Rule
|
|
9
|
+
self.rule_id = "MD013"
|
|
10
|
+
self.aliases = ["line-length"]
|
|
11
|
+
self.description = "Line length should not exceed the configured limit"
|
|
12
|
+
|
|
13
|
+
def check(_tokens, source)
|
|
14
|
+
maximum = @options.fetch(:line_length, @options.fetch(:length, 80)).to_i
|
|
15
|
+
return @violations if maximum <= 0
|
|
16
|
+
|
|
17
|
+
in_fence = false
|
|
18
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
19
|
+
stripped = line.chomp
|
|
20
|
+
in_fence = !in_fence if stripped.match?(/\A {0,3}(`{3,}|~{3,})/)
|
|
21
|
+
next if in_fence && @options.fetch(:ignore_code_blocks, false)
|
|
22
|
+
next if TextWidth.measure(stripped) <= maximum
|
|
23
|
+
|
|
24
|
+
add_violation(
|
|
25
|
+
message: "Line length #{TextWidth.measure(stripped)} exceeds #{maximum}",
|
|
26
|
+
line: line_number,
|
|
27
|
+
column: maximum + 1,
|
|
28
|
+
fixable: false
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@violations
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mdlint
|
|
8
|
+
module Linter
|
|
9
|
+
module Rules
|
|
10
|
+
class LinkCheck < Rule
|
|
11
|
+
self.rule_id = "MD052"
|
|
12
|
+
self.aliases = ["link-check"]
|
|
13
|
+
self.description = "Relative links should point to existing files"
|
|
14
|
+
|
|
15
|
+
def check(tokens, source)
|
|
16
|
+
return @violations unless @options[:check_links] || @options[:check_external_links]
|
|
17
|
+
|
|
18
|
+
@headings = heading_slugs(tokens)
|
|
19
|
+
|
|
20
|
+
tokens.each do |token|
|
|
21
|
+
next unless token.type == :inline
|
|
22
|
+
|
|
23
|
+
token.children.each do |child|
|
|
24
|
+
next unless child.type == :link_open || child.type == :image
|
|
25
|
+
|
|
26
|
+
target = child.attrs[:href] || child.attrs[:src]
|
|
27
|
+
image = child.is_a?(Token) && child.type == :image
|
|
28
|
+
check_target(target, token, image, source)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
@violations
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def check_target(target, token, image, source)
|
|
37
|
+
return if target.nil? || target.empty?
|
|
38
|
+
|
|
39
|
+
if target.match?(%r{\Ahttps?://}i)
|
|
40
|
+
check_external_target(target, token) if @options[:check_external_links]
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
return if target.match?(%r{\A(?:ftp|mailto):}i)
|
|
44
|
+
|
|
45
|
+
path, fragment = target.split("#", 2)
|
|
46
|
+
base = @options[:filename] && File.dirname(@options[:filename])
|
|
47
|
+
return unless base || path.empty?
|
|
48
|
+
|
|
49
|
+
if path.empty?
|
|
50
|
+
check_fragment(fragment, token, image, @headings)
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
resolved_path = File.expand_path(path, base)
|
|
55
|
+
unless File.file?(resolved_path)
|
|
56
|
+
add_missing_target(path, token)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return if image || fragment.nil? || fragment.empty?
|
|
61
|
+
return if non_markdown_file?(resolved_path)
|
|
62
|
+
|
|
63
|
+
target_headings = begin
|
|
64
|
+
heading_slugs(Parser.parse(File.read(resolved_path), @options))
|
|
65
|
+
rescue StandardError
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
check_fragment(fragment, token, image, target_headings)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def check_fragment(fragment, token, image, headings)
|
|
72
|
+
return if image || fragment.nil? || fragment.empty?
|
|
73
|
+
decoded = URI::DEFAULT_PARSER.unescape(fragment).downcase
|
|
74
|
+
return if headings.include?(decoded)
|
|
75
|
+
|
|
76
|
+
add_violation(
|
|
77
|
+
message: "Link anchor does not exist: ##{fragment}",
|
|
78
|
+
line: (token.map&.first || 0) + 1,
|
|
79
|
+
fixable: false
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_missing_target(path, token)
|
|
84
|
+
add_violation(
|
|
85
|
+
message: "Link target does not exist: #{path}",
|
|
86
|
+
line: (token.map&.first || 0) + 1,
|
|
87
|
+
fixable: false
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_external_target(target, token)
|
|
92
|
+
uri = URI.parse(target)
|
|
93
|
+
host = uri.host.to_s
|
|
94
|
+
request_path = uri.path.to_s
|
|
95
|
+
request_path = "/" if request_path.empty?
|
|
96
|
+
request_path += "?#{uri.query}" if uri.query
|
|
97
|
+
response = Net::HTTP.start(
|
|
98
|
+
host,
|
|
99
|
+
uri.port,
|
|
100
|
+
use_ssl: uri.scheme == "https",
|
|
101
|
+
open_timeout: 3,
|
|
102
|
+
read_timeout: 3
|
|
103
|
+
) do |http|
|
|
104
|
+
result = http.head(request_path)
|
|
105
|
+
result.is_a?(Net::HTTPMethodNotAllowed) ? http.get(request_path) : result
|
|
106
|
+
end
|
|
107
|
+
return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
|
|
108
|
+
|
|
109
|
+
add_violation(
|
|
110
|
+
message: "External link returned HTTP #{response.code}: #{target}",
|
|
111
|
+
line: (token.map&.first || 0) + 1,
|
|
112
|
+
fixable: false
|
|
113
|
+
)
|
|
114
|
+
rescue URI::InvalidURIError, SocketError, SystemCallError, Timeout::Error, IOError => error
|
|
115
|
+
add_violation(
|
|
116
|
+
message: "External link could not be reached: #{target} (#{error.class})",
|
|
117
|
+
line: (token.map&.first || 0) + 1,
|
|
118
|
+
fixable: false
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def non_markdown_file?(path)
|
|
123
|
+
!%w[.md .markdown .mdown .mkdn].include?(File.extname(path).downcase)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def heading_slugs(tokens)
|
|
127
|
+
counts = Hash.new(0)
|
|
128
|
+
slugs = []
|
|
129
|
+
tokens.each_with_index do |token, index|
|
|
130
|
+
next unless token.type == :heading_open
|
|
131
|
+
|
|
132
|
+
inline = tokens[(index + 1)..]&.find { |candidate| candidate.type == :inline }
|
|
133
|
+
base = slugify(inline&.content.to_s)
|
|
134
|
+
next if base.empty?
|
|
135
|
+
|
|
136
|
+
suffix = counts[base]
|
|
137
|
+
counts[base] += 1
|
|
138
|
+
slugs << (suffix.zero? ? base : "#{base}-#{suffix}")
|
|
139
|
+
end
|
|
140
|
+
slugs
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def slugify(value)
|
|
144
|
+
value = value.gsub(/[`*_~\[\]()<>]/, "")
|
|
145
|
+
value.downcase.gsub(/[^\p{Alnum}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\s-]/, "")
|
|
146
|
+
.strip.gsub(/\s+/, "-")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../dialect"
|
|
4
|
+
|
|
5
|
+
module Mdlint
|
|
6
|
+
module Linter
|
|
7
|
+
module Rules
|
|
8
|
+
module SourceStyleSupport
|
|
9
|
+
FENCE_REGEXP = /\A {0,3}(`{3,}|~{3,})/
|
|
10
|
+
LIST_MARKER_REGEXP = /\A( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)(\S.*)?\z/
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def lines(source)
|
|
15
|
+
source.lines
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fenced_lines(source)
|
|
19
|
+
in_fence = false
|
|
20
|
+
source.each_line.with_index(1).filter_map do |line, line_number|
|
|
21
|
+
marker = line.chomp.match(FENCE_REGEXP)
|
|
22
|
+
in_fence = !in_fence if marker
|
|
23
|
+
[line_number, in_fence, marker]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def outside_fence?(fence_state)
|
|
28
|
+
!fence_state
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def line_is_blank?(line)
|
|
32
|
+
line.to_s.match?(/\A\s*\z/)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inline_tokens(tokens)
|
|
36
|
+
tokens.select { |token| token.type == :inline }.flat_map(&:children)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class NoHardTabs < Rule
|
|
41
|
+
include SourceStyleSupport
|
|
42
|
+
|
|
43
|
+
self.rule_id = "MD010"
|
|
44
|
+
self.aliases = ["no-hard-tabs"]
|
|
45
|
+
self.description = "Hard tabs should not be used"
|
|
46
|
+
|
|
47
|
+
def check(_tokens, source)
|
|
48
|
+
in_fence = false
|
|
49
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
50
|
+
in_fence = !in_fence if line.match?(FENCE_REGEXP)
|
|
51
|
+
next if in_fence && @options.fetch(:ignore_code_blocks, false)
|
|
52
|
+
next unless line.include?("\t")
|
|
53
|
+
|
|
54
|
+
add_violation(message: "Hard tab character", line: line_number, column: line.index("\t").to_i + 1, fixable: true)
|
|
55
|
+
end
|
|
56
|
+
@violations
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fix(_tokens, source)
|
|
60
|
+
source.each_line.map { |line| line.gsub("\t", " ") }.join
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class NoSpaceAfterHash < Rule
|
|
65
|
+
self.rule_id = "MD018"
|
|
66
|
+
self.aliases = ["no-missing-space-atx"]
|
|
67
|
+
self.description = "No space after the hash on an ATX heading"
|
|
68
|
+
|
|
69
|
+
def check(_tokens, source)
|
|
70
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
71
|
+
next unless line.chomp.match?(/\A {0,3}\#{1,6}(?!\#)\S/)
|
|
72
|
+
|
|
73
|
+
add_violation(message: "Add a space after the heading marker", line: line_number, fixable: true)
|
|
74
|
+
end
|
|
75
|
+
@violations
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fix(_tokens, source)
|
|
79
|
+
source.each_line.map { |line| line.sub(/\A( {0,3}\#{1,6})(?!\#)(\S)/, '\\1 \\2') }.join
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class NoMultipleSpacesAfterHash < Rule
|
|
84
|
+
self.rule_id = "MD019"
|
|
85
|
+
self.aliases = ["no-multiple-space-atx"]
|
|
86
|
+
self.description = "No more than one space after the hash on an ATX heading"
|
|
87
|
+
|
|
88
|
+
def check(_tokens, source)
|
|
89
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
90
|
+
next unless line.chomp.match?(/\A {0,3}#+ {2,}\S/)
|
|
91
|
+
|
|
92
|
+
add_violation(message: "Use one space after the heading marker", line: line_number, fixable: true)
|
|
93
|
+
end
|
|
94
|
+
@violations
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fix(_tokens, source)
|
|
98
|
+
source.each_line.map { |line| line.sub(/\A( {0,3}#+) {2,}/, '\\1 ') }.join
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class NoMultipleSpacesAfterBlockquote < Rule
|
|
103
|
+
self.rule_id = "MD027"
|
|
104
|
+
self.aliases = ["no-multiple-space-blockquote"]
|
|
105
|
+
self.description = "No more than one space after a blockquote marker"
|
|
106
|
+
|
|
107
|
+
def check(_tokens, source)
|
|
108
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
109
|
+
next unless line.chomp.match?(/\A {0,3}> {2,}/)
|
|
110
|
+
|
|
111
|
+
add_violation(message: "Use one space after the blockquote marker", line: line_number, fixable: true)
|
|
112
|
+
end
|
|
113
|
+
@violations
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fix(_tokens, source)
|
|
117
|
+
source.each_line.map { |line| line.sub(/\A( {0,3}>) {2,}/, '\\1 ') }.join
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class ListMarkerSpace < Rule
|
|
122
|
+
self.rule_id = "MD030"
|
|
123
|
+
self.aliases = ["list-marker-space"]
|
|
124
|
+
self.description = "List markers should be followed by one space"
|
|
125
|
+
|
|
126
|
+
def check(_tokens, source)
|
|
127
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
128
|
+
match = line.chomp.match(/\A( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)(\S)/)
|
|
129
|
+
next unless match && match[2] != " "
|
|
130
|
+
|
|
131
|
+
add_violation(message: "Use one space after a list marker", line: line_number, fixable: true)
|
|
132
|
+
end
|
|
133
|
+
@violations
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fix(_tokens, source)
|
|
137
|
+
source.each_line.map { |line| line.sub(/\A( {0,3}(?:[-+*]|\d+[.)]))[ \t]+/, '\\1 ') }.join
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class FencedCodeBlankLines < Rule
|
|
142
|
+
include SourceStyleSupport
|
|
143
|
+
|
|
144
|
+
self.rule_id = "MD031"
|
|
145
|
+
self.aliases = ["blanks-around-fences"]
|
|
146
|
+
self.description = "Fenced code blocks should be surrounded by blank lines"
|
|
147
|
+
|
|
148
|
+
def check(_tokens, source)
|
|
149
|
+
source_lines = lines(source)
|
|
150
|
+
in_fence = false
|
|
151
|
+
source_lines.each_with_index do |line, index|
|
|
152
|
+
next unless line.chomp.match?(FENCE_REGEXP)
|
|
153
|
+
|
|
154
|
+
if !in_fence && index.positive? && !line_is_blank?(source_lines[index - 1])
|
|
155
|
+
add_violation(message: "Add a blank line before the fenced code block", line: index + 1)
|
|
156
|
+
elsif in_fence && index < source_lines.length - 1 && !line_is_blank?(source_lines[index + 1])
|
|
157
|
+
add_violation(message: "Add a blank line after the fenced code block", line: index + 1)
|
|
158
|
+
end
|
|
159
|
+
in_fence = !in_fence
|
|
160
|
+
end
|
|
161
|
+
@violations
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class ListBlankLines < Rule
|
|
166
|
+
include SourceStyleSupport
|
|
167
|
+
|
|
168
|
+
self.rule_id = "MD032"
|
|
169
|
+
self.aliases = ["blanks-around-lists"]
|
|
170
|
+
self.description = "Lists should be surrounded by blank lines"
|
|
171
|
+
|
|
172
|
+
def check(_tokens, source)
|
|
173
|
+
source_lines = lines(source)
|
|
174
|
+
list_indexes = source_lines.each_index.select { |index| source_lines[index].match?(LIST_MARKER_REGEXP) }
|
|
175
|
+
return @violations if list_indexes.empty?
|
|
176
|
+
|
|
177
|
+
first = list_indexes.first
|
|
178
|
+
last = list_indexes.last
|
|
179
|
+
if first.positive? && !line_is_blank?(source_lines[first - 1])
|
|
180
|
+
add_violation(message: "Add a blank line before the list", line: first + 1)
|
|
181
|
+
end
|
|
182
|
+
if last < source_lines.length - 1 && !line_is_blank?(source_lines[last + 1])
|
|
183
|
+
add_violation(message: "Add a blank line after the list", line: last + 1)
|
|
184
|
+
end
|
|
185
|
+
@violations
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
class NoBareUrls < Rule
|
|
190
|
+
include SourceStyleSupport
|
|
191
|
+
|
|
192
|
+
self.rule_id = "MD034"
|
|
193
|
+
self.aliases = ["no-bare-urls"]
|
|
194
|
+
self.description = "Bare URLs should be enclosed in angle brackets"
|
|
195
|
+
|
|
196
|
+
def check(tokens, source)
|
|
197
|
+
return @violations unless Dialect.resolve(@options[:dialect]).feature?(:bare_autolinks)
|
|
198
|
+
|
|
199
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
200
|
+
next if line.match?(FENCE_REGEXP)
|
|
201
|
+
next unless line.match?(%r{(?<![<\w"'=(])https?://[^\s<>]+})
|
|
202
|
+
|
|
203
|
+
add_violation(message: "Enclose bare URLs in angle brackets", line: line_number, fixable: false)
|
|
204
|
+
end
|
|
205
|
+
@violations
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
class NoSpaceInEmphasis < Rule
|
|
210
|
+
include SourceStyleSupport
|
|
211
|
+
|
|
212
|
+
self.rule_id = "MD037"
|
|
213
|
+
self.aliases = ["no-space-in-emphasis"]
|
|
214
|
+
self.description = "Emphasis markers should not contain extra spaces"
|
|
215
|
+
|
|
216
|
+
def check(_tokens, source)
|
|
217
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
218
|
+
next unless line.match?(/(?:\*\*|__|(?<!\*)\*)(?:\s+)[^\n]+?(?:\s+)(?:\*\*|__|(?<!\*)\*(?!\*))/)
|
|
219
|
+
|
|
220
|
+
add_violation(message: "Remove spaces inside emphasis markers", line: line_number, fixable: false)
|
|
221
|
+
end
|
|
222
|
+
@violations
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
class NoSpaceInCode < Rule
|
|
227
|
+
include SourceStyleSupport
|
|
228
|
+
|
|
229
|
+
self.rule_id = "MD038"
|
|
230
|
+
self.aliases = ["no-space-in-code"]
|
|
231
|
+
self.description = "Code spans should not contain extra spaces"
|
|
232
|
+
|
|
233
|
+
def check(_tokens, source)
|
|
234
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
235
|
+
invalid = line.scan(/(`+)([^`\n]*?)\1/).any? { |_marker, content| content.to_s != content.to_s.strip }
|
|
236
|
+
next unless invalid
|
|
237
|
+
|
|
238
|
+
add_violation(message: "Remove spaces inside code span markers", line: line_number, fixable: false)
|
|
239
|
+
end
|
|
240
|
+
@violations
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class FencedCodeStyle < Rule
|
|
245
|
+
include SourceStyleSupport
|
|
246
|
+
|
|
247
|
+
self.rule_id = "MD046"
|
|
248
|
+
self.aliases = ["fenced-code-style"]
|
|
249
|
+
self.description = "Use fenced code blocks instead of indented code blocks"
|
|
250
|
+
|
|
251
|
+
def check(_tokens, source)
|
|
252
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
253
|
+
next unless line.match?(/\A {4}\S/)
|
|
254
|
+
|
|
255
|
+
add_violation(message: "Use a fenced code block", line: line_number, fixable: false)
|
|
256
|
+
end
|
|
257
|
+
@violations
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
class SingleTrailingNewline < Rule
|
|
262
|
+
self.rule_id = "MD047"
|
|
263
|
+
self.aliases = ["single-trailing-newline"]
|
|
264
|
+
self.description = "Files should end with a single newline"
|
|
265
|
+
|
|
266
|
+
def check(_tokens, source)
|
|
267
|
+
return @violations if source.empty? || source.end_with?("\n") && !source.end_with?("\n\n")
|
|
268
|
+
|
|
269
|
+
add_violation(message: "File should end with a single newline", line: source.lines.length, fixable: true)
|
|
270
|
+
@violations
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def fix(_tokens, source)
|
|
274
|
+
source.rstrip + "\n"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
class EmphasisStyle < Rule
|
|
279
|
+
include SourceStyleSupport
|
|
280
|
+
|
|
281
|
+
self.rule_id = "MD049"
|
|
282
|
+
self.aliases = ["emphasis-style"]
|
|
283
|
+
self.description = "Emphasis style should be consistent"
|
|
284
|
+
|
|
285
|
+
def check(tokens, _source)
|
|
286
|
+
expected = @options.fetch(:emphasis_style, :asterisk).to_s
|
|
287
|
+
inline_tokens(tokens).each do |token|
|
|
288
|
+
next unless token.type == :em_open
|
|
289
|
+
next unless (expected == "asterisk" && token.markup == "_") || (expected == "underscore" && token.markup == "*")
|
|
290
|
+
|
|
291
|
+
add_violation(message: "Use #{expected} emphasis markers", line: 1, fixable: false)
|
|
292
|
+
end
|
|
293
|
+
@violations
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
class StrongStyle < Rule
|
|
298
|
+
include SourceStyleSupport
|
|
299
|
+
|
|
300
|
+
self.rule_id = "MD050"
|
|
301
|
+
self.aliases = ["strong-style"]
|
|
302
|
+
self.description = "Strong style should be consistent"
|
|
303
|
+
|
|
304
|
+
def check(tokens, _source)
|
|
305
|
+
expected = @options.fetch(:strong_style, :asterisk).to_s
|
|
306
|
+
inline_tokens(tokens).each do |token|
|
|
307
|
+
next unless token.type == :strong_open
|
|
308
|
+
next unless (expected == "asterisk" && token.markup == "__") || (expected == "underscore" && token.markup == "**")
|
|
309
|
+
|
|
310
|
+
add_violation(message: "Use #{expected} strong markers", line: 1, fixable: false)
|
|
311
|
+
end
|
|
312
|
+
@violations
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -10,7 +10,7 @@ module Mdlint
|
|
|
10
10
|
@message = message
|
|
11
11
|
@line = line
|
|
12
12
|
@column = column
|
|
13
|
-
@severity = severity
|
|
13
|
+
@severity = severity.to_sym
|
|
14
14
|
@fixable = fixable
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -30,6 +30,21 @@ module Mdlint
|
|
|
30
30
|
def warning?
|
|
31
31
|
@severity == :warning
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
def info?
|
|
35
|
+
@severity == :info
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
rule_id: @rule_id,
|
|
41
|
+
message: @message,
|
|
42
|
+
line: @line,
|
|
43
|
+
column: @column,
|
|
44
|
+
severity: @severity,
|
|
45
|
+
fixable: @fixable
|
|
46
|
+
}
|
|
47
|
+
end
|
|
33
48
|
end
|
|
34
49
|
end
|
|
35
50
|
end
|
data/lib/mdlint/linter.rb
CHANGED
|
@@ -3,23 +3,29 @@
|
|
|
3
3
|
require_relative "linter/violation"
|
|
4
4
|
require_relative "linter/rule"
|
|
5
5
|
require_relative "linter/rule_engine"
|
|
6
|
+
require_relative "linter/directive_filter"
|
|
6
7
|
require_relative "linter/rules/heading_style"
|
|
7
8
|
require_relative "linter/rules/heading_increment"
|
|
8
9
|
require_relative "linter/rules/no_trailing_spaces"
|
|
9
10
|
require_relative "linter/rules/no_multiple_blanks"
|
|
10
11
|
require_relative "linter/rules/first_line_heading"
|
|
12
|
+
require_relative "linter/rules/line_length"
|
|
13
|
+
require_relative "linter/rules/link_check"
|
|
14
|
+
require_relative "linter/rules/japanese"
|
|
15
|
+
require_relative "linter/rules/code_block_syntax"
|
|
16
|
+
require_relative "linter/rules/source_style"
|
|
11
17
|
|
|
12
18
|
module Mdlint
|
|
13
19
|
module Linter
|
|
14
20
|
class << self
|
|
15
21
|
def check(src, options = {})
|
|
16
|
-
tokens = Parser.parse(src)
|
|
22
|
+
tokens = Parser.parse(src, options)
|
|
17
23
|
engine = RuleEngine.new(options)
|
|
18
|
-
engine.check(tokens, src)
|
|
24
|
+
DirectiveFilter.apply(engine.check(tokens, src), src)
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
def fix(src, options = {})
|
|
22
|
-
tokens = Parser.parse(src)
|
|
28
|
+
tokens = Parser.parse(src, options)
|
|
23
29
|
engine = RuleEngine.new(options)
|
|
24
30
|
engine.fix(tokens, src)
|
|
25
31
|
end
|