Class: Mdlint::CLI

Inherits:
Object
  • Object
show all
Defined in:
lib/mdlint/cli.rb,
lib/mdlint/cli/output_formatter.rb,
sig/internal.rbs

Defined Under Namespace

Classes: OutputFormatter

Constant Summary collapse

FAIL_LEVELS =
{ info: 0, warning: 1, error: 2 }.freeze
COMMANDS =
%w[format lint fix lsp].freeze

Instance Method Summary collapse

Constructor Details

#initialize(argv) ⇒ CLI

Returns a new instance of CLI.

Parameters:

  • argv (Object)


14
15
16
17
18
# File 'lib/mdlint/cli.rb', line 14

def initialize(argv)
  @argv = argv.dup
  @command = extract_command
  @cli_options = { exclude: [] }
end

Instance Method Details

#add_code_block_command(key, specification) ⇒ Object

Parameters:

  • key (Object)
  • specification (Object)

Returns:

  • (Object)

Raises:

  • (OptionParser::InvalidArgument)


341
342
343
344
345
346
347
348
# File 'lib/mdlint/cli.rb', line 341

def add_code_block_command(key, specification)
  language, command = specification.split("=", 2)
  raise OptionParser::InvalidArgument, "expected LANG=COMMAND" if language.to_s.empty? || command.to_s.empty?

  @cli_options[key] ||= {}
  @cli_options[key][language.downcase] = command
  @cli_options[:check_code_blocks] = true
end

#apply_lint_fix(filename, source, path) ⇒ Object

Parameters:

  • filename (Object)
  • source (Object)
  • path (Object)

Returns:

  • (Object)


225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/mdlint/cli.rb', line 225

def apply_lint_fix(filename, source, path)
  return source unless fix_requested?

  fixed = Mdlint.fix(source, lint_options(filename))
  return source if fixed == source

  if path && !@options[:dry_run]
    File.write(path, fixed)
    puts "Fixed: #{filename}" unless @options[:quiet]
  elsif path.nil? && !@options[:dry_run] && !@options[:quiet]
    print fixed
  end
  fixed
end

#cached_lint(file, source) ⇒ Object

Parameters:

  • file (Object)
  • source (Object)

Returns:

  • (Object)


212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/mdlint/cli.rb', line 212

def cached_lint(file, source)
  options = lint_options(file)
  return Mdlint.lint(source, options) unless @cache_store && !fix_requested?

  key = @cache_store.key(source, options)
  cached = @cache_store.fetch(key)
  return cached if cached

  violations = Mdlint.lint(source, options)
  @cache_store.store(key, violations)
  violations
end

#collect_files(paths) ⇒ Object

Parameters:

  • paths (Object)

Returns:

  • (Object)


276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/mdlint/cli.rb', line 276

def collect_files(paths)
  paths.flat_map do |path|
    if File.directory?(path)
      Dir.glob(File.join(path, "**", "*.md")).reject { |file| excluded?(file) }
    elsif File.file?(path)
      excluded?(path) ? [] : [path]
    else
      warn "Warning: #{path} does not exist"
      []
    end
  end.sort
end

#excluded?(file) ⇒ Boolean

Parameters:

  • file (Object)

Returns:

  • (Boolean)


289
290
291
# File 'lib/mdlint/cli.rb', line 289

def excluded?(file)
  @options[:exclude].any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) }
end

#extract_commandObject

Returns:

  • (Object)


35
36
37
38
39
40
# File 'lib/mdlint/cli.rb', line 35

def extract_command
  command = @argv.first
  return :format unless COMMANDS.include?(command)

  @argv.shift.to_sym
end

#fail_for?(entries) ⇒ Boolean

Parameters:

  • entries (Object)

Returns:

  • (Boolean)


249
250
251
252
# File 'lib/mdlint/cli.rb', line 249

def fail_for?(entries)
  threshold = FAIL_LEVELS.fetch(@options[:fail_level].to_sym, FAIL_LEVELS[:warning])
  entries.any? { |entry| FAIL_LEVELS.fetch(entry[:violation].severity, FAIL_LEVELS[:warning]) >= threshold }
end

#fix_requested?Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/mdlint/cli.rb', line 240

def fix_requested?
  @cli_options[:fix] || @command == :fix
end

#format_optionsObject

Returns:

  • (Object)


309
310
311
312
313
314
315
316
317
318
# File 'lib/mdlint/cli.rb', line 309

def format_options
  {
    wrap: @options[:wrap] || :keep,
    number: @options[:number] || false,
    end_of_line: @options[:end_of_line] || :lf,
    dialect: @options[:dialect] || :commonmark,
    toc: @options[:toc],
    table_align: @options.fetch(:table_align, true)
  }
end

#lint_options(filename = nil) ⇒ Object

Parameters:

  • filename (Object) (defaults to: nil)

Returns:

  • (Object)


320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/mdlint/cli.rb', line 320

def lint_options(filename = nil)
  {
    rules: @options[:rules],
    disable: @options[:disable] || [],
    severity: @options[:severity],
    dialect: @options[:dialect] || :commonmark,
    preset: @options[:preset],
    check_links: @options[:check_links],
    check_external_links: @options[:check_external_links],
    check_code_blocks: @options[:check_code_blocks],
    code_block_commands: @options[:code_block_commands],
    code_block_format_commands: @options[:code_block_format_commands],
    code_block_timeout: @options[:code_block_timeout],
    filename: filename
  }.compact
end

#lint_output(entries) ⇒ String

Parameters:

  • entries (Object)

Returns:

  • (String)


244
245
246
247
# File 'lib/mdlint/cli.rb', line 244

def lint_output(entries)
  format = @options[:format] || "text"
  OutputFormatter.new(format).render(entries)
end

#load_configObject

Returns:

  • (Object)


42
43
44
45
46
47
# File 'lib/mdlint/cli.rb', line 42

def load_config
  config = Config.new
  config.load
  @options = config.merge(@cli_options)
  @options[:format] ||= "text" if @command != :format
end

#load_pluginsObject

Returns:

  • (Object)


176
177
178
179
180
181
182
183
184
# File 'lib/mdlint/cli.rb', line 176

def load_plugins
  paths = Array(@options[:plugins]) + Array(@options[:require])
  paths.each do |path|
    Mdlint::Plugin.load(path)
  rescue Mdlint::Plugin::Error => error
    warn "Warning: Could not load rule file #{path}: #{error.message}"
    exit 2
  end
end

#parse_level(level) ⇒ Object

Parameters:

  • level (Object)

Returns:

  • (Object)

Raises:

  • (OptionParser::InvalidArgument)


361
362
363
364
365
366
# File 'lib/mdlint/cli.rb', line 361

def parse_level(level)
  value = level.downcase.to_sym
  return value if FAIL_LEVELS.key?(value)

  raise OptionParser::InvalidArgument, "invalid severity: #{level}"
end

#parse_optionsObject

Returns:

  • (Object)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/mdlint/cli.rb', line 49

def parse_options
  parser = OptionParser.new do |opts|
    opts.banner = "Usage: mdlint [command] [options] [paths...]"
    opts.separator ""
    opts.separator "Commands: format (default), lint, fix, lsp"
    opts.separator ""
    opts.separator "Options:"

    opts.on("-c", "--check", "Check if files are formatted, exit with error if not") { @cli_options[:check] = true }
    opts.on("-d", "--diff", "Show diff of changes") { @cli_options[:diff] = true }
    opts.on("-q", "--quiet", "Suppress output") { @cli_options[:quiet] = true }
    opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") { |pattern| @cli_options[:exclude] << pattern }
    opts.on("-w", "--wrap MODE", "Paragraph wrapping: keep, no, or INTEGER") { |mode| @cli_options[:wrap] = parse_wrap_mode(mode) }
    opts.on("--number", "Use consecutive numbering for ordered lists") { @cli_options[:number] = true }
    opts.on("--end-of-line MODE", "End of line: lf, crlf, keep") { |mode| @cli_options[:end_of_line] = mode.downcase.to_sym }
    opts.on("--fix", "Fix fixable lint violations") do
      @cli_options[:fix] = true
      @command = :lint if @command == :format
    end
    opts.on("--fix-only", "Apply fixes without reporting remaining violations") do
      @cli_options[:fix] = true
      @cli_options[:fix_only] = true
      @command = :fix
    end
    opts.on("--dry-run", "Do not write fixes to files") { @cli_options[:dry_run] = true }
    opts.on("--disable RULES", "Disable comma-separated rules") { |rules| @cli_options[:disable] = split_rules(rules) }
    opts.on("--rule RULES", "Run only comma-separated rules") { |rules| @cli_options[:rules] = split_rules(rules) }
    opts.on("--severity LEVEL", "Default severity: error, warning, or info") { |level| @cli_options[:severity] = parse_level(level) }
    opts.on("--fail-level LEVEL", "Fail at severity: error, warning, or info") { |level| @cli_options[:fail_level] = parse_level(level) }
    opts.on("--format FORMAT", "Lint output: text, json, sarif, github, checkstyle, junit, reviewdog") { |format| @cli_options[:format] = format }
    opts.on("--stdin-filename NAME", "Filename to use for stdin diagnostics") { |name| @cli_options[:stdin_filename] = name }
    opts.on("--dialect DIALECT", "Markdown dialect: commonmark or gfm") { |dialect| @cli_options[:dialect] = dialect.to_sym }
    opts.on("--preset NAME", "Enable a rule preset, such as japanese") { |preset| @cli_options[:preset] = preset.to_sym }
    opts.on("--check-links", "Check relative link, image, and anchor targets") { @cli_options[:check_links] = true }
    opts.on("--check-external-links", "Check external HTTP(S) links") { @cli_options[:check_external_links] = true }
    opts.on("--check-code-blocks", "Validate supported fenced code blocks") { @cli_options[:check_code_blocks] = true }
    opts.on("--code-block-command SPEC", "Validate a language block with LANG=COMMAND") { |spec| add_code_block_command(:code_block_commands, spec) }
    opts.on("--code-block-formatter SPEC", "Format a language block with LANG=COMMAND") { |spec| add_code_block_command(:code_block_format_commands, spec) }
    opts.on("--code-block-timeout SECONDS", Integer, "Timeout for external code-block commands") { |seconds| @cli_options[:code_block_timeout] = [seconds, 1].max }
    opts.on("--toc", "Update table-of-contents markers while formatting") { @cli_options[:toc] = true }
    opts.on("--no-table-align", "Do not pad GFM table columns") { @cli_options[:table_align] = false }
    opts.on("--jobs N", Integer, "Process files concurrently") { |jobs| @cli_options[:jobs] = [jobs, 1].max }
    opts.on("--cache", "Cache lint diagnostics") { @cli_options[:cache] = true }
    opts.on("--cache-path PATH", "Path for the lint cache") { |path| @cli_options[:cache_path] = path; @cli_options[:cache] = true }
    opts.on("--no-cache", "Disable diagnostic cache") { @cli_options[:cache] = false }
    opts.on("--lsp", "Run the Language Server Protocol server") { @command = :lsp }
    opts.on("--list-rules", "List available lint rules") { @cli_options[:list_rules] = true }
    opts.on("--explain RULE", "Explain a lint rule") { |rule| @cli_options[:explain] = rule }
    opts.on("--require PATH", "Load a custom rule file") { |path| (@cli_options[:require] ||= []) << path }
    opts.on("--auto-gen-config [PATH]", "Write a config disabling current violations") do |path|
      @cli_options[:auto_gen_config] = path || ".mdlint_todo.yml"
    end
    opts.on("-v", "--version", "Show version") do
      puts "mdlint #{Mdlint::VERSION}"
      exit 0
    end
    opts.on("-h", "--help", "Show help") do
      puts opts
      exit 0
    end
  end

  parser.parse!(@argv)
end

#parse_wrap_mode(mode) ⇒ Object

Parameters:

  • mode (Object)

Returns:

  • (Object)


350
351
352
353
354
355
356
357
358
359
# File 'lib/mdlint/cli.rb', line 350

def parse_wrap_mode(mode)
  case mode.downcase
  when "keep" then :keep
  when "no" then :no
  else Integer(mode)
  end
rescue ArgumentError
  warn "Invalid wrap mode: #{mode}. Using 'keep'."
  :keep
end

#process_formatObject

Returns:

  • (Object)


114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/mdlint/cli.rb', line 114

def process_format
  return process_format_stdin if @argv.empty?

  files = collect_files(@argv)
  changed_files = ParallelRunner.map(files, jobs: @options[:jobs]) { |file| file if process_format_file(file) }.compact

  if @options[:check]
    exit(changed_files.empty? ? 0 : 1)
  end

  unless @options[:quiet] || @options[:diff]
    puts(changed_files.empty? ? "All files are formatted correctly" : "#{changed_files.length} file(s) reformatted")
  end
  @options[:diff] && changed_files.any? ? 1 : 0
end

#process_format_file(file) ⇒ Object

Parameters:

  • file (Object)

Returns:

  • (Object)


145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/mdlint/cli.rb', line 145

def process_format_file(file)
  original = File.read(file)
  formatted = Mdlint.format(original, format_options)
  changed = original != formatted
  return false unless changed

  if @options[:diff]
    show_diff(file, original, formatted)
  elsif @options[:check]
    puts "Would reformat: #{file}" unless @options[:quiet]
  else
    File.write(file, formatted)
    puts "Reformatted: #{file}" unless @options[:quiet]
  end
  true
end

#process_format_stdinObject

Returns:

  • (Object)


130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/mdlint/cli.rb', line 130

def process_format_stdin
  input = $stdin.read
  output = Mdlint.format(input, format_options)

  if @options[:check]
    exit(input == output ? 0 : 1)
  elsif @options[:diff]
    show_diff("stdin", input, output)
    exit(input == output ? 0 : 1)
  else
    print output
    0
  end
end

#process_lintObject

Returns:

  • (Object)


162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/mdlint/cli.rb', line 162

def process_lint
  entries = if @argv.empty?
              process_lint_stdin
            else
              ParallelRunner.map(collect_files(@argv), jobs: @options[:jobs]) { |file| process_lint_file(file) }.flatten
            end

  print lint_output(entries) unless @options[:quiet]
  @cache_store&.save
  return write_auto_gen_config(entries) if @options[:auto_gen_config]

  fail_for?(entries) ? exit(1) : 0
end

#process_lint_file(file) ⇒ Object

Parameters:

  • file (Object)

Returns:

  • (Object)


203
204
205
206
207
208
209
210
# File 'lib/mdlint/cli.rb', line 203

def process_lint_file(file)
  source = File.read(file)
  fixed_source = apply_lint_fix(file, source, file)
  return [] if @options[:fix_only]

  violations = cached_lint(file, fixed_source)
  violations.map { |violation| { filename: file, violation: violation } }
end

#process_lint_stdinObject

Returns:

  • (Object)


194
195
196
197
198
199
200
201
# File 'lib/mdlint/cli.rb', line 194

def process_lint_stdin
  filename = @options[:stdin_filename] || "stdin"
  source = $stdin.read
  fixed_source = apply_lint_fix(filename, source, nil)
  return [] if @options[:fix_only]

  Mdlint.lint(fixed_source, lint_options(filename)).map { |violation| { filename: filename, violation: violation } }
end

#runObject

Returns:

  • (Object)


20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/mdlint/cli.rb', line 20

def run
  parse_options
  load_config
  load_plugins
  @cache_store = CacheStore.new(@options[:cache_path]) if @options[:cache]
  return show_rule_list if @options[:list_rules]
  return show_rule_explanation(@options[:explain]) if @options[:explain]

  return Mdlint::Lsp::Server.new.run if @command == :lsp

  @command == :format ? process_format : process_lint
end

#show_diff(filename, original, formatted) ⇒ Object

Parameters:

  • filename (Object)
  • original (Object)
  • formatted (Object)

Returns:

  • (Object)


293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/mdlint/cli.rb', line 293

def show_diff(filename, original, formatted)
  require "tempfile"

  Tempfile.create("mdlint-original") do |original_file|
    Tempfile.create("mdlint-formatted") do |formatted_file|
      original_file.write(original)
      original_file.flush
      formatted_file.write(formatted)
      formatted_file.flush
      diff_output = `diff -u "#{original_file.path}" "#{formatted_file.path}" 2>&1`
      puts diff_output.gsub(original_file.path, "#{filename} (original)")
                      .gsub(formatted_file.path, "#{filename} (formatted)") unless diff_output.empty?
    end
  end
end

#show_rule_explanation(rule_id) ⇒ Object

Parameters:

  • rule_id (Object)

Returns:

  • (Object)


263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/mdlint/cli.rb', line 263

def show_rule_explanation(rule_id)
  rule = Mdlint::Linter::RuleRegistry.find(rule_id)
  unless rule
    warn "Unknown rule: #{rule_id}"
    exit 2
  end

  puts rule.rule_id
  puts "Aliases: #{Array(rule.aliases).join(", ")}" unless Array(rule.aliases).empty?
  puts rule.description
  exit 0
end

#show_rule_listObject

Returns:

  • (Object)


254
255
256
257
258
259
260
261
# File 'lib/mdlint/cli.rb', line 254

def show_rule_list
  Mdlint::Linter::RuleRegistry.all.each do |rule|
    aliases = Array(rule.aliases)
    alias_text = aliases.empty? ? "" : " (#{aliases.join(", ")})"
    puts "#{rule.rule_id}#{alias_text}: #{rule.description}"
  end
  exit 0
end

#split_rules(value) ⇒ Object

Parameters:

  • value (Object)

Returns:

  • (Object)


337
338
339
# File 'lib/mdlint/cli.rb', line 337

def split_rules(value)
  value.split(",").map(&:strip).reject(&:empty?)
end

#write_auto_gen_config(entries) ⇒ Object

Parameters:

  • entries (Object)

Returns:

  • (Object)


186
187
188
189
190
191
192
# File 'lib/mdlint/cli.rb', line 186

def write_auto_gen_config(entries)
  rules = entries.to_h { |entry| [entry[:violation].rule_id, false] }
  path = @options[:auto_gen_config]
  File.write(path, YAML.dump({ "rules" => rules }))
  puts "Wrote #{path}" unless @options[:quiet]
  0
end