diff --git a/cli_options/CHANGELOG.md b/cli_options/CHANGELOG.md index 1fdb086..aaab082 100644 --- a/cli_options/CHANGELOG.md +++ b/cli_options/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +* Support grouping options in the docs by section. You can now specify the `:doc_section` +to any option, and pass a `:sections` option in the `CliOptions.docs/2` function for the +headers and extended docs of each section. + ## [v0.1.1](https://github.com/sportradar/elixir-workspace/tree/cli_options/v0.1.1) (2024-07-04) ### Added diff --git a/cli_options/lib/cli_options.ex b/cli_options/lib/cli_options.ex index 74f2a84..3b63811 100644 --- a/cli_options/lib/cli_options.ex +++ b/cli_options/lib/cli_options.ex @@ -430,6 +430,29 @@ defmodule CliOptions do ## Options * `:sort` - if set to `true` the options will be sorted alphabetically. + * `:sections` - a keyword list with options sections. If set the options docs + will be added under the defined section, or at the root section if no + `:section` is defined in your schema. + + Notice that if `:sort` is set the options + will be sorted within the sections. The sections order is not sorted and it + follows the provided order. + + An entry for each section is expected in the `:sections` option with the + following format: + + [ + section_name: [ + header: "Section Header", + doc: "Optional extra docs for this docs section" + ] + ] + + where: + + * `:header` - The header that will be used for the section. Required. + * `:doc` - Optional detailed section docs to be added before the actual + options docs. """ @spec docs(schema :: keyword() | CliOptions.Schema.t(), opts :: keyword()) :: String.t() def docs(schema, opts \\ []) diff --git a/cli_options/lib/cli_options/docs.ex b/cli_options/lib/cli_options/docs.ex index e6220fa..77a7c8a 100644 --- a/cli_options/lib/cli_options/docs.ex +++ b/cli_options/lib/cli_options/docs.ex @@ -3,42 +3,119 @@ defmodule CliOptions.Docs do @doc false @spec generate(schema :: CliOptions.Schema.t(), opts :: keyword()) :: String.t() - def generate(%CliOptions.Schema{} = schema, opts) do - schema.schema + def generate(%CliOptions.Schema{schema: schema}, opts) do + validate_sections!(schema, opts[:sections]) + sections = opts[:sections] || [] + + schema |> remove_hidden_options() + |> group_by_section([nil] ++ Keyword.keys(sections)) |> maybe_sort(Keyword.get(opts, :sort, false)) - |> Enum.reduce([], &maybe_option_doc/2) - |> Enum.reverse() - |> Enum.join("\n") + |> Enum.map(fn {section, options} -> docs_by_section(section, options, sections) end) + |> Enum.join("\n\n") + end + + @sections_schema NimbleOptions.new!( + *: [ + type: :keyword_list, + keys: [ + header: [ + type: :string, + required: true + ], + doc: [type: :string] + ] + ] + ) + + defp validate_sections!(_schema, nil), do: :ok + + defp validate_sections!(schema, sections) do + sections = NimbleOptions.validate!(sections, @sections_schema) + + configured_sections = + schema + |> Enum.map(fn {_key, opts} -> opts[:doc_section] end) + |> Enum.reject(&is_nil/1) + + for section <- configured_sections do + if is_nil(sections[section]) do + raise ArgumentError, """ + You must include #{inspect(section)} in the :sections option + of CliOptions.docs/2, as following: + + sections: [ + #{section}: [ + header: "The section header", + doc: "Optional extended doc for the section" + ] + ] + """ + end + end end defp remove_hidden_options(schema), do: Enum.reject(schema, fn {_key, opts} -> opts[:doc] == false end) - defp maybe_sort(schema, true), do: Enum.sort_by(schema, fn {key, _value} -> key end, :asc) - defp maybe_sort(schema, _other), do: schema - - defp maybe_option_doc({key, schema}, acc) do - option_doc({key, schema}, acc) - end - - defp option_doc({_key, schema}, acc) do - doc = - [ - "*", - "`#{option_name_doc(schema)}`", - "(`#{schema[:type]}`)", - "-", - maybe_deprecated(schema), - maybe_required(schema), - option_body_doc(schema) - ] - |> Enum.filter(&is_binary/1) - |> Enum.map(&String.trim_trailing/1) - |> Enum.join(" ") - |> String.trim_trailing() - - [doc | acc] + defp group_by_section(schema, [nil]), do: [nil: schema] + + defp group_by_section(schema, sections) do + sections + |> Enum.reduce([], fn section, acc -> + options = Enum.filter(schema, fn {_key, opts} -> opts[:doc_section] == section end) + + case options do + [] -> acc + options -> [{section, options} | acc] + end + end) + |> Enum.reverse() + end + + defp maybe_sort(sections, true) do + Enum.map(sections, fn {section, options} -> + sorted = Enum.sort_by(options, fn {key, _value} -> key end, :asc) + {section, sorted} + end) + end + + defp maybe_sort(sections, _other), do: sections + + defp docs_by_section(nil, options, _sections), do: options_docs(options) + + defp docs_by_section(section, options, sections) do + section_opts = Keyword.fetch!(sections, section) + + [ + "### " <> Keyword.fetch!(section_opts, :header), + section_opts[:doc], + options_docs(options) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join("\n\n") + end + + defp options_docs(options) do + options + |> Enum.map(fn {_key, schema} -> option_doc(schema) end) + |> Enum.join("\n") + end + + defp option_doc(schema) do + [ + "*", + "`#{option_name_doc(schema)}`", + "(`#{schema[:type]}`)", + "-", + maybe_deprecated(schema), + maybe_required(schema), + option_body_doc(schema) + ] + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim_trailing/1) + |> Enum.join(" ") + |> String.trim_trailing() end defp option_name_doc(schema) do diff --git a/cli_options/lib/cli_options/schema.ex b/cli_options/lib/cli_options/schema.ex index e869808..5db4f4c 100644 --- a/cli_options/lib/cli_options/schema.ex +++ b/cli_options/lib/cli_options/schema.ex @@ -116,6 +116,14 @@ defmodule CliOptions.Schema do then the option will not be included in the generated docs. """ ], + doc_section: [ + type: :atom, + doc: """ + The section in the documentation this option will be put under. If not set the + option is added to the default unnamed section. If set you must also provide the + `:sections` option in the `CliOptions.docs/2` call. + """ + ], required: [ type: :boolean, doc: """ diff --git a/cli_options/test/cli_options/docs_test.exs b/cli_options/test/cli_options/docs_test.exs index 29355e7..5dec3a6 100644 --- a/cli_options/test/cli_options/docs_test.exs +++ b/cli_options/test/cli_options/docs_test.exs @@ -15,11 +15,13 @@ defmodule CliOptions.DocsTest do mode: [ type: :string, default: "parallel", - allowed: ["parallel", "serial"] + allowed: ["parallel", "serial"], + doc_section: :test ], with_dash: [ type: :boolean, - doc: "a key with a dash" + doc: "a key with a dash", + doc_section: :test ], hidden_option: [ type: :boolean, @@ -41,6 +43,75 @@ defmodule CliOptions.DocsTest do assert CliOptions.docs(@test_schema) == expected end + test "with sections configured" do + expected = + """ + * `--verbose` (`boolean`) - [default: `false`] + * `-p, --project...` (`string`) - Required. The project to use + + ### Test related options + + * `--mode` (`string`) - Allowed values: `["parallel", "serial"]`. [default: `parallel`] + * `--with-dash` (`boolean`) - a key with a dash [default: `false`] + """ + |> String.trim() + + assert CliOptions.docs(@test_schema, sections: [test: [header: "Test related options"]]) == + expected + + # extra sections are ignored if no option set + + assert CliOptions.docs(@test_schema, + sections: [test: [header: "Test related options"], other: [header: "Foo"]] + ) == expected + end + + test "raises with invalid section settings" do + message = + "unknown options [:heder], valid options are: [:header, :doc] (in options [:test])" + + assert_raise NimbleOptions.ValidationError, message, fn -> + CliOptions.docs(@test_schema, sections: [test: [heder: "Test related options"]]) + end + end + + test "raises if no section info is provided for an option" do + message = """ + You must include :foo in the :sections option + of CliOptions.docs/2, as following: + + sections: [ + foo: [ + header: "The section header", + doc: "Optional extended doc for the section" + ] + ] + """ + + schema = [var: [doc: "a var", long: "variable", doc_section: :foo]] + + assert_raise ArgumentError, message, fn -> CliOptions.docs(schema, sections: []) end + end + + test "with sections configured and sorting" do + expected = + """ + * `-p, --project...` (`string`) - Required. The project to use + * `--verbose` (`boolean`) - [default: `false`] + + ### Test related options + + * `--mode` (`string`) - Allowed values: `["parallel", "serial"]`. [default: `parallel`] + * `--with-dash` (`boolean`) - a key with a dash [default: `false`] + """ + |> String.trim() + + assert CliOptions.docs(@test_schema, + sort: true, + sections: [test: [header: "Test related options"]] + ) == expected + end + test "with sorting enabled" do expected = """ diff --git a/cli_options/test/cli_options/schema_test.exs b/cli_options/test/cli_options/schema_test.exs index ec8712f..e5c18ab 100644 --- a/cli_options/test/cli_options/schema_test.exs +++ b/cli_options/test/cli_options/schema_test.exs @@ -16,7 +16,7 @@ defmodule CliOptions.SchemaTest do message = "invalid schema for :foo, unknown options [:missing, :other], valid options are: " <> - "[:type, :default, :long, :short, :aliases, :short_aliases, :doc, :required, :multiple, " <> + "[:type, :default, :long, :short, :aliases, :short_aliases, :doc, :doc_section, :required, :multiple, " <> ":allowed, :deprecated]" assert_raise ArgumentError, message, fn ->