dead_end 2.0.2 → 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.
@@ -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"