Skip to content

Bootstrap practice exercises #774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 26, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions bin/bootstrap_practice_exercise.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
# Generate all files required for a practice exercise.
# File content is filled as much as possible, but some, including tests, need manual input.
#
# Run the following command from the root of the repo:
# $ elixir bin/boostrap_practice_exercise.exs complex-numbers
# Pass the name of the exercise (e. g., "complex-numbers") as an argument

Mix.install([
{:jason, "~> 1.2"},
{:toml, "~> 0.6"}
])

defmodule Generate do
def explore_properties(%{"cases" => cases}) when is_list(cases) do
Enum.map(cases, &Generate.explore_properties/1)
|> Enum.reduce(
%{},
&Map.merge(&1, &2, fn _prop, p1, p2 -> %{p1 | error: p1.error or p2.error} end)
)
end

def explore_properties(%{"property" => property, "input" => input, "expected" => expected}),
do: %{
property => %{
name: Macro.underscore(property),
variables:
if match?(%{}, input) do
Enum.map(input, fn
{var, %{} = val} -> {var, Enum.map(val, fn {v, _} -> v end)}
{var, _} -> {var, nil}
end)
else
[{"input", nil}]
end,
error: match?(%{"error" => _err}, expected)
}
}

def explore_properties(_data), do: %{}

def print_property(%{name: name, variables: variables, error: error}) do
return_type = if error, do: "{:ok, TODO.t()} | {:error, String.t()}", else: "TODO.t()"

variable_types =
Enum.map_join(variables, ", ", fn
{var, nil} ->
"#{var} :: TODO.t()"

{var, sub_vars} ->
"#{var} :: %{#{Enum.map_join(sub_vars, ", ", &(&1 <> " : TODO.t()"))}}"
end)

variable_list = Enum.map_join(variables, ", ", fn {var, _} -> var end)

"""
@doc \"\"\"
TODO: add function description and replace types in @spec
\"\"\"
@spec #{name}(#{variable_types}) :: #{return_type}
def #{name}(#{variable_list}) do
end
"""
end

def print_comments(comments, do_not_print) do
comments
|> Enum.reject(fn {field, _value} -> field in do_not_print end)
|> Enum.map_join("\n", fn
{"comments", values} when is_list(values) ->
"# #{Enum.map_join(values, "\n# ", &String.trim/1)}"

{field, values} when is_list(values) ->
"#\n# --#{field} --\n# #{Enum.map_join(values, "\n# ", &inspect/1)}"

{field, value} ->
"#\n# -- #{field} --\n# #{inspect(value)}"
end)
end

def print_input(%{} = input),
do: Enum.map_join(input, "\n", fn {variable, value} -> "#{variable} = #{inspect(value)}" end)

def print_input(input), do: "input = #{inspect(input)}"

def print_expected(%{"error" => err}, _error), do: "{:error, #{inspect(err)}}"
def print_expected(expected, true), do: "{:ok, #{inspect(expected)}}"
def print_expected(expected, false), do: inspect(expected)

def print_test_case(
%{"description" => description, "cases" => sub_cases} = category,
properties,
module
) do
"""
describe \"#{description}\" do
#{Generate.print_comments(category, ["description", "cases"])}
#{Enum.map_join(sub_cases, "\n\n", &Generate.print_test_case(&1, properties, module))}
end
"""
end

def print_test_case(
%{
"description" => description,
"property" => property,
"input" => input,
"expected" => expected
} = test,
properties,
module
) do
%{name: name, variables: variables, error: error} = properties[property]
variable_list = Enum.map_join(variables, ", ", fn {var, _} -> var end)

"""
@tag :pending
test \"#{description}\" do
#{Generate.print_comments(test, ["description", "property", "input", "expected", "uuid"])}
#{print_input(input)}
output = #{module}.#{name}(#{variable_list})
expected = #{print_expected(expected, error)}

assert output == expected
end
"""
end
end

[exercise] = System.argv()

exercise_snake_case = String.replace(exercise, "-", "_")

module =
exercise
|> String.split("-")
|> Enum.map_join("", &String.capitalize/1)

## Step 1: create folder structure

Mix.Generator.create_directory("exercises/practice/#{exercise}")
Mix.Generator.create_directory("exercises/practice/#{exercise}/.docs")
Mix.Generator.create_directory("exercises/practice/#{exercise}/.meta")
Mix.Generator.create_directory("exercises/practice/#{exercise}/lib")
Mix.Generator.create_directory("exercises/practice/#{exercise}/test")

## Step 2: add common files

# .formatter.exs
format = """
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
"""

Mix.Generator.create_file("exercises/practice/#{exercise}/.formatter.exs", format)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had no idea about this module 😍 awesome

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is quite nice. And actually it wouldn't be much more work to make this script into a Mix.Task. It would be kind of cool: mix ex.practice.gen darts.


# mix.exs
mix = """
defmodule #{module}.MixProject do
use Mix.Project

def project do
[
app: :#{exercise_snake_case},
version: "0.1.0",
# elixir: "~> 1.8",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end

# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end

# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end
"""
Comment on lines +160 to +188
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: if we can't run mix format on this file, can we indent here 2 spaces for this binding?

Copy link
Contributor Author

@jiegillet jiegillet Jun 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the end we run mix format on everything, it should take care of this one too..
I'm not sure what you mean, I ran mix format on the file.
Do you want to indent the triple quoted block? mix format puts it back there.


Mix.Generator.create_file("exercises/practice/#{exercise}/mix.exs", mix)

# test/test_helper.exs
test_helper = """
ExUnit.start()
ExUnit.configure(exclude: :pending, trace: true)
"""
Comment on lines +194 to +196
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, same indent as mentioned above

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the indentation looks good already 🤔 meaning: level 0, there's no indentation.


Mix.Generator.create_file("exercises/practice/#{exercise}/test/test_helper.exs", test_helper)

## Step 3: write files that depend on problem specifications

url =
'https://raw.githubusercontent.com/exercism/problem-specifications/main/exercises/#{exercise}'

:inets.start()
:ssl.start()

# .docs/instructions.md
{:ok, {_status, _header, description}} =
:httpc.request(:get, {url ++ '/description.md', []}, [], [])

Mix.Generator.create_file("exercises/practice/#{exercise}/.docs/instructions.md", description)

# .meta/config.json
{:ok, {_status, _header, metadata}} = :httpc.request(:get, {url ++ '/metadata.toml', []}, [], [])

metadata =
metadata
|> to_string
|> Toml.decode!()

config = %{
authors: [],
contributors: [],
files: %{
solution: ["lib/#{exercise_snake_case}.ex"],
test: ["test/#{exercise_snake_case}_test.exs"],
example: [".meta/example.ex"]
}
}

config =
Map.merge(metadata, config)
|> Jason.encode!(pretty: true)

Mix.Generator.create_file("exercises/practice/#{exercise}/.meta/config.json", config)
IO.puts("Don't forget to add your name and the names of contributors")

# tests and lib files
{:ok, {_status, _header, data}} =
:httpc.request(:get, {url ++ '/canonical-data.json', []}, [], [])

data = Jason.decode!(data)

properties = Generate.explore_properties(data)

# Generating lib file
lib_file = """
defmodule #{module} do

#{properties |> Map.values() |> Enum.map_join("\n\n", &Generate.print_property/1)}

end
"""

path = "exercises/practice/#{exercise}/lib/#{exercise_snake_case}.ex"
Mix.Generator.create_file(path, lib_file)

Mix.Generator.copy_file(path, "exercises/practice/#{exercise}/.meta/example.ex")

# Generating test file
test_file =
"""
defmodule #{module}Test do
use ExUnit.Case

#{Generate.print_comments(data, ["cases", "exercise"])}
#{Enum.map_join(data["cases"], "\n\n", &Generate.print_test_case(&1, properties, module))}
end
"""
|> String.replace("@tag", "# @tag", global: false)

path = "exercises/practice/#{exercise}/test/#{exercise_snake_case}_test.exs"
Mix.Generator.create_file(path, test_file)

# mix format all files
Mix.Tasks.Format.run(["exercises/practice/#{exercise}/**/*.{ex,exs}"])