diff --git a/Gemfile b/Gemfile index 7cb7a54..e734a98 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index f857b4b..ffae583 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -54,6 +57,7 @@ PLATFORMS ruby DEPENDENCIES + dry-cli irb kramdown overcommit diff --git a/Makefile b/Makefile index 2da5e11..30aa42e 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 6a0ad9a..4b05eb7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/stone/cli.rb b/src/stone/cli.rb index 77280f3..0d444fb 100644 --- a/src/stone/cli.rb +++ b/src/stone/cli.rb @@ -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 diff --git a/src/stone/cli/command.rb b/src/stone/cli/command.rb new file mode 100644 index 0000000..68c4108 --- /dev/null +++ b/src/stone/cli/command.rb @@ -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 diff --git a/src/stone/cli/eval.rb b/src/stone/cli/eval.rb new file mode 100644 index 0000000..1a67ee5 --- /dev/null +++ b/src/stone/cli/eval.rb @@ -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 diff --git a/src/stone/cli/parse.rb b/src/stone/cli/parse.rb new file mode 100644 index 0000000..73a1f70 --- /dev/null +++ b/src/stone/cli/parse.rb @@ -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 diff --git a/src/stone/cli/repl.rb b/src/stone/cli/repl.rb new file mode 100644 index 0000000..39afd1f --- /dev/null +++ b/src/stone/cli/repl.rb @@ -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 diff --git a/src/stone/cli/verify.rb b/src/stone/cli/verify.rb new file mode 100644 index 0000000..c1ec90f --- /dev/null +++ b/src/stone/cli/verify.rb @@ -0,0 +1,35 @@ +require "parslet" + +require "stone/cli/command" +require "stone/verification/suite" + + +module Stone + module CLI + + class Verify < Stone::CLI::Command + + desc "Verify that results of top-level expressions match expectations in comments" + argument :source_files, type: :array, required: true, desc: "Source files" + option :debug, type: :boolean, default: false + option :markdown, type: :boolean, default: false + + def call(source_files:, debug:, markdown:, **_args) + suite = Stone::Verification::Suite.new(debug: debug) + + each_input_file(source_files, markdown: markdown) 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 + end + + register "verify", Verify + + end +end diff --git a/src/stone/cli/version.rb b/src/stone/cli/version.rb new file mode 100644 index 0000000..5fea1a0 --- /dev/null +++ b/src/stone/cli/version.rb @@ -0,0 +1,21 @@ +require "stone/cli/command" +require "stone/version" + + +module Stone + module CLI + + class Version < Stone::CLI::Command + + desc "Print version" + + def call(*) + puts "Stone version #{Stone::VERSION}" + end + + end + + register "version", Version, aliases: ["--version", "-v"] + + end +end diff --git a/src/stone/language.rb b/src/stone/language.rb new file mode 100644 index 0000000..3d0f147 --- /dev/null +++ b/src/stone/language.rb @@ -0,0 +1,16 @@ +# Load all the sub-languages. +Dir[File.join(__dir__, "language", "*.rb")].sort.each do |file| + require file +end + +require "extensions/class" + + +module Stone + module Language + + # Determine the highest-level language (it'll have the most ancestors). + DEFAULT = Stone::Language::Base.descendants.max_by { |lang| lang.ancestors.size } + + end +end diff --git a/src/stone/language/base.rb b/src/stone/language/base.rb index 55e1369..47dcf01 100644 --- a/src/stone/language/base.rb +++ b/src/stone/language/base.rb @@ -1,5 +1,4 @@ require "extensions/module" -require "extensions/enumerable" require "stone/ast/base"