Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion lib/syntax_error_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,34 @@

module SyntaxErrorSearch
class Error < StandardError; end
# Your code goes here...

# Used for counting spaces
module SpaceCount
def self.indent(string)
string.split(/\w/).first&.length || 0
end
end


def self.valid?(source)
source = source.join if source.is_a?(Array)
source = source.to_s

# Parser writes to stderr even if you catch the error
#
stderr = $stderr
$stderr = StringIO.new

Parser::CurrentRuby.parse(source)
true
rescue Parser::SyntaxError
false
ensure
$stderr = stderr if stderr
end
end

require_relative "syntax_error_search/code_line"
require_relative "syntax_error_search/code_block"
require_relative "syntax_error_search/code_frontier"
require_relative "syntax_error_search/code_search"
209 changes: 209 additions & 0 deletions lib/syntax_error_search/code_block.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
module SyntaxErrorSearch
# Multiple lines form a singular CodeBlock
#
# Source code is made of multiple CodeBlocks. A code block
# has a reference to the source code that created itself, this allows
# a code block to "expand" when needed
#
# The most important ability of a CodeBlock is this ability to expand:
#
# Example:
#
# code_block.to_s # =>
# # def foo
# # puts "foo"
# # end
#
# code_block.expand_until_next_boundry
#
# code_block.to_s # =>
# # class Foo
# # def foo
# # puts "foo"
# # end
# # end
#
class CodeBlock
attr_reader :lines

def initialize(code_lines:, lines: [])
@lines = Array(lines)
@code_lines = code_lines
end

def is_end?
to_s.strip == "end"
end

def starts_at
@lines.first&.line_number
end

def code_lines
@code_lines
end

# This is used for frontier ordering, we are searching from
# the largest indentation to the smallest. This allows us to
# populate an array with multiple code blocks then call `sort!`
# on it without having to specify the sorting criteria
def <=>(other)
self.current_indent <=> other.current_indent
end

# Only the lines that are not empty and visible
def visible_lines
@lines
.select(&:not_empty?)
.select(&:visible?)
end

# This method is used to expand a code block to capture it's calling context
def expand_until_next_boundry
expand_to_indent(next_indent)
self
end

# This method expands the given code block until it captures
# its nearest neighbors. This is used to expand a single line of code
# to its smallest likely block.
#
# code_block.to_s # =>
# # puts "foo"
# code_block.expand_until_neighbors
#
# code_block.to_s # =>
# # puts "foo"
# # puts "bar"
# # puts "baz"
#
def expand_until_neighbors
expand_to_indent(current_indent)

expand_hidden_parner_line if self.to_s.strip == "end"
self
end

def expand_hidden_parner_line
index = @lines.first.index
indent = current_indent
partner_line = code_lines.select {|line| line.index < index && line.indent == indent }.last

if partner_line&.hidden?
partner_line.mark_visible
@lines.prepend(partner_line)
end
end

# This method expands the existing code block up (before)
# and down (after). It will break on change in indentation
# and empty lines.
#
# code_block.to_s # =>
# # def foo
# # puts "foo"
# # end
#
# code_block.expand_to_indent(0)
# code_block.to_s # =>
# # class Foo
# # def foo
# # puts "foo"
# # end
# # end
#
private def expand_to_indent(indent)
array = []
before_lines(skip_empty: false).each do |line|
if line.empty?
array.prepend(line)
break
end

if line.indent == indent
array.prepend(line)
else
break
end
end

array << @lines

after_lines(skip_empty: false).each do |line|
if line.empty?
array << line
break
end

if line.indent == indent
array << line
else
break
end
end

@lines = array.flatten
end

def next_indent
[
before_line&.indent || 0,
after_line&.indent || 0
].max
end

def current_indent
lines.detect(&:not_empty?)&.indent || 0
end

def before_line
before_lines.first
end

def after_line
after_lines.first
end

def before_lines(skip_empty: true)
index = @lines.first.index
lines = code_lines.select {|line| line.index < index }
lines.select!(&:not_empty?) if skip_empty
lines.select!(&:visible?)
lines.reverse!

lines
end

def after_lines(skip_empty: true)
index = @lines.last.index
lines = code_lines.select {|line| line.index > index }
lines.select!(&:not_empty?) if skip_empty
lines.select!(&:visible?)
lines
end

# Returns a code block of the source that does not include
# the current lines. This is useful for checking if a source
# with the given lines removed parses successfully. If so
#
# Then it's proof that the current block is invalid
def block_without
@block_without ||= CodeBlock.new(
source: @source,
lines: @source.code_lines - @lines
)
end

def document_valid_without?
block_without.valid?
end

def valid?
SyntaxErrorSearch.valid?(self.to_s)
end

def to_s
@lines.join
end
end
end
110 changes: 110 additions & 0 deletions lib/syntax_error_search/code_frontier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
module SyntaxErrorSearch
# This class is responsible for generating, storing, and sorting code blocks
class CodeFrontier
def initialize(code_lines: )
@code_lines = code_lines
@frontier = []
@indent_hash = {}
code_lines.each do |line|
next if line.empty?

@indent_hash[line.indent] ||= []
@indent_hash[line.indent] << line
end
end

# Returns true if the document is valid with all lines
# removed. By default it checks all blocks in present in
# the frontier array, but can be used for arbitrary arrays
# of codeblocks as well
def holds_all_syntax_errors?(block_array = @frontier)
lines = @code_lines
block_array.each do |block|
lines -= block.lines
end

return true if lines.empty?

CodeBlock.new(
code_lines: @code_lines,
lines: lines
).valid?
end

# Returns a code block with the largest indentation possible
def pop
return nil if empty?

if generate_new_block?
self << next_block
end

return @frontier.pop
end

def next_block
indent = @indent_hash.keys.sort.last
lines = @indent_hash[indent].first

CodeBlock.new(
lines: lines,
code_lines: @code_lines
).expand_until_neighbors
end

# This method is responsible for determining if a new code
# block should be generated instead of evaluating an already
# existing block in the frontier
def generate_new_block?
return false if @indent_hash.empty?
return true if @frontier.empty?

@frontier.last.current_indent <= @indent_hash.keys.sort.last
end

# Add a block to the frontier
#
# This method ensures the frontier always remains sorted (in indentation order)
# and that each code block's lines are removed from the indentation hash so we
# don't re-evaluate the same line multiple times.
def <<(block)
block.lines.each do |line|
@indent_hash[line.indent]&.delete(line)
end
@indent_hash.select! {|k, v| !v.empty?}

@frontier << block
@frontier.sort!

self
end

def any?
!empty?
end

def empty?
@frontier.empty? && @indent_hash.empty?
end

# Example:
#
# combination([:a, :b, :c, :d])
# # => [[:a], [:b], [:c], [:d], [:a, :b], [:a, :c], [:a, :d], [:b, :c], [:b, :d], [:c, :d], [:a, :b, :c], [:a, :b, :d], [:a, :c, :d], [:b, :c, :d], [:a, :b, :c, :d]]
def self.combination(array)
guesses = []
1.upto(array.length).each do |size|
guesses.concat(array.combination(size).to_a)
end
guesses
end

# Given that we know our syntax error exists somewhere in our frontier, we want to find
# the smallest possible set of blocks that contain all the syntax errors
def detect_invalid_blocks
self.class.combination(@frontier).detect do |block_array|
holds_all_syntax_errors?(block_array)
end || []
end
end
end
Loading