1

I would like to write a Ruby program that is basically a shell with custom commands. This means that after calling "ruby app.rb" the user will enter a shell which supports special commands that I wrote.

I used loop do ... end to create a basic loop that processes user input. I also used an array of strings to store all available commands, and a hash table with help messages for each available command. But this is very cumbersome.

I would like to know how to elegantly define custom commands with flags, some of which are mandatory (e.g. create -f "~/file.txt" --READONLY -v). Not only that, I need the "help" command of my shell to output info about every single custom command, including flags, AND to have a "menu" command that lists every available command. As a last resort, I thought of simply creating a bunch of .rb files for every command and then using OptionParser for flags and arguments, since I know of no way to make OptionParser work with functions instead of files.

This is what I came up with so far:

class Shell
    @@input = ""

    # List of all commands
    @@commands = [
        "help",
        "exit"
    ]

    # Help messages associated with commands
    @@help_strings = Hash[
        "help" => "Outputs this info.",
        "exit" => "Closes the program."
    ]


    def self.start
        # bash clear screen command
        system("clear")

        loop do
             print (">> ")
             input = gets.chomp

             case input
             when "help"
                 for com in @@commands
                     puts("#{com}: #{@@help_strings[com]}")
                 end
             when "exit"
                 # bash clear screen command
                 system("clear")
                 break
             else
                 puts("Unknown commands: #{input}")
                 puts("Type \"help\" for more info.")
             end
        end
    end
end
5
  • Do try and steer away from using double-at variables (@@) and instead use constants here. Even better, since this apparently can't be instantiated, use a module instead of a class, or move all this to an instance method like Shell.new.start. Commented Dec 17, 2019 at 22:52
  • I cannot make "input" a constant, since I made it a class variable instead of a variable local to the loop in the first place to avoid having to create it anew after every single input. Commented Dec 17, 2019 at 22:55
  • @@input shouldn't exist. input is already a local variable in the start block, and it's not related to @@input in any way. Commented Dec 17, 2019 at 23:09
  • 1
    Well this is awkward. Commented Dec 17, 2019 at 23:36
  • 1
    Nothing wrong with things being a bit confused so long as you clear them all up before it becomes a problem. Commented Dec 17, 2019 at 23:46

2 Answers 2

2

There's quite a bit that goes into creating a robust REPL, but there are a few optimizations you can take to simplify what you've got here.

  1. Create a separate Runner class that holds all of your commands. Use methods and instance_methods to get a list of supported commands without maintaining a separate list.
  2. Check whether a command is supported, then call the method directly rather than using a case statement.
  3. Capture ctrl+c to exit your loop cleanly.
class Shell
  class Runner
    def supported_method?(method)
      # This will return an array of the instance methods that are not provided by ancestors
      (methods - Object.instance_methods).include?(method.to_sym)
    end

    def help
      puts "[TODO: Help info]"
    end

    def exit
      system "clear"
    end
  end

  def self.start
    runner = Runner.new
    loop do 
      print (">> ")
      input = gets.chomp
      if runner.supported_method?(input)
        runner.send(input)
      else 
        puts("Unknown commands: #{input}\nType \"help\" for more info.")
      end
    end
  rescue SystemExit, Interrupt, IRB::Abort
    puts "\nShutting down."
  end
end
Sign up to request clarification or add additional context in comments.

1 Comment

Thank you. Even at a glance, your advice was very helpful. Unfortunately, I still cannot come up with a way to define command flags and arguments.
1

Here's one way to do this:

  • Make a Command class, with an inherited hook that registers the subclass in the Command
  • Make make each command a subclass of Command, with a method that produces an OptionParser for that command, a method that produces a short description, and another method that accepts args and executes the command
  • In your REPL, command_name, *agrs = Shellwords.shellsplit(line), find the registered command that matches the name, get its OptionParser and run it on args, then invoke the command's executor method
  • For help, just iterate on all the registered commands and get their descriptions
  • For help <command>, just get the command's OptionParser, then display its help
  • Use the readline module instead of gets so your users don't curse at you

EDIT: Here's the barebones example:

require 'optparse'
require 'shellwords'
require 'readline'

class Command
  @commands = {}

  def self.inherited(klass)
    klass.new.then { |instance| @commands[instance.name] = instance }
  end

  def self.commands
    @commands
  end

  def name
    self.class.to_s.downcase
  end

  def opts(parser)
    parser.program_name = name
  end

  def args; (0..Float::Infinity); end
end

class BangShell
  def self.start
    while line = Readline.readline("! ", true)
      name, *args = Shellwords.shellsplit(line)
      command = Command.commands[name]
      unless command
        STDERR.puts "Error: Don't know about #{name}!"
        next
      end
      command.opts(opts = OptionParser.new)
      opts.parse!(args, into: options = {})
      unless command.args === args.length
        STDERR.puts "Error: Invalid number of arguments for #{name}!"
        next
      end
      if options.empty?           # this is Ruby being weird about **; but I think it's changing in 3.0
        command.call(*args)
      else
        command.call(*args, **options)
      end
    end
  end
end

class Echo < Command
  def call(*args, n: false)
    print args.join(' ')
    puts unless n
  end

  def description
    "Echoes arguments to console"
  end

  def opts(parser)
    super
    parser.on('-n', 'Do not emit newline')
  end
end

class Help < Command
  def call(name=nil)
    if name
      command = Command.commands[name]
      unless command
        STDERR.puts "Error: Don't know about #{name}!"
        return
      end
      puts command.opts(OptionParser.new)
    else
      puts "Commands:"
      Command.commands.each do |name, command|
        puts command.name.ljust(20) + command.description
      end
    end
  end

  def description
    "Shows help"
  end

  def args; (0..1); end
end

BangShell.start

3 Comments

Thank you. I will look into it.
@Amadan — this is great, but I think you have a small bug. unless command.args === args.length is calling Echo.args but there is no such method.
@coreyward I renamed the method, but forgot to rename it in the parent class. Fixed now. (Also, this is more like a proof of concept, more work would be needed for something satisfactory, I think...)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.