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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-hooks.yaml +6 -0
  3. data/CHANGELOG.md +33 -0
  4. data/README.md +136 -4
  5. data/Rakefile +14 -0
  6. data/Steepfile +17 -0
  7. data/action.yml +54 -0
  8. data/benchmark/compare.rb +49 -0
  9. data/benchmark/format.rb +29 -0
  10. data/lib/mdlint/cache_store.rb +85 -0
  11. data/lib/mdlint/cli/output_formatter.rb +150 -0
  12. data/lib/mdlint/cli.rb +268 -106
  13. data/lib/mdlint/config.rb +87 -1
  14. data/lib/mdlint/dialect.rb +53 -0
  15. data/lib/mdlint/linter/directive_filter.rb +91 -0
  16. data/lib/mdlint/linter/rule.rb +25 -5
  17. data/lib/mdlint/linter/rule_engine.rb +44 -7
  18. data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
  19. data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
  20. data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
  21. data/lib/mdlint/linter/rules/heading_style.rb +24 -2
  22. data/lib/mdlint/linter/rules/japanese.rb +201 -0
  23. data/lib/mdlint/linter/rules/line_length.rb +37 -0
  24. data/lib/mdlint/linter/rules/link_check.rb +151 -0
  25. data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
  26. data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
  27. data/lib/mdlint/linter/rules/source_style.rb +317 -0
  28. data/lib/mdlint/linter/violation.rb +16 -1
  29. data/lib/mdlint/linter.rb +9 -3
  30. data/lib/mdlint/lsp.rb +176 -0
  31. data/lib/mdlint/parallel_runner.rb +42 -0
  32. data/lib/mdlint/parser/block_parser.rb +627 -50
  33. data/lib/mdlint/parser/inline_parser.rb +259 -27
  34. data/lib/mdlint/parser/state.rb +21 -2
  35. data/lib/mdlint/parser.rb +5 -5
  36. data/lib/mdlint/plugin.rb +31 -0
  37. data/lib/mdlint/renderer/html_renderer.rb +346 -0
  38. data/lib/mdlint/renderer/md_renderer.rb +147 -11
  39. data/lib/mdlint/renderer.rb +5 -0
  40. data/lib/mdlint/text_width.rb +39 -0
  41. data/lib/mdlint/toc.rb +80 -0
  42. data/lib/mdlint/token.rb +3 -1
  43. data/lib/mdlint/version.rb +1 -1
  44. data/lib/mdlint.rb +25 -5
  45. data/script/commonmark_compatibility.rb +24 -0
  46. data/script/fetch_commonmark_spec.rb +14 -0
  47. data/sig/internal.rbs +405 -0
  48. data/sig/mdlint.rbs +107 -0
  49. 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 "pathname"
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
- paths = @argv
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 paths.empty?
20
- process_stdin
21
- else
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") do
41
- @cli_options[:check] = true
42
- end
43
-
44
- opts.on("-d", "--diff", "Show diff of changes") do
45
- @cli_options[:diff] = true
46
- end
47
-
48
- opts.on("-q", "--quiet", "Suppress output") do
49
- @cli_options[:quiet] = true
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
- opts.on("-w", "--wrap MODE", "Paragraph wrapping mode: keep (default), no, or INTEGER") do |mode|
57
- @cli_options[:wrap] = parse_wrap_mode(mode)
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("--number", "Use consecutive numbering for ordered lists") do
61
- @cli_options[:number] = true
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 process_stdin
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 process_paths(paths)
98
- files = collect_files(paths)
99
- changed_files = []
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
- files.each do |file|
102
- result = process_file(file)
103
- changed_files << file if result
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
- if @options[:check]
107
- exit(changed_files.empty? ? 0 : 1)
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
- unless @options[:quiet]
111
- if changed_files.any?
112
- puts "#{changed_files.length} file(s) reformatted" unless @options[:diff]
113
- else
114
- puts "All files are formatted correctly" unless @options[:diff]
115
- end
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
- changed_files.empty? ? 0 : 1
240
+ def fix_requested?
241
+ @cli_options[:fix] || @command == :fix
119
242
  end
120
243
 
121
- def collect_files(paths)
122
- files = []
244
+ def lint_output(entries)
245
+ format = @options[:format] || "text"
246
+ OutputFormatter.new(format).render(entries)
247
+ end
123
248
 
124
- paths.each do |path|
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")).each do |file|
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
- files << path unless excluded?(path)
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? do |pattern|
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 process_file(file)
146
- original = File.read(file)
147
- formatted = Mdlint.format(original, format_options)
148
- changed = original != formatted
293
+ def show_diff(filename, original, formatted)
294
+ require "tempfile"
149
295
 
150
- if changed
151
- if @options[:diff]
152
- show_diff(file, original, formatted)
153
- elsif !@options[:check]
154
- File.write(file, formatted)
155
- puts "Reformatted: #{file}" unless @options[:quiet]
156
- elsif !@options[:quiet]
157
- puts "Would reformat: #{file}"
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
- changed
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 show_diff(filename, original, formatted)
165
- require "tempfile"
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
- Tempfile.create("mdlint-original") do |orig_file|
168
- Tempfile.create("mdlint-formatted") do |fmt_file|
169
- orig_file.write(original)
170
- orig_file.flush
171
- fmt_file.write(formatted)
172
- fmt_file.flush
173
-
174
- diff_output = `diff -u "#{orig_file.path}" "#{fmt_file.path}" 2>&1`
175
- unless diff_output.empty?
176
- diff_output = diff_output.gsub(orig_file.path, "#{filename} (original)")
177
- .gsub(fmt_file.path, "#{filename} (formatted)")
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
- :keep
188
- when "no"
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 format_options
199
- {
200
- wrap: @options[:wrap] || :keep,
201
- number: @options[:number] || false,
202
- end_of_line: @options[:end_of_line] || :lf
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