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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Mdlint
|
|
6
|
+
module Linter
|
|
7
|
+
class DirectiveFilter
|
|
8
|
+
DIRECTIVE_REGEXP = /<!--\s*mdlint-(disable-next-line|disable-file|disable|enable)(?:\s+([^>]*?))?\s*-->/i
|
|
9
|
+
FENCE_REGEXP = /\A {0,3}(`{3,}|~{3,})/
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def apply(violations, source)
|
|
13
|
+
new(source).filter(violations)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@disabled_by_line = {}
|
|
19
|
+
@file_disabled = false
|
|
20
|
+
scan(source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def filter(violations)
|
|
24
|
+
return [] if @file_disabled
|
|
25
|
+
|
|
26
|
+
violations.reject do |violation|
|
|
27
|
+
disabled = @disabled_by_line.fetch(violation.line, Set.new)
|
|
28
|
+
disabled.include?("*") || disabled.include?(RuleRegistry.normalize_id(violation.rule_id))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def scan(source)
|
|
35
|
+
disabled = Set.new
|
|
36
|
+
next_line = {}
|
|
37
|
+
fence = nil
|
|
38
|
+
|
|
39
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
40
|
+
current_fence = line.match(FENCE_REGEXP)
|
|
41
|
+
if current_fence
|
|
42
|
+
marker = current_fence[1].to_s
|
|
43
|
+
|
|
44
|
+
if !fence.nil? && marker.to_s[0] == fence.to_s[0] && marker.to_s.length >= fence.to_s.length
|
|
45
|
+
fence = nil
|
|
46
|
+
elsif fence.nil?
|
|
47
|
+
fence = marker
|
|
48
|
+
end
|
|
49
|
+
@disabled_by_line[line_number] = disabled.dup
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if fence
|
|
54
|
+
@disabled_by_line[line_number] = disabled.dup
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
directive = line.match(DIRECTIVE_REGEXP)
|
|
59
|
+
apply_directive(directive, disabled, next_line, line_number) if directive
|
|
60
|
+
line_disabled = disabled.dup
|
|
61
|
+
line_disabled.merge(next_line.delete(line_number) || Set.new)
|
|
62
|
+
@disabled_by_line[line_number] = line_disabled
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def apply_directive(directive, disabled, next_line, line_number)
|
|
67
|
+
action = directive[1].downcase
|
|
68
|
+
ids = normalize_ids(directive[2])
|
|
69
|
+
|
|
70
|
+
case action
|
|
71
|
+
when "disable-file"
|
|
72
|
+
@file_disabled = true
|
|
73
|
+
when "disable-next-line"
|
|
74
|
+
next_line[line_number + 1] ||= Set.new
|
|
75
|
+
next_line[line_number + 1].merge(ids)
|
|
76
|
+
when "disable"
|
|
77
|
+
disabled.merge(ids)
|
|
78
|
+
when "enable"
|
|
79
|
+
ids.include?("*") ? disabled.clear : ids.each { |id| disabled.delete(id) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_ids(value)
|
|
84
|
+
ids = value.to_s.split(/[\s,]+/).reject(&:empty?)
|
|
85
|
+
return Set["*"] if ids.empty?
|
|
86
|
+
|
|
87
|
+
Set.new(ids.map { |id| RuleRegistry.normalize_id(id) })
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/mdlint/linter/rule.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Mdlint
|
|
|
4
4
|
module Linter
|
|
5
5
|
class Rule
|
|
6
6
|
class << self
|
|
7
|
-
attr_accessor :rule_id, :description
|
|
7
|
+
attr_accessor :rule_id, :description, :aliases, :preset
|
|
8
8
|
|
|
9
9
|
def inherited(subclass)
|
|
10
10
|
super
|
|
@@ -14,7 +14,8 @@ module Mdlint
|
|
|
14
14
|
|
|
15
15
|
attr_reader :violations
|
|
16
16
|
|
|
17
|
-
def initialize
|
|
17
|
+
def initialize(options = {})
|
|
18
|
+
@options = options
|
|
18
19
|
@violations = []
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -23,7 +24,7 @@ module Mdlint
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def fix(_tokens, _source)
|
|
26
|
-
|
|
27
|
+
_source
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
protected
|
|
@@ -34,7 +35,8 @@ module Mdlint
|
|
|
34
35
|
message: message,
|
|
35
36
|
line: line,
|
|
36
37
|
column: column,
|
|
37
|
-
fixable: fixable
|
|
38
|
+
fixable: fixable,
|
|
39
|
+
severity: @options[:severity] || :warning
|
|
38
40
|
)
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -46,6 +48,9 @@ module Mdlint
|
|
|
46
48
|
attr_reader :rules
|
|
47
49
|
|
|
48
50
|
def register(rule_class)
|
|
51
|
+
return if rule_class.rule_id && @rules.any? { |existing| existing.rule_id == rule_class.rule_id }
|
|
52
|
+
return if @rules.include?(rule_class)
|
|
53
|
+
|
|
49
54
|
@rules << rule_class
|
|
50
55
|
end
|
|
51
56
|
|
|
@@ -54,12 +59,27 @@ module Mdlint
|
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def find(rule_id)
|
|
57
|
-
|
|
62
|
+
normalized_id = normalize_id(rule_id)
|
|
63
|
+
@rules.find { |r| r.rule_id == normalized_id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalize_id(rule_id)
|
|
67
|
+
value = rule_id.to_s
|
|
68
|
+
value = value.upcase if value.match?(/\Amd\d+\z/i)
|
|
69
|
+
rule = @rules.find do |rule_class|
|
|
70
|
+
aliases = rule_class.aliases
|
|
71
|
+
rule_class.rule_id == value || (aliases.is_a?(Array) && aliases.any? { |name| name.is_a?(String) && name.casecmp?(value.to_s) })
|
|
72
|
+
end
|
|
73
|
+
rule ? rule.rule_id : value
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
def clear
|
|
61
77
|
@rules = []
|
|
62
78
|
end
|
|
79
|
+
|
|
80
|
+
def unregister(rule_class)
|
|
81
|
+
@rules.delete(rule_class)
|
|
82
|
+
end
|
|
63
83
|
end
|
|
64
84
|
end
|
|
65
85
|
end
|
|
@@ -7,8 +7,9 @@ module Mdlint
|
|
|
7
7
|
|
|
8
8
|
def initialize(options = {})
|
|
9
9
|
@options = options
|
|
10
|
-
@
|
|
11
|
-
@
|
|
10
|
+
@configured_rules = options[:rules]
|
|
11
|
+
@enabled_rules = Array(options[:rules]).map { |rule| RuleRegistry.normalize_id(rule) } if options[:rules].is_a?(Array)
|
|
12
|
+
@disabled_rules = Array(options[:disable]).map { |rule| RuleRegistry.normalize_id(rule) }
|
|
12
13
|
@violations = []
|
|
13
14
|
end
|
|
14
15
|
|
|
@@ -16,7 +17,7 @@ module Mdlint
|
|
|
16
17
|
@violations = []
|
|
17
18
|
|
|
18
19
|
active_rules.each do |rule_class|
|
|
19
|
-
rule = rule_class.new
|
|
20
|
+
rule = rule_class.new(rule_options(rule_class))
|
|
20
21
|
rule.check(tokens, source)
|
|
21
22
|
@violations.concat(rule.violations)
|
|
22
23
|
end
|
|
@@ -26,10 +27,12 @@ module Mdlint
|
|
|
26
27
|
|
|
27
28
|
def fix(tokens, source)
|
|
28
29
|
result = source
|
|
30
|
+
current_tokens = tokens
|
|
29
31
|
|
|
30
32
|
active_rules.each do |rule_class|
|
|
31
|
-
rule = rule_class.new
|
|
32
|
-
result = rule.fix(
|
|
33
|
+
rule = rule_class.new(rule_options(rule_class))
|
|
34
|
+
result = rule.fix(current_tokens, result)
|
|
35
|
+
current_tokens = Parser.parse(result, @options) if result != source
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
result
|
|
@@ -39,10 +42,44 @@ module Mdlint
|
|
|
39
42
|
|
|
40
43
|
def active_rules
|
|
41
44
|
RuleRegistry.all.select do |rule_class|
|
|
42
|
-
|
|
43
|
-
!@disabled_rules.include?(rule_class.rule_id)
|
|
45
|
+
configured_enabled?(rule_class) && !@disabled_rules.include?(rule_class.rule_id)
|
|
44
46
|
end
|
|
45
47
|
end
|
|
48
|
+
|
|
49
|
+
def configured_enabled?(rule_class)
|
|
50
|
+
if rule_class.preset && @options[:preset].to_s != rule_class.preset.to_s
|
|
51
|
+
explicitly_selected = @enabled_rules&.include?(rule_class.rule_id) || explicitly_configured?(rule_class)
|
|
52
|
+
return false unless explicitly_selected
|
|
53
|
+
end
|
|
54
|
+
return @enabled_rules.include?(rule_class.rule_id) if @enabled_rules
|
|
55
|
+
return true unless @configured_rules.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
setting = rule_setting(rule_class)
|
|
58
|
+
setting != false && (!setting.is_a?(Hash) || setting[:enabled] != false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def explicitly_configured?(rule_class)
|
|
62
|
+
return false unless @configured_rules.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
@configured_rules.any? { |key, _value| RuleRegistry.normalize_id(key) == rule_class.rule_id }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rule_options(rule_class)
|
|
68
|
+
setting = rule_setting(rule_class)
|
|
69
|
+
setting = {} unless setting.is_a?(Hash)
|
|
70
|
+
global_options = @options.reject { |key, _value| %i[rules disable].include?(key) }
|
|
71
|
+
setting = global_options.merge(setting.transform_keys(&:to_sym))
|
|
72
|
+
setting[:severity] ||= @options[:severity] if @options[:severity]
|
|
73
|
+
setting
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def rule_setting(rule_class)
|
|
77
|
+
return nil unless @configured_rules.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
@configured_rules.find do |key, _value|
|
|
80
|
+
RuleRegistry.normalize_id(key) == rule_class.rule_id
|
|
81
|
+
end&.last
|
|
82
|
+
end
|
|
46
83
|
end
|
|
47
84
|
end
|
|
48
85
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "ripper"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
module Mdlint
|
|
10
|
+
module Linter
|
|
11
|
+
module Rules
|
|
12
|
+
class CodeBlockSyntax < Rule
|
|
13
|
+
self.rule_id = "MD040"
|
|
14
|
+
self.aliases = ["code-block-syntax"]
|
|
15
|
+
self.description = "Supported fenced code blocks should have valid syntax"
|
|
16
|
+
|
|
17
|
+
SUPPORTED_LANGUAGES = %w[json ruby].freeze
|
|
18
|
+
|
|
19
|
+
def check(tokens, _source)
|
|
20
|
+
commands = @options.fetch(:code_block_commands, {})
|
|
21
|
+
return @violations unless @options[:check_code_blocks] || !commands.empty?
|
|
22
|
+
|
|
23
|
+
tokens.each do |token|
|
|
24
|
+
next unless token.type == :fence
|
|
25
|
+
|
|
26
|
+
language = token.info.to_s.split.first.to_s.downcase
|
|
27
|
+
if language.empty?
|
|
28
|
+
add_violation(message: "Code block should specify a language", line: line_for(token), fixable: false)
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
unless SUPPORTED_LANGUAGES.include?(language) || commands.key?(language)
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
error = if SUPPORTED_LANGUAGES.include?(language)
|
|
36
|
+
syntax_error(language, token.content)
|
|
37
|
+
else
|
|
38
|
+
command_error(commands.fetch(language), token.content)
|
|
39
|
+
end
|
|
40
|
+
next unless error
|
|
41
|
+
|
|
42
|
+
add_violation(
|
|
43
|
+
message: "Invalid #{language} syntax: #{error}",
|
|
44
|
+
line: line_for(token),
|
|
45
|
+
fixable: false
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
@violations
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fix(tokens, source)
|
|
52
|
+
commands = @options.fetch(:code_block_format_commands, {})
|
|
53
|
+
return source if commands.empty?
|
|
54
|
+
|
|
55
|
+
lines = source.lines
|
|
56
|
+
tokens.reverse_each do |token|
|
|
57
|
+
next unless token.type == :fence
|
|
58
|
+
|
|
59
|
+
language = token.info.to_s.split.first.to_s.downcase
|
|
60
|
+
command = commands[language]
|
|
61
|
+
next unless command
|
|
62
|
+
|
|
63
|
+
formatted = command_output(command, token.content)
|
|
64
|
+
next unless formatted
|
|
65
|
+
|
|
66
|
+
start_line = token.map&.first
|
|
67
|
+
end_line = token.map&.last
|
|
68
|
+
next unless start_line && end_line
|
|
69
|
+
|
|
70
|
+
closing_line = end_line - 1
|
|
71
|
+
closing_line = end_line unless lines[closing_line].to_s.match?(/\A {0,3}(`{3,}|~{3,})\s*\r?\n?\z/)
|
|
72
|
+
replacement = formatted.end_with?("\n") ? formatted : "#{formatted}\n"
|
|
73
|
+
body_length = [closing_line - start_line - 1, 0].max
|
|
74
|
+
lines.slice!(start_line + 1, body_length)
|
|
75
|
+
lines.insert(start_line + 1, *replacement.lines)
|
|
76
|
+
end
|
|
77
|
+
lines.join
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def line_for(token)
|
|
83
|
+
(token.map&.first || 0) + 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def syntax_error(language, content)
|
|
87
|
+
case language
|
|
88
|
+
when "json"
|
|
89
|
+
JSON.parse(content)
|
|
90
|
+
nil
|
|
91
|
+
when "ruby"
|
|
92
|
+
Ripper.sexp(content) ? nil : "parser rejected the source"
|
|
93
|
+
end
|
|
94
|
+
rescue JSON::ParserError => error
|
|
95
|
+
error.message.lines.first.to_s.strip
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def command_error(command, content)
|
|
99
|
+
result = command_result(command, content)
|
|
100
|
+
return nil if result[:status].success?
|
|
101
|
+
|
|
102
|
+
result[:stderr].lines.first.to_s.strip.empty? ? "command exited with #{result[:status].exitstatus}" : result[:stderr].lines.first.strip
|
|
103
|
+
rescue StandardError => error
|
|
104
|
+
"#{error.class}: #{error.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def command_output(command, content)
|
|
108
|
+
result = command_result(command, content)
|
|
109
|
+
return result[:stdout] if result[:status].success?
|
|
110
|
+
|
|
111
|
+
nil
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def command_result(command, content)
|
|
117
|
+
argv = Shellwords.split(command)
|
|
118
|
+
raise ArgumentError, "empty code block command" if argv.empty?
|
|
119
|
+
|
|
120
|
+
stdout, stderr, status = Timeout.timeout(@options.fetch(:code_block_timeout, 10).to_i) do
|
|
121
|
+
Open3.capture3(*argv, stdin_data: content)
|
|
122
|
+
end
|
|
123
|
+
{ stdout: stdout, stderr: stderr, status: status }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -5,12 +5,13 @@ module Mdlint
|
|
|
5
5
|
module Rules
|
|
6
6
|
class FirstLineHeading < Rule
|
|
7
7
|
self.rule_id = "MD041"
|
|
8
|
+
self.aliases = ["first-line-heading"]
|
|
8
9
|
self.description = "First line in file should be a top-level heading"
|
|
9
10
|
|
|
10
11
|
def check(tokens, _source)
|
|
11
12
|
first_content_token = tokens.find do |t|
|
|
12
13
|
%i[heading_open paragraph_open bullet_list_open ordered_list_open
|
|
13
|
-
blockquote_open fence code_block hr html_block].include?(t.type)
|
|
14
|
+
blockquote_open fence code_block hr html_block].include?(t.type) && !directive?(t)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
return @violations unless first_content_token
|
|
@@ -32,8 +33,14 @@ module Mdlint
|
|
|
32
33
|
@violations
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
def fix(
|
|
36
|
-
|
|
36
|
+
def fix(_tokens, source)
|
|
37
|
+
source
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def directive?(token)
|
|
43
|
+
token.type == :html_block && token.content.match?(/<!--\s*mdlint-(?:disable|enable)/i)
|
|
37
44
|
end
|
|
38
45
|
end
|
|
39
46
|
end
|
|
@@ -5,6 +5,7 @@ module Mdlint
|
|
|
5
5
|
module Rules
|
|
6
6
|
class HeadingIncrement < Rule
|
|
7
7
|
self.rule_id = "MD001"
|
|
8
|
+
self.aliases = ["heading-increment"]
|
|
8
9
|
self.description = "Heading levels should only increment by one level at a time"
|
|
9
10
|
|
|
10
11
|
def check(tokens, _source)
|
|
@@ -13,7 +14,7 @@ module Mdlint
|
|
|
13
14
|
tokens.each do |token|
|
|
14
15
|
next unless token.type == :heading_open
|
|
15
16
|
|
|
16
|
-
level = token.tag[1].to_i
|
|
17
|
+
level = token.tag.to_s[1].to_i
|
|
17
18
|
if last_level > 0 && level > last_level + 1
|
|
18
19
|
add_violation(
|
|
19
20
|
message: "Heading level jumped from h#{last_level} to h#{level}",
|
|
@@ -27,8 +28,8 @@ module Mdlint
|
|
|
27
28
|
@violations
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
def fix(
|
|
31
|
-
|
|
31
|
+
def fix(_tokens, source)
|
|
32
|
+
source
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -5,6 +5,7 @@ module Mdlint
|
|
|
5
5
|
module Rules
|
|
6
6
|
class HeadingStyle < Rule
|
|
7
7
|
self.rule_id = "MD003"
|
|
8
|
+
self.aliases = ["heading-style"]
|
|
8
9
|
self.description = "Heading style should be consistent"
|
|
9
10
|
|
|
10
11
|
def check(tokens, _source)
|
|
@@ -22,8 +23,29 @@ module Mdlint
|
|
|
22
23
|
@violations
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def fix(tokens,
|
|
26
|
-
|
|
26
|
+
def fix(tokens, source)
|
|
27
|
+
lines = source.lines
|
|
28
|
+
heading_tokens = tokens.select do |token|
|
|
29
|
+
token.type == :heading_open && token.markup && !token.markup.start_with?("#")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
heading_tokens.reverse_each do |token|
|
|
33
|
+
start_line, end_line = token.map || []
|
|
34
|
+
next unless start_line && end_line && end_line == start_line + 2
|
|
35
|
+
next unless lines[start_line] && lines[start_line + 1]
|
|
36
|
+
|
|
37
|
+
match = lines[start_line].match(/\A(\s*(?:>\s*)*)(.*?)(?:\r?\n)?\z/)
|
|
38
|
+
next unless match
|
|
39
|
+
|
|
40
|
+
prefix = match[1].to_s
|
|
41
|
+
content = match[2].to_s
|
|
42
|
+
newline = lines[start_line].end_with?("\r\n") ? "\r\n" : "\n"
|
|
43
|
+
level = token.tag == "h1" ? "#" : "##"
|
|
44
|
+
lines[start_line] = "#{prefix}#{level} #{content.strip}#{newline}"
|
|
45
|
+
lines.delete_at(start_line + 1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
lines.compact.join
|
|
27
49
|
end
|
|
28
50
|
end
|
|
29
51
|
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlint
|
|
4
|
+
module Linter
|
|
5
|
+
module Rules
|
|
6
|
+
module JapaneseHelpers
|
|
7
|
+
CJK = /[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]/
|
|
8
|
+
JAPANESE = /[\p{Han}\p{Hiragana}\p{Katakana}]/
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def each_inline(tokens)
|
|
13
|
+
tokens.each do |token|
|
|
14
|
+
yield token if token.type == :inline
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prose_content(token)
|
|
19
|
+
token.children.filter_map do |child|
|
|
20
|
+
child.content if %i[text html_inline softbreak hardbreak].include?(child.type)
|
|
21
|
+
end.join
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def line_for(token)
|
|
25
|
+
(token.map&.first || 0) + 1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class JapaneseSpacing < Rule
|
|
30
|
+
include JapaneseHelpers
|
|
31
|
+
|
|
32
|
+
self.rule_id = "JA001"
|
|
33
|
+
self.aliases = ["japanese-spacing"]
|
|
34
|
+
self.description = "Japanese and ASCII text should use consistent spacing"
|
|
35
|
+
self.preset = :japanese
|
|
36
|
+
|
|
37
|
+
def check(tokens, _source)
|
|
38
|
+
each_inline(tokens) do |token|
|
|
39
|
+
content = prose_content(token)
|
|
40
|
+
next unless content.match?(JAPANESE)
|
|
41
|
+
next unless content.match?(/(?:#{JAPANESE})[A-Za-z0-9]|[A-Za-z0-9](?:#{JAPANESE})/)
|
|
42
|
+
|
|
43
|
+
add_violation(
|
|
44
|
+
message: "Add a space between Japanese and ASCII text",
|
|
45
|
+
line: line_for(token),
|
|
46
|
+
fixable: false
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
@violations
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class JapanesePunctuation < Rule
|
|
54
|
+
include JapaneseHelpers
|
|
55
|
+
|
|
56
|
+
self.rule_id = "JA002"
|
|
57
|
+
self.aliases = ["japanese-punctuation"]
|
|
58
|
+
self.description = "Japanese prose should use full-width punctuation"
|
|
59
|
+
self.preset = :japanese
|
|
60
|
+
|
|
61
|
+
def check(tokens, _source)
|
|
62
|
+
each_inline(tokens) do |token|
|
|
63
|
+
content = prose_content(token)
|
|
64
|
+
next unless content.match?(JAPANESE)
|
|
65
|
+
next unless content.match?(/[、。!?] |[,:!?](?:#{JAPANESE})|(?<!\.)\.(?:#{JAPANESE})/)
|
|
66
|
+
|
|
67
|
+
add_violation(
|
|
68
|
+
message: "Use Japanese punctuation consistently in Japanese prose",
|
|
69
|
+
line: line_for(token),
|
|
70
|
+
fixable: false
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
@violations
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class JapaneseStyle < Rule
|
|
78
|
+
include JapaneseHelpers
|
|
79
|
+
|
|
80
|
+
self.rule_id = "JA003"
|
|
81
|
+
self.aliases = ["japanese-style"]
|
|
82
|
+
self.description = "Japanese documents should not mix desu-masu and de-aru styles"
|
|
83
|
+
self.preset = :japanese
|
|
84
|
+
|
|
85
|
+
def check(tokens, _source)
|
|
86
|
+
prose = tokens.filter_map { |token| prose_content(token) if token.type == :inline }.join("\n")
|
|
87
|
+
has_desu_masu = prose.match?(/です(?:。|\z)|ます(?:。|\z)|でした(?:。|\z)|ません(?:。|\z)/)
|
|
88
|
+
has_de_aru = prose.match?(/である(?:。|\z)|だ(?:。|\z)|であった(?:。|\z)/)
|
|
89
|
+
return @violations unless has_desu_masu && has_de_aru
|
|
90
|
+
|
|
91
|
+
token = tokens.find { |candidate| candidate.type == :inline }
|
|
92
|
+
add_violation(
|
|
93
|
+
message: "Avoid mixing desu-masu and de-aru styles",
|
|
94
|
+
line: line_for(token),
|
|
95
|
+
fixable: false
|
|
96
|
+
)
|
|
97
|
+
@violations
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class JapaneseSentenceLength < Rule
|
|
102
|
+
include JapaneseHelpers
|
|
103
|
+
|
|
104
|
+
self.rule_id = "JA004"
|
|
105
|
+
self.aliases = ["japanese-sentence-length"]
|
|
106
|
+
self.description = "Japanese sentences should stay within the configured length"
|
|
107
|
+
self.preset = :japanese
|
|
108
|
+
|
|
109
|
+
def check(tokens, _source)
|
|
110
|
+
maximum = @options.fetch(:sentence_length, 80).to_i
|
|
111
|
+
return @violations if maximum <= 0
|
|
112
|
+
|
|
113
|
+
each_inline(tokens) do |token|
|
|
114
|
+
prose_content(token).split(/(?<=[。!?!?])\s*/).each do |sentence|
|
|
115
|
+
next if sentence.empty? || sentence.each_char.count <= maximum
|
|
116
|
+
|
|
117
|
+
add_violation(
|
|
118
|
+
message: "Sentence length #{sentence.each_char.count} exceeds #{maximum}",
|
|
119
|
+
line: line_for(token),
|
|
120
|
+
fixable: false
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
@violations
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class JapaneseCommaCount < Rule
|
|
129
|
+
include JapaneseHelpers
|
|
130
|
+
|
|
131
|
+
self.rule_id = "JA005"
|
|
132
|
+
self.aliases = ["japanese-comma-count"]
|
|
133
|
+
self.description = "Japanese sentences should not contain too many commas"
|
|
134
|
+
self.preset = :japanese
|
|
135
|
+
|
|
136
|
+
def check(tokens, _source)
|
|
137
|
+
maximum = @options.fetch(:max_commas, 3).to_i
|
|
138
|
+
each_inline(tokens) do |token|
|
|
139
|
+
prose_content(token).split(/(?<=[。!?!?])\s*/).each do |sentence|
|
|
140
|
+
commas = sentence.count("、,")
|
|
141
|
+
next unless commas > maximum
|
|
142
|
+
|
|
143
|
+
add_violation(
|
|
144
|
+
message: "Sentence contains #{commas} commas (maximum #{maximum})",
|
|
145
|
+
line: line_for(token),
|
|
146
|
+
fixable: false
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
@violations
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class JapaneseDuplicateParticle < Rule
|
|
155
|
+
include JapaneseHelpers
|
|
156
|
+
|
|
157
|
+
self.rule_id = "JA006"
|
|
158
|
+
self.aliases = ["japanese-duplicate-particle"]
|
|
159
|
+
self.description = "Avoid repeated Japanese particles in a short phrase"
|
|
160
|
+
self.preset = :japanese
|
|
161
|
+
|
|
162
|
+
def check(tokens, _source)
|
|
163
|
+
each_inline(tokens) do |token|
|
|
164
|
+
next unless prose_content(token).match?(/の[^。!?\n]{0,12}の|に[^。!?\n]{0,12}に/)
|
|
165
|
+
|
|
166
|
+
add_violation(
|
|
167
|
+
message: "Check repeated Japanese particles such as の or に",
|
|
168
|
+
line: line_for(token),
|
|
169
|
+
fixable: false
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
@violations
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class JapaneseWidth < Rule
|
|
177
|
+
include JapaneseHelpers
|
|
178
|
+
|
|
179
|
+
self.rule_id = "JA007"
|
|
180
|
+
self.aliases = ["japanese-width"]
|
|
181
|
+
self.description = "Japanese prose should use full-width brackets and punctuation"
|
|
182
|
+
self.preset = :japanese
|
|
183
|
+
|
|
184
|
+
def check(tokens, _source)
|
|
185
|
+
each_inline(tokens) do |token|
|
|
186
|
+
content = prose_content(token)
|
|
187
|
+
next unless content.match?(JAPANESE)
|
|
188
|
+
next unless content.match?(/[()\[\]{},.!?;:]/)
|
|
189
|
+
|
|
190
|
+
add_violation(
|
|
191
|
+
message: "Use full-width brackets and punctuation in Japanese prose",
|
|
192
|
+
line: line_for(token),
|
|
193
|
+
fixable: false
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
@violations
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|