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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +91 -0
- data/.github/workflows/check_changelog.yml +20 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.standard.yml +1 -0
- data/CHANGELOG.md +158 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/dead_end.gemspec +32 -0
- data/exe/syntax_suggest +7 -0
- data/lib/syntax_suggest/api.rb +199 -0
- data/lib/syntax_suggest/around_block_scan.rb +224 -0
- data/lib/syntax_suggest/block_expand.rb +74 -0
- data/lib/syntax_suggest/capture_code_context.rb +233 -0
- data/lib/syntax_suggest/clean_document.rb +304 -0
- data/lib/syntax_suggest/cli.rb +129 -0
- data/lib/syntax_suggest/code_block.rb +100 -0
- data/lib/syntax_suggest/code_frontier.rb +178 -0
- data/lib/syntax_suggest/code_line.rb +239 -0
- data/lib/syntax_suggest/code_search.rb +139 -0
- data/lib/syntax_suggest/core_ext.rb +101 -0
- data/lib/syntax_suggest/display_code_with_line_numbers.rb +70 -0
- data/lib/syntax_suggest/display_invalid_blocks.rb +84 -0
- data/lib/syntax_suggest/explain_syntax.rb +103 -0
- data/lib/syntax_suggest/left_right_lex_count.rb +168 -0
- data/lib/syntax_suggest/lex_all.rb +55 -0
- data/lib/syntax_suggest/lex_value.rb +70 -0
- data/lib/syntax_suggest/parse_blocks_from_indent_line.rb +60 -0
- data/lib/syntax_suggest/pathname_from_message.rb +59 -0
- data/lib/syntax_suggest/priority_engulf_queue.rb +63 -0
- data/lib/syntax_suggest/priority_queue.rb +105 -0
- data/lib/syntax_suggest/ripper_errors.rb +36 -0
- data/lib/syntax_suggest/unvisited_lines.rb +36 -0
- data/lib/syntax_suggest/version.rb +5 -0
- data/lib/syntax_suggest.rb +3 -0
- 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
|