From 5b82c9a54745e3a4e1893b5c6935bfcc3fc7bad2 Mon Sep 17 00:00:00 2001 From: Neer Friedman Date: Fri, 6 Mar 2026 13:16:20 +0200 Subject: [PATCH] add ability to point to external builtin registry --- lib/just_bash.ex | 4 ++ lib/just_bash/commands/registry.ex | 48 ++++++++++++-- lib/just_bash/commands/which.ex | 6 +- lib/just_bash/interpreter/executor.ex | 5 +- test/custom_builtin_test.exs | 91 +++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 test/custom_builtin_test.exs diff --git a/lib/just_bash.ex b/lib/just_bash.ex index e2b725f..1854793 100644 --- a/lib/just_bash.ex +++ b/lib/just_bash.ex @@ -57,6 +57,7 @@ defmodule JustBash do env: %{}, cwd: "/home/user", functions: %{}, + custom_builtin_registry: nil, exit_code: 0, last_exit_code: 0, network: %{enabled: false, allow_list: []}, @@ -87,6 +88,7 @@ defmodule JustBash do env: map(), cwd: String.t(), functions: map(), + custom_builtin_registry: {module(), term()} | nil, exit_code: non_neg_integer(), last_exit_code: non_neg_integer(), network: network_config(), @@ -125,6 +127,7 @@ defmodule JustBash do cwd = Keyword.get(opts, :cwd, "/home/user") network = Keyword.get(opts, :network, %{enabled: false, allow_list: []}) http_client = Keyword.get(opts, :http_client) + custom_builtin_registry = Keyword.get(opts, :custom_builtin_registry, nil) default_env = %{ "HOME" => cwd, @@ -142,6 +145,7 @@ defmodule JustBash do env: Map.merge(default_env, env), cwd: cwd, functions: %{}, + custom_builtin_registry: custom_builtin_registry, exit_code: 0, last_exit_code: 0, network: Map.merge(%{enabled: false, allow_list: []}, network), diff --git a/lib/just_bash/commands/registry.ex b/lib/just_bash/commands/registry.ex index 15aaeec..1bbfb38 100644 --- a/lib/just_bash/commands/registry.ex +++ b/lib/just_bash/commands/registry.ex @@ -87,18 +87,54 @@ defmodule JustBash.Commands.Registry do @doc """ Get the module that implements the given command. """ - @spec get(String.t()) :: module() | nil - def get(name), do: Map.get(@commands, name) + @spec get(JustBash.t(), String.t()) :: module() | nil + def get(bash, cmd) do + get_custom(bash, cmd) || Map.get(@commands, cmd) + end @doc """ Check if a command exists. """ - @spec exists?(String.t()) :: boolean() - def exists?(name), do: Map.has_key?(@commands, name) + @spec exists?(JustBash.t(), String.t()) :: boolean() + def exists?(bash, name) do + Map.has_key?(@commands, name) || exists_custom?(bash, name) + end @doc """ List all available command names. """ - @spec list() :: [String.t()] - def list, do: Map.keys(@commands) + @spec list(JustBash.t()) :: [String.t()] + def list(bash) do + Enum.uniq(Map.keys(@commands) ++ list_custom(bash)) + end + + defp get_custom(%JustBash{custom_builtin_registry: nil}, _cmd), do: nil + + defp get_custom(%JustBash{custom_builtin_registry: module}, cmd) when is_atom(module) do + module.get(cmd) + end + + defp get_custom(%JustBash{custom_builtin_registry: {module, ctx}}, cmd) do + module.get(cmd, ctx) + end + + defp exists_custom?(%JustBash{custom_builtin_registry: nil}, _cmd), do: false + + defp exists_custom?(%JustBash{custom_builtin_registry: module}, cmd) when is_atom(module) do + module.exists?(cmd) + end + + defp exists_custom?(%JustBash{custom_builtin_registry: {module, ctx}}, cmd) do + module.exists?(cmd, ctx) + end + + defp list_custom(%JustBash{custom_builtin_registry: nil}), do: [] + + defp list_custom(%JustBash{custom_builtin_registry: module}) when is_atom(module) do + module.list() + end + + defp list_custom(%JustBash{custom_builtin_registry: {module, ctx}}) do + module.list(ctx) + end end diff --git a/lib/just_bash/commands/which.ex b/lib/just_bash/commands/which.ex index 9d3ed5b..0bb0817 100644 --- a/lib/just_bash/commands/which.ex +++ b/lib/just_bash/commands/which.ex @@ -28,7 +28,7 @@ defmodule JustBash.Commands.Which do {output, all_found} = Enum.reduce(opts.names, {"", true}, fn name, {acc_out, acc_found} -> - paths = find_command(bash.fs, path_dirs, name, opts.show_all) + paths = find_command(bash, path_dirs, name, opts.show_all) accumulate_paths(paths, acc_out, acc_found, opts.silent) end) @@ -74,7 +74,7 @@ defmodule JustBash.Commands.Which do parse_args(rest, %{opts | names: opts.names ++ [name]}) end - defp find_command(fs, path_dirs, name, show_all) do + defp find_command(%{fs: fs} = bash, path_dirs, name, show_all) do results = Enum.flat_map(path_dirs, fn dir -> if dir == "" do @@ -83,7 +83,7 @@ defmodule JustBash.Commands.Which do full_path = Path.join(dir, name) cond do - Registry.exists?(name) -> + Registry.exists?(bash, name) -> [full_path] file_exists?(fs, full_path) -> diff --git a/lib/just_bash/interpreter/executor.ex b/lib/just_bash/interpreter/executor.ex index e4af152..9d5db27 100644 --- a/lib/just_bash/interpreter/executor.ex +++ b/lib/just_bash/interpreter/executor.ex @@ -600,10 +600,13 @@ defmodule JustBash.Interpreter.Executor do end defp execute_builtin(bash, cmd, args, stdin) do - case Registry.get(cmd) do + case Registry.get(bash, cmd) do nil -> {%{stdout: "", stderr: "bash: #{cmd}: command not found\n", exit_code: 127}, bash} + builtin when is_function(builtin, 3) -> + builtin.(bash, args, stdin) + module -> module.execute(bash, args, stdin) end diff --git a/test/custom_builtin_test.exs b/test/custom_builtin_test.exs new file mode 100644 index 0000000..6bf431e --- /dev/null +++ b/test/custom_builtin_test.exs @@ -0,0 +1,91 @@ +defmodule CustomBuiltinTest do + use ExUnit.Case + doctest JustBash + + defmodule HelloBuiltinModule do + @behaviour JustBash.Commands.Command + + alias JustBash.Commands.Command + + @impl true + def names, do: ["hello_module"] + + @impl true + def execute(bash, args, _stdin) do + output = "hello " <> Enum.join(args, "") + {Command.ok(output <> "\n"), bash} + end + end + + defmodule CustomBuiltinRegistry do + # without context + def get("hello") do + fn bash, args, _stdin -> + output = "hello " <> Enum.join(args, "") + {JustBash.Commands.Command.ok(output <> "\n"), bash} + end + end + + def get("hello_module"), do: HelloBuiltinModule + def get(_), do: nil + + def exists?("hello"), do: true + def exists?("hello_module"), do: true + def exists?(_), do: false + + # with context + def get("hello", username) do + fn bash, _args, _stdin -> + output = "hello #{username}" + {JustBash.Commands.Command.ok(output <> "\n"), bash} + end + end + + def get(_cmd, _ctx), do: nil + + def exists?("hello", "jose"), do: true + def exists?(_cmd, _ctx), do: false + end + + describe "custom builtins:" do + test "can register and execute custom builtin via function" do + bash = JustBash.new(custom_builtin_registry: CustomBuiltinRegistry) + {result, _} = JustBash.exec(bash, "hello world") + assert result.stdout == "hello world\n" + end + + test "can register and execute custom builtin via module" do + bash = JustBash.new(custom_builtin_registry: CustomBuiltinRegistry) + {result, _} = JustBash.exec(bash, "hello world") + assert result.stdout == "hello world\n" + end + + test "can find custom command with which" do + bash = JustBash.new(custom_builtin_registry: CustomBuiltinRegistry) + {result, _} = JustBash.exec(bash, "which hello") + assert result.stdout == "/bin/hello\n" + end + end + + describe "custom builtins with context:" do + test "can get custom builtin with more context" do + bash = JustBash.new(custom_builtin_registry: {CustomBuiltinRegistry, "jose"}) + {result, _} = JustBash.exec(bash, "hello") + assert result.stdout == "hello jose\n" + end + + test "can find custom command with which and context" do + bash = JustBash.new(custom_builtin_registry: {CustomBuiltinRegistry, "jose"}) + {result, _} = JustBash.exec(bash, "which hello") + assert result.stdout == "/bin/hello\n" + assert result.exit_code == 0 + end + + test "which detects method presence by context" do + bash = JustBash.new(custom_builtin_registry: {CustomBuiltinRegistry, "stranger"}) + {result, _} = JustBash.exec(bash, "which hello") + assert result.stdout == "" + assert result.exit_code == 1 + end + end +end