dead_end 2.0.0 → 3.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -70,8 +70,24 @@ module DeadEnd
70
70
  end
71
71
 
72
72
  def valid?
73
- return @valid if @valid != UNSET
74
- @valid = DeadEnd.valid?(to_s)
73
+ if @valid == UNSET
74
+ # Performance optimization
75
+ #
76
+ # If all the lines were previously hidden
77
+ # and we expand to capture additional empty
78
+ # lines then the result cannot be invalid
79
+ #
80
+ # That means there's no reason to re-check all
81
+ # lines with ripper (which is expensive).
82
+ # Benchmark in commit message
83
+ @valid = if lines.all? { |l| l.hidden? || l.empty? }
84
+ true
85
+ else
86
+ DeadEnd.valid?(lines.map(&:original).join)
87
+ end
88
+ else
89
+ @valid
90
+ end
75
91
  end
76
92
 
77
93
  def to_s
@@ -52,20 +52,44 @@ 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
+ @has_run = false
58
+ @check_next = true
57
59
  end
58
60
 
59
61
  def count
60
- @frontier.count
62
+ @frontier.to_a.length
63
+ end
64
+
65
+ # Performance optimization
66
+ #
67
+ # Parsing with ripper is expensive
68
+ # If we know we don't have any blocks with invalid
69
+ # syntax, then we know we cannot have found
70
+ # the incorrect syntax yet.
71
+ #
72
+ # When an invalid block is added onto the frontier
73
+ # check document state
74
+ private def can_skip_check?
75
+ check_next = @check_next
76
+ @check_next = false
77
+
78
+ if check_next
79
+ false
80
+ else
81
+ true
82
+ end
61
83
  end
62
84
 
63
85
  # Returns true if the document is valid with all lines
64
86
  # removed. By default it checks all blocks in present in
65
87
  # the frontier array, but can be used for arbitrary arrays
66
88
  # of codeblocks as well
67
- def holds_all_syntax_errors?(block_array = @frontier)
68
- without_lines = block_array.map do |block|
89
+ def holds_all_syntax_errors?(block_array = @frontier, can_cache: true)
90
+ return false if can_cache && can_skip_check?
91
+
92
+ without_lines = block_array.to_a.flat_map do |block|
69
93
  block.lines
70
94
  end
71
95
 
@@ -77,7 +101,7 @@ module DeadEnd
77
101
 
78
102
  # Returns a code block with the largest indentation possible
79
103
  def pop
80
- @frontier.pop
104
+ @frontier.to_a.pop
81
105
  end
82
106
 
83
107
  def next_indent_line
@@ -85,15 +109,15 @@ module DeadEnd
85
109
  end
86
110
 
87
111
  def expand?
88
- return false if @frontier.empty?
89
- return true if @unvisited_lines.empty?
112
+ return false if @frontier.to_a.empty?
113
+ return true if @unvisited_lines.to_a.empty?
90
114
 
91
- frontier_indent = @frontier.last.current_indent
115
+ frontier_indent = @frontier.to_a.last.current_indent
92
116
  unvisited_indent = next_indent_line.indent
93
117
 
94
118
  if ENV["DEBUG"]
95
119
  puts "```"
96
- puts @frontier.last.to_s
120
+ puts @frontier.to_a.last.to_s
97
121
  puts "```"
98
122
  puts " @frontier indent: #{frontier_indent}"
99
123
  puts " @unvisited indent: #{unvisited_indent}"
@@ -117,11 +141,13 @@ module DeadEnd
117
141
  register_indent_block(block)
118
142
 
119
143
  # Make sure we don't double expand, if a code block fully engulfs another code block, keep the bigger one
120
- @frontier.reject! { |b|
144
+ @frontier.to_a.reject! { |b|
121
145
  b.starts_at >= block.starts_at && b.ends_at <= block.ends_at
122
146
  }
147
+
148
+ @check_next = true if block.invalid?
123
149
  @frontier << block
124
- @frontier.sort!
150
+ # @frontier.sort!
125
151
 
126
152
  self
127
153
  end
@@ -141,8 +167,8 @@ module DeadEnd
141
167
  # Given that we know our syntax error exists somewhere in our frontier, we want to find
142
168
  # the smallest possible set of blocks that contain all the syntax errors
143
169
  def detect_invalid_blocks
144
- self.class.combination(@frontier.select(&:invalid?)).detect do |block_array|
145
- holds_all_syntax_errors?(block_array)
170
+ self.class.combination(@frontier.to_a.select(&:invalid?)).detect do |block_array|
171
+ holds_all_syntax_errors?(block_array, can_cache: false)
146
172
  end || []
147
173
  end
148
174
  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
 
@@ -8,98 +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
- if banner
41
- @io.puts banner
30
+ if filename
31
+ @io.puts("--> #{filename}")
42
32
  @io.puts
43
33
  end
44
- @io.puts("file: #{filename}") if filename
45
- @io.puts <<~EOM
46
- simplified:
34
+ @blocks.each do |block|
35
+ display_block(block)
36
+ end
47
37
 
48
- #{indent(code_block)}
49
- EOM
38
+ self
50
39
  end
51
40
 
52
- def banner
53
- case @invalid_obj.error_symbol
54
- when :missing_end
55
- <<~EOM
56
- DeadEnd: Missing `end` detected
57
-
58
- This code has a missing `end`. Ensure that all
59
- syntax keywords (`def`, `do`, etc.) have a matching `end`.
60
- EOM
61
- when :unmatched_syntax
62
- case @invalid_obj.unmatched_symbol
63
- when :end
64
- <<~EOM
65
- DeadEnd: Unmatched `end` detected
66
-
67
- This code has an unmatched `end`. Ensure that all `end` lines
68
- in your code have a matching syntax keyword (`def`, `do`, etc.)
69
- and that you don't have any extra `end` lines.
70
- EOM
71
- when :|
72
- <<~EOM
73
- DeadEnd: Unmatched `|` character detected
41
+ private def display_block(block)
42
+ # Build explanation
43
+ explain = ExplainSyntax.new(
44
+ code_lines: block.lines
45
+ ).call
74
46
 
75
- 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
76
53
 
77
- `do |x` should be `do |x|`
78
- EOM
79
- when :"}"
80
- <<~EOM
81
- 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
82
60
 
83
- This code has an unmatched `}`. Ensure that opening curly braces are
84
- closed: `{ }`.
85
- EOM
86
- else
87
- "DeadEnd: Unmatched `#{@invalid_obj.unmatched_symbol}` detected"
88
- end
61
+ # Output syntax error explanation
62
+ explain.errors.each do |e|
63
+ @io.puts e
89
64
  end
90
- end
91
-
92
- def indent(string, with: " ")
93
- string.each_line.map { |l| with + l }.join
94
- end
65
+ @io.puts
95
66
 
96
- def code_block
97
- string = +""
98
- string << code_with_context
99
- string
67
+ # Output code
68
+ @io.puts(document)
100
69
  end
101
70
 
102
- def code_with_context
71
+ private def code_with_context
103
72
  lines = CaptureCodeContext.new(
104
73
  blocks: @blocks,
105
74
  code_lines: @code_lines
@@ -111,13 +80,5 @@ module DeadEnd
111
80
  highlight_lines: @invalid_lines
112
81
  ).call
113
82
  end
114
-
115
- def code_with_lines
116
- DisplayCodeWithLineNumbers.new(
117
- lines: @code_lines.select(&:visible?),
118
- terminal: @terminal,
119
- highlight_lines: @invalid_lines
120
- ).call
121
- end
122
83
  end
123
84
  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,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
@@ -15,7 +15,7 @@ module DeadEnd
15
15
  last_lineno = source_lines.count
16
16
 
17
17
  until lineno >= last_lineno
18
- lines = source_lines[lineno..]
18
+ lines = source_lines[lineno..-1]
19
19
 
20
20
  @lex.concat(Ripper.lex(lines.join, "-", lineno + 1))
21
21
  lineno = @lex.last.first.first + 1
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadEnd
4
+ # Capture parse errors from ripper
5
+ #
6
+ # Example:
7
+ #
8
+ # puts RipperErrors.new(" def foo").call.errors
9
+ # # => ["syntax error, unexpected end-of-input, expecting ';' or '\\n'"]
10
+ class RipperErrors < Ripper
11
+ attr_reader :errors
12
+
13
+ # Comes from ripper, called
14
+ # on every parse error, msg
15
+ # is a string
16
+ def on_parse_error(msg)
17
+ @errors ||= []
18
+ @errors << msg
19
+ end
20
+
21
+ def call
22
+ @run_once ||= begin
23
+ @errors = []
24
+ parse
25
+ true
26
+ end
27
+ self
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadEnd
4
- VERSION = "2.0.0"
4
+ VERSION = "3.0.1"
5
5
  end