diff --git a/ruby/a1 b/ruby/a1 index df2851c..55b8a20 100755 --- a/ruby/a1 +++ b/ruby/a1 @@ -1,3 +1,9 @@ -#!/bin/sh +#!/usr/bin/env ruby -w -./main.rb "$@" +require 'English' + +$LOAD_PATH << File.expand_path(__dir__) + +require 'shell' + +Shell::CLI.new.run(args: ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/ruby/main.rb b/ruby/main.rb deleted file mode 100755 index 3134380..0000000 --- a/ruby/main.rb +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env ruby -w - -require 'English' -require 'readline' -require 'wordexp' - -require './builtins' -require './colours' -require './job_control' -require './logger' - -# TODO: change to module after extracting all or most of the code -class Shell - include Colours - - attr_reader :builtins, :job_control, :logger, :options - - def initialize(args: ARGV, builtins: nil, job_control: nil, logger: nil) - logger ||= Logger.instance - job_control ||= JobControl.new - builtins ||= Builtins.new(job_control) - @builtins = builtins - @job_control = job_control - @logger = logger - @options = parse_options(args) - logger.verbose "Options: #{options.inspect}" - end - - def main - if options[:command] - logger.verbose "Executing command: #{options[:command]}" - print_logs - exit process_command(options[:command]) - end - repl if $stdin.isatty - end - - def repl - @job_control.trap_sigchld - add_to_history = true - status = 0 - loop do - print_logs - print "#{red(status)} " unless status.zero? - line = Readline.readline(prompt(Dir.pwd), add_to_history) - Readline::HISTORY.pop if line.nil? || line.strip.empty? - status = process_command(line) - end - end - - # Looks like this: /path/to/somewhere% - def prompt(pwd) - "#{blue(pwd)}#{white('%')} #{CLEAR}" - end - - def parse_options(args) - options = { - verbose: false, - } - while (arg = args.shift) - case arg - when '-c' - options[:command] = args.shift - when '-v', '--verbose' - options[:verbose] = true - else - logger.warn "#{red('[ERROR]')} Unknown argument: #{arg}" - exit 1 - end - end - options - end - - def print_logs - logger.logs.each do |log| - message = "#{log.message}#{CLEAR}" - case log.level - when :verbose - warn message if options[:verbose] - else - warn message - end - end - logger.clear - end - - def process_command(line) - exit 0 if line.nil? # EOF, ctrl-d - return 0 if line.strip.empty? # no input, no-op - - logger.verbose "Processing command: #{line.inspect}" - args = Wordexp.expand(line) - cmd = args.shift - logger.verbose "Parsed command: #{cmd} #{args.inspect}" - if @builtins.builtin?(cmd) - logger.verbose "Executing builtin #{cmd}" - @builtins.exec(cmd, args) - else - logger.verbose "Shelling out for #{cmd}" - @job_control.exec_command(cmd, args) - end - rescue Errno => e - warn "#{red('[ERROR]')} #{e.message}" - -1 - end -end - -Shell.new(args: ARGV).main if $PROGRAM_NAME == __FILE__ diff --git a/ruby/shell.rb b/ruby/shell.rb new file mode 100644 index 0000000..22df219 --- /dev/null +++ b/ruby/shell.rb @@ -0,0 +1,5 @@ +require 'shell/cli' +require 'shell/repl' + +module Shell +end diff --git a/ruby/builtins.rb b/ruby/shell/builtins.rb similarity index 72% rename from ruby/builtins.rb rename to ruby/shell/builtins.rb index 03bb2de..98ebad4 100644 --- a/ruby/builtins.rb +++ b/ruby/shell/builtins.rb @@ -1,9 +1,13 @@ -class Shell - class Builtins - attr_reader :logger +require 'shell/job_control' +require 'shell/logger' - def initialize(job_control, logger = Logger.instance) - @job_control = job_control +module Shell + class Builtins + attr_reader :job_control, :logger + + def initialize(job_control: nil, logger: nil) + logger ||= Logger.instance + @job_control = job_control || JobControl.new(logger: logger) @logger = logger end @@ -21,7 +25,7 @@ class Shell def builtin_bg(args) cmd = args.shift - @job_control.exec_command(cmd, args, background: true) + job_control.exec_command(cmd, args, background: true) end def builtin_cd(args) diff --git a/ruby/shell/cli.rb b/ruby/shell/cli.rb new file mode 100644 index 0000000..1cf74bd --- /dev/null +++ b/ruby/shell/cli.rb @@ -0,0 +1,61 @@ +require 'shell/colours' +require 'shell/logger' +require 'shell/repl' + +module Shell + class CLI + include Colours + + attr_reader :logger, :options, :repl + + def initialize(logger: nil, repl: nil) + @logger = logger || Logger.instance + @options = {} + @repl = repl || REPL.new(logger: @logger) + @repl.precmd_hook = -> { print_logs } + end + + def run(args: nil) + @options = parse_options(args || ARGV) + logger.verbose "Options: #{options.inspect}" + if options[:command] + logger.verbose "Executing command: #{options[:command]}" + print_logs + exit repl.process_command(options[:command]) + elsif $stdin.isatty + repl.start(options: options) + end + end + + def parse_options(args) + options = { + verbose: false, + } + while (arg = args.shift) + case arg + when '-c' + options[:command] = args.shift + when '-v', '--verbose' + options[:verbose] = true + else + logger.warn "#{red('[ERROR]')} Unknown argument: #{arg}" + exit 1 + end + end + options + end + + def print_logs + logger.logs.each do |log| + message = "#{log.message}#{CLEAR}" + case log.level + when :verbose + warn message if options[:verbose] + else + warn message + end + end + logger.clear + end + end +end diff --git a/ruby/colours.rb b/ruby/shell/colours.rb similarity index 97% rename from ruby/colours.rb rename to ruby/shell/colours.rb index 9489e68..f2b809a 100644 --- a/ruby/colours.rb +++ b/ruby/shell/colours.rb @@ -1,4 +1,4 @@ -class Shell +module Shell module Colours # These colours should be safe on dark and light backgrounds. BLUE = "\033[1;34m".freeze diff --git a/ruby/job.rb b/ruby/shell/job.rb similarity index 78% rename from ruby/job.rb rename to ruby/shell/job.rb index ff546a0..f3f4eeb 100644 --- a/ruby/job.rb +++ b/ruby/shell/job.rb @@ -1,3 +1,3 @@ -class Shell +module Shell Job = Struct.new(:id, :pid, :cmd, :args) end diff --git a/ruby/job_control.rb b/ruby/shell/job_control.rb similarity index 93% rename from ruby/job_control.rb rename to ruby/shell/job_control.rb index fcb1e15..2bc42e8 100644 --- a/ruby/job_control.rb +++ b/ruby/shell/job_control.rb @@ -1,15 +1,18 @@ -require './colours' -require './job' +require 'English' -class Shell +require 'shell/colours' +require 'shell/job' +require 'shell/logger' + +module Shell class JobControl include Colours attr_reader :logger - def initialize(logger = Logger.instance) + def initialize(logger: nil) @jobs_by_pid = {} - @logger = logger + @logger = logger || Logger.instance end def trap_sigchld diff --git a/ruby/logger.rb b/ruby/shell/logger.rb similarity index 95% rename from ruby/logger.rb rename to ruby/shell/logger.rb index 54720d0..a5b4954 100644 --- a/ruby/logger.rb +++ b/ruby/shell/logger.rb @@ -1,6 +1,6 @@ -require './colours' +require 'shell/colours' -class Shell +module Shell # Queues up messages to be printed out when readline is waiting for input, to prevent # mixing shell output with command output. class Logger diff --git a/ruby/shell/repl.rb b/ruby/shell/repl.rb new file mode 100644 index 0000000..1a46776 --- /dev/null +++ b/ruby/shell/repl.rb @@ -0,0 +1,67 @@ +require 'readline' +require 'wordexp' + +require 'shell/builtins' +require 'shell/colours' +require 'shell/job_control' +require 'shell/logger' + +module Shell + class REPL + include Colours + + attr_reader :builtins, :job_control, :logger, :options + + attr_accessor :precmd_hook + + def initialize(builtins: nil, job_control: nil, logger: nil) + logger ||= Logger.instance + job_control ||= JobControl.new(logger: logger) + builtins ||= Builtins.new(job_control: job_control) + + @builtins = builtins + @job_control = job_control + @logger = logger + @options = {} + end + + def start(options: nil) + @options = options || {} + job_control.trap_sigchld + add_to_history = true + status = 0 + loop do + precmd_hook&.call + print "#{red(status)} " unless status.zero? + line = Readline.readline(prompt(Dir.pwd), add_to_history) + Readline::HISTORY.pop if line.nil? || line.strip.empty? + status = process_command(line) + end + end + + def process_command(line) + exit 0 if line.nil? # EOF, ctrl-d + return 0 if line.strip.empty? # no input, no-op + + logger.verbose "Processing command: #{line.inspect}" + args = Wordexp.expand(line) + cmd = args.shift + logger.verbose "Parsed command: #{cmd} #{args.inspect}" + if builtins.builtin?(cmd) + logger.verbose "Executing builtin #{cmd}" + builtins.exec(cmd, args) + else + logger.verbose "Shelling out for #{cmd}" + job_control.exec_command(cmd, args) + end + rescue Errno => e + warn "#{red('[ERROR]')} #{e.message}" + -1 + end + + # Looks like this: /path/to/somewhere% + def prompt(pwd) + "#{blue(pwd)}#{white('%')} #{CLEAR}" + end + end +end