From 97290aa351a0fe38a0e7b3bfe1d12204d8678779 Mon Sep 17 00:00:00 2001 From: Robert Dober Date: Mon, 7 Nov 2022 19:23:10 +0100 Subject: [PATCH 1/4] Add an implementation for walking and optionally restructuring an Earmark AST. (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jói Sigurdsson --- lib/earmark/restructure.ex | 83 +++++++++++++++++++++++++++++++++ test/restructure_test.exs | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 lib/earmark/restructure.ex create mode 100644 test/restructure_test.exs diff --git a/lib/earmark/restructure.ex b/lib/earmark/restructure.ex new file mode 100644 index 00000000..3587113a --- /dev/null +++ b/lib/earmark/restructure.ex @@ -0,0 +1,83 @@ +defmodule Earmark.Restructure do + + @doc """ + Walks an AST and allows you to process it (storing details in acc) and/or + modify it as it is walked. + + items is the AST you got from EarmarkParser.as_ast() + + acc is the initial value of an accumulator that is passed to both + process_item_fn and process_list_fn and accumulated. If your functions + do not need to use or store any state, you can pass nil. + + The process_item_fn function is required. It takes two parameters, the + single item to process (which will either be a string or a 4-tuple) and + the accumulator, and returns a tuple {processed_item, updated_acc}. + Returning the empty list for processed_item will remove the item processed + the AST. + + The process_list_fn function is optional and defaults to no modification of + items or accumulator. It takes two parameters, the list of items that + are the sub-items of a given element in the AST (or the top-level list of + items), and the accumulator, and returns a tuple + {processed_items_list, updated_acc}. + + This function ends up returning {ast, acc}. + """ + def walk_and_modify_ast(items, acc, process_item_fn, process_list_fn \\ &({&1, &2})) + when is_list(items) and is_function(process_item_fn) and is_function(process_list_fn) + do + {items, acc} = process_list_fn.(items, acc) + {ast, acc} = Enum.map_reduce(items, acc, fn (item, acc) -> + walk_and_modify_ast_item(item, acc, process_item_fn, process_list_fn) + end) + {List.flatten(ast), acc} + end + + defp walk_and_modify_ast_item(item, acc, process_item_fn, process_list_fn) do + case process_item_fn.(item, acc) do + {{type, attribs, items, annotations}, acc} + when is_binary(type) and is_list(attribs) and is_list(items) and is_map(annotations) -> + {items, acc} = walk_and_modify_ast(items, acc, process_item_fn, process_list_fn) + {{type, attribs, List.flatten(items), annotations}, acc} + {item_or_items, acc} when is_binary(item_or_items) or is_list(item_or_items) -> + {item_or_items, acc} + end + end + + @doc """ + Utility for creating a restructuring that parses text by splitting it into + parts "of interest" vs. "other parts" using a regular expression. + Returns a list of parts where the parts matching regex have been processed + by invoking map_captures_fn on each part, and a list of remaining parts, + preserving the order of parts from what it was in the plain text item. + """ + def text_to_ast_list_splitting_regex(item, regex, map_captures_fn) + when is_binary(item) and is_function(map_captures_fn) do + interest_parts = Regex.scan(regex, item) + |> Enum.map(map_captures_fn) + other_parts = Regex.split(regex, item) + # If the match is at the front of 'item', Regex.split will + # return an empty string "before" the split. Therefore + # the interest_parts always has either the same number of + # elements as the other_parts list, or one fewer. + zigzag_lists(other_parts, interest_parts) + end + + @doc """ + Given two lists that are either of equal length, or with the first list + exactly one element longer than the second, returns a list that begins with + the first element from the first list, then the first element from the first + list, and so forth until both lists are empty. + """ + def zigzag_lists(first, second, acc \\ []) + def zigzag_lists([], [], acc) do + Enum.reverse(acc) + end + def zigzag_lists([first|first_rest], second, acc) do + # Note that there will be no match for an empty 'first' list if 'second' is not empty, + # and this for our use case is on purpose - the lists should either be equal in + # length, or the first list as initially passed into the function should be one longer. + zigzag_lists(second, first_rest, [first|acc]) + end +end diff --git a/test/restructure_test.exs b/test/restructure_test.exs new file mode 100644 index 00000000..b67016fc --- /dev/null +++ b/test/restructure_test.exs @@ -0,0 +1,94 @@ +defmodule RestructureTest do + use ExUnit.Case + + alias Earmark.Restructure + + @doc """ + handle_italics is an example of a structure-changing function, that + takes a non-standard markdown where / is used as an italics marker, + parses for that within text items, and transforms a node containing + such markdown into a new structure with an "em" node. + """ + def handle_italics(ast) do + ast + |> Restructure.walk_and_modify_ast("", &handle_italics_impl/2) + |> elem(0) + end + def handle_italics_impl(item, "a"), do: {item, ""} + def handle_italics_impl(item, acc) when is_binary(item) do + new_item = Restructure.text_to_ast_list_splitting_regex( + item, + ~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//, + fn [_, content] -> + {"em", [], [content], %{}} + end + ) + {new_item, acc} + end + def handle_italics_impl({name, _, _, _} = item, _acc) do + # Store the last seen element name so we can skip handling + # italics within elements. + {item, name} + end + + @doc """ + handle_bold is an example of a mostly-structure-preserving function + that simply changes the element type, again to deal with a non-standard + markdown where a single * is used to indicate "strong" text. + """ + def handle_bold(ast) do + ast + |> Restructure.walk_and_modify_ast(nil, &handle_bold_impl/2) + |> elem(0) + end + def handle_bold_impl({"em", attribs, items, annotations}, acc) do + {{"strong", attribs, items, annotations}, acc} + end + def handle_bold_impl(item, acc), do: {item, acc} + + @doc """ + An example of a structure-modifying function that operates on the + list of items in an AST node, removing any italic ("em") items. + """ + def delete_italicized_text(items, acc) do + { + Enum.flat_map(items, fn item -> + case item do + {"em", _, _, _} -> [] + _ -> [item] + end + end), + acc + } + end + + test "handle_bold_and_italic_from_nonstandard_markdown" do + markdown = "Hello *boldness* my /italic/ friend!" + {:ok, ast, []} = markdown |> EarmarkParser.as_ast() + processed_ast = ast + |> handle_bold() + |> handle_italics() + + assert processed_ast == [ + { + "p", [], + [ + "Hello ", + {"strong", [], ["boldness"], %{}}, + " my ", + {"em", [], ["italic"], %{}}, + " friend!" + ], %{} + } + ] + end + + test "delete_italicized_text" do + markdown = "Hello *there* my *good* friend!" + {:ok, ast, []} = markdown |> EarmarkParser.as_ast() + {processed_ast, :acc_unused} = Restructure.walk_and_modify_ast( + ast, :acc_unused, &({&1, &2}), &delete_italicized_text/2) + assert processed_ast == [{"p", [], ["Hello ", " my ", " friend!"], %{}}] + end +end +# SPDX-License-Identifier: Apache-2.0 From 4dd7816d144517a5910edb90d5b5380b61e8fb0d Mon Sep 17 00:00:00 2001 From: Robert Dober Date: Mon, 7 Nov 2022 19:25:55 +0100 Subject: [PATCH 2/4] updated RELEASE.md --- RELEASE.md | 5 +++++ mix.exs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 604514df..66479b6d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,8 @@ +## [Earmark](https://hex.pm/packages/earmark) 1.4.34 2022-??-?? + +- [PR-455 restructuring api traversal](https://github.com/pragdave/earmark/pull/455) + Kudos to [Jói Sigurðsson](https://github.com/joisig) + ## [Earmark](https://hex.pm/packages/earmark) 1.4.33 2022-11-02 - [Getting rid of some compiler warnings](https://github.com/pragdave/earmark/pull/453) diff --git a/mix.exs b/mix.exs index 552e8191..b7aa05ec 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Earmark.Mixfile do use Mix.Project - @version "1.4.33" + @version "1.4.34" @url "https://github.com/pragdave/earmark" From 744d853c8a2e05bae460ca9808d76442449d3b2b Mon Sep 17 00:00:00 2001 From: Robert Dober Date: Sun, 27 Nov 2022 13:28:21 +0100 Subject: [PATCH 3/4] preparing release 1.4.34 - renaming Restructure.zigzag_lists to merge_lists - raise ArgumentError with explanation on bad call of Restructur.merge_lists - adding more docs and integrating them into README.eex - renamed text_to_ast_list_splitting_rgx and doctested --- README.md | 91 ++++++++++++++++++- README.md.eex | 6 +- RELEASE.md | 2 +- lib/earmark/restructure.ex | 71 ++++++++++++--- .../restructure/merge_lists_test.exs | 21 +++++ .../restructure/walk_and_modify_ast_test.exs} | 43 ++++++++- 6 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 test/acceptance/restructure/merge_lists_test.exs rename test/{restructure_test.exs => acceptance/restructure/walk_and_modify_ast_test.exs} (71%) diff --git a/README.md b/README.md index 47884270..40b350c4 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ and the following code examples are therefore verified with `ExUnit` doctests. - [Structure Conserving Transformers](#structure-conserving-transformers) - [Postprocessors and Convenience Functions](#postprocessors-and-convenience-functions) - [Structure Modifying Transformers](#structure-modifying-transformers) + - [Earmark.Restructure.walk_and_modify_ast/4](#earmarkrestructurewalk_and_modify_ast4) + - [Earmark.Restructure.split_by_regex/3](#earmarkrestructuresplit_by_regex3) - [Contributing](#contributing) - [Author](#author) @@ -443,6 +445,93 @@ tools has emerged yet. +#### Earmark.Restructure.walk_and_modify_ast/4 + + +Walks an AST and allows you to process it (storing details in acc) and/or +modify it as it is walked. + +items is the AST you got from EarmarkParser.as_ast() + +acc is the initial value of an accumulator that is passed to both +process_item_fn and process_list_fn and accumulated. If your functions +do not need to use or store any state, you can pass nil. + +The process_item_fn function is required. It takes two parameters, the +single item to process (which will either be a string or a 4-tuple) and +the accumulator, and returns a tuple {processed_item, updated_acc}. +Returning the empty list for processed_item will remove the item processed +the AST. + +The process_list_fn function is optional and defaults to no modification of +items or accumulator. It takes two parameters, the list of items that +are the sub-items of a given element in the AST (or the top-level list of +items), and the accumulator, and returns a tuple +{processed_items_list, updated_acc}. + +This function ends up returning {ast, acc}. + +Here is an example using a custom format to make `` nodes and allowing +commented text to be left out + +```elixir + iex(1)> is_comment? = fn item -> is_binary(item) && Regex.match?(~r/\A\s*--/, item) end + ...(1)> comment_remover = + ...(1)> fn items, acc -> {Enum.reject(items, is_comment?), acc} end + ...(1)> italics_maker = fn + ...(1)> item, acc when is_binary(item) -> + ...(1)> new_item = Restructure.split_by_regex( + ...(1)> item, + ...(1)> ~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//, + ...(1)> fn [_, content] -> + ...(1)> {"em", [], [content], %{}} + ...(1)> end + ...(1)> ) + ...(1)> {new_item, acc} + ...(1)> item, "a" -> {item, nil} + ...(1)> {name, _, _, _}=item, _ -> {item, name} + ...(1)> end + ...(1)> markdown = """ + ...(1)> [no italics in links](http://example.io/some/path) + ...(1)> but /here/ + ...(1)> + ...(1)> -- ignore me + ...(1)> + ...(1)> text + ...(1)> """ + ...(1)> {:ok, ast, []} = EarmarkParser.as_ast(markdown) + ...(1)> Restructure.walk_and_modify_ast(ast, nil, italics_maker, comment_remover) + {[ + {"p", [], + [ + {"a", [{"href", "http://example.io/some/path"}], ["no italics in links"], + %{}}, + "\nbut ", + {"em", [], ["here"], %{}}, + "" + ], %{}}, + {"p", [], [], %{}}, + {"p", [], ["text"], %{}} + ], "p"} +``` + + + +#### Earmark.Restructure.split_by_regex/3 + +Utility for creating a restructuring that parses text by splitting it into +parts "of interest" vs. "other parts" using a regular expression. +Returns a list of parts where the parts matching regex have been processed +by invoking map_captures_fn on each part, and a list of remaining parts, +preserving the order of parts from what it was in the plain text item. + +```elixir + iex(2)> input = "This is ::all caps::, right?" + ...(2)> split_by_regex(input, ~r/::(.*?)::/, fn [_, inner|_] -> String.upcase(inner) end) + ["This is ", "ALL CAPS", ", right?"] +``` + + ## Contributing @@ -457,7 +546,7 @@ Thank you all who have already helped with Earmark, your names are duly noted in ## Author -Copyright © 2014,5,6,7,8,9, 2020,1 Dave Thomas, The Pragmatic Programmers & Robert Dober +Copyright © 2014,5,6,7,8,9, 2020,1,2 Dave Thomas, The Pragmatic Programmers & Robert Dober @/+pragdave, dave@pragprog.com & robert.dober@gmail.com # LICENSE diff --git a/README.md.eex b/README.md.eex index ebf89e8d..bbbc5749 100644 --- a/README.md.eex +++ b/README.md.eex @@ -31,6 +31,10 @@ and the following code examples are therefore verified with `ExUnit` doctests. <%= xtra.moduledoc "Earmark.Transform", wrap_code_blocks: "elixir", headline: 3 %> +<%= xtra.functiondoc "Earmark.Restructure.walk_and_modify_ast/4", wrap_code_blocks: "elixir", headline: 4 %> + +<%= xtra.functiondoc "Earmark.Restructure.split_by_regex/3", wrap_code_blocks: "elixir", headline: 4 %> + ## Contributing @@ -45,7 +49,7 @@ Thank you all who have already helped with Earmark, your names are duly noted in ## Author -Copyright © 2014,5,6,7,8,9, 2020,1 Dave Thomas, The Pragmatic Programmers & Robert Dober +Copyright © 2014,5,6,7,8,9, 2020,1,2 Dave Thomas, The Pragmatic Programmers & Robert Dober @/+pragdave, dave@pragprog.com & robert.dober@gmail.com # LICENSE diff --git a/RELEASE.md b/RELEASE.md index 66479b6d..a87e0ea4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,4 @@ -## [Earmark](https://hex.pm/packages/earmark) 1.4.34 2022-??-?? +## [Earmark](https://hex.pm/packages/earmark) 1.4.34 2022-11-27 - [PR-455 restructuring api traversal](https://github.com/pragdave/earmark/pull/455) Kudos to [Jói Sigurðsson](https://github.com/joisig) diff --git a/lib/earmark/restructure.ex b/lib/earmark/restructure.ex index 3587113a..4e5c2bc3 100644 --- a/lib/earmark/restructure.ex +++ b/lib/earmark/restructure.ex @@ -1,6 +1,7 @@ defmodule Earmark.Restructure do - @doc """ + @doc ~S""" + Walks an AST and allows you to process it (storing details in acc) and/or modify it as it is walked. @@ -23,6 +24,49 @@ defmodule Earmark.Restructure do {processed_items_list, updated_acc}. This function ends up returning {ast, acc}. + + Here is an example using a custom format to make `` nodes and allowing + commented text to be left out + + iex(1)> is_comment? = fn item -> is_binary(item) && Regex.match?(~r/\A\s*--/, item) end + ...(1)> comment_remover = + ...(1)> fn items, acc -> {Enum.reject(items, is_comment?), acc} end + ...(1)> italics_maker = fn + ...(1)> item, acc when is_binary(item) -> + ...(1)> new_item = Restructure.split_by_regex( + ...(1)> item, + ...(1)> ~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//, + ...(1)> fn [_, content] -> + ...(1)> {"em", [], [content], %{}} + ...(1)> end + ...(1)> ) + ...(1)> {new_item, acc} + ...(1)> item, "a" -> {item, nil} + ...(1)> {name, _, _, _}=item, _ -> {item, name} + ...(1)> end + ...(1)> markdown = """ + ...(1)> [no italics in links](http://example.io/some/path) + ...(1)> but /here/ + ...(1)> + ...(1)> -- ignore me + ...(1)> + ...(1)> text + ...(1)> """ + ...(1)> {:ok, ast, []} = EarmarkParser.as_ast(markdown) + ...(1)> Restructure.walk_and_modify_ast(ast, nil, italics_maker, comment_remover) + {[ + {"p", [], + [ + {"a", [{"href", "http://example.io/some/path"}], ["no italics in links"], + %{}}, + "\nbut ", + {"em", [], ["here"], %{}}, + "" + ], %{}}, + {"p", [], [], %{}}, + {"p", [], ["text"], %{}} + ], "p"} + """ def walk_and_modify_ast(items, acc, process_item_fn, process_list_fn \\ &({&1, &2})) when is_list(items) and is_function(process_item_fn) and is_function(process_list_fn) @@ -51,8 +95,12 @@ defmodule Earmark.Restructure do Returns a list of parts where the parts matching regex have been processed by invoking map_captures_fn on each part, and a list of remaining parts, preserving the order of parts from what it was in the plain text item. + + iex(2)> input = "This is ::all caps::, right?" + ...(2)> split_by_regex(input, ~r/::(.*?)::/, fn [_, inner|_] -> String.upcase(inner) end) + ["This is ", "ALL CAPS", ", right?"] """ - def text_to_ast_list_splitting_regex(item, regex, map_captures_fn) + def split_by_regex(item, regex, map_captures_fn) when is_binary(item) and is_function(map_captures_fn) do interest_parts = Regex.scan(regex, item) |> Enum.map(map_captures_fn) @@ -61,23 +109,24 @@ defmodule Earmark.Restructure do # return an empty string "before" the split. Therefore # the interest_parts always has either the same number of # elements as the other_parts list, or one fewer. - zigzag_lists(other_parts, interest_parts) + merge_lists(other_parts, interest_parts) end @doc """ Given two lists that are either of equal length, or with the first list exactly one element longer than the second, returns a list that begins with - the first element from the first list, then the first element from the first + the first element from the first list, then the first element from the second list, and so forth until both lists are empty. """ - def zigzag_lists(first, second, acc \\ []) - def zigzag_lists([], [], acc) do + def merge_lists(first, second, acc \\ []) + def merge_lists([], [], acc) do Enum.reverse(acc) end - def zigzag_lists([first|first_rest], second, acc) do - # Note that there will be no match for an empty 'first' list if 'second' is not empty, - # and this for our use case is on purpose - the lists should either be equal in - # length, or the first list as initially passed into the function should be one longer. - zigzag_lists(second, first_rest, [first|acc]) + def merge_lists([first|first_rest], second, acc) do + merge_lists(second, first_rest, [first|acc]) + end + def merge_lists([], _, _) do + raise ArgumentError, "merge_lists takes two lists where the first list is not shorter and at most 1 longer than the second list" end end +# SPDX-License-Identifier: Apache-2.0 diff --git a/test/acceptance/restructure/merge_lists_test.exs b/test/acceptance/restructure/merge_lists_test.exs new file mode 100644 index 00000000..92a48240 --- /dev/null +++ b/test/acceptance/restructure/merge_lists_test.exs @@ -0,0 +1,21 @@ +defmodule Test.Acceptance.Restructure.MergeListsTest do + use ExUnit.Case + + import Earmark.Restructure, only: [merge_lists: 2] + + message = "merge_lists takes two lists where the first list is not shorter and at most 1 longer than the second list" + describe "merge lists' api is particular issue meaningful error messages" do + test "if first list is too short" do + assert_raise ArgumentError, unquote(message), fn -> + merge_lists(~W[a b], [1, 2, 3]) + end + end + test "if first list is too long" do + assert_raise ArgumentError, unquote(message), fn -> + merge_lists(~W[a b], []) + end + end + end + +end +# SPDX-License-Identifier: Apache-2.0 diff --git a/test/restructure_test.exs b/test/acceptance/restructure/walk_and_modify_ast_test.exs similarity index 71% rename from test/restructure_test.exs rename to test/acceptance/restructure/walk_and_modify_ast_test.exs index b67016fc..0b6eff43 100644 --- a/test/restructure_test.exs +++ b/test/acceptance/restructure/walk_and_modify_ast_test.exs @@ -1,8 +1,11 @@ -defmodule RestructureTest do +defmodule Test.Restructure.WalkeAndModifyAstTest do use ExUnit.Case alias Earmark.Restructure + doctest Restructure, import: true + + @doc """ handle_italics is an example of a structure-changing function, that takes a non-standard markdown where / is used as an italics marker, @@ -16,7 +19,7 @@ defmodule RestructureTest do end def handle_italics_impl(item, "a"), do: {item, ""} def handle_italics_impl(item, acc) when is_binary(item) do - new_item = Restructure.text_to_ast_list_splitting_regex( + new_item = Restructure.split_by_regex( item, ~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//, fn [_, content] -> @@ -58,7 +61,7 @@ defmodule RestructureTest do _ -> [item] end end), - acc + acc } end @@ -66,8 +69,8 @@ defmodule RestructureTest do markdown = "Hello *boldness* my /italic/ friend!" {:ok, ast, []} = markdown |> EarmarkParser.as_ast() processed_ast = ast - |> handle_bold() - |> handle_italics() + |> handle_bold() + |> handle_italics() assert processed_ast == [ { @@ -90,5 +93,35 @@ defmodule RestructureTest do ast, :acc_unused, &({&1, &2}), &delete_italicized_text/2) assert processed_ast == [{"p", [], ["Hello ", " my ", " friend!"], %{}}] end + + test "prepared doctest" do + is_comment? = fn item -> is_binary(item) && Regex.match?(~r/\A\s*--/, item) end + comment_remover = + fn items, acc -> {Enum.reject(items, is_comment?), acc} end + italics_maker = fn + item, acc when is_binary(item) -> + new_item = Restructure.split_by_regex( + item, + ~r/\/([[:graph:]].*?[[:graph:]]|[[:graph:]])\//, + fn [_, content] -> + {"em", [], [content], %{}} + end + ) + {new_item, acc} + item, "a" -> {item, nil} + {name, _, _, _}=item, _ -> {item, name} + end + + markdown = """ + [no italics in links](http://example.io/some/path) + but /here/ + + -- ignore me + + text + """ + {:ok, ast, []} = EarmarkParser.as_ast(markdown) + Restructure.walk_and_modify_ast(ast, nil, italics_maker, comment_remover) + end end # SPDX-License-Identifier: Apache-2.0 From ca8007cd7a23f8adbc6c65e50eab319e9b496879 Mon Sep 17 00:00:00 2001 From: RobertDober Date: Sun, 27 Nov 2022 13:48:09 +0100 Subject: [PATCH 4/4] Updated GH workflow --- .github/workflows/ci.yml | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32918b8..09c7c9ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,10 @@ on: push: branches: - master - - rel-1.4.25 jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} strategy: matrix: @@ -39,21 +38,14 @@ jobs: otp: "24.1.2" elixir: "1.13.0" - pair: - otp: "24.1.2" - elixir: "1.14.0" + otp: 25.0.4 + elixir: 1.14.0 steps: - uses: actions/checkout@v2 - - name: Set up Elixir - uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24 - with: - elixir-version: ${{matrix.elixir}} # Define the elixir version [required] - otp-version: ${{matrix.otp}} # Define the OTP version [required] - - name: Restore dependencies cache - uses: actions/cache@v2 + - uses: erlef/setup-beam@v1 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} - name: Install dependencies run: mix deps.get - name: Run tests