dead_end 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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