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
@@ -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
@@ -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
- raise NotImplementedError, "Subclasses must implement #fix"
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
- @rules.find { |r| r.rule_id == rule_id }
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
- @enabled_rules = options[:rules] || RuleRegistry.all.map(&:rule_id)
11
- @disabled_rules = options[:disable] || []
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(tokens, result)
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
- @enabled_rules.include?(rule_class.rule_id) &&
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(tokens, _source)
36
- tokens
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(tokens, _source)
31
- tokens
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, _source)
26
- tokens
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