dead_end 1.2.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,44 +4,47 @@ module DeadEnd
4
4
  # Represents a single line of code of a given source file
5
5
  #
6
6
  # This object contains metadata about the line such as
7
- # amount of indentation. An if it is empty or not.
7
+ # amount of indentation, if it is empty or not, and
8
+ # lexical data, such as if it has an `end` or a keyword
9
+ # in it.
8
10
  #
9
- # While a given search for syntax errors is being performed
10
- # state about the search can be stored in individual lines such
11
- # as :valid or :invalid.
12
- #
13
- # Visibility of lines can be toggled on and off.
11
+ # Visibility of lines can be toggled off. Marking a line as invisible
12
+ # indicates that it should not be used for syntax checks.
13
+ # It's functionally the same as commenting it out.
14
14
  #
15
15
  # Example:
16
16
  #
17
- # line = CodeLine.new(line: "def foo\n", index: 0)
18
- # line.line_number => 1
17
+ # line = CodeLine.from_source("def foo\n").first
18
+ # line.number => 1
19
19
  # line.empty? # => false
20
20
  # line.visible? # => true
21
21
  # line.mark_invisible
22
22
  # line.visible? # => false
23
23
  #
24
- # A CodeBlock is made of multiple CodeLines
25
- #
26
- # Marking a line as invisible indicates that it should not be used
27
- # for syntax checks. It's essentially the same as commenting it out
28
- #
29
- # Marking a line as invisible also lets the overall program know
30
- # that it should not check that area for syntax errors.
31
24
  class CodeLine
32
25
  TRAILING_SLASH = ("\\" + $/).freeze
33
26
 
34
- def self.parse(source)
27
+ # Returns an array of CodeLine objects
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 }
35
31
  source.lines.map.with_index do |line, index|
36
- CodeLine.new(line: line, index: index)
32
+ CodeLine.new(
33
+ line: line,
34
+ index: index,
35
+ lex: lex_array_for_line[index + 1]
36
+ )
37
37
  end
38
38
  end
39
39
 
40
- attr_reader :line, :index, :indent, :original_line
40
+ attr_reader :line, :index, :lex, :line_number, :indent
41
+ def initialize(line:, index:, lex:)
42
+ @lex = lex
43
+ @line = line
44
+ @index = index
45
+ @original = line.freeze
46
+ @line_number = @index + 1
41
47
 
42
- def initialize(line:, index:)
43
- @original_line = line.freeze
44
- @line = @original_line
45
48
  if line.strip.empty?
46
49
  @empty = true
47
50
  @indent = 0
@@ -49,102 +52,182 @@ module DeadEnd
49
52
  @empty = false
50
53
  @indent = SpaceCount.indent(line)
51
54
  end
52
- @index = index
53
- @status = nil # valid, invalid, unknown
54
- @invalid = false
55
55
 
56
- lex_detect!
57
- end
58
-
59
- private def lex_detect!
60
- lex_array = LexAll.new(source: line)
61
56
  kw_count = 0
62
57
  end_count = 0
63
- lex_array.each_with_index do |lex, index|
64
- next unless lex.type == :on_kw
65
-
66
- case lex.token
67
- when "if", "unless", "while", "until"
68
- # Only count if/unless when it's not a "trailing" if/unless
69
- # https://github.com/ruby/ruby/blob/06b44f819eb7b5ede1ff69cecb25682b56a1d60c/lib/irb/ruby-lex.rb#L374-L375
70
- kw_count += 1 unless lex.expr_label?
71
- when "def", "case", "for", "begin", "class", "module", "do"
72
- kw_count += 1
73
- when "end"
74
- end_count += 1
75
- end
58
+ @lex.each do |lex|
59
+ kw_count += 1 if lex.is_kw?
60
+ end_count += 1 if lex.is_end?
76
61
  end
77
62
 
78
- @is_comment = lex_array.detect { |lex| lex.type != :on_sp }&.type == :on_comment
79
- return if @is_comment
63
+ kw_count -= oneliner_method_count
64
+
80
65
  @is_kw = (kw_count - end_count) > 0
81
66
  @is_end = (end_count - kw_count) > 0
82
- @is_trailing_slash = lex_array.last.token == TRAILING_SLASH
83
- end
84
-
85
- alias_method :original, :original_line
86
-
87
- def trailing_slash?
88
- @is_trailing_slash
89
67
  end
90
68
 
69
+ # Used for stable sort via indentation level
70
+ #
71
+ # Ruby's sort is not "stable" meaning that when
72
+ # multiple elements have the same value, they are
73
+ # not guaranteed to return in the same order they
74
+ # were put in.
75
+ #
76
+ # So when multiple code lines have the same indentation
77
+ # level, they're sorted by their index value which is unique
78
+ # and consistent.
79
+ #
80
+ # This is mostly needed for consistency of the test suite
91
81
  def indent_index
92
82
  @indent_index ||= [indent, index]
93
83
  end
84
+ alias_method :number, :line_number
94
85
 
95
- def <=>(other)
96
- index <=> other.index
97
- end
98
-
99
- def is_comment?
100
- @is_comment
101
- end
102
-
103
- def not_comment?
104
- !is_comment?
105
- end
106
-
86
+ # Returns true if the code line is determined
87
+ # to contain a keyword that matches with an `end`
88
+ #
89
+ # For example: `def`, `do`, `begin`, `ensure`, etc.
107
90
  def is_kw?
108
91
  @is_kw
109
92
  end
110
93
 
94
+ # Returns true if the code line is determined
95
+ # to contain an `end` keyword
111
96
  def is_end?
112
97
  @is_end
113
98
  end
114
99
 
100
+ # Used to hide lines
101
+ #
102
+ # The search alorithm will group lines into blocks
103
+ # then if those blocks are determined to represent
104
+ # valid code they will be hidden
115
105
  def mark_invisible
116
106
  @line = ""
117
- self
118
- end
119
-
120
- def mark_visible
121
- @line = @original_line
122
- self
123
107
  end
124
108
 
109
+ # Means the line was marked as "invisible"
110
+ # Confusingly, "empty" lines are visible...they
111
+ # just don't contain any source code other than a newline ("\n").
125
112
  def visible?
126
113
  !line.empty?
127
114
  end
128
115
 
116
+ # Opposite or `visible?` (note: different than `empty?`)
129
117
  def hidden?
130
118
  !visible?
131
119
  end
132
120
 
133
- def line_number
134
- index + 1
121
+ # An `empty?` line is one that was originally left
122
+ # empty in the source code, while a "hidden" line
123
+ # is one that we've since marked as "invisible"
124
+ def empty?
125
+ @empty
135
126
  end
136
- alias_method :number, :line_number
137
127
 
128
+ # Opposite of `empty?` (note: different than `visible?`)
138
129
  def not_empty?
139
130
  !empty?
140
131
  end
141
132
 
142
- def empty?
143
- @empty
144
- end
145
-
133
+ # Renders the given line
134
+ #
135
+ # Also allows us to represent source code as
136
+ # an array of code lines.
137
+ #
138
+ # When we have an array of code line elements
139
+ # calling `join` on the array will call `to_s`
140
+ # on each element, which essentially converts
141
+ # it back into it's original source string.
146
142
  def to_s
147
143
  line
148
144
  end
145
+
146
+ # When the code line is marked invisible
147
+ # we retain the original value of it's line
148
+ # this is useful for debugging and for
149
+ # showing extra context
150
+ #
151
+ # DisplayCodeWithLineNumbers will render
152
+ # all lines given to it, not just visible
153
+ # lines, it uses the original method to
154
+ # obtain them.
155
+ attr_reader :original
156
+
157
+ # Comparison operator, needed for equality
158
+ # and sorting
159
+ def <=>(other)
160
+ index <=> other.index
161
+ end
162
+
163
+ # [Not stable API]
164
+ #
165
+ # Lines that have a `on_ignored_nl` type token and NOT
166
+ # a `BEG` type seem to be a good proxy for the ability
167
+ # to join multiple lines into one.
168
+ #
169
+ # This predicate method is used to determine when those
170
+ # two criteria have been met.
171
+ #
172
+ # The one known case this doesn't handle is:
173
+ #
174
+ # Ripper.lex <<~EOM
175
+ # a &&
176
+ # b ||
177
+ # c
178
+ # EOM
179
+ #
180
+ # For some reason this introduces `on_ignore_newline` but with BEG type
181
+ def ignore_newline_not_beg?
182
+ lex_value = lex.detect { |l| l.type == :on_ignored_nl }
183
+ !!(lex_value && !lex_value.expr_beg?)
184
+ end
185
+
186
+ # Determines if the given line has a trailing slash
187
+ #
188
+ # lines = CodeLine.from_source(<<~EOM)
189
+ # it "foo" \
190
+ # EOM
191
+ # expect(lines.first.trailing_slash?).to eq(true)
192
+ #
193
+ def trailing_slash?
194
+ last = @lex.last
195
+ return false unless last
196
+ return false unless last.type == :on_sp
197
+
198
+ last.token == TRAILING_SLASH
199
+ end
200
+
201
+ # Endless method detection
202
+ #
203
+ # From https://github.com/ruby/irb/commit/826ae909c9c93a2ddca6f9cfcd9c94dbf53d44ab
204
+ # Detecting a "oneliner" seems to need a state machine.
205
+ # This can be done by looking mostly at the "state" (last value):
206
+ #
207
+ # ENDFN -> BEG (token = '=' ) -> END
208
+ #
209
+ private def oneliner_method_count
210
+ oneliner_count = 0
211
+ in_oneliner_def = nil
212
+
213
+ @lex.each do |lex|
214
+ if in_oneliner_def.nil?
215
+ in_oneliner_def = :ENDFN if lex.state.allbits?(Ripper::EXPR_ENDFN)
216
+ elsif lex.state.allbits?(Ripper::EXPR_ENDFN)
217
+ # Continue
218
+ elsif lex.state.allbits?(Ripper::EXPR_BEG)
219
+ in_oneliner_def = :BODY if lex.token == "="
220
+ elsif lex.state.allbits?(Ripper::EXPR_END)
221
+ # We found an endless method, count it
222
+ oneliner_count += 1 if in_oneliner_def == :BODY
223
+
224
+ in_oneliner_def = nil
225
+ else
226
+ in_oneliner_def = nil
227
+ end
228
+ end
229
+
230
+ oneliner_count
231
+ end
149
232
  end
150
233
  end
@@ -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
  #
@@ -31,28 +39,24 @@ module DeadEnd
31
39
 
32
40
  public
33
41
 
34
- public
35
-
36
42
  attr_reader :invalid_blocks, :record_dir, :code_lines
37
43
 
38
44
  def initialize(source, record_dir: ENV["DEAD_END_RECORD_DIR"] || ENV["DEBUG"] ? "tmp" : nil)
39
- @source = source
40
45
  if record_dir
41
46
  @time = Time.now.strftime("%Y-%m-%d-%H-%M-%s-%N")
42
47
  @record_dir = Pathname(record_dir).join(@time).tap { |p| p.mkpath }
43
48
  @write_count = 0
44
49
  end
45
- code_lines = source.lines.map.with_index do |line, i|
46
- CodeLine.new(line: line, index: i)
47
- end
48
50
 
49
- @code_lines = TrailingSlashJoin.new(code_lines: code_lines).call
51
+ @tick = 0
52
+ @source = source
53
+ @name_tick = Hash.new { |hash, k| hash[k] = 0 }
54
+ @invalid_blocks = []
55
+
56
+ @code_lines = CleanDocument.new(source: source).call.lines
50
57
 
51
58
  @frontier = CodeFrontier.new(code_lines: @code_lines)
52
- @invalid_blocks = []
53
- @name_tick = Hash.new { |hash, k| hash[k] = 0 }
54
- @tick = 0
55
- @block_expand = BlockExpand.new(code_lines: code_lines)
59
+ @block_expand = BlockExpand.new(code_lines: @code_lines)
56
60
  @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines)
57
61
  end
58
62
 
@@ -63,18 +67,19 @@ module DeadEnd
63
67
  filename = "#{@write_count += 1}-#{name}-#{@name_tick[name]}.txt"
64
68
  if ENV["DEBUG"]
65
69
  puts "\n\n==== #{filename} ===="
66
- puts "\n```#{block.starts_at}:#{block.ends_at}"
70
+ puts "\n```#{block.starts_at}..#{block.ends_at}"
67
71
  puts block.to_s
68
72
  puts "```"
69
- puts " block indent: #{block.current_indent}"
73
+ puts " block indent: #{block.current_indent}"
70
74
  end
71
75
  @record_dir.join(filename).open(mode: "a") do |f|
72
- display = DisplayInvalidBlocks.new(
73
- blocks: block,
76
+ document = DisplayCodeWithLineNumbers.new(
77
+ lines: @code_lines.select(&:visible?),
74
78
  terminal: false,
75
- code_lines: @code_lines
76
- )
77
- f.write(display.indent(display.code_with_lines))
79
+ highlight_lines: block.lines
80
+ ).call
81
+
82
+ f.write(document)
78
83
  end
79
84
  end
80
85
 
@@ -122,26 +127,8 @@ module DeadEnd
122
127
  push(block, name: "expand")
123
128
  end
124
129
 
125
- def sweep_heredocs
126
- HeredocBlockParse.new(
127
- source: @source,
128
- code_lines: @code_lines
129
- ).call.each do |block|
130
- push(block, name: "heredoc")
131
- end
132
- end
133
-
134
- def sweep_comments
135
- lines = @code_lines.select(&:is_comment?)
136
- return if lines.empty?
137
- block = CodeBlock.new(lines: lines)
138
- sweep(block: block, name: "comments")
139
- end
140
-
141
130
  # Main search loop
142
131
  def call
143
- sweep_heredocs
144
- sweep_comments
145
132
  until frontier.holds_all_syntax_errors?
146
133
  @tick += 1
147
134
 
@@ -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]]
@@ -8,96 +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
- @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
- #{indent(code_block)}
47
- EOM
38
+ self
48
39
  end
49
40
 
50
- def banner
51
- case @invalid_obj.error_symbol
52
- when :missing_end
53
- <<~EOM
54
- DeadEnd: Missing `end` detected
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
- 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
74
53
 
75
- `do |x` should be `do |x|`
76
- EOM
77
- when :"}"
78
- <<~EOM
79
- 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
80
60
 
81
- This code has an unmatched `}`. Ensure that opening curly braces are
82
- closed: `{ }`.
83
- EOM
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
88
- end
89
-
90
- def indent(string, with: " ")
91
- string.each_line.map { |l| with + l }.join
92
- end
65
+ @io.puts
93
66
 
94
- def code_block
95
- string = +""
96
- string << code_with_context
97
- string
67
+ # Output code
68
+ @io.puts(document)
98
69
  end
99
70
 
100
- def code_with_context
71
+ private def code_with_context
101
72
  lines = CaptureCodeContext.new(
102
73
  blocks: @blocks,
103
74
  code_lines: @code_lines
@@ -109,13 +80,5 @@ module DeadEnd
109
80
  highlight_lines: @invalid_lines
110
81
  ).call
111
82
  end
112
-
113
- def code_with_lines
114
- DisplayCodeWithLineNumbers.new(
115
- lines: @code_lines.select(&:visible?),
116
- terminal: @terminal,
117
- highlight_lines: @invalid_lines
118
- ).call
119
- end
120
83
  end
121
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