├── VERSION ├── test ├── test_helper.exs ├── fixtures │ └── test.txt └── sh_test.exs ├── .gitignore ├── .travis.yml ├── .editorconfig ├── config └── config.exs ├── mix.exs ├── README.md ├── UNLICENSE └── lib └── sh.ex /VERSION: -------------------------------------------------------------------------------- 1 | 1.1.2 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | -------------------------------------------------------------------------------- /test/fixtures/test.txt: -------------------------------------------------------------------------------- 1 | foo 2 | bar 3 | baz 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.0 4 | - 1.1.1 5 | - 1.1.0 6 | - 1.0.5 7 | - 1.0.4 8 | otp_release: 9 | - 18.2 10 | - 18.1 11 | - 18.0 12 | - 17.5 13 | - 17.4 14 | - 17.3 15 | sudo: false 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 2 space indentation 12 | [*.{ex,exs}] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies. The Mix.Config module provides functions 3 | # to aid in doing so. 4 | use Mix.Config 5 | 6 | # Note this file is loaded before any dependency and is restricted 7 | # to this project. If another project depends on this project, this 8 | # file won't be loaded nor affect the parent project. 9 | 10 | # Sample configuration: 11 | # 12 | # config :my_dep, 13 | # key: :value, 14 | # limit: 42 15 | 16 | # It is also possible to import configuration files, relative to this 17 | # directory. For example, you can emulate configuration per environment 18 | # by uncommenting the line below and defining dev.exs, test.exs and such. 19 | # Configuration from the imported file will override the ones defined 20 | # here (which is why it is important to import them last). 21 | # 22 | # import_config "#{Mix.env}.exs" 23 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sh.Mixfile do 2 | use Mix.Project 3 | 4 | @version String.strip(File.read!("VERSION")) 5 | 6 | def project do 7 | [app: :sh, 8 | version: @version, 9 | elixir: "~> 1.0", 10 | description: "Run programs as functions in Elixir", 11 | deps: deps, 12 | package: package] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type `mix help compile.app` for more information 18 | def application do 19 | [applications: []] 20 | end 21 | 22 | # Dependencies can be hex.pm packages: 23 | # 24 | # {:mydep, "~> 0.3.0"} 25 | # 26 | # Or git/path repositories: 27 | # 28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1"} 29 | # 30 | # Type `mix help deps` for more examples and options 31 | defp deps do 32 | [] 33 | end 34 | 35 | defp package do 36 | [files: ~w(lib mix.exs README.md UNLICENSE VERSION), 37 | maintainers: ["Devin Torres"], 38 | licenses: ["Unlicense"], 39 | links: %{"GitHub" => "https://github.com/devinus/sh"}] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sh 2 | 3 | [![Build Status](https://api.travis-ci.org/devinus/sh.svg?branch=master)](https://travis-ci.org/devinus/sh) 4 | 5 | [![Support via Gratipay](https://cdn.rawgit.com/gratipay/gratipay-badge/2.3.0/dist/gratipay.png)](https://gratipay.com/devinus/) 6 | 7 | An Elixir module inspired by Python's [sh](http://amoffat.github.io/sh/) 8 | package. `Sh` allows you to call any program as if it were a function. 9 | 10 | ## Adding Sh to Your Project 11 | To use Sh with your projects, simply edit your mix.exs file and add it as a dependency: 12 | 13 | ```elixir 14 | defp deps do 15 | [{:sh, "~> 1.1.2"}] 16 | end 17 | ``` 18 | 19 | ## Example 20 | 21 | ```iex 22 | iex> Sh.echo "Hello World!" 23 | "Hello World!\n" 24 | ``` 25 | 26 | ## Options 27 | 28 | `Sh` commands accept as the last argument a list of options. 29 | 30 | ```elixir 31 | Sh.curl "http://example.com/", o: "page.html", silent: true 32 | "" 33 | ``` 34 | 35 | The equivalent call without using this feature would be: 36 | 37 | ```elixir 38 | Sh.curl "-o", "page.html", "--silent", "http://example.com/" 39 | ``` 40 | 41 | ## Underscores 42 | 43 | Underscores in a program name or keyword options are converted to dashes. 44 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /test/sh_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShTest do 2 | use ExUnit.Case 3 | 4 | alias Sh.CommandNotFound 5 | alias Sh.AbnormalExit 6 | 7 | @tmp_dir Path.expand("tmp", __DIR__) 8 | 9 | setup_all do 10 | File.mkdir_p!(@tmp_dir) 11 | 12 | on_exit fn -> 13 | File.rm_rf!(@tmp_dir) 14 | :ok 15 | end 16 | 17 | :ok 18 | end 19 | 20 | test "simple commands" do 21 | assert Sh.true == "" 22 | assert Sh.echo("Hello World!") == "Hello World!\n" 23 | assert Sh.ls(Path.expand("fixtures", __DIR__)) == "test.txt\n" 24 | assert Sh.cat(fixture_path("test.txt")) == "foo\nbar\nbaz\n" 25 | assert Sh.tail("-n1", fixture_path("test.txt")) == "baz\n" 26 | end 27 | 28 | test "commands with options" do 29 | assert Sh.echo("Hello World!", n: true) == "Hello World!" 30 | assert Sh.curl("http://httpbin.org/html", o: tmp_path("page.html"), silent: true) == "" 31 | assert File.exists?(tmp_path("page.html")) 32 | end 33 | 34 | test "command not found" do 35 | assert_raise CommandNotFound, fn -> 36 | Sh.kurvin 37 | end 38 | end 39 | 40 | test "non-zero exits" do 41 | assert_raise AbnormalExit, fn -> 42 | Sh.false 43 | end 44 | end 45 | 46 | defp fixture_path(path) do 47 | Path.expand(Path.join(["fixtures", path]), __DIR__) 48 | end 49 | 50 | defp tmp_path(path) do 51 | Path.expand(Path.join(["tmp", path]), __DIR__) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/sh.ex: -------------------------------------------------------------------------------- 1 | defmodule Sh do 2 | defmodule CommandNotFound do 3 | defexception [:command] 4 | 5 | def message(%{command: command}) do 6 | "Command not found: #{command}" 7 | end 8 | end 9 | 10 | defmodule AbnormalExit do 11 | defexception [:output, :status] 12 | 13 | def message(%{status: status}) do 14 | "exited with non-zero status (#{status})" 15 | end 16 | end 17 | 18 | def unquote(:"$handle_undefined_function")(program, args) do 19 | command = to_string(program) 20 | |> String.replace("_", "-") 21 | |> System.find_executable 22 | || raise CommandNotFound, command: program 23 | 24 | if is_list(List.last(args)) do 25 | { args, [opts] } = Enum.split(args, -1) 26 | args = process_options(opts) ++ args 27 | end 28 | 29 | # http://www.erlang.org/doc/man/erlang.html#open_port-2 30 | opts = ~w(exit_status stderr_to_stdout in binary eof hide)a ++ [args: args] 31 | port = Port.open({ :spawn_executable, command }, opts) 32 | 33 | loop(port, "") 34 | end 35 | 36 | defp loop(port, acc) do 37 | receive do 38 | { ^port, { :data, data } } -> 39 | loop(port, acc <> data) 40 | { ^port, :eof } -> 41 | send port, { self, :close } 42 | receive do 43 | { ^port, { :exit_status, 0 } } -> 44 | acc 45 | { ^port, { :exit_status, status } } -> 46 | raise %AbnormalExit{output: acc, status: status} 47 | end 48 | end 49 | end 50 | 51 | defp process_options(opts) do 52 | Enum.reduce Enum.reverse(opts), [], fn { key, value }, acc -> 53 | key = to_string(key) 54 | if String.length(key) == 1 do 55 | if value do 56 | if value != true do 57 | ["-#{key}", to_string(value) | acc] 58 | else 59 | ["-#{key}" | acc] 60 | end 61 | else 62 | acc 63 | end 64 | else 65 | key = String.replace(key, "_", "-") 66 | if value do 67 | if value != true do 68 | ["--#{key}=#{value}" | acc] 69 | else 70 | ["--#{key}" | acc] 71 | end 72 | else 73 | acc 74 | end 75 | end 76 | end 77 | end 78 | end 79 | --------------------------------------------------------------------------------