dead_end 1.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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadEnd
4
+ # Outputs code with highlighted lines
5
+ #
6
+ # Whatever is passed to this class will be rendered
7
+ # even if it is "marked invisible" any filtering of
8
+ # output should be done before calling this class.
9
+ #
10
+ #
11
+ # DisplayCodeWithLineNumbers.new(
12
+ # lines: lines,
13
+ # highlight_lines: [lines[2], lines[3]]
14
+ # ).call
15
+ # # =>
16
+ # 1
17
+ # 2 def cat
18
+ # ❯ 3 Dir.chdir
19
+ # ❯ 4 end
20
+ # 5 end
21
+ # 6
22
+ class DisplayCodeWithLineNumbers
23
+ TERMINAL_HIGHLIGHT = "\e[1;3m" # Bold, italics
24
+ TERMINAL_END = "\e[0m"
25
+
26
+ def initialize(lines: , highlight_lines: [], terminal: false)
27
+ @lines = Array(lines).sort
28
+ @terminal = terminal
29
+ @highlight_line_hash = Array(highlight_lines).each_with_object({}) {|line, h| h[line] = true }
30
+ @digit_count = @lines.last&.line_number.to_s.length
31
+ end
32
+
33
+ def call
34
+ @lines.map do |line|
35
+ format_line(line)
36
+ end.join
37
+ end
38
+
39
+ private def format_line(code_line)
40
+ # Handle trailing slash lines
41
+ code_line.original.lines.map.with_index do |contents, i|
42
+ format(
43
+ empty: code_line.empty?,
44
+ number: (code_line.number + i).to_s,
45
+ contents: contents,
46
+ highlight: @highlight_line_hash[code_line]
47
+ )
48
+ end.join
49
+ end
50
+
51
+ private def format(contents: , number: , highlight: false, empty:)
52
+ string = String.new("")
53
+ if highlight
54
+ string << "❯ "
55
+ else
56
+ string << " "
57
+ end
58
+
59
+ string << number.rjust(@digit_count).to_s
60
+ if empty
61
+ string << contents
62
+ else
63
+ string << " "
64
+ string << TERMINAL_HIGHLIGHT if @terminal && highlight
65
+ string << contents
66
+ string << TERMINAL_END if @terminal
67
+ end
68
+ string
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture_code_context"
4
+ require_relative "display_code_with_line_numbers"
5
+
6
+ module DeadEnd
7
+ # Used for formatting invalid blocks
8
+ class DisplayInvalidBlocks
9
+ attr_reader :filename
10
+
11
+ def initialize(code_lines: ,blocks:, io: $stderr, filename: nil, terminal: false, invalid_obj: WhoDisSyntaxError::Null.new)
12
+ @terminal = terminal
13
+ @filename = filename
14
+ @io = io
15
+
16
+ @blocks = Array(blocks)
17
+
18
+ @invalid_lines = @blocks.map(&:lines).flatten
19
+ @code_lines = code_lines
20
+
21
+ @invalid_obj = invalid_obj
22
+ end
23
+
24
+ def call
25
+ if @blocks.any? { |b| !b.hidden? }
26
+ found_invalid_blocks
27
+ else
28
+ @io.puts "Syntax OK"
29
+ end
30
+ self
31
+ end
32
+
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:
45
+
46
+ #{indent(code_block)}
47
+ EOM
48
+ end
49
+
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
72
+
73
+ Example:
74
+
75
+ `do |x` should be `do |x|`
76
+ EOM
77
+ when :"}"
78
+ <<~EOM
79
+ DeadEnd: Unmatched `}` character detected
80
+
81
+ This code has an unmatched `}`. Ensure that opening curl braces are
82
+ closed: `{ }`.
83
+ EOM
84
+ else
85
+ "DeadEnd: Unmatched #{@invalid_obj.unmatched_symbol}` detected"
86
+ end
87
+ end
88
+
89
+ end
90
+
91
+ def indent(string, with: " ")
92
+ string.each_line.map {|l| with + l }.join
93
+ end
94
+
95
+ def code_block
96
+ string = String.new("")
97
+ string << code_with_context
98
+ string
99
+ end
100
+
101
+ def code_with_context
102
+ lines = CaptureCodeContext.new(
103
+ blocks: @blocks,
104
+ code_lines: @code_lines
105
+ ).call
106
+
107
+ DisplayCodeWithLineNumbers.new(
108
+ lines: lines,
109
+ terminal: @terminal,
110
+ highlight_lines: @invalid_lines,
111
+ ).call
112
+ end
113
+
114
+ def code_with_lines
115
+ DisplayCodeWithLineNumbers.new(
116
+ lines: @code_lines.select(&:visible?),
117
+ terminal: @terminal,
118
+ highlight_lines: @invalid_lines,
119
+ ).call
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "../dead_end/internals"
2
+
3
+ require_relative "auto.rb"
4
+
5
+ DeadEnd.send(:remove_const, :SEARCH_SOURCE_ON_ERROR_DEFAULT)
6
+ DeadEnd::SEARCH_SOURCE_ON_ERROR_DEFAULT = false
7
+
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadEnd
4
+ # Takes in a source, and returns blocks containing each heredoc
5
+ class HeredocBlockParse
6
+ private; attr_reader :code_lines, :lex; public
7
+
8
+ def initialize(source:, code_lines: )
9
+ @code_lines = code_lines
10
+ @lex = LexAll.new(source: source)
11
+ end
12
+
13
+ def call
14
+ blocks = []
15
+ beginning = []
16
+ @lex.each do |lex|
17
+ case lex.type
18
+ when :on_heredoc_beg
19
+ beginning << lex.line
20
+ when :on_heredoc_end
21
+ start_index = beginning.pop - 1
22
+ end_index = lex.line - 1
23
+ blocks << CodeBlock.new(lines: code_lines[start_index..end_index])
24
+ end
25
+ end
26
+
27
+ blocks
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # This is the top level file, but is moved to `internals`
4
+ # so the top level file can instead enable the "automatic" behavior
5
+
6
+ require_relative "version"
7
+
8
+ require 'tmpdir'
9
+ require 'stringio'
10
+ require 'pathname'
11
+ require 'ripper'
12
+ require 'timeout'
13
+
14
+ module DeadEnd
15
+ class Error < StandardError; end
16
+ SEARCH_SOURCE_ON_ERROR_DEFAULT = true
17
+ TIMEOUT_DEFAULT = ENV.fetch("DEAD_END_TIMEOUT", 5).to_i
18
+
19
+ def self.handle_error(e, search_source_on_error: SEARCH_SOURCE_ON_ERROR_DEFAULT)
20
+ raise e if !e.message.include?("end-of-input")
21
+
22
+ filename = e.message.split(":").first
23
+
24
+ $stderr.sync = true
25
+ $stderr.puts "Run `$ dead_end #{filename}` for more options\n"
26
+
27
+ if search_source_on_error
28
+ self.call(
29
+ source: Pathname(filename).read,
30
+ filename: filename,
31
+ terminal: true,
32
+ )
33
+ end
34
+
35
+ $stderr.puts ""
36
+ $stderr.puts ""
37
+ raise e
38
+ end
39
+
40
+ def self.call(source: , filename: , terminal: false, record_dir: nil, timeout: TIMEOUT_DEFAULT)
41
+ search = nil
42
+ Timeout.timeout(timeout) do
43
+ search = CodeSearch.new(source, record_dir: record_dir).call
44
+ end
45
+
46
+ blocks = search.invalid_blocks
47
+ DisplayInvalidBlocks.new(
48
+ blocks: blocks,
49
+ filename: filename,
50
+ terminal: terminal,
51
+ code_lines: search.code_lines,
52
+ invalid_obj: invalid_type(source),
53
+ io: $stderr
54
+ ).call
55
+ rescue Timeout::Error
56
+ $stderr.puts "Search timed out DEAD_END_TIMEOUT=#{timeout}, run with DEBUG=1 for more info"
57
+ end
58
+
59
+ # Used for counting spaces
60
+ module SpaceCount
61
+ def self.indent(string)
62
+ string.split(/\S/).first&.length || 0
63
+ end
64
+ end
65
+
66
+ # This will tell you if the `code_lines` would be valid
67
+ # if you removed the `without_lines`. In short it's a
68
+ # way to detect if we've found the lines with syntax errors
69
+ # in our document yet.
70
+ #
71
+ # code_lines = [
72
+ # CodeLine.new(line: "def foo\n", index: 0)
73
+ # CodeLine.new(line: " def bar\n", index: 1)
74
+ # CodeLine.new(line: "end\n", index: 2)
75
+ # ]
76
+ #
77
+ # DeadEnd.valid_without?(
78
+ # without_lines: code_lines[1],
79
+ # code_lines: code_lines
80
+ # ) # => true
81
+ #
82
+ # DeadEnd.valid?(code_lines) # => false
83
+ def self.valid_without?(without_lines: , code_lines:)
84
+ lines = code_lines - Array(without_lines).flatten
85
+
86
+ if lines.empty?
87
+ return true
88
+ else
89
+ return valid?(lines)
90
+ end
91
+ end
92
+
93
+ def self.invalid?(source)
94
+ source = source.join if source.is_a?(Array)
95
+ source = source.to_s
96
+
97
+ Ripper.new(source).tap(&:parse).error?
98
+ end
99
+
100
+ # Returns truthy if a given input source is valid syntax
101
+ #
102
+ # DeadEnd.valid?(<<~EOM) # => true
103
+ # def foo
104
+ # end
105
+ # EOM
106
+ #
107
+ # DeadEnd.valid?(<<~EOM) # => false
108
+ # def foo
109
+ # def bar # Syntax error here
110
+ # end
111
+ # EOM
112
+ #
113
+ # You can also pass in an array of lines and they'll be
114
+ # joined before evaluating
115
+ #
116
+ # DeadEnd.valid?(
117
+ # [
118
+ # "def foo\n",
119
+ # "end\n"
120
+ # ]
121
+ # ) # => true
122
+ #
123
+ # DeadEnd.valid?(
124
+ # [
125
+ # "def foo\n",
126
+ # " def bar\n", # Syntax error here
127
+ # "end\n"
128
+ # ]
129
+ # ) # => false
130
+ #
131
+ # As an FYI the CodeLine class instances respond to `to_s`
132
+ # so passing a CodeLine in as an object or as an array
133
+ # will convert it to it's code representation.
134
+ def self.valid?(source)
135
+ !invalid?(source)
136
+ end
137
+
138
+
139
+ def self.invalid_type(source)
140
+ WhoDisSyntaxError.new(source).call
141
+ end
142
+ end
143
+
144
+ require_relative "code_line"
145
+ require_relative "code_block"
146
+ require_relative "code_frontier"
147
+ require_relative "display_invalid_blocks"
148
+ require_relative "around_block_scan"
149
+ require_relative "block_expand"
150
+ require_relative "parse_blocks_from_indent_line"
151
+
152
+ require_relative "code_search"
153
+ require_relative "who_dis_syntax_error"
154
+ require_relative "heredoc_block_parse"
155
+ require_relative "lex_all"
156
+ require_relative "trailing_slash_join"
@@ -0,0 +1,58 @@
1
+ module DeadEnd
2
+ # Ripper.lex is not guaranteed to lex the entire source document
3
+ #
4
+ # lex = LexAll.new(source: source)
5
+ # lex.each do |value|
6
+ # puts value.line
7
+ # end
8
+ class LexAll
9
+ include Enumerable
10
+
11
+ def initialize(source: )
12
+ @lex = Ripper.lex(source)
13
+ lineno = @lex.last&.first&.first + 1
14
+ source_lines = source.lines
15
+ last_lineno = source_lines.count
16
+
17
+ until lineno >= last_lineno
18
+ lines = source_lines[lineno..-1]
19
+
20
+ @lex.concat(Ripper.lex(lines.join, '-', lineno + 1))
21
+ lineno = @lex.last&.first&.first + 1
22
+ end
23
+
24
+ @lex.map! {|(line, _), type, token| LexValue.new(line, _, type, token) }
25
+ end
26
+
27
+ def each
28
+ return @lex.each unless block_given?
29
+ @lex.each do |x|
30
+ yield x
31
+ end
32
+ end
33
+
34
+ def last
35
+ @lex.last
36
+ end
37
+
38
+ # Value object for accessing lex values
39
+ #
40
+ # This lex:
41
+ #
42
+ # [1, 0], :on_ident, "describe", CMDARG
43
+ #
44
+ # Would translate into:
45
+ #
46
+ # lex.line # => 1
47
+ # lex.type # => :on_indent
48
+ # lex.token # => "describe"
49
+ class LexValue
50
+ attr_reader :line, :type, :token
51
+ def initialize(line, _, type, token)
52
+ @line = line
53
+ @type = type
54
+ @token = token
55
+ end
56
+ end
57
+ end
58
+ end