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
data/lib/mdlint/cli.rb
CHANGED
|
@@ -1,76 +1,108 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
-
require "
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../mdlint"
|
|
5
6
|
require_relative "config"
|
|
7
|
+
require_relative "cli/output_formatter"
|
|
6
8
|
|
|
7
9
|
module Mdlint
|
|
8
10
|
class CLI
|
|
11
|
+
FAIL_LEVELS = { info: 0, warning: 1, error: 2 }.freeze
|
|
12
|
+
COMMANDS = %w[format lint fix lsp].freeze
|
|
13
|
+
|
|
9
14
|
def initialize(argv)
|
|
10
15
|
@argv = argv.dup
|
|
16
|
+
@command = extract_command
|
|
11
17
|
@cli_options = { exclude: [] }
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def run
|
|
15
21
|
parse_options
|
|
16
22
|
load_config
|
|
17
|
-
|
|
23
|
+
load_plugins
|
|
24
|
+
@cache_store = CacheStore.new(@options[:cache_path]) if @options[:cache]
|
|
25
|
+
return show_rule_list if @options[:list_rules]
|
|
26
|
+
return show_rule_explanation(@options[:explain]) if @options[:explain]
|
|
18
27
|
|
|
19
|
-
if
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
process_paths(paths)
|
|
23
|
-
end
|
|
28
|
+
return Mdlint::Lsp::Server.new.run if @command == :lsp
|
|
29
|
+
|
|
30
|
+
@command == :format ? process_format : process_lint
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
private
|
|
27
34
|
|
|
35
|
+
def extract_command
|
|
36
|
+
command = @argv.first
|
|
37
|
+
return :format unless COMMANDS.include?(command)
|
|
38
|
+
|
|
39
|
+
@argv.shift.to_sym
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
def load_config
|
|
29
43
|
config = Config.new
|
|
30
44
|
config.load
|
|
31
45
|
@options = config.merge(@cli_options)
|
|
46
|
+
@options[:format] ||= "text" if @command != :format
|
|
32
47
|
end
|
|
33
48
|
|
|
34
49
|
def parse_options
|
|
35
50
|
parser = OptionParser.new do |opts|
|
|
36
|
-
opts.banner = "Usage: mdlint [options] [paths...]"
|
|
51
|
+
opts.banner = "Usage: mdlint [command] [options] [paths...]"
|
|
52
|
+
opts.separator ""
|
|
53
|
+
opts.separator "Commands: format (default), lint, fix, lsp"
|
|
37
54
|
opts.separator ""
|
|
38
55
|
opts.separator "Options:"
|
|
39
56
|
|
|
40
|
-
opts.on("-c", "--check", "Check if files are formatted, exit with error if not")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
opts.on("-
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") do |pattern|
|
|
53
|
-
@cli_options[:exclude] << pattern
|
|
57
|
+
opts.on("-c", "--check", "Check if files are formatted, exit with error if not") { @cli_options[:check] = true }
|
|
58
|
+
opts.on("-d", "--diff", "Show diff of changes") { @cli_options[:diff] = true }
|
|
59
|
+
opts.on("-q", "--quiet", "Suppress output") { @cli_options[:quiet] = true }
|
|
60
|
+
opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") { |pattern| @cli_options[:exclude] << pattern }
|
|
61
|
+
opts.on("-w", "--wrap MODE", "Paragraph wrapping: keep, no, or INTEGER") { |mode| @cli_options[:wrap] = parse_wrap_mode(mode) }
|
|
62
|
+
opts.on("--number", "Use consecutive numbering for ordered lists") { @cli_options[:number] = true }
|
|
63
|
+
opts.on("--end-of-line MODE", "End of line: lf, crlf, keep") { |mode| @cli_options[:end_of_line] = mode.downcase.to_sym }
|
|
64
|
+
opts.on("--fix", "Fix fixable lint violations") do
|
|
65
|
+
@cli_options[:fix] = true
|
|
66
|
+
@command = :lint if @command == :format
|
|
54
67
|
end
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@cli_options[:
|
|
68
|
+
opts.on("--fix-only", "Apply fixes without reporting remaining violations") do
|
|
69
|
+
@cli_options[:fix] = true
|
|
70
|
+
@cli_options[:fix_only] = true
|
|
71
|
+
@command = :fix
|
|
58
72
|
end
|
|
59
|
-
|
|
60
|
-
opts.on("--
|
|
61
|
-
|
|
73
|
+
opts.on("--dry-run", "Do not write fixes to files") { @cli_options[:dry_run] = true }
|
|
74
|
+
opts.on("--disable RULES", "Disable comma-separated rules") { |rules| @cli_options[:disable] = split_rules(rules) }
|
|
75
|
+
opts.on("--rule RULES", "Run only comma-separated rules") { |rules| @cli_options[:rules] = split_rules(rules) }
|
|
76
|
+
opts.on("--severity LEVEL", "Default severity: error, warning, or info") { |level| @cli_options[:severity] = parse_level(level) }
|
|
77
|
+
opts.on("--fail-level LEVEL", "Fail at severity: error, warning, or info") { |level| @cli_options[:fail_level] = parse_level(level) }
|
|
78
|
+
opts.on("--format FORMAT", "Lint output: text, json, sarif, github, checkstyle, junit, reviewdog") { |format| @cli_options[:format] = format }
|
|
79
|
+
opts.on("--stdin-filename NAME", "Filename to use for stdin diagnostics") { |name| @cli_options[:stdin_filename] = name }
|
|
80
|
+
opts.on("--dialect DIALECT", "Markdown dialect: commonmark or gfm") { |dialect| @cli_options[:dialect] = dialect.to_sym }
|
|
81
|
+
opts.on("--preset NAME", "Enable a rule preset, such as japanese") { |preset| @cli_options[:preset] = preset.to_sym }
|
|
82
|
+
opts.on("--check-links", "Check relative link, image, and anchor targets") { @cli_options[:check_links] = true }
|
|
83
|
+
opts.on("--check-external-links", "Check external HTTP(S) links") { @cli_options[:check_external_links] = true }
|
|
84
|
+
opts.on("--check-code-blocks", "Validate supported fenced code blocks") { @cli_options[:check_code_blocks] = true }
|
|
85
|
+
opts.on("--code-block-command SPEC", "Validate a language block with LANG=COMMAND") { |spec| add_code_block_command(:code_block_commands, spec) }
|
|
86
|
+
opts.on("--code-block-formatter SPEC", "Format a language block with LANG=COMMAND") { |spec| add_code_block_command(:code_block_format_commands, spec) }
|
|
87
|
+
opts.on("--code-block-timeout SECONDS", Integer, "Timeout for external code-block commands") { |seconds| @cli_options[:code_block_timeout] = [seconds, 1].max }
|
|
88
|
+
opts.on("--toc", "Update table-of-contents markers while formatting") { @cli_options[:toc] = true }
|
|
89
|
+
opts.on("--no-table-align", "Do not pad GFM table columns") { @cli_options[:table_align] = false }
|
|
90
|
+
opts.on("--jobs N", Integer, "Process files concurrently") { |jobs| @cli_options[:jobs] = [jobs, 1].max }
|
|
91
|
+
opts.on("--cache", "Cache lint diagnostics") { @cli_options[:cache] = true }
|
|
92
|
+
opts.on("--cache-path PATH", "Path for the lint cache") { |path| @cli_options[:cache_path] = path; @cli_options[:cache] = true }
|
|
93
|
+
opts.on("--no-cache", "Disable diagnostic cache") { @cli_options[:cache] = false }
|
|
94
|
+
opts.on("--lsp", "Run the Language Server Protocol server") { @command = :lsp }
|
|
95
|
+
opts.on("--list-rules", "List available lint rules") { @cli_options[:list_rules] = true }
|
|
96
|
+
opts.on("--explain RULE", "Explain a lint rule") { |rule| @cli_options[:explain] = rule }
|
|
97
|
+
opts.on("--require PATH", "Load a custom rule file") { |path| (@cli_options[:require] ||= []) << path }
|
|
98
|
+
opts.on("--auto-gen-config [PATH]", "Write a config disabling current violations") do |path|
|
|
99
|
+
@cli_options[:auto_gen_config] = path || ".mdlint_todo.yml"
|
|
62
100
|
end
|
|
63
|
-
|
|
64
|
-
opts.on("--end-of-line MODE", "End of line: lf (default), crlf, keep") do |mode|
|
|
65
|
-
@cli_options[:end_of_line] = mode.downcase.to_sym
|
|
66
|
-
end
|
|
67
|
-
|
|
68
101
|
opts.on("-v", "--version", "Show version") do
|
|
69
102
|
puts "mdlint #{Mdlint::VERSION}"
|
|
70
103
|
exit 0
|
|
71
104
|
end
|
|
72
|
-
|
|
73
|
-
opts.on("-h", "--help", "Show this help") do
|
|
105
|
+
opts.on("-h", "--help", "Show help") do
|
|
74
106
|
puts opts
|
|
75
107
|
exit 0
|
|
76
108
|
end
|
|
@@ -79,7 +111,23 @@ module Mdlint
|
|
|
79
111
|
parser.parse!(@argv)
|
|
80
112
|
end
|
|
81
113
|
|
|
82
|
-
def
|
|
114
|
+
def process_format
|
|
115
|
+
return process_format_stdin if @argv.empty?
|
|
116
|
+
|
|
117
|
+
files = collect_files(@argv)
|
|
118
|
+
changed_files = ParallelRunner.map(files, jobs: @options[:jobs]) { |file| file if process_format_file(file) }.compact
|
|
119
|
+
|
|
120
|
+
if @options[:check]
|
|
121
|
+
exit(changed_files.empty? ? 0 : 1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless @options[:quiet] || @options[:diff]
|
|
125
|
+
puts(changed_files.empty? ? "All files are formatted correctly" : "#{changed_files.length} file(s) reformatted")
|
|
126
|
+
end
|
|
127
|
+
@options[:diff] && changed_files.any? ? 1 : 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def process_format_stdin
|
|
83
131
|
input = $stdin.read
|
|
84
132
|
output = Mdlint.format(input, format_options)
|
|
85
133
|
|
|
@@ -94,113 +142,227 @@ module Mdlint
|
|
|
94
142
|
end
|
|
95
143
|
end
|
|
96
144
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
145
|
+
def process_format_file(file)
|
|
146
|
+
original = File.read(file)
|
|
147
|
+
formatted = Mdlint.format(original, format_options)
|
|
148
|
+
changed = original != formatted
|
|
149
|
+
return false unless changed
|
|
100
150
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
151
|
+
if @options[:diff]
|
|
152
|
+
show_diff(file, original, formatted)
|
|
153
|
+
elsif @options[:check]
|
|
154
|
+
puts "Would reformat: #{file}" unless @options[:quiet]
|
|
155
|
+
else
|
|
156
|
+
File.write(file, formatted)
|
|
157
|
+
puts "Reformatted: #{file}" unless @options[:quiet]
|
|
104
158
|
end
|
|
159
|
+
true
|
|
160
|
+
end
|
|
105
161
|
|
|
106
|
-
|
|
107
|
-
|
|
162
|
+
def process_lint
|
|
163
|
+
entries = if @argv.empty?
|
|
164
|
+
process_lint_stdin
|
|
165
|
+
else
|
|
166
|
+
ParallelRunner.map(collect_files(@argv), jobs: @options[:jobs]) { |file| process_lint_file(file) }.flatten
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
print lint_output(entries) unless @options[:quiet]
|
|
170
|
+
@cache_store&.save
|
|
171
|
+
return write_auto_gen_config(entries) if @options[:auto_gen_config]
|
|
172
|
+
|
|
173
|
+
fail_for?(entries) ? exit(1) : 0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def load_plugins
|
|
177
|
+
paths = Array(@options[:plugins]) + Array(@options[:require])
|
|
178
|
+
paths.each do |path|
|
|
179
|
+
Mdlint::Plugin.load(path)
|
|
180
|
+
rescue Mdlint::Plugin::Error => error
|
|
181
|
+
warn "Warning: Could not load rule file #{path}: #{error.message}"
|
|
182
|
+
exit 2
|
|
108
183
|
end
|
|
184
|
+
end
|
|
109
185
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
186
|
+
def write_auto_gen_config(entries)
|
|
187
|
+
rules = entries.to_h { |entry| [entry[:violation].rule_id, false] }
|
|
188
|
+
path = @options[:auto_gen_config]
|
|
189
|
+
File.write(path, YAML.dump({ "rules" => rules }))
|
|
190
|
+
puts "Wrote #{path}" unless @options[:quiet]
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def process_lint_stdin
|
|
195
|
+
filename = @options[:stdin_filename] || "stdin"
|
|
196
|
+
source = $stdin.read
|
|
197
|
+
fixed_source = apply_lint_fix(filename, source, nil)
|
|
198
|
+
return [] if @options[:fix_only]
|
|
199
|
+
|
|
200
|
+
Mdlint.lint(fixed_source, lint_options(filename)).map { |violation| { filename: filename, violation: violation } }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def process_lint_file(file)
|
|
204
|
+
source = File.read(file)
|
|
205
|
+
fixed_source = apply_lint_fix(file, source, file)
|
|
206
|
+
return [] if @options[:fix_only]
|
|
207
|
+
|
|
208
|
+
violations = cached_lint(file, fixed_source)
|
|
209
|
+
violations.map { |violation| { filename: file, violation: violation } }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def cached_lint(file, source)
|
|
213
|
+
options = lint_options(file)
|
|
214
|
+
return Mdlint.lint(source, options) unless @cache_store && !fix_requested?
|
|
215
|
+
|
|
216
|
+
key = @cache_store.key(source, options)
|
|
217
|
+
cached = @cache_store.fetch(key)
|
|
218
|
+
return cached if cached
|
|
219
|
+
|
|
220
|
+
violations = Mdlint.lint(source, options)
|
|
221
|
+
@cache_store.store(key, violations)
|
|
222
|
+
violations
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def apply_lint_fix(filename, source, path)
|
|
226
|
+
return source unless fix_requested?
|
|
227
|
+
|
|
228
|
+
fixed = Mdlint.fix(source, lint_options(filename))
|
|
229
|
+
return source if fixed == source
|
|
230
|
+
|
|
231
|
+
if path && !@options[:dry_run]
|
|
232
|
+
File.write(path, fixed)
|
|
233
|
+
puts "Fixed: #{filename}" unless @options[:quiet]
|
|
234
|
+
elsif path.nil? && !@options[:dry_run] && !@options[:quiet]
|
|
235
|
+
print fixed
|
|
116
236
|
end
|
|
237
|
+
fixed
|
|
238
|
+
end
|
|
117
239
|
|
|
118
|
-
|
|
240
|
+
def fix_requested?
|
|
241
|
+
@cli_options[:fix] || @command == :fix
|
|
119
242
|
end
|
|
120
243
|
|
|
121
|
-
def
|
|
122
|
-
|
|
244
|
+
def lint_output(entries)
|
|
245
|
+
format = @options[:format] || "text"
|
|
246
|
+
OutputFormatter.new(format).render(entries)
|
|
247
|
+
end
|
|
123
248
|
|
|
124
|
-
|
|
249
|
+
def fail_for?(entries)
|
|
250
|
+
threshold = FAIL_LEVELS.fetch(@options[:fail_level].to_sym, FAIL_LEVELS[:warning])
|
|
251
|
+
entries.any? { |entry| FAIL_LEVELS.fetch(entry[:violation].severity, FAIL_LEVELS[:warning]) >= threshold }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def show_rule_list
|
|
255
|
+
Mdlint::Linter::RuleRegistry.all.each do |rule|
|
|
256
|
+
aliases = Array(rule.aliases)
|
|
257
|
+
alias_text = aliases.empty? ? "" : " (#{aliases.join(", ")})"
|
|
258
|
+
puts "#{rule.rule_id}#{alias_text}: #{rule.description}"
|
|
259
|
+
end
|
|
260
|
+
exit 0
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def show_rule_explanation(rule_id)
|
|
264
|
+
rule = Mdlint::Linter::RuleRegistry.find(rule_id)
|
|
265
|
+
unless rule
|
|
266
|
+
warn "Unknown rule: #{rule_id}"
|
|
267
|
+
exit 2
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
puts rule.rule_id
|
|
271
|
+
puts "Aliases: #{Array(rule.aliases).join(", ")}" unless Array(rule.aliases).empty?
|
|
272
|
+
puts rule.description
|
|
273
|
+
exit 0
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def collect_files(paths)
|
|
277
|
+
paths.flat_map do |path|
|
|
125
278
|
if File.directory?(path)
|
|
126
|
-
Dir.glob(File.join(path, "**", "*.md")).
|
|
127
|
-
files << file unless excluded?(file)
|
|
128
|
-
end
|
|
279
|
+
Dir.glob(File.join(path, "**", "*.md")).reject { |file| excluded?(file) }
|
|
129
280
|
elsif File.file?(path)
|
|
130
|
-
|
|
281
|
+
excluded?(path) ? [] : [path]
|
|
131
282
|
else
|
|
132
283
|
warn "Warning: #{path} does not exist"
|
|
284
|
+
[]
|
|
133
285
|
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
files.sort
|
|
286
|
+
end.sort
|
|
137
287
|
end
|
|
138
288
|
|
|
139
289
|
def excluded?(file)
|
|
140
|
-
@options[:exclude].any?
|
|
141
|
-
File.fnmatch?(pattern, file, File::FNM_PATHNAME)
|
|
142
|
-
end
|
|
290
|
+
@options[:exclude].any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) }
|
|
143
291
|
end
|
|
144
292
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
formatted = Mdlint.format(original, format_options)
|
|
148
|
-
changed = original != formatted
|
|
293
|
+
def show_diff(filename, original, formatted)
|
|
294
|
+
require "tempfile"
|
|
149
295
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
puts "
|
|
296
|
+
Tempfile.create("mdlint-original") do |original_file|
|
|
297
|
+
Tempfile.create("mdlint-formatted") do |formatted_file|
|
|
298
|
+
original_file.write(original)
|
|
299
|
+
original_file.flush
|
|
300
|
+
formatted_file.write(formatted)
|
|
301
|
+
formatted_file.flush
|
|
302
|
+
diff_output = `diff -u "#{original_file.path}" "#{formatted_file.path}" 2>&1`
|
|
303
|
+
puts diff_output.gsub(original_file.path, "#{filename} (original)")
|
|
304
|
+
.gsub(formatted_file.path, "#{filename} (formatted)") unless diff_output.empty?
|
|
158
305
|
end
|
|
159
306
|
end
|
|
307
|
+
end
|
|
160
308
|
|
|
161
|
-
|
|
309
|
+
def format_options
|
|
310
|
+
{
|
|
311
|
+
wrap: @options[:wrap] || :keep,
|
|
312
|
+
number: @options[:number] || false,
|
|
313
|
+
end_of_line: @options[:end_of_line] || :lf,
|
|
314
|
+
dialect: @options[:dialect] || :commonmark,
|
|
315
|
+
toc: @options[:toc],
|
|
316
|
+
table_align: @options.fetch(:table_align, true)
|
|
317
|
+
}
|
|
162
318
|
end
|
|
163
319
|
|
|
164
|
-
def
|
|
165
|
-
|
|
320
|
+
def lint_options(filename = nil)
|
|
321
|
+
{
|
|
322
|
+
rules: @options[:rules],
|
|
323
|
+
disable: @options[:disable] || [],
|
|
324
|
+
severity: @options[:severity],
|
|
325
|
+
dialect: @options[:dialect] || :commonmark,
|
|
326
|
+
preset: @options[:preset],
|
|
327
|
+
check_links: @options[:check_links],
|
|
328
|
+
check_external_links: @options[:check_external_links],
|
|
329
|
+
check_code_blocks: @options[:check_code_blocks],
|
|
330
|
+
code_block_commands: @options[:code_block_commands],
|
|
331
|
+
code_block_format_commands: @options[:code_block_format_commands],
|
|
332
|
+
code_block_timeout: @options[:code_block_timeout],
|
|
333
|
+
filename: filename
|
|
334
|
+
}.compact
|
|
335
|
+
end
|
|
166
336
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
puts diff_output
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
end
|
|
337
|
+
def split_rules(value)
|
|
338
|
+
value.split(",").map(&:strip).reject(&:empty?)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def add_code_block_command(key, specification)
|
|
342
|
+
language, command = specification.split("=", 2)
|
|
343
|
+
raise OptionParser::InvalidArgument, "expected LANG=COMMAND" if language.to_s.empty? || command.to_s.empty?
|
|
344
|
+
|
|
345
|
+
@cli_options[key] ||= {}
|
|
346
|
+
@cli_options[key][language.downcase] = command
|
|
347
|
+
@cli_options[:check_code_blocks] = true
|
|
182
348
|
end
|
|
183
349
|
|
|
184
350
|
def parse_wrap_mode(mode)
|
|
185
351
|
case mode.downcase
|
|
186
|
-
when "keep"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
:no
|
|
190
|
-
else
|
|
191
|
-
Integer(mode)
|
|
352
|
+
when "keep" then :keep
|
|
353
|
+
when "no" then :no
|
|
354
|
+
else Integer(mode)
|
|
192
355
|
end
|
|
193
356
|
rescue ArgumentError
|
|
194
357
|
warn "Invalid wrap mode: #{mode}. Using 'keep'."
|
|
195
358
|
:keep
|
|
196
359
|
end
|
|
197
360
|
|
|
198
|
-
def
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
361
|
+
def parse_level(level)
|
|
362
|
+
value = level.downcase.to_sym
|
|
363
|
+
return value if FAIL_LEVELS.key?(value)
|
|
364
|
+
|
|
365
|
+
raise OptionParser::InvalidArgument, "invalid severity: #{level}"
|
|
204
366
|
end
|
|
205
367
|
end
|
|
206
368
|
end
|
data/lib/mdlint/config.rb
CHANGED
|
@@ -15,7 +15,26 @@ module Mdlint
|
|
|
15
15
|
check: false,
|
|
16
16
|
diff: false,
|
|
17
17
|
quiet: false,
|
|
18
|
-
exclude: []
|
|
18
|
+
exclude: [],
|
|
19
|
+
rules: nil,
|
|
20
|
+
disable: [],
|
|
21
|
+
severity: nil,
|
|
22
|
+
fail_level: :warning,
|
|
23
|
+
format: nil,
|
|
24
|
+
dialect: :commonmark,
|
|
25
|
+
preset: nil,
|
|
26
|
+
plugins: [],
|
|
27
|
+
check_links: false,
|
|
28
|
+
check_external_links: false,
|
|
29
|
+
check_code_blocks: false,
|
|
30
|
+
code_block_commands: {},
|
|
31
|
+
code_block_format_commands: {},
|
|
32
|
+
code_block_timeout: 10,
|
|
33
|
+
toc: false,
|
|
34
|
+
table_align: true,
|
|
35
|
+
jobs: 1,
|
|
36
|
+
cache: false,
|
|
37
|
+
cache_path: ".mdlint_cache"
|
|
19
38
|
}.freeze
|
|
20
39
|
|
|
21
40
|
attr_reader :options
|
|
@@ -39,6 +58,8 @@ module Mdlint
|
|
|
39
58
|
case key
|
|
40
59
|
when :exclude
|
|
41
60
|
@options[:exclude] = (@options[:exclude] + value).uniq
|
|
61
|
+
when :disable, :plugins
|
|
62
|
+
@options[key] = (@options[key] + Array(value)).uniq
|
|
42
63
|
else
|
|
43
64
|
@options[key] = value unless value.nil?
|
|
44
65
|
end
|
|
@@ -81,6 +102,32 @@ module Mdlint
|
|
|
81
102
|
options[:check] = parsed[:check] if parsed.key?(:check)
|
|
82
103
|
options[:diff] = parsed[:diff] if parsed.key?(:diff)
|
|
83
104
|
options[:quiet] = parsed[:quiet] if parsed.key?(:quiet)
|
|
105
|
+
options[:wrap] = normalize_wrap(parsed[:wrap]) if parsed.key?(:wrap)
|
|
106
|
+
options[:number] = parsed[:number] if parsed.key?(:number)
|
|
107
|
+
options[:end_of_line] = parsed[:end_of_line].to_s.downcase.to_sym if parsed[:end_of_line]
|
|
108
|
+
options[:rules] = normalize_rules(parsed[:rules]) if parsed.key?(:rules)
|
|
109
|
+
|
|
110
|
+
if parsed[:disable]
|
|
111
|
+
options[:disable] = Array(parsed[:disable]).map(&:to_s)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
options[:severity] = parsed[:severity].to_sym if parsed[:severity]
|
|
115
|
+
options[:fail_level] = parsed[:fail_level].to_sym if parsed[:fail_level]
|
|
116
|
+
options[:format] = parsed[:format].to_s if parsed[:format]
|
|
117
|
+
options[:dialect] = parsed[:dialect].to_sym if parsed[:dialect]
|
|
118
|
+
options[:preset] = parsed[:preset].to_sym if parsed[:preset]
|
|
119
|
+
options[:plugins] = Array(parsed[:plugins]).map(&:to_s) if parsed[:plugins]
|
|
120
|
+
options[:check_links] = parsed[:check_links] if parsed.key?(:check_links)
|
|
121
|
+
options[:check_external_links] = parsed[:check_external_links] if parsed.key?(:check_external_links)
|
|
122
|
+
options[:check_code_blocks] = parsed[:check_code_blocks] if parsed.key?(:check_code_blocks)
|
|
123
|
+
options[:code_block_commands] = normalize_command_map(parsed[:code_block_commands]) if parsed[:code_block_commands]
|
|
124
|
+
options[:code_block_format_commands] = normalize_command_map(parsed[:code_block_format_commands]) if parsed[:code_block_format_commands]
|
|
125
|
+
options[:code_block_timeout] = normalize_timeout(parsed[:code_block_timeout]) if parsed.key?(:code_block_timeout)
|
|
126
|
+
options[:toc] = parsed[:toc] if parsed.key?(:toc)
|
|
127
|
+
options[:table_align] = parsed[:table_align] if parsed.key?(:table_align)
|
|
128
|
+
options[:jobs] = parsed[:jobs].to_i if parsed[:jobs]
|
|
129
|
+
options[:cache] = parsed[:cache] if parsed.key?(:cache)
|
|
130
|
+
options[:cache_path] = parsed[:cache_path].to_s if parsed[:cache_path]
|
|
84
131
|
|
|
85
132
|
if parsed[:exclude]
|
|
86
133
|
options[:exclude] = Array(parsed[:exclude])
|
|
@@ -89,11 +136,50 @@ module Mdlint
|
|
|
89
136
|
options
|
|
90
137
|
end
|
|
91
138
|
|
|
139
|
+
def normalize_rules(rules)
|
|
140
|
+
return rules unless rules.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
rules.each_with_object({}) do |(rule_id, setting), normalized|
|
|
143
|
+
normalized[rule_id.to_s] = if setting.is_a?(Hash)
|
|
144
|
+
normalized_setting = setting.transform_keys(&:to_sym)
|
|
145
|
+
normalized_setting[:severity] = normalized_setting[:severity].to_sym if normalized_setting[:severity]
|
|
146
|
+
normalized_setting
|
|
147
|
+
else
|
|
148
|
+
setting
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_wrap(value)
|
|
154
|
+
case value.to_s.downcase
|
|
155
|
+
when "keep" then :keep
|
|
156
|
+
when "no" then :no
|
|
157
|
+
else Integer(value)
|
|
158
|
+
end
|
|
159
|
+
rescue ArgumentError, TypeError
|
|
160
|
+
:keep
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalize_command_map(value)
|
|
164
|
+
return {} unless value.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
value.to_h { |language, command| [language.to_s.downcase, command.to_s] }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_timeout(value)
|
|
170
|
+
timeout = Integer(value)
|
|
171
|
+
timeout.positive? ? timeout : DEFAULT_OPTIONS[:code_block_timeout]
|
|
172
|
+
rescue ArgumentError, TypeError
|
|
173
|
+
DEFAULT_OPTIONS[:code_block_timeout]
|
|
174
|
+
end
|
|
175
|
+
|
|
92
176
|
def merge_options(file_options)
|
|
93
177
|
file_options.each do |key, value|
|
|
94
178
|
case key
|
|
95
179
|
when :exclude
|
|
96
180
|
@options[:exclude] = (@options[:exclude] + value).uniq
|
|
181
|
+
when :disable, :plugins
|
|
182
|
+
@options[key] = (@options[key] + Array(value)).uniq
|
|
97
183
|
else
|
|
98
184
|
@options[key] = value
|
|
99
185
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlint
|
|
4
|
+
class Dialect
|
|
5
|
+
BUILT_IN_FEATURES = {
|
|
6
|
+
commonmark: [],
|
|
7
|
+
gfm: %i[tables task_lists strikethrough bare_autolinks]
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
@features = BUILT_IN_FEATURES.dup
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_reader :features
|
|
14
|
+
|
|
15
|
+
def register(name, features: [])
|
|
16
|
+
key = name.to_s.downcase.to_sym
|
|
17
|
+
raise ArgumentError, "dialect name cannot be empty" if key.to_s.empty?
|
|
18
|
+
|
|
19
|
+
@features[key] = Array(features).map(&:to_sym).uniq.freeze
|
|
20
|
+
key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def registered
|
|
24
|
+
@features.keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve(value)
|
|
28
|
+
return value if value.is_a?(self)
|
|
29
|
+
|
|
30
|
+
new(value || :commonmark)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :name, :features
|
|
35
|
+
|
|
36
|
+
def initialize(name = :commonmark, features: nil)
|
|
37
|
+
@name = name.to_s.downcase.to_sym
|
|
38
|
+
@features = Array(features || self.class.features.fetch(@name, [])).map(&:to_sym).freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def gfm?
|
|
42
|
+
feature?(:tables) && feature?(:strikethrough)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def feature?(feature)
|
|
46
|
+
@features.include?(feature.to_sym)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_sym
|
|
50
|
+
@name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|