diff --git a/.gitignore b/.gitignore index 4e0f123..276a0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /spec/reports/ /tmp/ /Gemfile.lock +ext/Makefile +ext/*.o +ext/*.bundle \ No newline at end of file diff --git a/README.md b/README.md index 2daa4a5..73ee30c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,12 @@ [![Gem Version](https://badge.fury.io/rb/wordexp.svg)](https://rubygems.org/gems/wordexp) [![Circle](https://circleci.com/gh/samsonjs/wordexp/tree/main.svg?style=shield)](https://app.circleci.com/pipelines/github/samsonjs/wordexp?branch=main) -[![Code Climate](https://codeclimate.com/github/samsonjs/wordexp/badges/gpa.svg)](https://codeclimate.com/github/samsonjs/wordexp) +[![Code Climate Maintainability](https://api.codeclimate.com/v1/badges/21cc24badf15d19b5cec/maintainability)](https://codeclimate.com/github/samsonjs/wordexp/maintainability) -TODO: Description of this gem goes here. +A Ruby gem for performing shell word expansion using [GNU wordexp][]. It's like [Shellwords][] turned up to 11. Not only does it split taking quotes into account, but it also expands environment variables and tildes, and runs subcommands in \``backticks`\` or `$(dollar parentheses)`. + +[GNU wordexp]: https://www.gnu.org/software/libc/manual/html_node/Word-Expansion.html +[Shellwords]: https://ruby-doc.org/stdlib-3.1.0/libdoc/shellwords/rdoc/Shellwords.html --- @@ -21,9 +24,17 @@ $ gem install wordexp ``` ```ruby -require "wordexp" +require 'wordexp' + +cmd = Wordexp.expand("echo 'roof cats' $HOME ~/bin $(date +%F)") +# => ["echo", "roof cats", "/home/queso", "/home/queso/bin", "2022-01-16"] + +fork { exec(*cmd) } +# roof cats /home/queso /home/queso/bin 2022-01-16 ``` +With that you're half way to a [fairly usable shell in Ruby](https://github.com/samsonjs/csc360-a1-shell). + ## Support If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/samsonjs/wordexp/issues/new) and I will do my best to provide a helpful answer. Happy hacking! @@ -38,4 +49,4 @@ Everyone interacting in this project’s codebases, issue trackers, chat rooms a ## Contribution guide -Pull requests are welcome! +Pull requests are welcome! Make sure that new code is reasonably well tested and all the checks pass. I'm happy to provide a bit of direction and guidance if you're unsure how to proceed with any of these things. diff --git a/Rakefile b/Rakefile index a4525ac..005de45 100644 --- a/Rakefile +++ b/Rakefile @@ -2,7 +2,15 @@ require 'bundler/gem_tasks' require 'rake/testtask' require 'rubocop/rake_task' -Rake::TestTask.new(:test) do |t| +task :compile do + puts 'Compiling C extension' + `cd ext && make clean` + `cd ext && ruby extconf.rb` + `cd ext && make` + puts 'Done' +end + +Rake::TestTask.new(test: :compile) do |t| t.libs << 'test' t.libs << 'lib' t.test_files = FileList['test/**/*_test.rb'] diff --git a/exe/wordexp b/exe/wordexp index 30e774d..b51c1bf 100755 --- a/exe/wordexp +++ b/exe/wordexp @@ -1,4 +1,11 @@ #!/usr/bin/env ruby -w require 'wordexp' -Wordexp::CLI.new.call(ARGV) + +string = ARGV.first +if string.nil? || string.strip.empty? + warn 'Usage: wordexp ' + exit 1 +end + +Wordexp::CLI.new.call(string) diff --git a/ext/extconf.rb b/ext/extconf.rb new file mode 100644 index 0000000..fb7299b --- /dev/null +++ b/ext/extconf.rb @@ -0,0 +1,3 @@ +require 'mkmf' + +create_makefile 'wordexp_ext' diff --git a/ext/wordexp_ext.c b/ext/wordexp_ext.c new file mode 100644 index 0000000..e73c09f --- /dev/null +++ b/ext/wordexp_ext.c @@ -0,0 +1,61 @@ +/** + * @file wordexp_ext.c + * @author Sami Samhuri (sami@samhuri.net) + * @brief Ruby wrapper for the standard Unix wordexp function, used to expand shell command lines in many useful ways. + * @version 0.1 + * @date 2022-01-16 + * + * @copyright Copyright (c) Sami Samhuri 2022 + * + * Released under the terms of the MIT license: https://sjs.mit-license.org + * + */ + +#include +#include + +#include "ruby.h" +#include "ruby/encoding.h" + +static VALUE ext_wordexp(VALUE self, VALUE rstring) { + Check_Type(rstring, T_STRING); + char *string = RSTRING_PTR(rstring); + + /* Split and expand words, showing errors and failing on undefined variables */ + wordexp_t words; + int result = wordexp(string, &words, WRDE_SHOWERR | WRDE_UNDEF); + + /* failure */ + if (result != 0) { + switch (result) { + case WRDE_BADCHAR: + return ID2SYM(rb_intern("wrde_badchar")); + case WRDE_BADVAL: + return ID2SYM(rb_intern("wrde_badval")); + case WRDE_CMDSUB: + return ID2SYM(rb_intern("wrde_cmdsub")); + case WRDE_NOSPACE: + return ID2SYM(rb_intern("wrde_nospace")); + case WRDE_SYNTAX: + return ID2SYM(rb_intern("wrde_syntax")); + default: + return ID2SYM(rb_intern("unknown_error")); + } + } + + /* success */ + VALUE rwords = rb_ary_new2(words.we_wordc); + for (size_t i = 0; i < words.we_wordc; i++) { + VALUE rword = rb_str_new2(words.we_wordv[i]); + rb_ary_push(rwords, rword); + } + wordfree(&words); + return rwords; +} + +void Init_wordexp_ext(void) { + VALUE Wordexp = rb_define_module("Wordexp"); + + VALUE Ext = rb_define_class_under(Wordexp, "Ext", rb_cObject); + rb_define_singleton_method(Ext, "wordexp", ext_wordexp, 1); +} diff --git a/lib/wordexp.rb b/lib/wordexp.rb index c818098..dc8fdea 100644 --- a/lib/wordexp.rb +++ b/lib/wordexp.rb @@ -1,4 +1,53 @@ +require 'wordexp_ext' + module Wordexp autoload :CLI, 'wordexp/cli' autoload :VERSION, 'wordexp/version' + + # Illegal occurrence of newline or one of |, &, ;, <, >, (, ), {, }. + class BadCharacterError < StandardError; end + + # An undefined shell variable was referenced, and the WRDE_UNDEF flag told us to consider this an error. + class BadValueError < StandardError; end + + # Command substitution requested, but the WRDE_NOCMD flag told us to consider this an error. + class CommandSubstitutionError < StandardError; end + + # Out of memory. + class NoSpaceError < StandardError; end + + # Shell syntax error, such as unbalanced parentheses or unmatched quotes. + class SyntaxError < StandardError; end + + # An undocumented error occurred. + class UndocumentedError < StandardError; end + + class << self + def expand(string) + result = Ext.wordexp(string) + return result if result.is_a?(Array) + + case result + when :wrde_badchar + # FIXME: useful message that includes the position of the character + raise BadCharacterError, 'Bad character' + + when :wrde_badval + raise BadValueError, 'Bad value' + + when :wrde_cmdsub + raise CommandSubstitutionError, 'Command substitution is disabled' + + when :wrde_nospace + raise NoSpaceError, 'Out of memory' + + when :wrde_syntax + raise SyntaxError, 'Bad value' + + else + warn "wordexp returned an unexpected result: #{result.inspect}" + raise UndocumentedError, 'An unknown error occurred' + end + end + end end diff --git a/lib/wordexp/cli.rb b/lib/wordexp/cli.rb index 3c13f46..9cb76ba 100644 --- a/lib/wordexp/cli.rb +++ b/lib/wordexp/cli.rb @@ -1,7 +1,7 @@ module Wordexp class CLI - def call(argv) - puts argv.join(' ') + def call(string) + puts Wordexp.expand(string).inspect end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8a14af0..fc31137 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,4 @@ +$LOAD_PATH.unshift File.expand_path('../ext', __dir__) $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'wordexp' diff --git a/test/wordexp/version_test.rb b/test/wordexp/version_test.rb new file mode 100644 index 0000000..a7ec630 --- /dev/null +++ b/test/wordexp/version_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class WordexpVersionTest < Minitest::Test + def test_that_it_has_a_version_number + refute_nil ::Wordexp::VERSION + end +end diff --git a/test/wordexp_test.rb b/test/wordexp_test.rb index b3d526e..a021675 100644 --- a/test/wordexp_test.rb +++ b/test/wordexp_test.rb @@ -1,7 +1,49 @@ require 'test_helper' class WordexpTest < Minitest::Test - def test_that_it_has_a_version_number - refute_nil ::Wordexp::VERSION + def test_constants + assert_equal %w[hello wordexp], ::Wordexp.expand('hello wordexp') + end + + def test_quotation + assert_equal ['hello wordexp', 'more words'], ::Wordexp.expand("'hello wordexp' \"more words\"") + end + + def test_environment_variable_expansion + assert_equal [ENV['HOME']], ::Wordexp.expand('$HOME') + end + + def test_tilde_expansion + assert_equal ["#{ENV['HOME']}/bin"], ::Wordexp.expand('~/bin') + end + + def test_command_substitution_backticks + assert_equal ["#{ENV['HOME']}/bin"], ::Wordexp.expand('`echo ~/bin`') + end + + def test_command_substitution_dollar_parentheses + assert_equal ["#{ENV['HOME']}/bin"], ::Wordexp.expand('$(echo ~/bin)') + end + + def test_error_badchar + assert_raises(Wordexp::BadCharacterError) do + ::Wordexp.expand('') + end + end + + def test_error_badval + assert_raises(Wordexp::BadValueError) do + ::Wordexp.expand('$DEFINITELY_DOES_NOT_EXIST') + end + end + + def test_error_cmdsub + # cannot test this until there's a way to disable command substitution + end + + def test_error_syntax + assert_raises(Wordexp::SyntaxError) do + ::Wordexp.expand('$(this is the command that never ends') + end end end diff --git a/wordexp.gemspec b/wordexp.gemspec index c3c30f9..044adf4 100644 --- a/wordexp.gemspec +++ b/wordexp.gemspec @@ -20,8 +20,9 @@ Gem::Specification.new do |spec| } # Specify which files should be added to the gem when it is released. - spec.files = Dir.glob(%w[LICENSE.txt README.md lib/**/*]).reject { |f| File.directory?(f) } + glob = %w[LICENSE.txt README.md lib/**/* ext/extconf.rb ext/wordexp_ext.c] + spec.files = Dir.glob(glob).reject { |f| File.directory?(f) } spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ['lib'] + spec.require_paths = %w[ext lib] end