Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/command.mond
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
(use result [Result])
(use string)

;;; Result of running a command.
(pub type CommandResult
[(:status ~ Int)
;; Combined stdout/stderr output.
(:output ~ String)])

;;; Errors that can occur when invoking commands.
(pub type CommandError
[CommandNotFound
InvalidCommand
InvalidArguments
OutputNotUtf8
(Unknown ~ String)])

;;; Stable string tag for branching and logging command errors.
(pub let error_kind {err}
(match err
CommandNotFound ~> "command_not_found"
InvalidCommand ~> "invalid_command"
InvalidArguments ~> "invalid_arguments"
OutputNotUtf8 ~> "output_not_utf8"
(Unknown _) ~> "unknown"))

;;; Convert a command error into a human-readable description.
(pub let describe_error {err}
(match err
CommandNotFound ~> "Command not found"
InvalidCommand ~> "Invalid command"
InvalidArguments ~> "Invalid command arguments"
OutputNotUtf8 ~> "Command output is not valid UTF-8"
(Unknown inner) ~> (string/concat "Unknown error: " inner)))

;;; Run an executable with the given argument list.
;;; This uses Erlang ports with `{spawn_executable, ...}` and does not invoke a shell.
;;; Stderr is merged into `:output`.
(pub extern let run ~ (String
-> (List String)
-> Result CommandResult CommandError) mond_command_helpers/run)
70 changes: 70 additions & 0 deletions src/mond_command_helpers.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-module(mond_command_helpers).

-export([run/2]).

run(Command, _Args) when not is_binary(Command) ->
{error, invalidcommand};
run(Command, _Args) when byte_size(Command) =:= 0 ->
{error, invalidcommand};
run(_Command, Args) when not is_list(Args) ->
{error, invalidarguments};
run(Command, Args) ->
case validate_args(Args) of
{ok, ErlArgs} ->
case os:find_executable(unicode:characters_to_list(Command)) of
false ->
{error, commandnotfound};
Executable ->
try
Port = open_port(
{spawn_executable, Executable},
[binary, exit_status, use_stdio, hide, stderr_to_stdout, {args, ErlArgs}]
),
collect_port_output(Port, [])
catch
Class:Reason ->
erlang_error(Class, Reason)
end
end;
{error, _} = Error ->
Error
end.

validate_args([]) ->
{ok, []};
validate_args([Arg | Rest]) when is_binary(Arg) ->
case validate_args(Rest) of
{ok, ConvertedRest} ->
{ok, [unicode:characters_to_list(Arg) | ConvertedRest]};
Error ->
Error
end;
validate_args(_) ->
{error, invalidarguments}.

collect_port_output(Port, Chunks) ->
receive
{Port, {data, Data}} ->
collect_port_output(Port, [Data | Chunks]);
{Port, {exit_status, Status}} ->
render_output(Status, Chunks);
{Port, closed} ->
collect_port_output(Port, Chunks);
{'EXIT', Port, Reason} ->
{error, {unknown, reason_to_binary(Reason)}}
end.

render_output(Status, Chunks) ->
OutputBin = iolist_to_binary(lists:reverse(Chunks)),
case unicode:characters_to_binary(OutputBin, utf8, utf8) of
Utf8 when is_binary(Utf8) ->
{ok, {commandresult, Status, Utf8}};
_ ->
{error, outputnotutf8}
end.

erlang_error(Class, Reason) ->
{error, {unknown, reason_to_binary({Class, Reason})}}.

reason_to_binary(Reason) ->
iolist_to_binary(io_lib:format("~p", [Reason])).
29 changes: 29 additions & 0 deletions tests/command_test.mond
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
(use std/command)
(use std/result [Result])
(use std/testing [assert_eq])

(let expect_command_error_kind {result expected_kind}
(match result
(Error err) ~> (assert_eq (command/error_kind err) expected_kind)
(Ok _) ~> (Error "expected command error")))

(test
"command/run captures stdout and status"
(match (command/run
"erl"
["-noshell" "-eval" "io:put_chars([104,101,108,108,111]), halt(0)."])
(Ok result) ~> (let? [_ (assert_eq (:status result) 0)]
(assert_eq (:output result) "hello"))
(Error err) ~> (Error (command/describe_error err))))

(test
"command/run returns non-zero exit status"
(match (command/run "erl" ["-noshell" "-eval" "halt(7)."])
(Ok result) ~> (assert_eq (:status result) 7)
(Error err) ~> (Error (command/describe_error err))))

(test
"command/run returns command_not_found for missing executable"
(expect_command_error_kind
(command/run "definitely-not-a-real-command-xyz" [])
"command_not_found"))
Loading