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
data/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # SyntaxSuggest
2
+
3
+ An error in your code forces you to stop. SyntaxSuggest helps you find those errors to get you back on your way faster.
4
+
5
+ ```
6
+ Unmatched `end', missing keyword (`do', `def`, `if`, etc.) ?
7
+
8
+ 1 class Dog
9
+ ❯ 2 defbark
10
+ ❯ 4 end
11
+ 5 end
12
+ ```
13
+
14
+ ## Installation in your codebase
15
+
16
+ To automatically annotate errors when they happen, add this to your Gemfile:
17
+
18
+ ```ruby
19
+ gem 'syntax_suggest'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ $ bundle install
25
+
26
+ If your application is not calling `Bundler.require` then you must manually add a require:
27
+
28
+ ```ruby
29
+ require "syntax_suggest"
30
+ ```
31
+
32
+ If you're using rspec add this to your `.rspec` file:
33
+
34
+ ```
35
+ --require syntax_suggest
36
+ ```
37
+
38
+ > This is needed because people can execute a single test file via `bundle exec rspec path/to/file_spec.rb` and if that file has a syntax error, it won't load `spec_helper.rb` to trigger any requires.
39
+
40
+ ## Install the CLI
41
+
42
+ To get the CLI and manually search for syntax errors (but not automatically annotate them), you can manually install the gem:
43
+
44
+ $ gem install syntax_suggest
45
+
46
+ This gives you the CLI command `$ syntax_suggest` for more info run `$ syntax_suggest --help`.
47
+
48
+ ## Editor integration
49
+
50
+ An extension is available for VSCode:
51
+
52
+ - Extension: https://marketplace.visualstudio.com/items?itemName=Zombocom.dead-end-vscode
53
+ - GitHub: https://github.com/zombocom/dead_end-vscode
54
+
55
+ ## What syntax errors does it handle?
56
+
57
+ Syntax suggest will fire against all syntax errors and can isolate any syntax error. In addition, syntax_suggest attempts to produce human readable descriptions of what needs to be done to resolve the issue. For example:
58
+
59
+ - Missing `end`:
60
+
61
+ <!--
62
+ ```ruby
63
+ class Dog
64
+ def bark
65
+ puts "bark"
66
+ end
67
+ ```
68
+ -->
69
+
70
+ ```
71
+ Unmatched keyword, missing `end' ?
72
+
73
+ ❯ 1 class Dog
74
+ ❯ 2 def bark
75
+ ❯ 4 end
76
+ ```
77
+
78
+ - Missing keyword
79
+ <!--
80
+ ```ruby
81
+ class Dog
82
+ def speak
83
+ @sounds.each |sound|
84
+ puts sound
85
+ end
86
+ end
87
+ end
88
+ ```
89
+ -->
90
+
91
+ ```
92
+ Unmatched `end', missing keyword (`do', `def`, `if`, etc.) ?
93
+
94
+ 1 class Dog
95
+ 2 def speak
96
+ ❯ 3 @sounds.each |sound|
97
+ ❯ 5 end
98
+ 6 end
99
+ 7 end
100
+ ```
101
+
102
+ - Missing pair characters (like `{}`, `[]`, `()` , or `|<var>|`)
103
+ <!--
104
+
105
+ ```ruby
106
+ class Dog
107
+ def speak(sound
108
+ puts sound
109
+ end
110
+ end
111
+ ```
112
+ -->
113
+
114
+ ```
115
+ Unmatched `(', missing `)' ?
116
+
117
+ 1 class Dog
118
+ ❯ 2 def speak(sound
119
+ ❯ 4 end
120
+ 5 end
121
+ ```
122
+
123
+ - Any ambiguous or unknown errors will be annotated by the original ripper error output:
124
+
125
+ <!--
126
+ class Dog
127
+ def meals_last_month
128
+ puts 3 *
129
+ end
130
+ end
131
+ -->
132
+
133
+ ```
134
+ syntax error, unexpected end-of-input
135
+
136
+ 1 class Dog
137
+ 2 def meals_last_month
138
+ ❯ 3 puts 3 *
139
+ 4 end
140
+ 5 end
141
+ ```
142
+
143
+ ## How is it better than `ruby -wc`?
144
+
145
+ Ruby allows you to syntax check a file with warnings using `ruby -wc`. This emits a parser error instead of a human focused error. Ruby's parse errors attempt to narrow down the location and can tell you if there is a glaring indentation error involving `end`.
146
+
147
+ The `syntax_suggest` algorithm doesn't just guess at the location of syntax errors, it re-parses the document to prove that it captured them.
148
+
149
+ This library focuses on the human side of syntax errors. It cares less about why the document could not be parsed (computer problem) and more on what the programmer needs (human problem) to fix the problem.
150
+
151
+ ## Sounds cool, but why isn't this baked into Ruby directly?
152
+
153
+ We are now talking about it https://bugs.ruby-lang.org/issues/18159#change-93682.
154
+
155
+ ## Artificial Inteligence?
156
+
157
+ This library uses a goal-seeking algorithm for syntax error detection similar to that of a path-finding search. For more information [read the blog post about how it works under the hood](https://schneems.com/2020/12/01/squash-unexpectedend-errors-with-syntaxsearch/).
158
+
159
+ ## How does it detect syntax error locations?
160
+
161
+ We know that source code that does not contain a syntax error can be parsed. We also know that code with a syntax error contains both valid code and invalid code. If you remove the invalid code, then we can programatically determine that the code we removed contained a syntax error. We can do this detection by generating small code blocks and searching for which blocks need to be removed to generate valid source code.
162
+
163
+ Since there can be multiple syntax errors in a document it's not good enough to check individual code blocks, we've got to check multiple at the same time. We will keep creating and adding new blocks to our search until we detect that our "frontier" (which contains all of our blocks) contains the syntax error. After this, we can stop our search and instead focus on filtering to find the smallest subset of blocks that contain the syntax error.
164
+
165
+ Here's an example:
166
+
167
+ ![](assets/syntax_search.gif)
168
+
169
+ ## Use internals
170
+
171
+ To use the `syntax_suggest` gem without monkeypatching you can `require 'syntax_suggest/api'`. This will allow you to load `syntax_suggest` and use its internals without mutating `require`.
172
+
173
+ Stable internal interface(s):
174
+
175
+ - `SyntaxSuggest.handle_error(e)`
176
+
177
+ Any other entrypoints are subject to change without warning. If you want to use an internal interface from `syntax_suggest` not on this list, open an issue to explain your use case.
178
+
179
+ ## Development
180
+
181
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
182
+
183
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
184
+
185
+ ### How to debug changes to output display
186
+
187
+ You can see changes to output against a variety of invalid code by running specs and using the `DEBUG_DISPLAY=1` environment variable. For example:
188
+
189
+ ```
190
+ $ DEBUG_DISPLAY=1 bundle exec rspec spec/ --format=failures
191
+ ```
192
+
193
+ ### Run profiler
194
+
195
+ You can output profiler data to the `tmp` directory by running:
196
+
197
+ ```
198
+ $ DEBUG_PERF=1 bundle exec rspec spec/integration/syntax_suggest_spec.rb
199
+ ```
200
+
201
+ Some outputs are in text format, some are html, the raw marshaled data is available in `raw.rb.marshal`. See https://ruby-prof.github.io/#reports for more info. One interesting one, is the "kcachegrind" interface. To view this on mac:
202
+
203
+ ```
204
+ $ brew install qcachegrind
205
+ ```
206
+
207
+ Open:
208
+
209
+ ```
210
+ $ qcachegrind tmp/last/profile.callgrind.out.<numbers>
211
+ ```
212
+
213
+ ## Environment variables
214
+
215
+ - `SYNTAX_SUGGEST_DEBUG` - Enables debug output to STDOUT/STDERR and/or disk at `./tmp`. The contents of debugging output are not stable and may change. If you would like stability, please open an issue to explain your use case.
216
+ - `SYNTAX_SUGGEST_TIMEOUT` - Changes the default timeout value to the number set (in seconds).
217
+
218
+ ## Contributing
219
+
220
+ Bug reports and pull requests are welcome on GitHub at https://github.com/zombocom/syntax_suggest. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zombocom/syntax_suggest/blob/main/CODE_OF_CONDUCT.md).
221
+
222
+
223
+ ## License
224
+
225
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
226
+
227
+ ## Code of Conduct
228
+
229
+ Everyone interacting in the SyntaxSuggest project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zombocom/syntax_suggest/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "syntax_suggest"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/dead_end.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require_relative "lib/syntax_suggest/version"
5
+ rescue LoadError # Fallback to load version file in ruby core repository
6
+ require_relative "version"
7
+ end
8
+
9
+ Gem::Specification.new do |spec|
10
+ spec.name = "syntax_suggest"
11
+ spec.version = SyntaxSuggest::VERSION
12
+ spec.authors = ["schneems"]
13
+ spec.email = ["richard.schneeman+foo@gmail.com"]
14
+
15
+ spec.summary = "Find syntax errors in your source in a snap"
16
+ spec.description = 'When you get an "unexpected end" in your syntax this gem helps you find it'
17
+ spec.homepage = "https://github.com/zombocom/syntax_suggest.git"
18
+ spec.license = "MIT"
19
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/zombocom/syntax_suggest.git"
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|assets)/}) }
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/syntax_suggest/api"
4
+
5
+ SyntaxSuggest::Cli.new(
6
+ argv: ARGV
7
+ ).call
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ require "tmpdir"
6
+ require "stringio"
7
+ require "pathname"
8
+ require "ripper"
9
+ require "timeout"
10
+
11
+ module SyntaxSuggest
12
+ # Used to indicate a default value that cannot
13
+ # be confused with another input.
14
+ DEFAULT_VALUE = Object.new.freeze
15
+
16
+ class Error < StandardError; end
17
+ TIMEOUT_DEFAULT = ENV.fetch("SYNTAX_SUGGEST_TIMEOUT", 1).to_i
18
+
19
+ # SyntaxSuggest.handle_error [Public]
20
+ #
21
+ # Takes a `SyntaxError` exception, uses the
22
+ # error message to locate the file. Then the file
23
+ # will be analyzed to find the location of the syntax
24
+ # error and emit that location to stderr.
25
+ #
26
+ # Example:
27
+ #
28
+ # begin
29
+ # require 'bad_file'
30
+ # rescue => e
31
+ # SyntaxSuggest.handle_error(e)
32
+ # end
33
+ #
34
+ # By default it will re-raise the exception unless
35
+ # `re_raise: false`. The message output location
36
+ # can be configured using the `io: $stderr` input.
37
+ #
38
+ # If a valid filename cannot be determined, the original
39
+ # exception will be re-raised (even with
40
+ # `re_raise: false`).
41
+ def self.handle_error(e, re_raise: true, io: $stderr)
42
+ unless e.is_a?(SyntaxError)
43
+ io.puts("SyntaxSuggest: Must pass a SyntaxError, got: #{e.class}")
44
+ raise e
45
+ end
46
+
47
+ file = PathnameFromMessage.new(e.message, io: io).call.name
48
+ raise e unless file
49
+
50
+ io.sync = true
51
+
52
+ call(
53
+ io: io,
54
+ source: file.read,
55
+ filename: file
56
+ )
57
+
58
+ raise e if re_raise
59
+ end
60
+
61
+ # SyntaxSuggest.call [Private]
62
+ #
63
+ # Main private interface
64
+ def self.call(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_dir: DEFAULT_VALUE, timeout: TIMEOUT_DEFAULT, io: $stderr)
65
+ search = nil
66
+ filename = nil if filename == DEFAULT_VALUE
67
+ Timeout.timeout(timeout) do
68
+ record_dir ||= ENV["DEBUG"] ? "tmp" : nil
69
+ search = CodeSearch.new(source, record_dir: record_dir).call
70
+ end
71
+
72
+ blocks = search.invalid_blocks
73
+ DisplayInvalidBlocks.new(
74
+ io: io,
75
+ blocks: blocks,
76
+ filename: filename,
77
+ terminal: terminal,
78
+ code_lines: search.code_lines
79
+ ).call
80
+ rescue Timeout::Error => e
81
+ io.puts "Search timed out SYNTAX_SUGGEST_TIMEOUT=#{timeout}, run with DEBUG=1 for more info"
82
+ io.puts e.backtrace.first(3).join($/)
83
+ end
84
+
85
+ # SyntaxSuggest.record_dir [Private]
86
+ #
87
+ # Used to generate a unique directory to record
88
+ # search steps for debugging
89
+ def self.record_dir(dir)
90
+ time = Time.now.strftime("%Y-%m-%d-%H-%M-%s-%N")
91
+ dir = Pathname(dir)
92
+ dir.join(time).tap { |path|
93
+ path.mkpath
94
+ FileUtils.ln_sf(time, dir.join("last"))
95
+ }
96
+ end
97
+
98
+ # SyntaxSuggest.valid_without? [Private]
99
+ #
100
+ # This will tell you if the `code_lines` would be valid
101
+ # if you removed the `without_lines`. In short it's a
102
+ # way to detect if we've found the lines with syntax errors
103
+ # in our document yet.
104
+ #
105
+ # code_lines = [
106
+ # CodeLine.new(line: "def foo\n", index: 0)
107
+ # CodeLine.new(line: " def bar\n", index: 1)
108
+ # CodeLine.new(line: "end\n", index: 2)
109
+ # ]
110
+ #
111
+ # SyntaxSuggest.valid_without?(
112
+ # without_lines: code_lines[1],
113
+ # code_lines: code_lines
114
+ # ) # => true
115
+ #
116
+ # SyntaxSuggest.valid?(code_lines) # => false
117
+ def self.valid_without?(without_lines:, code_lines:)
118
+ lines = code_lines - Array(without_lines).flatten
119
+
120
+ if lines.empty?
121
+ true
122
+ else
123
+ valid?(lines)
124
+ end
125
+ end
126
+
127
+ # SyntaxSuggest.invalid? [Private]
128
+ #
129
+ # Opposite of `SyntaxSuggest.valid?`
130
+ def self.invalid?(source)
131
+ source = source.join if source.is_a?(Array)
132
+ source = source.to_s
133
+
134
+ Ripper.new(source).tap(&:parse).error?
135
+ end
136
+
137
+ # SyntaxSuggest.valid? [Private]
138
+ #
139
+ # Returns truthy if a given input source is valid syntax
140
+ #
141
+ # SyntaxSuggest.valid?(<<~EOM) # => true
142
+ # def foo
143
+ # end
144
+ # EOM
145
+ #
146
+ # SyntaxSuggest.valid?(<<~EOM) # => false
147
+ # def foo
148
+ # def bar # Syntax error here
149
+ # end
150
+ # EOM
151
+ #
152
+ # You can also pass in an array of lines and they'll be
153
+ # joined before evaluating
154
+ #
155
+ # SyntaxSuggest.valid?(
156
+ # [
157
+ # "def foo\n",
158
+ # "end\n"
159
+ # ]
160
+ # ) # => true
161
+ #
162
+ # SyntaxSuggest.valid?(
163
+ # [
164
+ # "def foo\n",
165
+ # " def bar\n", # Syntax error here
166
+ # "end\n"
167
+ # ]
168
+ # ) # => false
169
+ #
170
+ # As an FYI the CodeLine class instances respond to `to_s`
171
+ # so passing a CodeLine in as an object or as an array
172
+ # will convert it to it's code representation.
173
+ def self.valid?(source)
174
+ !invalid?(source)
175
+ end
176
+ end
177
+
178
+ # Integration
179
+ require_relative "cli"
180
+
181
+ # Core logic
182
+ require_relative "code_search"
183
+ require_relative "code_frontier"
184
+ require_relative "explain_syntax"
185
+ require_relative "clean_document"
186
+
187
+ # Helpers
188
+ require_relative "lex_all"
189
+ require_relative "code_line"
190
+ require_relative "code_block"
191
+ require_relative "block_expand"
192
+ require_relative "ripper_errors"
193
+ require_relative "priority_queue"
194
+ require_relative "unvisited_lines"
195
+ require_relative "around_block_scan"
196
+ require_relative "priority_engulf_queue"
197
+ require_relative "pathname_from_message"
198
+ require_relative "display_invalid_blocks"
199
+ require_relative "parse_blocks_from_indent_line"