├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── shell.rb └── shell │ ├── builtin-command.rb │ ├── command-processor.rb │ ├── error.rb │ ├── filter.rb │ ├── process-controller.rb │ ├── system-command.rb │ └── version.rb ├── shell.gemspec └── test ├── lib └── helper.rb └── shell └── test_command_processor.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ruby-versions: 7 | uses: ruby/actions/.github/workflows/ruby_versions.yml@master 8 | with: 9 | min_version: 2.5 10 | engine: cruby-truffleruby 11 | 12 | build: 13 | needs: ruby-versions 14 | name: build (${{ matrix.ruby }} / ${{ matrix.os }}) 15 | strategy: 16 | matrix: 17 | ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} 18 | os: [ ubuntu-latest, macos-latest ] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | - name: Install dependencies 27 | run: bundle install 28 | - name: Run test 29 | run: rake test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "bundler" 7 | gem "rake" 8 | gem "test-unit" 9 | gem "test-unit-ruby-core" 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 14 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 15 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 16 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 17 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 18 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 19 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 20 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 21 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 22 | SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shell 2 | 3 | Shell implements an idiomatic Ruby interface for common UNIX shell commands. 4 | 5 | It provides users the ability to execute commands with filters and pipes, like `sh`/`csh` by using native facilities of Ruby. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'shell' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install shell 22 | 23 | ## Usage 24 | 25 | ### Temp file creation 26 | 27 | In this example we will create three `tmpFile`'s in three different folders under the `/tmp` directory. 28 | 29 | ```ruby 30 | sh = Shell.cd("/tmp") # Change to the /tmp directory 31 | sh.mkdir "shell-test-1" unless sh.exist?("shell-test-1") 32 | # make the 'shell-test-1' directory if it doesn't already exist 33 | sh.cd("shell-test-1") # Change to the /tmp/shell-test-1 directory 34 | for dir in ["dir1", "dir3", "dir5"] 35 | if !sh.exist?(dir) 36 | sh.mkdir dir # make dir if it doesn't already exist 37 | sh.cd(dir) do 38 | # change to the `dir` directory 39 | f = sh.open("tmpFile", "w") # open a new file in write mode 40 | f.print "TEST\n" # write to the file 41 | f.close # close the file handler 42 | end 43 | print sh.pwd # output the process working directory 44 | end 45 | end 46 | ``` 47 | 48 | ### Temp file creation with self 49 | 50 | This example is identical to the first, except we're using `CommandProcessor#transact`. 51 | 52 | `CommandProcessor#transact` executes the given block against self, in this case `sh`; our Shell object. Within the block we can substitute `sh.cd` to `cd`, because the scope within the block uses `sh` already. 53 | 54 | ```ruby 55 | sh = Shell.cd("/tmp") 56 | sh.transact do 57 | mkdir "shell-test-1" unless exist?("shell-test-1") 58 | cd("shell-test-1") 59 | for dir in ["dir1", "dir3", "dir5"] 60 | if !exist?(dir) 61 | mkdir dir 62 | cd(dir) do 63 | f = open("tmpFile", "w") 64 | f.print "TEST\n" 65 | f.close 66 | end 67 | print pwd 68 | end 69 | end 70 | end 71 | ``` 72 | 73 | ### Pipe /etc/printcap into a file 74 | 75 | In this example we will read the operating system file `/etc/printcap`, generated by `cupsd`, and then output it to a new file relative to the `pwd` of `sh`. 76 | 77 | ```ruby 78 | sh = Shell.new 79 | sh.cat("/etc/printcap") | sh.tee("tee1") > "tee2" 80 | (sh.cat < "/etc/printcap") | sh.tee("tee11") > "tee12" 81 | sh.cat("/etc/printcap") | sh.tee("tee1") >> "tee2" 82 | (sh.cat < "/etc/printcap") | sh.tee("tee11") >> "tee12" 83 | ``` 84 | 85 | ## Development 86 | 87 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 88 | 89 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 90 | 91 | ## Contributing 92 | 93 | Bug reports and pull requests are welcome on GitHub at https://github.com/ruby/shell. 94 | 95 | ## License 96 | 97 | The gem is available as open source under the terms of the [2-Clause BSD License](https://opensource.org/licenses/BSD-2-Clause). 98 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test/lib" 6 | t.ruby_opts << "-rhelper" 7 | t.test_files = FileList["test/**/test_*.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require_relative "../lib/shell" 5 | 6 | require "irb" 7 | IRB.start(__FILE__) 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /lib/shell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision: 1.9 $ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | require "e2mmap" 14 | 15 | require "forwardable" 16 | 17 | require "shell/error" 18 | require "shell/command-processor" 19 | require "shell/process-controller" 20 | require "shell/version" 21 | 22 | # Shell implements an idiomatic Ruby interface for common UNIX shell commands. 23 | # 24 | # It provides users the ability to execute commands with filters and pipes, 25 | # like +sh+/+csh+ by using native facilities of Ruby. 26 | # 27 | # == Examples 28 | # 29 | # === Temp file creation 30 | # 31 | # In this example we will create three +tmpFile+'s in three different folders 32 | # under the +/tmp+ directory. 33 | # 34 | # sh = Shell.cd("/tmp") # Change to the /tmp directory 35 | # sh.mkdir "shell-test-1" unless sh.exist?("shell-test-1") 36 | # # make the 'shell-test-1' directory if it doesn't already exist 37 | # sh.cd("shell-test-1") # Change to the /tmp/shell-test-1 directory 38 | # for dir in ["dir1", "dir3", "dir5"] 39 | # if !sh.exist?(dir) 40 | # sh.mkdir dir # make dir if it doesn't already exist 41 | # sh.cd(dir) do 42 | # # change to the `dir` directory 43 | # f = sh.open("tmpFile", "w") # open a new file in write mode 44 | # f.print "TEST\n" # write to the file 45 | # f.close # close the file handler 46 | # end 47 | # print sh.pwd # output the process working directory 48 | # end 49 | # end 50 | # 51 | # === Temp file creation with self 52 | # 53 | # This example is identical to the first, except we're using 54 | # CommandProcessor#transact. 55 | # 56 | # CommandProcessor#transact executes the given block against self, in this case 57 | # +sh+; our Shell object. Within the block we can substitute +sh.cd+ to +cd+, 58 | # because the scope within the block uses +sh+ already. 59 | # 60 | # sh = Shell.cd("/tmp") 61 | # sh.transact do 62 | # mkdir "shell-test-1" unless exist?("shell-test-1") 63 | # cd("shell-test-1") 64 | # for dir in ["dir1", "dir3", "dir5"] 65 | # if !exist?(dir) 66 | # mkdir dir 67 | # cd(dir) do 68 | # f = open("tmpFile", "w") 69 | # f.print "TEST\n" 70 | # f.close 71 | # end 72 | # print pwd 73 | # end 74 | # end 75 | # end 76 | # 77 | # === Pipe /etc/printcap into a file 78 | # 79 | # In this example we will read the operating system file +/etc/printcap+, 80 | # generated by +cupsd+, and then output it to a new file relative to the +pwd+ 81 | # of +sh+. 82 | # 83 | # sh = Shell.new 84 | # sh.cat("/etc/printcap") | sh.tee("tee1") > "tee2" 85 | # (sh.cat < "/etc/printcap") | sh.tee("tee11") > "tee12" 86 | # sh.cat("/etc/printcap") | sh.tee("tee1") >> "tee2" 87 | # (sh.cat < "/etc/printcap") | sh.tee("tee11") >> "tee12" 88 | # 89 | class Shell 90 | 91 | include Error 92 | extend Exception2MessageMapper 93 | 94 | # debug: true -> normal debug 95 | # debug: 1 -> eval definition debug 96 | # debug: 2 -> detail inspect debug 97 | @debug = false 98 | @verbose = true 99 | 100 | @debug_display_process_id = false 101 | @debug_display_thread_id = true 102 | @debug_output_mutex = Thread::Mutex.new 103 | @default_system_path = nil 104 | @default_record_separator = nil 105 | 106 | class << Shell 107 | extend Forwardable 108 | 109 | attr_accessor :cascade, :verbose 110 | attr_reader :debug 111 | 112 | alias debug? debug 113 | alias verbose? verbose 114 | @verbose = true 115 | 116 | def debug=(val) 117 | @debug = val 118 | @verbose = val if val 119 | end 120 | 121 | 122 | # call-seq: 123 | # Shell.cd(path) 124 | # 125 | # Creates a new Shell instance with the current working directory 126 | # set to +path+. 127 | def cd(path) 128 | new(path) 129 | end 130 | 131 | # Returns the directories in the current shell's PATH environment variable 132 | # as an array of directory names. This sets the system_path for all 133 | # instances of Shell. 134 | # 135 | # Example: If in your current shell, you did: 136 | # 137 | # $ echo $PATH 138 | # /usr/bin:/bin:/usr/local/bin 139 | # 140 | # Running this method in the above shell would then return: 141 | # 142 | # ["/usr/bin", "/bin", "/usr/local/bin"] 143 | # 144 | def default_system_path 145 | if @default_system_path 146 | @default_system_path 147 | else 148 | ENV["PATH"].split(":") 149 | end 150 | end 151 | 152 | # Sets the system_path that new instances of Shell should have as their 153 | # initial system_path. 154 | # 155 | # +path+ should be an array of directory name strings. 156 | def default_system_path=(path) 157 | @default_system_path = path 158 | end 159 | 160 | def default_record_separator 161 | if @default_record_separator 162 | @default_record_separator 163 | else 164 | $/ 165 | end 166 | end 167 | 168 | def default_record_separator=(rs) 169 | @default_record_separator = rs 170 | end 171 | 172 | # os resource mutex 173 | mutex_methods = ["unlock", "lock", "locked?", "synchronize", "try_lock"] 174 | for m in mutex_methods 175 | def_delegator("@debug_output_mutex", m, "debug_output_"+m.to_s) 176 | end 177 | 178 | end 179 | 180 | # call-seq: 181 | # Shell.new(pwd, umask) -> obj 182 | # 183 | # Creates a Shell object which current directory is set to the process 184 | # current directory, unless otherwise specified by the +pwd+ argument. 185 | def initialize(pwd = Dir.pwd, umask = nil) 186 | @cwd = File.expand_path(pwd) 187 | @dir_stack = [] 188 | @umask = umask 189 | 190 | @system_path = Shell.default_system_path 191 | @record_separator = Shell.default_record_separator 192 | 193 | @command_processor = CommandProcessor.new(self) 194 | @process_controller = ProcessController.new(self) 195 | 196 | @verbose = Shell.verbose 197 | @debug = Shell.debug 198 | end 199 | 200 | # Returns the command search path in an array 201 | attr_reader :system_path 202 | 203 | # Sets the system path (the Shell instance's PATH environment variable). 204 | # 205 | # +path+ should be an array of directory name strings. 206 | def system_path=(path) 207 | @system_path = path 208 | rehash 209 | end 210 | 211 | 212 | # Returns the umask 213 | attr_accessor :umask 214 | attr_accessor :record_separator 215 | attr_accessor :verbose 216 | attr_reader :debug 217 | 218 | def debug=(val) 219 | @debug = val 220 | @verbose = val if val 221 | end 222 | 223 | alias verbose? verbose 224 | alias debug? debug 225 | 226 | attr_reader :command_processor 227 | attr_reader :process_controller 228 | 229 | def expand_path(path) 230 | File.expand_path(path, @cwd) 231 | end 232 | 233 | # Most Shell commands are defined via CommandProcessor 234 | 235 | # 236 | # Dir related methods 237 | # 238 | # Shell#cwd/dir/getwd/pwd 239 | # Shell#chdir/cd 240 | # Shell#pushdir/pushd 241 | # Shell#popdir/popd 242 | # Shell#mkdir 243 | # Shell#rmdir 244 | 245 | # Returns the current working directory. 246 | attr_reader :cwd 247 | alias dir cwd 248 | alias getwd cwd 249 | alias pwd cwd 250 | 251 | attr_reader :dir_stack 252 | alias dirs dir_stack 253 | 254 | # call-seq: 255 | # Shell.chdir(path) 256 | # 257 | # Creates a Shell object which current directory is set to +path+. 258 | # 259 | # If a block is given, it restores the current directory when the block ends. 260 | # 261 | # If called as iterator, it restores the current directory when the 262 | # block ends. 263 | def chdir(path = nil, verbose = @verbose) 264 | check_point 265 | 266 | if block_given? 267 | notify("chdir(with block) #{path}") if verbose 268 | cwd_old = @cwd 269 | begin 270 | chdir(path, nil) 271 | yield 272 | ensure 273 | chdir(cwd_old, nil) 274 | end 275 | else 276 | notify("chdir #{path}") if verbose 277 | path = "~" unless path 278 | @cwd = expand_path(path) 279 | notify "current dir: #{@cwd}" 280 | rehash 281 | Void.new(self) 282 | end 283 | end 284 | alias cd chdir 285 | 286 | # call-seq: 287 | # pushdir(path) 288 | # pushdir(path) { &block } 289 | # 290 | # Pushes the current directory to the directory stack, changing the current 291 | # directory to +path+. 292 | # 293 | # If +path+ is omitted, it exchanges its current directory and the top of its 294 | # directory stack. 295 | # 296 | # If a block is given, it restores the current directory when the block ends. 297 | def pushdir(path = nil, verbose = @verbose) 298 | check_point 299 | 300 | if block_given? 301 | notify("pushdir(with block) #{path}") if verbose 302 | pushdir(path, nil) 303 | begin 304 | yield 305 | ensure 306 | popdir 307 | end 308 | elsif path 309 | notify("pushdir #{path}") if verbose 310 | @dir_stack.push @cwd 311 | chdir(path, nil) 312 | notify "dir stack: [#{@dir_stack.join ', '}]" 313 | self 314 | else 315 | notify("pushdir") if verbose 316 | if pop = @dir_stack.pop 317 | @dir_stack.push @cwd 318 | chdir pop 319 | notify "dir stack: [#{@dir_stack.join ', '}]" 320 | self 321 | else 322 | Shell.Fail DirStackEmpty 323 | end 324 | end 325 | Void.new(self) 326 | end 327 | alias pushd pushdir 328 | 329 | # Pops a directory from the directory stack, and sets the current directory 330 | # to it. 331 | def popdir 332 | check_point 333 | 334 | notify("popdir") 335 | if pop = @dir_stack.pop 336 | chdir pop 337 | notify "dir stack: [#{@dir_stack.join ', '}]" 338 | self 339 | else 340 | Shell.Fail DirStackEmpty 341 | end 342 | Void.new(self) 343 | end 344 | alias popd popdir 345 | 346 | # Returns a list of scheduled jobs. 347 | def jobs 348 | @process_controller.jobs 349 | end 350 | 351 | # call-seq: 352 | # kill(signal, job) 353 | # 354 | # Sends the given +signal+ to the given +job+ 355 | def kill(sig, command) 356 | @process_controller.kill_job(sig, command) 357 | end 358 | 359 | # call-seq: 360 | # def_system_command(command, path = command) 361 | # 362 | # Convenience method for Shell::CommandProcessor.def_system_command. 363 | # Defines an instance method which will execute the given shell command. 364 | # If the executable is not in Shell.default_system_path, you must 365 | # supply the path to it. 366 | # 367 | # Shell.def_system_command('hostname') 368 | # Shell.new.hostname # => localhost 369 | # 370 | # # How to use an executable that's not in the default path 371 | # 372 | # Shell.def_system_command('run_my_program', "~/hello") 373 | # Shell.new.run_my_program # prints "Hello from a C program!" 374 | # 375 | def Shell.def_system_command(command, path = command) 376 | CommandProcessor.def_system_command(command, path) 377 | end 378 | 379 | # Convenience method for Shell::CommandProcessor.undef_system_command 380 | def Shell.undef_system_command(command) 381 | CommandProcessor.undef_system_command(command) 382 | end 383 | 384 | # call-seq: 385 | # alias_command(alias, command, *opts, &block) 386 | # 387 | # Convenience method for Shell::CommandProcessor.alias_command. 388 | # Defines an instance method which will execute a command under 389 | # an alternative name. 390 | # 391 | # Shell.def_system_command('date') 392 | # Shell.alias_command('date_in_utc', 'date', '-u') 393 | # Shell.new.date_in_utc # => Sat Jan 25 16:59:57 UTC 2014 394 | # 395 | def Shell.alias_command(ali, command, *opts, &block) 396 | CommandProcessor.alias_command(ali, command, *opts, &block) 397 | end 398 | 399 | # Convenience method for Shell::CommandProcessor.unalias_command 400 | def Shell.unalias_command(ali) 401 | CommandProcessor.unalias_command(ali) 402 | end 403 | 404 | # call-seq: 405 | # install_system_commands(pre = "sys_") 406 | # 407 | # Convenience method for Shell::CommandProcessor.install_system_commands. 408 | # Defines instance methods representing all the executable files found in 409 | # Shell.default_system_path, with the given prefix prepended to their 410 | # names. 411 | # 412 | # Shell.install_system_commands 413 | # Shell.new.sys_echo("hello") # => hello 414 | # 415 | def Shell.install_system_commands(pre = "sys_") 416 | CommandProcessor.install_system_commands(pre) 417 | end 418 | 419 | # 420 | def inspect 421 | if debug.kind_of?(Integer) && debug > 2 422 | super 423 | else 424 | to_s 425 | end 426 | end 427 | 428 | def self.notify(*opts) 429 | Shell::debug_output_synchronize do 430 | if opts[-1].kind_of?(String) 431 | yorn = verbose? 432 | else 433 | yorn = opts.pop 434 | end 435 | return unless yorn 436 | 437 | if @debug_display_thread_id 438 | if @debug_display_process_id 439 | prefix = "shell(##{Process.pid}:#{Thread.current.to_s.sub("Thread", "Th")}): " 440 | else 441 | prefix = "shell(#{Thread.current.to_s.sub("Thread", "Th")}): " 442 | end 443 | else 444 | prefix = "shell: " 445 | end 446 | _head = true 447 | STDERR.print opts.collect{|mes| 448 | mes = mes.dup 449 | yield mes if block_given? 450 | if _head 451 | _head = false 452 | prefix + mes 453 | else 454 | " "* prefix.size + mes 455 | end 456 | }.join("\n")+"\n" 457 | end 458 | end 459 | 460 | CommandProcessor.initialize 461 | CommandProcessor.run_config 462 | end 463 | -------------------------------------------------------------------------------- /lib/shell/builtin-command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/builtin-command.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | require_relative "filter" 14 | 15 | class Shell 16 | class BuiltInCommand < Filter 17 | def wait? 18 | false 19 | end 20 | def active? 21 | true 22 | end 23 | end 24 | 25 | class Void < BuiltInCommand 26 | def initialize(sh, *opts) 27 | super sh 28 | end 29 | 30 | def each(rs = nil) 31 | # do nothing 32 | end 33 | end 34 | 35 | class Echo < BuiltInCommand 36 | def initialize(sh, *strings) 37 | super sh 38 | @strings = strings 39 | end 40 | 41 | def each(rs = nil) 42 | rs = @shell.record_separator unless rs 43 | for str in @strings 44 | yield str + rs 45 | end 46 | end 47 | end 48 | 49 | class Cat < BuiltInCommand 50 | def initialize(sh, *filenames) 51 | super sh 52 | @cat_files = filenames 53 | end 54 | 55 | def each(rs = nil) 56 | if @cat_files.empty? 57 | super 58 | else 59 | for src in @cat_files 60 | @shell.foreach(src, rs){|l| yield l} 61 | end 62 | end 63 | end 64 | end 65 | 66 | class Glob < BuiltInCommand 67 | def initialize(sh, pattern) 68 | super sh 69 | 70 | @pattern = pattern 71 | end 72 | 73 | def each(rs = nil) 74 | if @pattern[0] == ?/ 75 | @files = Dir[@pattern] 76 | else 77 | prefix = @shell.pwd+"/" 78 | @files = Dir[prefix+@pattern].collect{|p| p.sub(prefix, "")} 79 | end 80 | rs = @shell.record_separator unless rs 81 | for f in @files 82 | yield f+rs 83 | end 84 | end 85 | end 86 | 87 | class AppendIO < BuiltInCommand 88 | def initialize(sh, io, filter) 89 | super sh 90 | @input = filter 91 | @io = io 92 | end 93 | 94 | def input=(filter) 95 | @input.input=filter 96 | for l in @input 97 | @io << l 98 | end 99 | end 100 | 101 | end 102 | 103 | class AppendFile < AppendIO 104 | def initialize(sh, to_filename, filter) 105 | @file_name = to_filename 106 | io = sh.open(to_filename, "a") 107 | super(sh, io, filter) 108 | end 109 | 110 | def input=(filter) 111 | begin 112 | super 113 | ensure 114 | @io.close 115 | end 116 | end 117 | end 118 | 119 | class Tee < BuiltInCommand 120 | def initialize(sh, filename) 121 | super sh 122 | @to_filename = filename 123 | end 124 | 125 | def each(rs = nil) 126 | to = @shell.open(@to_filename, "w") 127 | begin 128 | super{|l| to << l; yield l} 129 | ensure 130 | to.close 131 | end 132 | end 133 | end 134 | 135 | class Concat < BuiltInCommand 136 | def initialize(sh, *jobs) 137 | super(sh) 138 | @jobs = jobs 139 | end 140 | 141 | def each(rs = nil) 142 | while job = @jobs.shift 143 | job.each{|l| yield l} 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/shell/command-processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/command-controller.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | require "e2mmap" 14 | 15 | require_relative "error" 16 | require_relative "filter" 17 | require_relative "system-command" 18 | require_relative "builtin-command" 19 | 20 | class Shell 21 | # In order to execute a command on your OS, you need to define it as a 22 | # Shell method. 23 | # 24 | # Alternatively, you can execute any command via 25 | # Shell::CommandProcessor#system even if it is not defined. 26 | class CommandProcessor 27 | 28 | # 29 | # initialize of Shell and related classes. 30 | # 31 | m = [:initialize, :expand_path] 32 | if Object.methods.first.kind_of?(String) 33 | NoDelegateMethods = m.collect{|x| x.id2name} 34 | else 35 | NoDelegateMethods = m 36 | end 37 | 38 | def self.initialize 39 | 40 | install_builtin_commands 41 | 42 | # define CommandProcessor#methods to Shell#methods and Filter#methods 43 | for m in CommandProcessor.instance_methods(false) - NoDelegateMethods 44 | add_delegate_command_to_shell(m) 45 | end 46 | 47 | def self.method_added(id) 48 | add_delegate_command_to_shell(id) 49 | end 50 | end 51 | 52 | # 53 | # include run file. 54 | # 55 | def self.run_config 56 | rc = "~/.rb_shell" 57 | begin 58 | load File.expand_path(rc) if ENV.key?("HOME") 59 | rescue LoadError, Errno::ENOENT 60 | rescue 61 | print "load error: #{rc}\n" 62 | print $!.class, ": ", $!, "\n" 63 | for err in $@[0, $@.size - 2] 64 | print "\t", err, "\n" 65 | end 66 | end 67 | end 68 | 69 | def initialize(shell) 70 | @shell = shell 71 | @system_commands = {} 72 | end 73 | 74 | # 75 | # CommandProcessor#expand_path(path) 76 | # path: String 77 | # return: String 78 | # returns the absolute path for 79 | # 80 | def expand_path(path) 81 | @shell.expand_path(path) 82 | end 83 | 84 | # call-seq: 85 | # foreach(path, record_separator) -> Enumerator 86 | # foreach(path, record_separator) { block } 87 | # 88 | # See IO.foreach when +path+ is a file. 89 | # 90 | # See Dir.foreach when +path+ is a directory. 91 | # 92 | def foreach(path = nil, *rs) 93 | path = "." unless path 94 | path = expand_path(path) 95 | 96 | if File.directory?(path) 97 | Dir.foreach(path){|fn| yield fn} 98 | else 99 | IO.foreach(path, *rs){|l| yield l} 100 | end 101 | end 102 | 103 | # call-seq: 104 | # open(path, mode, permissions) -> Enumerator 105 | # open(path, mode, permissions) { block } 106 | # 107 | # See IO.open when +path+ is a file. 108 | # 109 | # See Dir.open when +path+ is a directory. 110 | # 111 | def open(path, mode = nil, perm = 0666, &b) 112 | path = expand_path(path) 113 | if File.directory?(path) 114 | Dir.open(path, &b) 115 | else 116 | if @shell.umask 117 | f = File.open(path, mode, perm) 118 | File.chmod(perm & ~@shell.umask, path) 119 | if block_given? 120 | f.each(&b) 121 | end 122 | f 123 | else 124 | File.open(path, mode, perm, &b) 125 | end 126 | end 127 | end 128 | 129 | # call-seq: 130 | # unlink(path) 131 | # 132 | # See IO.unlink when +path+ is a file. 133 | # 134 | # See Dir.unlink when +path+ is a directory. 135 | # 136 | def unlink(path) 137 | @shell.check_point 138 | 139 | path = expand_path(path) 140 | if File.directory?(path) 141 | Dir.unlink(path) 142 | else 143 | IO.unlink(path) 144 | end 145 | Void.new(@shell) 146 | end 147 | 148 | # See Shell::CommandProcessor#test 149 | alias top_level_test test 150 | # call-seq: 151 | # test(command, file1, file2) -> true or false 152 | # [command, file1, file2] -> true or false 153 | # 154 | # Tests if the given +command+ exists in +file1+, or optionally +file2+. 155 | # 156 | # Example: 157 | # sh[?e, "foo"] 158 | # sh[:e, "foo"] 159 | # sh["e", "foo"] 160 | # sh[:exist?, "foo"] 161 | # sh["exist?", "foo"] 162 | # 163 | def test(command, file1, file2=nil) 164 | file1 = expand_path(file1) 165 | file2 = expand_path(file2) if file2 166 | command = command.id2name if command.kind_of?(Symbol) 167 | 168 | case command 169 | when Integer 170 | if file2 171 | top_level_test(command, file1, file2) 172 | else 173 | top_level_test(command, file1) 174 | end 175 | when String 176 | if command.size == 1 177 | if file2 178 | top_level_test(command, file1, file2) 179 | else 180 | top_level_test(command, file1) 181 | end 182 | else 183 | unless FileTest.methods(false).include?(command.to_sym) 184 | raise "unsupported command: #{ command }" 185 | end 186 | if file2 187 | FileTest.send(command, file1, file2) 188 | else 189 | FileTest.send(command, file1) 190 | end 191 | end 192 | end 193 | end 194 | # See Shell::CommandProcessor#test 195 | alias [] test 196 | 197 | # call-seq: 198 | # mkdir(path) 199 | # 200 | # Same as Dir.mkdir, except multiple directories are allowed. 201 | def mkdir(*path) 202 | @shell.check_point 203 | notify("mkdir #{path.join(' ')}") 204 | 205 | perm = nil 206 | if path.last.kind_of?(Integer) 207 | perm = path.pop 208 | end 209 | for dir in path 210 | d = expand_path(dir) 211 | if perm 212 | Dir.mkdir(d, perm) 213 | else 214 | Dir.mkdir(d) 215 | end 216 | File.chmod(d, 0666 & ~@shell.umask) if @shell.umask 217 | end 218 | Void.new(@shell) 219 | end 220 | 221 | # call-seq: 222 | # rmdir(path) 223 | # 224 | # Same as Dir.rmdir, except multiple directories are allowed. 225 | def rmdir(*path) 226 | @shell.check_point 227 | notify("rmdir #{path.join(' ')}") 228 | 229 | for dir in path 230 | Dir.rmdir(expand_path(dir)) 231 | end 232 | Void.new(@shell) 233 | end 234 | 235 | # call-seq: 236 | # system(command, *options) -> SystemCommand 237 | # 238 | # Executes the given +command+ with the +options+ parameter. 239 | # 240 | # Example: 241 | # print sh.system("ls", "-l") 242 | # sh.system("ls", "-l") | sh.head > STDOUT 243 | # 244 | def system(command, *opts) 245 | if opts.empty? 246 | if command =~ /\*|\?|\{|\}|\[|\]|<|>|\(|\)|~|&|\||\\|\$|;|'|`|"|\n/ 247 | return SystemCommand.new(@shell, find_system_command("sh"), "-c", command) 248 | else 249 | command, *opts = command.split(/\s+/) 250 | end 251 | end 252 | SystemCommand.new(@shell, find_system_command(command), *opts) 253 | end 254 | 255 | # call-seq: 256 | # rehash 257 | # 258 | # Clears the command hash table. 259 | def rehash 260 | @system_commands = {} 261 | end 262 | 263 | def check_point # :nodoc: 264 | @shell.process_controller.wait_all_jobs_execution 265 | end 266 | alias finish_all_jobs check_point # :nodoc: 267 | 268 | # call-seq: 269 | # transact { block } 270 | # 271 | # Executes a block as self 272 | # 273 | # Example: 274 | # sh.transact { system("ls", "-l") | head > STDOUT } 275 | def transact(&block) 276 | begin 277 | @shell.instance_eval(&block) 278 | ensure 279 | check_point 280 | end 281 | end 282 | 283 | # call-seq: 284 | # out(device) { block } 285 | # 286 | # Calls device.print on the result passing the _block_ to 287 | # #transact 288 | def out(dev = STDOUT, &block) 289 | dev.print transact(&block) 290 | end 291 | 292 | # call-seq: 293 | # echo(*strings) -> Echo 294 | # 295 | # Returns a Echo object, for the given +strings+ 296 | def echo(*strings) 297 | Echo.new(@shell, *strings) 298 | end 299 | 300 | # call-seq: 301 | # cat(*filename) -> Cat 302 | # 303 | # Returns a Cat object, for the given +filenames+ 304 | def cat(*filenames) 305 | Cat.new(@shell, *filenames) 306 | end 307 | 308 | # def sort(*filenames) 309 | # Sort.new(self, *filenames) 310 | # end 311 | # call-seq: 312 | # glob(pattern) -> Glob 313 | # 314 | # Returns a Glob filter object, with the given +pattern+ object 315 | def glob(pattern) 316 | Glob.new(@shell, pattern) 317 | end 318 | 319 | def append(to, filter) 320 | case to 321 | when String 322 | AppendFile.new(@shell, to, filter) 323 | when IO 324 | AppendIO.new(@shell, to, filter) 325 | else 326 | Shell.Fail Error::CantApplyMethod, "append", to.class 327 | end 328 | end 329 | 330 | # call-seq: 331 | # tee(file) -> Tee 332 | # 333 | # Returns a Tee filter object, with the given +file+ command 334 | def tee(file) 335 | Tee.new(@shell, file) 336 | end 337 | 338 | # call-seq: 339 | # concat(*jobs) -> Concat 340 | # 341 | # Returns a Concat object, for the given +jobs+ 342 | def concat(*jobs) 343 | Concat.new(@shell, *jobs) 344 | end 345 | 346 | # %pwd, %cwd -> @pwd 347 | def notify(*opts) 348 | Shell.notify(*opts) {|mes| 349 | yield mes if block_given? 350 | 351 | mes.gsub!("%pwd", "#{@cwd}") 352 | mes.gsub!("%cwd", "#{@cwd}") 353 | } 354 | end 355 | 356 | # 357 | # private functions 358 | # 359 | def find_system_command(command) 360 | return command if /^\// =~ command 361 | case path = @system_commands[command] 362 | when String 363 | if exist?(path) 364 | return path 365 | else 366 | Shell.Fail Error::CommandNotFound, command 367 | end 368 | when false 369 | Shell.Fail Error::CommandNotFound, command 370 | end 371 | 372 | for p in @shell.system_path 373 | path = join(p, command) 374 | begin 375 | st = File.stat(path) 376 | rescue SystemCallError 377 | next 378 | else 379 | next unless st.executable? and !st.directory? 380 | @system_commands[command] = path 381 | return path 382 | end 383 | end 384 | @system_commands[command] = false 385 | Shell.Fail Error::CommandNotFound, command 386 | end 387 | 388 | # call-seq: 389 | # def_system_command(command, path) -> Shell::SystemCommand 390 | # 391 | # Defines a command, registering +path+ as a Shell method for the given 392 | # +command+. 393 | # 394 | # Shell::CommandProcessor.def_system_command "ls" 395 | # #=> Defines ls. 396 | # 397 | # Shell::CommandProcessor.def_system_command "sys_sort", "sort" 398 | # #=> Defines sys_sort as sort 399 | # 400 | def self.def_system_command(command, path = command) 401 | begin 402 | eval((d = %Q[def #{command}(*opts) 403 | SystemCommand.new(@shell, '#{path}', *opts) 404 | end]), nil, __FILE__, __LINE__ - 1) 405 | rescue SyntaxError 406 | Shell.notify "warn: Can't define #{command} path: #{path}." 407 | end 408 | Shell.notify "Define #{command} path: #{path}.", Shell.debug? 409 | Shell.notify("Definition of #{command}: ", d, 410 | Shell.debug.kind_of?(Integer) && Shell.debug > 1) 411 | end 412 | 413 | # call-seq: 414 | # undef_system_command(command) -> self 415 | # 416 | # Undefines a command 417 | def self.undef_system_command(command) 418 | command = command.id2name if command.kind_of?(Symbol) 419 | remove_method(command) 420 | Shell.module_eval{remove_method(command)} 421 | Filter.module_eval{remove_method(command)} 422 | self 423 | end 424 | 425 | @alias_map = {} 426 | # Returns a list of aliased commands 427 | def self.alias_map 428 | @alias_map 429 | end 430 | # call-seq: 431 | # alias_command(alias, command, *options) -> self 432 | # 433 | # Creates a command alias at the given +alias+ for the given +command+, 434 | # passing any +options+ along with it. 435 | # 436 | # Shell::CommandProcessor.alias_command "lsC", "ls", "-CBF", "--show-control-chars" 437 | # Shell::CommandProcessor.alias_command("lsC", "ls"){|*opts| ["-CBF", "--show-control-chars", *opts]} 438 | # 439 | def self.alias_command(ali, command, *opts) 440 | ali = ali.id2name if ali.kind_of?(Symbol) 441 | command = command.id2name if command.kind_of?(Symbol) 442 | begin 443 | if block_given? 444 | @alias_map[ali.intern] = proc 445 | 446 | eval((d = %Q[def #{ali}(*opts) 447 | @shell.__send__(:#{command}, 448 | *(CommandProcessor.alias_map[:#{ali}].call *opts)) 449 | end]), nil, __FILE__, __LINE__ - 1) 450 | 451 | else 452 | args = opts.collect{|opt| '"' + opt + '"'}.join(",") 453 | eval((d = %Q[def #{ali}(*opts) 454 | @shell.__send__(:#{command}, #{args}, *opts) 455 | end]), nil, __FILE__, __LINE__ - 1) 456 | end 457 | rescue SyntaxError 458 | Shell.notify "warn: Can't alias #{ali} command: #{command}." 459 | Shell.notify("Definition of #{ali}: ", d) 460 | raise 461 | end 462 | Shell.notify "Define #{ali} command: #{command}.", Shell.debug? 463 | Shell.notify("Definition of #{ali}: ", d, 464 | Shell.debug.kind_of?(Integer) && Shell.debug > 1) 465 | self 466 | end 467 | 468 | # call-seq: 469 | # unalias_command(alias) -> self 470 | # 471 | # Unaliases the given +alias+ command. 472 | def self.unalias_command(ali) 473 | ali = ali.id2name if ali.kind_of?(Symbol) 474 | @alias_map.delete ali.intern 475 | undef_system_command(ali) 476 | end 477 | 478 | # :nodoc: 479 | # 480 | # Delegates File and FileTest methods into Shell, including the following 481 | # commands: 482 | # 483 | # * Shell#blockdev?(file) 484 | # * Shell#chardev?(file) 485 | # * Shell#directory?(file) 486 | # * Shell#executable?(file) 487 | # * Shell#executable_real?(file) 488 | # * Shell#exist?(file) 489 | # * Shell#file?(file) 490 | # * Shell#grpowned?(file) 491 | # * Shell#owned?(file) 492 | # * Shell#pipe?(file) 493 | # * Shell#readable?(file) 494 | # * Shell#readable_real?(file) 495 | # * Shell#setgid?(file) 496 | # * Shell#setuid?(file) 497 | # * Shell#size(file)/Shell#size?(file) 498 | # * Shell#socket?(file) 499 | # * Shell#sticky?(file) 500 | # * Shell#symlink?(file) 501 | # * Shell#writable?(file) 502 | # * Shell#writable_real?(file) 503 | # * Shell#zero?(file) 504 | # * Shell#syscopy(filename_from, filename_to) 505 | # * Shell#copy(filename_from, filename_to) 506 | # * Shell#move(filename_from, filename_to) 507 | # * Shell#compare(filename_from, filename_to) 508 | # * Shell#safe_unlink(*filenames) 509 | # * Shell#makedirs(*filenames) 510 | # * Shell#install(filename_from, filename_to, mode) 511 | # 512 | # And also, there are some aliases for convenience: 513 | # 514 | # * Shell#cmp <- Shell#compare 515 | # * Shell#mv <- Shell#move 516 | # * Shell#cp <- Shell#copy 517 | # * Shell#rm_f <- Shell#safe_unlink 518 | # * Shell#mkpath <- Shell#makedirs 519 | # 520 | def self.def_builtin_commands(delegation_class, command_specs) 521 | for meth, args in command_specs 522 | arg_str = args.collect{|arg| arg.downcase}.join(", ") 523 | call_arg_str = args.collect{ 524 | |arg| 525 | case arg 526 | when /^(FILENAME.*)$/ 527 | format("expand_path(%s)", $1.downcase) 528 | when /^(\*FILENAME.*)$/ 529 | # \*FILENAME* -> filenames.collect{|fn| expand_path(fn)}.join(", ") 530 | $1.downcase + '.collect{|fn| expand_path(fn)}' 531 | else 532 | arg 533 | end 534 | }.join(", ") 535 | d = %Q[def #{meth}(#{arg_str}) 536 | #{delegation_class}.#{meth}(#{call_arg_str}) 537 | end] 538 | Shell.notify "Define #{meth}(#{arg_str})", Shell.debug? 539 | Shell.notify("Definition of #{meth}: ", d, 540 | Shell.debug.kind_of?(Integer) && Shell.debug > 1) 541 | eval d 542 | end 543 | end 544 | 545 | # call-seq: 546 | # install_system_commands(prefix = "sys_") 547 | # 548 | # Defines all commands in the Shell.default_system_path as Shell method, 549 | # all with given +prefix+ appended to their names. 550 | # 551 | # Any invalid character names are converted to +_+, and errors are passed 552 | # to Shell.notify. 553 | # 554 | # Methods already defined are skipped. 555 | def self.install_system_commands(pre = "sys_") 556 | defined_meth = {} 557 | for m in Shell.methods 558 | defined_meth[m] = true 559 | end 560 | sh = Shell.new 561 | for path in Shell.default_system_path 562 | next unless sh.directory? path 563 | sh.cd path 564 | sh.foreach do 565 | |cn| 566 | if !defined_meth[pre + cn] && sh.file?(cn) && sh.executable?(cn) 567 | command = (pre + cn).gsub(/\W/, "_").sub(/^([0-9])/, '_\1') 568 | begin 569 | def_system_command(command, sh.expand_path(cn)) 570 | rescue 571 | Shell.notify "warn: Can't define #{command} path: #{cn}" 572 | end 573 | defined_meth[command] = command 574 | end 575 | end 576 | end 577 | end 578 | 579 | def self.add_delegate_command_to_shell(id) # :nodoc: 580 | id = id.intern if id.kind_of?(String) 581 | name = id.id2name 582 | if Shell.method_defined?(id) 583 | Shell.notify "warn: override definition of Shell##{name}." 584 | Shell.notify "warn: alias Shell##{name} to Shell##{name}_org.\n" 585 | Shell.module_eval "alias #{name}_org #{name}" 586 | end 587 | Shell.notify "method added: Shell##{name}.", Shell.debug? 588 | Shell.module_eval(%Q[def #{name}(*args, &block) 589 | begin 590 | @command_processor.__send__(:#{name}, *args, &block) 591 | rescue Exception 592 | $@.delete_if{|s| /:in `__getobj__'$/ =~ s} #` 593 | $@.delete_if{|s| /^\\(eval\\):/ =~ s} 594 | raise 595 | end 596 | end], __FILE__, __LINE__) 597 | 598 | if Shell::Filter.method_defined?(id) 599 | Shell.notify "warn: override definition of Shell::Filter##{name}." 600 | Shell.notify "warn: alias Shell##{name} to Shell::Filter##{name}_org." 601 | Filter.module_eval "alias #{name}_org #{name}" 602 | end 603 | Shell.notify "method added: Shell::Filter##{name}.", Shell.debug? 604 | Filter.module_eval(%Q[def #{name}(*args, &block) 605 | begin 606 | self | @shell.__send__(:#{name}, *args, &block) 607 | rescue Exception 608 | $@.delete_if{|s| /:in `__getobj__'$/ =~ s} #` 609 | $@.delete_if{|s| /^\\(eval\\):/ =~ s} 610 | raise 611 | end 612 | end], __FILE__, __LINE__) 613 | end 614 | 615 | # Delegates File methods into Shell, including the following commands: 616 | # 617 | # * Shell#atime(file) 618 | # * Shell#basename(file, *opt) 619 | # * Shell#chmod(mode, *files) 620 | # * Shell#chown(owner, group, *file) 621 | # * Shell#ctime(file) 622 | # * Shell#delete(*file) 623 | # * Shell#dirname(file) 624 | # * Shell#ftype(file) 625 | # * Shell#join(*file) 626 | # * Shell#link(file_from, file_to) 627 | # * Shell#lstat(file) 628 | # * Shell#mtime(file) 629 | # * Shell#readlink(file) 630 | # * Shell#rename(file_from, file_to) 631 | # * Shell#split(file) 632 | # * Shell#stat(file) 633 | # * Shell#symlink(file_from, file_to) 634 | # * Shell#truncate(file, length) 635 | # * Shell#utime(atime, mtime, *file) 636 | # 637 | def self.install_builtin_commands 638 | # method related File. 639 | # (exclude open/foreach/unlink) 640 | normal_delegation_file_methods = [ 641 | ["atime", ["FILENAME"]], 642 | ["basename", ["fn", "*opts"]], 643 | ["chmod", ["mode", "*FILENAMES"]], 644 | ["chown", ["owner", "group", "*FILENAME"]], 645 | ["ctime", ["FILENAMES"]], 646 | ["delete", ["*FILENAMES"]], 647 | ["dirname", ["FILENAME"]], 648 | ["ftype", ["FILENAME"]], 649 | ["join", ["*items"]], 650 | ["link", ["FILENAME_O", "FILENAME_N"]], 651 | ["lstat", ["FILENAME"]], 652 | ["mtime", ["FILENAME"]], 653 | ["readlink", ["FILENAME"]], 654 | ["rename", ["FILENAME_FROM", "FILENAME_TO"]], 655 | ["split", ["pathname"]], 656 | ["stat", ["FILENAME"]], 657 | ["symlink", ["FILENAME_O", "FILENAME_N"]], 658 | ["truncate", ["FILENAME", "length"]], 659 | ["utime", ["atime", "mtime", "*FILENAMES"]]] 660 | 661 | def_builtin_commands(File, normal_delegation_file_methods) 662 | alias_method :rm, :delete 663 | 664 | # method related FileTest 665 | def_builtin_commands(FileTest, 666 | FileTest.singleton_methods(false).collect{|m| [m, ["FILENAME"]]}) 667 | 668 | end 669 | 670 | end 671 | end 672 | -------------------------------------------------------------------------------- /lib/shell/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/error.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | require "e2mmap" 14 | 15 | class Shell 16 | module Error 17 | extend Exception2MessageMapper 18 | def_e2message TypeError, "wrong argument type %s (expected %s)" 19 | 20 | def_exception :DirStackEmpty, "Directory stack empty." 21 | def_exception :CantDefine, "Can't define method(%s, %s)." 22 | def_exception :CantApplyMethod, "This method(%s) does not apply to this type(%s)." 23 | def_exception :CommandNotFound, "Command not found(%s)." 24 | end 25 | end 26 | 27 | -------------------------------------------------------------------------------- /lib/shell/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/filter.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | class Shell #:nodoc: 14 | # Any result of command execution is a Filter. 15 | # 16 | # This class includes Enumerable, therefore a Filter object can use all 17 | # Enumerable 18 | # facilities. 19 | # 20 | class Filter 21 | include Enumerable 22 | 23 | def initialize(sh) 24 | @shell = sh # parent shell 25 | @input = nil # input filter 26 | end 27 | 28 | attr_reader :input 29 | 30 | def input=(filter) 31 | @input = filter 32 | end 33 | 34 | # call-seq: 35 | # each(record_separator=nil) { block } 36 | # 37 | # Iterates a block for each line. 38 | def each(rs = nil) 39 | rs = @shell.record_separator unless rs 40 | if @input 41 | @input.each(rs){|l| yield l} 42 | end 43 | end 44 | 45 | # call-seq: 46 | # < source 47 | # 48 | # Inputs from +source+, which is either a string of a file name or an IO 49 | # object. 50 | def <(src) 51 | case src 52 | when String 53 | cat = Cat.new(@shell, src) 54 | cat | self 55 | when IO 56 | self.input = src 57 | self 58 | else 59 | Shell.Fail Error::CantApplyMethod, "<", src.class 60 | end 61 | end 62 | 63 | # call-seq: 64 | # > source 65 | # 66 | # Outputs from +source+, which is either a string of a file name or an IO 67 | # object. 68 | def >(to) 69 | case to 70 | when String 71 | dst = @shell.open(to, "w") 72 | begin 73 | each(){|l| dst << l} 74 | ensure 75 | dst.close 76 | end 77 | when IO 78 | each(){|l| to << l} 79 | else 80 | Shell.Fail Error::CantApplyMethod, ">", to.class 81 | end 82 | self 83 | end 84 | 85 | # call-seq: 86 | # >> source 87 | # 88 | # Appends the output to +source+, which is either a string of a file name 89 | # or an IO object. 90 | def >>(to) 91 | begin 92 | Shell.cd(@shell.pwd).append(to, self) 93 | rescue CantApplyMethod 94 | Shell.Fail Error::CantApplyMethod, ">>", to.class 95 | end 96 | end 97 | 98 | # call-seq: 99 | # | filter 100 | # 101 | # Processes a pipeline. 102 | def |(filter) 103 | filter.input = self 104 | if active? 105 | @shell.process_controller.start_job filter 106 | end 107 | filter 108 | end 109 | 110 | # call-seq: 111 | # filter1 + filter2 112 | # 113 | # Outputs +filter1+, and then +filter2+ using Join.new 114 | def +(filter) 115 | Join.new(@shell, self, filter) 116 | end 117 | 118 | def to_a 119 | ary = [] 120 | each(){|l| ary.push l} 121 | ary 122 | end 123 | 124 | def to_s 125 | str = "" 126 | each(){|l| str.concat l} 127 | str 128 | end 129 | 130 | def inspect 131 | if @shell.debug.kind_of?(Integer) && @shell.debug > 2 132 | super 133 | else 134 | to_s 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/shell/process-controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/process-controller.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | require "forwardable" 13 | require "sync" 14 | 15 | class Shell 16 | class ProcessController 17 | 18 | @ProcessControllers = {} 19 | @ProcessControllersMonitor = Thread::Mutex.new 20 | @ProcessControllersCV = Thread::ConditionVariable.new 21 | 22 | @BlockOutputMonitor = Thread::Mutex.new 23 | @BlockOutputCV = Thread::ConditionVariable.new 24 | 25 | class << self 26 | extend Forwardable 27 | 28 | def_delegator("@ProcessControllersMonitor", 29 | "synchronize", "process_controllers_exclusive") 30 | 31 | def active_process_controllers 32 | process_controllers_exclusive do 33 | @ProcessControllers.dup 34 | end 35 | end 36 | 37 | def activate(pc) 38 | process_controllers_exclusive do 39 | @ProcessControllers[pc] ||= 0 40 | @ProcessControllers[pc] += 1 41 | end 42 | end 43 | 44 | def inactivate(pc) 45 | process_controllers_exclusive do 46 | if @ProcessControllers[pc] 47 | if (@ProcessControllers[pc] -= 1) == 0 48 | @ProcessControllers.delete(pc) 49 | @ProcessControllersCV.signal 50 | end 51 | end 52 | end 53 | end 54 | 55 | def each_active_object 56 | process_controllers_exclusive do 57 | for ref in @ProcessControllers.keys 58 | yield ref 59 | end 60 | end 61 | end 62 | 63 | def block_output_synchronize(&b) 64 | @BlockOutputMonitor.synchronize(&b) 65 | end 66 | 67 | def wait_to_finish_all_process_controllers 68 | process_controllers_exclusive do 69 | while !@ProcessControllers.empty? 70 | Shell::notify("Process finishing, but active shell exists", 71 | "You can use Shell#transact or Shell#check_point for more safe execution.") 72 | if Shell.debug? 73 | for pc in @ProcessControllers.keys 74 | Shell::notify(" Not finished jobs in "+pc.shell.to_s) 75 | for com in pc.jobs 76 | com.notify(" Jobs: %id") 77 | end 78 | end 79 | end 80 | @ProcessControllersCV.wait(@ProcessControllersMonitor) 81 | end 82 | end 83 | end 84 | end 85 | 86 | # for shell-command complete finish at this process exit. 87 | USING_AT_EXIT_WHEN_PROCESS_EXIT = true 88 | at_exit do 89 | wait_to_finish_all_process_controllers unless $@ 90 | end 91 | 92 | def initialize(shell) 93 | @shell = shell 94 | @waiting_jobs = [] 95 | @active_jobs = [] 96 | @jobs_sync = Sync.new 97 | 98 | @job_monitor = Thread::Mutex.new 99 | @job_condition = Thread::ConditionVariable.new 100 | end 101 | 102 | attr_reader :shell 103 | 104 | def jobs 105 | jobs = [] 106 | @jobs_sync.synchronize(:SH) do 107 | jobs.concat @waiting_jobs 108 | jobs.concat @active_jobs 109 | end 110 | jobs 111 | end 112 | 113 | def active_jobs 114 | @active_jobs 115 | end 116 | 117 | def waiting_jobs 118 | @waiting_jobs 119 | end 120 | 121 | def jobs_exist? 122 | @jobs_sync.synchronize(:SH) do 123 | @active_jobs.empty? or @waiting_jobs.empty? 124 | end 125 | end 126 | 127 | def active_jobs_exist? 128 | @jobs_sync.synchronize(:SH) do 129 | @active_jobs.empty? 130 | end 131 | end 132 | 133 | def waiting_jobs_exist? 134 | @jobs_sync.synchronize(:SH) do 135 | @waiting_jobs.empty? 136 | end 137 | end 138 | 139 | # schedule a command 140 | def add_schedule(command) 141 | @jobs_sync.synchronize(:EX) do 142 | ProcessController.activate(self) 143 | if @active_jobs.empty? 144 | start_job command 145 | else 146 | @waiting_jobs.push(command) 147 | end 148 | end 149 | end 150 | 151 | # start a job 152 | def start_job(command = nil) 153 | @jobs_sync.synchronize(:EX) do 154 | if command 155 | return if command.active? 156 | @waiting_jobs.delete command 157 | else 158 | command = @waiting_jobs.shift 159 | 160 | return unless command 161 | end 162 | @active_jobs.push command 163 | command.start 164 | 165 | # start all jobs that input from the job 166 | for job in @waiting_jobs.dup 167 | start_job(job) if job.input == command 168 | end 169 | end 170 | end 171 | 172 | def waiting_job?(job) 173 | @jobs_sync.synchronize(:SH) do 174 | @waiting_jobs.include?(job) 175 | end 176 | end 177 | 178 | def active_job?(job) 179 | @jobs_sync.synchronize(:SH) do 180 | @active_jobs.include?(job) 181 | end 182 | end 183 | 184 | # terminate a job 185 | def terminate_job(command) 186 | @jobs_sync.synchronize(:EX) do 187 | @active_jobs.delete command 188 | ProcessController.inactivate(self) 189 | if @active_jobs.empty? 190 | command.notify("start_job in terminate_job(%id)", Shell::debug?) 191 | start_job 192 | end 193 | end 194 | end 195 | 196 | # kill a job 197 | def kill_job(sig, command) 198 | @jobs_sync.synchronize(:EX) do 199 | if @waiting_jobs.delete command 200 | ProcessController.inactivate(self) 201 | return 202 | elsif @active_jobs.include?(command) 203 | begin 204 | r = command.kill(sig) 205 | ProcessController.inactivate(self) 206 | rescue 207 | print "Shell: Warn: $!\n" if @shell.verbose? 208 | return nil 209 | end 210 | @active_jobs.delete command 211 | r 212 | end 213 | end 214 | end 215 | 216 | # wait for all jobs to terminate 217 | def wait_all_jobs_execution 218 | @job_monitor.synchronize do 219 | begin 220 | while !jobs.empty? 221 | @job_condition.wait(@job_monitor) 222 | for job in jobs 223 | job.notify("waiting job(%id)", Shell::debug?) 224 | end 225 | end 226 | ensure 227 | redo unless jobs.empty? 228 | end 229 | end 230 | end 231 | 232 | # simple fork 233 | def sfork(command) 234 | pipe_me_in, pipe_peer_out = IO.pipe 235 | pipe_peer_in, pipe_me_out = IO.pipe 236 | 237 | 238 | pid = nil 239 | pid_mutex = Thread::Mutex.new 240 | pid_cv = Thread::ConditionVariable.new 241 | 242 | Thread.start do 243 | ProcessController.block_output_synchronize do 244 | STDOUT.flush 245 | ProcessController.each_active_object do |pc| 246 | for jobs in pc.active_jobs 247 | jobs.flush 248 | end 249 | end 250 | 251 | pid = fork { 252 | Thread.list.each do |th| 253 | th.kill unless Thread.current == th 254 | end 255 | 256 | STDIN.reopen(pipe_peer_in) 257 | STDOUT.reopen(pipe_peer_out) 258 | 259 | ObjectSpace.each_object(IO) do |io| 260 | if ![STDIN, STDOUT, STDERR].include?(io) 261 | io.close 262 | end 263 | end 264 | 265 | yield 266 | } 267 | end 268 | pid_cv.signal 269 | 270 | pipe_peer_in.close 271 | pipe_peer_out.close 272 | command.notify "job(%name:##{pid}) start", @shell.debug? 273 | 274 | begin 275 | _pid = nil 276 | command.notify("job(%id) start to waiting finish.", @shell.debug?) 277 | _pid = Process.waitpid(pid, nil) 278 | rescue Errno::ECHILD 279 | command.notify "warn: job(%id) was done already waitpid." 280 | _pid = true 281 | ensure 282 | command.notify("Job(%id): Wait to finish when Process finished.", @shell.debug?) 283 | # when the process ends, wait until the command terminates 284 | if USING_AT_EXIT_WHEN_PROCESS_EXIT or _pid 285 | else 286 | command.notify("notice: Process finishing...", 287 | "wait for Job[%id] to finish.", 288 | "You can use Shell#transact or Shell#check_point for more safe execution.") 289 | redo 290 | end 291 | 292 | @job_monitor.synchronize do 293 | terminate_job(command) 294 | @job_condition.signal 295 | command.notify "job(%id) finish.", @shell.debug? 296 | end 297 | end 298 | end 299 | 300 | pid_mutex.synchronize do 301 | while !pid 302 | pid_cv.wait(pid_mutex) 303 | end 304 | end 305 | 306 | return pid, pipe_me_in, pipe_me_out 307 | end 308 | end 309 | end 310 | -------------------------------------------------------------------------------- /lib/shell/system-command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # shell/system-command.rb - 4 | # $Release Version: 0.7 $ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | require_relative "filter" 14 | 15 | class Shell 16 | class SystemCommand < Filter 17 | def initialize(sh, command, *opts) 18 | if t = opts.find{|opt| !opt.kind_of?(String) && opt.class} 19 | Shell.Fail TypeError, t.class, "String" 20 | end 21 | super(sh) 22 | @command = command 23 | @opts = opts 24 | 25 | @input_queue = Thread::Queue.new 26 | @pid = nil 27 | 28 | sh.process_controller.add_schedule(self) 29 | end 30 | 31 | attr_reader :command 32 | alias name command 33 | 34 | def wait? 35 | @shell.process_controller.waiting_job?(self) 36 | end 37 | 38 | def active? 39 | @shell.process_controller.active_job?(self) 40 | end 41 | 42 | def input=(inp) 43 | super 44 | if active? 45 | start_export 46 | end 47 | end 48 | 49 | def start 50 | notify([@command, *@opts].join(" ")) 51 | 52 | @pid, @pipe_in, @pipe_out = @shell.process_controller.sfork(self) { 53 | Dir.chdir @shell.pwd 54 | $0 = @command 55 | exec(@command, *@opts) 56 | } 57 | if @input 58 | start_export 59 | end 60 | start_import 61 | end 62 | 63 | def flush 64 | @pipe_out.flush if @pipe_out and !@pipe_out.closed? 65 | end 66 | 67 | def terminate 68 | begin 69 | @pipe_in.close 70 | rescue IOError 71 | end 72 | begin 73 | @pipe_out.close 74 | rescue IOError 75 | end 76 | end 77 | 78 | def kill(sig) 79 | if @pid 80 | Process.kill(sig, @pid) 81 | end 82 | end 83 | 84 | def start_import 85 | notify "Job(%id) start imp-pipe.", @shell.debug? 86 | _eop = true 87 | Thread.start { 88 | begin 89 | while l = @pipe_in.gets 90 | @input_queue.push l 91 | end 92 | _eop = false 93 | rescue Errno::EPIPE 94 | _eop = false 95 | ensure 96 | if !ProcessController::USING_AT_EXIT_WHEN_PROCESS_EXIT and _eop 97 | notify("warn: Process finishing...", 98 | "wait for Job[%id] to finish pipe importing.", 99 | "You can use Shell#transact or Shell#check_point for more safe execution.") 100 | redo 101 | end 102 | notify "job(%id}) close imp-pipe.", @shell.debug? 103 | @input_queue.push :EOF 104 | @pipe_in.close 105 | end 106 | } 107 | end 108 | 109 | def start_export 110 | notify "job(%id) start exp-pipe.", @shell.debug? 111 | _eop = true 112 | Thread.start{ 113 | begin 114 | @input.each do |l| 115 | ProcessController::block_output_synchronize do 116 | @pipe_out.print l 117 | end 118 | end 119 | _eop = false 120 | rescue Errno::EPIPE, Errno::EIO 121 | _eop = false 122 | ensure 123 | if !ProcessController::USING_AT_EXIT_WHEN_PROCESS_EXIT and _eop 124 | notify("shell: warn: Process finishing...", 125 | "wait for Job(%id) to finish pipe exporting.", 126 | "You can use Shell#transact or Shell#check_point for more safe execution.") 127 | redo 128 | end 129 | notify "job(%id) close exp-pipe.", @shell.debug? 130 | @pipe_out.close 131 | end 132 | } 133 | end 134 | 135 | alias super_each each 136 | def each(rs = nil) 137 | while (l = @input_queue.pop) != :EOF 138 | yield l 139 | end 140 | end 141 | 142 | # ex) 143 | # if you wish to output: 144 | # "shell: job(#{@command}:#{@pid}) close pipe-out." 145 | # then 146 | # mes: "job(%id) close pipe-out." 147 | # yorn: Boolean(@shell.debug? or @shell.verbose?) 148 | def notify(*opts) 149 | @shell.notify(*opts) do |mes| 150 | yield mes if block_given? 151 | 152 | mes.gsub!("%id", "#{@command}:##{@pid}") 153 | mes.gsub!("%name", "#{@command}") 154 | mes.gsub!("%pid", "#{@pid}") 155 | mes 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/shell/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | # 3 | # version.rb - shell version definition file 4 | # $Release Version: 0.7$ 5 | # $Revision$ 6 | # by Keiju ISHITSUKA(keiju@ruby-lang.org) 7 | # 8 | # -- 9 | # 10 | # 11 | # 12 | 13 | class Shell # :nodoc: 14 | VERSION = "0.8.1" 15 | @RELEASE_VERSION = VERSION 16 | @LAST_UPDATE_DATE = "07/03/20" 17 | end 18 | -------------------------------------------------------------------------------- /shell.gemspec: -------------------------------------------------------------------------------- 1 | begin 2 | require_relative "lib/shell/version" 3 | rescue LoadError 4 | # for Ruby core repository 5 | require_relative "version" 6 | end 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "shell" 10 | spec.version = Shell::VERSION 11 | spec.authors = ["Keiju ISHITSUKA"] 12 | spec.email = ["keiju@ruby-lang.org"] 13 | 14 | spec.summary = %q{An idiomatic Ruby interface for common UNIX shell commands.} 15 | spec.description = %q{An idiomatic Ruby interface for common UNIX shell commands.} 16 | spec.homepage = "https://github.com/ruby/shell" 17 | spec.license = "BSD-2-Clause" 18 | 19 | spec.files = [ 20 | "Gemfile", 21 | "LICENSE.txt", 22 | "README.md", 23 | "Rakefile", 24 | "bin/console", 25 | "bin/setup", 26 | "lib/shell.rb", 27 | "lib/shell/builtin-command.rb", 28 | "lib/shell/command-processor.rb", 29 | "lib/shell/error.rb", 30 | "lib/shell/filter.rb", 31 | "lib/shell/process-controller.rb", 32 | "lib/shell/system-command.rb", 33 | "lib/shell/version.rb", 34 | "shell.gemspec", 35 | ] 36 | spec.bindir = "exe" 37 | spec.executables = [] 38 | spec.require_paths = ["lib"] 39 | 40 | spec.add_runtime_dependency "e2mmap" 41 | spec.add_runtime_dependency "sync" 42 | end 43 | -------------------------------------------------------------------------------- /test/lib/helper.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | require "core_assertions" 3 | 4 | Test::Unit::TestCase.include Test::Unit::CoreAssertions 5 | -------------------------------------------------------------------------------- /test/shell/test_command_processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | require 'test/unit' 3 | require 'shell' 4 | require 'tmpdir' 5 | 6 | class TestShell < Test::Unit::TestCase 7 | end 8 | class TestShell::CommandProcessor < Test::Unit::TestCase 9 | def setup 10 | @tmpdir = Dir.mktmpdir("test_shell") 11 | @shell = Shell.new 12 | @shell.system_path = [@tmpdir] 13 | end 14 | 15 | def teardown 16 | Dir.rmdir(@tmpdir) 17 | end 18 | 19 | def catch_command_start(tc = Object.new) 20 | @shell.process_controller.singleton_class.class_eval do 21 | define_method(:add_schedule) {|cmd| throw tc, cmd} 22 | end 23 | tc 24 | end 25 | 26 | def exeext 27 | RbConfig::CONFIG["EXECUTABLE_EXTS"][/\S+\z/] 28 | end 29 | 30 | def test_system_external 31 | name = "foo#{exeext}" 32 | path = File.join(@tmpdir, name) 33 | open(path, "w", 0755) {} 34 | 35 | cmd = assert_throw(catch_command_start) {@shell.system(name)} 36 | assert_equal(path, cmd.command) 37 | ensure 38 | File.unlink(path) 39 | end 40 | 41 | def test_system_not_found 42 | bug8918 = '[ruby-core:57235] [Bug #8918]' 43 | 44 | name = "foo" 45 | path = File.join(@tmpdir, name) 46 | open(path, "w", 0644) {} 47 | 48 | assert_raise(Shell::Error::CommandNotFound, bug8918) { 49 | catch(catch_command_start) {@shell.system(name)} 50 | } 51 | ensure 52 | Process.waitall 53 | File.unlink(path) 54 | end 55 | 56 | def test_system_directory 57 | bug8918 = '[ruby-core:57235] [Bug #8918]' 58 | 59 | name = "foo#{exeext}" 60 | path = File.join(@tmpdir, name) 61 | Dir.mkdir(path) 62 | 63 | assert_raise(Shell::Error::CommandNotFound, bug8918) { 64 | catch(catch_command_start) {@shell.system(name)} 65 | } 66 | ensure 67 | Process.waitall 68 | Dir.rmdir(path) 69 | end 70 | 71 | def test_test 72 | name = "foo#{exeext}" 73 | path = File.join(@tmpdir, name) 74 | open(path, "w", 0644) {} 75 | 76 | assert_equal(true, @shell[?e, path]) 77 | assert_equal(true, @shell[:e, path]) 78 | assert_equal(true, @shell["e", path]) 79 | assert_equal(true, @shell[:exist?, path]) 80 | assert_equal(true, @shell["exist?", path]) 81 | assert_raise(RuntimeError) do 82 | assert_equal(true, @shell[:instance_eval, path]) 83 | end 84 | ensure 85 | Process.waitall 86 | File.unlink(path) 87 | end 88 | 89 | def test_option_type 90 | name = 'foo.cmd' 91 | path = File.join(@tmpdir, name) 92 | 93 | open(path, 'w', 0755) {} 94 | assert_raise(TypeError) { 95 | catch(catch_command_start) {@shell.system(name, 42)} 96 | } 97 | ensure 98 | Process.waitall 99 | File.unlink(path) 100 | end 101 | end 102 | --------------------------------------------------------------------------------