dead_end 2.0.2 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,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
@@ -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.2"
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/dead_end.rb CHANGED
@@ -1,4 +1,148 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "dead_end/internals"
3
+ require_relative "dead_end/version"
4
+
5
+ require "tmpdir"
6
+ require "stringio"
7
+ require "pathname"
8
+ require "ripper"
9
+ require "timeout"
10
+
11
+ module DeadEnd
12
+ # Used to indicate a default value that cannot
13
+ # be confused with another input
14
+ DEFAULT_VALUE = Object.new.freeze
15
+
16
+ class Error < StandardError; end
17
+ TIMEOUT_DEFAULT = ENV.fetch("DEAD_END_TIMEOUT", 1).to_i
18
+
19
+ def self.handle_error(e)
20
+ filename = e.message.split(":").first
21
+ $stderr.sync = true
22
+
23
+ call(
24
+ source: Pathname(filename).read,
25
+ filename: filename
26
+ )
27
+
28
+ raise e
29
+ end
30
+
31
+ def self.call(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_dir: nil, timeout: TIMEOUT_DEFAULT, io: $stderr)
32
+ search = nil
33
+ filename = nil if filename == DEFAULT_VALUE
34
+ Timeout.timeout(timeout) do
35
+ record_dir ||= ENV["DEBUG"] ? "tmp" : nil
36
+ search = CodeSearch.new(source, record_dir: record_dir).call
37
+ end
38
+
39
+ blocks = search.invalid_blocks
40
+ DisplayInvalidBlocks.new(
41
+ io: io,
42
+ blocks: blocks,
43
+ filename: filename,
44
+ terminal: terminal,
45
+ code_lines: search.code_lines
46
+ ).call
47
+ rescue Timeout::Error => e
48
+ io.puts "Search timed out DEAD_END_TIMEOUT=#{timeout}, run with DEBUG=1 for more info"
49
+ io.puts e.backtrace.first(3).join($/)
50
+ end
51
+
52
+ # Used for counting spaces
53
+ module SpaceCount
54
+ def self.indent(string)
55
+ string.split(/\S/).first&.length || 0
56
+ end
57
+ end
58
+
59
+ # This will tell you if the `code_lines` would be valid
60
+ # if you removed the `without_lines`. In short it's a
61
+ # way to detect if we've found the lines with syntax errors
62
+ # in our document yet.
63
+ #
64
+ # code_lines = [
65
+ # CodeLine.new(line: "def foo\n", index: 0)
66
+ # CodeLine.new(line: " def bar\n", index: 1)
67
+ # CodeLine.new(line: "end\n", index: 2)
68
+ # ]
69
+ #
70
+ # DeadEnd.valid_without?(
71
+ # without_lines: code_lines[1],
72
+ # code_lines: code_lines
73
+ # ) # => true
74
+ #
75
+ # DeadEnd.valid?(code_lines) # => false
76
+ def self.valid_without?(without_lines:, code_lines:)
77
+ lines = code_lines - Array(without_lines).flatten
78
+
79
+ if lines.empty?
80
+ true
81
+ else
82
+ valid?(lines)
83
+ end
84
+ end
85
+
86
+ def self.invalid?(source)
87
+ source = source.join if source.is_a?(Array)
88
+ source = source.to_s
89
+
90
+ Ripper.new(source).tap(&:parse).error?
91
+ end
92
+
93
+ # Returns truthy if a given input source is valid syntax
94
+ #
95
+ # DeadEnd.valid?(<<~EOM) # => true
96
+ # def foo
97
+ # end
98
+ # EOM
99
+ #
100
+ # DeadEnd.valid?(<<~EOM) # => false
101
+ # def foo
102
+ # def bar # Syntax error here
103
+ # end
104
+ # EOM
105
+ #
106
+ # You can also pass in an array of lines and they'll be
107
+ # joined before evaluating
108
+ #
109
+ # DeadEnd.valid?(
110
+ # [
111
+ # "def foo\n",
112
+ # "end\n"
113
+ # ]
114
+ # ) # => true
115
+ #
116
+ # DeadEnd.valid?(
117
+ # [
118
+ # "def foo\n",
119
+ # " def bar\n", # Syntax error here
120
+ # "end\n"
121
+ # ]
122
+ # ) # => false
123
+ #
124
+ # As an FYI the CodeLine class instances respond to `to_s`
125
+ # so passing a CodeLine in as an object or as an array
126
+ # will convert it to it's code representation.
127
+ def self.valid?(source)
128
+ !invalid?(source)
129
+ end
130
+ end
131
+
132
+ require_relative "dead_end/code_line"
133
+ require_relative "dead_end/code_block"
134
+ require_relative "dead_end/code_search"
135
+ require_relative "dead_end/code_frontier"
136
+ require_relative "dead_end/clean_document"
137
+
138
+ require_relative "dead_end/lex_all"
139
+ require_relative "dead_end/block_expand"
140
+ require_relative "dead_end/around_block_scan"
141
+ require_relative "dead_end/ripper_errors"
142
+ require_relative "dead_end/display_invalid_blocks"
143
+ require_relative "dead_end/parse_blocks_from_indent_line"
144
+
145
+ require_relative "dead_end/explain_syntax"
146
+
4
147
  require_relative "dead_end/auto"
148
+ require_relative "dead_end/cli"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dead_end
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - schneems
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-10-31 00:00:00.000000000 Z
11
+ date: 2021-11-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: When you get an "unexpected end" in your syntax this gem helps you find
14
14
  it
@@ -38,23 +38,23 @@ files:
38
38
  - lib/dead_end.rb
39
39
  - lib/dead_end/around_block_scan.rb
40
40
  - lib/dead_end/auto.rb
41
- - lib/dead_end/banner.rb
42
41
  - lib/dead_end/block_expand.rb
43
42
  - lib/dead_end/capture_code_context.rb
44
43
  - lib/dead_end/clean_document.rb
44
+ - lib/dead_end/cli.rb
45
45
  - lib/dead_end/code_block.rb
46
46
  - lib/dead_end/code_frontier.rb
47
47
  - lib/dead_end/code_line.rb
48
48
  - lib/dead_end/code_search.rb
49
49
  - lib/dead_end/display_code_with_line_numbers.rb
50
50
  - lib/dead_end/display_invalid_blocks.rb
51
- - lib/dead_end/fyi.rb
52
- - lib/dead_end/internals.rb
51
+ - lib/dead_end/explain_syntax.rb
52
+ - lib/dead_end/left_right_lex_count.rb
53
53
  - lib/dead_end/lex_all.rb
54
54
  - lib/dead_end/lex_value.rb
55
55
  - lib/dead_end/parse_blocks_from_indent_line.rb
56
+ - lib/dead_end/ripper_errors.rb
56
57
  - lib/dead_end/version.rb
57
- - lib/dead_end/who_dis_syntax_error.rb
58
58
  homepage: https://github.com/zombocom/dead_end.git
59
59
  licenses:
60
60
  - MIT
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DeadEnd
4
- class Banner
5
- attr_reader :invalid_obj
6
-
7
- def initialize(invalid_obj:)
8
- @invalid_obj = invalid_obj
9
- end
10
-
11
- def call
12
- case invalid_obj.error_symbol
13
- when :missing_end
14
- <<~EOM
15
- DeadEnd: Missing `end` detected
16
-
17
- This code has a missing `end`. Ensure that all
18
- syntax keywords (`def`, `do`, etc.) have a matching `end`.
19
- EOM
20
- when :unmatched_syntax
21
- case unmatched_symbol
22
- when :end
23
- <<~EOM
24
- DeadEnd: Unmatched `end` detected
25
-
26
- This code has an unmatched `end`. Ensure that all `end` lines
27
- in your code have a matching syntax keyword (`def`, `do`, etc.)
28
- and that you don't have any extra `end` lines.
29
- EOM
30
- when :|
31
- <<~EOM
32
- DeadEnd: Unmatched `|` character detected
33
-
34
- Example:
35
-
36
- `do |x` should be `do |x|`
37
- EOM
38
- when *WhoDisSyntaxError::CHARACTERS.keys
39
- <<~EOM
40
- DeadEnd: Unmatched `#{unmatched_symbol}` character detected
41
-
42
- It appears a `#{missing_character}` might be missing.
43
- EOM
44
- else
45
- "DeadEnd: Unmatched `#{unmatched_symbol}` detected"
46
- end
47
- end
48
- end
49
-
50
- private def unmatched_symbol
51
- invalid_obj.unmatched_symbol
52
- end
53
-
54
- private def missing_character
55
- WhoDisSyntaxError::CHARACTERS[unmatched_symbol]
56
- end
57
- end
58
- end
data/lib/dead_end/fyi.rb DELETED
@@ -1,8 +0,0 @@
1
- require_relative "../dead_end/internals"
2
-
3
- require_relative "auto"
4
-
5
- DeadEnd.send(:remove_const, :SEARCH_SOURCE_ON_ERROR_DEFAULT)
6
- DeadEnd::SEARCH_SOURCE_ON_ERROR_DEFAULT = false
7
-
8
- warn "DEPRECATED: calling `require 'dead_end/fyi'` is deprecated, `require 'dead_end'` instead"