Skip to content

Commit

Permalink
Major refactor of CLI parsing, using dry-cli gem
Browse files Browse the repository at this point in the history
Lots of refactoring, mostly to move each sub-command into its own class.
  • Loading branch information
booch committed Jan 21, 2021
1 parent ad2ad2c commit 9ef6c6d
Show file tree
Hide file tree
Showing 13 changed files with 247 additions and 163 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ ruby "2.7.2"

source "https://rubygems.org"

# Command-line argument parsing
gem "dry-cli"

# Building
gem "overcommit"
gem "rubocop", require: false
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ GEM
byebug (11.1.3)
childprocess (4.0.0)
coderay (1.1.3)
concurrent-ruby (1.1.8)
dry-cli (0.6.0)
concurrent-ruby (~> 1.0)
iniparse (1.5.0)
io-console (0.5.7)
irb (1.3.2)
Expand Down Expand Up @@ -54,6 +57,7 @@ PLATFORMS
ruby

DEPENDENCIES
dry-cli
irb
kramdown
overcommit
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ lint: rubocop


verify-specs: bundle
bin/stone --debug verify docs/specs/*.md
bin/stone verify --debug docs/specs/*.md

setup-overcommit: .git/hooks/overcommit-hook

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ Running

Binaries are located in the `bin` directory:

* `stone` - the main executable
* `stone eval` - output the result of each top-level expression
* `stone repl` - accept manual input, and show the result of each line
* `stone verify` - check that results of top-level expressions match expectations in comments
* `stone`
* `stone eval` - Output the result of each top-level expression (non-interactive REPL)
* `stone repl` - Accept interactive manual input, and show the result of each top-level expression
* `stone verify` - Verify that results of top-level expressions match expectations in comments
* `stone parse` - Output the parse tree


License
Expand Down
166 changes: 9 additions & 157 deletions src/stone/cli.rb
Original file line number Diff line number Diff line change
@@ -1,168 +1,20 @@
require "extensions/argf"
require "extensions/class"

require "stone/version"
require "stone/verification/suite"
require "stone/top"
require "stone/ast/error"

# Load all the sub-languages, then determine the highest-level sub-language (it'll have the most ancestors).
Dir[File.join(__dir__, "language", "*.rb")].sort.each do |file|
require file
end
DEFAULT_LANGUAGE = Stone::Language::Base.descendants.max_by { |lang| lang.ancestors.size }

require "readline"
require "kramdown"
require "dry/cli"


module Stone
module CLI

class CLI
extend Dry::CLI::Registry

def self.run
new.run
end

def run
if options.include?("--version")
puts "Stone version #{Stone::VERSION}"
exit 0
elsif self.respond_to?("run_#{subcommand}", true)
__send__("run_#{subcommand}")
exit 0
else
puts "Don't know the '#{subcommand}' subcommand."
exit 1
end
end

private def language
@language ||= DEFAULT_LANGUAGE.new
end

private def run_parse
each_input_file do |input|
puts language.parse(input)
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
exit 1
end
end

private def run_eval
each_input_file do |input|
top_context = Stone::Top.context
puts language.ast(input).map{ |node|
node.evaluate(top_context)
}.compact
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
exit 1
end
end

private def run_repl
puts "Stone REPL"
while (input = Readline.readline("#> ", true))
repl_1_line(input, top_context)
end
end

private def repl_1_line(line, context)
ast = language.ast(line, single_line: true)
case result = ast.evaluate(context)
when AST::Error
puts "#! #{result}"
when AST::Value
puts "#= #{result}"
end
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
end

private def run_verify
suite = Stone::Verification::Suite.new(debug: debug_option?)
each_input_file do |input|
suite.run(input) do
language.ast(input)
rescue Parslet::ParseFailed => e
suite.add_failure(input, Stone::AST::Error.new("ParseError", e.parse_failure_cause))
[]
end
end
suite.complete
end

private def options # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
return @options if @options
@options = []
while ARGV[0] =~ /^--/
@options << ARGV[0]
ARGV.shift
# TODO: I should really use an options-parsing library here.
if @options.last == "--grammar" # rubocop:disable Style/Next
grammar = ARGV[0].capitalize
if Stone::Language.const_defined?(grammar.to_sym)
@language = Stone::Language.const_get(grammar.to_sym).new
else
puts "Don't know the #{grammar} sub-language."
exit 1
end
ARGV.shift
end
end
@options
end

private def subcommand
return @subcommand if @subcommand
options # Global options come before subcommands, so we have to look for them in ARGV first.
@subcommand = ARGV[0]
ARGV.shift
@subcommand
end

private def each_input_file(&block)
ARGF.each_file do |file|
if ARGF.filename.end_with?(".md") || (ARGF.filename == "-" && markdown_option?)
markdown_code_blocks(file).each do |code_block|
block.call(code_block)
end
else
input = file.read
block.call(input.end_with?("\n") ? input : input << "\n")
end
end
end

private def markdown_code_blocks(file)
markdown = Kramdown::Document.new(file.read)
markdown.root.children.select{ |e| e.type == :codeblock && e.options[:lang] == "stone" }.map(&:value)
end

private def parse(input)
parser.parse("#{input}\n", reporter: Parslet::ErrorReporter::Contextual.new)
end

private def transform(parse_tree)
transformer = Stone::Transform.new
ast = transformer.apply(parse_tree)
ast.respond_to?(:compact) ? ast.compact : ast
end

private def markdown_option?
options.include?("--markdown")
end

private def debug_option?
options.include?("--debug")
end

private def top_context
@top_context ||= Stone::Top.context
Dry::CLI.new(self).call
end

end
end


# Load all the sub-commands.
Dir[File.join(__dir__, "cli", "*.rb")].sort.each do |file|
require file
end
48 changes: 48 additions & 0 deletions src/stone/cli/command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require "dry/cli"
require "kramdown"

require "stone/language"


module Stone
module CLI

class Command < Dry::CLI::Command

private def each_input_file(files, markdown: false, &block) # rubocop:disable Metrics/MethodLength
files.each do |filename|
file = File.open(filename) # TODO: Handle `-` and handle missing files.
if filename.end_with?(".md") || (filename == "-" && markdown)
markdown_code_blocks(file).each do |code_block|
block.call(code_block)
end
else
input = file.read
block.call(input.end_with?("\n") ? input : input << "\n")
end
end
end

private def language
@language ||= Stone::Language::DEFAULT.new
end

private def markdown_code_blocks(file)
markdown = Kramdown::Document.new(file.read)
markdown.root.children.select{ |e| e.type == :codeblock && e.options[:lang] == "stone" }.map(&:value)
end

private def parse(input)
parser.parse("#{input}\n", reporter: Parslet::ErrorReporter::Contextual.new)
end

private def transform(parse_tree)
transformer = Stone::Transform.new
ast = transformer.apply(parse_tree)
ast.respond_to?(:compact) ? ast.compact : ast
end

end

end
end
33 changes: 33 additions & 0 deletions src/stone/cli/eval.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require "parslet"

require "stone/cli/command"
require "stone/top"


module Stone
module CLI

class Eval < Stone::CLI::Command

desc "Output the result of each top-level expression (non-interactive REPL)"
argument :source_files, type: :array, required: true, desc: "Source files"
option :markdown, type: :boolean, default: false

def call(source_files:, markdown:, **_args)
each_input_file(source_files, markdown: markdown) do |input|
top_context = Stone::Top.context
puts language.ast(input).map{ |node|
node.evaluate(top_context)
}.compact
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
exit 1
end
end

end

register "eval", Eval

end
end
29 changes: 29 additions & 0 deletions src/stone/cli/parse.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require "parslet"

require "stone/cli/command"


module Stone
module CLI

class Parse < Stone::CLI::Command

desc "Output the parse tree"
argument :source_files, type: :array, required: true, desc: "Source files"
option :markdown, type: :boolean, default: false

def call(source_files:, markdown:, **_args)
each_input_file(source_files, markdown: markdown) do |input|
puts language.parse(input)
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
exit 1
end
end

end

register "parse", Parse

end
end
43 changes: 43 additions & 0 deletions src/stone/cli/repl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "readline"
require "parslet"

require "stone/cli/command"
require "stone/top"


module Stone
module CLI

class REPL < Stone::CLI::Command

desc "Accept interactive manual input, and show the result of each top-level expression"

def call(**_args)
puts "Stone REPL"
while (input = Readline.readline("#> ", true))
repl_1_line(input, top_context)
end
end

private def repl_1_line(line, context)
ast = language.ast(line, single_line: true)
case result = ast.evaluate(context)
when AST::Error
puts "#! #{result}"
when AST::Value
puts "#= #{result}"
end
rescue Parslet::ParseFailed => e
puts e.parse_failure_cause.ascii_tree
end

private def top_context
@top_context ||= Stone::Top.context
end

end

register "repl", REPL

end
end
Loading

0 comments on commit 9ef6c6d

Please sign in to comment.