dead_end 1.2.0 → 3.0.0

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.
@@ -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