dead_end 1.1.7 → 3.1.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +27 -1
  3. data/.github/workflows/check_changelog.yml +14 -7
  4. data/.standard.yml +1 -0
  5. data/CHANGELOG.md +60 -0
  6. data/CODE_OF_CONDUCT.md +2 -2
  7. data/Gemfile +2 -0
  8. data/Gemfile.lock +31 -2
  9. data/README.md +122 -35
  10. data/Rakefile +1 -1
  11. data/dead_end.gemspec +12 -12
  12. data/exe/dead_end +4 -67
  13. data/lib/dead_end/{internals.rb → api.rb} +90 -52
  14. data/lib/dead_end/around_block_scan.rb +16 -18
  15. data/lib/dead_end/auto.rb +3 -101
  16. data/lib/dead_end/block_expand.rb +6 -5
  17. data/lib/dead_end/capture_code_context.rb +167 -50
  18. data/lib/dead_end/clean_document.rb +304 -0
  19. data/lib/dead_end/cli.rb +129 -0
  20. data/lib/dead_end/code_block.rb +20 -4
  21. data/lib/dead_end/code_frontier.rb +74 -29
  22. data/lib/dead_end/code_line.rb +176 -87
  23. data/lib/dead_end/code_search.rb +40 -51
  24. data/lib/dead_end/core_ext.rb +35 -0
  25. data/lib/dead_end/display_code_with_line_numbers.rb +7 -8
  26. data/lib/dead_end/display_invalid_blocks.rb +42 -80
  27. data/lib/dead_end/explain_syntax.rb +103 -0
  28. data/lib/dead_end/insertion_sort.rb +46 -0
  29. data/lib/dead_end/left_right_lex_count.rb +168 -0
  30. data/lib/dead_end/lex_all.rb +25 -34
  31. data/lib/dead_end/lex_value.rb +70 -0
  32. data/lib/dead_end/parse_blocks_from_indent_line.rb +3 -4
  33. data/lib/dead_end/pathname_from_message.rb +47 -0
  34. data/lib/dead_end/ripper_errors.rb +36 -0
  35. data/lib/dead_end/version.rb +1 -1
  36. data/lib/dead_end.rb +2 -2
  37. metadata +14 -9
  38. data/.travis.yml +0 -6
  39. data/lib/dead_end/fyi.rb +0 -7
  40. data/lib/dead_end/heredoc_block_parse.rb +0 -30
  41. data/lib/dead_end/trailing_slash_join.rb +0 -53
  42. data/lib/dead_end/who_dis_syntax_error.rb +0 -69
@@ -3,11 +3,19 @@
3
3
  module DeadEnd
4
4
  # Searches code for a syntax error
5
5
  #
6
+ # There are three main phases in the algorithm:
7
+ #
8
+ # 1. Sanitize/format input source
9
+ # 2. Search for invalid blocks
10
+ # 3. Format invalid blocks into something meaninful
11
+ #
12
+ # This class handles the part.
13
+ #
6
14
  # The bulk of the heavy lifting is done in:
7
15
  #
8
16
  # - CodeFrontier (Holds information for generating blocks and determining if we can stop searching)
9
17
  # - ParseBlocksFromLine (Creates blocks into the frontier)
10
- # - BlockExpand (Expands existing blocks to search more code
18
+ # - BlockExpand (Expands existing blocks to search more code)
11
19
  #
12
20
  # ## Syntax error detection
13
21
  #
@@ -25,65 +33,64 @@ module DeadEnd
25
33
  # # => ["def lol\n"]
26
34
  #
27
35
  class CodeSearch
28
- private; attr_reader :frontier; public
29
- public; attr_reader :invalid_blocks, :record_dir, :code_lines
36
+ private
37
+
38
+ attr_reader :frontier
39
+
40
+ public
41
+
42
+ attr_reader :invalid_blocks, :record_dir, :code_lines
30
43
 
31
44
  def initialize(source, record_dir: ENV["DEAD_END_RECORD_DIR"] || ENV["DEBUG"] ? "tmp" : nil)
32
- @source = source
33
45
  if record_dir
34
- @time = Time.now.strftime('%Y-%m-%d-%H-%M-%s-%N')
35
- @record_dir = Pathname(record_dir).join(@time).tap {|p| p.mkpath }
46
+ @record_dir = DeadEnd.record_dir(record_dir)
36
47
  @write_count = 0
37
48
  end
38
- code_lines = source.lines.map.with_index do |line, i|
39
- CodeLine.new(line: line, index: i)
40
- end
41
49
 
42
- @code_lines = TrailingSlashJoin.new(code_lines: code_lines).call
50
+ @tick = 0
51
+ @source = source
52
+ @name_tick = Hash.new { |hash, k| hash[k] = 0 }
53
+ @invalid_blocks = []
54
+
55
+ @code_lines = CleanDocument.new(source: source).call.lines
43
56
 
44
57
  @frontier = CodeFrontier.new(code_lines: @code_lines)
45
- @invalid_blocks = []
46
- @name_tick = Hash.new {|hash, k| hash[k] = 0 }
47
- @tick = 0
48
- @block_expand = BlockExpand.new(code_lines: code_lines)
58
+ @block_expand = BlockExpand.new(code_lines: @code_lines)
49
59
  @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines)
50
60
  end
51
61
 
52
62
  # Used for debugging
53
63
  def record(block:, name: "record")
54
- return if !@record_dir
64
+ return unless @record_dir
55
65
  @name_tick[name] += 1
56
66
  filename = "#{@write_count += 1}-#{name}-#{@name_tick[name]}.txt"
57
67
  if ENV["DEBUG"]
58
68
  puts "\n\n==== #{filename} ===="
59
- puts "\n```#{block.starts_at}:#{block.ends_at}"
60
- puts "#{block.to_s}"
69
+ puts "\n```#{block.starts_at}..#{block.ends_at}"
70
+ puts block.to_s
61
71
  puts "```"
62
- puts " block indent: #{block.current_indent}"
72
+ puts " block indent: #{block.current_indent}"
63
73
  end
64
74
  @record_dir.join(filename).open(mode: "a") do |f|
65
- display = DisplayInvalidBlocks.new(
66
- blocks: block,
75
+ document = DisplayCodeWithLineNumbers.new(
76
+ lines: @code_lines.select(&:visible?),
67
77
  terminal: false,
68
- code_lines: @code_lines,
69
- )
70
- f.write(display.indent display.code_with_lines)
78
+ highlight_lines: block.lines
79
+ ).call
80
+
81
+ f.write(document)
71
82
  end
72
83
  end
73
84
 
74
- def push(block, name: )
85
+ def push(block, name:)
75
86
  record(block: block, name: name)
76
87
 
77
- if block.valid?
78
- block.mark_invisible
79
- frontier << block
80
- else
81
- frontier << block
82
- end
88
+ block.mark_invisible if block.valid?
89
+ frontier << block
83
90
  end
84
91
 
85
92
  # Removes the block without putting it back in the frontier
86
- def sweep(block:, name: )
93
+ def sweep(block:, name:)
87
94
  record(block: block, name: name)
88
95
 
89
96
  block.lines.each(&:mark_invisible)
@@ -119,26 +126,8 @@ module DeadEnd
119
126
  push(block, name: "expand")
120
127
  end
121
128
 
122
- def sweep_heredocs
123
- HeredocBlockParse.new(
124
- source: @source,
125
- code_lines: @code_lines
126
- ).call.each do |block|
127
- push(block, name: "heredoc")
128
- end
129
- end
130
-
131
- def sweep_comments
132
- lines = @code_lines.select(&:is_comment?)
133
- return if lines.empty?
134
- block = CodeBlock.new(lines: lines)
135
- sweep(block: block, name: "comments")
136
- end
137
-
138
129
  # Main search loop
139
130
  def call
140
- sweep_heredocs
141
- sweep_comments
142
131
  until frontier.holds_all_syntax_errors?
143
132
  @tick += 1
144
133
 
@@ -149,8 +138,8 @@ module DeadEnd
149
138
  end
150
139
  end
151
140
 
152
- @invalid_blocks.concat(frontier.detect_invalid_blocks )
153
- @invalid_blocks.sort_by! {|block| block.starts_at }
141
+ @invalid_blocks.concat(frontier.detect_invalid_blocks)
142
+ @invalid_blocks.sort_by! { |block| block.starts_at }
154
143
  self
155
144
  end
156
145
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Monkey patch kernel to ensure that all `require` calls call the same
4
+ # method
5
+ module Kernel
6
+ module_function
7
+
8
+ alias_method :dead_end_original_require, :require
9
+ alias_method :dead_end_original_require_relative, :require_relative
10
+ alias_method :dead_end_original_load, :load
11
+
12
+ def load(file, wrap = false)
13
+ dead_end_original_load(file)
14
+ rescue SyntaxError => e
15
+ DeadEnd.handle_error(e)
16
+ end
17
+
18
+ def require(file)
19
+ dead_end_original_require(file)
20
+ rescue SyntaxError => e
21
+ DeadEnd.handle_error(e)
22
+ end
23
+
24
+ def require_relative(file)
25
+ if Pathname.new(file).absolute?
26
+ dead_end_original_require file
27
+ else
28
+ relative_from = caller_locations(1..1).first
29
+ relative_from_path = relative_from.absolute_path || relative_from.path
30
+ dead_end_original_require File.expand_path("../#{file}", relative_from_path)
31
+ end
32
+ rescue SyntaxError => e
33
+ DeadEnd.handle_error(e)
34
+ end
35
+ end
@@ -7,7 +7,6 @@ module DeadEnd
7
7
  # even if it is "marked invisible" any filtering of
8
8
  # output should be done before calling this class.
9
9
  #
10
- #
11
10
  # DisplayCodeWithLineNumbers.new(
12
11
  # lines: lines,
13
12
  # highlight_lines: [lines[2], lines[3]]
@@ -23,10 +22,10 @@ module DeadEnd
23
22
  TERMINAL_HIGHLIGHT = "\e[1;3m" # Bold, italics
24
23
  TERMINAL_END = "\e[0m"
25
24
 
26
- def initialize(lines: , highlight_lines: [], terminal: false)
25
+ def initialize(lines:, highlight_lines: [], terminal: false)
27
26
  @lines = Array(lines).sort
28
27
  @terminal = terminal
29
- @highlight_line_hash = Array(highlight_lines).each_with_object({}) {|line, h| h[line] = true }
28
+ @highlight_line_hash = Array(highlight_lines).each_with_object({}) { |line, h| h[line] = true }
30
29
  @digit_count = @lines.last&.line_number.to_s.length
31
30
  end
32
31
 
@@ -48,12 +47,12 @@ module DeadEnd
48
47
  end.join
49
48
  end
50
49
 
51
- private def format(contents: , number: , highlight: false, empty:)
52
- string = String.new("")
53
- if highlight
54
- string << "❯ "
50
+ private def format(contents:, number:, empty:, highlight: false)
51
+ string = +""
52
+ string << if highlight
53
+ "❯ "
55
54
  else
56
- string << " "
55
+ " "
57
56
  end
58
57
 
59
58
  string << number.rjust(@digit_count).to_s
@@ -8,97 +8,67 @@ module DeadEnd
8
8
  class DisplayInvalidBlocks
9
9
  attr_reader :filename
10
10
 
11
- def initialize(code_lines: ,blocks:, io: $stderr, filename: nil, terminal: false, invalid_obj: WhoDisSyntaxError::Null.new)
12
- @terminal = terminal
13
- @filename = filename
11
+ def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: DEFAULT_VALUE)
14
12
  @io = io
15
-
16
13
  @blocks = Array(blocks)
17
-
18
- @invalid_lines = @blocks.map(&:lines).flatten
14
+ @filename = filename
19
15
  @code_lines = code_lines
20
16
 
21
- @invalid_obj = invalid_obj
17
+ @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal
18
+ end
19
+
20
+ def document_ok?
21
+ @blocks.none? { |b| !b.hidden? }
22
22
  end
23
23
 
24
24
  def call
25
- if @blocks.any? { |b| !b.hidden? }
26
- found_invalid_blocks
27
- else
25
+ if document_ok?
28
26
  @io.puts "Syntax OK"
27
+ return self
29
28
  end
30
- self
31
- end
32
29
 
33
- private def no_invalid_blocks
34
- @io.puts <<~EOM
35
- EOM
36
- end
37
-
38
- private def found_invalid_blocks
39
- @io.puts
40
- @io.puts banner
41
- @io.puts
42
- @io.puts("file: #{filename}") if filename
43
- @io.puts <<~EOM
44
- simplified:
30
+ if filename
31
+ @io.puts("--> #{filename}")
32
+ @io.puts
33
+ end
34
+ @blocks.each do |block|
35
+ display_block(block)
36
+ end
45
37
 
46
- #{indent(code_block)}
47
- EOM
38
+ self
48
39
  end
49
40
 
50
- def banner
51
- case @invalid_obj.error_symbol
52
- when :missing_end
53
- <<~EOM
54
- DeadEnd: Missing `end` detected
55
-
56
- This code has a missing `end`. Ensure that all
57
- syntax keywords (`def`, `do`, etc.) have a matching `end`.
58
- EOM
59
- when :unmatched_syntax
60
- case @invalid_obj.unmatched_symbol
61
- when :end
62
- <<~EOM
63
- DeadEnd: Unmatched `end` detected
64
-
65
- This code has an unmatched `end`. Ensure that all `end` lines
66
- in your code have a matching syntax keyword (`def`, `do`, etc.)
67
- and that you don't have any extra `end` lines.
68
- EOM
69
- when :|
70
- <<~EOM
71
- DeadEnd: Unmatched `|` character detected
41
+ private def display_block(block)
42
+ # Build explanation
43
+ explain = ExplainSyntax.new(
44
+ code_lines: block.lines
45
+ ).call
72
46
 
73
- Example:
47
+ # Enhance code output
48
+ # Also handles several ambiguious cases
49
+ lines = CaptureCodeContext.new(
50
+ blocks: block,
51
+ code_lines: @code_lines
52
+ ).call
74
53
 
75
- `do |x` should be `do |x|`
76
- EOM
77
- when :"}"
78
- <<~EOM
79
- DeadEnd: Unmatched `}` character detected
54
+ # Build code output
55
+ document = DisplayCodeWithLineNumbers.new(
56
+ lines: lines,
57
+ terminal: @terminal,
58
+ highlight_lines: block.lines
59
+ ).call
80
60
 
81
- This code has an unmatched `}`. Ensure that opening curl braces are
82
- closed: `{ }`.
83
- EOM
84
- else
85
- "DeadEnd: Unmatched `#{@invalid_obj.unmatched_symbol}` detected"
86
- end
61
+ # Output syntax error explanation
62
+ explain.errors.each do |e|
63
+ @io.puts e
87
64
  end
65
+ @io.puts
88
66
 
67
+ # Output code
68
+ @io.puts(document)
89
69
  end
90
70
 
91
- def indent(string, with: " ")
92
- string.each_line.map {|l| with + l }.join
93
- end
94
-
95
- def code_block
96
- string = String.new("")
97
- string << code_with_context
98
- string
99
- end
100
-
101
- def code_with_context
71
+ private def code_with_context
102
72
  lines = CaptureCodeContext.new(
103
73
  blocks: @blocks,
104
74
  code_lines: @code_lines
@@ -107,15 +77,7 @@ module DeadEnd
107
77
  DisplayCodeWithLineNumbers.new(
108
78
  lines: lines,
109
79
  terminal: @terminal,
110
- highlight_lines: @invalid_lines,
111
- ).call
112
- end
113
-
114
- def code_with_lines
115
- DisplayCodeWithLineNumbers.new(
116
- lines: @code_lines.select(&:visible?),
117
- terminal: @terminal,
118
- highlight_lines: @invalid_lines,
80
+ highlight_lines: @invalid_lines
119
81
  ).call
120
82
  end
121
83
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "left_right_lex_count"
4
+
5
+ module DeadEnd
6
+ # Explains syntax errors based on their source
7
+ #
8
+ # example:
9
+ #
10
+ # source = "def foo; puts 'lol'" # Note missing end
11
+ # explain ExplainSyntax.new(
12
+ # code_lines: CodeLine.from_source(source)
13
+ # ).call
14
+ # explain.errors.first
15
+ # # => "Unmatched keyword, missing `end' ?"
16
+ #
17
+ # When the error cannot be determined by lexical counting
18
+ # then ripper is run against the input and the raw ripper
19
+ # errors returned.
20
+ #
21
+ # Example:
22
+ #
23
+ # source = "1 * " # Note missing a second number
24
+ # explain ExplainSyntax.new(
25
+ # code_lines: CodeLine.from_source(source)
26
+ # ).call
27
+ # explain.errors.first
28
+ # # => "syntax error, unexpected end-of-input"
29
+ class ExplainSyntax
30
+ INVERSE = {
31
+ "{" => "}",
32
+ "}" => "{",
33
+ "[" => "]",
34
+ "]" => "[",
35
+ "(" => ")",
36
+ ")" => "(",
37
+ "|" => "|"
38
+ }.freeze
39
+
40
+ def initialize(code_lines:)
41
+ @code_lines = code_lines
42
+ @left_right = LeftRightLexCount.new
43
+ @missing = nil
44
+ end
45
+
46
+ def call
47
+ @code_lines.each do |line|
48
+ line.lex.each do |lex|
49
+ @left_right.count_lex(lex)
50
+ end
51
+ end
52
+
53
+ self
54
+ end
55
+
56
+ # Returns an array of missing elements
57
+ #
58
+ # For example this:
59
+ #
60
+ # ExplainSyntax.new(code_lines: lines).missing
61
+ # # => ["}"]
62
+ #
63
+ # Would indicate that the source is missing
64
+ # a `}` character in the source code
65
+ def missing
66
+ @missing ||= @left_right.missing
67
+ end
68
+
69
+ # Converts a missing string to
70
+ # an human understandable explanation.
71
+ #
72
+ # Example:
73
+ #
74
+ # explain.why("}")
75
+ # # => "Unmatched `{', missing `}' ?"
76
+ #
77
+ def why(miss)
78
+ case miss
79
+ when "keyword"
80
+ "Unmatched `end', missing keyword (`do', `def`, `if`, etc.) ?"
81
+ when "end"
82
+ "Unmatched keyword, missing `end' ?"
83
+ else
84
+ inverse = INVERSE.fetch(miss) {
85
+ raise "Unknown explain syntax char or key: #{miss.inspect}"
86
+ }
87
+ "Unmatched `#{inverse}', missing `#{miss}' ?"
88
+ end
89
+ end
90
+
91
+ # Returns an array of syntax error messages
92
+ #
93
+ # If no missing pairs are found it falls back
94
+ # on the original ripper error messages
95
+ def errors
96
+ if missing.empty?
97
+ return RipperErrors.new(@code_lines.map(&:original).join).call.errors
98
+ end
99
+
100
+ missing.map { |miss| why(miss) }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadEnd
4
+ # Sort elements on insert
5
+ #
6
+ # Instead of constantly calling `sort!`, put
7
+ # the element where it belongs the first time
8
+ # around
9
+ #
10
+ # Example:
11
+ #
12
+ # sorted = InsertionSort.new
13
+ # sorted << 33
14
+ # sorted << 44
15
+ # sorted << 1
16
+ # puts sorted.to_a
17
+ # # => [1, 44, 33]
18
+ #
19
+ class InsertionSort
20
+ def initialize
21
+ @array = []
22
+ end
23
+
24
+ def <<(value)
25
+ insert_in = @array.length
26
+ @array.each.with_index do |existing, index|
27
+ case value <=> existing
28
+ when -1
29
+ insert_in = index
30
+ break
31
+ when 0
32
+ insert_in = index
33
+ break
34
+ when 1
35
+ # Keep going
36
+ end
37
+ end
38
+
39
+ @array.insert(insert_in, value)
40
+ end
41
+
42
+ def to_a
43
+ @array
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadEnd
4
+ # Find mis-matched syntax based on lexical count
5
+ #
6
+ # Used for detecting missing pairs of elements
7
+ # each keyword needs an end, each '{' needs a '}'
8
+ # etc.
9
+ #
10
+ # Example:
11
+ #
12
+ # left_right = LeftRightLexCount.new
13
+ # left_right.count_kw
14
+ # left_right.missing.first
15
+ # # => "end"
16
+ #
17
+ # left_right = LeftRightLexCount.new
18
+ # source = "{ a: b, c: d" # Note missing '}'
19
+ # LexAll.new(source: source).each do |lex|
20
+ # left_right.count_lex(lex)
21
+ # end
22
+ # left_right.missing.first
23
+ # # => "}"
24
+ class LeftRightLexCount
25
+ def initialize
26
+ @kw_count = 0
27
+ @end_count = 0
28
+
29
+ @count_for_char = {
30
+ "{" => 0,
31
+ "}" => 0,
32
+ "[" => 0,
33
+ "]" => 0,
34
+ "(" => 0,
35
+ ")" => 0,
36
+ "|" => 0
37
+ }
38
+ end
39
+
40
+ def count_kw
41
+ @kw_count += 1
42
+ end
43
+
44
+ def count_end
45
+ @end_count += 1
46
+ end
47
+
48
+ # Count source code characters
49
+ #
50
+ # Example:
51
+ #
52
+ # left_right = LeftRightLexCount.new
53
+ # left_right.count_lex(LexValue.new(1, :on_lbrace, "{", Ripper::EXPR_BEG))
54
+ # left_right.count_for_char("{")
55
+ # # => 1
56
+ # left_right.count_for_char("}")
57
+ # # => 0
58
+ def count_lex(lex)
59
+ case lex.type
60
+ when :on_tstring_content
61
+ # ^^^
62
+ # Means it's a string or a symbol `"{"` rather than being
63
+ # part of a data structure (like a hash) `{ a: b }`
64
+ # ignore it.
65
+ when :on_words_beg, :on_symbos_beg, :on_qwords_beg,
66
+ :on_qsymbols_beg, :on_regexp_beg, :on_tstring_beg
67
+ # ^^^
68
+ # Handle shorthand syntaxes like `%Q{ i am a string }`
69
+ #
70
+ # The start token will be the full thing `%Q{` but we
71
+ # need to count it as if it's a `{`. Any token
72
+ # can be used
73
+ char = lex.token[-1]
74
+ @count_for_char[char] += 1 if @count_for_char.key?(char)
75
+ when :on_embexpr_beg
76
+ # ^^^
77
+ # Embedded string expressions like `"#{foo} <-embed"`
78
+ # are parsed with chars:
79
+ #
80
+ # `#{` as :on_embexpr_beg
81
+ # `}` as :on_embexpr_end
82
+ #
83
+ # We cannot ignore both :on_emb_expr_beg and :on_embexpr_end
84
+ # because sometimes the lexer thinks something is an embed
85
+ # string end, when it is not like `lol = }` (no clue why).
86
+ #
87
+ # When we see `#{` count it as a `{` or we will
88
+ # have a mis-match count.
89
+ #
90
+ case lex.token
91
+ when "\#{"
92
+ @count_for_char["{"] += 1
93
+ end
94
+ else
95
+ @end_count += 1 if lex.is_end?
96
+ @kw_count += 1 if lex.is_kw?
97
+ @count_for_char[lex.token] += 1 if @count_for_char.key?(lex.token)
98
+ end
99
+ end
100
+
101
+ def count_for_char(char)
102
+ @count_for_char[char]
103
+ end
104
+
105
+ # Returns an array of missing syntax characters
106
+ # or `"end"` or `"keyword"`
107
+ #
108
+ # left_right.missing
109
+ # # => ["}"]
110
+ def missing
111
+ out = missing_pairs
112
+ out << missing_pipe
113
+ out << missing_keyword_end
114
+ out.compact!
115
+ out
116
+ end
117
+
118
+ PAIRS = {
119
+ "{" => "}",
120
+ "[" => "]",
121
+ "(" => ")"
122
+ }.freeze
123
+
124
+ # Opening characters like `{` need closing characters # like `}`.
125
+ #
126
+ # When a mis-match count is detected, suggest the
127
+ # missing member.
128
+ #
129
+ # For example if there are 3 `}` and only two `{`
130
+ # return `"{"`
131
+ private def missing_pairs
132
+ PAIRS.map do |(left, right)|
133
+ case @count_for_char[left] <=> @count_for_char[right]
134
+ when 1
135
+ right
136
+ when 0
137
+ nil
138
+ when -1
139
+ left
140
+ end
141
+ end
142
+ end
143
+
144
+ # Keywords need ends and ends need keywords
145
+ #
146
+ # If we have more keywords, there's a missing `end`
147
+ # if we have more `end`-s, there's a missing keyword
148
+ private def missing_keyword_end
149
+ case @kw_count <=> @end_count
150
+ when 1
151
+ "end"
152
+ when 0
153
+ nil
154
+ when -1
155
+ "keyword"
156
+ end
157
+ end
158
+
159
+ # Pipes come in pairs.
160
+ # If there's an odd number of pipes then we
161
+ # are missing one
162
+ private def missing_pipe
163
+ if @count_for_char["|"].odd?
164
+ "|"
165
+ end
166
+ end
167
+ end
168
+ end