defmodule Spark.Options.Docs do @moduledoc false def generate(schema, options) when is_list(schema) and is_list(options) do nest_level = Keyword.get(options, :nest_level, 0) {docs, sections, _level} = build_docs(schema, {[], [], nest_level}) to_string([Enum.reverse(docs), Enum.reverse(sections)]) end # If the schema is a function, we want to not show anything (it's a recursive # function) and "back up" one level since when we got here we already # increased the level by one. defp build_docs(fun, {docs, sections, level}) when is_function(fun) do {docs, sections, level - 1} end defp build_docs(schema, {docs, sections, level} = acc) do if schema[:*] do build_docs(schema[:*][:keys], acc) else Enum.reduce(schema || [], {docs, sections, level}, &maybe_option_doc/2) end end defp build_docs_with_subsection(subsection, schema, {docs, sections, level}) do subsection = String.trim_trailing(subsection, "\n") <> "\n\n" {item_docs, sections, _level} = build_docs(schema, {[], sections, 0}) item_section = [subsection | Enum.reverse(item_docs)] {docs, [item_section | sections], level} end defp maybe_option_doc({key, schema}, acc) do if schema[:doc] == false do acc else option_doc({key, schema}, acc) end end defp option_doc({key, schema}, {docs, sections, level}) do type_str = if type_str = get_type_str(schema), do: " #{type_str}" desc_indent = String.duplicate(" ", level + 1) description = get_required_str(schema) |> get_doc_str(schema) |> get_default_str(schema) |> case do nil -> "" parts -> indent_doc(" - " <> parts, desc_indent) end doc = [String.duplicate(" ", level), "* `#{inspect(key)}`#{type_str}", description, "\n\n"] docs = [IO.iodata_to_binary(doc) | docs] cond do schema[:keys] && schema[:subsection] -> build_docs_with_subsection(schema[:subsection], schema[:keys], {docs, sections, level}) schema[:keys] -> {docs, sections, _level} = build_docs(schema[:keys], {docs, sections, level + 1}) {docs, sections, level} true -> {docs, sections, level} end end defp space_concat(left, nil), do: left defp space_concat(nil, right), do: right defp space_concat(left, right), do: left <> " " <> right defp get_required_str(schema) do if schema[:required], do: "Required." end defp get_doc_str(prev_str, schema) do space_concat(prev_str, schema[:doc] && String.trim(schema[:doc])) end defp get_default_str(prev_str, schema) do if Keyword.has_key?(schema, :default) do default_str = "The default value is `#{inspect(schema[:default])}`." # If the documentation contains multiple lines, # the default must be in a trailing line. if prev_str && String.contains?(prev_str, ["\r\n", "\n\n"]) do prev_str <> "\n\n" <> default_str else space_concat(prev_str, default_str) end else prev_str end end defp get_type_str(schema) do str = case Keyword.fetch(schema, :type_doc) do {:ok, false} -> nil {:ok, type_doc} when is_binary(type_doc) -> type_doc :error -> get_raw_type_str(schema[:type]) end if str do "(#{str})" end end # Only shows types when they are concise. defp get_raw_type_str(nil), do: nil defp get_raw_type_str({:custom, _mod, _fun, _args}), do: nil defp get_raw_type_str(:mfa), do: nil defp get_raw_type_str(:mod_arg), do: nil defp get_raw_type_str(:module), do: "`t:module/0`" defp get_raw_type_str({:or, _values}), do: nil defp get_raw_type_str({:in, _}), do: nil defp get_raw_type_str({:one_of, _}), do: nil defp get_raw_type_str({:fun, arity}) when is_integer(arity), do: "function of arity #{arity}" defp get_raw_type_str(:any), do: "`t:term/0`" defp get_raw_type_str(:reference), do: "`t:reference/0`" defp get_raw_type_str(:pid), do: "`t:pid/0`" defp get_raw_type_str(:timeout), do: "`t:timeout/0`" defp get_raw_type_str(:boolean), do: "`t:boolean/0`" defp get_raw_type_str(:atom), do: "`t:atom/0`" defp get_raw_type_str(:integer), do: "`t:integer/0`" defp get_raw_type_str(:non_neg_integer), do: "`t:non_neg_integer/0`" defp get_raw_type_str(:pos_integer), do: "`t:pos_integer/0`" defp get_raw_type_str(:float), do: "`t:float/0`" defp get_raw_type_str(:string), do: "`t:String.t/0`" defp get_raw_type_str(:keyword_list), do: "`t:keyword/0`" defp get_raw_type_str(:non_empty_keyword_list), do: "non-empty `t:keyword/0`" defp get_raw_type_str({:map, _keys}), do: "`t:map/0`" defp get_raw_type_str({:keyword_list, _keys}), do: "`t:keyword/0`" defp get_raw_type_str({:non_empty_keyword_list, _keys}), do: "non-empty `t:keyword/0`" defp get_raw_type_str(:map), do: "`t:map/0`" defp get_raw_type_str({:tagged_tuple, tag, tuple_type}) do get_raw_type_str({:tuple, [{:literal, tag}, tuple_type]}) end defp get_raw_type_str({:spark_behaviour, module}), do: "module that adopts `#{inspect(module)}` or a module and options" defp get_raw_type_str({:spark_behaviour, module, _}), do: "module that adopts `#{inspect(module)}` or a module and options" defp get_raw_type_str({:spark_function_behaviour, module, {_, integer}}), do: "module that adopts `#{inspect(module)}`, a module and options, or a function of arity #{integer}" defp get_raw_type_str({:spark_function_behaviour, module, _, {_, integer}}), do: "module that adopts `#{inspect(module)}`, a module and options, or a function of arity #{integer}" defp get_raw_type_str({:behaviour, module}), do: "module that adopts `#{inspect(module)}`" defp get_raw_type_str({:protocol, module}), do: "value that implements the `#{inspect(module)}` protocol" defp get_raw_type_str({:struct, struct_type}), do: "struct of type `#{inspect(struct_type)}`" defp get_raw_type_str(:struct), do: "struct" defp get_raw_type_str({:spark, module}), do: "`#{inspect(module)}`" defp get_raw_type_str({:mfa_or_fun, arity}), do: "mfa or function of arity #{arity}" defp get_raw_type_str({:spark_type, module, _}), do: "an `#{inspect(module)}`" defp get_raw_type_str({:spark_type, module, _, _}), do: "an `#{inspect(module)}`" defp get_raw_type_str(:fun), do: "function" defp get_raw_type_str({:fun, _}), do: "function" defp get_raw_type_str({:fun, _, _}), do: "function" defp get_raw_type_str({:wrap_list, subtype}) do if subtype_str = get_raw_type_str(subtype), do: "one or a list of #{subtype_str}" end defp get_raw_type_str(:literal), do: nil defp get_raw_type_str({:literal, value}), do: "`#{inspect(value)}`" defp get_raw_type_str(:quoted), do: "a quoted value" defp get_raw_type_str({:list, subtype}) do if subtype_str = get_raw_type_str(subtype), do: "list of #{subtype_str}" end defp get_raw_type_str({:map, key_type, value_type}) do with key_type_str when is_binary(key_type_str) <- get_raw_type_str(key_type), value_type_str when is_binary(value_type_str) <- get_raw_type_str(value_type) do "map of #{key_type_str} keys and #{value_type_str} values" end end defp get_raw_type_str({:tuple, value_types}) do value_types = value_types |> Enum.map_join(", ", &get_raw_type_str/1) "tuple of #{value_types} values" end def dsl_docs_type({:behaviour, _mod}), do: "module" def dsl_docs_type({:protocol, protocol}), do: "an `#{inspect(protocol)}` value" def dsl_docs_type({:spark, mod}), do: dsl_docs_type({:behaviour, mod}) def dsl_docs_type({:spark_behaviour, mod}), do: dsl_docs_type({:behaviour, mod}) def dsl_docs_type({:spark_behaviour, mod, _builtins}), do: dsl_docs_type({:behaviour, inspect(mod)}) def dsl_docs_type(:module), do: "module" def dsl_docs_type({:spark_function_behaviour, mod, {_, arity}}) do dsl_docs_type({:or, [{:fun, arity}, {:spark_behaviour, mod}]}) end def dsl_docs_type({:spark_function_behaviour, mod, _builtins, {_, arity}}) do dsl_docs_type({:or, [{:fun, arity}, {:spark_behaviour, mod}]}) end def dsl_docs_type({:custom, _, _, _}), do: "any" def dsl_docs_type(:any), do: "any" def dsl_docs_type(:keyword_list), do: "keyword" def dsl_docs_type(:non_empty_keyword_list), do: "keyword" def dsl_docs_type({keyword_type, [*: value]}) when keyword_type in [:keyword_list, :non_empty_keyword_list] and is_list(value), do: "keyword(#{dsl_docs_type(value[:type])})" def dsl_docs_type({keyword_type, schema}) when keyword_type in [:keyword_list, :non_empty_keyword_list] and is_list(schema) do options = Enum.map_join(schema, ", ", fn {key, value} -> "#{key}: #{dsl_docs_type(value[:type])}" end) "[#{options}]" end def dsl_docs_type(:atom), do: "atom" def dsl_docs_type(:string), do: "String.t" def dsl_docs_type(:boolean), do: "boolean" def dsl_docs_type(:integer), do: "integer" def dsl_docs_type(:non_neg_integer), do: "non_neg_integer" def dsl_docs_type(:pos_integer), do: "pos_integer" def dsl_docs_type(:float), do: "float" def dsl_docs_type(:timeout), do: "timeout" def dsl_docs_type(:pid), do: "pid" def dsl_docs_type(:mfa), do: "mfa" def dsl_docs_type(:mod_arg), do: dsl_docs_type({:tuple, [:module, {:list, :any}]}) def dsl_docs_type({:tuple, list}) when is_list(list) do tuple = Enum.map_join(list, ", ", &dsl_docs_type/1) "{#{tuple}}" end def dsl_docs_type(:map), do: "map" def dsl_docs_type({:map, key_type, value_type}), do: "%{optional(#{dsl_docs_type(key_type)}) => #{dsl_docs_type(value_type)}}" def dsl_docs_type({:map, schema}) when is_list(schema) do options = Enum.map_join(schema, ", ", fn {key, value} -> "#{if value[:required], do: "required", else: "optional"}(:#{key}) => #{dsl_docs_type(value[:type])}" end) "%{#{options}}" end def dsl_docs_type(:struct), do: "struct" def dsl_docs_type({:struct, struct}), do: "#{inspect(struct)}" def dsl_docs_type(nil), do: "nil" def dsl_docs_type({:fun, 0}), do: "(-> any)" def dsl_docs_type({:fun, arity}) when is_integer(arity) do args = Enum.map_join(0..(arity - 1), ", ", fn _ -> "any" end) "(#{args} -> any)" end def dsl_docs_type({:fun, args}) when is_list(args) do args = Enum.map_join(args, ", ", &dsl_docs_type/1) "(#{args} -> any)" end def dsl_docs_type({:fun, args, return}) when is_list(args) do args = Enum.map_join(args, ", ", &dsl_docs_type/1) "(#{args} -> #{dsl_docs_type(return)})" end def dsl_docs_type({:one_of, values}), do: dsl_docs_type({:in, values}) def dsl_docs_type({:in, values}), do: Enum.map_join(values, " | ", &inspect/1) def dsl_docs_type({:or, subtypes}), do: Enum.map_join(subtypes, " | ", &dsl_docs_type/1) def dsl_docs_type({:list, subtype}), do: "list(#{dsl_docs_type(subtype)})" def dsl_docs_type(:quoted), do: "any" def dsl_docs_type({:wrap_list, subtype}) do str = dsl_docs_type(subtype) "#{str} | list(#{str})" end def dsl_docs_type({:list_of, subtype}), do: dsl_docs_type({:list, subtype}) def dsl_docs_type({:mfa_or_fun, arity}), do: dsl_docs_type({:or, [{:fun, arity}, :mfa]}) def dsl_docs_type(:literal), do: "any" def dsl_docs_type({:literal, value}), do: inspect(value) def dsl_docs_type({:tagged_tuple, tag, type}), do: dsl_docs_type({:tuple, [{:literal, tag}, type]}) def dsl_docs_type({:spark_type, type, _}), do: dsl_docs_type({:behaviour, type}) def dsl_docs_type({:spark_type, type, _, _}), do: dsl_docs_type({:behaviour, type}) defp indent_doc(text, indent) do [head | tail] = String.split(text, ["\r\n", "\n"]) tail = Enum.map(tail, fn "" -> "\n" str -> [?\n, indent, str] end) [head | tail] end def schema_specs(schema, or_nil? \\ false) do Enum.map(schema, fn {key, opt_schema} -> typespec = Keyword.get_lazy(opt_schema, :type_spec, fn -> type_to_spec(opt_schema[:type]) end) if or_nil? && !opt_schema[:required] && is_nil(opt_schema[:default]) do quote do: {unquote(key), unquote(typespec) | nil} else quote do: {unquote(key), unquote(typespec)} end end) end def schema_to_spec(schema) do schema |> schema_specs() |> unionize_quoted() end defp type_to_spec(type) do case type do :any -> quote(do: term()) :integer -> quote(do: integer()) :non_neg_integer -> quote(do: non_neg_integer()) :pos_integer -> quote(do: pos_integer()) :atom -> quote(do: atom()) :float -> quote(do: float()) :boolean -> quote(do: boolean()) :pid -> quote(do: pid()) :reference -> quote(do: reference()) :timeout -> quote(do: timeout()) :string -> quote(do: String.t()) :mfa -> quote(do: {module(), atom(), [term()]}) :mod_arg -> quote(do: {module(), [term()]}) :keyword_list -> quote(do: keyword()) {:keyword_list, _keys} -> quote(do: keyword()) :non_empty_keyword_list -> quote(do: keyword()) {:non_empty_keyword_list, _keys} -> quote(do: keyword()) nil -> quote(do: nil) :map -> quote(do: map()) {:map, key_type, value_type} -> quote( do: %{optional(unquote(type_to_spec(key_type))) => unquote(type_to_spec(value_type))} ) :fun -> quote do: (... -> term()) {:fun, arity} when is_integer(arity) -> function_spec(arity) {:fun, arg_types} -> function_spec(arg_types) {:fun, arg_types, return_type} -> function_spec(arg_types, return_type) {:in, %Range{first: first, last: last} = range} -> if Map.get(range, :step) in [nil, 1] do quote(do: unquote(first)..unquote(last)) else quote(do: term()) end {one_of, choices} when one_of in [:in, :one_of] -> if Enum.all?(choices, &is_atom(&1)) do unionize_quoted(choices) else quote(do: term()) end {:custom, _mod, _fun, _args} -> quote(do: term()) {:list, subtype} -> quote(do: [unquote(type_to_spec(subtype))]) {:or, subtypes} -> subtypes |> Enum.map(&type_to_spec/1) |> unionize_quoted() {:struct, _struct_name} -> quote(do: struct()) :struct -> quote(do: struct()) {:tuple, tuple_types} -> case Enum.map(tuple_types, &type_to_spec/1) do [type1, type2] -> quote(do: {unquote(type1), unquote(type2)}) types -> quote do: {unquote_splicing(types)} end {:tagged_tuple, tag, subtype} -> quote do: {unquote(tag), unquote(type_to_spec(subtype))} {tag, _} when tag in [:behaviour, :spark_behaviour, :protocol, :spark] -> quote(do: module()) {:spark_function_behaviour, _, _} -> quote(do: module()) {:spark_function_behaviour, _, _, _} -> quote(do: module()) {:spark_type, _, _} -> quote(do: module) {:spark_type, _, _, _} -> quote(do: module) {:mfa_or_fun, arity} -> quote do: unquote(type_to_spec(:mfa)) | unquote(type_to_spec({:fun, arity})) {:struct, module} -> quote do: unquote(module).t() {:wrap_list, type} -> quote do: unquote(type_to_spec(type)) | [unquote(type_to_spec(type))] :literal -> quote do: term() {:literal, atom} when is_atom(atom) -> atom {:literal, _} -> quote do: term() :quoted -> quote do: Macro.t() end end defp function_spec(arity) when is_integer(arity) do args = List.duplicate(quote(do: term()), arity) quote do unquote_splicing(args) -> term() end end defp function_spec(arg_types) when is_list(arg_types) do arg_types = arg_types |> Enum.map(&type_to_spec/1) quote do unquote_splicing(arg_types) -> term() end end defp function_spec(arg_types, return_type) when is_list(arg_types) do arg_types = arg_types |> Enum.map(&type_to_spec/1) return_type = type_to_spec(return_type) quote do unquote_splicing(arg_types) -> unquote(return_type) end end defp unionize_quoted(specs) do specs |> Enum.reverse() |> Enum.reduce("e(do: unquote(&1) | unquote(&2))) end end