rubocop-prose 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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +10 -2
  3. data/CHANGELOG.md +20 -0
  4. data/CONTRIBUTING.md +17 -7
  5. data/DESIGN.md +13 -5
  6. data/README.md +37 -20
  7. data/RELEASING.md +8 -8
  8. data/UPGRADING.md +16 -0
  9. data/assets/README.md +1 -1
  10. data/config/cops/markdown/code_block_style.yml +1 -1
  11. data/config/cops/markdown/emphasis_style.yml +4 -0
  12. data/config/cops/markdown/fence_style.yml +1 -1
  13. data/config/cops/markdown/first_heading.yml +1 -2
  14. data/config/cops/markdown/front_matter_keys.yml +5 -0
  15. data/config/cops/markdown/front_matter_syntax.yml +3 -0
  16. data/config/cops/markdown/hard_break_style.yml +3 -0
  17. data/config/cops/markdown/hard_tabs.yml +1 -1
  18. data/config/cops/markdown/heading_capitalization.yml +31 -0
  19. data/config/cops/markdown/heading_style.yml +1 -1
  20. data/config/cops/markdown/{syntax.yml → kramdown_syntax.yml} +1 -1
  21. data/config/cops/markdown/list_item_capitalization.yml +32 -0
  22. data/config/cops/markdown/list_item_punctuation.yml +13 -0
  23. data/config/cops/markdown/list_marker_spacing.yml +6 -5
  24. data/config/cops/markdown/ordered_list_numbering.yml +1 -1
  25. data/config/cops/markdown/reference_definitions.yml +2 -3
  26. data/config/cops/markdown/strong_style.yml +4 -0
  27. data/config/cops/markdown/structural_line_length.yml +1 -4
  28. data/config/cops/markdown/table_pipes.yml +6 -0
  29. data/config/cops/markdown/table_spacing.yml +4 -0
  30. data/config/cops/markdown/trailing_whitespace.yml +1 -0
  31. data/config/cops/markdown/unordered_list_marker.yml +1 -1
  32. data/config/cops/prose/sentence_capitalization.yml +9 -0
  33. data/config/cops/text/hard_tabs.yml +3 -0
  34. data/config/cops/text/proper_names.yml +4 -0
  35. data/config/obsoletion.yml +45 -0
  36. data/docs/cops/markdown/blockquote_spacing.md +2 -0
  37. data/docs/cops/markdown/code_block_style.md +11 -2
  38. data/docs/cops/markdown/duplicate_headings.md +51 -0
  39. data/docs/cops/markdown/emphasis_style.md +5 -1
  40. data/docs/cops/markdown/fence_style.md +11 -2
  41. data/docs/cops/markdown/first_heading.md +29 -3
  42. data/docs/cops/markdown/front_matter_keys.md +57 -0
  43. data/docs/cops/markdown/front_matter_syntax.md +32 -0
  44. data/docs/cops/markdown/hard_break_style.md +10 -5
  45. data/docs/cops/markdown/hard_tabs.md +3 -3
  46. data/docs/cops/markdown/heading_capitalization.md +176 -0
  47. data/docs/cops/markdown/heading_style.md +11 -2
  48. data/docs/cops/markdown/{syntax.md → kramdown_syntax.md} +6 -2
  49. data/docs/cops/markdown/list_item_capitalization.md +115 -0
  50. data/docs/cops/markdown/list_item_punctuation.md +70 -0
  51. data/docs/cops/markdown/list_marker_spacing.md +57 -7
  52. data/docs/cops/markdown/ordered_list_numbering.md +10 -1
  53. data/docs/cops/markdown/proper_names.md +3 -3
  54. data/docs/cops/markdown/raw_html.md +99 -6
  55. data/docs/cops/markdown/reference_definitions.md +30 -4
  56. data/docs/cops/markdown/strong_style.md +5 -1
  57. data/docs/cops/markdown/structural_line_length.md +31 -7
  58. data/docs/cops/markdown/table_pipes.md +7 -1
  59. data/docs/cops/markdown/table_spacing.md +5 -1
  60. data/docs/cops/markdown/thematic_break_style.md +2 -0
  61. data/docs/cops/markdown/trailing_whitespace.md +29 -0
  62. data/docs/cops/markdown/unordered_list_marker.md +11 -2
  63. data/docs/cops/prose/multiple_blank_lines.md +2 -0
  64. data/docs/cops/prose/sentence_capitalization.md +46 -0
  65. data/docs/cops/text/hard_tabs.md +28 -0
  66. data/docs/cops/text/proper_names.md +36 -0
  67. data/docs/settings/files.md +1 -1
  68. data/docs/settings/front_matter.md +2 -2
  69. data/lib/rubocop/cop/markdown/code_block_style.rb +1 -1
  70. data/lib/rubocop/cop/markdown/code_span_spacing.rb +2 -2
  71. data/lib/rubocop/cop/markdown/duplicate_headings.rb +55 -5
  72. data/lib/rubocop/cop/markdown/emphasis_spacing.rb +1 -1
  73. data/lib/rubocop/cop/markdown/emphasis_style.rb +7 -0
  74. data/lib/rubocop/cop/markdown/fence_style.rb +1 -1
  75. data/lib/rubocop/cop/markdown/first_heading.rb +13 -2
  76. data/lib/rubocop/cop/markdown/front_matter_keys.rb +70 -0
  77. data/lib/rubocop/cop/markdown/front_matter_syntax.rb +26 -0
  78. data/lib/rubocop/cop/markdown/hard_tabs.rb +11 -7
  79. data/lib/rubocop/cop/markdown/heading_capitalization.rb +43 -0
  80. data/lib/rubocop/cop/markdown/heading_style.rb +1 -1
  81. data/lib/rubocop/cop/markdown/{syntax.rb → kramdown_syntax.rb} +1 -1
  82. data/lib/rubocop/cop/markdown/list_item_capitalization.rb +90 -0
  83. data/lib/rubocop/cop/markdown/list_item_punctuation.rb +64 -0
  84. data/lib/rubocop/cop/markdown/list_marker_spacing.rb +13 -4
  85. data/lib/rubocop/cop/markdown/ordered_list_numbering.rb +1 -1
  86. data/lib/rubocop/cop/markdown/outside_spacing.rb +6 -6
  87. data/lib/rubocop/cop/markdown/proper_names.rb +20 -31
  88. data/lib/rubocop/cop/markdown/raw_html.rb +62 -7
  89. data/lib/rubocop/cop/markdown/reference_definitions.rb +15 -5
  90. data/lib/rubocop/cop/markdown/strong_style.rb +7 -0
  91. data/lib/rubocop/cop/markdown/structural_line_length.rb +7 -6
  92. data/lib/rubocop/cop/markdown/table_blank_lines.rb +1 -1
  93. data/lib/rubocop/cop/markdown/table_columns.rb +1 -1
  94. data/lib/rubocop/cop/markdown/table_pipes.rb +8 -1
  95. data/lib/rubocop/cop/markdown/table_spacing.rb +8 -1
  96. data/lib/rubocop/cop/markdown/thematic_break_style.rb +4 -0
  97. data/lib/rubocop/cop/markdown/trailing_whitespace.rb +6 -0
  98. data/lib/rubocop/cop/markdown/unordered_list_marker.rb +1 -1
  99. data/lib/rubocop/cop/prose/sentence_capitalization.rb +100 -0
  100. data/lib/rubocop/cop/text/hard_tabs.rb +21 -0
  101. data/lib/rubocop/cop/text/proper_names.rb +20 -0
  102. data/lib/rubocop/prose/capitalization_rules.rb +189 -0
  103. data/lib/rubocop/prose/inline_rules.rb +17 -10
  104. data/lib/rubocop/prose/link_rules.rb +34 -13
  105. data/lib/rubocop/prose/markdown_document.rb +30 -8
  106. data/lib/rubocop/prose/proper_name_rules.rb +31 -0
  107. data/lib/rubocop/prose/structural_blank_lines.rb +1 -1
  108. data/lib/rubocop/prose/table_rules.rb +1 -1
  109. data/lib/rubocop/prose/version.rb +1 -1
  110. data/lib/rubocop-prose.rb +3 -0
  111. metadata +32 -4
@@ -4,12 +4,17 @@ Trims trailing whitespace while preserving paragraph hard breaks at the configur
4
4
 
5
5
  When [Markdown/HardBreakStyle](hard_break_style.md) is enabled, that cop selects the hard-break syntax and this cop avoids overlapping corrections.
6
6
 
7
+ Set `AllowEmptyBlockquoteSpace: true` to permit exactly one trailing ASCII space on an otherwise-empty blockquote line, including nested quotes. Bare quote markers remain valid. Two or more trailing spaces, tabs, and mixed trailing whitespace are still rejected; paragraph hard breaks and code-block handling are unchanged.
8
+
9
+ [Markdown/BlockquoteSpacing](blockquote_spacing.md) leaves empty quote markers alone, and [Markdown/BlockquoteBlankLines](blockquote_blank_lines.md) adds bare markers without requiring a trailing space. [Prose/MultipleBlankLines](../prose/multiple_blank_lines.md) still counts empty quote lines toward `Max`, whether they have the allowed space or not.
10
+
7
11
  ## Configuration
8
12
 
9
13
  ```yaml
10
14
  Markdown/TrailingWhitespace:
11
15
  Enabled: true
12
16
  HardBreakSpaces: 2 # Must be an integer of at least 2.
17
+ AllowEmptyBlockquoteSpace: false
13
18
  CodeBlocks: false # When true, code whitespace is report-only to preserve embedded strings.
14
19
  ```
15
20
 
@@ -28,3 +33,27 @@ Inputs are shown as Ruby strings so tabs, trailing spaces, and newline character
28
33
  ```ruby
29
34
  "A paragraph.\n"
30
35
  ```
36
+
37
+ ## Empty Blockquote Lines
38
+
39
+ For this configuration:
40
+
41
+ ```yaml
42
+ Markdown/TrailingWhitespace:
43
+ AllowEmptyBlockquoteSpace: true
44
+ ```
45
+
46
+ ### Bad
47
+
48
+ ```ruby
49
+ "> First paragraph.\n> \n> Second paragraph.\n"
50
+ "> First paragraph.\n>\t\n> Second paragraph.\n"
51
+ ```
52
+
53
+ ### Good
54
+
55
+ ```ruby
56
+ "> First paragraph.\n> \n> Second paragraph.\n"
57
+ "> > First paragraph.\n> > \n> > Second paragraph.\n"
58
+ "> First paragraph.\n>\n> Second paragraph.\n"
59
+ ```
@@ -9,7 +9,7 @@ Autocorrection changes only the marker and is withheld if it could create a them
9
9
  ```yaml
10
10
  Markdown/UnorderedListMarker:
11
11
  Enabled: true
12
- EnforcedStyle: consistent # consistent, asterisk, plus or dash.
12
+ Style: consistent # consistent, asterisk, plus or dash.
13
13
  ```
14
14
 
15
15
  ## Example
@@ -18,7 +18,7 @@ For this configuration:
18
18
 
19
19
  ```yaml
20
20
  Markdown/UnorderedListMarker:
21
- EnforcedStyle: dash
21
+ Style: dash
22
22
  ```
23
23
 
24
24
  ### Bad
@@ -34,3 +34,12 @@ Markdown/UnorderedListMarker:
34
34
  - one
35
35
  - two
36
36
  ```
37
+
38
+ ## Migrating From 0.1.0
39
+
40
+ Replace `EnforcedStyle` with `Style`:
41
+
42
+ ```yaml
43
+ Markdown/UnorderedListMarker:
44
+ Style: dash
45
+ ```
@@ -4,6 +4,8 @@ Removes excess consecutive blank lines while preserving paragraph separation. In
4
4
 
5
5
  [Markdown/BlockquoteBlankLines](../markdown/blockquote_blank_lines.md) handles the separate case of adding markers between adjacent blockquotes.
6
6
 
7
+ Empty quote lines with the single trailing space permitted by [Markdown/TrailingWhitespace](../markdown/trailing_whitespace.md)'s `AllowEmptyBlockquoteSpace` option still count toward `Max`; retained lines keep their original spelling.
8
+
7
9
  At Markdown structural boundaries, the effective limit is at least the minimum required by the enabled [heading](../markdown/heading_blank_lines.md), [fence](../markdown/fence_blank_lines.md), [list](../markdown/list_blank_lines.md), or [table](../markdown/table_blank_lines.md) spacing cop in that file's configuration. Shared boundaries use the largest minimum, not their sum. `Max` still applies elsewhere, including document edges where no structural padding is required.
8
10
 
9
11
  ## Configuration
@@ -0,0 +1,46 @@
1
+ # Prose/SentenceCapitalization
2
+
3
+ Checks lowercase words at paragraph openings in Markdown and plain-text files using the single `initial_capital` style. It is an opt-in editorial rule. It checks ordinary prose and reports lowercase openings inside inline code or quoted terms in Markdown, but never changes quoted or code contents. For literal terms, rewrite the sentence around the term rather than changing the literal spelling.
4
+
5
+ The cop checks paragraph openings only; it does not split paragraphs into sentences. In Markdown, it handles paragraphs in blockquotes, footnotes, and list items, but leaves each list item's opening paragraph to [Markdown/ListItemCapitalization](../markdown/list_item_capitalization.md) so consistent lowercase lists remain possible. Subsequent paragraphs in list items are checked.
6
+
7
+ In plain-text files, paragraphs are literal groups of nonblank lines. The cop does not interpret Markdown-like code, emphasis, links, or containers in text files. A quoted paragraph opening is still reported without correction. Plain-text list-item capitalization is not inferred.
8
+
9
+ Headings, tables, code blocks, and ignored front matter are not Markdown paragraphs for this rule. Mixed-case brands such as `eBay` pass by default. `IgnoredWords` exempts exact, case-sensitive words. Native RuboCop suppression directives can silence intentional Markdown exceptions; plain-text files use `IgnoredWords`.
10
+
11
+ Supports unsafe autocorrection with `rubocop -A` for a mapped ordinary prose opening only. A lowercase opening in code, quotation marks, or ambiguous source mapping is report-only. Abbreviations, URLs, decimals, and later sentence boundaries are not analyzed.
12
+
13
+ ## Configuration
14
+
15
+ ```yaml
16
+ Prose/SentenceCapitalization:
17
+ Enabled: false
18
+ SafeAutoCorrect: false
19
+ Style: initial_capital
20
+ PreserveMixedCase: true
21
+ IgnoredWords: []
22
+ SupportedStyles:
23
+ - initial_capital
24
+ ```
25
+
26
+ ## Examples
27
+
28
+ ### Bad
29
+
30
+ ```markdown
31
+ lowercase prose starts this paragraph.
32
+
33
+ `fixed` requires exactly one space after every marker.
34
+
35
+ "aligned" adds padding after shorter markers.
36
+ ```
37
+
38
+ ### Good
39
+
40
+ ```markdown
41
+ Lowercase prose starts this paragraph.
42
+
43
+ The `fixed` style requires exactly one space after every marker.
44
+
45
+ The "aligned" style adds padding after shorter markers.
46
+ ```
@@ -0,0 +1,28 @@
1
+ # Text/HardTabs
2
+
3
+ Reports every hard tab in plain-text files without correction. Markdown-like fences, code, and metadata are literal text and receive no exemptions.
4
+
5
+ Use [Markdown/HardTabs](../markdown/hard_tabs.md) for Markdown code-block and language exceptions.
6
+
7
+ ## Configuration
8
+
9
+ ```yaml
10
+ Text/HardTabs:
11
+ Enabled: true
12
+ ```
13
+
14
+ ## Example
15
+
16
+ Inputs are shown as Ruby strings so tabs and newline characters are visible.
17
+
18
+ ### Bad
19
+
20
+ ```ruby
21
+ "A\tparagraph.\n"
22
+ ```
23
+
24
+ ### Good
25
+
26
+ ```ruby
27
+ "A paragraph.\n"
28
+ ```
@@ -0,0 +1,36 @@
1
+ # Text/ProperNames
2
+
3
+ Corrects configured names to their canonical capitalization in literal plain text. Matching is case-insensitive, uses whole-word boundaries, and treats punctuation in names literally. Markdown-like code, links, URLs, and fences receive no special treatment.
4
+
5
+ Use [Markdown/ProperNames](../markdown/proper_names.md) for Markdown syntax exclusions and code-content options.
6
+
7
+ ## Configuration
8
+
9
+ ```yaml
10
+ Text/ProperNames:
11
+ Enabled: false
12
+ Names: [] # Canonical spellings; an empty list disables checks.
13
+ ```
14
+
15
+ ## Example
16
+
17
+ For this configuration:
18
+
19
+ ```yaml
20
+ Text/ProperNames:
21
+ Enabled: true
22
+ Names:
23
+ - GitHub
24
+ ```
25
+
26
+ ### Bad
27
+
28
+ ```text
29
+ `github` https://github.com
30
+ ```
31
+
32
+ ### Good
33
+
34
+ ```text
35
+ `GitHub` https://GitHub.com
36
+ ```
@@ -1,4 +1,4 @@
1
- # File selection
1
+ # File Selection
2
2
 
3
3
  `Markdown/Include` and `Text/Include` select files using RuboCop's normal filename and glob patterns.
4
4
 
@@ -1,8 +1,8 @@
1
- # Front matter
1
+ # Front Matter
2
2
 
3
3
  By default, Markdown checks ignore a leading header opened with `---` and closed with `---` or `...`. Unclosed headers and delimiters elsewhere remain ordinary Markdown.
4
4
 
5
- Set `IgnoreFrontMatter: false` to inspect the header as Markdown, not validate its YAML. RuboCop directives inside metadata remain ignored, and this setting does not change Ruby snippet extraction.
5
+ Set `IgnoreFrontMatter: false` to inspect the header as Markdown, not validate its YAML. Use the opt-in [Markdown/FrontMatterSyntax](../cops/markdown/front_matter_syntax.md) cop to report YAML syntax errors independently of this setting. RuboCop directives inside metadata remain ignored, and this setting does not change Ruby snippet extraction.
6
6
 
7
7
  ## Example
8
8
 
@@ -12,7 +12,7 @@ module RuboCop
12
12
  private
13
13
 
14
14
  def investigate_document
15
- expected = cop_config.fetch('EnforcedStyle', 'consistent')
15
+ expected = cop_config.fetch('Style', 'consistent')
16
16
  code_blocks.each do |node|
17
17
  actual = node.options[:fenced] ? 'fenced' : 'indented'
18
18
  expected = actual if expected == 'consistent'
@@ -11,8 +11,8 @@ module RuboCop
11
11
  def investigate_document
12
12
  multiline = RuboCop::Prose::InlineRules.multiline_atoms(document)
13
13
  .select { |range| range.source.start_with?('`') }
14
- RuboCop::Prose::InlineRules.lines(document).each do |text, number, column|
15
- RuboCop::Prose::InlineRules.spans(text).each do |span|
14
+ document.inline_lines.each do |text, number, column|
15
+ document.inline_spans(number).each do |span|
16
16
  next unless span.kind == :code && span.begin_pos >= column
17
17
 
18
18
  position = document.range(number, span.begin_pos, number, span.end_pos)
@@ -8,20 +8,70 @@ module RuboCop
8
8
  class DuplicateHeadings < Base
9
9
  include RuboCop::Prose::HeadingRules
10
10
 
11
+ def validate_config
12
+ scopes = ['siblings', 'document', false]
13
+ value = cop_config['Scope']
14
+ return if scopes.include?(value)
15
+
16
+ if value.is_a?(Hash) && value.key?('Default') && scopes.include?(value['Default'])
17
+ covered = []
18
+ valid = value.except('Default').all? do |selector, scope|
19
+ levels = selector_levels(selector)
20
+ next false unless levels && scopes.include?(scope) && !covered.intersect?(levels)
21
+
22
+ covered.concat(levels)
23
+ true
24
+ end
25
+ return if valid
26
+ end
27
+
28
+ raise ValidationError, "#{cop_name}: Scope must be siblings, document, false, or a mapping with Default " \
29
+ 'and nonoverlapping levels (1-6), ranges (4-6), or comparisons (>4), ' \
30
+ 'set to siblings, document, or false.'
31
+ end
32
+
11
33
  def investigate_document
12
- seen = Set.new
34
+ document_seen = Set.new
35
+ sibling_seen = Set.new
13
36
  parents = []
14
37
  heading_entries.each do |heading|
15
38
  parents.pop while parents.any? && parents.last[:level] >= heading[:level]
16
- scope = cop_config.fetch('Scope', 'siblings') == 'siblings' ? parents.last&.fetch(:line) : nil
17
- key = [scope, heading[:text]]
18
- if seen.include?(key)
39
+ key = [parents.last&.fetch(:line), heading[:text]]
40
+ setting = cop_config['Scope']
41
+ scope = effective_scope(setting, heading[:level])
42
+ duplicate = scope == 'document' ? document_seen.include?(heading[:text]) : sibling_seen.include?(key)
43
+ if scope != false && duplicate
19
44
  prose_offense(heading_range(heading), message: 'Use unique heading text within the configured scope.')
20
45
  end
21
- seen.add(key)
46
+ document_seen.add(heading[:text])
47
+ sibling_seen.add(key)
22
48
  parents << heading
23
49
  end
24
50
  end
51
+
52
+ private
53
+
54
+ def effective_scope(setting, level)
55
+ return setting unless setting.is_a?(Hash)
56
+
57
+ selected = setting.find do |selector, _scope|
58
+ selector != 'Default' && selector_levels(selector).include?(level)
59
+ end
60
+ selected ? selected.last : setting.fetch('Default')
61
+ end
62
+
63
+ def selector_levels(selector)
64
+ return [selector] if selector.is_a?(Integer) && selector.between?(1, 6)
65
+ return unless selector.is_a?(String)
66
+
67
+ if (match = /\A([1-6])-([1-6])\z/.match(selector))
68
+ first, last = match.captures.map(&:to_i)
69
+ (first..last).to_a if first <= last
70
+ elsif (match = /\A([<>])([1-6])\z/.match(selector))
71
+ levels = (1..6).select { |level| match[1] == '>' ? level > match[2].to_i : level < match[2].to_i }
72
+ levels unless levels.empty?
73
+ end
74
+ end
25
75
  end
26
76
  end
27
77
  end
@@ -9,7 +9,7 @@ module RuboCop
9
9
  extend AutoCorrector
10
10
 
11
11
  def investigate_document
12
- RuboCop::Prose::InlineRules.lines(document).each do |text, number, column|
12
+ document.inline_lines.each do |text, number, column|
13
13
  masked = RuboCop::Prose::InlineRules.mask(text, document: document, number: number)
14
14
  masked.to_enum(:scan, /(?<![\w*_])(\*{1,3}|_{1,3})(?![*_])(.+?)\1(?![\w*_])/).each do
15
15
  match = Regexp.last_match
@@ -8,6 +8,13 @@ module RuboCop
8
8
  class EmphasisStyle < Base
9
9
  extend AutoCorrector
10
10
 
11
+ def validate_config
12
+ styles = cop_config['SupportedStyles']
13
+ return if styles.include?(cop_config['Style'])
14
+
15
+ raise ValidationError, "#{cop_name}: Style must be one of #{styles.join(', ')}."
16
+ end
17
+
11
18
  def investigate_document
12
19
  expected = cop_config.fetch('Style', 'consistent')
13
20
  document.emphasis_ranges(:em).each do |opening, closing, char|
@@ -12,7 +12,7 @@ module RuboCop
12
12
  private
13
13
 
14
14
  def investigate_document
15
- expected = { 'backtick' => '`', 'tilde' => '~' }[cop_config.fetch('EnforcedStyle', 'consistent')]
15
+ expected = { 'backtick' => '`', 'tilde' => '~' }[cop_config.fetch('Style', 'consistent')]
16
16
  code_blocks.each do |node|
17
17
  details = fence_details(node)
18
18
  next unless details
@@ -24,16 +24,27 @@ module RuboCop
24
24
  message: 'Start the document with a heading at the configured level.')
25
25
  end
26
26
 
27
+ def validate_config
28
+ title_key = cop_config['FrontMatterTitle']
29
+ return if title_key == false || (title_key.is_a?(String) && !title_key.strip.empty?)
30
+
31
+ raise ValidationError,
32
+ "#{cop_name}: FrontMatterTitle must be a nonempty metadata key or false; " \
33
+ 'replace `FrontMatterTitle: true` and `TitleKey` with the metadata key, or use false to disable.'
34
+ end
35
+
27
36
  private
28
37
 
29
38
  def front_matter_title?
30
- return false unless cop_config.fetch('FrontMatterTitle', true) && document.front_matter
39
+ title_key = cop_config['FrontMatterTitle']
40
+ return false if title_key == false
41
+ return false unless document.front_matter
31
42
 
32
43
  lines = document.front_matter.lines
33
44
  metadata = YAML.safe_load(lines[1...-1].join)
34
45
  return false unless metadata.is_a?(Hash)
35
46
 
36
- title = metadata[cop_config.fetch('TitleKey', 'title')]
47
+ title = metadata[title_key]
37
48
  title.is_a?(String) && !title.strip.empty?
38
49
  rescue Psych::Exception
39
50
  false
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class FrontMatterKeys < Base
9
+ def validate_config
10
+ %w[AllowedKeys RequiredKeys].each do |option|
11
+ keys = cop_config[option]
12
+ next if option == 'AllowedKeys' && keys.nil?
13
+ next if keys.is_a?(Array) && keys.all? { |key| key.is_a?(String) && !key.empty? }
14
+
15
+ raise ValidationError, "#{cop_name}: #{option} must be an array of nonempty key names."
16
+ end
17
+ allowed = cop_config['AllowedKeys']
18
+ return if allowed.nil? || (cop_config['RequiredKeys'] - allowed).empty?
19
+
20
+ raise ValidationError, "#{cop_name}: RequiredKeys must be included in AllowedKeys."
21
+ end
22
+
23
+ private
24
+
25
+ def investigate_document
26
+ return unless document.front_matter
27
+
28
+ parsed = parse_front_matter
29
+ return if parsed == :invalid
30
+
31
+ root = parsed&.root
32
+ if root && !root.is_a?(Psych::Nodes::Mapping)
33
+ prose_offense(node_range(root), message: 'Front matter must be a mapping of metadata keys.')
34
+ return
35
+ end
36
+
37
+ keys = root ? root.children.each_slice(2).map(&:first) : []
38
+ names = keys.filter_map do |key|
39
+ unless key.is_a?(Psych::Nodes::Scalar)
40
+ prose_offense(node_range(key), message: 'Front matter keys must be scalar names.')
41
+ next
42
+ end
43
+
44
+ allowed = cop_config['AllowedKeys']
45
+ if allowed && !allowed.include?(key.value)
46
+ prose_offense(node_range(key), message: "Front matter key `#{key.value}` is not allowed.")
47
+ end
48
+ key.value
49
+ end
50
+ (cop_config['RequiredKeys'] - names).each do |key|
51
+ prose_offense(document.line_range(1), message: "Missing required front matter key `#{key}`.")
52
+ end
53
+ end
54
+
55
+ def parse_front_matter
56
+ Psych.parse(document.front_matter.lines[1...-1].join) || nil
57
+ rescue Psych::SyntaxError
58
+ # Syntax diagnostics belong to Markdown/FrontMatterSyntax.
59
+ :invalid
60
+ end
61
+
62
+ def node_range(node)
63
+ first = node.start_line + 2
64
+ last = node.end_line + 2
65
+ document.range(first, node.start_column, last, node.end_column)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class FrontMatterSyntax < Base
9
+ private
10
+
11
+ def investigate_document
12
+ return unless document.front_matter
13
+
14
+ Psych.parse(document.front_matter.lines[1...-1].join)
15
+ rescue Psych::SyntaxError => e
16
+ line = (e.line + 1).clamp(1, document.lines.length)
17
+ column = (e.column - 1).clamp(0, document.line(line).length)
18
+ prose_offense(
19
+ document.range(line, column, line, [column + 1, document.line(line).length].min),
20
+ message: "Invalid YAML front matter: #{e.problem}."
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -7,13 +7,7 @@ module RuboCop
7
7
  private
8
8
 
9
9
  def investigate_document
10
- allowed = cop_config['AllowedCodeLanguages'].map(&:downcase)
11
- exempt = document.nodes(:codeblock).flat_map do |block|
12
- next [] unless allowed.include?(block.options[:lang].to_s.split.first.to_s.downcase)
13
-
14
- first = block.options.fetch(:code_start_line)
15
- (first...(first + block.value.lines.length)).to_a
16
- end.to_set
10
+ exempt = allowed_code_lines
17
11
  document.lines.each_with_index do |text, index|
18
12
  number = index + 1
19
13
  next if document.front_matter_line?(number) || exempt.include?(number)
@@ -26,6 +20,16 @@ module RuboCop
26
20
  end
27
21
  end
28
22
  end
23
+
24
+ def allowed_code_lines
25
+ allowed = cop_config['AllowedCodeLanguages'].map(&:downcase)
26
+ document.nodes(:codeblock).flat_map do |block|
27
+ next [] unless allowed.include?(block.options[:lang].to_s.split.first.to_s.downcase)
28
+
29
+ first = block.options.fetch(:code_start_line)
30
+ (first...(first + block.value.lines.length)).to_a
31
+ end.to_set
32
+ end
29
33
  end
30
34
  end
31
35
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/capitalization_rules'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Markdown
8
+ class HeadingCapitalization < Base
9
+ extend AutoCorrector
10
+ include RuboCop::Prose::CapitalizationRules
11
+
12
+ MSG = 'Capitalize the first prose letter of the heading.'
13
+ TITLE_MSG = 'Use title case: capitalize major words and lowercase internal minor words.'
14
+
15
+ def investigate_document
16
+ document.nodes(:header).each do |node|
17
+ text = prose_text(node)
18
+ title_case = cop_config['Style'] == 'title_case'
19
+ number = node.options.fetch(:location)
20
+ opening = heading_opening(number)
21
+ literal_lowercase = opening&.literal && lowercase_opening?(opening)
22
+ invalid = title_case ? invalid_title?(text) || literal_lowercase : lowercase_opening?(opening)
23
+ invalid ||= !title_case && !opening && invalid_initial?(text)
24
+ next unless invalid
25
+
26
+ range = opening&.literal || identifier_source?(document.line(number)) ? nil : correction_range(text, number)
27
+ replacement = capitalized_text(text, title_case)
28
+ prose_offense(document.line_range(number), message: title_case ? TITLE_MSG : MSG) do |corrector|
29
+ corrector.replace(processed_range(range), replacement) if range
30
+ end
31
+ end
32
+ end
33
+
34
+ def heading_opening(number)
35
+ source = document.line(number)
36
+ text = source.sub(/\A(?:[ \t]*>[ \t]?)*[ \t]*(?:[-+*]|\d+[.)])?[ \t]*/, '')
37
+ text = text.sub(/\A\#{1,6}[ \t]+/, '').sub(/[ \t]+\#{1,}[ \t]*\z/, '')
38
+ opening_word(text)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -10,7 +10,7 @@ module RuboCop
10
10
 
11
11
  def investigate_document
12
12
  headings = heading_entries
13
- style = cop_config.fetch('EnforcedStyle', 'consistent')
13
+ style = cop_config.fetch('Style', 'consistent')
14
14
  style = headings.first&.fetch(:style) if style == 'consistent'
15
15
  headings.each do |heading|
16
16
  next if heading[:style] == style
@@ -3,7 +3,7 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Markdown
6
- class Syntax < Base
6
+ class KramdownSyntax < Base
7
7
  private
8
8
 
9
9
  def investigate_document
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../prose/list_rules'
4
+ require_relative '../../prose/capitalization_rules'
5
+
6
+ module RuboCop
7
+ module Cop
8
+ module Markdown
9
+ class ListItemCapitalization < Base
10
+ extend AutoCorrector
11
+ include RuboCop::Prose::ListRules
12
+ include RuboCop::Prose::CapitalizationRules
13
+
14
+ private
15
+
16
+ def investigate_document
17
+ list_groups do |_list, marks, _parent|
18
+ expected = nil
19
+ marks.each do |mark|
20
+ if cop_config['Style'] == 'title_case'
21
+ investigate_title(mark)
22
+ next
23
+ end
24
+
25
+ initial = prose_initial(mark)
26
+ next unless initial
27
+
28
+ range, style = initial
29
+ next if initial.last && expected.nil? && cop_config['Style'] == 'consistent'
30
+
31
+ expected ||= cop_config['Style'] == 'initial_capital' ? 'uppercase' : style
32
+ next if style == expected
33
+
34
+ prose_offense(range, message: "Use #{expected} initial capitalization in list items.") do |corrector|
35
+ next if initial.last
36
+
37
+ replacement = expected == 'uppercase' ? range.source.upcase : range.source.downcase
38
+ corrector.replace(processed_range(range), replacement)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def investigate_title(mark)
45
+ paragraph = mark[:item].children.first
46
+ return unless paragraph&.type == :p && paragraph.options[:prose_source]
47
+ return unless paragraph.options[:location] == mark[:line]
48
+
49
+ source = paragraph.options[:prose_source]
50
+ checkbox = source[/\A[ \t]*\[[ xX]\][ \t]+/].to_s
51
+ text = prose_text(paragraph).sub(/\A\[[ xX]\][ \t]+/, '')
52
+ opening = opening_word(source[checkbox.length..])
53
+ return unless invalid_title?(text) || (opening&.literal && lowercase_opening?(opening))
54
+
55
+ range = opening&.literal || identifier_source?(source) ? nil : correction_range(text, mark[:line])
56
+ prose_offense(document.line_range(mark[:line]), message: 'Use title case in list items.') do |corrector|
57
+ corrector.replace(processed_range(range), capitalized_text(text, true)) if range
58
+ end
59
+ end
60
+
61
+ def prose_initial(mark)
62
+ paragraph = mark[:item].children.first
63
+ return unless paragraph&.type == :p && paragraph.options[:prose_source]
64
+ return unless paragraph.options[:location] == mark[:line]
65
+
66
+ source = paragraph.options[:prose_source]
67
+ checkbox = source[/\A[ \t]*\[[ xX]\][ \t]+/].to_s
68
+ text = source[checkbox.length..]
69
+ opening = opening_word(text)
70
+ word = opening&.word
71
+ return unless word&.match?(/\A[\p{L}\p{M}]+\z/) ||
72
+ (opening&.code && word&.match?(/\A[\p{L}\p{M}][\p{L}\p{M}\p{N}_]*\z/))
73
+
74
+ return if ignored_word?(opening.word)
75
+ return if cop_config['PreserveMixedCase'] && opening.word[1..].match?(/[\p{Lu}\p{Lt}]/)
76
+
77
+ after = text[opening.end_pos..].sub(/\A(?:\*+|_+|`+|["'“”‘’»›\]]+)+/, '')
78
+ return if after.match?(%r{\A(?:[\p{L}\p{M}_]|[._/:-])})
79
+
80
+ offset = checkbox.length + opening.begin_pos
81
+ return if source[0...offset].include?("\n")
82
+
83
+ column = document.content_start(paragraph, mark[:line]) + offset
84
+ range = document.range(mark[:line], column, mark[:line], column + 1)
85
+ [range, opening.word[0].match?(/\p{Ll}/) ? 'lowercase' : 'uppercase', opening.literal]
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end