├── VERSION ├── test ├── test_helper.exs ├── test.proto └── compile_proto_test.exs ├── .formatter.exs ├── mix.exs ├── .gitignore ├── CHANGELOG.md ├── mix.lock ├── README.md └── lib └── mix └── tasks └── compile.proto.ex /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.0 -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | message EmptyMessage {} // empty message 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CompileProto.MixProject do 2 | use Mix.Project 3 | 4 | @version "./VERSION" |> File.read!() |> String.trim() 5 | 6 | def project do 7 | [ 8 | app: :protobuf_compiler, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | extra_applications: [:logger], 19 | env: [ 20 | plugin_version: "0.9.0" 21 | ] 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:ex_doc, ">= 0.0.0", runtime: false} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | compile_proto-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.0] 9 | 10 | ### Changed 11 | 12 | - `mix compile.proto` changed to install plugin automatically 13 | - compiler now supports specifying plugin version via the config 14 | - updated to support `protobuf` v0.9.0 which include additional options 15 | - removed `protobuf` as a dependency - user needs to include 16 | - project name changed to `protobuf_compiler` 17 | 18 | ## [0.1.1] 19 | 20 | ### Added 21 | - CHANGELOG 22 | 23 | ### Changed 24 | 25 | - fixed breaking `mix test` 26 | - switched `protobuf` dependency to `OffgridElectric/protobuf` 27 | 28 | ## [0.1.0] 29 | 30 | Initial release 31 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 3 | "ex_doc": {:hex, :ex_doc, "0.25.0", "4070a254664ee5495c2f7cce87c2f43064a8752f7976f2de4937b65871b05223", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2d90883bd4f3d826af0bde7fea733a4c20adba1c79158e2330f7465821c8949b"}, 4 | "google_protos": {:hex, :google_protos, "0.1.0", "c6b9e12092d17571b093d4156d004494ca143b65dbbcbfc3ffff463ea03467c0", [:mix], [{:protobuf, "~> 0.5", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "ff5564525f89d2638a4cfa9fb4d31e9ee9d9d7cb937b3e8a95f558440c039e1b"}, 5 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 9 | "protobuf": {:hex, :protobuf, "0.9.0", "9c1633ecc098f3d7ec0a00503e070541b0e1868114fff41523934888442319e7", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "15fb7cddc5f85b8055fedaf81a9093020e4cd283647a21deb8f7de8d243abb9d"}, 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProtobufCompiler 2 | 3 | Provides a mix task `mix compile.proto` for easier integration of `elixir-protobuf/protobuf`. 4 | 5 | The task will: 6 | 1. Fetch options, gather `.proto` sources 7 | 2. Check the `protoc` binary exists and is executable 8 | 3. Check the `protoc-gen-elixir` plugin is available and executable 9 | 4. Ensure the target directory exists 10 | 5. Check if any of the sources are "stale" 11 | 6. Compile each file replacing the basename `.proto` with `.pb.ex` 12 | 13 | ## Installation 14 | 15 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 16 | by adding `protobuf_compiler` to your list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:protobuf, "~> 0.9.0"}, 22 | {:protobuf_compiler, "~> 0.2.0"} 23 | ] 24 | end 25 | ``` 26 | 27 | Protoc options can be set in the project: 28 | 29 | ```elixir 30 | defmodule MyProject.MixProject do 31 | use Mix.Project 32 | 33 | def project do 34 | [ 35 | app: :my_project, 36 | ... 37 | protoc_opts: [ 38 | paths: ["lib"], 39 | dest: "lib/protobuf/", 40 | gen_descriptors: true 41 | ] 42 | ] 43 | end 44 | ``` 45 | 46 | ## Module prefix 47 | 48 | ```elixir 49 | defmodule MyProject.MixProject do 50 | use Mix.Project 51 | 52 | def project do 53 | [ 54 | app: :my_project, 55 | ... 56 | protoc_opts: [ 57 | ... 58 | package_prefix: "Custom" 59 | ] 60 | ] 61 | end 62 | ``` 63 | 64 | Your modules will now be generated with a namespace: 65 | 66 | ```elixir 67 | defmodule Custom.Example do 68 | @moduledoc false 69 | use Protobuf, syntax: :proto2 70 | @type t :: %__MODULE__{} 71 | 72 | defstruct [] 73 | end 74 | ``` 75 | 76 | ## Plugin Version 77 | 78 | There is a strict dependency between `protobuf` library version and plugin used 79 | for code generation. 80 | 81 | This compiler tries to install the best suited plugin version: 82 | * if `protobuf` is a dep in your application, plugin is built from sources; else 83 | * if `protoc-gen-elixir` is found in your `PATH`, it will be used (mostly 84 | intended to developers); else 85 | * compiler uses `mix escript.install hex` to install prebuilt version of the 86 | plugin. 87 | 88 | When not built from source, you can force a specific version of the plugin with 89 | the config: 90 | 91 | ```elixir 92 | config :protobuf_compiler, plugin_version: "0.8.0" 93 | ``` 94 | 95 | ## Documentation 96 | 97 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 98 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 99 | be found at [https://hexdocs.pm/protobuf_compiler](https://hexdocs.pm/protobuf_compiler). 100 | 101 | -------------------------------------------------------------------------------- /test/compile_proto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CompileProtoTest do 2 | use ExUnit.Case 3 | 4 | alias Mix.Tasks.Compile.Proto 5 | 6 | setup do 7 | # Get Mix output sent to the current process to avoid polluting tests. 8 | Mix.shell(Mix.Shell.Process) 9 | 10 | on_exit(fn -> Mix.shell(Mix.Shell.IO) end) 11 | 12 | :ok 13 | end 14 | 15 | test "use default options" do 16 | result = Proto.run([]) 17 | 18 | assert result == :ok 19 | end 20 | 21 | test "accepts options" do 22 | options = [ 23 | paths: ["test"], 24 | dest: Path.expand("test/output"), 25 | gen_descriptors: true 26 | ] 27 | 28 | result = Proto.run(options) 29 | _ = Proto.clean() 30 | 31 | assert result == :ok 32 | end 33 | 34 | describe ".do_protoc_args/3" do 35 | test "destdir" do 36 | s = %Proto.State{opts: %Proto.Options{}} 37 | args = Proto.do_protoc_args(s, [], "/destdir") 38 | 39 | assert ["--elixir_out=/destdir"] == args 40 | end 41 | 42 | test "sources" do 43 | s = %Proto.State{opts: %Proto.Options{}} 44 | args = Proto.do_protoc_args(s, ["a.proto", "b.proto"], "/destdir") 45 | 46 | assert ["-I.", "--elixir_out=/destdir", "a.proto", "b.proto"] == args 47 | end 48 | 49 | test "sources with different dirname" do 50 | s = %Proto.State{opts: %Proto.Options{}} 51 | args = Proto.do_protoc_args(s, ["/dira/a.proto", "/dirb/b.proto"], "/destdir") 52 | 53 | assert ["-I/dira", "-I/dirb", "--elixir_out=/destdir", "/dira/a.proto", "/dirb/b.proto"] == 54 | args 55 | end 56 | 57 | test "additional includes" do 58 | s = %Proto.State{opts: %Proto.Options{includes: ["/additional"]}} 59 | args = Proto.do_protoc_args(s, ["/dira/a.proto", "/dirb/b.proto"], "/destdir") 60 | 61 | assert [ 62 | "-I/dira", 63 | "-I/dirb", 64 | "-I/additional", 65 | "--elixir_out=/destdir", 66 | "/dira/a.proto", 67 | "/dirb/b.proto" 68 | ] == args 69 | end 70 | 71 | test "plugins" do 72 | s = %Proto.State{opts: %Proto.Options{plugins: ["grpc"]}} 73 | args = Proto.do_protoc_args(s, ["a.proto"], "/destdir") 74 | 75 | assert ["-I.", "--elixir_out=plugins=grpc:/destdir", "a.proto"] == args 76 | end 77 | 78 | test "gen_descriptors=true" do 79 | s = %Proto.State{opts: %Proto.Options{gen_descriptors: true}} 80 | args = Proto.do_protoc_args(s, ["a.proto"], "/destdir") 81 | 82 | assert ["-I.", "--elixir_out=gen_descriptors=true:/destdir", "a.proto"] == args 83 | end 84 | 85 | test "package_prefix=..." do 86 | s = %Proto.State{opts: %Proto.Options{package_prefix: "prefix"}} 87 | args = Proto.do_protoc_args(s, ["a.proto"], "/destdir") 88 | 89 | assert ["-I.", "--elixir_out=package_prefix=prefix:/destdir", "a.proto"] == args 90 | end 91 | 92 | test "transform_module=module" do 93 | s = %Proto.State{opts: %Proto.Options{transform_module: "App.Module"}} 94 | args = Proto.do_protoc_args(s, ["a.proto"], "/destdir") 95 | 96 | assert ["-I.", "--elixir_out=transform_module=App.Module:/destdir", "a.proto"] == args 97 | end 98 | 99 | test "one_file_per_module=true" do 100 | s = %Proto.State{opts: %Proto.Options{one_file_per_module: true}} 101 | args = Proto.do_protoc_args(s, ["a.proto"], "/destdir") 102 | 103 | assert ["-I.", "--elixir_out=one_file_per_module=true:/destdir", "a.proto"] == args 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/mix/tasks/compile.proto.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Compile.Proto do 2 | @moduledoc """ 3 | Compiles `.proto` files into `.pb.ex`. 4 | 5 | ## Usage 6 | 7 | This compiler can be added to the list of compilers for a project: 8 | 9 | ``` 10 | def project do 11 | [ 12 | ... 13 | compilers: [ :proto | Mix.compilers() ], 14 | protoc_opts: [ 15 | paths: ["proto"], 16 | sources: ["lib/my.proto"], 17 | transform_module: ... 18 | ] 19 | ... 20 | ] 21 | end 22 | ``` 23 | 24 | It can also be called as a mix task: 25 | 26 | ``` 27 | mix compile.proto 28 | ``` 29 | 30 | ## Compiler options 31 | 32 | * `:paths` - `[String.t()]` - directories to look for sources (default: 33 | `elixirc_paths`) 34 | * `:sources` - [String.t()] - additional `.proto` files to compile 35 | * `:dest` - `String.t()` - directory where to put generated files (default: 36 | `hd(elixirc_paths)`) 37 | * `:includes` - `[String.t()]` - directories to look for imports 38 | * `:plugins` - `[String.t()]` - list of plugin name, eg `["grpc"]` 39 | * `:gen_descriptors` - `boolean` - if true, generates descriptors (default: 40 | `false`) 41 | * `:package_prefix` - `String.t()`- package prefix 42 | * `:transform_module` - `atom() | String.t()`- transformation module (see 43 | `Protobuf.TransformModule` behaviour) 44 | * `:one_file_per_module` - `boolean` - generates one file per module 45 | """ 46 | 47 | defmodule Options do 48 | @moduledoc """ 49 | Defines a structure for compiler options 50 | """ 51 | defstruct paths: [], 52 | sources: [], 53 | dest: nil, 54 | includes: [], 55 | plugins: [], 56 | gen_descriptors: false, 57 | package_prefix: nil, 58 | transform_module: nil, 59 | one_file_per_module: false 60 | end 61 | 62 | defmodule State do 63 | @moduledoc false 64 | defstruct errors: [], 65 | env: [], 66 | sources: [], 67 | opts: nil, 68 | manifest: nil, 69 | force: false, 70 | version: nil 71 | end 72 | 73 | defmodule Manifest do 74 | @moduledoc false 75 | defstruct sources: [], targets: [] 76 | end 77 | 78 | @shortdoc "Compiles .proto file into elixir files" 79 | 80 | @task_name "compile.proto" 81 | 82 | @plugin "protoc-gen-elixir" 83 | @manifest "compile.proto.manifest" 84 | @manifest_vsn 2 85 | 86 | use Mix.Task.Compiler 87 | 88 | @doc false 89 | @impl true 90 | def run(_args) do 91 | Application.ensure_loaded(:protobuf_compiler) 92 | 93 | %State{opts: get_options(), manifest: parse_manifest(manifest())} 94 | |> set_force() 95 | |> check_exec("protoc") 96 | |> ensure_plugin() 97 | |> get_sources() 98 | |> do_compile() 99 | |> case do 100 | %State{errors: []} -> 101 | :ok 102 | 103 | %State{errors: errors} -> 104 | Enum.each(errors, &error/1) 105 | {:error, errors} 106 | end 107 | end 108 | 109 | @doc false 110 | @impl true 111 | def manifests, do: [manifest()] 112 | defp manifest, do: Path.join(Mix.Project.manifest_path(), @manifest) 113 | 114 | @doc false 115 | @impl true 116 | def clean do 117 | %State{opts: get_options(), manifest: parse_manifest(manifest())} 118 | |> do_clean() 119 | end 120 | 121 | ### 122 | ### Priv 123 | ### 124 | defp do_clean(%State{manifest: %Manifest{targets: targets}}) do 125 | targets 126 | |> Enum.each(fn target -> 127 | _ = info(target, "compile.clean") 128 | File.rm(target) 129 | end) 130 | end 131 | 132 | defp do_compile(%State{sources: []} = s), do: s 133 | 134 | defp do_compile(%State{errors: [], sources: srcs, opts: opts} = s) do 135 | timestamp = System.os_time(:second) 136 | :ok = File.mkdir_p(opts.dest) 137 | 138 | if s.force do 139 | do_clean(s) 140 | end 141 | 142 | if Mix.Utils.stale?(srcs, targets(srcs, s)) do 143 | tmpdir = Path.join(Mix.Project.manifest_path(), "#{:erlang.phash2(make_ref())}") 144 | do_protoc(s, srcs, tmpdir) 145 | else 146 | s 147 | end 148 | |> write_manifest(timestamp) 149 | end 150 | 151 | defp do_compile(s), do: s 152 | 153 | defp get_sources(s) do 154 | sources = 155 | Enum.flat_map(s.opts.paths, fn srcdir -> 156 | Path.wildcard(Path.join(srcdir, "**/*.proto")) 157 | end) 158 | |> Kernel.++(s.opts.sources) 159 | |> MapSet.new() 160 | |> MapSet.to_list() 161 | 162 | %{s | sources: sources} 163 | end 164 | 165 | defp get_options do 166 | project = Mix.Project.config() 167 | opts = Keyword.get(project, :protoc_opts, []) 168 | 169 | dest = 170 | Keyword.get_lazy(opts, :dest, fn -> 171 | hd(Keyword.get(project, :elixirc_paths, ["lib"])) 172 | end) 173 | 174 | struct!( 175 | Options, 176 | Keyword.merge( 177 | [paths: opts[:paths] || project[:elixirc_paths], dest: dest], 178 | Keyword.take(opts, [ 179 | :sources, 180 | :includes, 181 | :plugins, 182 | :gen_descriptors, 183 | :package_prefix, 184 | :transform_module, 185 | :one_file_per_module 186 | ]) 187 | ) 188 | ) 189 | end 190 | 191 | defp set_force(s) do 192 | force = Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest()]) 193 | %{s | force: force} 194 | end 195 | 196 | defp do_protoc(s, sources, tmpdir) do 197 | _ = info(Enum.join(sources, " ")) 198 | 199 | :ok = File.mkdir_p!(tmpdir) 200 | args = do_protoc_args(s, sources, tmpdir) 201 | cmd = "protoc " <> Enum.join(args, " ") 202 | 203 | if Mix.shell().cmd(cmd, env: s.env) == 0 do 204 | targets = 205 | tmpdir 206 | |> Path.join("**/*.pb.ex") 207 | |> Path.wildcard() 208 | |> Enum.map(&Path.relative_to(&1, tmpdir)) 209 | 210 | s 211 | |> move_to_destdir(tmpdir, targets) 212 | |> update_manifest(sources, targets) 213 | else 214 | %{s | errors: s.errors ++ ["Compilation failed"]} 215 | end 216 | after 217 | File.rm_rf(tmpdir) 218 | end 219 | 220 | # For testing purpose 221 | @doc false 222 | def do_protoc_args(s, sources, tmpdir) do 223 | elixir_out_opts = 224 | s.opts 225 | |> Map.from_struct() 226 | |> Enum.reduce([], &elixir_out_opts/2) 227 | |> case do 228 | [] -> 229 | tmpdir 230 | 231 | out_opts -> 232 | Enum.join(out_opts, ",") <> ":" <> tmpdir 233 | end 234 | 235 | includes = 236 | sources 237 | |> Enum.reduce(MapSet.new(), &MapSet.put(&2, Path.dirname(&1))) 238 | |> MapSet.to_list() 239 | |> Kernel.++(s.opts.includes) 240 | 241 | [] 242 | |> Kernel.++(Enum.map(includes, &"-I#{&1}")) 243 | |> Kernel.++(["--elixir_out=" <> elixir_out_opts]) 244 | |> Kernel.++(sources) 245 | end 246 | 247 | defp move_to_destdir(s, tmpdir, targets) do 248 | targets 249 | |> Enum.each(fn target -> 250 | :ok = move(Path.join(tmpdir, target), Path.join(s.opts.dest, target)) 251 | end) 252 | 253 | s 254 | end 255 | 256 | defp update_manifest(s, sources, targets) do 257 | manifest = %Manifest{sources: sources, targets: targets} 258 | %{s | manifest: manifest} 259 | end 260 | 261 | defp targets(_sources, %{manifest: %{targets: targets}}) do 262 | targets 263 | end 264 | 265 | defp elixir_out_opts({:plugins, []}, acc), do: acc 266 | 267 | defp elixir_out_opts({:plugins, plugins}, acc), 268 | do: ["plugins=#{Enum.join(plugins, "+")}" | acc] 269 | 270 | defp elixir_out_opts({:gen_descriptors, true}, acc), 271 | do: ["gen_descriptors=true" | acc] 272 | 273 | defp elixir_out_opts({:package_prefix, nil}, acc), do: acc 274 | 275 | defp elixir_out_opts({:package_prefix, prefix}, acc), 276 | do: ["package_prefix=#{prefix}" | acc] 277 | 278 | defp elixir_out_opts({:transform_module, nil}, acc), do: acc 279 | 280 | defp elixir_out_opts({:transform_module, mod}, acc), 281 | do: ["transform_module=#{mod}" | acc] 282 | 283 | defp elixir_out_opts({:one_file_per_module, true}, acc), 284 | do: ["one_file_per_module=true" | acc] 285 | 286 | defp elixir_out_opts(_, acc), do: acc 287 | 288 | defp info(msg, task_name \\ @task_name) do 289 | Mix.shell().info([:bright, task_name, :normal, " ", msg]) 290 | end 291 | 292 | defp error(msg) do 293 | Mix.shell().info([:bright, @task_name, :normal, " ", :red, msg]) 294 | end 295 | 296 | defp check_exec(s, exec) do 297 | if System.find_executable(exec) do 298 | s 299 | else 300 | %{s | errors: ["Missing executable: #{exec}" | s.errors]} 301 | end 302 | end 303 | 304 | defp ensure_plugin(s) do 305 | if protobuf_is_dep?() do 306 | ensure_build_plugin(s) 307 | else 308 | case System.find_executable(@plugin) do 309 | nil -> 310 | install_plugin(s) 311 | 312 | path -> 313 | req = version_req() 314 | s = get_plugin_version(s, path) 315 | 316 | if match_plugin_version?(s, req) do 317 | s 318 | else 319 | Mix.shell().info("Found plugin `#{@plugin}=#{s.version}` (config: #{req})") 320 | s 321 | end 322 | end 323 | end 324 | end 325 | 326 | defp protobuf_is_dep?, do: Map.has_key?(Mix.Project.deps_paths(), :protobuf) 327 | 328 | defp ensure_build_plugin(s) do 329 | builddir = Path.join(Mix.Project.build_path(), "lib/protobuf") 330 | buildpath = Path.join(builddir, @plugin) 331 | 332 | if Mix.Utils.stale?([Mix.Project.config()[:lockfile]], [buildpath]) do 333 | build_plugin(s, builddir) 334 | else 335 | %{s | env: system_path_prepend(s.env, builddir)} 336 | end 337 | end 338 | 339 | defp build_plugin(s, destdir) do 340 | srcdir = Mix.Project.deps_paths()[:protobuf] 341 | Mix.Project.in_project(:protobuf, srcdir, fn _project -> 342 | with_env(:prod, fn -> Mix.Task.run("escript.build") end) 343 | end) 344 | 345 | destpath = Path.join(destdir, @plugin) 346 | move(Path.join(srcdir, @plugin), destpath) 347 | Mix.shell().info("[protoc] #{destpath} (from dep)") 348 | 349 | %{s | env: system_path_prepend(s.env, destdir)} 350 | end 351 | 352 | defp install_plugin(s) do 353 | escriptsdir = Mix.path_for(:escripts) 354 | env = system_path_prepend(s.env, escriptsdir) 355 | version = version_req() 356 | 357 | true = Mix.Task.run("escript.install", ["--force", "hex", "protobuf", version]) 358 | Mix.shell().info("[protoc] #{Path.join(escriptsdir, @plugin)} (= #{version})") 359 | 360 | %{s | env: env, version: version} 361 | end 362 | 363 | defp system_path_prepend(env, dir) do 364 | path = List.keyfind(env, "PATH", 0, String.split(System.get_env("PATH", ""), ":")) 365 | List.keystore(env, "PATH", 0, {"PATH", Enum.join([dir | path], ":")}) 366 | end 367 | 368 | defp with_env(env, fun) do 369 | orig = Mix.env() 370 | try do 371 | Mix.env(env) 372 | fun.() 373 | after 374 | Mix.env(orig) 375 | end 376 | end 377 | 378 | defp get_plugin_version(s, path) do 379 | case System.cmd(path, ["--version"]) do 380 | {out, 0} -> %{s | version: String.trim(out)} 381 | {_, _} -> s 382 | end 383 | end 384 | 385 | defp match_plugin_version?(%{version: nil}, _), do: false 386 | 387 | defp match_plugin_version?(%{version: version}, req) do 388 | Version.match?(version, "~> #{req}") 389 | rescue 390 | _ -> false 391 | end 392 | 393 | defp move(from, to) do 394 | with :ok <- File.mkdir_p(Path.dirname(to)), 395 | :ok <- File.rename(from, to) do 396 | :ok 397 | else 398 | {:error, :exdev} -> 399 | # `from` and `to` are on different filesystems, can not just rename 400 | xfs_move(from, to) 401 | end 402 | end 403 | 404 | defp xfs_move(from, to) do 405 | with :ok <- File.cp(from, to), 406 | :ok <- File.rm(from) do 407 | :ok 408 | end 409 | end 410 | 411 | defp write_manifest(s, timestamp) do 412 | case s.manifest do 413 | %Manifest{targets: []} -> 414 | File.rm(manifest()) 415 | 416 | manifest -> 417 | path = manifest() 418 | File.mkdir_p!(Path.dirname(path)) 419 | 420 | term = {@manifest_vsn, manifest} 421 | manifest_data = :erlang.term_to_binary(term, [:compressed]) 422 | File.write!(path, manifest_data) 423 | File.touch!(path, timestamp) 424 | end 425 | 426 | s 427 | end 428 | 429 | defp parse_manifest(path) do 430 | try do 431 | path |> File.read!() |> :erlang.binary_to_term() 432 | rescue 433 | _ -> 434 | %Manifest{} 435 | else 436 | {1, data} -> 437 | targets = 438 | data 439 | |> Enum.reduce(MapSet.new(), fn {_source, targets}, acc -> 440 | Enum.reduce(targets, acc, &MapSet.put(&2, &1)) 441 | end) 442 | |> MapSet.to_list() 443 | 444 | %Manifest{sources: Map.keys(data), targets: targets} 445 | 446 | {@manifest_vsn, %Manifest{} = data} -> 447 | data 448 | end 449 | end 450 | 451 | defp version_req, do: Application.fetch_env!(:protobuf_compiler, :plugin_version) 452 | end 453 | --------------------------------------------------------------------------------