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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +27 -1
- data/.github/workflows/check_changelog.yml +14 -7
- data/.standard.yml +1 -0
- data/CHANGELOG.md +60 -0
- data/CODE_OF_CONDUCT.md +2 -2
- data/Gemfile +2 -0
- data/Gemfile.lock +31 -2
- data/README.md +122 -35
- data/Rakefile +1 -1
- data/dead_end.gemspec +12 -12
- data/exe/dead_end +4 -67
- data/lib/dead_end/{internals.rb → api.rb} +90 -52
- data/lib/dead_end/around_block_scan.rb +16 -18
- data/lib/dead_end/auto.rb +3 -101
- data/lib/dead_end/block_expand.rb +6 -5
- data/lib/dead_end/capture_code_context.rb +167 -50
- data/lib/dead_end/clean_document.rb +304 -0
- data/lib/dead_end/cli.rb +129 -0
- data/lib/dead_end/code_block.rb +20 -4
- data/lib/dead_end/code_frontier.rb +74 -29
- data/lib/dead_end/code_line.rb +176 -87
- data/lib/dead_end/code_search.rb +40 -51
- data/lib/dead_end/core_ext.rb +35 -0
- data/lib/dead_end/display_code_with_line_numbers.rb +7 -8
- data/lib/dead_end/display_invalid_blocks.rb +42 -80
- data/lib/dead_end/explain_syntax.rb +103 -0
- data/lib/dead_end/insertion_sort.rb +46 -0
- data/lib/dead_end/left_right_lex_count.rb +168 -0
- data/lib/dead_end/lex_all.rb +25 -34
- data/lib/dead_end/lex_value.rb +70 -0
- data/lib/dead_end/parse_blocks_from_indent_line.rb +3 -4
- data/lib/dead_end/pathname_from_message.rb +47 -0
- data/lib/dead_end/ripper_errors.rb +36 -0
- data/lib/dead_end/version.rb +1 -1
- data/lib/dead_end.rb +2 -2
- metadata +14 -9
- data/.travis.yml +0 -6
- data/lib/dead_end/fyi.rb +0 -7
- data/lib/dead_end/heredoc_block_parse.rb +0 -30
- data/lib/dead_end/trailing_slash_join.rb +0 -53
- data/lib/dead_end/who_dis_syntax_error.rb +0 -69
data/lib/dead_end/code_search.rb
CHANGED
@@ -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
|
29
|
-
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
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}
|
60
|
-
puts
|
69
|
+
puts "\n```#{block.starts_at}..#{block.ends_at}"
|
70
|
+
puts block.to_s
|
61
71
|
puts "```"
|
62
|
-
puts " block indent:
|
72
|
+
puts " block indent: #{block.current_indent}"
|
63
73
|
end
|
64
74
|
@record_dir.join(filename).open(mode: "a") do |f|
|
65
|
-
|
66
|
-
|
75
|
+
document = DisplayCodeWithLineNumbers.new(
|
76
|
+
lines: @code_lines.select(&:visible?),
|
67
77
|
terminal: false,
|
68
|
-
|
69
|
-
)
|
70
|
-
|
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
|
-
|
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
|
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
|
52
|
-
string =
|
53
|
-
if highlight
|
54
|
-
|
50
|
+
private def format(contents:, number:, empty:, highlight: false)
|
51
|
+
string = +""
|
52
|
+
string << if highlight
|
53
|
+
"❯ "
|
55
54
|
else
|
56
|
-
|
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
|
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
|
-
@
|
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
|
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
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
47
|
-
EOM
|
38
|
+
self
|
48
39
|
end
|
49
40
|
|
50
|
-
def
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
54
|
+
# Build code output
|
55
|
+
document = DisplayCodeWithLineNumbers.new(
|
56
|
+
lines: lines,
|
57
|
+
terminal: @terminal,
|
58
|
+
highlight_lines: block.lines
|
59
|
+
).call
|
80
60
|
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
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
|