syntax_suggest 0.0.1

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.
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