diff --git a/lib/mix/lib/mix/dep.ex b/lib/mix/lib/mix/dep.ex index 3f98135148..80acfb7ce4 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 @@ -73,7 +73,7 @@ defmodule Mix.Dep do status: {:ok, String.t() | nil} | atom | tuple, opts: keyword, top_level: boolean, - manager: :rebar3 | :mix | :make | nil, + manager: :rebar3 | :mix | :make | :gleam | nil, from: String.t(), extra: term, system_env: keyword @@ -535,6 +535,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 1d036c4982..5ae5afe7fb 100644 --- a/lib/mix/lib/mix/dep/converger.ex +++ b/lib/mix/lib/mix/dep/converger.ex @@ -426,7 +426,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 984fa32e47..63187a3d3a 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 @@ -220,7 +223,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) @@ -246,6 +249,9 @@ defmodule Mix.Dep.Loader do any_of?(dest, ["Makefile", "Makefile.win"]) -> :make + any_of?(dest, ["gleam.toml"]) -> + :gleam + true -> nil end @@ -361,6 +367,20 @@ defmodule Mix.Dep.Loader do {dep, []} end + defp gleam_dep(%Mix.Dep{opts: opts} = dep, _children = nil, manager, locked?) do + Mix.Gleam.require!() + + config = File.cd!(opts[:dest], fn -> Mix.Gleam.load_config(".") end) + from = Path.join(opts[: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 0000000000..9ae3fb2780 --- /dev/null +++ b/lib/mix/lib/mix/gleam.ex @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team +# SPDX-FileCopyrightText: 2012 Plataformatec + +defmodule Mix.Gleam do + # Version that introduced `gleam export package-information` command + @required_gleam_version ">= 1.10.0" + + def load_config(dir) do + File.cd!(dir, fn -> + gleam!(["export", "package-information", "--out", "/dev/stdout"]) + |> JSON.decode!() + |> Map.fetch!("gleam.toml") + |> parse_config() + end) + end + + def parse_config(json) do + deps = + Map.get(json, "dependencies", %{}) + |> Enum.map(&parse_dep/1) + + dev_deps = + Map.get(json, "dev-dependencies", %{}) + |> Enum.map(&parse_dep(&1, only: :dev)) + + %{ + name: Map.fetch!(json, "name"), + version: Map.fetch!(json, "version"), + deps: deps ++ dev_deps + } + |> maybe_gleam_version(json) + |> maybe_erlang_opts(json) + rescue + KeyError -> + Mix.raise("Command \"gleam export package-information\" unexpected format: \n" <> json) + end + + defp parse_dep({dep, requirement}, opts \\ []) do + dep = String.to_atom(dep) + + spec = + case requirement do + %{"version" => version} -> + {dep, version, opts} + + %{"path" => path} -> + {dep, Keyword.merge(opts, path: Path.expand(path))} + + %{"git" => git, "ref" => ref} -> + {dep, git: git, ref: ref} + + _ -> + Mix.raise("Gleam package #{dep} has unsupported requirement: #{inspect(requirement)}") + end + + case spec do + {dep, version, []} -> {dep, version} + spec -> spec + end + end + + defp maybe_gleam_version(config, json) do + case json["gleam"] do + nil -> config + version -> Map.put(config, :gleam, version) + end + end + + defp maybe_erlang_opts(config, json) do + config = + case get_in(json, ["erlang", "application_start_module"]) do + nil -> config + mod -> Map.put(config, :mod, mod) + end + + case get_in(json, ["erlang", "extra_applications"]) do + nil -> config + extra_applications -> Map.put(config, :extra_applications, extra_applications) + end + end + + def require!() do + available_version() + |> Version.match?(@required_gleam_version) + end + + defp available_version do + case gleam!(["--version"]) do + "gleam " <> version -> Version.parse!(version) |> Version.to_string() + output -> Mix.raise("Command \"gleam --version\" unexpected format: #{output}") + end + rescue + e in Version.InvalidVersionError -> + Mix.raise("Command \"gleam --version\" invalid version format: #{e.version}") + end + + defp gleam!(args) do + System.cmd("gleam", args) + catch + :error, :enoent -> + Mix.raise( + "The \"gleam\" executable is not available in your PATH. " <> + "Please install it, as one of your dependencies requires it. " + ) + else + {response, 0} -> + String.trim(response) + + {response, _} when is_binary(response) -> + Mix.raise("Command \"gleam #{Enum.join(args, " ")}\" failed with reason: #{response}") + + {_, _} -> + Mix.raise("Command \"gleam #{Enum.join(args, " ")}\" failed") + end +end diff --git a/lib/mix/lib/mix/task.compiler.ex b/lib/mix/lib/mix/task.compiler.ex index 730234afdf..7f399d7a80 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 962b7be9f3..5761020543 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: @@ -139,9 +140,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)" ) @@ -320,6 +324,63 @@ defmodule Mix.Tasks.Deps.Compile do true end + defp do_gleam(%Mix.Dep{opts: opts} = dep, config) do + Mix.Gleam.require!() + Mix.Project.ensure_structure() + + lib = Path.join(Mix.Project.build_path(), "lib") + out = opts[:build] + package = opts[:dest] + + command = + {"gleam", + [ + "compile-package", + "--no-beam", + "--target", + "erlang", + "--package", + package, + "--out", + out, + "--lib", + lib + ]} + + shell_cmd!(dep, config, command) + + File.cd!(package, fn -> Mix.Gleam.load_config(".") end) + |> push_gleam_project(dep, Keyword.fetch!(config, :deps_path)) + + Code.prepend_path(Path.join(out, "ebin"), cache: true) + end + + defp push_gleam_project(toml, dep, deps_path) do + build = Path.expand(dep.opts[:build]) + src = Path.join(build, "_gleam_artefacts") + File.mkdir(Path.join(build, "ebin")) + + config = + [ + app: dep.app, + version: toml.version, + deps: toml.deps, + build_per_environment: true, + lockfile: "mix.lock", + # Remove per-environment segment from the path since ProjectStack.push below will append it + build_path: Mix.Project.build_path() |> Path.split() |> Enum.drop(-1) |> Path.join(), + deps_path: deps_path, + erlc_paths: [src], + erlc_include_path: Path.join(build, "include") + ] + + Mix.ProjectStack.pop() + Mix.ProjectStack.push(dep.app, config, "nofile") + # Somehow running just `compile` task won't work (doesn't compile the .erl files) + Mix.Task.run("compile.erlang", ["--force"]) + Mix.Task.run("compile.app") + end + defp make_command(dep) do makefile_win? = makefile_win?(dep) @@ -355,7 +416,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 1a216730f2..a23867a602 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 0000000000..eefc9c554f --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/.gitignore @@ -0,0 +1,11 @@ +# The directory Mix 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 0000000000..0a25008790 --- /dev/null +++ b/lib/mix/test/fixtures/gleam_dep/gleam.toml @@ -0,0 +1,24 @@ +name = "gleam_dep" +version = "1.0.0" + +# 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.44.0 and < 2.0.0" +gleam_otp = ">= 0.16.1 and < 1.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" + +[erlang] +extra_applications = ["ssl"] +application_start_module = "gleam_dep@somemodule" 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 0000000000..f7e3f2b653 --- /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 0000000000..ea2ed915e7 --- /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 0000000000..4f11d986b2 --- /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 0000000000..537e287c68 --- /dev/null +++ b/lib/mix/test/mix/gleam_test.exs @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2021 The Elixir Team +# SPDX-FileCopyrightText: 2012 Plataformatec + +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"), app: false} + ] + ] + 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.44.0 and < 2.0.0"}, + {:gleam_otp, ">= 0.16.1 and < 1.0.0"}, + {:gleeunit, ">= 1.0.0 and < 2.0.0", only: :dev} + ] + + assert Enum.sort(config[:deps]) == Enum.sort(expected) + end + end + + describe "gleam export package-information format" do + test "parse_config" do + 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"}, + "my_other_project" => %{"path" => "../my_other_project"} + }, + "dev-dependencies" => %{ + "gleeunit" => %{"version" => ">= 1.0.0 and < 2.0.0"} + }, + "erlang" => %{ + "application_start_module" => "some@application" + } + } + |> Mix.Gleam.parse_config() + + assert config == %{ + name: "gael", + version: "1.0.0", + gleam: ">= 1.8.0", + deps: [ + {:git_dep, git: "../git_dep", ref: "957b83b"}, + {:gleam_stdlib, ">= 0.18.0 and < 2.0.0"}, + {:my_other_project, path: "../my_other_project"}, + {:gleeunit, ">= 1.0.0 and < 2.0.0", only: :dev} + ], + application: [ + mod: {:some@application, []} + ] + } + 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, content} = :file.consult("_build/dev/lib/gleam_dep/ebin/gleam_dep.app") + + assert content == [ + { + :application, + :gleam_dep, + [ + {:modules, [:collocated_erlang, :gleam_dep]}, + {:optional_applications, []}, + {:applications, + [:kernel, :stdlib, :elixir, :gleam_otp, :gleam_stdlib, :gleeunit]}, + {:description, ~c"gleam_dep"}, + {:registered, []}, + {:vsn, ~c"1.0.0"} + # Need to add support for :application option in Compile.App + # {:mod, {:gleam_dep@somemodule, []}} + ] + } + ] + end) + end + end +end diff --git a/lib/mix/test/test_helper.exs b/lib/mix/test/test_helper.exs index fcf9e80f77..e14d6d06a5 100644 --- a/lib/mix/test/test_helper.exs +++ b/lib/mix/test/test_helper.exs @@ -43,12 +43,21 @@ cover_exclude = [] end +gleam_exclude = + try do + Mix.Gleam.require!() + [] + rescue + Mix.Error -> [gleam: true] + end + Code.require_file("../../elixir/scripts/cover_record.exs", __DIR__) CoverageRecorder.maybe_record("mix") ExUnit.start( trace: !!System.get_env("TRACE"), - exclude: epmd_exclude ++ os_exclude ++ git_exclude ++ line_exclude ++ cover_exclude, + exclude: + epmd_exclude ++ os_exclude ++ git_exclude ++ line_exclude ++ cover_exclude ++ gleam_exclude, include: line_include ) @@ -275,6 +284,15 @@ Enum.each(fixtures, fn fixture -> File.cp_r!(source, dest) end) +## Set up Gleam fixtures + +fixture = "gleam_dep" + +source = MixTest.Case.fixture_path(fixture) +dest = MixTest.Case.tmp_path(fixture) +File.mkdir_p!(dest) +File.cp_r!(source, dest) + ## Set up Git fixtures System.cmd("git", ~w[config --global user.email mix@example.com])