dead_end 1.1.7 → 3.1.1

Sign up to get free protection for your applications and to get access to all the features.
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