├── .dialyzerignore.exs ├── test ├── test_helper.exs ├── nexus │ ├── helpers_test.exs │ ├── cli │ │ ├── root_flags_test.exs │ │ ├── dispatcher_test.exs │ │ └── help_test.exs │ ├── parser_test.exs │ └── parser │ │ └── dsl_test.exs └── support │ └── my_cli.ex ├── .github ├── .release-please-manifest.json ├── pull_request_template.md ├── workflows │ ├── release-please.yml │ ├── publish.yml │ └── ci.yml ├── FUNDING.yml └── release-please-config.json ├── .envrc ├── .formatter.exs ├── LICENSE ├── .gitignore ├── flake.nix ├── CHANGELOG.md ├── examples ├── mix │ └── tasks │ │ └── example.ex └── escript │ └── example.ex ├── lib ├── nexus │ ├── cli │ │ ├── helpers.ex │ │ ├── help.ex │ │ ├── dispatcher.ex │ │ └── validation.ex │ ├── parser.ex │ ├── parser │ │ └── dsl.ex │ └── cli.ex └── nexus.ex ├── mix.exs ├── flake.lock ├── CLAUDE.md ├── README.md └── mix.lock /.dialyzerignore.exs: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.github/.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.6.0" 3 | } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Problem 2 | 3 | 4 | 5 | ## Solution 6 | 7 | 8 | 9 | ## Rationale 10 | 11 | 12 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export GPG_TTY="$(tty)" 2 | 3 | # this allows mix to work on the local directory 4 | export MIX_HOME=$PWD/.nix-mix 5 | export HEX_HOME=$PWD/.nix-mix 6 | export PATH=$MIX_HOME/bin:$HEX_HOME/bin:$PATH 7 | export ERL_AFLAGS="-kernel shell_history enabled" 8 | 9 | export LANG=en_US.UTF-8 10 | 11 | use flake 12 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | local_without_parens = [ 2 | defcommand: 2, 3 | subcommand: 2, 4 | value: 2, 5 | flag: 2, 6 | short: 1, 7 | description: 1 8 | ] 9 | 10 | [ 11 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 12 | export: [locals_without_parens: local_without_parens], 13 | locals_without_parens: local_without_parens, 14 | plugins: [Styler] 15 | ] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2023 Zoey Pessanha 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | name: Release Please 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | release_created: ${{ steps.release.outputs.release_created }} 18 | tag_name: ${{ steps.release.outputs.tag_name }} 19 | steps: 20 | - uses: googleapis/release-please-action@v4 21 | id: release 22 | with: 23 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 24 | config-file: .github/release-please-config.json 25 | manifest-file: .github/.release-please-manifest.json 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: zoedsoupe 4 | patreon: # Replace with a single Patreon username 5 | open_collective: zoedsoupe 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | nexus-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Dialyzer 29 | /priv/plts/ 30 | 31 | # Nix files 32 | result 33 | /.nix-mix/ 34 | 35 | # Escript builds 36 | ./nexus 37 | 38 | /priv/plts/ 39 | nexus_cli 40 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "CLI framework for Elixir, with magic!"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05-small"; 6 | elixir-overlay.url = "github:zoedsoupe/elixir-overlay"; 7 | }; 8 | 9 | outputs = { 10 | self, 11 | nixpkgs, 12 | elixir-overlay, 13 | }: let 14 | inherit (nixpkgs.lib) genAttrs; 15 | inherit (nixpkgs.lib.systems) flakeExposed; 16 | 17 | forAllSystems = f: 18 | genAttrs flakeExposed ( 19 | system: let 20 | overlays = [elixir-overlay.overlays.default]; 21 | pkgs = import nixpkgs {inherit system overlays;}; 22 | in 23 | f pkgs 24 | ); 25 | in { 26 | devShells = forAllSystems (pkgs: { 27 | default = pkgs.mkShell { 28 | name = "nexus-dev"; 29 | packages = with pkgs; [ 30 | (elixir-with-otp erlang_28).latest 31 | erlang_28 32 | just 33 | ]; 34 | }; 35 | }); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.6.0](https://github.com/zoedsoupe/nexus/compare/v0.5.0...v0.6.0) (2025-08-04) 4 | 5 | 6 | ### Features 7 | 8 | * root flags definition ([#35](https://github.com/zoedsoupe/nexus/issues/35)) ([7cdc0a7](https://github.com/zoedsoupe/nexus/commit/7cdc0a72a649c500166ada8d678f6269b465e72c)) 9 | * using parser combinators ([#29](https://github.com/zoedsoupe/nexus/issues/29)) ([fdc224f](https://github.com/zoedsoupe/nexus/commit/fdc224f2f2c760da86ee23b1d67c805dff338c6b)) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * correctly parse space separated flags ([#33](https://github.com/zoedsoupe/nexus/issues/33)) ([a3add2d](https://github.com/zoedsoupe/nexus/commit/a3add2d77eb472558b091fb07bdbaaa32f4b6d27)) 15 | 16 | 17 | ### Code Refactoring 18 | 19 | * missing test coverage and performance issues ([#34](https://github.com/zoedsoupe/nexus/issues/34)) ([b9bdbe2](https://github.com/zoedsoupe/nexus/commit/b9bdbe28767578a54274ff73fc5b94b2f85fe6ec)) 20 | 21 | 22 | ### Tests 23 | 24 | * new test cases ([#28](https://github.com/zoedsoupe/nexus/issues/28)) ([36bcaca](https://github.com/zoedsoupe/nexus/commit/36bcaca377feb4aea38cfee9917df92b48ed89b7)) 25 | -------------------------------------------------------------------------------- /examples/mix/tasks/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Example do 2 | @moduledoc """ 3 | This is a Mix Task example using Nexus. 4 | Basically, you can `use` both `Mix.Task` and `Nexus.CLI` 5 | modules, define your commands as usual with `defcommand/2` 6 | and implement others callbacks. 7 | 8 | In a `Mix.Task` module, the `run/1` function will supply 9 | the behaviour, so you don't need to define it yourself. 10 | 11 | If you need to do other computations inside `Mix.Task.run/1`, 12 | then simply define `run/1` by yourself and call `__MODULE__.run/1` 13 | when you need it, passing the raw args to it. 14 | """ 15 | 16 | use Mix.Task 17 | use Nexus.CLI, otp_app: :nexus_cli 18 | 19 | defcommand :foo do 20 | description "This is a foo command" 21 | value :string, required: false, default: "bar" 22 | end 23 | 24 | @impl Nexus.CLI 25 | def version, do: "0.1.0" 26 | 27 | @impl Nexus.CLI 28 | def banner, do: "Hello I'm a test" 29 | 30 | @impl Nexus.CLI 31 | def handle_input(:foo, _args) do 32 | IO.puts("Running :foo command...") 33 | end 34 | 35 | @impl Mix.Task 36 | def run(argv) when is_list(argv) do 37 | argv 38 | |> Enum.join(" ") 39 | |> execute() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/nexus/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.HelpersTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias Nexus.CLI.Helpers 7 | 8 | test "say_success/1 prints a success message in green" do 9 | message = "Success!" 10 | output = capture_io(fn -> Helpers.say_success(message) end) 11 | assert output == IO.ANSI.green() <> message <> IO.ANSI.reset() <> "\n" 12 | end 13 | 14 | test "ask/1 prompts the user with a question and returns input" do 15 | question = "What's your name?" 16 | input = "John Doe\n" 17 | output = capture_io([input: input], fn -> assert Helpers.ask(question) == "John Doe" end) 18 | assert output == question <> " " 19 | end 20 | 21 | test "yes?/1 returns true for 'y' and false for 'n'" do 22 | question = "Do you agree?" 23 | 24 | output = capture_io([input: "y\n"], fn -> assert Helpers.yes?(question) == true end) 25 | assert output == question <> " (y/n) " 26 | 27 | output = capture_io([input: "n\n"], fn -> assert Helpers.yes?(question) == false end) 28 | assert output == question <> " (y/n) " 29 | end 30 | 31 | test "no?/1 returns true for 'n' and false for 'y'" do 32 | question = "Do you disagree?" 33 | 34 | output = capture_io([input: "n\n"], fn -> assert Helpers.no?(question) == true end) 35 | assert output == question <> " (y/n) " 36 | 37 | output = capture_io([input: "y\n"], fn -> assert Helpers.no?(question) == false end) 38 | assert output == question <> " (y/n) " 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Hex 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | MIX_ENV: test 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | elixir-version: 1.18 24 | otp-version: 28 25 | 26 | - name: Cache Elixir deps 27 | uses: actions/cache@v4 28 | id: deps-cache 29 | with: 30 | path: deps 31 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 32 | 33 | - name: Cache Elixir _build 34 | uses: actions/cache@v4 35 | id: build-cache 36 | with: 37 | path: _build 38 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-27-1.18-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 39 | 40 | - name: Install deps 41 | if: steps.deps-cache.outputs.cache-hit != 'true' 42 | run: | 43 | mix local.rebar --force 44 | mix local.hex --force 45 | mix deps.get 46 | 47 | - name: Compile deps 48 | if: steps.build-cache.outputs.cache-hit != 'true' 49 | run: mix deps.compile 50 | 51 | - name: Compile 52 | run: mix compile --warnings-as-errors 53 | 54 | - name: Run tests 55 | run: mix test 56 | 57 | - name: Check formatting 58 | run: mix format --check-formatted 59 | 60 | - name: Run Credo 61 | run: mix credo --strict 62 | 63 | - name: Publish to Hex 64 | env: 65 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 66 | run: mix hex.publish --yes 67 | -------------------------------------------------------------------------------- /lib/nexus/cli/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.Helpers do 2 | @moduledoc """ 3 | Common CLI helper functions. 4 | """ 5 | 6 | @doc """ 7 | Prints a success message to the console. 8 | """ 9 | def say_success(message) do 10 | IO.puts(IO.ANSI.green() <> message <> IO.ANSI.reset()) 11 | end 12 | 13 | @doc """ 14 | Asks a question and returns the user's input as a string. 15 | 16 | ## Examples 17 | iex> ask("What's your name?") 18 | "John" 19 | 20 | iex> ask("Enter a number:", "> ") 21 | "42" 22 | """ 23 | @spec ask(question :: String.t()) :: String.t() 24 | @spec ask(question :: String.t(), prompt_symbol :: String.t()) :: String.t() 25 | def ask(question, prompt_symbol \\ " ") do 26 | (question <> prompt_symbol) |> IO.gets() |> String.trim() 27 | end 28 | 29 | @doc """ 30 | Asks a yes/no question and returns true for yes and false for no. 31 | 32 | ## Options 33 | - confirmations: A list of strings that are considered affirmative responses. 34 | Defaulting to ["y", "yes"]. 35 | - negations: A list of strings that are considered negative responses. 36 | Defaulting to `["n", "no"]`. 37 | """ 38 | @spec yes?( 39 | question :: String.t(), 40 | confirmations :: list(String.t()), 41 | negations :: list(String.t()) 42 | ) :: boolean 43 | def yes?(question, confirmations \\ ["y", "yes"], negations \\ ["n", "no"]) do 44 | response = (question <> " (y/n)") |> ask() |> String.downcase() 45 | 46 | cond do 47 | response in confirmations -> true 48 | response in negations -> false 49 | true -> yes?(question, confirmations, negations) 50 | end 51 | end 52 | 53 | @doc """ 54 | Asks a yes/no question and returns true for no and false for yes. 55 | """ 56 | def no?(question) do 57 | not yes?(question) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "fa1453f", 3 | "pull-request-header": ":rocket: Want to release this?", 4 | "pull-request-title-pattern": "chore: release ${version}", 5 | "changelog-sections": [ 6 | { 7 | "type": "feat", 8 | "section": "Features" 9 | }, 10 | { 11 | "type": "feature", 12 | "section": "Features" 13 | }, 14 | { 15 | "type": "fix", 16 | "section": "Bug Fixes" 17 | }, 18 | { 19 | "type": "perf", 20 | "section": "Performance Improvements" 21 | }, 22 | { 23 | "type": "revert", 24 | "section": "Reverts" 25 | }, 26 | { 27 | "type": "docs", 28 | "section": "Documentation", 29 | "hidden": false 30 | }, 31 | { 32 | "type": "style", 33 | "section": "Styles", 34 | "hidden": false 35 | }, 36 | { 37 | "type": "chore", 38 | "section": "Miscellaneous Chores", 39 | "hidden": false 40 | }, 41 | { 42 | "type": "refactor", 43 | "section": "Code Refactoring", 44 | "hidden": false 45 | }, 46 | { 47 | "type": "test", 48 | "section": "Tests", 49 | "hidden": false 50 | }, 51 | { 52 | "type": "build", 53 | "section": "Build System", 54 | "hidden": false 55 | }, 56 | { 57 | "type": "ci", 58 | "section": "Continuous Integration", 59 | "hidden": false 60 | } 61 | ], 62 | "extra-files": [ 63 | { 64 | "type": "generic", 65 | "path": "flake.nix", 66 | "glob": false 67 | }, 68 | { 69 | "type": "generic", 70 | "path": "README.md", 71 | "glob": false 72 | } 73 | ], 74 | "packages": { 75 | ".": { 76 | "release-type": "elixir" 77 | } 78 | }, 79 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 80 | } -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.6.0" 5 | @source_url "https://github.com/zoedsoupe/nexus" 6 | 7 | def project do 8 | [ 9 | app: :nexus_cli, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | docs: docs(), 15 | escript: [main_module: Escript.Example], 16 | package: package(), 17 | source_url: @source_url, 18 | description: description(), 19 | elixirc_paths: elixirc_paths(Mix.env()), 20 | dialyzer: [ 21 | plt_local_path: "priv/plts", 22 | ignore_warnings: ".dialyzerignore.exs", 23 | plt_add_apps: [:mix, :ex_unit] 24 | ] 25 | ] 26 | end 27 | 28 | def application do 29 | [extra_applications: [:logger]] 30 | end 31 | 32 | defp elixirc_paths(:dev), do: ["lib", "examples/"] 33 | defp elixirc_paths(:test), do: ["lib", "examples/", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | defp deps do 37 | [ 38 | {:styler, "~> 1.4", only: [:dev, :test], runtime: false}, 39 | {:ex_doc, ">= 0.0.0", only: [:dev, :test], runtime: false}, 40 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 41 | {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false} 42 | ] 43 | end 44 | 45 | defp package do 46 | %{ 47 | name: "nexus_cli", 48 | licenses: ["WTFPL"], 49 | contributors: ["zoedsoupe"], 50 | links: %{"GitHub" => @source_url}, 51 | files: ~w(lib/nexus lib/nexus.ex LICENSE README.md mix.* examples) 52 | } 53 | end 54 | 55 | defp docs do 56 | [ 57 | main: "readme", 58 | extras: ["README.md"] 59 | ] 60 | end 61 | 62 | defp description do 63 | """ 64 | An `Elixir` library to write command line apps in a cleaner and elegant way! 65 | """ 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /examples/escript/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Escript.Example do 2 | @moduledoc """ 3 | This is an example on how to use `Nexus` with an 4 | escript application. 5 | 6 | After defined `:escript` entry into your `project/0` function 7 | on `mix.exs` and set the `main_module` option, you can safely 8 | define your commands as usual with `defcommand/2`, CLI config 9 | and handlers. 10 | 11 | Then you need to call `parse/0` macro, which will inject both 12 | `parse/1` and `run/1` function, which the latter you can delegate 13 | from the `main/1` escript funciton, as can seen below. 14 | """ 15 | 16 | use Nexus.CLI, otp_app: :nexus_cli 17 | alias Nexus.CLI.Helpers 18 | 19 | defcommand :echo do 20 | description "Command that receives a string as argument and prints it." 21 | 22 | value :string, required: true 23 | end 24 | 25 | defcommand :greet do 26 | description "Greets the user by asking his name" 27 | end 28 | 29 | defcommand :fizzbuzz do 30 | description "Fizz bUZZ" 31 | 32 | value :integer, required: true 33 | end 34 | 35 | defcommand :foo_bar do 36 | description "Teste" 37 | 38 | subcommand :foo do 39 | description "hello" 40 | 41 | value :string, required: false, default: "hello" 42 | end 43 | 44 | subcommand :bar do 45 | description "hello" 46 | 47 | value :string, required: false, default: "hello" 48 | end 49 | end 50 | 51 | @impl true 52 | def version, do: "0.1.0" 53 | 54 | @impl true 55 | def handle_input(:echo, %{value: value}) do 56 | IO.puts(value) 57 | end 58 | 59 | def handle_input(:greet, _args) do 60 | name = Helpers.ask("Whats your name?") 61 | Helpers.say_success("Hello, #{name}!") 62 | :ok 63 | end 64 | 65 | def handle_input(:fizzbuzz, %{value: value}) when is_integer(value) do 66 | cond do 67 | rem(value, 3) == 0 and rem(value, 5) == 0 -> IO.puts("fizzbuzz") 68 | rem(value, 3) == 0 -> IO.puts("fizz") 69 | rem(value, 5) == 0 -> IO.puts("buzz") 70 | true -> IO.puts value 71 | end 72 | end 73 | 74 | def handle_input([:foo_bar, :foo], %{value: _}) do 75 | IO.puts("Issued foo") 76 | :ok 77 | end 78 | 79 | def handle_input([:foo_bar, :bar], %{value: _}) do 80 | IO.puts("Issued bar") 81 | :ok 82 | end 83 | 84 | defdelegate main(args), to: __MODULE__, as: :execute 85 | end 86 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "elixir-overlay": { 4 | "inputs": { 5 | "flake-utils": "flake-utils", 6 | "nixpkgs": "nixpkgs" 7 | }, 8 | "locked": { 9 | "lastModified": 1752676017, 10 | "narHash": "sha256-F5nmW38F1dW/IOz/Kj8hS5SM5ehhuRH7xvrFM20jA5Q=", 11 | "owner": "zoedsoupe", 12 | "repo": "elixir-overlay", 13 | "rev": "19108d02ac1029f9b5abaf20363903eecc894530", 14 | "type": "github" 15 | }, 16 | "original": { 17 | "owner": "zoedsoupe", 18 | "repo": "elixir-overlay", 19 | "type": "github" 20 | } 21 | }, 22 | "flake-utils": { 23 | "inputs": { 24 | "systems": "systems" 25 | }, 26 | "locked": { 27 | "lastModified": 1731533236, 28 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 29 | "owner": "numtide", 30 | "repo": "flake-utils", 31 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "numtide", 36 | "repo": "flake-utils", 37 | "type": "github" 38 | } 39 | }, 40 | "nixpkgs": { 41 | "locked": { 42 | "lastModified": 1747744144, 43 | "narHash": "sha256-W7lqHp0qZiENCDwUZ5EX/lNhxjMdNapFnbErcbnP11Q=", 44 | "owner": "NixOS", 45 | "repo": "nixpkgs", 46 | "rev": "2795c506fe8fb7b03c36ccb51f75b6df0ab2553f", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "NixOS", 51 | "ref": "nixos-unstable", 52 | "repo": "nixpkgs", 53 | "type": "github" 54 | } 55 | }, 56 | "nixpkgs_2": { 57 | "locked": { 58 | "lastModified": 1754209052, 59 | "narHash": "sha256-WDtmDIiUTv/WqG+gqh94Ks0UUFGtUMPKCabasq5YhIg=", 60 | "owner": "NixOS", 61 | "repo": "nixpkgs", 62 | "rev": "0d8c646215cde0121d4ee221be8213675607c34d", 63 | "type": "github" 64 | }, 65 | "original": { 66 | "owner": "NixOS", 67 | "ref": "nixos-25.05-small", 68 | "repo": "nixpkgs", 69 | "type": "github" 70 | } 71 | }, 72 | "root": { 73 | "inputs": { 74 | "elixir-overlay": "elixir-overlay", 75 | "nixpkgs": "nixpkgs_2" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /test/nexus/cli/root_flags_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.RootFlagsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Nexus.CLI.Input 5 | alias Nexus.Parser 6 | 7 | defmodule TestCLIWithRootFlags do 8 | @moduledoc false 9 | use Nexus.CLI, otp_app: :nexus_cli, name: "my_cli" 10 | 11 | flag :version do 12 | short :v 13 | description "Shows the version of the program" 14 | end 15 | 16 | flag :debug do 17 | short :d 18 | description "Enable debug mode" 19 | end 20 | 21 | defcommand :test_cmd do 22 | description "Test command" 23 | end 24 | 25 | @impl Nexus.CLI 26 | def handle_input(:version, %Input{flags: %{version: true}}) do 27 | {:ok, "Version: #{version()}"} 28 | end 29 | 30 | @impl Nexus.CLI 31 | def handle_input(:debug, %Input{flags: %{debug: true}}) do 32 | {:ok, "Debug mode enabled"} 33 | end 34 | 35 | @impl Nexus.CLI 36 | def handle_input(:test_cmd, %Input{}) do 37 | {:ok, "Test command executed"} 38 | end 39 | end 40 | 41 | describe "root-level flags" do 42 | test "root flags are included in CLI spec" do 43 | spec = TestCLIWithRootFlags.__nexus_spec__() 44 | 45 | # Root flags should be stored in the CLI struct 46 | assert is_list(spec.root_flags) 47 | assert length(spec.root_flags) == 2 48 | 49 | version_flag = Enum.find(spec.root_flags, &(&1.name == :version)) 50 | assert version_flag.short == :v 51 | assert version_flag.description == "Shows the version of the program" 52 | 53 | debug_flag = Enum.find(spec.root_flags, &(&1.name == :debug)) 54 | assert debug_flag.short == :d 55 | assert debug_flag.description == "Enable debug mode" 56 | end 57 | 58 | test "parsing root flags without commands" do 59 | cli = TestCLIWithRootFlags.__nexus_spec__() 60 | 61 | {:ok, result} = Parser.parse_ast(cli, ["my_cli", "--version"]) 62 | 63 | assert result.flags.version == true 64 | assert result.command == [] 65 | end 66 | 67 | test "parsing short root flags" do 68 | cli = TestCLIWithRootFlags.__nexus_spec__() 69 | 70 | {:ok, result} = Parser.parse_ast(cli, ["my_cli", "-v"]) 71 | 72 | assert result.flags.version == true 73 | assert result.command == [] 74 | end 75 | 76 | test "root flags work alongside commands" do 77 | cli = TestCLIWithRootFlags.__nexus_spec__() 78 | 79 | {:ok, result} = Parser.parse_ast(cli, ["test_cmd", "--debug"]) 80 | 81 | assert result.flags.debug == true 82 | assert result.command == [:test_cmd] 83 | end 84 | 85 | test "dispatching root flags calls correct handler" do 86 | alias Nexus.CLI.Dispatcher 87 | 88 | cli = TestCLIWithRootFlags.__nexus_spec__() 89 | 90 | result = %{ 91 | command: [], 92 | flags: %{version: true}, 93 | args: %{} 94 | } 95 | 96 | assert {:ok, "Version: " <> _} = Dispatcher.dispatch(cli, result) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Nexus is an Elixir library for building command-line interfaces (CLIs) using a declarative macro-based DSL. It provides a clean way to define commands, subcommands, flags, and arguments with automatic help generation and input parsing. 8 | 9 | ## Core Architecture 10 | 11 | ### Main Components 12 | 13 | - **`Nexus.CLI`** - Core module providing the macro-based DSL (`defcommand`, `flag`, `value`, etc.) and the main behavior 14 | - **`Nexus.Parser`** - Handles tokenization and parsing of command-line input against the defined CLI AST 15 | - **`Nexus.CLI.Dispatcher`** - Dispatches parsed commands to handler functions 16 | - **`Nexus.CLI.Help`** - Generates help documentation from CLI definitions 17 | - **`Nexus.CLI.Validation`** - Validates command and flag definitions 18 | 19 | ### Key Concepts 20 | 21 | - CLI definitions are built into an AST (Abstract Syntax Tree) of `Command` structs 22 | - Commands can have subcommands, flags, and positional arguments 23 | - The `handle_input/2` callback receives the command path and an `Input` struct with parsed flags/args 24 | - Help flags (`--help`, `-h`) are automatically injected into all commands 25 | 26 | ## Development Commands 27 | 28 | ### Testing 29 | ```bash 30 | mix test # Run all tests 31 | mix test test/specific_test.exs # Run specific test file 32 | ``` 33 | 34 | ### Code Quality 35 | ```bash 36 | mix credo # Run code analysis (currently clean) 37 | mix dialyzer # Run type checking (has 1 known issue in examples/) 38 | ``` 39 | 40 | ### Dependencies 41 | ```bash 42 | mix deps.get # Get dependencies 43 | mix deps.compile # Compile dependencies 44 | ``` 45 | 46 | ### Build & Distribution 47 | ```bash 48 | mix escript.build # Build executable script 49 | mix compile # Compile the library 50 | ``` 51 | 52 | ## CLI Usage Patterns 53 | 54 | ### Basic CLI Definition 55 | ```elixir 56 | defmodule MyCLI do 57 | use Nexus.CLI, otp_app: :my_app 58 | 59 | defcommand :my_command do 60 | description "Command description" 61 | value :string, required: true, as: :filename 62 | 63 | flag :verbose do 64 | short :v 65 | description "Enable verbose output" 66 | end 67 | end 68 | 69 | @impl Nexus.CLI 70 | def handle_input(:my_command, %{flags: flags, args: args}) do 71 | # Implementation 72 | :ok 73 | end 74 | end 75 | ``` 76 | 77 | ### Command Execution 78 | - Use `execute/1` function with string or list of arguments 79 | - Returns `:ok` for success or `{:error, {code, reason}}` for errors 80 | - Help is automatically available via `--help` or `-h` flags 81 | 82 | ## Important Notes 83 | 84 | - All CLI modules must implement the `Nexus.CLI` behavior 85 | - The `:otp_app` option is required when using `Nexus.CLI` 86 | - Commands with multiple arguments must specify names via `:as` option 87 | - Help flags are automatically injected into all commands 88 | - The library supports escript compilation for standalone executables -------------------------------------------------------------------------------- /lib/nexus.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus do 2 | @moduledoc """ 3 | Nexus is a comprehensive toolkit for building Command-Line Interfaces (CLI) and Terminal User Interfaces (TUI) in Elixir. It provides a unified framework that simplifies the development of interactive applications running in the terminal. 4 | 5 | ## Overview 6 | 7 | The Nexus ecosystem is designed to be modular and extensible, comprising different namespaces to organize its functionalities: 8 | 9 | - `Nexus.CLI`: Tools and macros for building robust command-line interfaces. 10 | - `Nexus.TUI`: *(Upcoming)* A toolkit leveraging Phoenix LiveView and The Elm Architecture (TEA) to create rich terminal user interfaces. 11 | 12 | By leveraging Elixir's strengths and integrating with powerful frameworks like Phoenix LiveView, Nexus aims to streamline the process of creating both CLIs and TUIs with minimal boilerplate and maximum flexibility. 13 | 14 | ## Features 15 | 16 | - **Declarative Command Definitions**: Use expressive macros to define commands, subcommands, arguments, and flags in a clean and readable manner. 17 | - **Automatic Help Generation**: Automatically generate help messages and usage instructions based on your command definitions. 18 | - **Extensible Architecture**: Designed to be extended and integrated with other tools, making it adaptable to a wide range of applications. 19 | 20 | ## Getting Started with Nexus 21 | 22 | To start using Nexus for building CLIs, add it as a dependency in your `mix.exs` file: 23 | 24 | ```elixir 25 | def deps do 26 | [ 27 | {:nexus_cli, "~> 0.5"} 28 | ] 29 | end 30 | ``` 31 | 32 | Then, create your CLI module: 33 | 34 | ```elixir 35 | defmodule MyCLI do 36 | use Nexus.CLI, otp_app: :my_app 37 | 38 | # no value root command 39 | defcommand :version do 40 | description "Shows the program version" 41 | end 42 | 43 | # nested subcommand 44 | defcommand :file do 45 | description "Performs file operations such as copy, move, and delete." 46 | 47 | # multi value nested subcommand 48 | subcommand :copy do 49 | description "Copies files from source to destination." 50 | 51 | value :string, required: true, as: :source 52 | value :string, required: true, as: :dest 53 | 54 | flag :verbose do 55 | short :v 56 | description "Enables verbose output." 57 | end 58 | 59 | flag :recursive do 60 | short :r 61 | description "Copies directories recursively." 62 | end 63 | end 64 | 65 | # Define more subcommands as needed 66 | end 67 | 68 | @impl Nexus.CLI 69 | def handle_input(:version, %{value: true}) do 70 | # `version/1` is auto injected or you can define the callback yourself 71 | IO.puts(version()) 72 | end 73 | 74 | def handle_input([:file, :copy], %{args: args, flags: flags}) do 75 | if flags[:verbose] do 76 | IO.puts("Copying from \#{args[:source]} to \#{args[:dest]}...") 77 | end 78 | 79 | with {:error, reason} <- do_copy(args) do 80 | {:error, {1, reason}} 81 | end 82 | end 83 | 84 | @spec do_copy(map) :: :ok | {:error, term} 85 | defp do_copy(%{source: _, dest: _}) do 86 | # Implement the copy logic here 87 | end 88 | end 89 | ``` 90 | 91 | Check `Nexus.CLI` module for more information about callbacks and function returns 92 | 93 | > Get started today and build amazing CLI and TUI applications with Nexus! 94 | """ 95 | end 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nexus 2 | 3 | ```sh 4 | _ __ 5 | |\ ||_\/| |(_ 6 | | \||_/\|_|__) 7 | ``` 8 | 9 | > Create CLIs in a magic and declarative way! 10 | 11 | A pure `Elixir` library to write command line apps in a cleaner and elegant way! 12 | 13 | [![Hex.pm](https://img.shields.io/hexpm/v/nexus_cli.svg)](https://hex.pm/packages/nexus_cli) 14 | [![Downloads](https://img.shields.io/hexpm/dt/nexus_cli.svg)](https://hex.pm/packages/nexus_cli) 15 | [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/nexus_cli) 16 | [![ci](https://github.com/zoedsoupe/nexus/actions/workflows/ci.yml/badge.svg)](https://github.com/zoedsoupe/nexus/actions/workflows/ci.yml) 17 | 18 | ## Installation 19 | 20 | Just add the `nexus_cli` package to your `mix.exs` 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:nexus_cli, "~> 0.5.0"} # x-release-version 26 | ] 27 | end 28 | ``` 29 | 30 | ## Example 31 | 32 | ```elixir 33 | defmodule MyCLI do 34 | @moduledoc "This will be used into as help" 35 | 36 | use Nexus.CLI 37 | 38 | defcommand :fizzbuzz do 39 | description "Plays fizzbuzz - this will also be used as help" 40 | 41 | value :integer, required: true 42 | end 43 | 44 | @impl Nexus.CLI 45 | def handle_input(:fizzbuzz, %{value: value}) when is_integer(value) do 46 | cond do 47 | rem(value, 3) == 0 and rem(value, 5) == 0 -> IO.puts("fizzbuzz") 48 | rem(value, 3) == 0 -> IO.puts("fizz") 49 | rem(value, 5) == 0 -> IO.puts("buzz") 50 | true -> IO.puts value 51 | end 52 | end 53 | end 54 | ``` 55 | 56 | More different ways to use this library can be found on the [examples](./examples) folder 57 | Documentation on defining a CLI module can be found at the [Nexus.CLI](https://hexdocs.pm/nexus_cli/Nexus.CLI.html) 58 | 59 | # Roadmap 60 | 61 | | Feature | Status | Description | 62 | |----------------------------------|--------------|-----------------------------------------------------------------------------------------------| 63 | | **Core Nexus.CLI Implementation**| ✅ Completed | Base implementation of the `Nexus.CLI` module with macro-based DSL for defining CLIs. | 64 | | **Automatic Help Generation** | ✅ Completed | Automatically generate help messages based on command definitions. | 65 | | **Command Parsing and Dispatching**| ✅ Completed| Parse user input and dispatch commands to the appropriate handlers. | 66 | | **Nexus.TUI Development** | 🚧 In Progress | Build the `Nexus.TUI` module leveraging Phoenix LiveView and TEA for terminal UIs. | 67 | | **TUI Component Library** | 🔜 Planned | Develop reusable components for building terminal UIs. | 68 | | **Integration with Phoenix LiveView**| 🔜 Planned| Integrate `Nexus.TUI` with Phoenix LiveView for live terminal experiences. | 69 | | **Community Contributions** | ♾️ Ongoing | Encourage and incorporate feedback and contributions from the community. | 70 | 71 | ### Features 72 | - Macro based DSL 73 | - [x] define global/root commands 74 | - [x] define subcommands (supporting nesting) 75 | - [x] define flags 76 | - [ ] define global/root flags 77 | - [x] Automatic help flags generation 78 | 79 | ## Why "Nexus" 80 | 81 | Nexus is a connection from two different worlds! This library connects the world of CLIs with the magic world of `Elixir`! 82 | 83 | ## Inspirations 84 | 85 | Highly inspired in [clap-rs](https://github.com/clap-rs/clap/) 86 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, 8 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 9 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 10 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 16 | "styler": {:hex, :styler, "1.6.0", "211339c7c16db2b159bf07014a302864b17f1b651b33bb24a58a7e47ef35ef22", [:mix], [], "hexpm", "7019dfa15317a1a5bab141cfa3a751b3aae440a146e28c6a45caccf726c9d765"}, 17 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 18 | } 19 | -------------------------------------------------------------------------------- /lib/nexus/cli/help.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.Help do 2 | @moduledoc """ 3 | Provides functionality to display help messages based on the CLI AST. 4 | """ 5 | 6 | alias Nexus.CLI 7 | alias Nexus.CLI.Command 8 | 9 | @doc """ 10 | Displays help information for the given command path. 11 | 12 | If no command path is provided, it displays the root help. 13 | """ 14 | @spec display(CLI.t(), list(atom)) :: :ok 15 | def display(%CLI{} = cli, command_path \\ []) do 16 | cmd = get_command(cli, command_path) 17 | 18 | if cmd do 19 | if function_exported?(cli.handler, :banner, 0) do 20 | IO.puts(cli.handler.banner() <> "\n") 21 | end 22 | 23 | # Build usage line 24 | usage = build_usage_line(cli.name, command_path, cmd) 25 | IO.puts("Usage: #{usage}\n") 26 | 27 | # Display description of the command 28 | if cmd.description do 29 | IO.puts("#{cmd.description}\n") 30 | end 31 | 32 | # Display subcommands, arguments, and options 33 | display_subcommands(cmd) 34 | display_arguments(cmd) 35 | display_options(cmd) 36 | 37 | # Final note 38 | if cmd.subcommands != [] do 39 | IO.puts("\nUse '#{cli.name} #{Enum.join(command_path, " ")} [COMMAND] --help' for more information on a command.") 40 | end 41 | else 42 | IO.puts("Command not found") 43 | end 44 | end 45 | 46 | ## Helper Functions 47 | 48 | # Retrieves the command based on the command path 49 | defp get_command(%CLI{} = cli, []) do 50 | %Command{ 51 | name: cli.name, 52 | description: cli.description, 53 | subcommands: cli.spec, 54 | flags: [], 55 | args: [] 56 | } 57 | end 58 | 59 | defp get_command(%CLI{} = cli, [root | rest]) do 60 | if root_cmd = Enum.find(cli.spec, &(&1.name == root)) do 61 | get_subcommand(root_cmd, rest) 62 | end 63 | end 64 | 65 | defp get_subcommand(cmd, []) do 66 | cmd 67 | end 68 | 69 | defp get_subcommand(cmd, [name | rest]) do 70 | if subcmd = Enum.find(cmd.subcommands || [], &(&1.name == name)) do 71 | get_subcommand(subcmd, rest) 72 | end 73 | end 74 | 75 | # Builds the usage line for the help output 76 | defp build_usage_line(cli_name, command_path, cmd) do 77 | parts = [cli_name | Enum.map(command_path, &Atom.to_string/1)] 78 | 79 | # Include options 80 | parts = if cmd.flags == [], do: parts, else: parts ++ ["[OPTIONS]"] 81 | 82 | # Include subcommands 83 | parts = 84 | if cmd.subcommands == [] do 85 | parts 86 | else 87 | parts ++ ["[COMMAND]"] 88 | end 89 | 90 | # Include arguments 91 | arg_strings = 92 | Enum.map(cmd.args || [], fn arg -> 93 | if arg.required, do: "<#{arg.name}>", else: "[#{arg.name}]" 94 | end) 95 | 96 | parts = parts ++ arg_strings 97 | 98 | Enum.join(parts, " ") 99 | end 100 | 101 | # Displays subcommands if any 102 | defp display_subcommands(cmd) do 103 | if cmd.subcommands != [] do 104 | IO.puts("Commands:") 105 | 106 | Enum.each(cmd.subcommands, fn subcmd -> 107 | IO.puts(" #{subcmd.name} #{subcmd.description || "No description"}") 108 | end) 109 | 110 | IO.puts("") 111 | end 112 | end 113 | 114 | # Displays arguments if any 115 | defp display_arguments(cmd) do 116 | if cmd.args != [] do 117 | IO.puts("Arguments:") 118 | 119 | Enum.each(cmd.args, &display_arg/1) 120 | 121 | IO.puts("") 122 | end 123 | end 124 | 125 | defp display_arg(arg) do 126 | arg_name = if arg.required, do: "<#{arg.name}>", else: "[#{arg.name}]" 127 | IO.puts(" #{arg_name} Type: #{format_arg_type(arg.type)}") 128 | end 129 | 130 | # Displays options (flags), including the help option 131 | defp display_options(cmd) do 132 | all_flags = cmd.flags || [] 133 | 134 | if all_flags != [] do 135 | IO.puts("Options:") 136 | 137 | Enum.each(all_flags, &display_option/1) 138 | end 139 | end 140 | 141 | defp display_option(flag) do 142 | short = if flag.short, do: "-#{flag.short}, ", else: " " 143 | type = if flag.type == :boolean, do: "", else: " <#{String.upcase(to_string(flag.type))}>" 144 | IO.puts(" #{short}--#{flag.name}#{type} #{flag.description || "No description"}") 145 | end 146 | 147 | # Formats the argument type for display 148 | defp format_arg_type({:list, type}), do: "List of #{inspect(type)}" 149 | defp format_arg_type({:enum, values}), do: "One of #{inspect(values)}" 150 | defp format_arg_type(type), do: "#{inspect(type)}" 151 | end 152 | -------------------------------------------------------------------------------- /test/support/my_cli.ex: -------------------------------------------------------------------------------- 1 | defmodule MyCLI do 2 | @moduledoc """ 3 | MyCLI provides file operations such as copy, move, and delete using the Nexus.CLI DSL. 4 | """ 5 | 6 | use Nexus.CLI, otp_app: :nexus_cli 7 | 8 | defcommand :version do 9 | description "Shows the version of the CLI" 10 | end 11 | 12 | defcommand :folder do 13 | description "Performs folder operations like merging" 14 | 15 | subcommand :merge do 16 | description "Merges two or more directories" 17 | 18 | value {:list, :string}, required: true, as: :targets 19 | 20 | flag :level do 21 | description "The level of the folder that will be merged" 22 | value :integer, required: false 23 | short :l 24 | end 25 | 26 | flag :recursive do 27 | description "IF the merge should operate recursively" 28 | value :boolean, required: false, default: false 29 | short :rc 30 | end 31 | end 32 | end 33 | 34 | defcommand :file do 35 | description "Performs file operations such as copy, move, and delete." 36 | 37 | subcommand :copy do 38 | description "Copies files from source to destination." 39 | 40 | value :string, required: true, as: :source 41 | value :string, required: true, as: :dest 42 | 43 | flag :level do 44 | value :integer, required: false 45 | end 46 | 47 | flag :verbose do 48 | short :v 49 | description "Enables verbose output." 50 | end 51 | 52 | flag :recursive do 53 | short :rc 54 | description "Copies directories recursively." 55 | end 56 | end 57 | 58 | subcommand :move do 59 | description "Moves files from source to destination." 60 | 61 | value :string, required: true, as: :source 62 | value :string, required: true, as: :dest 63 | 64 | flag :force do 65 | short :f 66 | description "Forces the move without confirmation." 67 | end 68 | 69 | flag :verbose do 70 | short :v 71 | description "Enables verbose output." 72 | end 73 | end 74 | 75 | subcommand :delete do 76 | description "Deletes specified files or directories." 77 | 78 | value {:list, :string}, required: true, as: :targets 79 | 80 | flag :force do 81 | short :f 82 | description "Forces deletion without confirmation." 83 | end 84 | 85 | flag :recursive do 86 | short :rc 87 | description "Deletes directories recursively." 88 | end 89 | 90 | flag :verbose do 91 | short :v 92 | description "Enables verbose output." 93 | end 94 | end 95 | end 96 | 97 | @impl Nexus.CLI 98 | def handle_input(:version, _) do 99 | # `version/0` comes from Nexus.CLI or the callback this module defined 100 | vsn = version() 101 | IO.puts(vsn) 102 | end 103 | 104 | def handle_input([:folder, :merge], %{args: args, flags: flags}) do 105 | if flags.recursive do 106 | IO.puts("Recursive merging enabled") 107 | end 108 | 109 | if level = flags.level do 110 | IO.puts("Set level of merging to #{level}") 111 | end 112 | 113 | Enum.each(args.targets, fn target -> 114 | IO.puts("Merged #{target}") 115 | end) 116 | 117 | :ok 118 | end 119 | 120 | def handle_input([:file, :copy], %{args: args, flags: flags}) do 121 | if flags.verbose do 122 | IO.puts("Copying from #{args.source} to #{args.dest}") 123 | end 124 | 125 | if flags.recursive do 126 | IO.puts("Recursive copy enabled") 127 | end 128 | 129 | # Implement actual copy logic here 130 | IO.puts("Copied #{args.source} to #{args.dest}") 131 | :ok 132 | end 133 | 134 | def handle_input([:file, :move], %{args: args, flags: flags}) do 135 | if flags.verbose do 136 | IO.puts("Moving from #{args.source} to #{args.dest}") 137 | end 138 | 139 | if flags.force do 140 | IO.puts("Force move enabled") 141 | end 142 | 143 | # Implement actual move logic here 144 | IO.puts("Moved #{args.source} to #{args.dest}") 145 | :ok 146 | end 147 | 148 | def handle_input([:file, :delete], %{args: args, flags: flags}) do 149 | if flags.verbose do 150 | IO.puts("Deleting targets: #{Enum.join(args.targets, ", ")}") 151 | end 152 | 153 | if flags.recursive do 154 | IO.puts("Recursive delete enabled") 155 | end 156 | 157 | if flags.force do 158 | IO.puts("Force delete enabled") 159 | end 160 | 161 | # Implement actual delete logic here 162 | Enum.each(args.targets, fn target -> 163 | IO.puts("Deleted #{target}") 164 | end) 165 | 166 | :ok 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/nexus/cli/dispatcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.Dispatcher do 2 | @moduledoc """ 3 | Dispatches parsed CLI commands to the appropriate handler functions. 4 | """ 5 | 6 | alias Nexus.CLI 7 | alias Nexus.CLI.Help 8 | alias Nexus.CLI.Input 9 | alias Nexus.Parser 10 | 11 | @doc """ 12 | Dispatches the parsed command to the corresponding handler. 13 | 14 | - `module` is the module where the handler functions are defined. 15 | - `parsed` is the result from `Nexus.Parser.parse_ast/2`. 16 | """ 17 | @spec dispatch(CLI.t(), Parser.result()) :: :ok | {:error, CLI.error()} 18 | def dispatch(%CLI{} = cli, %{flags: %{help: true}} = result) do 19 | Help.display(cli, result.command) 20 | 21 | :ok 22 | end 23 | 24 | def dispatch(%CLI{} = cli, %{command: []} = result) do 25 | case find_active_root_flag(cli.root_flags, result.flags) do 26 | nil -> dispatch(cli, put_in(result, [:flags, :help], true)) 27 | flag_name -> dispatch_root_flag(cli, flag_name, result) 28 | end 29 | end 30 | 31 | def dispatch(%CLI{} = cli, %{args: args, flags: flags, command: command}) when map_size(args) == 1 do 32 | single = 33 | case Map.values(args) do 34 | [value] -> value 35 | [] -> nil 36 | [first | _rest] -> first 37 | end 38 | 39 | input = %Input{flags: flags, value: single} 40 | 41 | try do 42 | case command do 43 | [root] -> cli.handler.handle_input(root, input) 44 | path -> cli.handler.handle_input(path, input) 45 | end 46 | rescue 47 | e in [UndefinedFunctionError] -> 48 | log_handler_error(e, cli, command, "Handler function not defined", __STACKTRACE__) 49 | {:error, {1, "Command '#{format_command(command)}' is not implemented"}} 50 | 51 | e in [FunctionClauseError] -> 52 | log_handler_error(e, cli, command, "Invalid arguments for handler", __STACKTRACE__) 53 | {:error, {1, "Invalid arguments for command '#{format_command(command)}'"}} 54 | 55 | e in [ArgumentError] -> 56 | log_handler_error(e, cli, command, "Invalid argument", __STACKTRACE__) 57 | {:error, {1, "Invalid argument: #{Exception.message(e)}"}} 58 | 59 | exception -> 60 | log_handler_error(exception, cli, command, "Unexpected error in handler", __STACKTRACE__) 61 | {:error, {1, "An error occurred while executing '#{format_command(command)}'"}} 62 | end 63 | end 64 | 65 | def dispatch(%CLI{} = cli, %{args: args, flags: flags, command: command}) do 66 | input = %Input{args: args, flags: flags} 67 | 68 | try do 69 | case command do 70 | [root] -> cli.handler.handle_input(root, input) 71 | path -> cli.handler.handle_input(path, input) 72 | end 73 | rescue 74 | e in [UndefinedFunctionError] -> 75 | log_handler_error(e, cli, command, "Handler function not defined", __STACKTRACE__) 76 | {:error, {1, "Command '#{format_command(command)}' is not implemented"}} 77 | 78 | e in [FunctionClauseError] -> 79 | log_handler_error(e, cli, command, "Invalid arguments for handler", __STACKTRACE__) 80 | {:error, {1, "Invalid arguments for command '#{format_command(command)}'"}} 81 | 82 | e in [ArgumentError] -> 83 | log_handler_error(e, cli, command, "Invalid argument", __STACKTRACE__) 84 | {:error, {1, "Invalid argument: #{Exception.message(e)}"}} 85 | 86 | exception -> 87 | log_handler_error(exception, cli, command, "Unexpected error in handler", __STACKTRACE__) 88 | {:error, {1, "An error occurred while executing '#{format_command(command)}'"}} 89 | end 90 | end 91 | 92 | defp log_handler_error(exception, cli, command, context, stack) do 93 | require Logger 94 | 95 | Logger.error([ 96 | "CLI Handler Error in #{cli.handler} for command '#{format_command(command)}': ", 97 | context, 98 | "\nException: #{Exception.format(:error, exception, stack)}" 99 | ]) 100 | end 101 | 102 | defp format_command([single]), do: to_string(single) 103 | defp format_command(path) when is_list(path), do: Enum.join(path, " ") 104 | 105 | defp find_active_root_flag(root_flags, flags) do 106 | Enum.find_value(root_flags, fn flag -> 107 | if Map.get(flags, flag.name, false) == true, do: flag.name 108 | end) 109 | end 110 | 111 | defp dispatch_root_flag(cli, flag_name, result) do 112 | input = %Input{flags: result.flags, args: result.args} 113 | 114 | try do 115 | cli.handler.handle_input(flag_name, input) 116 | rescue 117 | e in [UndefinedFunctionError] -> 118 | log_handler_error(e, cli, [flag_name], "Handler function not defined", __STACKTRACE__) 119 | {:error, {1, "Root flag '#{flag_name}' is not implemented"}} 120 | 121 | e in [FunctionClauseError] -> 122 | log_handler_error(e, cli, [flag_name], "Invalid arguments for handler", __STACKTRACE__) 123 | {:error, {1, "Invalid arguments for root flag '#{flag_name}'"}} 124 | 125 | e in [ArgumentError] -> 126 | log_handler_error(e, cli, [flag_name], "Invalid argument", __STACKTRACE__) 127 | {:error, {1, "Invalid argument: #{Exception.message(e)}"}} 128 | 129 | exception -> 130 | log_handler_error(exception, cli, [flag_name], "Unexpected error in handler", __STACKTRACE__) 131 | {:error, {1, "An error occurred while executing root flag '#{flag_name}'"}} 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/nexus/cli/validation.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.Validation do 2 | @moduledoc """ 3 | Provides validation functions for commands, flags, and arguments within the Nexus.CLI DSL. 4 | """ 5 | 6 | alias Nexus.CLI.Argument 7 | alias Nexus.CLI.Command 8 | alias Nexus.CLI.Flag 9 | 10 | @supported_types [:boolean, :string, :integer, :float] 11 | 12 | defmodule ValidationError do 13 | @moduledoc false 14 | defexception message: "Validation error" 15 | 16 | @spec exception(String.t()) :: %__MODULE__{message: String.t()} 17 | def exception(msg) do 18 | %__MODULE__{message: msg} 19 | end 20 | end 21 | 22 | @doc """ 23 | Validates a command, including its subcommands, flags, and arguments. 24 | """ 25 | @spec validate_command(Command.t()) :: Command.t() 26 | def validate_command(%Command{} = command) do 27 | command 28 | |> validate_command_name() 29 | |> validate_subcommands() 30 | |> validate_flags() 31 | |> validate_arguments() 32 | end 33 | 34 | defp validate_command_name(%Command{name: nil}) do 35 | raise ValidationError, "Command name is required and must be an atom." 36 | end 37 | 38 | defp validate_command_name(%Command{name: name} = command) when is_atom(name) do 39 | command 40 | end 41 | 42 | defp validate_command_name(%Command{name: name}) do 43 | raise ValidationError, "Command name must be an atom, got: #{inspect(name)}." 44 | end 45 | 46 | defp validate_subcommands(%Command{subcommands: subcommands} = command) do 47 | # Validate each subcommand 48 | subcommands = Enum.map(subcommands, &validate_command/1) 49 | # Check for duplicate subcommand names 50 | subcommand_names = Enum.map(subcommands, & &1.name) 51 | duplicates = find_duplicates(subcommand_names) 52 | 53 | if duplicates != [] do 54 | raise ValidationError, 55 | "Duplicate subcommand names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." 56 | end 57 | 58 | %{command | subcommands: subcommands} 59 | end 60 | 61 | defp validate_flags(%Command{flags: flags} = command) do 62 | flags = Enum.map(flags, &validate_flag/1) 63 | 64 | flag_names = Enum.map(flags, & &1.name) 65 | duplicates = find_duplicates(flag_names) 66 | 67 | if duplicates != [] do 68 | raise ValidationError, 69 | "Duplicate flag names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." 70 | end 71 | 72 | # Check for duplicate short flag names 73 | short_names = for flag <- flags, flag.short, do: flag.short 74 | duplicates_short = find_duplicates(short_names) 75 | 76 | if duplicates_short != [] do 77 | raise ValidationError, 78 | "Duplicate short flag aliases in command '#{command.name}': #{Enum.join(duplicates_short, ", ")}." 79 | end 80 | 81 | %{command | flags: flags} 82 | end 83 | 84 | defp validate_arguments(%Command{args: args} = command) do 85 | args = Enum.map(args, &validate_argument/1) 86 | 87 | arg_names = Enum.map(args, & &1.name) 88 | duplicates = find_duplicates(arg_names) 89 | 90 | if duplicates != [] do 91 | raise ValidationError, 92 | "Duplicate argument names in command '#{command.name}': #{Enum.join(duplicates, ", ")}." 93 | end 94 | 95 | %{command | args: args} 96 | end 97 | 98 | @doc """ 99 | Validates a flag. 100 | """ 101 | @spec validate_flag(Flag.t()) :: Flag.t() 102 | def validate_flag(%Flag{} = flag) do 103 | flag 104 | |> validate_flag_name() 105 | |> validate_flag_type() 106 | |> validate_flag_default() 107 | end 108 | 109 | defp validate_flag_name(%Flag{name: nil}) do 110 | raise ValidationError, "Flag name is required and must be an atom." 111 | end 112 | 113 | defp validate_flag_name(%Flag{name: name} = flag) when is_atom(name) do 114 | flag 115 | end 116 | 117 | defp validate_flag_name(%Flag{name: name}) do 118 | raise ValidationError, "Flag name must be an atom, got: #{inspect(name)}." 119 | end 120 | 121 | defp validate_flag_type(%Flag{type: type} = flag) do 122 | validate_type(type) 123 | flag 124 | end 125 | 126 | defp validate_flag_default(%Flag{default: default, type: type, name: name} = flag) do 127 | if default != nil do 128 | if !valid_default?(default, type) do 129 | raise ValidationError, 130 | "Default value for flag '#{name}' must be of type #{inspect(type)}, got: #{inspect(default)}." 131 | end 132 | end 133 | 134 | flag 135 | end 136 | 137 | @doc """ 138 | Validates an argument. 139 | """ 140 | @spec validate_argument(Argument.t()) :: Argument.t() 141 | def validate_argument(%Argument{} = arg) do 142 | arg 143 | |> validate_argument_name() 144 | |> validate_argument_type() 145 | |> validate_argument_default() 146 | end 147 | 148 | defp validate_argument_name(%Argument{name: nil}) do 149 | raise ValidationError, "Argument name is required and must be an atom." 150 | end 151 | 152 | defp validate_argument_name(%Argument{name: name} = arg) when is_atom(name) do 153 | arg 154 | end 155 | 156 | defp validate_argument_name(%Argument{name: name}) do 157 | raise ValidationError, "Argument name must be an atom, got: #{inspect(name)}." 158 | end 159 | 160 | defp validate_argument_type(%Argument{type: type} = arg) do 161 | validate_type(type) 162 | arg 163 | end 164 | 165 | defp validate_argument_default(%Argument{default: default, type: type, name: name} = arg) do 166 | if default != nil do 167 | if !valid_default?(default, type) do 168 | raise ValidationError, 169 | "Default value for argument '#{name}' must be of type #{inspect(type)}, got: #{inspect(default)}." 170 | end 171 | end 172 | 173 | arg 174 | end 175 | 176 | defp validate_type({:enum, values}) when is_list(values) do 177 | if Enum.all?(values, &is_atom/1) or Enum.all?(values, &is_binary/1) do 178 | :ok 179 | else 180 | raise ValidationError, "Enum values must be all atoms or all strings." 181 | end 182 | end 183 | 184 | defp validate_type({:list, subtype}) do 185 | validate_type(subtype) 186 | end 187 | 188 | defp validate_type(type) when type in @supported_types, do: :ok 189 | 190 | defp validate_type(type) do 191 | raise ValidationError, "Unsupported type: #{inspect(type)}." 192 | end 193 | 194 | defp valid_default?(default, {:enum, values}) do 195 | Enum.member?(values, default) 196 | end 197 | 198 | defp valid_default?(default, {:list, subtype}) when is_list(default) do 199 | Enum.all?(default, fn item -> valid_default?(item, subtype) end) 200 | end 201 | 202 | defp valid_default?(default, type) do 203 | case type do 204 | :boolean -> is_boolean(default) 205 | :string -> is_binary(default) 206 | :integer -> is_integer(default) 207 | :float -> is_float(default) 208 | _ -> false 209 | end 210 | end 211 | 212 | defp find_duplicates(list) do 213 | list 214 | |> Enum.frequencies() 215 | |> Enum.filter(fn {_item, count} -> count > 1 end) 216 | |> Enum.map(fn {item, _count} -> item end) 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | MIX_ENV: test 17 | 18 | strategy: 19 | matrix: 20 | include: 21 | - elixir: 1.18 22 | otp: 28 23 | - elixir: 1.18 24 | otp: 27 25 | - elixir: 1.18 26 | otp: 26 27 | - elixir: 1.17 28 | otp: 27 29 | - elixir: 1.17 30 | otp: 26 31 | - elixir: 1.17 32 | otp: 25 33 | - elixir: 1.16 34 | otp: 26 35 | - elixir: 1.16 36 | otp: 25 37 | - elixir: 1.16 38 | otp: 24 39 | - elixir: 1.15 40 | otp: 26 41 | - elixir: 1.15 42 | otp: 25 43 | - elixir: 1.15 44 | otp: 24 45 | - elixir: 1.14 46 | otp: 25 47 | - elixir: 1.14 48 | otp: 24 49 | 50 | steps: 51 | - name: Checkout code 52 | uses: actions/checkout@v3 53 | 54 | - name: Set up Elixir 55 | uses: erlef/setup-beam@v1 56 | with: 57 | elixir-version: ${{ matrix.elixir }} 58 | otp-version: ${{ matrix.otp }} 59 | 60 | - name: Cache Elixir deps 61 | uses: actions/cache@v4 62 | id: deps-cache 63 | with: 64 | path: deps 65 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 66 | 67 | - name: Cache Elixir _build 68 | uses: actions/cache@v4 69 | id: build-cache 70 | with: 71 | path: _build 72 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 73 | 74 | - name: Install deps 75 | if: steps.deps-cache.outputs.cache-hit != 'true' 76 | run: | 77 | mix local.rebar --force 78 | mix local.hex --force 79 | mix deps.get --only ${{ env.MIX_ENV }} 80 | 81 | - name: Compile deps 82 | if: steps.build-cache.outputs.cache-hit != 'true' 83 | run: mix deps.compile --warnings-as-errors 84 | 85 | - name: Check code formatting 86 | if: ${{ matrix.elixir != 1.14 }} 87 | run: mix format --check-formatted 88 | 89 | - name: Run Credo 90 | run: mix credo --strict 91 | 92 | static-analysis: 93 | runs-on: ubuntu-latest 94 | 95 | env: 96 | MIX_ENV: test 97 | 98 | strategy: 99 | matrix: 100 | include: 101 | - elixir: 1.18 102 | otp: 28 103 | - elixir: 1.18 104 | otp: 27 105 | - elixir: 1.18 106 | otp: 26 107 | - elixir: 1.17 108 | otp: 27 109 | - elixir: 1.17 110 | otp: 26 111 | - elixir: 1.17 112 | otp: 25 113 | - elixir: 1.16 114 | otp: 26 115 | - elixir: 1.16 116 | otp: 25 117 | - elixir: 1.16 118 | otp: 24 119 | - elixir: 1.15 120 | otp: 26 121 | - elixir: 1.15 122 | otp: 25 123 | - elixir: 1.15 124 | otp: 24 125 | - elixir: 1.14 126 | otp: 25 127 | - elixir: 1.14 128 | otp: 24 129 | 130 | steps: 131 | - name: Checkout code 132 | uses: actions/checkout@v3 133 | 134 | - name: Set up Elixir 135 | uses: erlef/setup-beam@v1 136 | with: 137 | elixir-version: ${{ matrix.elixir }} 138 | otp-version: ${{ matrix.otp }} 139 | 140 | - name: Cache Elixir deps 141 | uses: actions/cache@v4 142 | id: deps-cache 143 | with: 144 | path: deps 145 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 146 | 147 | - name: Cache Elixir _build 148 | uses: actions/cache@v4 149 | id: build-cache 150 | with: 151 | path: _build 152 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 153 | 154 | - name: Install deps 155 | if: steps.deps-cache.outputs.cache-hit != 'true' 156 | run: | 157 | mix local.rebar --force 158 | mix local.hex --force 159 | mix deps.get --only ${{ env.MIX_ENV }} 160 | 161 | - name: Compile deps 162 | if: steps.build-cache.outputs.cache-hit != 'true' 163 | run: mix deps.compile --warnings-as-errors 164 | 165 | # Ensure PLTs directory exists 166 | - name: Create PLTs directory 167 | run: mkdir -p priv/plts 168 | 169 | # Cache PLTs based on Elixir & Erlang version + mix.lock hash 170 | - name: Restore/Save PLT cache 171 | uses: actions/cache@v4 172 | id: plt_cache 173 | with: 174 | path: priv/plts 175 | key: plt-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 176 | restore-keys: | 177 | plt-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 178 | 179 | # Create PLTs if no cache was found 180 | - name: Create PLTs 181 | if: steps.plt_cache.outputs.cache-hit != 'true' 182 | run: mix dialyzer --plt 183 | 184 | - name: Run dialyzer 185 | run: mix dialyzer --format github 186 | 187 | test: 188 | runs-on: ubuntu-latest 189 | 190 | env: 191 | MIX_ENV: test 192 | 193 | strategy: 194 | matrix: 195 | include: 196 | - elixir: 1.18 197 | otp: 28 198 | - elixir: 1.18 199 | otp: 27 200 | - elixir: 1.18 201 | otp: 26 202 | - elixir: 1.17 203 | otp: 27 204 | - elixir: 1.17 205 | otp: 26 206 | - elixir: 1.17 207 | otp: 25 208 | - elixir: 1.16 209 | otp: 26 210 | - elixir: 1.16 211 | otp: 25 212 | - elixir: 1.16 213 | otp: 24 214 | - elixir: 1.15 215 | otp: 26 216 | - elixir: 1.15 217 | otp: 25 218 | - elixir: 1.15 219 | otp: 24 220 | - elixir: 1.14 221 | otp: 25 222 | - elixir: 1.14 223 | otp: 24 224 | 225 | steps: 226 | - name: Checkout code 227 | uses: actions/checkout@v3 228 | 229 | - name: Set up Elixir 230 | uses: erlef/setup-beam@v1 231 | with: 232 | elixir-version: ${{ matrix.elixir }} 233 | otp-version: ${{ matrix.otp }} 234 | 235 | - name: Cache Elixir deps 236 | uses: actions/cache@v4 237 | id: deps-cache 238 | with: 239 | path: deps 240 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 241 | 242 | - name: Cache Elixir _build 243 | uses: actions/cache@v4 244 | id: build-cache 245 | with: 246 | path: _build 247 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 248 | 249 | - name: Install deps 250 | if: steps.deps-cache.outputs.cache-hit != 'true' 251 | run: | 252 | mix local.rebar --force 253 | mix local.hex --force 254 | mix deps.get --only ${{ env.MIX_ENV }} 255 | 256 | - name: Compile deps 257 | if: steps.build-cache.outputs.cache-hit != 'true' 258 | run: mix deps.compile --warnings-as-errors 259 | 260 | - name: Run tests 261 | run: mix test 262 | -------------------------------------------------------------------------------- /test/nexus/cli/dispatcher_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.DispatcherTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Nexus.CLI 5 | alias Nexus.CLI.Dispatcher 6 | alias Nexus.CLI.Input 7 | 8 | defmodule TestHandler do 9 | @moduledoc false 10 | @behaviour CLI 11 | 12 | def description, do: "Test CLI handler" 13 | def version, do: "1.0.0" 14 | 15 | def handle_input(:success, %Input{flags: %{help: true}}) do 16 | :ok 17 | end 18 | 19 | def handle_input(:success, %Input{value: value, args: args}) when is_nil(args) or map_size(args) == 0 do 20 | {:ok, "Processed: #{value}"} 21 | end 22 | 23 | def handle_input(:success, %Input{args: args}) when map_size(args) > 0 do 24 | {:ok, "Args: #{inspect(args)}"} 25 | end 26 | 27 | def handle_input(:error, _input) do 28 | raise "Test error" 29 | end 30 | 31 | def handle_input(:function_clause_error, %Input{value: "specific"}) do 32 | :ok 33 | end 34 | 35 | def handle_input(:argument_error, _input) do 36 | raise ArgumentError, "Invalid argument" 37 | end 38 | end 39 | 40 | defmodule UndefinedHandler do 41 | # Missing handle_input implementation 42 | @moduledoc false 43 | end 44 | 45 | @cli %CLI{ 46 | name: :test_cli, 47 | handler: TestHandler, 48 | description: "Test CLI description", 49 | spec: [] 50 | } 51 | 52 | @undefined_cli %CLI{ 53 | name: :test_cli, 54 | handler: UndefinedHandler, 55 | description: "Test CLI description", 56 | spec: [] 57 | } 58 | 59 | describe "dispatch/2 with help flag" do 60 | test "displays help and returns :ok when help flag is true" do 61 | import ExUnit.CaptureIO 62 | 63 | result = %{ 64 | command: [:test], 65 | flags: %{help: true}, 66 | args: %{} 67 | } 68 | 69 | # Capture help output 70 | output = 71 | capture_io(fn -> 72 | assert :ok = Dispatcher.dispatch(@cli, result) 73 | end) 74 | 75 | # The help command will display "Command not found" since :test is not in spec 76 | # This is expected behavior for an undefined command 77 | assert output =~ "Command not found" 78 | end 79 | end 80 | 81 | describe "dispatch/2 with empty command" do 82 | test "redirects to help when command is empty" do 83 | import ExUnit.CaptureIO 84 | 85 | result = %{ 86 | command: [], 87 | flags: %{verbose: true}, 88 | args: %{} 89 | } 90 | 91 | output = 92 | capture_io(fn -> 93 | assert :ok = Dispatcher.dispatch(@cli, result) 94 | end) 95 | 96 | assert output =~ "test_cli" 97 | end 98 | end 99 | 100 | describe "dispatch/2 with single argument" do 101 | test "handles successful single value dispatch" do 102 | result = %{ 103 | command: [:success], 104 | flags: %{verbose: true}, 105 | args: %{file: "test.txt"} 106 | } 107 | 108 | assert {:ok, "Processed: test.txt"} = Dispatcher.dispatch(@cli, result) 109 | end 110 | 111 | test "handles successful single value dispatch with nil value" do 112 | result = %{ 113 | command: [:success], 114 | flags: %{verbose: true}, 115 | args: %{} 116 | } 117 | 118 | assert {:ok, "Processed: "} = Dispatcher.dispatch(@cli, result) 119 | end 120 | 121 | test "handles UndefinedFunctionError" do 122 | import ExUnit.CaptureLog 123 | 124 | result = %{ 125 | command: [:missing], 126 | flags: %{}, 127 | args: %{file: "test.txt"} 128 | } 129 | 130 | log = 131 | capture_log(fn -> 132 | assert {:error, {1, "Command 'missing' is not implemented"}} = 133 | Dispatcher.dispatch(@undefined_cli, result) 134 | end) 135 | 136 | assert log =~ "CLI Handler Error" 137 | assert log =~ "Handler function not defined" 138 | end 139 | 140 | test "handles FunctionClauseError" do 141 | import ExUnit.CaptureLog 142 | 143 | result = %{ 144 | command: [:function_clause_error], 145 | flags: %{}, 146 | args: %{file: "wrong_value"} 147 | } 148 | 149 | log = 150 | capture_log(fn -> 151 | assert {:error, {1, "Invalid arguments for command 'function_clause_error'"}} = 152 | Dispatcher.dispatch(@cli, result) 153 | end) 154 | 155 | assert log =~ "CLI Handler Error" 156 | assert log =~ "Invalid arguments for handler" 157 | end 158 | 159 | test "handles ArgumentError" do 160 | import ExUnit.CaptureLog 161 | 162 | result = %{ 163 | command: [:argument_error], 164 | flags: %{}, 165 | args: %{file: "test.txt"} 166 | } 167 | 168 | log = 169 | capture_log(fn -> 170 | assert {:error, {1, "Invalid argument: Invalid argument"}} = 171 | Dispatcher.dispatch(@cli, result) 172 | end) 173 | 174 | assert log =~ "CLI Handler Error" 175 | assert log =~ "Invalid argument" 176 | end 177 | 178 | test "handles generic exceptions" do 179 | import ExUnit.CaptureLog 180 | 181 | result = %{ 182 | command: [:error], 183 | flags: %{}, 184 | args: %{file: "test.txt"} 185 | } 186 | 187 | log = 188 | capture_log(fn -> 189 | assert {:error, {1, "An error occurred while executing 'error'"}} = 190 | Dispatcher.dispatch(@cli, result) 191 | end) 192 | 193 | assert log =~ "CLI Handler Error" 194 | assert log =~ "Unexpected error in handler" 195 | end 196 | end 197 | 198 | describe "dispatch/2 with multiple arguments" do 199 | test "handles successful multiple arguments dispatch" do 200 | result = %{ 201 | command: [:success], 202 | flags: %{verbose: true}, 203 | args: %{source: "file1.txt", dest: "file2.txt"} 204 | } 205 | 206 | assert {:ok, response} = Dispatcher.dispatch(@cli, result) 207 | assert String.contains?(response, "Args:") 208 | assert String.contains?(response, "source: \"file1.txt\"") 209 | assert String.contains?(response, "dest: \"file2.txt\"") 210 | end 211 | 212 | test "handles command path with multiple levels" do 213 | import ExUnit.CaptureLog 214 | 215 | result = %{ 216 | command: [:file, :copy], 217 | flags: %{}, 218 | args: %{source: "file1.txt", dest: "file2.txt"} 219 | } 220 | 221 | log = 222 | capture_log(fn -> 223 | # This will fail because TestHandler doesn't have handle_input for [:file, :copy] 224 | assert {:error, {1, "Command 'file copy' is not implemented"}} = 225 | Dispatcher.dispatch(@undefined_cli, result) 226 | end) 227 | 228 | assert log =~ "CLI Handler Error" 229 | end 230 | 231 | test "handles exceptions in multiple args dispatch" do 232 | import ExUnit.CaptureLog 233 | 234 | result = %{ 235 | command: [:error], 236 | flags: %{}, 237 | args: %{source: "file1.txt", dest: "file2.txt"} 238 | } 239 | 240 | log = 241 | capture_log(fn -> 242 | assert {:error, {1, "An error occurred while executing 'error'"}} = 243 | Dispatcher.dispatch(@cli, result) 244 | end) 245 | 246 | assert log =~ "CLI Handler Error" 247 | assert log =~ "Unexpected error in handler" 248 | end 249 | end 250 | 251 | describe "format_command/1" do 252 | test "formats single command" do 253 | import ExUnit.CaptureLog 254 | # This tests the private function indirectly through error messages 255 | result = %{ 256 | command: [:test], 257 | flags: %{}, 258 | args: %{file: "test.txt"} 259 | } 260 | 261 | capture_log(fn -> 262 | {:error, {1, message}} = Dispatcher.dispatch(@undefined_cli, result) 263 | assert message =~ "Command 'test' is not implemented" 264 | end) 265 | end 266 | 267 | test "formats multiple command path" do 268 | import ExUnit.CaptureLog 269 | 270 | result = %{ 271 | command: [:file, :copy, :recursive], 272 | flags: %{}, 273 | args: %{file: "test.txt"} 274 | } 275 | 276 | capture_log(fn -> 277 | {:error, {1, message}} = Dispatcher.dispatch(@undefined_cli, result) 278 | assert message =~ "Command 'file copy recursive' is not implemented" 279 | end) 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /test/nexus/cli/help_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI.HelpTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias Nexus.CLI 7 | alias Nexus.CLI.Argument 8 | alias Nexus.CLI.Command 9 | alias Nexus.CLI.Flag 10 | alias Nexus.CLI.Help 11 | 12 | defmodule TestHandler do 13 | @moduledoc false 14 | @behaviour CLI 15 | 16 | def description, do: "Test CLI handler" 17 | def version, do: "1.0.0" 18 | 19 | def handle_input(_, _), do: :ok 20 | end 21 | 22 | @simple_cli %CLI{ 23 | name: :test_cli, 24 | handler: TestHandler, 25 | description: "A simple test CLI", 26 | spec: [] 27 | } 28 | 29 | @complex_cli %CLI{ 30 | name: :complex_cli, 31 | handler: TestHandler, 32 | description: "A complex test CLI with subcommands", 33 | spec: [ 34 | %Command{ 35 | name: :file, 36 | description: "File operations", 37 | flags: [ 38 | %Flag{name: :verbose, short: :v, type: :boolean, description: "Enable verbose output"}, 39 | %Flag{name: :force, short: :f, type: :boolean, description: "Force operation"} 40 | ], 41 | args: [], 42 | subcommands: [ 43 | %Command{ 44 | name: :copy, 45 | description: "Copy files", 46 | flags: [ 47 | %Flag{name: :recursive, short: :r, type: :boolean, description: "Copy recursively"}, 48 | %Flag{name: :level, type: :integer, description: "Compression level", default: nil} 49 | ], 50 | args: [ 51 | %Argument{name: :source, type: :string, required: true}, 52 | %Argument{name: :dest, type: :string, required: true} 53 | ], 54 | subcommands: [] 55 | } 56 | ] 57 | }, 58 | %Command{ 59 | name: :version, 60 | description: "Show version", 61 | flags: [], 62 | args: [], 63 | subcommands: [] 64 | } 65 | ] 66 | } 67 | 68 | describe "display/2 with simple CLI" do 69 | test "displays root help for empty command path" do 70 | output = 71 | capture_io(fn -> 72 | Help.display(@simple_cli, []) 73 | end) 74 | 75 | assert output =~ "Usage: test_cli" 76 | assert output =~ "A simple test CLI" 77 | end 78 | 79 | test "displays root help with no command path argument" do 80 | output = 81 | capture_io(fn -> 82 | Help.display(@simple_cli) 83 | end) 84 | 85 | assert output =~ "Usage: test_cli" 86 | assert output =~ "A simple test CLI" 87 | end 88 | end 89 | 90 | describe "display/2 with complex CLI" do 91 | test "displays root help with available commands" do 92 | output = 93 | capture_io(fn -> 94 | Help.display(@complex_cli, []) 95 | end) 96 | 97 | assert output =~ "Usage: complex_cli [COMMAND]" 98 | assert output =~ "A complex test CLI with subcommands" 99 | assert output =~ "Commands:" 100 | assert output =~ "file File operations" 101 | assert output =~ "version Show version" 102 | assert output =~ "Use 'complex_cli [COMMAND] --help' for more information" 103 | end 104 | 105 | test "displays help for specific command" do 106 | output = 107 | capture_io(fn -> 108 | Help.display(@complex_cli, [:file]) 109 | end) 110 | 111 | assert output =~ "Usage: complex_cli file [OPTIONS] [COMMAND]" 112 | assert output =~ "File operations" 113 | assert output =~ "Commands:" 114 | assert output =~ "copy Copy files" 115 | assert output =~ "Options:" 116 | assert output =~ "-v, --verbose Enable verbose output" 117 | assert output =~ "-f, --force Force operation" 118 | end 119 | 120 | test "displays help for nested subcommand" do 121 | output = 122 | capture_io(fn -> 123 | Help.display(@complex_cli, [:file, :copy]) 124 | end) 125 | 126 | assert output =~ "Usage: complex_cli file copy [OPTIONS] " 127 | assert output =~ "Copy files" 128 | assert output =~ "Arguments:" 129 | assert output =~ " Type: :string" 130 | assert output =~ " Type: :string" 131 | assert output =~ "Options:" 132 | assert output =~ "-r, --recursive Copy recursively" 133 | assert output =~ "--level Compression level" 134 | end 135 | 136 | test "displays help for command without subcommands" do 137 | output = 138 | capture_io(fn -> 139 | Help.display(@complex_cli, [:version]) 140 | end) 141 | 142 | assert output =~ "Usage: complex_cli version" 143 | assert output =~ "Show version" 144 | refute output =~ "Commands:" 145 | refute output =~ "Use 'complex_cli version [COMMAND] --help'" 146 | end 147 | 148 | test "displays 'Command not found' for non-existent command" do 149 | output = 150 | capture_io(fn -> 151 | Help.display(@complex_cli, [:nonexistent]) 152 | end) 153 | 154 | assert output =~ "Command not found" 155 | end 156 | 157 | test "displays 'Command not found' for non-existent nested command" do 158 | output = 159 | capture_io(fn -> 160 | Help.display(@complex_cli, [:file, :nonexistent]) 161 | end) 162 | 163 | assert output =~ "Command not found" 164 | end 165 | end 166 | 167 | describe "banner integration" do 168 | defmodule BannerHandler do 169 | @moduledoc false 170 | @behaviour CLI 171 | 172 | def description, do: "CLI with banner" 173 | def version, do: "1.0.0" 174 | def banner, do: "My Custom Banner" 175 | 176 | def handle_input(_, _), do: :ok 177 | end 178 | 179 | test "displays banner when handler implements banner/0" do 180 | cli_with_banner = %CLI{ 181 | name: :banner_cli, 182 | handler: BannerHandler, 183 | description: "CLI with custom banner", 184 | spec: [] 185 | } 186 | 187 | output = 188 | capture_io(fn -> 189 | Help.display(cli_with_banner, []) 190 | end) 191 | 192 | assert output =~ "My Custom Banner" 193 | assert output =~ "Usage: banner_cli" 194 | end 195 | end 196 | 197 | describe "usage line formatting" do 198 | test "includes OPTIONS when flags are present" do 199 | output = 200 | capture_io(fn -> 201 | Help.display(@complex_cli, [:file]) 202 | end) 203 | 204 | assert output =~ "Usage: complex_cli file [OPTIONS] [COMMAND]" 205 | end 206 | 207 | test "includes COMMAND when subcommands are present" do 208 | output = 209 | capture_io(fn -> 210 | Help.display(@complex_cli, []) 211 | end) 212 | 213 | assert output =~ "Usage: complex_cli [COMMAND]" 214 | end 215 | 216 | test "includes arguments in usage line" do 217 | output = 218 | capture_io(fn -> 219 | Help.display(@complex_cli, [:file, :copy]) 220 | end) 221 | 222 | assert output =~ "Usage: complex_cli file copy [OPTIONS] " 223 | end 224 | end 225 | 226 | describe "argument display" do 227 | test "shows required arguments with angle brackets" do 228 | output = 229 | capture_io(fn -> 230 | Help.display(@complex_cli, [:file, :copy]) 231 | end) 232 | 233 | assert output =~ " Type: :string" 234 | assert output =~ " Type: :string" 235 | end 236 | 237 | test "would show optional arguments with square brackets" do 238 | # This test shows how optional arguments would be displayed 239 | # The current CLI spec doesn't have optional args, but this tests the format 240 | optional_cli = %CLI{ 241 | name: :optional_cli, 242 | handler: TestHandler, 243 | description: "CLI with optional args", 244 | spec: [ 245 | %Command{ 246 | name: :test, 247 | description: "Test command", 248 | flags: [], 249 | args: [ 250 | %Argument{name: :optional_arg, type: :string, required: false} 251 | ], 252 | subcommands: [] 253 | } 254 | ] 255 | } 256 | 257 | output = 258 | capture_io(fn -> 259 | Help.display(optional_cli, [:test]) 260 | end) 261 | 262 | assert output =~ "Usage: optional_cli test [optional_arg]" 263 | assert output =~ "[optional_arg] Type: :string" 264 | end 265 | end 266 | 267 | describe "flag display formatting" do 268 | test "shows flags with short versions" do 269 | output = 270 | capture_io(fn -> 271 | Help.display(@complex_cli, [:file]) 272 | end) 273 | 274 | assert output =~ "-v, --verbose Enable verbose output" 275 | assert output =~ "-f, --force Force operation" 276 | end 277 | 278 | test "shows flags without short versions" do 279 | output = 280 | capture_io(fn -> 281 | Help.display(@complex_cli, [:file, :copy]) 282 | end) 283 | 284 | assert output =~ " --level Compression level" 285 | end 286 | 287 | test "shows boolean flags without type indicator" do 288 | output = 289 | capture_io(fn -> 290 | Help.display(@complex_cli, [:file]) 291 | end) 292 | 293 | assert output =~ "-v, --verbose Enable verbose output" 294 | refute output =~ "--verbose " 295 | end 296 | 297 | test "shows non-boolean flags with type indicator" do 298 | output = 299 | capture_io(fn -> 300 | Help.display(@complex_cli, [:file, :copy]) 301 | end) 302 | 303 | assert output =~ "--level Compression level" 304 | end 305 | end 306 | 307 | describe "edge cases" do 308 | @empty_spec_cli %CLI{ 309 | name: :empty_cli, 310 | handler: TestHandler, 311 | description: nil, 312 | spec: [] 313 | } 314 | 315 | test "handles CLI with no description" do 316 | output = 317 | capture_io(fn -> 318 | Help.display(@empty_spec_cli, []) 319 | end) 320 | 321 | assert output =~ "Usage: empty_cli" 322 | refute output =~ "nil" 323 | end 324 | 325 | test "handles commands with no description" do 326 | no_desc_cli = %CLI{ 327 | name: :no_desc_cli, 328 | handler: TestHandler, 329 | description: "CLI", 330 | spec: [ 331 | %Command{ 332 | name: :cmd, 333 | description: nil, 334 | flags: [], 335 | args: [], 336 | subcommands: [] 337 | } 338 | ] 339 | } 340 | 341 | output = 342 | capture_io(fn -> 343 | Help.display(no_desc_cli, []) 344 | end) 345 | 346 | assert output =~ "cmd No description" 347 | end 348 | 349 | test "handles flags with no description" do 350 | no_flag_desc_cli = %CLI{ 351 | name: :no_flag_desc_cli, 352 | handler: TestHandler, 353 | description: "CLI", 354 | spec: [ 355 | %Command{ 356 | name: :cmd, 357 | description: "Command", 358 | flags: [ 359 | %Flag{name: :no_desc, type: :boolean, description: nil} 360 | ], 361 | args: [], 362 | subcommands: [] 363 | } 364 | ] 365 | } 366 | 367 | output = 368 | capture_io(fn -> 369 | Help.display(no_flag_desc_cli, [:cmd]) 370 | end) 371 | 372 | assert output =~ "--no_desc No description" 373 | end 374 | end 375 | end 376 | -------------------------------------------------------------------------------- /test/nexus/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.ParserTest do 2 | use ExUnit.Case 3 | 4 | alias Nexus.Parser 5 | 6 | @cli MyCLI.__nexus_spec__() 7 | @program @cli.name 8 | 9 | test "parses other single root command" do 10 | input = "version" 11 | 12 | expected = %{ 13 | program: @program, 14 | command: [:version], 15 | flags: %{help: false}, 16 | args: %{} 17 | } 18 | 19 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 20 | assert parsed == expected 21 | end 22 | 23 | test "parses any other root command" do 24 | input = "folder merge -rc folder1 folder2 folder3" 25 | 26 | expected = %{ 27 | program: @program, 28 | command: [:folder, :merge], 29 | flags: %{help: false, recursive: true, level: nil}, 30 | args: %{targets: ~w(folder1 folder2 folder3)} 31 | } 32 | 33 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 34 | assert parsed == expected 35 | end 36 | 37 | test "parses copy command with verbose flag and arguments" do 38 | input = "file copy --verbose file1.txt file2.txt" 39 | 40 | expected = %{ 41 | program: @program, 42 | command: [:file, :copy], 43 | flags: %{verbose: true, level: nil, recursive: false, help: false}, 44 | args: %{source: "file1.txt", dest: "file2.txt"} 45 | } 46 | 47 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 48 | assert parsed == expected 49 | end 50 | 51 | test "parses move command with force flag and arguments" do 52 | input = "file move --force source.txt dest.txt" 53 | 54 | expected = %{ 55 | program: @program, 56 | command: [:file, :move], 57 | flags: %{force: true, verbose: false, help: false}, 58 | args: %{source: "source.txt", dest: "dest.txt"} 59 | } 60 | 61 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 62 | assert parsed == expected 63 | end 64 | 65 | test "parses delete command with multiple flags and arguments" do 66 | input = "file delete --force --recursive file1.txt file2.txt" 67 | 68 | expected = %{ 69 | program: @program, 70 | command: [:file, :delete], 71 | flags: %{force: true, recursive: true, verbose: false, help: false}, 72 | args: %{targets: ["file1.txt", "file2.txt"]} 73 | } 74 | 75 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 76 | assert parsed == expected 77 | end 78 | 79 | test "fails on missing required arguments" do 80 | input = "file copy --verbose file1.txt" 81 | assert {:error, ["Missing required argument 'dest'"]} = Parser.parse_ast(@cli, input) 82 | end 83 | 84 | test "parses copy command with short flag and arguments" do 85 | input = "file copy -v file1.txt file2.txt" 86 | 87 | expected = %{ 88 | program: @program, 89 | command: [:file, :copy], 90 | flags: %{verbose: true, level: nil, recursive: false, help: false}, 91 | args: %{source: "file1.txt", dest: "file2.txt"} 92 | } 93 | 94 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 95 | assert parsed == expected 96 | end 97 | 98 | test "parses move command with verbose flag using short alias" do 99 | input = "file move -v source.txt dest.txt" 100 | 101 | expected = %{ 102 | program: @program, 103 | command: [:file, :move], 104 | flags: %{verbose: true, force: false, help: false}, 105 | args: %{source: "source.txt", dest: "dest.txt"} 106 | } 107 | 108 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 109 | assert parsed == expected 110 | end 111 | 112 | test "parses delete command with flags in different order" do 113 | input = "file delete --recursive --force file1.txt file2.txt" 114 | 115 | expected = %{ 116 | program: @program, 117 | command: [:file, :delete], 118 | flags: %{recursive: true, force: true, verbose: false, help: false}, 119 | args: %{targets: ["file1.txt", "file2.txt"]} 120 | } 121 | 122 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 123 | assert parsed == expected 124 | end 125 | 126 | test "parses copy command with flag value" do 127 | input = "file copy --level=3 file1.txt file2.txt" 128 | 129 | expected = %{ 130 | program: @program, 131 | command: [:file, :copy], 132 | flags: %{level: 3, recursive: false, verbose: false, help: false}, 133 | args: %{source: "file1.txt", dest: "file2.txt"} 134 | } 135 | 136 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 137 | assert parsed == expected 138 | end 139 | 140 | test "parses copy command with negative flag value" do 141 | input = "file copy --level=-2 file1.txt file2.txt" 142 | 143 | expected = %{ 144 | program: @program, 145 | command: [:file, :copy], 146 | flags: %{level: -2, verbose: false, recursive: false, help: false}, 147 | args: %{source: "file1.txt", dest: "file2.txt"} 148 | } 149 | 150 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 151 | assert parsed == expected 152 | end 153 | 154 | test "parses copy command with quoted string argument" do 155 | input = ~s(file copy --verbose "file 1.txt" "file 2.txt") 156 | 157 | expected = %{ 158 | program: @program, 159 | command: [:file, :copy], 160 | flags: %{verbose: true, level: nil, recursive: false, help: false}, 161 | args: %{source: "file 1.txt", dest: "file 2.txt"} 162 | } 163 | 164 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 165 | assert parsed == expected 166 | end 167 | 168 | test "parses command with --help flag" do 169 | input = "file copy --help" 170 | 171 | expected = %{ 172 | program: @program, 173 | command: [:file, :copy], 174 | flags: %{help: true}, 175 | args: %{} 176 | } 177 | 178 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 179 | assert parsed == expected 180 | end 181 | 182 | test "parses command with -h flag" do 183 | input = "file copy -h" 184 | 185 | expected = %{ 186 | program: @program, 187 | command: [:file, :copy], 188 | flags: %{help: true}, 189 | args: %{} 190 | } 191 | 192 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 193 | assert parsed == expected 194 | end 195 | 196 | test "parses command with -h flag and other flags and arguments" do 197 | input = "file copy -v -h source.txt dest.txt" 198 | 199 | expected = %{ 200 | program: @program, 201 | command: [:file, :copy], 202 | flags: %{help: true}, 203 | args: %{} 204 | } 205 | 206 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 207 | assert parsed == expected 208 | end 209 | 210 | test "handles empty input gracefully" do 211 | input = "" 212 | 213 | assert {:error, reasons} = Parser.parse_ast(@cli, input) 214 | assert is_list(reasons) 215 | assert length(reasons) > 0 216 | end 217 | 218 | test "handles only whitespace input" do 219 | input = " \t\n " 220 | 221 | assert {:error, reasons} = Parser.parse_ast(@cli, input) 222 | assert is_list(reasons) 223 | end 224 | 225 | test "handles malformed quoted strings" do 226 | input = ~s(file copy "unclosed quote file1.txt file2.txt) 227 | 228 | assert {:error, reasons} = Parser.parse_ast(@cli, input) 229 | assert is_list(reasons) 230 | assert Enum.any?(reasons, &String.contains?(&1, "Unclosed quoted string")) 231 | end 232 | 233 | test "handles unknown flags gracefully" do 234 | input = "file copy --unknown file1.txt file2.txt" 235 | 236 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 237 | refute parsed.flags[:unknown] 238 | end 239 | 240 | test "handles integer parsing errors gracefully" do 241 | input = "file copy --level=abc file1.txt file2.txt" 242 | 243 | assert {:error, reason} = Parser.parse_ast(@cli, input) 244 | assert reason == ["Invalid integer value: abc"] 245 | end 246 | 247 | test "validates command existence" do 248 | input = "nonexistent command" 249 | 250 | assert {:error, reasons} = Parser.parse_ast(@cli, input) 251 | assert is_list(reasons) 252 | 253 | flattened_reasons = List.flatten(reasons) 254 | assert Enum.any?(flattened_reasons, &String.contains?(&1, "not found")) 255 | end 256 | 257 | test "handles flags after arguments" do 258 | input = "file copy file1.txt file2.txt --verbose" 259 | 260 | expected = %{ 261 | program: @program, 262 | command: [:file, :copy], 263 | flags: %{verbose: true, level: nil, recursive: false, help: false}, 264 | args: %{source: "file1.txt", dest: "file2.txt"} 265 | } 266 | 267 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 268 | assert parsed == expected 269 | end 270 | 271 | test "handles special characters in file names" do 272 | input = ~s(file copy "file@#$%.txt" "dest file!.txt") 273 | 274 | expected = %{ 275 | program: @program, 276 | command: [:file, :copy], 277 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 278 | args: %{source: "file@#$%.txt", dest: "dest file!.txt"} 279 | } 280 | 281 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 282 | assert parsed == expected 283 | end 284 | 285 | test "parses list arguments without flags" do 286 | input = "folder merge folder1 folder2 folder3" 287 | 288 | expected = %{ 289 | program: @program, 290 | command: [:folder, :merge], 291 | flags: %{help: false, recursive: false, level: nil}, 292 | args: %{targets: ["folder1", "folder2", "folder3"]} 293 | } 294 | 295 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 296 | assert parsed == expected 297 | end 298 | 299 | test "handles duplicate flags - last one wins" do 300 | input = "file copy --level=1 --level=2 file1.txt file2.txt" 301 | 302 | expected = %{ 303 | program: @program, 304 | command: [:file, :copy], 305 | flags: %{level: 2, verbose: false, recursive: false, help: false}, 306 | args: %{source: "file1.txt", dest: "file2.txt"} 307 | } 308 | 309 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 310 | assert parsed == expected 311 | end 312 | 313 | test "parses negative numbers correctly in flag values" do 314 | input = "folder merge --level=-5 folder1" 315 | 316 | expected = %{ 317 | program: @program, 318 | command: [:folder, :merge], 319 | flags: %{level: -5, recursive: false, help: false}, 320 | args: %{targets: ["folder1"]} 321 | } 322 | 323 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 324 | assert parsed == expected 325 | end 326 | 327 | test "handles mixed quoted and unquoted arguments" do 328 | input = ~s(file copy "quoted file.txt" unquoted.txt) 329 | 330 | expected = %{ 331 | program: @program, 332 | command: [:file, :copy], 333 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 334 | args: %{source: "quoted file.txt", dest: "unquoted.txt"} 335 | } 336 | 337 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 338 | assert parsed == expected 339 | end 340 | 341 | test "handles non-integer values for integer flags - should return error" do 342 | input = ~s(folder merge --level="5=test" folder1) 343 | 344 | assert {:error, reason} = Parser.parse_ast(@cli, input) 345 | assert reason == [~s|Invalid integer value: "5=test"|] 346 | end 347 | 348 | test "handles empty quoted strings" do 349 | input = ~s(file copy "" "") 350 | 351 | expected = %{ 352 | program: @program, 353 | command: [:file, :copy], 354 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 355 | args: %{source: "", dest: ""} 356 | } 357 | 358 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 359 | assert parsed == expected 360 | end 361 | 362 | test "handles unicode characters in arguments" do 363 | input = ~s(file copy "файл.txt" "目的地.txt") 364 | 365 | expected = %{ 366 | program: @program, 367 | command: [:file, :copy], 368 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 369 | args: %{source: "файл.txt", dest: "目的地.txt"} 370 | } 371 | 372 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 373 | assert parsed == expected 374 | end 375 | 376 | test "handles boolean flag values explicitly" do 377 | input = "file copy --verbose=true file1.txt file2.txt" 378 | 379 | expected = %{ 380 | program: @program, 381 | command: [:file, :copy], 382 | flags: %{verbose: true, level: nil, recursive: false, help: false}, 383 | args: %{source: "file1.txt", dest: "file2.txt"} 384 | } 385 | 386 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 387 | assert parsed == expected 388 | end 389 | 390 | test "handles false boolean flag values" do 391 | input = "file copy --verbose=false file1.txt file2.txt" 392 | 393 | expected = %{ 394 | program: @program, 395 | command: [:file, :copy], 396 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 397 | args: %{source: "file1.txt", dest: "file2.txt"} 398 | } 399 | 400 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 401 | assert parsed == expected 402 | end 403 | 404 | test "parses integer flag with space-separated value" do 405 | input = "file copy --level 5 file1.txt file2.txt" 406 | 407 | expected = %{ 408 | program: @program, 409 | command: [:file, :copy], 410 | flags: %{level: 5, verbose: false, recursive: false, help: false}, 411 | args: %{source: "file1.txt", dest: "file2.txt"} 412 | } 413 | 414 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 415 | assert parsed == expected 416 | end 417 | 418 | test "parses multiple integer flags with space-separated values" do 419 | input = "folder merge --level 3 --recursive folder1" 420 | 421 | expected = %{ 422 | program: @program, 423 | command: [:folder, :merge], 424 | flags: %{level: 3, recursive: true, help: false}, 425 | args: %{targets: ["folder1"]} 426 | } 427 | 428 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 429 | assert parsed == expected 430 | end 431 | 432 | test "parses boolean flag followed by integer flag with space-separated value" do 433 | input = "folder merge --recursive --level 7 folder1 folder2" 434 | 435 | expected = %{ 436 | program: @program, 437 | command: [:folder, :merge], 438 | flags: %{recursive: true, level: 7, help: false}, 439 | args: %{targets: ["folder1", "folder2"]} 440 | } 441 | 442 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 443 | assert parsed == expected 444 | end 445 | 446 | test "handles subcommands with exact matching - should fail for unknown subcommand" do 447 | input = "file cop file1.txt file2.txt" 448 | 449 | assert {:error, reasons} = Parser.parse_ast(@cli, input) 450 | assert is_list(reasons) 451 | end 452 | 453 | test "handles extremely long input gracefully" do 454 | long_filename = String.duplicate("a", 1000) 455 | input = "file copy #{long_filename} dest.txt" 456 | 457 | expected = %{ 458 | program: @program, 459 | command: [:file, :copy], 460 | flags: %{verbose: false, level: nil, recursive: false, help: false}, 461 | args: %{source: long_filename, dest: "dest.txt"} 462 | } 463 | 464 | assert {:ok, parsed} = Parser.parse_ast(@cli, input) 465 | assert parsed == expected 466 | end 467 | end 468 | -------------------------------------------------------------------------------- /lib/nexus/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.Parser do 2 | @moduledoc """ 3 | Nexus.Parser provides functionalities to parse raw input strings based on the CLI AST. 4 | This implementation uses functional parser combinators for clean, composable parsing. 5 | """ 6 | 7 | alias Nexus.CLI.Command 8 | alias Nexus.CLI.Flag 9 | alias Nexus.Parser.DSL 10 | 11 | @type result :: %{ 12 | program: atom, 13 | command: list(atom), 14 | flags: %{atom => term}, 15 | args: %{atom => term} 16 | } 17 | 18 | @doc """ 19 | Parses the raw input string based on the given AST. 20 | """ 21 | @spec parse_ast(cli :: Nexus.CLI.t(), input :: String.t() | list(String.t())) :: 22 | {:ok, result} | {:error, list(String.t())} 23 | def parse_ast(%Nexus.CLI{} = cli, input) when is_binary(input) do 24 | case tokenize(input) do 25 | {:ok, []} -> {:error, ["No program specified"]} 26 | {:ok, tokens} -> parse_ast(cli, tokens) 27 | {:error, msg} -> {:error, [msg]} 28 | end 29 | end 30 | 31 | def parse_ast(%Nexus.CLI{} = cli, tokens) when is_list(tokens) do 32 | with {:ok, root_cmd, tokens} <- extract_root_cmd_name(tokens), 33 | {:ok, root_ast} <- find_root_or_use_cli(root_cmd, cli), 34 | {:ok, command_path, command_ast, tokens} <- extract_commands(tokens, root_ast, cli), 35 | {:ok, flags, args} <- parse_flags_and_args_with_context(tokens, command_ast.flags, cli.root_flags), 36 | {:ok, help_issued?} <- verify_help_presence(flags), 37 | {:ok, processed_flags} <- process_flags(flags, command_ast.flags ++ cli.root_flags, help: help_issued?), 38 | {:ok, processed_args} <- process_args(args, command_ast.args, help: help_issued?) do 39 | {:ok, 40 | %{ 41 | program: cli.name, 42 | command: if(root_ast, do: [root_cmd | Enum.map(command_path, &String.to_atom/1)], else: []), 43 | flags: processed_flags, 44 | args: processed_args 45 | }} 46 | else 47 | {:error, reason} -> {:error, List.wrap(reason)} 48 | end 49 | end 50 | 51 | defp tokenize(input) do 52 | input 53 | |> String.trim() 54 | |> String.split(~r/\s+/, trim: true) 55 | |> handle_quoted_strings() 56 | end 57 | 58 | defp handle_quoted_strings(tokens) do 59 | tokens 60 | |> Enum.reduce({:ok, [], false, []}, &handle_quoted_string/2) 61 | |> case do 62 | {:ok, acc, false, []} -> 63 | {:ok, Enum.reverse(acc)} 64 | 65 | {:ok, _acc, true, _buffer} -> 66 | {:error, "Unclosed quoted string"} 67 | 68 | {:error, msg} -> 69 | {:error, [msg]} 70 | end 71 | end 72 | 73 | defp handle_quoted_string(token, {:ok, acc, in_quote, buffer}) do 74 | cond do 75 | raw_quoted?(token) -> handle_raw_quoted(token, buffer, in_quote, acc) 76 | String.starts_with?(token, "\"") -> handle_started_quoted(token, acc) 77 | String.ends_with?(token, "\"") and in_quote -> handle_ended_quoted(token, buffer, acc) 78 | in_quote -> {:ok, acc, true, [token | buffer]} 79 | true -> {:ok, [token | acc], in_quote, buffer} 80 | end 81 | end 82 | 83 | defp raw_quoted?(token) do 84 | String.starts_with?(token, "\"") and String.ends_with?(token, "\"") and 85 | String.length(token) > 1 86 | end 87 | 88 | defp handle_raw_quoted(token, buffer, in_quote, acc) do 89 | unquoted = String.slice(token, 1..-2//1) 90 | {:ok, [unquoted | acc], in_quote, buffer} 91 | end 92 | 93 | defp handle_started_quoted(token, acc) do 94 | unquoted = String.trim_leading(token, "\"") 95 | {:ok, acc, true, [unquoted]} 96 | end 97 | 98 | defp handle_ended_quoted(token, buffer, acc) do 99 | unquoted = String.trim_trailing(token, "\"") 100 | buffer = Enum.reverse([unquoted | buffer]) 101 | combined = Enum.join(buffer, " ") 102 | {:ok, [combined | acc], false, []} 103 | end 104 | 105 | defp extract_root_cmd_name([program_name | rest]) do 106 | {:ok, String.to_existing_atom(program_name), rest} 107 | rescue 108 | ArgumentError -> 109 | {:error, "Command '#{program_name}' not found"} 110 | end 111 | 112 | defp extract_root_cmd_name([]), do: {:error, "No program specified"} 113 | 114 | defp extract_commands(tokens, program_ast, _cli) do 115 | if program_ast do 116 | extract_commands_recursive(tokens, [], program_ast) 117 | else 118 | # No command found, this might be a root flag invocation 119 | {:ok, [], %Command{flags: [], args: []}, tokens} 120 | end 121 | end 122 | 123 | defp extract_commands_recursive([token | rest_tokens], command_path, current_ast) do 124 | subcommand_ast = 125 | Enum.find(current_ast.subcommands || [], fn cmd -> 126 | to_string(cmd.name) == token 127 | end) 128 | 129 | if subcommand_ast do 130 | extract_commands_recursive(rest_tokens, command_path ++ [token], subcommand_ast) 131 | else 132 | {:ok, command_path, current_ast, [token | rest_tokens]} 133 | end 134 | end 135 | 136 | defp extract_commands_recursive([], command_path, current_ast) do 137 | {:ok, command_path, current_ast, []} 138 | end 139 | 140 | defp parse_flags_and_args_with_context(tokens, flag_definitions, root_flags) do 141 | all_flags = flag_definitions ++ root_flags 142 | flag_lookup = build_flag_lookup_maps(all_flags) 143 | parse_flags_and_args_with_context_impl(tokens, [{:help_flag, "help", false}], [], flag_lookup) 144 | end 145 | 146 | defp parse_flags_and_args_with_context_impl([], flags, args, _flag_definitions) do 147 | {:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)} 148 | end 149 | 150 | defp parse_flags_and_args_with_context_impl([token | rest] = tokens, flags, args, flag_definitions) do 151 | cond do 152 | help_flag_present?(tokens) -> 153 | finish_parsing_with_help(flags, args) 154 | 155 | String.starts_with?(token, "--") -> 156 | parse_long_flag_with_context(token, rest, flags, args, flag_definitions) 157 | 158 | String.starts_with?(token, "-") and token != "-" -> 159 | parse_short_flag_with_context(token, rest, flags, args, flag_definitions) 160 | 161 | true -> 162 | parse_argument_with_context(token, rest, flags, args, flag_definitions) 163 | end 164 | end 165 | 166 | defp help_flag_present?(tokens) do 167 | "--help" in tokens or "-h" in tokens 168 | end 169 | 170 | defp finish_parsing_with_help(flags, args) do 171 | flags = [{:help_flag, "help", true} | flags] 172 | {:ok, Enum.reverse(uniq_flag_by_name(flags)), Enum.reverse(args)} 173 | end 174 | 175 | defp parse_long_flag_with_context(token, rest, flags, args, flag_definitions) do 176 | case DSL.parse(DSL.long_flag_parser(), [token]) do 177 | {:ok, {:flag, :long, name, true}, _} -> 178 | handle_flag_value_consumption(:long, name, true, rest, flags, args, flag_definitions) 179 | 180 | {:ok, {:flag, :long, name, value}, _} -> 181 | parse_flags_and_args_with_context_impl(rest, [{:long_flag, name, value} | flags], args, flag_definitions) 182 | 183 | {:error, _} -> 184 | parse_flags_and_args_with_context_impl(rest, flags, [token | args], flag_definitions) 185 | end 186 | end 187 | 188 | defp parse_short_flag_with_context(token, rest, flags, args, flag_definitions) do 189 | case DSL.parse(DSL.short_flag_parser(), [token]) do 190 | {:ok, {:flag, :short, name, true}, _} -> 191 | handle_flag_value_consumption(:short, name, true, rest, flags, args, flag_definitions) 192 | 193 | {:ok, {:flag, :short, name, value}, _} -> 194 | parse_flags_and_args_with_context_impl(rest, [{:short_flag, name, value} | flags], args, flag_definitions) 195 | 196 | {:error, _} -> 197 | parse_flags_and_args_with_context_impl(rest, flags, [token | args], flag_definitions) 198 | end 199 | end 200 | 201 | defp parse_argument_with_context(token, rest, flags, args, flag_definitions) do 202 | unquoted_arg = DSL.unquote_string(token) 203 | parse_flags_and_args_with_context_impl(rest, flags, [unquoted_arg | args], flag_definitions) 204 | end 205 | 206 | defp handle_flag_value_consumption(flag_type, name, _default_value, rest, flags, args, flag_definitions) do 207 | flag_def = find_flag_definition(name, flag_definitions) 208 | 209 | case flag_def do 210 | %Flag{type: :boolean} -> 211 | flag_entry = {flag_type_to_atom(flag_type), name, true} 212 | parse_flags_and_args_with_context_impl(rest, [flag_entry | flags], args, flag_definitions) 213 | 214 | %Flag{type: type} when type != :boolean and rest != [] -> 215 | [value | remaining_rest] = rest 216 | flag_entry = {flag_type_to_atom(flag_type), name, value} 217 | parse_flags_and_args_with_context_impl(remaining_rest, [flag_entry | flags], args, flag_definitions) 218 | 219 | %Flag{type: type} when type != :boolean -> 220 | {:error, "Flag --#{name} expects a #{type} value but none was provided"} 221 | 222 | nil -> 223 | flag_entry = {flag_type_to_atom(flag_type), name, true} 224 | parse_flags_and_args_with_context_impl(rest, [flag_entry | flags], args, flag_definitions) 225 | end 226 | end 227 | 228 | defp build_flag_lookup_maps(flag_definitions) do 229 | long_map = 230 | Map.new(flag_definitions, fn flag_def -> 231 | {to_string(flag_def.name), flag_def} 232 | end) 233 | 234 | short_map = 235 | flag_definitions 236 | |> Enum.filter(& &1.short) 237 | |> Map.new(fn flag_def -> 238 | {to_string(flag_def.short), flag_def} 239 | end) 240 | 241 | %{long: long_map, short: short_map} 242 | end 243 | 244 | defp find_flag_definition(name, %{long: long_map, short: short_map}) do 245 | Map.get(long_map, name) || Map.get(short_map, name) 246 | end 247 | 248 | defp flag_type_to_atom(:long), do: :long_flag 249 | defp flag_type_to_atom(:short), do: :short_flag 250 | 251 | defp uniq_flag_by_name(flags) do 252 | Enum.uniq_by(flags, &elem(&1, 1)) 253 | end 254 | 255 | defp find_root_or_use_cli(name, cli) do 256 | case Enum.find(cli.spec, &(&1.name == name)) do 257 | nil -> 258 | # Check if the name is the CLI itself (program name for root flags) 259 | if name == cli.name and Enum.any?(cli.root_flags) do 260 | # No command, just root flags 261 | {:ok, nil} 262 | else 263 | {:error, "Command '#{name}' not found"} 264 | end 265 | 266 | program -> 267 | {:ok, program} 268 | end 269 | end 270 | 271 | defp verify_help_presence(flags) when is_list(flags) do 272 | help = Enum.find(flags, &help_flag?/1) 273 | {:ok, if(help, do: elem(help, 2), else: false)} 274 | end 275 | 276 | defp help_flag?({_type, "help", _v}), do: true 277 | defp help_flag?({_type, "h", _v}), do: true 278 | defp help_flag?(_), do: false 279 | 280 | defp process_flags(_flag_tokens, _defined_flags, help: true) do 281 | {:ok, %{help: true}} 282 | end 283 | 284 | defp process_flags(flag_tokens, defined_flags, _help) do 285 | flag_lookup = build_flag_lookup_maps(defined_flags) 286 | 287 | case parse_all_flags(flag_tokens, flag_lookup, %{}) do 288 | {:ok, flags} -> 289 | missing_required_flags = list_missing_required_flags(flags, defined_flags) 290 | 291 | if Enum.empty?(missing_required_flags) do 292 | non_parsed_flags = list_non_parsed_flags(flags, defined_flags) 293 | {:ok, Map.merge(flags, non_parsed_flags)} 294 | else 295 | {:error, "Missing required flags: #{Enum.join(missing_required_flags, ", ")}"} 296 | end 297 | 298 | {:error, reason} -> 299 | {:error, reason} 300 | end 301 | end 302 | 303 | defp parse_all_flags([], _defined_flags, acc), do: {:ok, acc} 304 | 305 | defp parse_all_flags([flag_token | rest], defined_flags, acc) do 306 | case parse_flag(flag_token, acc, defined_flags) do 307 | {:ok, updated_acc} -> 308 | parse_all_flags(rest, defined_flags, updated_acc) 309 | 310 | {:error, reason} -> 311 | {:error, reason} 312 | end 313 | end 314 | 315 | defp parse_flag({_flag_type, "help", value}, parsed, _defined) do 316 | {:ok, Map.put(parsed, :help, value)} 317 | end 318 | 319 | defp parse_flag({_flag_type, name, value}, parsed, %{long: _, short: _} = flag_lookup) do 320 | flag_def = find_flag_definition(name, flag_lookup) 321 | 322 | if flag_def do 323 | case parse_value(value, flag_def.type) do 324 | {:ok, parsed_value} -> 325 | {:ok, Map.put(parsed, flag_def.name, parsed_value)} 326 | 327 | {:error, reason} -> 328 | {:error, reason} 329 | end 330 | else 331 | {:ok, parsed} 332 | end 333 | end 334 | 335 | defp list_missing_required_flags(parsed, defined) do 336 | defined 337 | |> Enum.filter(fn flag -> 338 | flag.required and not Map.has_key?(parsed, flag.name) 339 | end) 340 | |> Enum.map(&to_string(&1.name)) 341 | end 342 | 343 | defp list_non_parsed_flags(parsed, defined) do 344 | defined 345 | |> Enum.filter(&(not Map.has_key?(parsed, &1.name))) 346 | |> Map.new(&{&1.name, &1.default}) 347 | end 348 | 349 | defp parse_value(value, :boolean) when is_boolean(value), do: {:ok, value} 350 | defp parse_value("true", :boolean), do: {:ok, true} 351 | defp parse_value("false", :boolean), do: {:ok, false} 352 | 353 | defp parse_value(value, :integer) when is_integer(value), do: {:ok, value} 354 | 355 | defp parse_value(value, :integer) do 356 | case Integer.parse(value) do 357 | {int, ""} -> {:ok, int} 358 | _ -> {:error, "Invalid integer value: #{value}"} 359 | end 360 | end 361 | 362 | defp parse_value(value, :float) when is_float(value), do: {:ok, value} 363 | 364 | defp parse_value(value, :float) do 365 | case Float.parse(value) do 366 | {float, ""} -> {:ok, float} 367 | _ -> {:error, "Invalid float value: #{value}"} 368 | end 369 | end 370 | 371 | defp parse_value(value, _), do: {:ok, value} 372 | 373 | defp process_args(_arg_tokens, _defined_args, help: true) do 374 | {:ok, %{}} 375 | end 376 | 377 | defp process_args(arg_tokens, defined_args, _help) do 378 | case process_args_recursive(arg_tokens, defined_args, %{}) do 379 | {:ok, acc} -> {:ok, acc} 380 | {:error, reason} -> {:error, [reason]} 381 | end 382 | end 383 | 384 | defp process_args_recursive([], [], acc), do: {:ok, acc} 385 | 386 | defp process_args_recursive([token | _rest], [], _acc) do 387 | {:error, ["Unexpected argument '#{token}' - command does not accept arguments"]} 388 | end 389 | 390 | defp process_args_recursive(tokens, [arg_def | rest_args], acc) do 391 | case process_single_arg(tokens, arg_def) do 392 | {:ok, value, rest_tokens} -> 393 | case parse_value(value, arg_def.type) do 394 | {:ok, parsed_value} -> 395 | acc = Map.put(acc, arg_def.name, parsed_value) 396 | process_args_recursive(rest_tokens, rest_args, acc) 397 | 398 | {:error, reason} -> 399 | {:error, reason} 400 | end 401 | 402 | {:error, reason} -> 403 | {:error, reason} 404 | end 405 | end 406 | 407 | defp process_single_arg(tokens, arg_def) do 408 | case arg_def.type do 409 | {:list, _type} -> process_list_arg(tokens, arg_def) 410 | {:enum, values_list} -> process_enum_arg(tokens, arg_def, values_list) 411 | _ -> process_default_arg(tokens, arg_def) 412 | end 413 | end 414 | 415 | defp process_list_arg(tokens, arg_def) do 416 | if tokens == [] and arg_def.required do 417 | {:error, "Missing required argument '#{arg_def.name}' of type list"} 418 | else 419 | inner_type = 420 | case arg_def.type do 421 | {:list, type} -> type 422 | _ -> :string 423 | end 424 | 425 | case parse_list_values(tokens, inner_type, []) do 426 | {:ok, parsed_values} -> {:ok, parsed_values, []} 427 | {:error, reason} -> {:error, reason} 428 | end 429 | end 430 | end 431 | 432 | defp parse_list_values([], _type, acc), do: {:ok, Enum.reverse(acc)} 433 | 434 | defp parse_list_values([token | rest], type, acc) do 435 | case parse_value(token, type) do 436 | {:ok, parsed_value} -> 437 | parse_list_values(rest, type, [parsed_value | acc]) 438 | 439 | {:error, reason} -> 440 | {:error, reason} 441 | end 442 | end 443 | 444 | defp process_enum_arg([value | rest_tokens], arg_def, values_list) do 445 | if value in Enum.map(values_list, &to_string/1) do 446 | {:ok, value, rest_tokens} 447 | else 448 | {:error, 449 | "Invalid value for argument '#{arg_def.name}': expected one of [#{Enum.join(values_list, ", ")}], got '#{value}'"} 450 | end 451 | end 452 | 453 | defp process_enum_arg([], arg_def, _values_list) do 454 | if arg_def.required do 455 | {:error, "Missing required argument '#{arg_def.name}'"} 456 | else 457 | {:ok, nil, []} 458 | end 459 | end 460 | 461 | defp process_default_arg([value | rest_tokens], _arg_def) do 462 | {:ok, value, rest_tokens} 463 | end 464 | 465 | defp process_default_arg([], arg_def) do 466 | if arg_def.required do 467 | {:error, "Missing required argument '#{arg_def.name}'"} 468 | else 469 | {:ok, nil, []} 470 | end 471 | end 472 | end 473 | -------------------------------------------------------------------------------- /test/nexus/parser/dsl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Nexus.Parser.DSLTest do 2 | use ExUnit.Case 3 | 4 | import Nexus.Parser.DSL 5 | 6 | doctest Nexus.Parser.DSL 7 | 8 | describe "literal/1" do 9 | test "matches exact string" do 10 | parser = literal("hello") 11 | assert parse(parser, ["hello", "world"]) == {:ok, "hello", ["world"]} 12 | end 13 | 14 | test "fails on different string" do 15 | parser = literal("hello") 16 | assert parse(parser, ["goodbye", "world"]) == {:error, "Expected 'hello', got 'goodbye'"} 17 | end 18 | 19 | test "fails on empty input" do 20 | parser = literal("hello") 21 | assert parse(parser, []) == {:error, "Expected 'hello', got end of input"} 22 | end 23 | end 24 | 25 | describe "choice/1" do 26 | test "returns first successful match" do 27 | parser = choice([literal("git"), literal("svn"), literal("hg")]) 28 | assert parse(parser, ["git", "status"]) == {:ok, "git", ["status"]} 29 | assert parse(parser, ["svn", "status"]) == {:ok, "svn", ["status"]} 30 | assert parse(parser, ["hg", "status"]) == {:ok, "hg", ["status"]} 31 | end 32 | 33 | test "fails when no choice matches" do 34 | parser = choice([literal("add"), literal("commit")]) 35 | assert {:error, message} = parse(parser, ["push", "origin"]) 36 | assert String.contains?(message, "No choice matched") 37 | end 38 | 39 | test "works with empty list" do 40 | parser = choice([]) 41 | assert {:error, message} = parse(parser, ["anything"]) 42 | assert String.contains?(message, "No choice matched") 43 | end 44 | end 45 | 46 | describe "many/1" do 47 | test "matches zero occurrences" do 48 | parser = many(literal("very")) 49 | assert parse(parser, ["good", "day"]) == {:ok, [], ["good", "day"]} 50 | end 51 | 52 | test "matches multiple occurrences" do 53 | parser = many(literal("very")) 54 | assert parse(parser, ["very", "very", "good"]) == {:ok, ["very", "very"], ["good"]} 55 | end 56 | 57 | test "stops at first non-match" do 58 | parser = many(literal("la")) 59 | assert parse(parser, ["la", "la", "di", "da"]) == {:ok, ["la", "la"], ["di", "da"]} 60 | end 61 | end 62 | 63 | describe "many1/1" do 64 | test "requires at least one match" do 65 | parser = many1(literal("file")) 66 | assert parse(parser, ["file", "file", "done"]) == {:ok, ["file", "file"], ["done"]} 67 | end 68 | 69 | test "fails with zero matches" do 70 | parser = many1(literal("file")) 71 | assert parse(parser, ["done"]) == {:error, "Expected at least one match"} 72 | end 73 | end 74 | 75 | describe "sequence/1" do 76 | test "applies parsers in order" do 77 | parser = sequence([literal("git"), literal("commit")]) 78 | assert parse(parser, ["git", "commit", "--all"]) == {:ok, ["git", "commit"], ["--all"]} 79 | end 80 | 81 | test "fails if any parser fails" do 82 | parser = sequence([literal("git"), literal("commit")]) 83 | assert parse(parser, ["git", "push", "--all"]) == {:error, "Expected 'commit', got 'push'"} 84 | end 85 | 86 | test "works with empty sequence" do 87 | parser = sequence([]) 88 | assert parse(parser, ["anything"]) == {:ok, [], ["anything"]} 89 | end 90 | end 91 | 92 | describe "optional/1" do 93 | test "returns result when parser succeeds" do 94 | parser = optional(literal("--verbose")) 95 | assert parse(parser, ["--verbose", "file.txt"]) == {:ok, "--verbose", ["file.txt"]} 96 | end 97 | 98 | test "returns nil when parser fails" do 99 | parser = optional(literal("--verbose")) 100 | assert parse(parser, ["file.txt"]) == {:ok, nil, ["file.txt"]} 101 | end 102 | end 103 | 104 | describe "flag_parser/0" do 105 | test "parses long flag without value" do 106 | parser = flag_parser() 107 | assert parse(parser, ["--verbose"]) == {:ok, {:flag, :long, "verbose", true}, []} 108 | end 109 | 110 | test "parses long flag with value" do 111 | parser = flag_parser() 112 | assert parse(parser, ["--output=file.txt"]) == {:ok, {:flag, :long, "output", "file.txt"}, []} 113 | end 114 | 115 | test "parses short flag without value" do 116 | parser = flag_parser() 117 | assert parse(parser, ["-v"]) == {:ok, {:flag, :short, "v", true}, []} 118 | end 119 | 120 | test "parses short flag with value" do 121 | parser = flag_parser() 122 | assert parse(parser, ["-o=file.txt"]) == {:ok, {:flag, :short, "o", "file.txt"}, []} 123 | end 124 | 125 | test "fails on non-flag input" do 126 | parser = flag_parser() 127 | assert {:error, _} = parse(parser, ["file.txt"]) 128 | end 129 | end 130 | 131 | describe "long_flag_parser/0" do 132 | test "parses long flag" do 133 | parser = long_flag_parser() 134 | assert parse(parser, ["--help", "command"]) == {:ok, {:flag, :long, "help", true}, ["command"]} 135 | end 136 | 137 | test "parses long flag with equals value" do 138 | parser = long_flag_parser() 139 | assert parse(parser, ["--level=5", "command"]) == {:ok, {:flag, :long, "level", "5"}, ["command"]} 140 | end 141 | 142 | test "fails on short flag" do 143 | parser = long_flag_parser() 144 | assert {:error, message} = parse(parser, ["-h"]) 145 | assert String.contains?(message, "Expected long flag") 146 | end 147 | 148 | test "fails on regular argument" do 149 | parser = long_flag_parser() 150 | assert {:error, message} = parse(parser, ["file.txt"]) 151 | assert String.contains?(message, "Expected long flag") 152 | end 153 | end 154 | 155 | describe "short_flag_parser/0" do 156 | test "parses short flag" do 157 | parser = short_flag_parser() 158 | assert parse(parser, ["-h", "command"]) == {:ok, {:flag, :short, "h", true}, ["command"]} 159 | end 160 | 161 | test "parses short flag with equals value" do 162 | parser = short_flag_parser() 163 | assert parse(parser, ["-l=5", "command"]) == {:ok, {:flag, :short, "l", "5"}, ["command"]} 164 | end 165 | 166 | test "fails on long flag" do 167 | parser = short_flag_parser() 168 | assert {:error, message} = parse(parser, ["--help"]) 169 | assert String.contains?(message, "Expected short flag") 170 | end 171 | 172 | test "fails on regular argument" do 173 | parser = short_flag_parser() 174 | assert {:error, message} = parse(parser, ["file.txt"]) 175 | assert String.contains?(message, "Expected short flag") 176 | end 177 | end 178 | 179 | describe "command_parser/1" do 180 | test "matches valid command" do 181 | parser = command_parser(["commit", "push", "pull"]) 182 | assert parse(parser, ["commit", "--message"]) == {:ok, "commit", ["--message"]} 183 | end 184 | 185 | test "fails on invalid command" do 186 | parser = command_parser(["add", "remove"]) 187 | assert {:error, message} = parse(parser, ["commit", "--message"]) 188 | assert String.contains?(message, "Expected one of [add, remove]") 189 | assert String.contains?(message, "got 'commit'") 190 | end 191 | 192 | test "fails on empty input" do 193 | parser = command_parser(["add", "remove"]) 194 | assert parse(parser, []) == {:error, "Expected command, got end of input"} 195 | end 196 | end 197 | 198 | describe "value_parser/1" do 199 | test "parses string values" do 200 | parser = value_parser(:string) 201 | assert parse(parser, ["hello", "world"]) == {:ok, "hello", ["world"]} 202 | end 203 | 204 | test "parses integer values" do 205 | parser = value_parser(:integer) 206 | assert parse(parser, ["42", "rest"]) == {:ok, 42, ["rest"]} 207 | end 208 | 209 | test "parses negative integer values" do 210 | parser = value_parser(:integer) 211 | assert parse(parser, ["-42", "rest"]) == {:ok, -42, ["rest"]} 212 | end 213 | 214 | test "fails on invalid integer" do 215 | parser = value_parser(:integer) 216 | assert {:error, message} = parse(parser, ["abc", "rest"]) 217 | assert String.contains?(message, "Invalid integer value") 218 | end 219 | 220 | test "parses float values" do 221 | parser = value_parser(:float) 222 | assert parse(parser, ["3.14", "rest"]) == {:ok, 3.14, ["rest"]} 223 | end 224 | 225 | test "parses negative float values" do 226 | parser = value_parser(:float) 227 | assert parse(parser, ["-3.14", "rest"]) == {:ok, -3.14, ["rest"]} 228 | end 229 | 230 | test "fails on invalid float" do 231 | parser = value_parser(:float) 232 | assert {:error, message} = parse(parser, ["abc", "rest"]) 233 | assert String.contains?(message, "Invalid float value") 234 | end 235 | 236 | test "parses boolean true" do 237 | parser = value_parser(:boolean) 238 | assert parse(parser, ["true", "rest"]) == {:ok, true, ["rest"]} 239 | end 240 | 241 | test "parses boolean false" do 242 | parser = value_parser(:boolean) 243 | assert parse(parser, ["false", "rest"]) == {:ok, false, ["rest"]} 244 | end 245 | 246 | test "fails on invalid boolean" do 247 | parser = value_parser(:boolean) 248 | assert {:error, message} = parse(parser, ["maybe", "rest"]) 249 | assert String.contains?(message, "Invalid boolean value") 250 | assert String.contains?(message, "Expected 'true' or 'false'") 251 | end 252 | 253 | test "fails on empty input" do 254 | parser = value_parser(:string) 255 | assert parse(parser, []) == {:error, "Expected string value, got end of input"} 256 | end 257 | end 258 | 259 | describe "quoted_string_parser/0" do 260 | test "removes quotes from quoted string" do 261 | parser = quoted_string_parser() 262 | assert parse(parser, ["\"hello world\"", "rest"]) == {:ok, "hello world", ["rest"]} 263 | end 264 | 265 | test "handles unquoted string" do 266 | parser = quoted_string_parser() 267 | assert parse(parser, ["hello", "world"]) == {:ok, "hello", ["world"]} 268 | end 269 | 270 | test "handles empty quoted string" do 271 | parser = quoted_string_parser() 272 | assert parse(parser, ["\"\"", "rest"]) == {:ok, "", ["rest"]} 273 | end 274 | 275 | test "fails on empty input" do 276 | parser = quoted_string_parser() 277 | assert parse(parser, []) == {:error, "Expected string, got end of input"} 278 | end 279 | end 280 | 281 | describe "rest_parser/0" do 282 | test "consumes all remaining input" do 283 | parser = rest_parser() 284 | assert parse(parser, ["file1", "file2", "file3"]) == {:ok, ["file1", "file2", "file3"], []} 285 | end 286 | 287 | test "works with empty input" do 288 | parser = rest_parser() 289 | assert parse(parser, []) == {:ok, [], []} 290 | end 291 | end 292 | 293 | describe "parse_typed_value/2" do 294 | test "parses string values" do 295 | assert parse_typed_value("hello", :string) == {:ok, "hello"} 296 | end 297 | 298 | test "parses integer values" do 299 | assert parse_typed_value("42", :integer) == {:ok, 42} 300 | assert parse_typed_value("-42", :integer) == {:ok, -42} 301 | assert parse_typed_value("0", :integer) == {:ok, 0} 302 | end 303 | 304 | test "fails on invalid integer" do 305 | assert {:error, message} = parse_typed_value("abc", :integer) 306 | assert String.contains?(message, "Invalid integer value") 307 | end 308 | 309 | test "parses float values" do 310 | assert parse_typed_value("3.14", :float) == {:ok, 3.14} 311 | assert parse_typed_value("-3.14", :float) == {:ok, -3.14} 312 | assert parse_typed_value("0.0", :float) == {:ok, 0.0} 313 | end 314 | 315 | test "fails on invalid float" do 316 | assert {:error, message} = parse_typed_value("abc", :float) 317 | assert String.contains?(message, "Invalid float value") 318 | end 319 | 320 | test "parses boolean values" do 321 | assert parse_typed_value("true", :boolean) == {:ok, true} 322 | assert parse_typed_value("false", :boolean) == {:ok, false} 323 | end 324 | 325 | test "fails on invalid boolean" do 326 | assert {:error, message} = parse_typed_value("maybe", :boolean) 327 | assert String.contains?(message, "Invalid boolean value") 328 | end 329 | end 330 | 331 | describe "unquote_string/1" do 332 | test "removes surrounding quotes" do 333 | assert unquote_string("\"hello world\"") == "hello world" 334 | end 335 | 336 | test "handles string without quotes" do 337 | assert unquote_string("hello") == "hello" 338 | end 339 | 340 | test "handles empty quoted string" do 341 | assert unquote_string("\"\"") == "" 342 | end 343 | 344 | test "handles string with internal quotes" do 345 | assert unquote_string(~s("say \\"hello\\"")) == "say \\\"hello\\\"" 346 | end 347 | end 348 | 349 | describe "map/2" do 350 | test "transforms parser result" do 351 | parser = "hello" |> literal() |> map(&String.upcase/1) 352 | assert parse(parser, ["hello", "world"]) == {:ok, "HELLO", ["world"]} 353 | end 354 | 355 | test "preserves error" do 356 | parser = "hello" |> literal() |> map(&String.upcase/1) 357 | assert {:error, _} = parse(parser, ["goodbye", "world"]) 358 | end 359 | end 360 | 361 | describe "tag/2" do 362 | test "tags successful result" do 363 | parser = "commit" |> literal() |> tag(:command) 364 | assert parse(parser, ["commit", "message"]) == {:ok, {:command, "commit"}, ["message"]} 365 | end 366 | 367 | test "preserves error" do 368 | parser = "commit" |> literal() |> tag(:command) 369 | assert {:error, _} = parse(parser, ["push", "message"]) 370 | end 371 | end 372 | 373 | describe "ignore/1" do 374 | test "ignores parser result" do 375 | parser = ignore(literal("--")) 376 | assert parse(parser, ["--", "args"]) == {:ok, nil, ["args"]} 377 | end 378 | 379 | test "preserves error" do 380 | parser = ignore(literal("--")) 381 | assert {:error, _} = parse(parser, ["args"]) 382 | end 383 | end 384 | 385 | describe "separated_by/2" do 386 | test "parses single element" do 387 | parser = separated_by(literal("file"), literal(",")) 388 | assert parse(parser, ["file"]) == {:ok, ["file"], []} 389 | end 390 | 391 | test "parses multiple elements" do 392 | parser = separated_by(literal("file"), literal(",")) 393 | assert parse(parser, ["file", ",", "file", ",", "file"]) == {:ok, ["file", "file", "file"], []} 394 | end 395 | 396 | test "handles trailing elements" do 397 | parser = separated_by(literal("file"), literal(",")) 398 | assert parse(parser, ["file", ",", "file", "done"]) == {:ok, ["file", "file"], ["done"]} 399 | end 400 | 401 | test "fails if first element doesn't match" do 402 | parser = separated_by(literal("file"), literal(",")) 403 | assert {:error, _} = parse(parser, ["dir", ",", "file"]) 404 | end 405 | end 406 | 407 | describe "complex parser combinations" do 408 | test "parses git commit command with flags" do 409 | parser = 410 | sequence([ 411 | literal("git"), 412 | command_parser(["commit", "push", "pull"]), 413 | many(flag_parser()) 414 | ]) 415 | 416 | input = ["git", "commit", "--message", "--verbose"] 417 | 418 | expected_flags = [ 419 | {:flag, :long, "message", true}, 420 | {:flag, :long, "verbose", true} 421 | ] 422 | 423 | assert parse(parser, input) == {:ok, ["git", "commit", expected_flags], []} 424 | end 425 | 426 | test "parses file operations with arguments" do 427 | parser = 428 | sequence([ 429 | literal("file"), 430 | command_parser(["copy", "move", "delete"]), 431 | many(quoted_string_parser()) 432 | ]) 433 | 434 | input = ["file", "copy", "\"source file.txt\"", "\"dest file.txt\""] 435 | expected_args = ["source file.txt", "dest file.txt"] 436 | 437 | assert parse(parser, input) == {:ok, ["file", "copy", expected_args], []} 438 | end 439 | 440 | test "parses optional flags with required arguments" do 441 | parser = 442 | sequence([ 443 | literal("deploy"), 444 | optional(flag_parser()), 445 | many1(quoted_string_parser()) 446 | ]) 447 | 448 | # With flag 449 | input1 = ["deploy", "--force", "app.jar"] 450 | assert parse(parser, input1) == {:ok, ["deploy", {:flag, :long, "force", true}, ["app.jar"]], []} 451 | 452 | # Without flag 453 | input2 = ["deploy", "app.jar", "config.yml"] 454 | assert parse(parser, input2) == {:ok, ["deploy", nil, ["app.jar", "config.yml"]], []} 455 | end 456 | 457 | test "handles choice between different command structures" do 458 | git_parser = sequence([literal("git"), command_parser(["commit", "push"])]) 459 | npm_parser = sequence([literal("npm"), command_parser(["install", "test"])]) 460 | 461 | parser = choice([git_parser, npm_parser]) 462 | 463 | assert parse(parser, ["git", "commit"]) == {:ok, ["git", "commit"], []} 464 | assert parse(parser, ["npm", "install"]) == {:ok, ["npm", "install"], []} 465 | assert {:error, _} = parse(parser, ["make", "build"]) 466 | end 467 | end 468 | end 469 | -------------------------------------------------------------------------------- /lib/nexus/parser/dsl.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.Parser.DSL do 2 | @moduledoc """ 3 | A parsing combinator DSL for command-line input parsing in pure Elixir. 4 | 5 | This module provides functional parsing combinators that can be composed 6 | to build complex parsers from simple building blocks. The combinators 7 | follow functional programming principles and are designed to be both 8 | efficient and easy to reason about. 9 | 10 | ## Basic Combinators 11 | 12 | - `literal/1` - Matches exact string literals 13 | - `choice/1` - Tries multiple parsers, returning the first successful one 14 | - `many/1` - Applies a parser zero or more times 15 | - `sequence/1` - Applies a list of parsers in order 16 | - `optional/1` - Makes a parser optional (succeeds with nil if parser fails) 17 | 18 | ## High-Level Combinators 19 | 20 | - `flag_parser/1` - Parses command-line flags (--flag, -f) 21 | - `command_parser/1` - Parses command names and subcommands 22 | - `value_parser/1` - Parses typed values (strings, integers, floats, etc.) 23 | - `quoted_string_parser/0` - Handles quoted strings with escape sequences 24 | 25 | ## Usage 26 | 27 | iex> import Nexus.Parser.DSL 28 | iex> parser = sequence([literal("git"), literal("commit")]) 29 | iex> parse(parser, ["git", "commit", "--message", "hello"]) 30 | {:ok, ["git", "commit"], ["--message", "hello"]} 31 | 32 | ## Parser Result Format 33 | 34 | All parsers return a tuple in the format: 35 | - `{:ok, result, remaining_input}` on success 36 | - `{:error, reason}` on failure 37 | 38 | Where: 39 | - `result` is the parsed value 40 | - `remaining_input` is the unconsumed input tokens 41 | - `reason` is a descriptive error message 42 | """ 43 | 44 | @typedoc "Input tokens to be parsed" 45 | @type input :: [String.t()] 46 | 47 | @typedoc "Parsed result value" 48 | @type result :: term() 49 | 50 | @typedoc "Parser success result" 51 | @type success :: {:ok, result(), input()} 52 | 53 | @typedoc "Parser error result" 54 | @type error :: {:error, String.t()} 55 | 56 | @typedoc "Parser result" 57 | @type parser_result :: success() | error() 58 | 59 | @typedoc "A parser function" 60 | @type parser :: (input() -> parser_result()) 61 | 62 | @typedoc "Flag type specification" 63 | @type flag_type :: :boolean | :string | :integer | :float 64 | 65 | @doc """ 66 | Executes a parser against the given input. 67 | 68 | ## Examples 69 | 70 | iex> import Nexus.Parser.DSL 71 | iex> parser = literal("hello") 72 | iex> parse(parser, ["hello", "world"]) 73 | {:ok, "hello", ["world"]} 74 | 75 | iex> import Nexus.Parser.DSL 76 | iex> parser = literal("goodbye") 77 | iex> parse(parser, ["hello", "world"]) 78 | {:error, "Expected 'goodbye', got 'hello'"} 79 | """ 80 | @spec parse(parser(), input()) :: parser_result() 81 | def parse(parser, input) when is_function(parser, 1) and is_list(input) do 82 | parser.(input) 83 | end 84 | 85 | # ============================================================================= 86 | # Basic Combinators 87 | # ============================================================================= 88 | 89 | @doc """ 90 | Creates a parser that matches an exact string literal. 91 | 92 | ## Examples 93 | 94 | iex> import Nexus.Parser.DSL 95 | iex> parser = literal("commit") 96 | iex> parse(parser, ["commit", "message"]) 97 | {:ok, "commit", ["message"]} 98 | 99 | iex> import Nexus.Parser.DSL 100 | iex> parser = literal("push") 101 | iex> parse(parser, ["commit", "message"]) 102 | {:error, "Expected 'push', got 'commit'"} 103 | """ 104 | @spec literal(String.t()) :: parser() 105 | def literal(expected) when is_binary(expected) do 106 | fn 107 | [^expected | rest] -> {:ok, expected, rest} 108 | [actual | _] -> {:error, "Expected '#{expected}', got '#{actual}'"} 109 | [] -> {:error, "Expected '#{expected}', got end of input"} 110 | end 111 | end 112 | 113 | @doc """ 114 | Creates a parser that tries multiple parsers in order, returning the first success. 115 | 116 | ## Examples 117 | 118 | iex> import Nexus.Parser.DSL 119 | iex> parser = choice([literal("git"), literal("svn"), literal("hg")]) 120 | iex> parse(parser, ["git", "status"]) 121 | {:ok, "git", ["status"]} 122 | 123 | iex> import Nexus.Parser.DSL 124 | iex> parser = choice([literal("add"), literal("commit")]) 125 | iex> parse(parser, ["push", "origin"]) 126 | {:error, "No choice matched: Expected 'add', got 'push', Expected 'commit', got 'push'"} 127 | """ 128 | @spec choice([parser()]) :: parser() 129 | def choice(parsers) when is_list(parsers) do 130 | fn input -> 131 | choice_impl(parsers, input, []) 132 | end 133 | end 134 | 135 | defp choice_impl([], _input, errors) do 136 | {:error, "No choice matched: #{Enum.join(Enum.reverse(errors), ", ")}"} 137 | end 138 | 139 | defp choice_impl([parser | rest], input, errors) do 140 | case parse(parser, input) do 141 | {:ok, result, remaining} -> {:ok, result, remaining} 142 | {:error, reason} -> choice_impl(rest, input, [reason | errors]) 143 | end 144 | end 145 | 146 | @doc """ 147 | Creates a parser that applies another parser zero or more times. 148 | 149 | ## Examples 150 | 151 | iex> import Nexus.Parser.DSL 152 | iex> parser = many(literal("very")) 153 | iex> parse(parser, ["very", "very", "good"]) 154 | {:ok, ["very", "very"], ["good"]} 155 | 156 | iex> import Nexus.Parser.DSL 157 | iex> parser = many(literal("not")) 158 | iex> parse(parser, ["good", "day"]) 159 | {:ok, [], ["good", "day"]} 160 | """ 161 | @spec many(parser()) :: parser() 162 | def many(parser) when is_function(parser, 1) do 163 | fn input -> 164 | many_impl(parser, input, []) 165 | end 166 | end 167 | 168 | defp many_impl(parser, input, acc) do 169 | case parse(parser, input) do 170 | {:ok, result, remaining} -> many_impl(parser, remaining, [result | acc]) 171 | {:error, _} -> {:ok, Enum.reverse(acc), input} 172 | end 173 | end 174 | 175 | @doc """ 176 | Creates a parser that applies parsers in sequence. 177 | 178 | ## Examples 179 | 180 | iex> import Nexus.Parser.DSL 181 | iex> parser = sequence([literal("git"), literal("commit")]) 182 | iex> parse(parser, ["git", "commit", "--all"]) 183 | {:ok, ["git", "commit"], ["--all"]} 184 | """ 185 | @spec sequence([parser()]) :: parser() 186 | def sequence(parsers) when is_list(parsers) do 187 | fn input -> 188 | sequence_impl(parsers, input, []) 189 | end 190 | end 191 | 192 | defp sequence_impl([], input, acc) do 193 | {:ok, Enum.reverse(acc), input} 194 | end 195 | 196 | defp sequence_impl([parser | rest], input, acc) do 197 | case parse(parser, input) do 198 | {:ok, result, remaining} -> sequence_impl(rest, remaining, [result | acc]) 199 | {:error, reason} -> {:error, reason} 200 | end 201 | end 202 | 203 | @doc """ 204 | Creates a parser that makes another parser optional. 205 | 206 | ## Examples 207 | 208 | iex> import Nexus.Parser.DSL 209 | iex> parser = optional(literal("--verbose")) 210 | iex> parse(parser, ["--verbose", "file.txt"]) 211 | {:ok, "--verbose", ["file.txt"]} 212 | 213 | iex> import Nexus.Parser.DSL 214 | iex> parser = optional(literal("--verbose")) 215 | iex> parse(parser, ["file.txt"]) 216 | {:ok, nil, ["file.txt"]} 217 | """ 218 | @spec optional(parser()) :: parser() 219 | def optional(parser) when is_function(parser, 1) do 220 | fn input -> 221 | case parse(parser, input) do 222 | {:ok, result, remaining} -> {:ok, result, remaining} 223 | {:error, _} -> {:ok, nil, input} 224 | end 225 | end 226 | end 227 | 228 | @doc """ 229 | Creates a parser that applies another parser one or more times. 230 | 231 | ## Examples 232 | 233 | iex> import Nexus.Parser.DSL 234 | iex> parser = many1(literal("file")) 235 | iex> parse(parser, ["file", "file", "done"]) 236 | {:ok, ["file", "file"], ["done"]} 237 | 238 | iex> import Nexus.Parser.DSL 239 | iex> parser = many1(literal("file")) 240 | iex> parse(parser, ["done"]) 241 | {:error, "Expected at least one match"} 242 | """ 243 | @spec many1(parser()) :: parser() 244 | def many1(parser) when is_function(parser, 1) do 245 | fn input -> 246 | case parse(many(parser), input) do 247 | {:ok, [], _} -> {:error, "Expected at least one match"} 248 | result -> result 249 | end 250 | end 251 | end 252 | 253 | # ============================================================================= 254 | # High-Level Combinators 255 | # ============================================================================= 256 | 257 | @doc """ 258 | Creates a parser for command-line flags. 259 | 260 | Handles both long flags (--flag) and short flags (-f), with optional values. 261 | 262 | ## Examples 263 | 264 | iex> import Nexus.Parser.DSL 265 | iex> parser = flag_parser() 266 | iex> parse(parser, ["--verbose"]) 267 | {:ok, {:flag, :long, "verbose", true}, []} 268 | 269 | iex> import Nexus.Parser.DSL 270 | iex> parser = flag_parser() 271 | iex> parse(parser, ["--output=file.txt"]) 272 | {:ok, {:flag, :long, "output", "file.txt"}, []} 273 | 274 | iex> import Nexus.Parser.DSL 275 | iex> parser = flag_parser() 276 | iex> parse(parser, ["-v"]) 277 | {:ok, {:flag, :short, "v", true}, []} 278 | """ 279 | @spec flag_parser() :: parser() 280 | def flag_parser do 281 | choice([long_flag_parser(), short_flag_parser()]) 282 | end 283 | 284 | @doc """ 285 | Creates a parser for long flags (--flag or --flag=value). 286 | """ 287 | @spec long_flag_parser() :: parser() 288 | def long_flag_parser do 289 | fn 290 | ["--" <> flag_text | rest] -> 291 | case String.split(flag_text, "=", parts: 2) do 292 | [flag_name] -> {:ok, {:flag, :long, flag_name, true}, rest} 293 | [flag_name, value] -> {:ok, {:flag, :long, flag_name, value}, rest} 294 | end 295 | 296 | [other | _] -> 297 | {:error, "Expected long flag (--flag), got '#{other}'"} 298 | 299 | [] -> 300 | {:error, "Expected long flag (--flag), got end of input"} 301 | end 302 | end 303 | 304 | @doc """ 305 | Creates a parser for short flags (-f or -f=value). 306 | """ 307 | @spec short_flag_parser() :: parser() 308 | def short_flag_parser do 309 | fn 310 | ["-" <> flag_text | rest] when flag_text != "" -> 311 | parse_short_flag_text(flag_text, rest) 312 | 313 | [other | _] -> 314 | {:error, "Expected short flag (-f), got '#{other}'"} 315 | 316 | [] -> 317 | {:error, "Expected short flag (-f), got end of input"} 318 | end 319 | end 320 | 321 | defp parse_short_flag_text(flag_text, rest) do 322 | if String.starts_with?(flag_text, "-") do 323 | {:error, "Expected short flag (-f), got '--#{String.trim_leading(flag_text, "-")}'"} 324 | else 325 | case String.split(flag_text, "=", parts: 2) do 326 | [flag_name] -> {:ok, {:flag, :short, flag_name, true}, rest} 327 | [flag_name, value] -> {:ok, {:flag, :short, flag_name, value}, rest} 328 | end 329 | end 330 | end 331 | 332 | @doc """ 333 | Creates a parser for command names. 334 | 335 | ## Examples 336 | 337 | iex> import Nexus.Parser.DSL 338 | iex> parser = command_parser(["commit", "push", "pull"]) 339 | iex> parse(parser, ["commit", "--message"]) 340 | {:ok, "commit", ["--message"]} 341 | 342 | iex> import Nexus.Parser.DSL 343 | iex> parser = command_parser(["add", "remove"]) 344 | iex> parse(parser, ["commit", "--message"]) 345 | {:error, "Expected one of [add, remove], got 'commit'"} 346 | """ 347 | @spec command_parser([String.t()]) :: parser() 348 | def command_parser(valid_commands) when is_list(valid_commands) do 349 | fn 350 | [command | rest] -> 351 | if command in valid_commands do 352 | {:ok, command, rest} 353 | else 354 | {:error, "Expected one of [#{Enum.join(valid_commands, ", ")}], got '#{command}'"} 355 | end 356 | 357 | [] -> 358 | {:error, "Expected command, got end of input"} 359 | end 360 | end 361 | 362 | @doc """ 363 | Creates a parser for typed values. 364 | 365 | ## Examples 366 | 367 | iex> import Nexus.Parser.DSL 368 | iex> parser = value_parser(:integer) 369 | iex> parse(parser, ["42", "rest"]) 370 | {:ok, 42, ["rest"]} 371 | 372 | iex> import Nexus.Parser.DSL 373 | iex> parser = value_parser(:string) 374 | iex> parse(parser, ["hello", "world"]) 375 | {:ok, "hello", ["world"]} 376 | """ 377 | @spec value_parser(flag_type()) :: parser() 378 | def value_parser(type) do 379 | fn 380 | [value | rest] -> 381 | case parse_typed_value(value, type) do 382 | {:ok, parsed_value} -> {:ok, parsed_value, rest} 383 | {:error, reason} -> {:error, reason} 384 | end 385 | 386 | [] -> 387 | {:error, "Expected #{type} value, got end of input"} 388 | end 389 | end 390 | 391 | @doc """ 392 | Creates a parser for quoted strings with escape sequence handling. 393 | 394 | ## Examples 395 | 396 | iex> import Nexus.Parser.DSL 397 | iex> parser = quoted_string_parser() 398 | iex> parse(parser, ["\\"hello world\\"", "rest"]) 399 | {:ok, "hello world", ["rest"]} 400 | 401 | iex> import Nexus.Parser.DSL 402 | iex> parser = quoted_string_parser() 403 | iex> parse(parser, ["unquoted", "rest"]) 404 | {:ok, "unquoted", ["rest"]} 405 | """ 406 | @spec quoted_string_parser() :: parser() 407 | def quoted_string_parser do 408 | fn 409 | [string | rest] -> 410 | {:ok, unquote_string(string), rest} 411 | 412 | [] -> 413 | {:error, "Expected string, got end of input"} 414 | end 415 | end 416 | 417 | @doc """ 418 | Creates a parser that consumes remaining input as a list. 419 | 420 | ## Examples 421 | 422 | iex> import Nexus.Parser.DSL 423 | iex> parser = rest_parser() 424 | iex> parse(parser, ["file1", "file2", "file3"]) 425 | {:ok, ["file1", "file2", "file3"], []} 426 | """ 427 | @spec rest_parser() :: parser() 428 | def rest_parser do 429 | fn input -> 430 | {:ok, input, []} 431 | end 432 | end 433 | 434 | # ============================================================================= 435 | # Utility Functions 436 | # ============================================================================= 437 | 438 | @doc """ 439 | Parses a string value into the specified type. 440 | 441 | ## Examples 442 | 443 | iex> Nexus.Parser.DSL.parse_typed_value("42", :integer) 444 | {:ok, 42} 445 | 446 | iex> Nexus.Parser.DSL.parse_typed_value("3.14", :float) 447 | {:ok, 3.14} 448 | 449 | iex> Nexus.Parser.DSL.parse_typed_value("true", :boolean) 450 | {:ok, true} 451 | """ 452 | @spec parse_typed_value(String.t(), flag_type()) :: {:ok, term()} | {:error, String.t()} 453 | def parse_typed_value(value, :string), do: {:ok, value} 454 | 455 | def parse_typed_value(value, :boolean) when value in ["true", "false"] do 456 | {:ok, value == "true"} 457 | end 458 | 459 | def parse_typed_value(value, :boolean) do 460 | {:error, "Invalid boolean value: #{value}. Expected 'true' or 'false'"} 461 | end 462 | 463 | def parse_typed_value(value, :integer) do 464 | case Integer.parse(value) do 465 | {int, ""} -> {:ok, int} 466 | _ -> {:error, "Invalid integer value: #{value}"} 467 | end 468 | end 469 | 470 | def parse_typed_value(value, :float) do 471 | case Float.parse(value) do 472 | {float, ""} -> {:ok, float} 473 | _ -> {:error, "Invalid float value: #{value}"} 474 | end 475 | end 476 | 477 | @doc """ 478 | Removes quotes from a string if present. 479 | 480 | ## Examples 481 | 482 | iex> Nexus.Parser.DSL.unquote_string("\\"hello world\\"") 483 | "hello world" 484 | 485 | iex> Nexus.Parser.DSL.unquote_string("hello") 486 | "hello" 487 | """ 488 | @spec unquote_string(String.t()) :: String.t() 489 | def unquote_string("\"" <> rest) do 490 | if String.ends_with?(rest, "\"") do 491 | String.slice(rest, 0..-2//1) 492 | else 493 | rest 494 | end 495 | end 496 | 497 | def unquote_string(string), do: string 498 | 499 | # ============================================================================= 500 | # Combinator Utilities 501 | # ============================================================================= 502 | 503 | @doc """ 504 | Creates a parser that applies a transformation function to the result. 505 | 506 | ## Examples 507 | 508 | iex> import Nexus.Parser.DSL 509 | iex> parser = literal("hello") |> map(&String.upcase/1) 510 | iex> parse(parser, ["hello", "world"]) 511 | {:ok, "HELLO", ["world"]} 512 | """ 513 | @spec map(parser(), (term() -> term())) :: parser() 514 | def map(parser, transform_fn) when is_function(parser, 1) and is_function(transform_fn, 1) do 515 | fn input -> 516 | case parse(parser, input) do 517 | {:ok, result, remaining} -> {:ok, transform_fn.(result), remaining} 518 | error -> error 519 | end 520 | end 521 | end 522 | 523 | @doc """ 524 | Creates a parser that tags the result with a label. 525 | 526 | ## Examples 527 | 528 | iex> import Nexus.Parser.DSL 529 | iex> parser = literal("commit") |> tag(:command) 530 | iex> parse(parser, ["commit", "message"]) 531 | {:ok, {:command, "commit"}, ["message"]} 532 | """ 533 | @spec tag(parser(), atom()) :: parser() 534 | def tag(parser, label) when is_function(parser, 1) and is_atom(label) do 535 | map(parser, &{label, &1}) 536 | end 537 | 538 | @doc """ 539 | Creates a parser that ignores the result of another parser. 540 | 541 | ## Examples 542 | 543 | iex> import Nexus.Parser.DSL 544 | iex> parser = ignore(literal("--")) 545 | iex> parse(parser, ["--", "args"]) 546 | {:ok, nil, ["args"]} 547 | """ 548 | @spec ignore(parser()) :: parser() 549 | def ignore(parser) when is_function(parser, 1) do 550 | map(parser, fn _ -> nil end) 551 | end 552 | 553 | @doc """ 554 | Creates a parser that applies multiple parsers separated by a separator. 555 | 556 | ## Examples 557 | 558 | iex> import Nexus.Parser.DSL 559 | iex> parser = separated_by(literal("file"), literal(",")) 560 | iex> parse(parser, ["file", ",", "file", ",", "file"]) 561 | {:ok, ["file", "file", "file"], []} 562 | """ 563 | @spec separated_by(parser(), parser()) :: parser() 564 | def separated_by(element_parser, separator_parser) do 565 | fn input -> 566 | case parse(element_parser, input) do 567 | {:ok, first, remaining} -> 568 | parse_remaining_elements(element_parser, separator_parser, first, remaining) 569 | 570 | error -> 571 | error 572 | end 573 | end 574 | end 575 | 576 | defp parse_remaining_elements(element_parser, separator_parser, first, remaining) do 577 | case parse(many(sequence([separator_parser, element_parser])), remaining) do 578 | {:ok, rest_pairs, final_remaining} -> 579 | rest_elements = Enum.map(rest_pairs, fn [_, element] -> element end) 580 | {:ok, [first | rest_elements], final_remaining} 581 | 582 | {:error, reason} -> 583 | {:error, reason} 584 | end 585 | end 586 | end 587 | -------------------------------------------------------------------------------- /lib/nexus/cli.ex: -------------------------------------------------------------------------------- 1 | defmodule Nexus.CLI do 2 | @moduledoc """ 3 | Nexus.CLI provides a macro-based DSL for defining command-line interfaces with commands, 4 | flags, and positional arguments using structured ASTs with structs. 5 | 6 | ## Overview 7 | 8 | The `Nexus.CLI` module allows you to build robust command-line applications by defining commands, subcommands, flags, and arguments using a declarative syntax. It handles parsing, validation, and dispatching of commands, so you can focus on implementing your application's logic. 9 | 10 | ## Command Life Cycle 11 | 12 | 1. **Definition**: Use the provided macros (`defcommand`, `subcommand`, `flag`, `value`, etc.) to define your CLI's structure in a clear and organized way. 13 | 2. **Compilation**: During compilation, Nexus processes your definitions, builds an abstract syntax tree (AST), and validates your commands and flags. 14 | 3. **Parsing**: When your application runs, Nexus parses the user input (e.g., command-line arguments) against the defined AST, handling flags, arguments, and subcommands. 15 | 4. **Dispatching**: After successful parsing, Nexus dispatches the command to your `handle_input/2` callback, passing the parsed input. 16 | 5. **Execution**: You implement the `handle_input/2` function to perform the desired actions based on the command and input. 17 | 18 | ## The `handle_input/2` Callback 19 | 20 | The `handle_input/2` function is the core of your command's execution logic. It receives the command path and an `Nexus.CLI.Input` struct containing parsed flags and arguments. 21 | 22 | ### Signature 23 | 24 | @callback handle_input(cmd :: atom | list(atom), input :: Input.t()) :: :ok | {:error, error} 25 | 26 | - `cmd`: The command or command path (list of atoms) representing the executed command or a single atom if no subcommand is provided. 27 | - `input`: An `%Nexus.CLI.Input{}` struct containing `flags`, `args`, and `value`. 28 | 29 | ### Return Values 30 | 31 | - `:ok`: Indicates successful execution. The application will exit with a success code (`0`). 32 | - `{:error, {code :: integer, reason :: String.t()}}`: Indicates an error occurred. The application will exit with the provided error code. 33 | 34 | ## Running the CLI Application 35 | 36 | You can run your CLI application using different methods: 37 | 38 | ### Using `mix run` 39 | 40 | If you're developing and testing your CLI, you can run it directly with `mix run`: 41 | 42 | mix run -e 'MyCLI.execute("file copy source.txt dest.txt --verbose")' 43 | 44 | ### Compiling with Escript 45 | 46 | Escript allows you to compile your application into a single executable script. 47 | 48 | **Steps:** 49 | 50 | 1. **Add Escript Configuration**: In your `mix.exs`, add the `:escript` configuration: 51 | 52 | ```elixir 53 | def project do 54 | [ 55 | app: :my_cli, 56 | version: "0.1.0", 57 | elixir: "~> 1.12", 58 | escript: [main_module: MyCLI], 59 | deps: deps() 60 | ] 61 | end 62 | ``` 63 | 64 | 2. **Build the Escript**: 65 | 66 | ```sh 67 | mix escript.build 68 | ``` 69 | 70 | 3. **Run the Executable**: 71 | 72 | ```sh 73 | ./my_cli file copy --verbose source.txt dest.txt 74 | ``` 75 | 76 | > Note that in order to use and distribute escript binaries, the host needs to have Erlang runtime available on $PATH 77 | 78 | ### Compiling with Burrito 79 | 80 | [Burrito](https://github.com/burrito-elixir/burrito) allows you to compile your application into a standalone binary. 81 | 82 | **Steps:** 83 | 84 | 1. **Add Burrito Dependency**: Add Burrito to your `mix.exs`: 85 | 86 | ```elixir 87 | defp deps do 88 | [ 89 | {:burrito, github: "burrito-elixir/burrito"} 90 | ] 91 | end 92 | ``` 93 | 94 | 2. **Configure Releases**: Update your `mix.exs` with Burrito release configuration: 95 | 96 | ```elixir 97 | def project do 98 | [ 99 | app: :my_cli, 100 | version: "0.1.0", 101 | elixir: "~> 1.12", 102 | releases: releases() 103 | ] 104 | end 105 | 106 | def releases do 107 | [ 108 | my_cli: [ 109 | steps: [:assemble, &Burrito.wrap/1], 110 | burrito: [ 111 | targets: [ 112 | macos: [os: :darwin, cpu: :x86_64], 113 | linux: [os: :linux, cpu: :x86_64], 114 | windows: [os: :windows, cpu: :x86_64] 115 | ] 116 | ] 117 | ] 118 | ] 119 | end 120 | ``` 121 | 122 | 3. **Build the Release**: 123 | 124 | ```sh 125 | MIX_ENV=prod mix release 126 | ``` 127 | 128 | 4. **Run the Binary**: 129 | 130 | ```sh 131 | ./burrito_out/my_cli_macos file copy --verbose source.txt dest.txt 132 | ``` 133 | 134 | ### Using Mix Tasks 135 | 136 | You can also run your CLI as a Mix task. 137 | 138 | **Steps:** 139 | 140 | 1. **Create a Mix Task Module**: 141 | 142 | ```elixir 143 | defmodule Mix.Tasks.MyCli do 144 | use Mix.Task 145 | 146 | @shortdoc "Runs the MyCLI application" 147 | 148 | def run(args) do 149 | MyCLI.execute(args) 150 | end 151 | end 152 | ``` 153 | 154 | 2. **Run the Task**: 155 | 156 | ```sh 157 | mix my_cli file copy --verbose source.txt dest.txt 158 | ``` 159 | 160 | ## Additional Information 161 | 162 | - **Version and Description**: By default, `version/0` and `description/0` callbacks fetch information from `mix.exs` and `@moduledoc`, respectively. You can override them if needed. 163 | - **Error Handling**: Use the `{:error, {code, reason}}` tuple to return errors from `handle_input/2`. The application will exit with the specified code, and the reason will be printed. 164 | """ 165 | 166 | alias Nexus.CLI.Argument 167 | alias Nexus.CLI.Command 168 | alias Nexus.CLI.Dispatcher 169 | alias Nexus.CLI.Flag 170 | alias Nexus.CLI.Help 171 | alias Nexus.CLI.Input 172 | alias Nexus.CLI.Validation, as: V 173 | alias Nexus.CLI.Validation.ValidationError 174 | alias Nexus.Parser 175 | 176 | @typedoc "Represents the CLI spec, basically a list of `Command.t()` spec" 177 | @type ast :: list(Command.t()) 178 | 179 | @typedoc "Represent all possible value types of an command argument or flag value" 180 | @type value :: 181 | :boolean 182 | | :string 183 | | :integer 184 | | :float 185 | | {:list, value} 186 | | {:enum, list(atom | String.t())} 187 | 188 | @typedoc """ 189 | Represents an final-user error while executing a command 190 | 191 | Need to inform the return code of the program and a reason of the error 192 | """ 193 | @type error :: {code :: integer, reason :: String.Chars.t()} 194 | 195 | @doc """ 196 | Sets the version of the CLI 197 | 198 | Default implementation fetches from the `mix.exs` 199 | """ 200 | @callback version :: String.t() 201 | 202 | @doc """ 203 | Sets the CLI description 204 | 205 | Default implementation fetches from `@moduledoc`, however 206 | take in account that if you're compiling your app as an escript 207 | or single binary (rg. burrito) the `@moduledoc` attribute may be 208 | not available on runtime 209 | 210 | Fetch module documentation on compile-time is marked to Elixir 2.0 211 | check https://github.com/elixir-lang/elixir/issues/8095 212 | """ 213 | @callback description :: String.t() 214 | 215 | @doc """ 216 | Custom banners can be set 217 | """ 218 | @callback banner :: String.t() 219 | 220 | @doc """ 221 | Function that receives the current command being used and its args 222 | 223 | If a subcommand is being used, then the first argument will be a list 224 | of atoms representing the command path 225 | 226 | Note that when returning `:ok` from this function, your program will 227 | exit with a success code, generally `0` 228 | 229 | To inform errors, check the `Nexus.CLI.error()` type 230 | 231 | ## Examples 232 | 233 | @impl Nexus.CLI 234 | def handle_input(:my_cmd, _), do: nil 235 | 236 | def handle_inpu([:my, :nested, :cmd], _), do: nil 237 | """ 238 | @callback handle_input(cmd :: atom, input :: Input.t()) :: :ok | {:error, error} 239 | @callback handle_input(cmd :: list(atom), input :: Input.t()) :: :ok | {:error, error} 240 | 241 | @optional_callbacks banner: 0 242 | 243 | @type t :: %__MODULE__{ 244 | otp_app: atom, 245 | name: atom, 246 | spec: ast, 247 | version: String.t(), 248 | description: String.t(), 249 | handler: module, 250 | root_flags: list(Flag.t()) 251 | } 252 | 253 | defstruct [:name, :spec, :version, :description, :handler, :otp_app, root_flags: []] 254 | 255 | defmodule Input do 256 | @moduledoc """ 257 | Represents a command input, with args and flags values parsed 258 | 259 | - `flags` is a map with keys as flags names and values as flags values 260 | - `args` is a map of positional arguments where keys are the arguments 261 | names defined with the `:as` option on the `value/2` macro 262 | - `value` is a single value term that represents the command value itself 263 | 264 | If the command define multiple (positional) arguments, `value` will be `nil` 265 | and `args` willbe populated, otherwise `args` will be an empty map and `value` 266 | will be populated 267 | """ 268 | 269 | @type t :: %__MODULE__{flags: %{atom => term}, args: %{atom => term}, value: term | nil} 270 | 271 | defstruct [:flags, args: %{}, value: nil] 272 | end 273 | 274 | defmodule Command do 275 | @moduledoc "Represents a command or subcommand." 276 | 277 | @type t :: %__MODULE__{ 278 | name: atom | nil, 279 | description: String.t() | nil, 280 | subcommands: list(t), 281 | flags: list(Flag.t()), 282 | args: list(Argument.t()) 283 | } 284 | 285 | defstruct name: nil, 286 | description: nil, 287 | subcommands: [], 288 | flags: [], 289 | args: [] 290 | end 291 | 292 | defmodule Flag do 293 | @moduledoc "Represents a flag (option) for a command." 294 | 295 | @type t :: %__MODULE__{ 296 | name: atom | nil, 297 | short: atom | nil, 298 | type: Nexus.CLI.value(), 299 | required: boolean, 300 | default: term, 301 | description: String.t() | nil 302 | } 303 | 304 | defstruct name: nil, 305 | short: nil, 306 | type: :boolean, 307 | required: false, 308 | default: false, 309 | description: nil 310 | end 311 | 312 | defmodule Argument do 313 | @moduledoc "Represents a positional argument for a command." 314 | 315 | @type t :: %__MODULE__{ 316 | name: atom | nil, 317 | type: Nexus.CLI.value(), 318 | required: boolean, 319 | default: term | nil 320 | } 321 | 322 | defstruct name: nil, 323 | type: :string, 324 | required: false, 325 | default: nil 326 | end 327 | 328 | defmacro __using__(opts \\ []) when is_list(opts) do 329 | mod = __CALLER__.module 330 | otp_app = opts[:otp_app] || raise ValidationError, "missing :otp_app option" 331 | name = String.to_atom(opts[:name] || Macro.underscore(mod)) 332 | cli = %__MODULE__{name: name, otp_app: otp_app} 333 | 334 | quote do 335 | @behaviour Nexus.CLI 336 | 337 | import Nexus.CLI, 338 | only: [ 339 | defcommand: 2, 340 | subcommand: 2, 341 | value: 2, 342 | flag: 2, 343 | short: 1, 344 | description: 1 345 | ] 346 | 347 | Module.put_attribute(__MODULE__, :cli, unquote(Macro.escape(cli))) 348 | Module.register_attribute(__MODULE__, :cli_commands, accumulate: true) 349 | Module.register_attribute(__MODULE__, :cli_command_stack, accumulate: false) 350 | Module.register_attribute(__MODULE__, :cli_flag_stack, accumulate: false) 351 | Module.register_attribute(__MODULE__, :cli_root_flags, accumulate: true) 352 | 353 | @before_compile Nexus.CLI 354 | 355 | @impl Nexus.CLI 356 | def version do 357 | vsn = 358 | unquote(otp_app) 359 | |> Application.spec() 360 | |> Keyword.get(:vsn, ~c"") 361 | 362 | for c <- vsn, into: "", do: <> 363 | end 364 | 365 | defoverridable version: 0 366 | end 367 | end 368 | 369 | @doc """ 370 | Defines a top-level command for the CLI application. 371 | 372 | Use this macro to declare a new command along with its subcommands, arguments, and flags. 373 | 374 | ## Parameters 375 | 376 | - `name` - The name of the command (an atom). 377 | - `do: block` - A block containing the command's definitions. 378 | 379 | ## Examples 380 | 381 | defcommand :my_command do 382 | # Define subcommands, flags, and arguments here 383 | end 384 | """ 385 | defmacro defcommand(name, do: block) do 386 | quote do 387 | # Initialize a new Command struct 388 | command = %Command{name: unquote(name)} 389 | 390 | # Push the command onto the command stack 391 | Nexus.CLI.__push_command__(command, __MODULE__) 392 | 393 | # Execute the block to populate subcommands, flags, and args 394 | unquote(block) 395 | 396 | # Finalize the command and accumulate it 397 | Nexus.CLI.__finalize_command__(__MODULE__) 398 | end 399 | end 400 | 401 | @doc """ 402 | Defines a subcommand within the current command. 403 | 404 | Use this macro inside a `defcommand` or another `subcommand` block to define a nested subcommand. 405 | 406 | ## Parameters 407 | 408 | - `name` - The name of the subcommand (an atom). 409 | - `do: block` - A block containing the subcommand's definitions. 410 | 411 | ## Examples 412 | 413 | defcommand :parent_command do 414 | subcommand :child_command do 415 | # Define subcommands, flags, and arguments here 416 | end 417 | end 418 | """ 419 | defmacro subcommand(name, do: block) do 420 | quote do 421 | # Initialize a new Command struct for the subcommand 422 | subcommand = %Command{name: unquote(name)} 423 | 424 | # Push the subcommand onto the command stack 425 | Nexus.CLI.__push_command__(subcommand, __MODULE__) 426 | 427 | # Execute the block to populate subcommands, flags, and args 428 | unquote(block) 429 | 430 | # Finalize the subcommand and attach it to its parent 431 | Nexus.CLI.__finalize_subcommand__(__MODULE__) 432 | end 433 | end 434 | 435 | @doc """ 436 | Defines a positional argument for a command or a flag. 437 | 438 | Use this macro to specify an argument's type and options within a command, subcommand, or flag block. 439 | 440 | ## Parameters 441 | 442 | - `type` - The type of the argument (e.g., `:string`, `:integer`). Check `Nexus.CLI.value()` type 443 | - `opts` - A keyword list of options (optional). 444 | 445 | ## Options 446 | 447 | - `:required` - Indicates if the argument is required (boolean). 448 | - `:as` - The name of the argument (atom), required if multiple values are defined 449 | otherwise the name will be the same of the command that it defined 450 | - `:default` - Defines the default value if the argument is not provided 451 | 452 | ## Examples 453 | 454 | defcommand :my_command do 455 | value :string, required: true, as: :filename 456 | end 457 | 458 | flag :output do 459 | value :string, required: true 460 | end 461 | """ 462 | defmacro value(type, opts \\ []) do 463 | quote do 464 | flag_stack = Module.get_attribute(__MODULE__, :cli_flag_stack) 465 | 466 | if not is_nil(flag_stack) and not Enum.empty?(flag_stack) do 467 | # we're inside a flag 468 | Nexus.CLI.__set_flag_value__(unquote(type), unquote(opts), __MODULE__) 469 | else 470 | # we're inside cmd/subcmd 471 | Nexus.CLI.__set_command_value__(unquote(type), unquote(opts), __MODULE__) 472 | end 473 | end 474 | end 475 | 476 | @doc """ 477 | Defines a flag (option) for a command or subcommand. 478 | 479 | Use this macro within a command or subcommand block to declare a new flag and its properties. 480 | 481 | Flags can have arguments too, therefore you can safely use the `value/2` macro inside of it. 482 | 483 | ## Parameters 484 | 485 | - `name` - The name of the flag (an atom). 486 | - `do: block` - A block containing the flag's definitions. 487 | 488 | ## Examples 489 | 490 | defcommand :my_command do 491 | flag :verbose do 492 | short :v 493 | description "Enables verbose mode." 494 | end 495 | end 496 | """ 497 | defmacro flag(name, do: block) do 498 | quote do 499 | # Initialize a new Flag struct 500 | flag = %Flag{name: unquote(name)} 501 | 502 | # Push the flag onto the flag stack 503 | Nexus.CLI.__push_flag__(flag, __MODULE__) 504 | 505 | # Execute the block to set flag properties 506 | unquote(block) 507 | 508 | # Finalize the flag and add it to its parent 509 | Nexus.CLI.__finalize_flag__(__MODULE__) 510 | end 511 | end 512 | 513 | @doc """ 514 | Defines a short alias for a flag. 515 | 516 | Use this macro within a `flag` block to assign a short (single-letter) alias to a flag. 517 | 518 | ## Parameters 519 | 520 | - `short_name` - The short alias for the flag (an atom). 521 | 522 | ## Examples 523 | 524 | flag :verbose do 525 | short :v 526 | description "Enables verbose mode." 527 | end 528 | """ 529 | defmacro short(short_name) do 530 | quote do 531 | Nexus.CLI.__set_flag_short__(unquote(short_name), __MODULE__) 532 | end 533 | end 534 | 535 | @doc """ 536 | Sets the description for a command, subcommand, or flag. 537 | 538 | Use this macro within a `defcommand`, `subcommand`, or `flag` block to provide a description. 539 | 540 | ## Parameters 541 | 542 | - `desc` - The description text (a string). 543 | 544 | ## Examples 545 | 546 | defcommand :my_command do 547 | description "Performs the main operation." 548 | 549 | flag :verbose do 550 | description "Enables verbose mode." 551 | end 552 | end 553 | """ 554 | defmacro description(desc) do 555 | quote do 556 | Nexus.CLI.__set_description__(unquote(desc), __MODULE__) 557 | end 558 | end 559 | 560 | # Internal functions to manage the command stack and build the AST 561 | 562 | # Push a command or subcommand onto the command stack 563 | def __push_command__(command, module) do 564 | Module.put_attribute(module, :cli_command_stack, [ 565 | command | Module.get_attribute(module, :cli_command_stack) || [] 566 | ]) 567 | end 568 | 569 | def __finalize_command__(module) do 570 | case Module.get_attribute(module, :cli_command_stack) do 571 | [_command | _rest] -> 572 | :ok 573 | 574 | [] -> 575 | raise CompileError, 576 | description: "No command found in stack to finalize", 577 | file: __ENV__.file 578 | end 579 | 580 | [command | rest] = Module.get_attribute(module, :cli_command_stack) 581 | 582 | command = 583 | command 584 | |> __process_command_arguments__() 585 | |> V.validate_command() 586 | |> __inject_help__() 587 | 588 | existing_commands = Module.get_attribute(module, :cli_commands) || [] 589 | 590 | if Enum.any?(existing_commands, &(&1.name == command.name)) do 591 | raise ValidationError, "Duplicate command name: '#{command.name}'." 592 | end 593 | 594 | Module.put_attribute(module, :cli_commands, command) 595 | Module.put_attribute(module, :cli_command_stack, rest) 596 | end 597 | 598 | def __finalize_subcommand__(module) do 599 | [subcommand, parent | rest] = Module.get_attribute(module, :cli_command_stack) 600 | 601 | subcommand = 602 | subcommand 603 | |> __process_command_arguments__() 604 | |> V.validate_command() 605 | |> __inject_help__() 606 | 607 | # Ensure no duplicate subcommand names within the parent 608 | if Enum.any?(parent.subcommands, &(&1.name == subcommand.name)) do 609 | raise ValidationError, 610 | "Duplicate subcommand name: '#{subcommand.name}' within command '#{parent.name}'." 611 | end 612 | 613 | updated_parent = Map.update!(parent, :subcommands, fn subs -> [subcommand | subs] end) 614 | Module.put_attribute(module, :cli_command_stack, [updated_parent | rest]) 615 | end 616 | 617 | # Push a flag onto the flag stack 618 | def __push_flag__(flag, module) do 619 | Module.put_attribute(module, :cli_flag_stack, [ 620 | flag | Module.get_attribute(module, :cli_flag_stack) || [] 621 | ]) 622 | end 623 | 624 | def __set_flag_value__(type, opts, module) do 625 | [flag | rest] = Module.get_attribute(module, :cli_flag_stack) 626 | 627 | Module.put_attribute(module, :cli_flag_stack, [ 628 | Map.merge(flag, %{ 629 | type: type, 630 | required: Keyword.get(opts, :required, false), 631 | default: Keyword.get(opts, :default) 632 | }) 633 | | rest 634 | ]) 635 | end 636 | 637 | def __set_command_value__(type, opts, module) do 638 | [current | rest] = Module.get_attribute(module, :cli_command_stack) 639 | 640 | arg = %Argument{ 641 | name: Keyword.get(opts, :as), 642 | type: type, 643 | required: Keyword.get(opts, :required, false), 644 | default: Keyword.get(opts, :default) 645 | } 646 | 647 | updated = Map.update!(current, :args, fn args -> args ++ [arg] end) 648 | Module.put_attribute(module, :cli_command_stack, [updated | rest]) 649 | end 650 | 651 | def __set_flag_short__(short_name, module) do 652 | [flag | rest] = Module.get_attribute(module, :cli_flag_stack) 653 | updated_flag = Map.put(flag, :short, short_name) 654 | Module.put_attribute(module, :cli_flag_stack, [updated_flag | rest]) 655 | end 656 | 657 | # Set the description for the current command, subcommand, or flag 658 | def __set_description__(desc, module) do 659 | flag_stack = Module.get_attribute(module, :cli_flag_stack) || [] 660 | 661 | if Enum.empty?(flag_stack) do 662 | # if we're not operating on a flag, so it's a command/subcommand 663 | stack = Module.get_attribute(module, :cli_command_stack) || [] 664 | [current | rest] = stack 665 | updated = Map.put(current, :description, desc) 666 | Module.put_attribute(module, :cli_command_stack, [updated | rest]) 667 | else 668 | [flag | rest] = flag_stack 669 | updated_flag = Map.put(flag, :description, desc) 670 | Module.put_attribute(module, :cli_flag_stack, [updated_flag | rest]) 671 | end 672 | end 673 | 674 | def __finalize_flag__(module) do 675 | [flag | rest_flag] = Module.get_attribute(module, :cli_flag_stack) 676 | Module.put_attribute(module, :cli_flag_stack, rest_flag) 677 | 678 | command_stack = Module.get_attribute(module, :cli_command_stack) 679 | 680 | flag = V.validate_flag(flag) 681 | flag = if flag.type == :boolean, do: Map.put_new(flag, :default, false), else: flag 682 | 683 | case command_stack do 684 | nil -> 685 | # We're at the root level, add to root flags 686 | Module.put_attribute(module, :cli_root_flags, flag) 687 | 688 | [] -> 689 | # We're at the root level, add to root flags 690 | Module.put_attribute(module, :cli_root_flags, flag) 691 | 692 | [current | rest] -> 693 | # We're inside a command, add to command flags 694 | updated = Map.update!(current, :flags, fn flags -> [flag | flags] end) 695 | Module.put_attribute(module, :cli_command_stack, [updated | rest]) 696 | end 697 | end 698 | 699 | def __process_command_arguments__(command) do 700 | unnamed_args = Enum.filter(command.args, &(&1.name == nil)) 701 | 702 | cond do 703 | Enum.empty?(unnamed_args) -> 704 | # All arguments have names 705 | command 706 | 707 | length(command.args) == 1 -> 708 | # Single unnamed argument; set its name to the command's name 709 | [arg] = command.args 710 | arg = %{arg | name: command.name} 711 | %{command | args: [arg]} 712 | 713 | true -> 714 | # Multiple arguments; all must have names 715 | raise "All arguments must have names when defining multiple arguments in command '#{command.name}'. Please specify 'as: :name' option." 716 | end 717 | end 718 | 719 | defp __inject_help__(%Command{name: :help} = command) do 720 | command 721 | end 722 | 723 | defp __inject_help__(%Command{} = command) do 724 | command 725 | |> ensure_help_flag() 726 | |> inject_help_into_subcommands() 727 | end 728 | 729 | # Ensures that a command has the '--help' and '-h' flags 730 | defp ensure_help_flag(%Command{flags: flags} = command) do 731 | if Enum.any?(flags, &(&1.name == :help)) do 732 | command 733 | else 734 | help_flag = %Flag{ 735 | name: :help, 736 | short: :h, 737 | type: :boolean, 738 | required: false, 739 | default: false, 740 | description: "Prints help information." 741 | } 742 | 743 | %{command | flags: [help_flag | flags]} 744 | end 745 | end 746 | 747 | # Recursively injects help into all subcommands 748 | defp inject_help_into_subcommands(%Command{subcommands: subcommands} = command) do 749 | updated_subcommands = Enum.map(subcommands, &__inject_help__/1) 750 | %{command | subcommands: updated_subcommands} 751 | end 752 | 753 | defmacro __before_compile__(env) do 754 | commands = Module.get_attribute(env.module, :cli_commands) 755 | root_flags = Module.get_attribute(env.module, :cli_root_flags) || [] 756 | cli = Module.get_attribute(env.module, :cli) 757 | 758 | quote do 759 | @impl Nexus.CLI 760 | def description, do: @moduledoc 761 | 762 | defoverridable description: 0 763 | 764 | @doc false 765 | def __nexus_spec__ do 766 | %{ 767 | unquote(Macro.escape(cli)) 768 | | description: description(), 769 | spec: unquote(Macro.escape(commands)), 770 | root_flags: unquote(Macro.escape(root_flags)), 771 | version: version(), 772 | handler: __MODULE__ 773 | } 774 | end 775 | 776 | @doc """ 777 | Given the system `argv`, tries to parse the input 778 | and dispatches the parsed command to the handler 779 | module (aka `#{__MODULE__}`) 780 | 781 | If it fails it will display the CLI help. This 782 | convenience function can be used as delegated 783 | of an `Escript` or `Mix.Task` module 784 | 785 | For more information chekc `Nexus.CLI.__run_cli__/2` 786 | """ 787 | # Nexus.Parser will tokenize the whole input 788 | # Mix tasks already split the argv into a list 789 | def execute(argv) when is_binary(argv) or is_list(argv) do 790 | Nexus.CLI.__run_cli__(__nexus_spec__(), argv) 791 | end 792 | 793 | @doc """ 794 | Prints CLI documentation based into the CLI spec defined given a command path 795 | 796 | For more information, check `Nexus.CLI.Help` 797 | 798 | It receives an optional command path, for displaying 799 | subcommands help, for example 800 | 801 | ## Examples 802 | 803 | iex> #{inspect(__MODULE__)}.display_help() 804 | # prints help for the CLI itself showing all available commands and flags 805 | :ok 806 | 807 | iex> #{inspect(__MODULE__)}.display_help([:root]) 808 | # prints help for the `root` cmd showing all available subcommands and flags 809 | :ok 810 | 811 | iex> #{inspect(__MODULE__)}.display_help([:root, :nested]) 812 | # prints help for the `root -> :nested` subcmd showing all available subcommands and flags 813 | :ok 814 | """ 815 | def display_help(path \\ []) do 816 | alias Nexus.CLI 817 | 818 | Help.display(__nexus_spec__(), path) 819 | end 820 | end 821 | end 822 | 823 | @doc """ 824 | Given a the CLI spec and the user input, 825 | tries to parse the input against the spec and dispatches the 826 | parsed result to the CLI handler module - the one that imeplement `Nexus.CLI` 827 | behaviour. 828 | 829 | If the dispatchment is successfull and the `handle_input/2` return an `:ok`, 830 | then it stops the VM with a success code. 831 | 832 | If there is a parsing error it will display the CLI help and stop the VM with 833 | error code. 834 | 835 | If `handle_input/2` returns an error, it stops the VM with the desired code. 836 | """ 837 | @spec __run_cli__(t, binary | [binary]) :: :ok 838 | def __run_cli__(%__MODULE__{} = cli, input) when is_binary(input) or is_list(input) do 839 | with {:ok, result} <- Parser.parse_ast(cli, input), 840 | :ok <- Dispatcher.dispatch(cli, result) do 841 | System.stop(0) 842 | else 843 | {:error, reason} when is_list(reason) -> 844 | Help.display(cli) 845 | System.stop(1) 846 | 847 | {:error, {code, reason}} -> 848 | if reason, do: IO.puts(reason) 849 | System.stop(code) 850 | end 851 | end 852 | end 853 | --------------------------------------------------------------------------------