diff --git a/test/cheat_sheet_test.exs b/test/cheat_sheet_test.exs new file mode 100644 index 00000000..c697c60d --- /dev/null +++ b/test/cheat_sheet_test.exs @@ -0,0 +1,617 @@ +# SPDX-FileCopyrightText: 2026 spark contributors +# +# SPDX-License-Identifier: MIT + +defmodule Spark.CheatSheetTest do + use ExUnit.Case, async: true + + alias Spark.CheatSheet + + defmodule FakeEntityTarget do + @moduledoc false + def hello, do: :world + end + + defmodule FakeEntity do + defstruct [ + :name, + :describe, + :schema, + :args, + :entities, + :examples, + :hide, + :auto_set_fields, + :target + ] + end + + defmodule FakeSection do + defstruct [ + :name, + :describe, + :schema, + :examples, + :entities, + :sections, + :imports + ] + end + + defmodule FakeDsl do + @moduledoc false + + def sections do + [ + %FakeSection{ + name: :root, + describe: "Root section", + schema: [opt: [type: :atom, doc: "an option"]], + examples: ["root()"], + imports: [Some.Imported.Module], + entities: [sample_entity()], + sections: [nested_section()] + } + ] + end + + def dsl_patches do + [ + %Spark.Dsl.Patch.AddEntity{ + section_path: [:root], + entity: patched_entity() + } + ] + end + + defp nested_section do + %FakeSection{ + name: :nested, + describe: "Nested section", + schema: [flag: [type: :boolean, doc: "nested flag"]], + examples: ["nested true"], + imports: [Other.Import], + entities: [nested_entity()], + sections: [] + } + end + + defp sample_entity do + %FakeEntity{ + name: :sample, + describe: "Sample entity", + schema: [value: [type: :integer, doc: "value"]], + args: [:value], + hide: [:hidden_opt], + auto_set_fields: [auto: 10], + examples: ["sample 10"], + entities: [{:children, [child_entity()]}], + target: FakeEntityTarget + } + end + + defp child_entity do + %FakeEntity{ + name: :child, + describe: "Child entity", + schema: [child_val: [type: :string, doc: "child"]], + args: [:child_val], + hide: [], + auto_set_fields: [], + examples: ["child \"ok\""], + entities: [], + target: FakeEntityTarget + } + end + + defp nested_entity do + %FakeEntity{ + name: :nested_entity, + describe: "Nested entity", + schema: [x: [type: :atom, doc: "x"]], + args: [:x], + hide: [], + auto_set_fields: [], + examples: ["nested_entity :ok"], + entities: [], + target: FakeEntityTarget + } + end + + defp patched_entity do + %FakeEntity{ + name: :patched, + describe: "Patched entity", + schema: [patched: [type: :string, doc: "patched"]], + args: [:patched], + hide: [], + auto_set_fields: [], + examples: ["patched \"yes\""], + entities: [], + target: FakeEntityTarget + } + end + end + + describe "cheat_sheet/1" do + test "generates a full markdown cheat sheet including patches" do + expected_result = """ + + # Spark.CheatSheetTest.FakeDsl\n\n\n + ## root + Root section\n + ### Nested DSLs + * [nested](#root-nested) + * nested_entity + * [sample](#root-sample) + * child\n\n + ### Examples + ``` + root() + ```\n\n\n\n + ### Options\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`opt`](#root-opt){: #root-opt } | `atom` | | an option |\n\n + ### root.nested + Nested section\n + ### Nested DSLs + * [nested_entity](#root-nested-nested_entity)\n\n + ### Examples + ``` + nested true + ```\n\n\n\n + ### Options\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`flag`](#root-nested-flag){: #root-nested-flag } | `boolean` | | nested flag |\n\n\n + ### root.nested.nested_entity + ```elixir + nested_entity x + ```\n\n + Nested entity\n\n\n + ### Examples + ``` + nested_entity :ok + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`x`](#root-nested-nested_entity-x){: #root-nested-nested_entity-x } | `atom` | | x |\n\n\n\n\n\n\n\n\n + ### root.sample + ```elixir + sample value + ```\n\n + Sample entity\n + ### Nested DSLs + * [child](#root-sample-child)\n\n + ### Examples + ``` + sample 10 + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`value`](#root-sample-value){: #root-sample-value } | `integer` | | value |\n\n\n + ### root.sample.child + ```elixir + child child_val + ```\n\n + Child entity\n\n\n + ### Examples + ``` + child \"ok\" + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`child_val`](#root-sample-child-child_val){: #root-sample-child-child_val } | `String.t` | | child |\n\n\n\n\n\n\n\n\n\n\n\n\n + ### root.patched + ```elixir + patched patched + ```\n\n + Patched entity\n\n\n + ### Examples + ``` + patched \"yes\" + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`patched`](#root-patched-patched){: #root-patched-patched } | `String.t` | | patched |\n\n\n\n\n\n\n\n\n + + """ + + md = CheatSheet.cheat_sheet(FakeDsl) + + assert normalize_newlines(md) == normalize_newlines(expected_result) + end + + test "preserves code blocks in section and entity descriptions verbatim" do + defmodule CodeDsl do + def sections do + [ + %FakeSection{ + name: :root, + describe: """ + Root description + + ```python + def fibonacci(n): + if n <= 0: + return [] + elif n == 1: + return [0] + elif n == 2: + return [0, 1] + + seq = [0, 1] + for i in range(2, n): + next_value = seq[i-1] + seq[i-2] + seq.append(next_value) + return seq + + print(fibonacci(10)) + ``` + + After. + """, + schema: [], + examples: [], + imports: [], + entities: [ + %FakeEntity{ + name: :sample, + describe: """ + some desc but with code example below + ```elixir + defmodule MathUtils do + @moduledoc \"\"\" + Utility functions for math operations + \"\"\" + + # Compute factorial recursively + def factorial(0), do: 1 + def factorial(n) when n > 0 do + n * factorial(n - 1) + end + + # Compute the nth Fibonacci number + def fibonacci(0), do: 0 + def fibonacci(1), do: 1 + def fibonacci(n) when n > 1 do + fibonacci(n - 1) + fibonacci(n - 2) + end + end + + IO.puts("Factorial of 5: \#{MathUtils.factorial(5)}") + IO.puts("Fibonacci of 10: \#{MathUtils.fibonacci(10)}") + ``` + + After code block + """, + schema: [], + args: [], + hide: [], + auto_set_fields: [], + examples: [], + entities: [], + target: EntityTarget + } + ], + sections: [] + } + ] + end + + def dsl_patches, do: [] + end + + md = CheatSheet.cheat_sheet(CodeDsl) + + expected_python_code_block_result = """ + Root description + + ```python + def fibonacci(n): + if n <= 0: + return [] + elif n == 1: + return [0] + elif n == 2: + return [0, 1] + + seq = [0, 1] + for i in range(2, n): + next_value = seq[i-1] + seq[i-2] + seq.append(next_value) + return seq + + print(fibonacci(10)) + ``` + + After. + """ + + expected_elixir_code_block_result = """ + some desc but with code example below + ```elixir + defmodule MathUtils do + @moduledoc \"\"\" + Utility functions for math operations + \"\"\" + + # Compute factorial recursively + def factorial(0), do: 1 + def factorial(n) when n > 0 do + n * factorial(n - 1) + end + + # Compute the nth Fibonacci number + def fibonacci(0), do: 0 + def fibonacci(1), do: 1 + def fibonacci(n) when n > 1 do + fibonacci(n - 1) + fibonacci(n - 2) + end + end + + IO.puts(\"Factorial of 5: \#{MathUtils.factorial(5)}\") + IO.puts(\"Fibonacci of 10: \#{MathUtils.fibonacci(10)}\") + ``` + + After code block + """ + + # Section code block must be preserved EXACTLY, including indentations + + assert normalize_newlines(md) =~ normalize_newlines(expected_python_code_block_result) + + assert normalize_newlines(md) =~ normalize_newlines(expected_elixir_code_block_result) + end + end + + describe "section_cheat_sheet/2" do + test "renders section, options, examples and nested entities" do + expected_result = """ + ## root + Root section\n + ### Nested DSLs + * [nested](#root-nested) + * nested_entity + * [sample](#root-sample) + * child\n\n + ### Examples + ``` + root() + ```\n\n\n\n + ### Options\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`opt`](#root-opt){: #root-opt } | `atom` | | an option |\n\n + ### root.nested + Nested section\n + ### Nested DSLs + * [nested_entity](#root-nested-nested_entity)\n\n + ### Examples + ``` + nested true + ```\n\n\n\n + ### Options\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`flag`](#root-nested-flag){: #root-nested-flag } | `boolean` | | nested flag |\n\n\n + ### root.nested.nested_entity + ```elixir + nested_entity x + ```\n\n + Nested entity\n\n\n + ### Examples + ``` + nested_entity :ok + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`x`](#root-nested-nested_entity-x){: #root-nested-nested_entity-x } | `atom` | | x |\n\n\n\n\n\n\n\n\n + ### root.sample + ```elixir + sample value + ```\n\n + Sample entity\n + ### Nested DSLs + * [child](#root-sample-child)\n\n + ### Examples + ``` + sample 10 + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`value`](#root-sample-value){: #root-sample-value } | `integer` | | value |\n\n\n + ### root.sample.child + ```elixir + child child_val + ```\n\n + Child entity\n\n\n + ### Examples + ``` + child \"ok\" + ```\n\n\n + ### Arguments\n + | Name | Type | Default | Docs | + |------|------|---------|------| + | [`child_val`](#root-sample-child-child_val){: #root-sample-child-child_val } | `String.t` | | child |\n\n\n\n\n\n\n\n\n\n\n\n + """ + + section = hd(FakeDsl.sections()) + md = CheatSheet.section_cheat_sheet(section) + + assert normalize_newlines(md) == normalize_newlines(expected_result) + end + end + + describe "doc/2" do + test "generates markdown documentation for DSL" do + expected_result = """ + ## root\n + Root section\n + * [sample](#module-sample) + * child + * [nested](#module-nested) + * nested_entity\n + Examples: + ``` + root() + ```\n\n + Imports:\n\n* `Some.Imported.Module`\n + ---\n + * `:opt` (`t:atom/0`) - an option\n\n\n + ### sample\n + Sample entity\n + * child\n + Examples: + ``` + sample 10 + ```\n\n + * `:value` (`t:integer/0`) - value\n\n\n + ##### child\n + Child entity\n\n\n + Examples: + ``` + child \"ok\" + ```\n\n + * `:child_val` (`t:String.t/0`) - child\n\n\n\n\n\n\n + ### nested\n + Nested section\n + * [nested_entity](#module-nested_entity)\n + Examples: + ``` + nested true + ```\n\n + Imports:\n\n* `Other.Import`\n + ---\n + * `:flag` (`t:boolean/0`) - nested flag\n\n\n + #### nested_entity\n + Nested entity\n\n\n + Examples: + ``` + nested_entity :ok + ```\n\n + * `:x` (`t:atom/0`) - x\n\n\n\n\n\n\n\n + """ + + md = CheatSheet.doc(FakeDsl.sections()) + + assert normalize_newlines(md) == normalize_newlines(expected_result) + end + end + + describe "doc_section/2" do + test "documents section options, examples and entities" do + expected_result = """ + Root section\n + * [sample](#module-sample) + * child + * [nested](#module-nested) + * nested_entity\n + Examples: + ``` + root() + ```\n\n + Imports:\n + * `Some.Imported.Module`\n + ---\n + * `:opt` (`t:atom/0`) - an option\n\n\n + ### sample\n + Sample entity\n + * child\n + Examples: + ``` + sample 10 + ```\n\n + * `:value` (`t:integer/0`) - value\n\n\n + ##### child\n + Child entity\n\n\n + Examples: + ``` + child \"ok\" + ```\n\n + * `:child_val` (`t:String.t/0`) - child\n\n\n\n\n\n\n + ### nested\n + Nested section\n + * [nested_entity](#module-nested_entity)\n + Examples: + ``` + nested true + ```\n\n + Imports:\n + * `Other.Import`\n + ---\n + * `:flag` (`t:boolean/0`) - nested flag\n\n\n + #### nested_entity\n + Nested entity\n\n\n + Examples: + ``` + nested_entity :ok + ```\n\n + * `:x` (`t:atom/0`) - x\n\n\n\n\n\n\n\n + """ + + section = hd(FakeDsl.sections()) + md = CheatSheet.doc_section(section) + + assert normalize_newlines(md) == normalize_newlines(expected_result) + end + end + + describe "doc_entity/2" do + test "documents entity details" do + expected_result = """ + Sample entity\n + * child\n + Examples: + ``` + sample 10 + ```\n\n + * `:value` (`t:integer/0`) - value\n\n\n + ### child\n + Child entity\n\n\n + Examples: + ``` + child \"ok\" + ```\n\n + * `:child_val` (`t:String.t/0`) - child\n\n\n\n\n + """ + + entity = hd(hd(FakeDsl.sections()).entities) + md = CheatSheet.doc_entity(entity) + + assert normalize_newlines(md) == normalize_newlines(expected_result) + end + end + + describe "doc_index/3" do + test "builds nested table of contents" do + expected_result = """ + * [root](#module-root) + * sample + * child + * nested + * nested_entity + """ + + md = CheatSheet.doc_index(FakeDsl.sections()) + + assert md == String.replace_suffix(normalize_newlines(expected_result), "\n", "") + end + end + + defp normalize_newlines(str) do + # windows newline to linux newline + + str = Regex.replace(~r/\r\n/, str, "\n") + Regex.replace(~r/\r$/m, str, "\n") + end +end