dead_end 2.0.1 → 3.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -52,20 +52,46 @@ module DeadEnd
52
52
  class CodeFrontier
53
53
  def initialize(code_lines:)
54
54
  @code_lines = code_lines
55
- @frontier = []
55
+ @frontier = InsertionSort.new
56
56
  @unvisited_lines = @code_lines.sort_by(&:indent_index)
57
+ @visited_lines = {}
58
+
59
+ @has_run = false
60
+ @check_next = true
57
61
  end
58
62
 
59
63
  def count
60
- @frontier.count
64
+ @frontier.to_a.length
65
+ end
66
+
67
+ # Performance optimization
68
+ #
69
+ # Parsing with ripper is expensive
70
+ # If we know we don't have any blocks with invalid
71
+ # syntax, then we know we cannot have found
72
+ # the incorrect syntax yet.
73
+ #
74
+ # When an invalid block is added onto the frontier
75
+ # check document state
76
+ private def can_skip_check?
77
+ check_next = @check_next
78
+ @check_next = false
79
+
80
+ if check_next
81
+ false
82
+ else
83
+ true
84
+ end
61
85
  end
62
86
 
63
87
  # Returns true if the document is valid with all lines
64
88
  # removed. By default it checks all blocks in present in
65
89
  # the frontier array, but can be used for arbitrary arrays
66
90
  # of codeblocks as well
67
- def holds_all_syntax_errors?(block_array = @frontier)
68
- without_lines = block_array.map do |block|
91
+ def holds_all_syntax_errors?(block_array = @frontier, can_cache: true)
92
+ return false if can_cache && can_skip_check?
93
+
94
+ without_lines = block_array.to_a.flat_map do |block|
69
95
  block.lines
70
96
  end
71
97
 
@@ -77,7 +103,7 @@ module DeadEnd
77
103
 
78
104
  # Returns a code block with the largest indentation possible
79
105
  def pop
80
- @frontier.pop
106
+ @frontier.to_a.pop
81
107
  end
82
108
 
83
109
  def next_indent_line
@@ -85,15 +111,15 @@ module DeadEnd
85
111
  end
86
112
 
87
113
  def expand?
88
- return false if @frontier.empty?
89
- return true if @unvisited_lines.empty?
114
+ return false if @frontier.to_a.empty?
115
+ return true if @unvisited_lines.to_a.empty?
90
116
 
91
- frontier_indent = @frontier.last.current_indent
117
+ frontier_indent = @frontier.to_a.last.current_indent
92
118
  unvisited_indent = next_indent_line.indent
93
119
 
94
120
  if ENV["DEBUG"]
95
121
  puts "```"
96
- puts @frontier.last.to_s
122
+ puts @frontier.to_a.last.to_s
97
123
  puts "```"
98
124
  puts " @frontier indent: #{frontier_indent}"
99
125
  puts " @unvisited indent: #{unvisited_indent}"
@@ -104,7 +130,13 @@ module DeadEnd
104
130
  end
105
131
 
106
132
  def register_indent_block(block)
107
- @unvisited_lines -= block.lines
133
+ block.lines.each do |line|
134
+ next if @visited_lines[line]
135
+ @visited_lines[line] = true
136
+
137
+ index = @unvisited_lines.bsearch_index { |l| line.indent_index <=> l.indent_index }
138
+ @unvisited_lines.delete_at(index)
139
+ end
108
140
  self
109
141
  end
110
142
 
@@ -117,11 +149,13 @@ module DeadEnd
117
149
  register_indent_block(block)
118
150
 
119
151
  # Make sure we don't double expand, if a code block fully engulfs another code block, keep the bigger one
120
- @frontier.reject! { |b|
152
+ @frontier.to_a.reject! { |b|
121
153
  b.starts_at >= block.starts_at && b.ends_at <= block.ends_at
122
154
  }
155
+
156
+ @check_next = true if block.invalid?
123
157
  @frontier << block
124
- @frontier.sort!
158
+ # @frontier.sort!
125
159
 
126
160
  self
127
161
  end
@@ -141,8 +175,8 @@ module DeadEnd
141
175
  # Given that we know our syntax error exists somewhere in our frontier, we want to find
142
176
  # the smallest possible set of blocks that contain all the syntax errors
143
177
  def detect_invalid_blocks
144
- self.class.combination(@frontier.select(&:invalid?)).detect do |block_array|
145
- holds_all_syntax_errors?(block_array)
178
+ self.class.combination(@frontier.to_a.select(&:invalid?)).detect do |block_array|
179
+ holds_all_syntax_errors?(block_array, can_cache: false)
146
180
  end || []
147
181
  end
148
182
  end
@@ -26,9 +26,10 @@ module DeadEnd
26
26
 
27
27
  # Returns an array of CodeLine objects
28
28
  # from the source string
29
- def self.from_source(source)
30
- lex_array_for_line = LexAll.new(source: source).each_with_object(Hash.new { |h, k| h[k] = [] }) { |lex, hash| hash[lex.line] << lex }
31
- source.lines.map.with_index do |line, index|
29
+ def self.from_source(source, lines: nil)
30
+ lines ||= source.lines
31
+ lex_array_for_line = LexAll.new(source: source, source_lines: lines).each_with_object(Hash.new { |h, k| h[k] = [] }) { |lex, hash| hash[lex.line] << lex }
32
+ lines.map.with_index do |line, index|
32
33
  CodeLine.new(
33
34
  line: line,
34
35
  index: index,
@@ -42,28 +43,20 @@ module DeadEnd
42
43
  @lex = lex
43
44
  @line = line
44
45
  @index = index
45
- @original = line.freeze
46
+ @original = line
46
47
  @line_number = @index + 1
48
+ strip_line = line.dup
49
+ strip_line.lstrip!
47
50
 
48
- if line.strip.empty?
51
+ if strip_line.empty?
49
52
  @empty = true
50
53
  @indent = 0
51
54
  else
52
55
  @empty = false
53
- @indent = SpaceCount.indent(line)
56
+ @indent = line.length - strip_line.length
54
57
  end
55
58
 
56
- kw_count = 0
57
- end_count = 0
58
- @lex.each do |lex|
59
- kw_count += 1 if lex.is_kw?
60
- end_count += 1 if lex.is_end?
61
- end
62
-
63
- kw_count -= oneliner_method_count
64
-
65
- @is_kw = (kw_count - end_count) > 0
66
- @is_end = (end_count - kw_count) > 0
59
+ set_kw_end
67
60
  end
68
61
 
69
62
  # Used for stable sort via indentation level
@@ -179,8 +172,7 @@ module DeadEnd
179
172
  #
180
173
  # For some reason this introduces `on_ignore_newline` but with BEG type
181
174
  def ignore_newline_not_beg?
182
- lex_value = lex.detect { |l| l.type == :on_ignored_nl }
183
- !!(lex_value && !lex_value.expr_beg?)
175
+ @ignore_newline_not_beg
184
176
  end
185
177
 
186
178
  # Determines if the given line has a trailing slash
@@ -206,11 +198,22 @@ module DeadEnd
206
198
  #
207
199
  # ENDFN -> BEG (token = '=' ) -> END
208
200
  #
209
- private def oneliner_method_count
201
+ private def set_kw_end
210
202
  oneliner_count = 0
211
203
  in_oneliner_def = nil
212
204
 
205
+ kw_count = 0
206
+ end_count = 0
207
+
208
+ @ignore_newline_not_beg = false
213
209
  @lex.each do |lex|
210
+ kw_count += 1 if lex.is_kw?
211
+ end_count += 1 if lex.is_end?
212
+
213
+ if lex.type == :on_ignored_nl
214
+ @ignore_newline_not_beg = !lex.expr_beg?
215
+ end
216
+
214
217
  if in_oneliner_def.nil?
215
218
  in_oneliner_def = :ENDFN if lex.state.allbits?(Ripper::EXPR_ENDFN)
216
219
  elsif lex.state.allbits?(Ripper::EXPR_ENDFN)
@@ -227,7 +230,10 @@ module DeadEnd
227
230
  end
228
231
  end
229
232
 
230
- oneliner_count
233
+ kw_count -= oneliner_count
234
+
235
+ @is_kw = (kw_count - end_count) > 0
236
+ @is_end = (end_count - kw_count) > 0
231
237
  end
232
238
  end
233
239
  end
@@ -43,8 +43,7 @@ module DeadEnd
43
43
 
44
44
  def initialize(source, record_dir: ENV["DEAD_END_RECORD_DIR"] || ENV["DEBUG"] ? "tmp" : nil)
45
45
  if record_dir
46
- @time = Time.now.strftime("%Y-%m-%d-%H-%M-%s-%N")
47
- @record_dir = Pathname(record_dir).join(@time).tap { |p| p.mkpath }
46
+ @record_dir = DeadEnd.record_dir(record_dir)
48
47
  @write_count = 0
49
48
  end
50
49
 
@@ -73,12 +72,13 @@ module DeadEnd
73
72
  puts " block indent: #{block.current_indent}"
74
73
  end
75
74
  @record_dir.join(filename).open(mode: "a") do |f|
76
- display = DisplayInvalidBlocks.new(
77
- blocks: block,
75
+ document = DisplayCodeWithLineNumbers.new(
76
+ lines: @code_lines.select(&:visible?),
78
77
  terminal: false,
79
- code_lines: @code_lines
80
- )
81
- f.write(display.indent(display.code_with_lines))
78
+ highlight_lines: block.lines
79
+ ).call
80
+
81
+ f.write(document)
82
82
  end
83
83
  end
84
84
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "banner"
4
3
  require_relative "capture_code_context"
5
4
  require_relative "display_code_with_line_numbers"
6
5
 
@@ -9,17 +8,13 @@ module DeadEnd
9
8
  class DisplayInvalidBlocks
10
9
  attr_reader :filename
11
10
 
12
- def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: false, invalid_obj: WhoDisSyntaxError::Null.new)
13
- @terminal = terminal
14
- @filename = filename
11
+ def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: DEFAULT_VALUE)
15
12
  @io = io
16
-
17
13
  @blocks = Array(blocks)
18
-
19
- @invalid_lines = @blocks.map(&:lines).flatten
14
+ @filename = filename
20
15
  @code_lines = code_lines
21
16
 
22
- @invalid_obj = invalid_obj
17
+ @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal
23
18
  end
24
19
 
25
20
  def document_ok?
@@ -29,61 +24,58 @@ module DeadEnd
29
24
  def call
30
25
  if document_ok?
31
26
  @io.puts "Syntax OK"
32
- else
33
- found_invalid_blocks
27
+ return self
34
28
  end
35
- self
36
- end
37
-
38
- private def no_invalid_blocks
39
- @io.puts <<~EOM
40
- EOM
41
- end
42
29
 
43
- private def found_invalid_blocks
44
- @io.puts
45
- if banner
46
- @io.puts banner
30
+ if filename
31
+ @io.puts("--> #{filename}")
47
32
  @io.puts
48
33
  end
49
- @io.puts("file: #{filename}") if filename
50
- @io.puts <<~EOM
51
- simplified:
52
-
53
- #{indent(code_block)}
54
- EOM
55
- end
56
-
57
- def banner
58
- Banner.new(invalid_obj: @invalid_obj).call
59
- end
34
+ @blocks.each do |block|
35
+ display_block(block)
36
+ end
60
37
 
61
- def indent(string, with: " ")
62
- string.each_line.map { |l| with + l }.join
38
+ self
63
39
  end
64
40
 
65
- def code_block
66
- string = +""
67
- string << code_with_context
68
- string
69
- end
41
+ private def display_block(block)
42
+ # Build explanation
43
+ explain = ExplainSyntax.new(
44
+ code_lines: block.lines
45
+ ).call
70
46
 
71
- def code_with_context
47
+ # Enhance code output
48
+ # Also handles several ambiguious cases
72
49
  lines = CaptureCodeContext.new(
73
- blocks: @blocks,
50
+ blocks: block,
74
51
  code_lines: @code_lines
75
52
  ).call
76
53
 
77
- DisplayCodeWithLineNumbers.new(
54
+ # Build code output
55
+ document = DisplayCodeWithLineNumbers.new(
78
56
  lines: lines,
79
57
  terminal: @terminal,
80
- highlight_lines: @invalid_lines
58
+ highlight_lines: block.lines
81
59
  ).call
60
+
61
+ # Output syntax error explanation
62
+ explain.errors.each do |e|
63
+ @io.puts e
64
+ end
65
+ @io.puts
66
+
67
+ # Output code
68
+ @io.puts(document)
82
69
  end
83
70
 
84
- def code_with_lines
71
+ private def code_with_context
72
+ lines = CaptureCodeContext.new(
73
+ blocks: @blocks,
74
+ code_lines: @code_lines
75
+ ).call
76
+
85
77
  DisplayCodeWithLineNumbers.new(
86
- lines: @code_lines.select(&:visible?),
78
+ lines: lines,
87
79
  terminal: @terminal,
88
80
  highlight_lines: @invalid_lines
89
81
  ).call
@@ -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,157 @@
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
+ when :on_embexpr_beg
65
+ # ^^^
66
+ # Embedded string expressions like `"#{foo} <-embed"`
67
+ # are parsed with chars:
68
+ #
69
+ # `#{` as :on_embexpr_beg
70
+ # `}` as :on_embexpr_end
71
+ #
72
+ # We cannot ignore both :on_emb_expr_beg and :on_embexpr_end
73
+ # because sometimes the lexer thinks something is an embed
74
+ # string end, when it is not like `lol = }` (no clue why).
75
+ #
76
+ # When we see `#{` count it as a `{` or we will
77
+ # have a mis-match count.
78
+ #
79
+ case lex.token
80
+ when "\#{"
81
+ @count_for_char["{"] += 1
82
+ end
83
+ else
84
+ @end_count += 1 if lex.is_end?
85
+ @kw_count += 1 if lex.is_kw?
86
+ @count_for_char[lex.token] += 1 if @count_for_char.key?(lex.token)
87
+ end
88
+ end
89
+
90
+ def count_for_char(char)
91
+ @count_for_char[char]
92
+ end
93
+
94
+ # Returns an array of missing syntax characters
95
+ # or `"end"` or `"keyword"`
96
+ #
97
+ # left_right.missing
98
+ # # => ["}"]
99
+ def missing
100
+ out = missing_pairs
101
+ out << missing_pipe
102
+ out << missing_keyword_end
103
+ out.compact!
104
+ out
105
+ end
106
+
107
+ PAIRS = {
108
+ "{" => "}",
109
+ "[" => "]",
110
+ "(" => ")"
111
+ }.freeze
112
+
113
+ # Opening characters like `{` need closing characters # like `}`.
114
+ #
115
+ # When a mis-match count is detected, suggest the
116
+ # missing member.
117
+ #
118
+ # For example if there are 3 `}` and only two `{`
119
+ # return `"{"`
120
+ private def missing_pairs
121
+ PAIRS.map do |(left, right)|
122
+ case @count_for_char[left] <=> @count_for_char[right]
123
+ when 1
124
+ right
125
+ when 0
126
+ nil
127
+ when -1
128
+ left
129
+ end
130
+ end
131
+ end
132
+
133
+ # Keywords need ends and ends need keywords
134
+ #
135
+ # If we have more keywords, there's a missing `end`
136
+ # if we have more `end`-s, there's a missing keyword
137
+ private def missing_keyword_end
138
+ case @kw_count <=> @end_count
139
+ when 1
140
+ "end"
141
+ when 0
142
+ nil
143
+ when -1
144
+ "keyword"
145
+ end
146
+ end
147
+
148
+ # Pipes come in pairs.
149
+ # If there's an odd number of pipes then we
150
+ # are missing one
151
+ private def missing_pipe
152
+ if @count_for_char["|"].odd?
153
+ "|"
154
+ end
155
+ end
156
+ end
157
+ end