syntax_suggest 2.0.3 → 3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de1d75012724df4b1222ba39772c0fbfb6205cfd8713dd4f19ff53e473d7e4f3
4
- data.tar.gz: 1a705bbd28e351a682343089c14a2277953d917725f970eaa7884912a093be45
3
+ metadata.gz: af5147af49a48cc1639c03e5b4f89d9bd2d841981548fef41477786fc1955fa4
4
+ data.tar.gz: 21bbc4796ad0444e965258c645637f1faf853392d27a54a4a28096db92f44257
5
5
  SHA512:
6
- metadata.gz: d883c10ef026cfe8c26c930d0857ef02f7655421cdd83f69bd971e231f674124fcca4957032f0a9311c87d2fd798ffb7b9921aee6e9859b112213cfda0c48cd5
7
- data.tar.gz: e3d0a25404aa3ea0a802400ee42b3d8337f9180420772409e920683dd631e59c8c2f98bcf85a107dcb3ba888246a3962aeb7ca7ec036b3d0b08e17496406f145
6
+ metadata.gz: a48e4e6788b73a404d67cafd519713cce379ad1e84dc270e81f7047ea523d84b75d91420f3915614a68233eb9c4c07ab237e090741d2d7ffd4326001d6e4fcd7
7
+ data.tar.gz: e29a44a49a2185bec367895efda2ee6bc978adc24930711e9ddff7a9e4881519519ed2890d4ba9828608e30bf9b8e196f4b45bac42d78f1be0c8b3f745b87f02
@@ -24,6 +24,7 @@ jobs:
24
24
  uses: ruby/actions/.github/workflows/ruby_versions.yml@master
25
25
  with:
26
26
  engine: cruby
27
+ min_version: 3.3
27
28
 
28
29
  test:
29
30
  needs: ruby-versions
@@ -33,7 +34,9 @@ jobs:
33
34
  matrix:
34
35
  ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }}
35
36
  prism_version:
36
- - 1.2.0 # Shipped with Ruby 3.4 as default parser https://www.ruby-lang.org/en/news/2024/12/25/ruby-3-4-0-released/
37
+ # See https://stdgems.org/prism for which ruby version shipped with which prism version
38
+ - 0.19.0
39
+ - 1.2.0
37
40
  - 1.8.0
38
41
  - head
39
42
  env:
@@ -52,22 +55,3 @@ jobs:
52
55
  - name: test
53
56
  run: bin/rake test
54
57
  continue-on-error: ${{ matrix.ruby == 'head' }}
55
-
56
- test-disable-prism:
57
- needs: ruby-versions
58
- runs-on: ubuntu-latest
59
- strategy:
60
- fail-fast: false
61
- matrix:
62
- ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }}
63
- steps:
64
- - name: Checkout code
65
- uses: actions/checkout@v6
66
- - name: Set up Ruby
67
- uses: ruby/setup-ruby@v1
68
- with:
69
- ruby-version: ${{ matrix.ruby }}
70
- bundler-cache: true
71
- - name: test
72
- run: SYNTAX_SUGGEST_DISABLE_PRISM=1 bin/rake test
73
- continue-on-error: ${{ matrix.ruby == 'head' }}
@@ -12,7 +12,7 @@ jobs:
12
12
 
13
13
  - name: Create GitHub App token
14
14
  id: app-token
15
- uses: actions/create-github-app-token@v2
15
+ uses: actions/create-github-app-token@v3
16
16
  with:
17
17
  app-id: 2060836
18
18
  private-key: ${{ secrets.RUBY_SYNC_DEFAULT_GEMS_PRIVATE_KEY }}
data/.standard.yml CHANGED
@@ -1 +1 @@
1
- ruby_version: 3.0.0
1
+ ruby_version: 3.3.0
data/CHANGELOG.md CHANGED
@@ -1,8 +1,14 @@
1
1
  ## HEAD (unreleased)
2
2
 
3
+ ## 3.0.0
4
+
5
+ - Changed: Minimum supported Ruby version is now 3.3. (https://github.com/ruby/syntax_suggest/pull/246)
6
+ - Changed: Use native prism to analyse. (https://github.com/ruby/syntax_suggest/pull/251)
7
+
3
8
  ## 2.0.3
4
9
 
5
10
  - Fix: Correctly identify trailing slashes when using Prism > 1.8.0. (https://github.com/ruby/syntax_suggest/pull/243)
11
+ - Fix: Correctly handle `%I` delimiters. (https://github.com/ruby/syntax_suggest/pull/249)
6
12
  - Internal: Add tests to multiple versions of prism
7
13
 
8
14
  ## 2.0.2
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ gem "stackprof"
11
11
  gem "standard"
12
12
  gem "ruby-prof"
13
13
 
14
+ gem "benchmark"
14
15
  gem "benchmark-ips"
15
16
 
16
17
  case ENV["PRISM_VERSION"]&.strip&.downcase
@@ -7,25 +7,8 @@ require "stringio"
7
7
  require "pathname"
8
8
  require "timeout"
9
9
 
10
- # We need Ripper loaded for `Prism.lex_compat` even if we're using Prism
11
- # for lexing and parsing
12
- require "ripper"
13
-
14
10
  # Prism is the new parser, replacing Ripper
15
- #
16
- # We need to "dual boot" both for now because syntax_suggest
17
- # supports older rubies that do not ship with syntax suggest.
18
- #
19
- # We also need the ability to control loading of this library
20
- # so we can test that both modes work correctly in CI.
21
- if (value = ENV["SYNTAX_SUGGEST_DISABLE_PRISM"])
22
- warn "Skipping loading prism due to SYNTAX_SUGGEST_DISABLE_PRISM=#{value}"
23
- else
24
- begin
25
- require "prism"
26
- rescue LoadError
27
- end
28
- end
11
+ require "prism"
29
12
 
30
13
  module SyntaxSuggest
31
14
  # Used to indicate a default value that cannot
@@ -35,14 +18,6 @@ module SyntaxSuggest
35
18
  class Error < StandardError; end
36
19
  TIMEOUT_DEFAULT = ENV.fetch("SYNTAX_SUGGEST_TIMEOUT", 1).to_i
37
20
 
38
- # SyntaxSuggest.use_prism_parser? [Private]
39
- #
40
- # Tells us if the prism parser is available for use
41
- # or if we should fallback to `Ripper`
42
- def self.use_prism_parser?
43
- defined?(Prism)
44
- end
45
-
46
21
  # SyntaxSuggest.handle_error [Public]
47
22
  #
48
23
  # Takes a `SyntaxError` exception, uses the
@@ -152,20 +127,11 @@ module SyntaxSuggest
152
127
  # SyntaxSuggest.invalid? [Private]
153
128
  #
154
129
  # Opposite of `SyntaxSuggest.valid?`
155
- if defined?(Prism)
156
- def self.invalid?(source)
157
- source = source.join if source.is_a?(Array)
158
- source = source.to_s
159
-
160
- Prism.parse(source).failure?
161
- end
162
- else
163
- def self.invalid?(source)
164
- source = source.join if source.is_a?(Array)
165
- source = source.to_s
130
+ def self.invalid?(source)
131
+ source = source.join if source.is_a?(Array)
132
+ source = source.to_s
166
133
 
167
- Ripper.new(source).tap(&:parse).error?
168
- end
134
+ Prism.parse(source).failure?
169
135
  end
170
136
 
171
137
  # SyntaxSuggest.valid? [Private]
@@ -219,7 +185,6 @@ require_relative "explain_syntax"
219
185
  require_relative "clean_document"
220
186
 
221
187
  # Helpers
222
- require_relative "lex_all"
223
188
  require_relative "code_line"
224
189
  require_relative "code_block"
225
190
  require_relative "block_expand"
@@ -231,3 +196,5 @@ require_relative "priority_engulf_queue"
231
196
  require_relative "pathname_from_message"
232
197
  require_relative "display_invalid_blocks"
233
198
  require_relative "parse_blocks_from_indent_line"
199
+ require_relative "visitor"
200
+ require_relative "token"
@@ -66,27 +66,9 @@ module SyntaxSuggest
66
66
  #
67
67
  # All of these problems are fixed by joining the whole heredoc into a single
68
68
  # line.
69
- #
70
- # ## Comments and whitespace
71
- #
72
- # Comments can throw off the way the lexer tells us that the line
73
- # logically belongs with the next line. This is valid ruby but
74
- # results in a different lex output than before:
75
- #
76
- # 1 User.
77
- # 2 where(name: "schneems").
78
- # 3 # Comment here
79
- # 4 first
80
- #
81
- # To handle this we can replace comment lines with empty lines
82
- # and then re-lex the source. This removal and re-lexing preserves
83
- # line index and document size, but generates an easier to work with
84
- # document.
85
- #
86
69
  class CleanDocument
87
70
  def initialize(source:)
88
- lines = clean_sweep(source: source)
89
- @document = CodeLine.from_source(lines.join, lines: lines)
71
+ @document = CodeLine.from_source(source)
90
72
  end
91
73
 
92
74
  # Call all of the document "cleaners"
@@ -110,62 +92,6 @@ module SyntaxSuggest
110
92
  @document.join
111
93
  end
112
94
 
113
- # Remove comments
114
- #
115
- # replace with empty newlines
116
- #
117
- # source = <<~'EOM'
118
- # # Comment 1
119
- # puts "hello"
120
- # # Comment 2
121
- # puts "world"
122
- # EOM
123
- #
124
- # lines = CleanDocument.new(source: source).lines
125
- # expect(lines[0].to_s).to eq("\n")
126
- # expect(lines[1].to_s).to eq("puts "hello")
127
- # expect(lines[2].to_s).to eq("\n")
128
- # expect(lines[3].to_s).to eq("puts "world")
129
- #
130
- # Important: This must be done before lexing.
131
- #
132
- # After this change is made, we lex the document because
133
- # removing comments can change how the doc is parsed.
134
- #
135
- # For example:
136
- #
137
- # values = LexAll.new(source: <<~EOM))
138
- # User.
139
- # # comment
140
- # where(name: 'schneems')
141
- # EOM
142
- # expect(
143
- # values.count {|v| v.type == :on_ignored_nl}
144
- # ).to eq(1)
145
- #
146
- # After the comment is removed:
147
- #
148
- # values = LexAll.new(source: <<~EOM))
149
- # User.
150
- #
151
- # where(name: 'schneems')
152
- # EOM
153
- # expect(
154
- # values.count {|v| v.type == :on_ignored_nl}
155
- # ).to eq(2)
156
- #
157
- def clean_sweep(source:)
158
- # Match comments, but not HEREDOC strings with #{variable} interpolation
159
- # https://rubular.com/r/HPwtW9OYxKUHXQ
160
- source.lines.map do |line|
161
- if line.match?(/^\s*#([^{].*|)$/)
162
- $/
163
- else
164
- line
165
- end
166
- end
167
- end
168
-
169
95
  # Smushes all heredoc lines into one line
170
96
  #
171
97
  # source = <<~'EOM'
@@ -182,11 +108,11 @@ module SyntaxSuggest
182
108
  start_index_stack = []
183
109
  heredoc_beg_end_index = []
184
110
  lines.each do |line|
185
- line.lex.each do |lex_value|
186
- case lex_value.type
187
- when :on_heredoc_beg
111
+ line.tokens.each do |token|
112
+ case token.type
113
+ when :HEREDOC_START
188
114
  start_index_stack << line.index
189
- when :on_heredoc_end
115
+ when :HEREDOC_END
190
116
  start_index = start_index_stack.pop
191
117
  end_index = line.index
192
118
  heredoc_beg_end_index << [start_index, end_index]
@@ -212,20 +138,10 @@ module SyntaxSuggest
212
138
  # expect(lines[0].to_s).to eq(source)
213
139
  # expect(lines[1].to_s).to eq("")
214
140
  #
215
- # The one known case this doesn't handle is:
216
- #
217
- # Ripper.lex <<~EOM
218
- # a &&
219
- # b ||
220
- # c
221
- # EOM
222
- #
223
- # For some reason this introduces `on_ignore_newline` but with BEG type
224
- #
225
141
  def join_consecutive!
226
- consecutive_groups = @document.select(&:ignore_newline_not_beg?).map do |code_line|
142
+ consecutive_groups = @document.select(&:consecutive?).map do |code_line|
227
143
  take_while_including(code_line.index..) do |line|
228
- line.ignore_newline_not_beg?
144
+ line.consecutive?
229
145
  end
230
146
  end
231
147
 
@@ -273,16 +189,17 @@ module SyntaxSuggest
273
189
 
274
190
  # Join group into the first line
275
191
  @document[line.index] = CodeLine.new(
276
- lex: lines.map(&:lex).flatten,
192
+ tokens: lines.map(&:tokens).flatten,
277
193
  line: lines.join,
278
- index: line.index
194
+ index: line.index,
195
+ consecutive: false
279
196
  )
280
197
 
281
198
  # Hide the rest of the lines
282
199
  lines[1..].each do |line|
283
200
  # The above lines already have newlines in them, if add more
284
201
  # then there will be double newline, use an empty line instead
285
- @document[line.index] = CodeLine.new(line: "", index: line.index, lex: [])
202
+ @document[line.index] = CodeLine.new(line: "", index: line.index, tokens: [], consecutive: false)
286
203
  end
287
204
  end
288
205
  self
@@ -26,23 +26,57 @@ module SyntaxSuggest
26
26
 
27
27
  # Returns an array of CodeLine objects
28
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|
29
+ def self.from_source(source)
30
+ source = +source
31
+ parse_result = Prism.parse_lex(source)
32
+ ast, tokens = parse_result.value
33
+
34
+ clean_comments!(source, parse_result.comments)
35
+
36
+ visitor = Visitor.new
37
+ visitor.visit(ast)
38
+ tokens.sort_by! { |token, _state| token.location.start_line }
39
+
40
+ prev_token = nil
41
+ tokens.map! do |token, _state|
42
+ prev_token = Token.new(token, prev_token, visitor)
43
+ end
44
+
45
+ tokens_for_line = tokens.each_with_object(Hash.new { |h, k| h[k] = [] }) { |token, hash| hash[token.line] << token }
46
+ source.lines.map.with_index do |line, index|
33
47
  CodeLine.new(
34
48
  line: line,
35
49
  index: index,
36
- lex: lex_array_for_line[index + 1]
50
+ tokens: tokens_for_line[index + 1],
51
+ consecutive: visitor.consecutive_lines.include?(index + 1)
37
52
  )
38
53
  end
39
54
  end
40
55
 
41
- attr_reader :line, :index, :lex, :line_number, :indent
42
- def initialize(line:, index:, lex:)
43
- @lex = lex
56
+ # Remove comments that apear on their own in source. They will never be the cause
57
+ # of syntax errors and are just visual noise. Example:
58
+ #
59
+ # source = +<<~RUBY
60
+ # # Comment-only line
61
+ # foo # Inline comment
62
+ # RUBY
63
+ # CodeLine.clean_comments!(source, Prism.parse(source).comments)
64
+ # source # => "\nfoo # Inline comment\n"
65
+ def self.clean_comments!(source, comments)
66
+ # Iterate backwards since we are modifying the source in place and must preserve
67
+ # the offsets. Prism comments are sorted by their location in the source.
68
+ comments.reverse_each do |comment|
69
+ next if comment.trailing?
70
+ source.bytesplice(comment.location.start_offset, comment.location.length, "")
71
+ end
72
+ end
73
+
74
+ attr_reader :line, :index, :tokens, :line_number, :indent
75
+ def initialize(line:, index:, tokens:, consecutive:)
76
+ @tokens = tokens
44
77
  @line = line
45
78
  @index = index
79
+ @consecutive = consecutive
46
80
  @original = line
47
81
  @line_number = @index + 1
48
82
  strip_line = line.dup
@@ -151,29 +185,16 @@ module SyntaxSuggest
151
185
  index <=> other.index
152
186
  end
153
187
 
154
- # [Not stable API]
155
- #
156
- # Lines that have a `on_ignored_nl` type token and NOT
157
- # a `BEG` type seem to be a good proxy for the ability
158
- # to join multiple lines into one.
159
- #
160
- # This predicate method is used to determine when those
161
- # two criteria have been met.
162
- #
163
- # The one known case this doesn't handle is:
164
- #
165
- # Ripper.lex <<~EOM
166
- # a &&
167
- # b ||
168
- # c
169
- # EOM
170
- #
171
- # For some reason this introduces `on_ignore_newline` but with BEG type
172
- def ignore_newline_not_beg?
173
- @ignore_newline_not_beg
188
+ # Can this line be logically joined together
189
+ # with the following line? Determined by walking
190
+ # the AST
191
+ def consecutive?
192
+ @consecutive
174
193
  end
175
194
 
176
- # Determines if the given line has a trailing slash
195
+ # Determines if the given line has a trailing slash.
196
+ # Simply check if the line contains a backslash after
197
+ # the content of the last token.
177
198
  #
178
199
  # lines = CodeLine.from_source(<<~EOM)
179
200
  # it "foo" \
@@ -181,61 +202,19 @@ module SyntaxSuggest
181
202
  # expect(lines.first.trailing_slash?).to eq(true)
182
203
  #
183
204
  def trailing_slash?
184
- last = @lex.last
185
-
186
- # Older versions of prism diverged slightly from Ripper in compatibility mode
187
- case last&.type
188
- when :on_sp
189
- last.token == TRAILING_SLASH
190
- when :on_tstring_end
191
- true
192
- else
193
- false
194
- end
205
+ return unless (last = @tokens.last)
206
+ @line.byteindex(TRAILING_SLASH, last.location.end_column) != nil
195
207
  end
196
208
 
197
- # Endless method detection
198
- #
199
- # From https://github.com/ruby/irb/commit/826ae909c9c93a2ddca6f9cfcd9c94dbf53d44ab
200
- # Detecting a "oneliner" seems to need a state machine.
201
- # This can be done by looking mostly at the "state" (last value):
202
- #
203
- # ENDFN -> BEG (token = '=' ) -> END
204
- #
205
209
  private def set_kw_end
206
- oneliner_count = 0
207
- in_oneliner_def = nil
208
-
209
210
  kw_count = 0
210
211
  end_count = 0
211
212
 
212
- @ignore_newline_not_beg = false
213
- @lex.each do |lex|
214
- kw_count += 1 if lex.is_kw?
215
- end_count += 1 if lex.is_end?
216
-
217
- if lex.type == :on_ignored_nl
218
- @ignore_newline_not_beg = !lex.expr_beg?
219
- end
220
-
221
- if in_oneliner_def.nil?
222
- in_oneliner_def = :ENDFN if lex.state.allbits?(Ripper::EXPR_ENDFN)
223
- elsif lex.state.allbits?(Ripper::EXPR_ENDFN)
224
- # Continue
225
- elsif lex.state.allbits?(Ripper::EXPR_BEG)
226
- in_oneliner_def = :BODY if lex.token == "="
227
- elsif lex.state.allbits?(Ripper::EXPR_END)
228
- # We found an endless method, count it
229
- oneliner_count += 1 if in_oneliner_def == :BODY
230
-
231
- in_oneliner_def = nil
232
- else
233
- in_oneliner_def = nil
234
- end
213
+ @tokens.each do |token|
214
+ kw_count += 1 if token.is_kw?
215
+ end_count += 1 if token.is_end?
235
216
  end
236
217
 
237
- kw_count -= oneliner_count
238
-
239
218
  @is_kw = (kw_count - end_count) > 0
240
219
  @is_end = (end_count - kw_count) > 0
241
220
  end
@@ -1,96 +1,47 @@
1
1
  # frozen_string_literal: true
2
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
- # SyntaxSuggest.module_for_detailed_message [Private]
7
- #
8
- # Used to monkeypatch SyntaxError via Module.prepend
9
- def self.module_for_detailed_message
10
- Module.new {
11
- def detailed_message(highlight: true, syntax_suggest: true, **kwargs)
12
- return super unless syntax_suggest
13
-
14
- require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
15
-
16
- message = super
17
-
18
- if path
19
- file = Pathname.new(path)
20
- io = SyntaxSuggest::MiniStringIO.new
21
-
22
- SyntaxSuggest.call(
23
- io: io,
24
- source: file.read,
25
- filename: file,
26
- terminal: highlight
27
- )
28
- annotation = io.string
29
-
30
- annotation += "\n" unless annotation.end_with?("\n")
31
-
32
- annotation + message
33
- else
34
- message
35
- end
36
- rescue => e
37
- if ENV["SYNTAX_SUGGEST_DEBUG"]
38
- $stderr.warn(e.message)
39
- $stderr.warn(e.backtrace)
40
- end
41
-
42
- # Ignore internal errors
3
+ module SyntaxSuggest
4
+ # SyntaxSuggest.module_for_detailed_message [Private]
5
+ #
6
+ # Used to monkeypatch SyntaxError via Module.prepend
7
+ def self.module_for_detailed_message
8
+ Module.new {
9
+ def detailed_message(highlight: true, syntax_suggest: true, **kwargs)
10
+ return super unless syntax_suggest
11
+
12
+ require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
13
+
14
+ message = super
15
+
16
+ if path
17
+ file = Pathname.new(path)
18
+ io = SyntaxSuggest::MiniStringIO.new
19
+
20
+ SyntaxSuggest.call(
21
+ io: io,
22
+ source: file.read,
23
+ filename: file,
24
+ terminal: highlight
25
+ )
26
+ annotation = io.string
27
+
28
+ annotation += "\n" unless annotation.end_with?("\n")
29
+
30
+ annotation + message
31
+ else
43
32
  message
44
33
  end
45
- }
46
- end
47
- end
48
-
49
- SyntaxError.prepend(SyntaxSuggest.module_for_detailed_message)
50
- else
51
- autoload :Pathname, "pathname"
52
-
53
- #--
54
- # Monkey patch kernel to ensure that all `require` calls call the same
55
- # method
56
- #++
57
- module Kernel
58
- # :stopdoc:
59
-
60
- module_function
61
-
62
- alias_method :syntax_suggest_original_require, :require
63
- alias_method :syntax_suggest_original_require_relative, :require_relative
64
- alias_method :syntax_suggest_original_load, :load
65
-
66
- def load(file, wrap = false)
67
- syntax_suggest_original_load(file)
68
- rescue SyntaxError => e
69
- require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
70
-
71
- SyntaxSuggest.handle_error(e)
72
- end
73
-
74
- def require(file)
75
- syntax_suggest_original_require(file)
76
- rescue SyntaxError => e
77
- require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
78
-
79
- SyntaxSuggest.handle_error(e)
80
- end
34
+ rescue => e
35
+ if ENV["SYNTAX_SUGGEST_DEBUG"]
36
+ $stderr.warn(e.message)
37
+ $stderr.warn(e.backtrace)
38
+ end
81
39
 
82
- def require_relative(file)
83
- if Pathname.new(file).absolute?
84
- syntax_suggest_original_require file
85
- else
86
- relative_from = caller_locations(1..1).first
87
- relative_from_path = relative_from.absolute_path || relative_from.path
88
- syntax_suggest_original_require File.expand_path("../#{file}", relative_from_path)
40
+ # Ignore internal errors
41
+ message
89
42
  end
90
- rescue SyntaxError => e
91
- require "syntax_suggest/api" unless defined?(SyntaxSuggest::DEFAULT_VALUE)
92
-
93
- SyntaxSuggest.handle_error(e)
94
- end
43
+ }
95
44
  end
96
45
  end
46
+
47
+ SyntaxError.prepend(SyntaxSuggest.module_for_detailed_message)
@@ -1,19 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "left_right_lex_count"
4
-
5
- if !SyntaxSuggest.use_prism_parser?
6
- require_relative "ripper_errors"
7
- end
3
+ require_relative "left_right_token_count"
8
4
 
9
5
  module SyntaxSuggest
10
6
  class GetParseErrors
11
7
  def self.errors(source)
12
- if SyntaxSuggest.use_prism_parser?
13
- Prism.parse(source).errors.map(&:message)
14
- else
15
- RipperErrors.new(source).call.errors
16
- end
8
+ Prism.parse(source).errors.map(&:message)
17
9
  end
18
10
  end
19
11
 
@@ -53,14 +45,14 @@ module SyntaxSuggest
53
45
 
54
46
  def initialize(code_lines:)
55
47
  @code_lines = code_lines
56
- @left_right = LeftRightLexCount.new
48
+ @left_right = LeftRightTokenCount.new
57
49
  @missing = nil
58
50
  end
59
51
 
60
52
  def call
61
53
  @code_lines.each do |line|
62
- line.lex.each do |lex|
63
- @left_right.count_lex(lex)
54
+ line.tokens.each do |token|
55
+ @left_right.count_token(token)
64
56
  end
65
57
  end
66
58
 
@@ -9,19 +9,19 @@ module SyntaxSuggest
9
9
  #
10
10
  # Example:
11
11
  #
12
- # left_right = LeftRightLexCount.new
12
+ # left_right = LeftRightTokenCount.new
13
13
  # left_right.count_kw
14
14
  # left_right.missing.first
15
15
  # # => "end"
16
16
  #
17
- # left_right = LeftRightLexCount.new
17
+ # left_right = LeftRightTokenCount.new
18
18
  # source = "{ a: b, c: d" # Note missing '}'
19
- # LexAll.new(source: source).each do |lex|
20
- # left_right.count_lex(lex)
19
+ # LexAll.new(source: source).each do |token|
20
+ # left_right.count_token(token)
21
21
  # end
22
22
  # left_right.missing.first
23
23
  # # => "}"
24
- class LeftRightLexCount
24
+ class LeftRightTokenCount
25
25
  def initialize
26
26
  @kw_count = 0
27
27
  @end_count = 0
@@ -49,52 +49,46 @@ module SyntaxSuggest
49
49
  #
50
50
  # Example:
51
51
  #
52
- # left_right = LeftRightLexCount.new
53
- # left_right.count_lex(LexValue.new(1, :on_lbrace, "{", Ripper::EXPR_BEG))
52
+ # token = CodeLine.from_source("{").first.tokens.first
53
+ # left_right = LeftRightTokenCount.new
54
+ # left_right.count_token(Token.new(token)
54
55
  # left_right.count_for_char("{")
55
56
  # # => 1
56
57
  # left_right.count_for_char("}")
57
58
  # # => 0
58
- def count_lex(lex)
59
- case lex.type
60
- when :on_tstring_content
59
+ def count_token(token)
60
+ case token.type
61
+ when :STRING_CONTENT
61
62
  # ^^^
62
63
  # Means it's a string or a symbol `"{"` rather than being
63
64
  # part of a data structure (like a hash) `{ a: b }`
64
65
  # ignore it.
65
- when :on_words_beg, :on_symbos_beg, :on_qwords_beg,
66
- :on_qsymbols_beg, :on_regexp_beg, :on_tstring_beg
66
+ when :PERCENT_UPPER_W, :PERCENT_UPPER_I, :PERCENT_LOWER_W,
67
+ :PERCENT_LOWER_I, :REGEXP_BEGIN, :STRING_BEGIN
67
68
  # ^^^
68
69
  # Handle shorthand syntaxes like `%Q{ i am a string }`
69
70
  #
70
71
  # The start token will be the full thing `%Q{` but we
71
72
  # need to count it as if it's a `{`. Any token
72
73
  # can be used
73
- char = lex.token[-1]
74
+ char = token.value[-1]
74
75
  @count_for_char[char] += 1 if @count_for_char.key?(char)
75
- when :on_embexpr_beg
76
+ when :EMBEXPR_BEGIN
76
77
  # ^^^
77
78
  # Embedded string expressions like `"#{foo} <-embed"`
78
79
  # are parsed with chars:
79
80
  #
80
- # `#{` as :on_embexpr_beg
81
- # `}` as :on_embexpr_end
82
- #
83
- # We cannot ignore both :on_emb_expr_beg and :on_embexpr_end
84
- # because sometimes the lexer thinks something is an embed
85
- # string end, when it is not like `lol = }` (no clue why).
81
+ # `#{` as :EMBEXPR_BEGIN
82
+ # `}` as :EMBEXPR_END
86
83
  #
87
84
  # When we see `#{` count it as a `{` or we will
88
85
  # have a mis-match count.
89
86
  #
90
- case lex.token
91
- when "\#{"
92
- @count_for_char["{"] += 1
93
- end
87
+ @count_for_char["{"] += 1
94
88
  else
95
- @end_count += 1 if lex.is_end?
96
- @kw_count += 1 if lex.is_kw?
97
- @count_for_char[lex.token] += 1 if @count_for_char.key?(lex.token)
89
+ @end_count += 1 if token.is_end?
90
+ @kw_count += 1 if token.is_kw?
91
+ @count_for_char[token.value] += 1 if @count_for_char.key?(token.value)
98
92
  end
99
93
  end
100
94
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxSuggest
4
+ # Value object for accessing lex values
5
+ #
6
+ # This lex:
7
+ #
8
+ # [IDENTIFIER(1,0)-(1,8)("describe"), 32]
9
+ #
10
+ # Would translate into:
11
+ #
12
+ # lex.location # => (1,0)-(1,8)
13
+ # lex.type # => :IDENTIFIER
14
+ # lex.token # => "describe"
15
+ class Token
16
+ attr_reader :location, :type, :value
17
+
18
+ KW_TYPES = %i[
19
+ KEYWORD_IF KEYWORD_UNLESS KEYWORD_WHILE KEYWORD_UNTIL
20
+ KEYWORD_DEF KEYWORD_CASE KEYWORD_FOR KEYWORD_BEGIN KEYWORD_CLASS KEYWORD_MODULE KEYWORD_DO KEYWORD_DO_LOOP
21
+ ].to_set.freeze
22
+ private_constant :KW_TYPES
23
+
24
+ def initialize(prism_token, previous_prism_token, visitor)
25
+ @location = prism_token.location
26
+ @type = prism_token.type
27
+ @value = prism_token.value
28
+
29
+ # Prism lexes `:module` as SYMBOL_BEGIN, KEYWORD_MODULE
30
+ # https://github.com/ruby/prism/issues/3940
31
+ symbol_content = previous_prism_token&.type == :SYMBOL_BEGIN
32
+ @is_kw = KW_TYPES.include?(@type)
33
+ @is_kw = false if symbol_content || visitor.endless_def_keyword_offsets.include?(@location.start_offset)
34
+ @is_end = @type == :KEYWORD_END
35
+ end
36
+
37
+ def line
38
+ @location.start_line
39
+ end
40
+
41
+ def is_end?
42
+ @is_end
43
+ end
44
+
45
+ def is_kw?
46
+ @is_kw
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SyntaxSuggest
4
- VERSION = "2.0.3"
4
+ VERSION = "3.0.0"
5
5
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxSuggest
4
+ # Walks the Prism AST to extract structural info that cannot be reliably determined from tokens
5
+ # alone.
6
+ #
7
+ # Such as the location of lines that must be logically joined so the search algorithm will
8
+ # treat them as one. Example:
9
+ #
10
+ # source = <<~RUBY
11
+ # User # 1
12
+ # .where(name: "Earlopain") # 2
13
+ # .first # 3
14
+ # RUBY
15
+ # ast, _tokens = Prism.parse_lex(source).value
16
+ # visitor = Visitor.new
17
+ # visitor.visit(ast)
18
+ # visitor.consecutive_lines # => Set[2, 1]
19
+ #
20
+ # This output means that line 1 and line 2 need to be joined with their next line.
21
+ #
22
+ # And determining the location of "endless" method definitions. For example:
23
+ #
24
+ # source = <<~RUBY
25
+ # def cube(x)
26
+ # x * x * x
27
+ # end
28
+ # def square(x) = x * x # 1
29
+ # RUBY
30
+ #
31
+ # ast, _tokens = Prism.parse_lex(source).value
32
+ # visitor = Visitor.new
33
+ # visitor.visit(ast)
34
+ # visitor.endless_def_keyword_offsets # => Set[28]
35
+ class Visitor < Prism::Visitor
36
+ attr_reader :endless_def_keyword_offsets, :consecutive_lines
37
+
38
+ def initialize
39
+ @endless_def_keyword_offsets = Set.new
40
+ @consecutive_lines = Set.new
41
+ end
42
+
43
+ # Called by Prism::Visitor for every method-call node in the AST
44
+ # (e.g. `foo.bar`, `foo.bar.baz`).
45
+ def visit_call_node(node)
46
+ receiver_loc = node.receiver&.location
47
+ call_operator_loc = node.call_operator_loc
48
+ message_loc = node.message_loc
49
+ if receiver_loc && call_operator_loc && message_loc
50
+ # dot-leading (dot on the next line)
51
+ # foo # line 1 - consecutive
52
+ # .bar # line 2
53
+ if receiver_loc.end_line != call_operator_loc.start_line && call_operator_loc.start_line == message_loc.start_line
54
+ (receiver_loc.end_line..call_operator_loc.start_line - 1).each do |line|
55
+ @consecutive_lines << line
56
+ end
57
+ end
58
+
59
+ # dot-trailing (dot on the same line as the receiver)
60
+ # foo. # line 1 - consecutive
61
+ # bar # line 2
62
+ if receiver_loc.end_line == call_operator_loc.start_line && call_operator_loc.start_line != message_loc.start_line
63
+ (call_operator_loc.start_line..message_loc.start_line - 1).each do |line|
64
+ @consecutive_lines << line
65
+ end
66
+ end
67
+ end
68
+ super
69
+ end
70
+
71
+ # Called by Prism::Visitor for every `def` node in the AST.
72
+ # Records the keyword start location for endless method definitions
73
+ # like `def foo = 123`. These are valid without a matching `end`,
74
+ # so Token must exclude them when deciding if a line is a keyword.
75
+ def visit_def_node(node)
76
+ @endless_def_keyword_offsets << node.def_keyword_loc.start_offset if node.equal_loc
77
+ super
78
+ end
79
+ end
80
+ end
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
16
16
  spec.description = 'When you get an "unexpected end" in your syntax this gem helps you find it'
17
17
  spec.homepage = "https://github.com/ruby/syntax_suggest.git"
18
18
  spec.license = "MIT"
19
- spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
19
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0")
20
20
 
21
21
  spec.metadata["homepage_uri"] = spec.homepage
22
22
  spec.metadata["source_code_uri"] = "https://github.com/ruby/syntax_suggest.git"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syntax_suggest
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.3
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - schneems
@@ -54,18 +54,17 @@ files:
54
54
  - lib/syntax_suggest/display_code_with_line_numbers.rb
55
55
  - lib/syntax_suggest/display_invalid_blocks.rb
56
56
  - lib/syntax_suggest/explain_syntax.rb
57
- - lib/syntax_suggest/left_right_lex_count.rb
58
- - lib/syntax_suggest/lex_all.rb
59
- - lib/syntax_suggest/lex_value.rb
57
+ - lib/syntax_suggest/left_right_token_count.rb
60
58
  - lib/syntax_suggest/mini_stringio.rb
61
59
  - lib/syntax_suggest/parse_blocks_from_indent_line.rb
62
60
  - lib/syntax_suggest/pathname_from_message.rb
63
61
  - lib/syntax_suggest/priority_engulf_queue.rb
64
62
  - lib/syntax_suggest/priority_queue.rb
65
- - lib/syntax_suggest/ripper_errors.rb
66
63
  - lib/syntax_suggest/scan_history.rb
64
+ - lib/syntax_suggest/token.rb
67
65
  - lib/syntax_suggest/unvisited_lines.rb
68
66
  - lib/syntax_suggest/version.rb
67
+ - lib/syntax_suggest/visitor.rb
69
68
  - syntax_suggest.gemspec
70
69
  homepage: https://github.com/ruby/syntax_suggest.git
71
70
  licenses:
@@ -80,7 +79,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
79
  requirements:
81
80
  - - ">="
82
81
  - !ruby/object:Gem::Version
83
- version: 3.0.0
82
+ version: 3.3.0
84
83
  required_rubygems_version: !ruby/object:Gem::Requirement
85
84
  requirements:
86
85
  - - ">="
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SyntaxSuggest
4
- # Ripper.lex is not guaranteed to lex the entire source document
5
- #
6
- # This class guarantees the whole document is lex-ed by iteratively
7
- # lexing the document where ripper stopped.
8
- #
9
- # Prism likely doesn't have the same problem. Once ripper support is removed
10
- # we can likely reduce the complexity here if not remove the whole concept.
11
- #
12
- # Example usage:
13
- #
14
- # lex = LexAll.new(source: source)
15
- # lex.each do |value|
16
- # puts value.line
17
- # end
18
- class LexAll
19
- include Enumerable
20
-
21
- def initialize(source:, source_lines: nil)
22
- @lex = self.class.lex(source, 1)
23
- lineno = @lex.last[0][0] + 1
24
- source_lines ||= source.lines
25
- last_lineno = source_lines.length
26
-
27
- until lineno >= last_lineno
28
- lines = source_lines[lineno..]
29
-
30
- @lex.concat(
31
- self.class.lex(lines.join, lineno + 1)
32
- )
33
-
34
- lineno = @lex.last[0].first + 1
35
- end
36
-
37
- last_lex = nil
38
- @lex.map! { |elem|
39
- last_lex = LexValue.new(elem[0].first, elem[1], elem[2], elem[3], last_lex)
40
- }
41
- end
42
-
43
- if SyntaxSuggest.use_prism_parser?
44
- def self.lex(source, line_number)
45
- Prism.lex_compat(source, line: line_number).value.sort_by { |values| values[0] }
46
- end
47
- else
48
- def self.lex(source, line_number)
49
- Ripper::Lexer.new(source, "-", line_number).parse.sort_by(&:pos)
50
- end
51
- end
52
-
53
- def to_a
54
- @lex
55
- end
56
-
57
- def each
58
- return @lex.each unless block_given?
59
- @lex.each do |x|
60
- yield x
61
- end
62
- end
63
-
64
- def [](index)
65
- @lex[index]
66
- end
67
-
68
- def last
69
- @lex.last
70
- end
71
- end
72
- end
73
-
74
- require_relative "lex_value"
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SyntaxSuggest
4
- # Value object for accessing lex values
5
- #
6
- # This lex:
7
- #
8
- # [1, 0], :on_ident, "describe", CMDARG
9
- #
10
- # Would translate into:
11
- #
12
- # lex.line # => 1
13
- # lex.type # => :on_indent
14
- # lex.token # => "describe"
15
- class LexValue
16
- attr_reader :line, :type, :token, :state
17
-
18
- def initialize(line, type, token, state, last_lex = nil)
19
- @line = line
20
- @type = type
21
- @token = token
22
- @state = state
23
-
24
- set_kw_end(last_lex)
25
- end
26
-
27
- private def set_kw_end(last_lex)
28
- @is_end = false
29
- @is_kw = false
30
- return if type != :on_kw
31
-
32
- return if last_lex && last_lex.fname? # https://github.com/ruby/ruby/commit/776759e300e4659bb7468e2b97c8c2d4359a2953
33
-
34
- case token
35
- when "if", "unless", "while", "until"
36
- # Only count if/unless when it's not a "trailing" if/unless
37
- # https://github.com/ruby/ruby/blob/06b44f819eb7b5ede1ff69cecb25682b56a1d60c/lib/irb/ruby-lex.rb#L374-L375
38
- @is_kw = true unless expr_label?
39
- when "def", "case", "for", "begin", "class", "module", "do"
40
- @is_kw = true
41
- when "end"
42
- @is_end = true
43
- end
44
- end
45
-
46
- def fname?
47
- state.allbits?(Ripper::EXPR_FNAME)
48
- end
49
-
50
- def ignore_newline?
51
- type == :on_ignored_nl
52
- end
53
-
54
- def is_end?
55
- @is_end
56
- end
57
-
58
- def is_kw?
59
- @is_kw
60
- end
61
-
62
- def expr_beg?
63
- state.anybits?(Ripper::EXPR_BEG)
64
- end
65
-
66
- def expr_label?
67
- state.allbits?(Ripper::EXPR_LABEL)
68
- end
69
- end
70
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SyntaxSuggest
4
- # Capture parse errors from Ripper
5
- #
6
- # Prism returns the errors with their messages, but Ripper
7
- # does not. To get them we must make a custom subclass.
8
- #
9
- # Example:
10
- #
11
- # puts RipperErrors.new(" def foo").call.errors
12
- # # => ["syntax error, unexpected end-of-input, expecting ';' or '\\n'"]
13
- class RipperErrors < Ripper
14
- attr_reader :errors
15
-
16
- # Comes from ripper, called
17
- # on every parse error, msg
18
- # is a string
19
- def on_parse_error(msg)
20
- @errors ||= []
21
- @errors << msg
22
- end
23
-
24
- alias_method :on_alias_error, :on_parse_error
25
- alias_method :on_assign_error, :on_parse_error
26
- alias_method :on_class_name_error, :on_parse_error
27
- alias_method :on_param_error, :on_parse_error
28
- alias_method :compile_error, :on_parse_error
29
-
30
- def call
31
- @run_once ||= begin
32
- @errors = []
33
- parse
34
- true
35
- end
36
- self
37
- end
38
- end
39
- end