syntax_suggest 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +91 -0
  3. data/.github/workflows/check_changelog.yml +20 -0
  4. data/.gitignore +14 -0
  5. data/.rspec +3 -0
  6. data/.standard.yml +1 -0
  7. data/CHANGELOG.md +158 -0
  8. data/CODE_OF_CONDUCT.md +74 -0
  9. data/Gemfile +14 -0
  10. data/Gemfile.lock +67 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +229 -0
  13. data/Rakefile +8 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/dead_end.gemspec +32 -0
  17. data/exe/syntax_suggest +7 -0
  18. data/lib/syntax_suggest/api.rb +199 -0
  19. data/lib/syntax_suggest/around_block_scan.rb +224 -0
  20. data/lib/syntax_suggest/block_expand.rb +74 -0
  21. data/lib/syntax_suggest/capture_code_context.rb +233 -0
  22. data/lib/syntax_suggest/clean_document.rb +304 -0
  23. data/lib/syntax_suggest/cli.rb +129 -0
  24. data/lib/syntax_suggest/code_block.rb +100 -0
  25. data/lib/syntax_suggest/code_frontier.rb +178 -0
  26. data/lib/syntax_suggest/code_line.rb +239 -0
  27. data/lib/syntax_suggest/code_search.rb +139 -0
  28. data/lib/syntax_suggest/core_ext.rb +101 -0
  29. data/lib/syntax_suggest/display_code_with_line_numbers.rb +70 -0
  30. data/lib/syntax_suggest/display_invalid_blocks.rb +84 -0
  31. data/lib/syntax_suggest/explain_syntax.rb +103 -0
  32. data/lib/syntax_suggest/left_right_lex_count.rb +168 -0
  33. data/lib/syntax_suggest/lex_all.rb +55 -0
  34. data/lib/syntax_suggest/lex_value.rb +70 -0
  35. data/lib/syntax_suggest/parse_blocks_from_indent_line.rb +60 -0
  36. data/lib/syntax_suggest/pathname_from_message.rb +59 -0
  37. data/lib/syntax_suggest/priority_engulf_queue.rb +63 -0
  38. data/lib/syntax_suggest/priority_queue.rb +105 -0
  39. data/lib/syntax_suggest/ripper_errors.rb +36 -0
  40. data/lib/syntax_suggest/unvisited_lines.rb +36 -0
  41. data/lib/syntax_suggest/version.rb +5 -0
  42. data/lib/syntax_suggest.rb +3 -0
  43. metadata +88 -0
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxSuggest
4
+ # Represents a single line of code of a given source file
5
+ #
6
+ # This object contains metadata about the line such as
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.
10
+ #
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
+ #
15
+ # Example:
16
+ #
17
+ # line = CodeLine.from_source("def foo\n").first
18
+ # line.number => 1
19
+ # line.empty? # => false
20
+ # line.visible? # => true
21
+ # line.mark_invisible
22
+ # line.visible? # => false
23
+ #
24
+ class CodeLine
25
+ TRAILING_SLASH = ("\\" + $/).freeze
26
+
27
+ # Returns an array of CodeLine objects
28
+ # from the source string
29
+ def self.from_source(source, lines: nil)
30
+ lines ||= source.lines
31
+ lex_array_for_line = LexAll.new(source: source, source_lines: lines).each_with_object(Hash.new { |h, k| h[k] = [] }) { |lex, hash| hash[lex.line] << lex }
32
+ lines.map.with_index do |line, index|
33
+ CodeLine.new(
34
+ line: line,
35
+ index: index,
36
+ lex: lex_array_for_line[index + 1]
37
+ )
38
+ end
39
+ end
40
+
41
+ attr_reader :line, :index, :lex, :line_number, :indent
42
+ def initialize(line:, index:, lex:)
43
+ @lex = lex
44
+ @line = line
45
+ @index = index
46
+ @original = line
47
+ @line_number = @index + 1
48
+ strip_line = line.dup
49
+ strip_line.lstrip!
50
+
51
+ if strip_line.empty?
52
+ @empty = true
53
+ @indent = 0
54
+ else
55
+ @empty = false
56
+ @indent = line.length - strip_line.length
57
+ end
58
+
59
+ set_kw_end
60
+ end
61
+
62
+ # Used for stable sort via indentation level
63
+ #
64
+ # Ruby's sort is not "stable" meaning that when
65
+ # multiple elements have the same value, they are
66
+ # not guaranteed to return in the same order they
67
+ # were put in.
68
+ #
69
+ # So when multiple code lines have the same indentation
70
+ # level, they're sorted by their index value which is unique
71
+ # and consistent.
72
+ #
73
+ # This is mostly needed for consistency of the test suite
74
+ def indent_index
75
+ @indent_index ||= [indent, index]
76
+ end
77
+ alias_method :number, :line_number
78
+
79
+ # Returns true if the code line is determined
80
+ # to contain a keyword that matches with an `end`
81
+ #
82
+ # For example: `def`, `do`, `begin`, `ensure`, etc.
83
+ def is_kw?
84
+ @is_kw
85
+ end
86
+
87
+ # Returns true if the code line is determined
88
+ # to contain an `end` keyword
89
+ def is_end?
90
+ @is_end
91
+ end
92
+
93
+ # Used to hide lines
94
+ #
95
+ # The search alorithm will group lines into blocks
96
+ # then if those blocks are determined to represent
97
+ # valid code they will be hidden
98
+ def mark_invisible
99
+ @line = ""
100
+ end
101
+
102
+ # Means the line was marked as "invisible"
103
+ # Confusingly, "empty" lines are visible...they
104
+ # just don't contain any source code other than a newline ("\n").
105
+ def visible?
106
+ !line.empty?
107
+ end
108
+
109
+ # Opposite or `visible?` (note: different than `empty?`)
110
+ def hidden?
111
+ !visible?
112
+ end
113
+
114
+ # An `empty?` line is one that was originally left
115
+ # empty in the source code, while a "hidden" line
116
+ # is one that we've since marked as "invisible"
117
+ def empty?
118
+ @empty
119
+ end
120
+
121
+ # Opposite of `empty?` (note: different than `visible?`)
122
+ def not_empty?
123
+ !empty?
124
+ end
125
+
126
+ # Renders the given line
127
+ #
128
+ # Also allows us to represent source code as
129
+ # an array of code lines.
130
+ #
131
+ # When we have an array of code line elements
132
+ # calling `join` on the array will call `to_s`
133
+ # on each element, which essentially converts
134
+ # it back into it's original source string.
135
+ def to_s
136
+ line
137
+ end
138
+
139
+ # When the code line is marked invisible
140
+ # we retain the original value of it's line
141
+ # this is useful for debugging and for
142
+ # showing extra context
143
+ #
144
+ # DisplayCodeWithLineNumbers will render
145
+ # all lines given to it, not just visible
146
+ # lines, it uses the original method to
147
+ # obtain them.
148
+ attr_reader :original
149
+
150
+ # Comparison operator, needed for equality
151
+ # and sorting
152
+ def <=>(other)
153
+ index <=> other.index
154
+ end
155
+
156
+ # [Not stable API]
157
+ #
158
+ # Lines that have a `on_ignored_nl` type token and NOT
159
+ # a `BEG` type seem to be a good proxy for the ability
160
+ # to join multiple lines into one.
161
+ #
162
+ # This predicate method is used to determine when those
163
+ # two criteria have been met.
164
+ #
165
+ # The one known case this doesn't handle is:
166
+ #
167
+ # Ripper.lex <<~EOM
168
+ # a &&
169
+ # b ||
170
+ # c
171
+ # EOM
172
+ #
173
+ # For some reason this introduces `on_ignore_newline` but with BEG type
174
+ def ignore_newline_not_beg?
175
+ @ignore_newline_not_beg
176
+ end
177
+
178
+ # Determines if the given line has a trailing slash
179
+ #
180
+ # lines = CodeLine.from_source(<<~EOM)
181
+ # it "foo" \
182
+ # EOM
183
+ # expect(lines.first.trailing_slash?).to eq(true)
184
+ #
185
+ def trailing_slash?
186
+ last = @lex.last
187
+ return false unless last
188
+ return false unless last.type == :on_sp
189
+
190
+ last.token == TRAILING_SLASH
191
+ end
192
+
193
+ # Endless method detection
194
+ #
195
+ # From https://github.com/ruby/irb/commit/826ae909c9c93a2ddca6f9cfcd9c94dbf53d44ab
196
+ # Detecting a "oneliner" seems to need a state machine.
197
+ # This can be done by looking mostly at the "state" (last value):
198
+ #
199
+ # ENDFN -> BEG (token = '=' ) -> END
200
+ #
201
+ private def set_kw_end
202
+ oneliner_count = 0
203
+ in_oneliner_def = nil
204
+
205
+ kw_count = 0
206
+ end_count = 0
207
+
208
+ @ignore_newline_not_beg = false
209
+ @lex.each do |lex|
210
+ kw_count += 1 if lex.is_kw?
211
+ end_count += 1 if lex.is_end?
212
+
213
+ if lex.type == :on_ignored_nl
214
+ @ignore_newline_not_beg = !lex.expr_beg?
215
+ end
216
+
217
+ if in_oneliner_def.nil?
218
+ in_oneliner_def = :ENDFN if lex.state.allbits?(Ripper::EXPR_ENDFN)
219
+ elsif lex.state.allbits?(Ripper::EXPR_ENDFN)
220
+ # Continue
221
+ elsif lex.state.allbits?(Ripper::EXPR_BEG)
222
+ in_oneliner_def = :BODY if lex.token == "="
223
+ elsif lex.state.allbits?(Ripper::EXPR_END)
224
+ # We found an endless method, count it
225
+ oneliner_count += 1 if in_oneliner_def == :BODY
226
+
227
+ in_oneliner_def = nil
228
+ else
229
+ in_oneliner_def = nil
230
+ end
231
+ end
232
+
233
+ kw_count -= oneliner_count
234
+
235
+ @is_kw = (kw_count - end_count) > 0
236
+ @is_end = (end_count - kw_count) > 0
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxSuggest
4
+ # Searches code for a syntax error
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
+ #
14
+ # The bulk of the heavy lifting is done in:
15
+ #
16
+ # - CodeFrontier (Holds information for generating blocks and determining if we can stop searching)
17
+ # - ParseBlocksFromLine (Creates blocks into the frontier)
18
+ # - BlockExpand (Expands existing blocks to search more code)
19
+ #
20
+ # ## Syntax error detection
21
+ #
22
+ # When the frontier holds the syntax error, we can stop searching
23
+ #
24
+ # search = CodeSearch.new(<<~EOM)
25
+ # def dog
26
+ # def lol
27
+ # end
28
+ # EOM
29
+ #
30
+ # search.call
31
+ #
32
+ # search.invalid_blocks.map(&:to_s) # =>
33
+ # # => ["def lol\n"]
34
+ #
35
+ class CodeSearch
36
+ private
37
+
38
+ attr_reader :frontier
39
+
40
+ public
41
+
42
+ attr_reader :invalid_blocks, :record_dir, :code_lines
43
+
44
+ def initialize(source, record_dir: DEFAULT_VALUE)
45
+ record_dir = if record_dir == DEFAULT_VALUE
46
+ ENV["SYNTAX_SUGGEST_RECORD_DIR"] || ENV["SYNTAX_SUGGEST_DEBUG"] ? "tmp" : nil
47
+ else
48
+ record_dir
49
+ end
50
+
51
+ if record_dir
52
+ @record_dir = SyntaxSuggest.record_dir(record_dir)
53
+ @write_count = 0
54
+ end
55
+
56
+ @tick = 0
57
+ @source = source
58
+ @name_tick = Hash.new { |hash, k| hash[k] = 0 }
59
+ @invalid_blocks = []
60
+
61
+ @code_lines = CleanDocument.new(source: source).call.lines
62
+
63
+ @frontier = CodeFrontier.new(code_lines: @code_lines)
64
+ @block_expand = BlockExpand.new(code_lines: @code_lines)
65
+ @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines)
66
+ end
67
+
68
+ # Used for debugging
69
+ def record(block:, name: "record")
70
+ return unless @record_dir
71
+ @name_tick[name] += 1
72
+ filename = "#{@write_count += 1}-#{name}-#{@name_tick[name]}-(#{block.starts_at}__#{block.ends_at}).txt"
73
+ if ENV["SYNTAX_SUGGEST_DEBUG"]
74
+ puts "\n\n==== #{filename} ===="
75
+ puts "\n```#{block.starts_at}..#{block.ends_at}"
76
+ puts block.to_s
77
+ puts "```"
78
+ puts " block indent: #{block.current_indent}"
79
+ end
80
+ @record_dir.join(filename).open(mode: "a") do |f|
81
+ document = DisplayCodeWithLineNumbers.new(
82
+ lines: @code_lines.select(&:visible?),
83
+ terminal: false,
84
+ highlight_lines: block.lines
85
+ ).call
86
+
87
+ f.write(" Block lines: #{block.starts_at..block.ends_at} (#{name}) \n\n#{document}")
88
+ end
89
+ end
90
+
91
+ def push(block, name:)
92
+ record(block: block, name: name)
93
+
94
+ block.mark_invisible if block.valid?
95
+ frontier << block
96
+ end
97
+
98
+ # Parses the most indented lines into blocks that are marked
99
+ # and added to the frontier
100
+ def create_blocks_from_untracked_lines
101
+ max_indent = frontier.next_indent_line&.indent
102
+
103
+ while (line = frontier.next_indent_line) && (line.indent == max_indent)
104
+ @parse_blocks_from_indent_line.each_neighbor_block(frontier.next_indent_line) do |block|
105
+ push(block, name: "add")
106
+ end
107
+ end
108
+ end
109
+
110
+ # Given an already existing block in the frontier, expand it to see
111
+ # if it contains our invalid syntax
112
+ def expand_existing
113
+ block = frontier.pop
114
+ return unless block
115
+
116
+ record(block: block, name: "before-expand")
117
+
118
+ block = @block_expand.call(block)
119
+ push(block, name: "expand")
120
+ end
121
+
122
+ # Main search loop
123
+ def call
124
+ until frontier.holds_all_syntax_errors?
125
+ @tick += 1
126
+
127
+ if frontier.expand?
128
+ expand_existing
129
+ else
130
+ create_blocks_from_untracked_lines
131
+ end
132
+ end
133
+
134
+ @invalid_blocks.concat(frontier.detect_invalid_blocks)
135
+ @invalid_blocks.sort_by! { |block| block.starts_at }
136
+ self
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby 3.2+ has a cleaner way to hook into Ruby that doesn't use `require`
4
+ if SyntaxError.method_defined?(:detailed_message)
5
+ module SyntaxSuggest
6
+ class MiniStringIO
7
+ def initialize(isatty: $stderr.isatty)
8
+ @string = +""
9
+ @isatty = isatty
10
+ end
11
+
12
+ attr_reader :isatty
13
+ def puts(value = $/, **)
14
+ @string << value
15
+ end
16
+
17
+ attr_reader :string
18
+ end
19
+ end
20
+
21
+ SyntaxError.prepend Module.new {
22
+ def detailed_message(highlight: true, syntax_suggest: true, **kwargs)
23
+ return super unless syntax_suggest
24
+
25
+ require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
26
+
27
+ message = super
28
+ file = if highlight
29
+ SyntaxSuggest::PathnameFromMessage.new(super(highlight: false, **kwargs)).call.name
30
+ else
31
+ SyntaxSuggest::PathnameFromMessage.new(message).call.name
32
+ end
33
+
34
+ io = SyntaxSuggest::MiniStringIO.new
35
+
36
+ if file
37
+ SyntaxSuggest.call(
38
+ io: io,
39
+ source: file.read,
40
+ filename: file,
41
+ terminal: highlight
42
+ )
43
+ annotation = io.string
44
+
45
+ annotation + message
46
+ else
47
+ message
48
+ end
49
+ rescue => e
50
+ if ENV["SYNTAX_SUGGEST_DEBUG"]
51
+ $stderr.warn(e.message)
52
+ $stderr.warn(e.backtrace)
53
+ end
54
+
55
+ # Ignore internal errors
56
+ message
57
+ end
58
+ }
59
+ else
60
+ autoload :Pathname, "pathname"
61
+
62
+ # Monkey patch kernel to ensure that all `require` calls call the same
63
+ # method
64
+ module Kernel
65
+ module_function
66
+
67
+ alias_method :syntax_suggest_original_require, :require
68
+ alias_method :syntax_suggest_original_require_relative, :require_relative
69
+ alias_method :syntax_suggest_original_load, :load
70
+
71
+ def load(file, wrap = false)
72
+ syntax_suggest_original_load(file)
73
+ rescue SyntaxError => e
74
+ require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
75
+
76
+ SyntaxSuggest.handle_error(e)
77
+ end
78
+
79
+ def require(file)
80
+ syntax_suggest_original_require(file)
81
+ rescue SyntaxError => e
82
+ require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
83
+
84
+ SyntaxSuggest.handle_error(e)
85
+ end
86
+
87
+ def require_relative(file)
88
+ if Pathname.new(file).absolute?
89
+ syntax_suggest_original_require file
90
+ else
91
+ relative_from = caller_locations(1..1).first
92
+ relative_from_path = relative_from.absolute_path || relative_from.path
93
+ syntax_suggest_original_require File.expand_path("../#{file}", relative_from_path)
94
+ end
95
+ rescue SyntaxError => e
96
+ require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
97
+
98
+ SyntaxSuggest.handle_error(e)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxSuggest
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
+ # DisplayCodeWithLineNumbers.new(
11
+ # lines: lines,
12
+ # highlight_lines: [lines[2], lines[3]]
13
+ # ).call
14
+ # # =>
15
+ # 1
16
+ # 2 def cat
17
+ # ❯ 3 Dir.chdir
18
+ # ❯ 4 end
19
+ # 5 end
20
+ # 6
21
+ class DisplayCodeWithLineNumbers
22
+ TERMINAL_HIGHLIGHT = "\e[1;3m" # Bold, italics
23
+ TERMINAL_END = "\e[0m"
24
+
25
+ def initialize(lines:, highlight_lines: [], terminal: false)
26
+ @lines = Array(lines).sort
27
+ @terminal = terminal
28
+ @highlight_line_hash = Array(highlight_lines).each_with_object({}) { |line, h| h[line] = true }
29
+ @digit_count = @lines.last&.line_number.to_s.length
30
+ end
31
+
32
+ def call
33
+ @lines.map do |line|
34
+ format_line(line)
35
+ end.join
36
+ end
37
+
38
+ private def format_line(code_line)
39
+ # Handle trailing slash lines
40
+ code_line.original.lines.map.with_index do |contents, i|
41
+ format(
42
+ empty: code_line.empty?,
43
+ number: (code_line.number + i).to_s,
44
+ contents: contents,
45
+ highlight: @highlight_line_hash[code_line]
46
+ )
47
+ end.join
48
+ end
49
+
50
+ private def format(contents:, number:, empty:, highlight: false)
51
+ string = +""
52
+ string << if highlight
53
+ "❯ "
54
+ else
55
+ " "
56
+ end
57
+
58
+ string << number.rjust(@digit_count).to_s
59
+ if empty
60
+ string << contents
61
+ else
62
+ string << " "
63
+ string << TERMINAL_HIGHLIGHT if @terminal && highlight
64
+ string << contents
65
+ string << TERMINAL_END if @terminal
66
+ end
67
+ string
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture_code_context"
4
+ require_relative "display_code_with_line_numbers"
5
+
6
+ module SyntaxSuggest
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: DEFAULT_VALUE)
12
+ @io = io
13
+ @blocks = Array(blocks)
14
+ @filename = filename
15
+ @code_lines = code_lines
16
+
17
+ @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal
18
+ end
19
+
20
+ def document_ok?
21
+ @blocks.none? { |b| !b.hidden? }
22
+ end
23
+
24
+ def call
25
+ if document_ok?
26
+ @io.puts "Syntax OK"
27
+ return self
28
+ end
29
+
30
+ if filename
31
+ @io.puts("--> #{filename}")
32
+ @io.puts
33
+ end
34
+ @blocks.each do |block|
35
+ display_block(block)
36
+ end
37
+
38
+ self
39
+ end
40
+
41
+ private def display_block(block)
42
+ # Build explanation
43
+ explain = ExplainSyntax.new(
44
+ code_lines: block.lines
45
+ ).call
46
+
47
+ # Enhance code output
48
+ # Also handles several ambiguious cases
49
+ lines = CaptureCodeContext.new(
50
+ blocks: block,
51
+ code_lines: @code_lines
52
+ ).call
53
+
54
+ # Build code output
55
+ document = DisplayCodeWithLineNumbers.new(
56
+ lines: lines,
57
+ terminal: @terminal,
58
+ highlight_lines: block.lines
59
+ ).call
60
+
61
+ # Output syntax error explanation
62
+ explain.errors.each do |e|
63
+ @io.puts e
64
+ end
65
+ @io.puts
66
+
67
+ # Output code
68
+ @io.puts(document)
69
+ end
70
+
71
+ private def code_with_context
72
+ lines = CaptureCodeContext.new(
73
+ blocks: @blocks,
74
+ code_lines: @code_lines
75
+ ).call
76
+
77
+ DisplayCodeWithLineNumbers.new(
78
+ lines: lines,
79
+ terminal: @terminal,
80
+ highlight_lines: @invalid_lines
81
+ ).call
82
+ end
83
+ end
84
+ end