diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb0bca04efe..6ef6044b12d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: otp-version: ${{ matrix.otp_version }} - + gleam-version: "1.11.1" - name: Set ERL_COMPILER_OPTIONS if: ${{ matrix.deterministic }} run: echo "ERL_COMPILER_OPTIONS=deterministic" >> $GITHUB_ENV diff --git a/lib/mix/lib/mix/dep.ex b/lib/mix/lib/mix/dep.ex index 1009857f2f6..7e8a4e98345 100644 --- a/lib/mix/lib/mix/dep.ex +++ b/lib/mix/lib/mix/dep.ex @@ -27,7 +27,7 @@ defmodule Mix.Dep do * `top_level` - true if dependency was defined in the top-level project * `manager` - the project management, possible values: - `:rebar3` | `:mix` | `:make` | `nil` + `:rebar3` | `:mix` | `:make` | `:gleam` | `nil` * `from` - path to the file where the dependency was defined @@ -74,7 +74,7 @@ defmodule Mix.Dep do opts: keyword, deps: [t], top_level: boolean, - manager: :rebar3 | :mix | :make | nil, + manager: :rebar3 | :mix | :make | :gleam | nil, from: String.t(), extra: term, system_env: keyword @@ -571,6 +571,13 @@ defmodule Mix.Dep do manager == :make end + @doc """ + Returns `true` if dependency is a Gleam project. + """ + def gleam?(%Mix.Dep{manager: manager}) do + manager == :gleam + end + ## Helpers defp mix_env_var do diff --git a/lib/mix/lib/mix/dep/converger.ex b/lib/mix/lib/mix/dep/converger.ex index 98accde6fab..fce51b2f41c 100644 --- a/lib/mix/lib/mix/dep/converger.ex +++ b/lib/mix/lib/mix/dep/converger.ex @@ -407,7 +407,7 @@ defmodule Mix.Dep.Converger do %{other | manager: sort_manager(other_manager, manager, in_upper?)} end - @managers [:mix, :rebar3, :make] + @managers [:mix, :rebar3, :make, :gleam] defp sort_manager(other_manager, manager, true) do other_manager || manager diff --git a/lib/mix/lib/mix/dep/loader.ex b/lib/mix/lib/mix/dep/loader.ex index af4be417ca2..d1e71e03bf4 100644 --- a/lib/mix/lib/mix/dep/loader.ex +++ b/lib/mix/lib/mix/dep/loader.ex @@ -8,7 +8,7 @@ defmodule Mix.Dep.Loader do @moduledoc false - import Mix.Dep, only: [ok?: 1, mix?: 1, rebar?: 1, make?: 1] + import Mix.Dep, only: [ok?: 1, mix?: 1, rebar?: 1, make?: 1, gleam?: 1] @doc """ Gets all direct children of the current `Mix.Project` @@ -84,9 +84,9 @@ defmodule Mix.Dep.Loader do def load(%Mix.Dep{manager: manager, scm: scm, opts: opts} = dep, children, locked?) do # The manager for a child dependency is set based on the following rules: # 1. Set in dependency definition - # 2. From SCM, so that Hex dependencies of a rebar project can be compiled with mix + # 2. From SCM, so that Hex dependencies of a Rebar/Gleam project can be compiled with Mix # 3. From the parent dependency, used for rebar dependencies from git - # 4. Inferred from files in dependency (mix.exs, rebar.config, Makefile) + # 4. Inferred from files in dependency (mix.exs, rebar.config, Makefile, gleam.toml) manager = opts[:manager] || scm_manager(scm, opts) || manager || infer_manager(opts[:dest]) dep = %{dep | manager: manager, status: scm_status(scm, opts)} @@ -106,6 +106,9 @@ defmodule Mix.Dep.Loader do make?(dep) -> make_dep(dep) + gleam?(dep) -> + gleam_dep(dep, children, manager, locked?) + true -> {dep, []} end @@ -228,7 +231,7 @@ defmodule Mix.Dep.Loader do # Note that we ignore Make dependencies because the # file based heuristic will always figure it out. - @scm_managers ~w(mix rebar3)a + @scm_managers ~w(mix rebar3 gleam)a defp scm_manager(scm, opts) do managers = scm.managers(opts) @@ -254,6 +257,9 @@ defmodule Mix.Dep.Loader do any_of?(dest, ["Makefile", "Makefile.win"]) -> :make + any_of?(dest, ["gleam.toml"]) -> + :gleam + true -> nil end @@ -369,6 +375,20 @@ defmodule Mix.Dep.Loader do {dep, []} end + defp gleam_dep(%Mix.Dep{opts: opts} = dep, _children = nil, manager, locked?) do + Mix.Gleam.requirements!() + dest = opts[:dest] + config = File.cd!(dest, fn -> Mix.Gleam.load_config(".") end) + from = Path.join(dest, "gleam.toml") + deps = Enum.map(config[:deps], &to_dep(&1, from, manager, locked?)) + + {dep, deps} + end + + defp gleam_dep(%Mix.Dep{opts: opts} = dep, children, manager, locked?) do + {dep, Enum.map(children, &to_dep(&1, opts[:dest], manager, locked?))} + end + defp mix_children(config, locked?, opts) do from = Mix.Project.project_file() diff --git a/lib/mix/lib/mix/gleam.ex b/lib/mix/lib/mix/gleam.ex new file mode 100644 index 00000000000..6706efed4ea --- /dev/null +++ b/lib/mix/lib/mix/gleam.ex @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team + +defmodule Mix.Gleam do + # Version that introduced generating an .app file + @gleam_version_requirement ">= 1.19.0" + + @spec load_config(Path.t()) :: config :: map() + def load_config(dir) do + File.cd!(dir, fn -> + with {:ok, output} <- + gleam(~W(export package-information)), + json <- JSON.decode!(output), + {:ok, gleam_toml} <- Map.fetch(json, "gleam.toml") do + parse_config(gleam_toml) + else + :error -> + {:error, "\"gleam.toml\" key not found in \"gleam export package-information\" output"} + + {:error, message} -> + {:error, message} + end + |> assert_ok_value!() + end) + end + + @spec parse_config(map()) :: {:ok, config :: map()} | {:error, message :: binary()} + def parse_config(json) when is_map(json) do + deps = + Map.get(json, "dependencies", %{}) + |> Enum.map(&parse_dep!/1) + + with {:ok, name} <- Map.fetch(json, "name"), + {:ok, version} <- Map.fetch(json, "version") do + config = + %{ + name: name, + version: version, + deps: deps + } + + {:ok, config} + else + :error -> + {:error, + "Command \"gleam export package-information\" unexpected format: \n" <> + inspect(json, pretty: true, limit: :infinity)} + end + end + + defp parse_dep!({dep, requirement}, opts \\ []) do + String.to_atom(dep) + |> build_dep_spec(requirement, opts) + |> assert_ok_value!() + end + + defp build_dep_spec(dep, %{"version" => version}, []), + do: {:ok, {dep, version}} + + defp build_dep_spec(dep, %{"version" => version}, opts), + do: {:ok, {dep, version, opts}} + + defp build_dep_spec(dep, %{"path" => path}, opts), + do: {:ok, {dep, Keyword.merge(opts, path: Path.expand(path))}} + + defp build_dep_spec(dep, %{"git" => git, "ref" => ref}, _opts), + do: {:ok, {dep, git: git, ref: ref}} + + defp build_dep_spec(dep, requirement, _opts), + do: {:error, "Gleam package #{dep} has unsupported requirement: #{inspect(requirement)}"} + + @spec requirements!() :: :ok + def requirements!() do + case fetch_gleam_version() do + {:ok, gleam_version} -> + if Version.match?(gleam_version, @gleam_version_requirement) do + :ok + else + {:error, + "Current Gleam version does not meet minimum requirements " <> + "#{@gleam_version_requirement}, got: #{gleam_version}"} + end + + {:error, message} -> + {:error, message} + end + |> assert_ok_value!() + end + + defp fetch_gleam_version() do + case gleam(["--version"]) do + {:ok, "gleam " <> version} -> + case Version.parse(version) do + {:ok, parsed_version} -> + {:ok, Version.to_string(parsed_version)} + + :error -> + {:error, "Command \"gleam --version\" invalid version format: #{version}"} + end + + {:error, output} -> + {:error, "Command \"gleam --version\" unexpected format: #{output}"} + end + end + + defp gleam(args) do + System.cmd("gleam", args, stderr_to_stdout: true) + catch + :error, :enoent -> + {:error, + "The \"gleam\" executable is not available in your PATH. " <> + "Please install it, as one of your dependencies requires it"} + else + {response, 0} -> + {:ok, String.trim(response)} + + {response, _} when is_binary(response) -> + {:error, "Command \"gleam #{Enum.join(args, " ")}\" failed with reason: #{response}"} + + {_, _} -> + {:error, "Command \"gleam #{Enum.join(args, " ")}\" failed"} + end + + defp assert_ok_value!(:ok), do: :ok + defp assert_ok_value!({:ok, term}), do: term + defp assert_ok_value!({:error, message}) when is_binary(message), do: Mix.raise(message) +end diff --git a/lib/mix/lib/mix/task.compiler.ex b/lib/mix/lib/mix/task.compiler.ex index fd3154af684..81f7a48ce0f 100644 --- a/lib/mix/lib/mix/task.compiler.ex +++ b/lib/mix/lib/mix/task.compiler.ex @@ -80,7 +80,7 @@ defmodule Mix.Task.Compiler do * `:scm` - the SCM module of the dependency. * `:manager` - the dependency project management, possible values: - `:rebar3`, `:mix`, `:make`, `nil`. + `:rebar3`, `:mix`, `:make`, `:gleam`, `nil`. * `:os_pid` - the operating system PID of the process that run the compilation. The value is a string and it can be compared diff --git a/lib/mix/lib/mix/tasks/deps.compile.ex b/lib/mix/lib/mix/tasks/deps.compile.ex index 7f75a00f53f..6d8f3b88baa 100644 --- a/lib/mix/lib/mix/tasks/deps.compile.ex +++ b/lib/mix/lib/mix/tasks/deps.compile.ex @@ -22,6 +22,7 @@ defmodule Mix.Tasks.Deps.Compile do * `Makefile.win`- invokes `nmake /F Makefile.win` (only on Windows) * `Makefile` - invokes `gmake` on DragonFlyBSD, FreeBSD, NetBSD, and OpenBSD, invokes `make` on any other operating system (except on Windows) + * `gleam.toml` - invokes `gleam compile-package` The compilation can be customized by passing a `compile` option in the dependency: @@ -143,9 +144,12 @@ defmodule Mix.Tasks.Deps.Compile do dep.manager == :rebar3 -> do_rebar3(dep, config) + dep.manager == :gleam -> + do_gleam(dep, config) + true -> Mix.shell().error( - "Could not compile #{inspect(app)}, no \"mix.exs\", \"rebar.config\" or \"Makefile\" " <> + "Could not compile #{inspect(app)}, no \"mix.exs\", \"rebar.config\", \"Makefile\" or \"gleam.toml\" " <> "(pass :compile as an option to customize compilation, set it to \"false\" to do nothing)" ) @@ -310,6 +314,40 @@ defmodule Mix.Tasks.Deps.Compile do true end + defp do_gleam(%Mix.Dep{opts: opts} = dep, config) do + Mix.Gleam.requirements!() + Mix.Project.ensure_structure() + + lib = Path.join(Mix.Project.build_path(), "lib") + out = opts[:build] + package = opts[:dest] + + args = [ + "compile-package", + "--src-only", + "--target", + "erlang", + "--package", + package, + "--out", + out, + "--lib", + lib + ] + + case System.cmd("gleam", args, [stderr_to_stdout: true] ++ opts_for_cmd(dep, config, [])) do + {_output, 0} -> + :ok + + {output, _} -> + Mix.raise( + "Could not compile dependency #{inspect(dep.app)}, `gleam compile-package` command failed with reason: #{output}" + ) + end + + Code.prepend_path(Path.join(out, "ebin"), cache: true) + end + defp make_command(dep) do makefile_win? = makefile_win?(dep) @@ -345,7 +383,7 @@ defmodule Mix.Tasks.Deps.Compile do defp shell_cmd!(%Mix.Dep{app: app} = dep, config, command, env \\ []) do if Mix.shell().cmd(command, [print_app: true] ++ opts_for_cmd(dep, config, env)) != 0 do Mix.raise( - "Could not compile dependency #{inspect(app)}, \"#{command}\" command failed. " <> + "Could not compile dependency #{inspect(app)}, #{inspect(command)} command failed. " <> deps_compile_feedback(app) ) end diff --git a/lib/mix/lib/mix/tasks/deps.ex b/lib/mix/lib/mix/tasks/deps.ex index b0b28a8e590..70e8428d76e 100644 --- a/lib/mix/lib/mix/tasks/deps.ex +++ b/lib/mix/lib/mix/tasks/deps.ex @@ -101,10 +101,10 @@ defmodule Mix.Tasks.Deps do * `:override` - if set to `true` the dependency will override any other definitions of itself by other dependencies - * `:manager` - Mix can also compile Rebar3 and makefile projects + * `:manager` - Mix can also compile Rebar3, makefile and Gleam projects and can fetch sub dependencies of Rebar3 projects. Mix will try to infer the type of project but it can be overridden with this - option by setting it to `:mix`, `:rebar3`, or `:make`. In case + option by setting it to `:mix`, `:rebar3`, `:make` or `:gleam`. In case there are conflicting definitions, the first manager in the list above will be picked up. For example, if a dependency is found with `:rebar3` as a manager in different part of the trees, `:rebar3` will be automatically diff --git a/lib/mix/test/fixtures/gleam_dep/.gitignore b/lib/mix/test/fixtures/gleam_dep/.gitignore new file mode 100644 index 00000000000..6f6e2eb4b4d --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/.gitignore @@ -0,0 +1,14 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# The directory Gleam will write compiled artifacts to. +/build/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# BEAM bytecode files. +*.beam + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez diff --git a/lib/mix/test/fixtures/gleam_dep/gleam.toml b/lib/mix/test/fixtures/gleam_dep/gleam.toml new file mode 100644 index 00000000000..36fcdce7f0b --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/gleam.toml @@ -0,0 +1,23 @@ +name = "gleam_dep" +version = "1.0.0" +description = "GleamDep description" +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = "0.59.0" +gleam_otp = "0.16.1" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" + +[erlang] +extra_applications = ["ssl"] diff --git a/lib/mix/test/fixtures/gleam_dep/manifest.toml b/lib/mix/test/fixtures/gleam_dep/manifest.toml new file mode 100644 index 00000000000..f7e3f2b653e --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/manifest.toml @@ -0,0 +1,14 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, + { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, + { name = "gleam_stdlib", version = "0.54.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "723BA61A2BAE8D67406E59DD88CEA1B3C3F266FC8D70F64BE9FEC81B4505B927" }, + { name = "gleeunit", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "0E6C83834BA65EDCAAF4FE4FB94AC697D9262D83E6F58A750D63C9F6C8A9D9FF" }, +] + +[requirements] +gleam_otp = { version = ">= 0.16.1 and < 1.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/lib/mix/test/fixtures/gleam_dep/src/collocated_erlang.erl b/lib/mix/test/fixtures/gleam_dep/src/collocated_erlang.erl new file mode 100644 index 00000000000..ea2ed915e71 --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/src/collocated_erlang.erl @@ -0,0 +1,5 @@ +-module(collocated_erlang). +-export([hello/0]). + +hello() -> + "Hello from Collocated Erlang!". diff --git a/lib/mix/test/fixtures/gleam_dep/src/gleam_dep.gleam b/lib/mix/test/fixtures/gleam_dep/src/gleam_dep.gleam new file mode 100644 index 00000000000..4f11d986b22 --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/src/gleam_dep.gleam @@ -0,0 +1,6 @@ +pub fn main() { + True +} + +@external(erlang, "collocated_erlang", "hello") +pub fn erl() -> String diff --git a/lib/mix/test/mix/gleam_test.exs b/lib/mix/test/mix/gleam_test.exs new file mode 100644 index 00000000000..d94c75de93e --- /dev/null +++ b/lib/mix/test/mix/gleam_test.exs @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team + +Code.require_file("../test_helper.exs", __DIR__) + +defmodule Mix.GleamTest do + use MixTest.Case + @moduletag :gleam + + @compile {:no_warn_undefined, [:gleam_dep, :gleam@int]} + + defmodule GleamAsDep do + def project do + [ + app: :gleam_as_dep, + version: "0.1.0", + deps: [ + {:gleam_dep, path: MixTest.Case.tmp_path("/gleam_dep")} + ] + ] + end + end + + defmodule BrokenGleamAsDep do + def project do + [ + app: :broken_gleam_as_dep, + version: "0.1.0", + deps: [ + {:broken_gleam_dep, path: MixTest.Case.tmp_path("broken_gleam_dep")} + ] + ] + end + end + + describe "load_config/1" do + test "loads gleam.toml" do + path = MixTest.Case.fixture_path("gleam_dep") + config = Mix.Gleam.load_config(path) + + expected = [ + {:gleam_stdlib, "0.59.0"}, + {:gleam_otp, "0.16.1"} + ] + + assert Enum.sort(config[:deps]) == Enum.sort(expected) + end + end + + describe "Gleam export package-information format" do + test "parse_config" do + {:ok, config} = + %{ + "name" => "gael", + "version" => "1.0.0", + "gleam" => ">= 1.8.0", + "dependencies" => %{ + "git_dep" => %{"git" => "../git_dep", "ref" => "957b83b"}, + "gleam_stdlib" => %{"version" => ">= 0.18.0 and < 2.0.0"} + } + } + |> Mix.Gleam.parse_config() + + assert config == %{ + name: "gael", + version: "1.0.0", + deps: [ + {:git_dep, git: "../git_dep", ref: "957b83b"}, + {:gleam_stdlib, ">= 0.18.0 and < 2.0.0"} + ] + } + end + end + + describe "integration with Mix" do + test "gets and compiles dependencies" do + in_tmp("get and compile dependencies", fn -> + Mix.Project.push(GleamAsDep) + + Mix.Tasks.Deps.Get.run([]) + assert_received {:mix_shell, :info, ["* Getting gleam_stdlib " <> _]} + assert_received {:mix_shell, :info, ["* Getting gleam_otp " <> _]} + + Mix.Tasks.Deps.Compile.run([]) + assert :gleam_dep.main() + assert :gleam_dep.erl() == ~c'Hello from Collocated Erlang!' + assert :gleam@int.to_string(1) == "1" + + {:ok, [{:application, :gleam_dep, specs}]} = + :file.consult("_build/dev/lib/gleam_dep/ebin/gleam_dep.app") + + assert Keyword.equal?(specs, [ + {:modules, [:collocated_erlang, :gleam_dep]}, + {:applications, [:gleam_otp, :gleam_stdlib, :ssl]}, + {:description, ~c"GleamDep description"}, + {:registered, []}, + {:vsn, ~c"1.0.0"} + ]) + + assert :ok == Mix.Tasks.Deps.Loadpaths.run([]) + end) + end + + test "raises the Gleam CLI's own message when compile-package fails" do + dep_path = MixTest.Case.tmp_path("broken_gleam_dep") + File.rm_rf!(dep_path) + File.mkdir_p!(dep_path) + + File.write!(Path.join(dep_path, "gleam.toml"), """ + name = "broken_gleam_dep" + version = "1.0.0" + """) + + in_tmp("compile gleam dependency failure", fn -> + Mix.Project.push(BrokenGleamAsDep) + + Mix.Tasks.Deps.Get.run([]) + + assert_raise Mix.Error, ~r/File IO failure/, fn -> + Mix.Tasks.Deps.Compile.run([]) + end + end) + end + end +end diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index faba7d07f7d..ae8d0b1ab01 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -51,6 +51,14 @@ re_import_exclude = [:re_import] end +gleam_exclude = + try do + Mix.Gleam.requirements!() + [] + rescue + Mix.Error -> [gleam: true] + end + Code.require_file("../../elixir/scripts/cover_record.exs", __DIR__) cover_exclude = @@ -68,7 +76,10 @@ ex_unit_opts = exclude: epmd_exclude ++ deterministic_exclude ++ - os_exclude ++ git_exclude ++ line_exclude ++ cover_exclude ++ re_import_exclude, + os_exclude ++ + git_exclude ++ + line_exclude ++ + cover_exclude ++ re_import_exclude ++ gleam_exclude, include: line_include, assert_receive_timeout: String.to_integer(System.get_env("ELIXIR_ASSERT_TIMEOUT", "300")) ] ++ maybe_seed_opt @@ -293,7 +304,7 @@ rebar3_target = Path.join([mix, "elixir", version_dir, "rebar3"]) File.mkdir_p!(Path.dirname(rebar3_target)) File.cp!(rebar3_source, rebar3_target) -fixtures = ~w(rebar_dep rebar_override) +fixtures = ~w(rebar_dep rebar_override gleam_dep) Enum.each(fixtures, fn fixture -> source = MixTest.Case.fixture_path(fixture)