dead_end 2.0.0 → 3.0.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.
@@ -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