dead_end 2.0.2 → 3.0.3

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.
@@ -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,18 +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: DEFAULT_VALUE, invalid_obj: WhoDisSyntaxError::Null.new)
13
- @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal
14
-
15
- @filename = filename
11
+ def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: DEFAULT_VALUE)
16
12
  @io = io
17
-
18
13
  @blocks = Array(blocks)
19
-
20
- @invalid_lines = @blocks.map(&:lines).flatten
14
+ @filename = filename
21
15
  @code_lines = code_lines
22
16
 
23
- @invalid_obj = invalid_obj
17
+ @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal
24
18
  end
25
19
 
26
20
  def document_ok?
@@ -30,61 +24,58 @@ module DeadEnd
30
24
  def call
31
25
  if document_ok?
32
26
  @io.puts "Syntax OK"
33
- else
34
- found_invalid_blocks
27
+ return self
35
28
  end
36
- self
37
- end
38
29
 
39
- private def no_invalid_blocks
40
- @io.puts <<~EOM
41
- EOM
42
- end
43
-
44
- private def found_invalid_blocks
45
- @io.puts
46
- if banner
47
- @io.puts banner
30
+ if filename
31
+ @io.puts("--> #{filename}")
48
32
  @io.puts
49
33
  end
50
- @io.puts("file: #{filename}") if filename
51
- @io.puts <<~EOM
52
- simplified:
53
-
54
- #{indent(code_block)}
55
- EOM
56
- end
57
-
58
- def banner
59
- Banner.new(invalid_obj: @invalid_obj).call
60
- end
34
+ @blocks.each do |block|
35
+ display_block(block)
36
+ end
61
37
 
62
- def indent(string, with: " ")
63
- string.each_line.map { |l| with + l }.join
38
+ self
64
39
  end
65
40
 
66
- def code_block
67
- string = +""
68
- string << code_with_context
69
- string
70
- end
41
+ private def display_block(block)
42
+ # Build explanation
43
+ explain = ExplainSyntax.new(
44
+ code_lines: block.lines
45
+ ).call
71
46
 
72
- def code_with_context
47
+ # Enhance code output
48
+ # Also handles several ambiguious cases
73
49
  lines = CaptureCodeContext.new(
74
- blocks: @blocks,
50
+ blocks: block,
75
51
  code_lines: @code_lines
76
52
  ).call
77
53
 
78
- DisplayCodeWithLineNumbers.new(
54
+ # Build code output
55
+ document = DisplayCodeWithLineNumbers.new(
79
56
  lines: lines,
80
57
  terminal: @terminal,
81
- highlight_lines: @invalid_lines
58
+ highlight_lines: block.lines
82
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)
83
69
  end
84
70
 
85
- 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
+
86
77
  DisplayCodeWithLineNumbers.new(
87
- lines: @code_lines.select(&:visible?),
78
+ lines: lines,
88
79
  terminal: @terminal,
89
80
  highlight_lines: @invalid_lines
90
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,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