Skip to content

Commit 2232dab

Browse files
committed
port installer changes from upstream
1 parent bf29634 commit 2232dab

File tree

3 files changed

+275
-37
lines changed

3 files changed

+275
-37
lines changed

scripts/installer.exs

Lines changed: 151 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,20 @@ defmodule ElixirLS.Mix do
6161

6262
@mix_install_project Mix.InstallProject
6363

64-
# This is a forked version of https://github.com/elixir-lang/elixir/blob/c521bdb91a77b36be16fdf18d632ad7719de4f91/lib/mix/lib/mix.ex#L765
65-
# with added option to disable stopping apps after install
66-
# we don't want hex app stopped
64+
# This is a forked version of Mix.install from Elixir's lib/mix/lib/mix.ex
65+
# Originally forked from commit c521bdb91a77b36be16fdf18d632ad7719de4f91
66+
# Updated with upstream changes through the current Elixir version
67+
# Includes custom option :stop_started_applications to prevent stopping hex app
68+
#
69+
# Backward compatibility: Supports Elixir 1.13+
70+
# Features gated by Version.match? checks:
71+
# - Keyword.validate! for option validation (Elixir 1.13+, uses function_exported?)
72+
# - Mix.Dep.Lock.read/1 and write/2 for external lockfile (Elixir 1.14+)
73+
# - Mix.Task.clear/0 for task cleanup (Elixir 1.14+)
74+
# - MIX_INSTALL_RESTORE_PROJECT_DIR support (Elixir 1.16.2+)
75+
# - Shorter install directory paths ex-X-erl-Y (Elixir 1.19+)
76+
# - Cache ID encoding with url_encode64 (Elixir 1.19+)
77+
#
6778
# The original code is licensed under
6879

6980
# Apache License
@@ -277,71 +288,112 @@ defmodule ElixirLS.Mix do
277288
other
278289
end)
279290

280-
config = Keyword.get(opts, :config, [])
291+
# Use Keyword.validate! if available (Elixir 1.13+), otherwise manual validation
292+
opts =
293+
if function_exported?(Keyword, :validate!, 2) do
294+
Keyword.validate!(opts,
295+
config: [],
296+
config_path: nil,
297+
consolidate_protocols: true,
298+
compilers: [:elixir],
299+
elixir: nil,
300+
force: false,
301+
lockfile: nil,
302+
runtime_config: [],
303+
start_applications: true,
304+
# custom elixirLS option
305+
stop_started_applications: true,
306+
system_env: [],
307+
verbose: false
308+
)
309+
else
310+
# Fallback for older Elixir versions
311+
opts
312+
end
313+
281314
config_path = expand_path(opts[:config_path], deps, :config_path, "config/config.exs")
315+
config = Keyword.get(opts, :config, [])
282316
system_env = Keyword.get(opts, :system_env, [])
283317
consolidate_protocols? = Keyword.get(opts, :consolidate_protocols, true)
284318
start_applications? = Keyword.get(opts, :start_applications, true)
319+
compilers = Keyword.get(opts, :compilers, [:elixir])
285320
# custom elixirLS option
286321
stop_started_applications? = Keyword.get(opts, :stop_started_applications, true)
287322

288323
id =
289-
{deps, config, system_env, consolidate_protocols?}
290-
|> :erlang.term_to_binary()
291-
|> :erlang.md5()
292-
|> Base.encode16(case: :lower)
324+
if Version.match?(System.version(), ">= 1.19.0-dev") do
325+
{deps, config, system_env, consolidate_protocols?}
326+
|> :erlang.term_to_binary()
327+
|> :erlang.md5()
328+
|> Base.url_encode64(padding: false)
329+
else
330+
{deps, config, system_env, consolidate_protocols?}
331+
|> :erlang.term_to_binary()
332+
|> :erlang.md5()
333+
|> Base.encode16(case: :lower)
334+
end
293335

294-
force? = System.get_env("MIX_INSTALL_FORCE") in ["1", "true"] or !!opts[:force]
336+
force? =
337+
System.get_env("MIX_INSTALL_FORCE") in ["1", "true"] or Keyword.get(opts, :force, false)
295338

296339
case Mix.State.get(:installed) do
297340
nil ->
298-
Application.put_all_env(config, persistent: true)
299341
System.put_env(system_env)
342+
install_project_dir = install_dir(id)
300343

301-
install_dir = install_dir(id)
302-
303-
if opts[:verbose] do
304-
Mix.shell().info("Mix.install/2 using #{install_dir}")
344+
if Keyword.get(opts, :verbose, false) do
345+
Mix.shell().info("Mix.install/2 using #{install_project_dir}")
305346
end
306347

307348
if force? do
308-
File.rm_rf!(install_dir)
349+
File.rm_rf!(install_project_dir)
309350
end
310351

311-
config = [
312-
version: "0.1.0",
313-
build_embedded: false,
314-
build_per_environment: true,
315-
build_path: "_build",
316-
lockfile: "mix.lock",
317-
deps_path: "deps",
352+
dynamic_config = [
318353
deps: deps,
319-
app: :mix_install,
320-
erlc_paths: [],
321-
elixirc_paths: [],
322-
compilers: [],
323354
consolidate_protocols: consolidate_protocols?,
324355
config_path: config_path,
325-
prune_code_paths: false
356+
compilers: compilers
326357
]
327358

359+
:ok =
360+
Mix.ProjectStack.push(
361+
@mix_install_project,
362+
[compile_config: config] ++ install_project_config(dynamic_config),
363+
"nofile"
364+
)
365+
328366
started_apps = Application.started_applications()
329-
:ok = Mix.ProjectStack.push(@mix_install_project, config, "nofile")
330-
build_dir = Path.join(install_dir, "_build")
367+
build_dir = Path.join(install_project_dir, "_build")
331368
external_lockfile = expand_path(opts[:lockfile], deps, :lockfile, "mix.lock")
332369

333370
try do
334371
first_build? = not File.dir?(build_dir)
335-
File.mkdir_p!(install_dir)
336372

337-
File.cd!(install_dir, fn ->
373+
# MIX_INSTALL_RESTORE_PROJECT_DIR support (Elixir 1.16.2+)
374+
restore_dir =
375+
if Version.match?(System.version(), ">= 1.16.2-dev") do
376+
System.get_env("MIX_INSTALL_RESTORE_PROJECT_DIR")
377+
end
378+
379+
if first_build? and restore_dir != nil and not force? do
380+
File.cp_r(restore_dir, install_project_dir)
381+
remove_dep(install_project_dir, "mix_install")
382+
end
383+
384+
File.mkdir_p!(install_project_dir)
385+
386+
File.cd!(install_project_dir, fn ->
387+
# This step needs to be mirrored in mix deps.partition
388+
Application.put_all_env(config, persistent: true)
389+
338390
if config_path do
339391
Mix.Task.rerun("loadconfig")
340392
end
341393

342394
cond do
343395
external_lockfile ->
344-
md5_path = Path.join(install_dir, "merge.lock.md5")
396+
md5_path = Path.join(install_project_dir, "merge.lock.md5")
345397

346398
old_md5 =
347399
case File.read(md5_path) do
@@ -352,8 +404,9 @@ defmodule ElixirLS.Mix do
352404
new_md5 = external_lockfile |> File.read!() |> :erlang.md5()
353405

354406
if old_md5 != new_md5 do
407+
# Mix.Dep.Lock.read/1 and write/2 were added in Elixir 1.14
355408
if Version.match?(System.version(), ">= 1.14.0-dev") do
356-
lockfile = Path.join(install_dir, "mix.lock")
409+
lockfile = Path.join(install_project_dir, "mix.lock")
357410
old_lock = Mix.Dep.Lock.read(lockfile)
358411
new_lock = Mix.Dep.Lock.read(external_lockfile)
359412
Mix.Dep.Lock.write(Map.merge(old_lock, new_lock), file: lockfile)
@@ -362,7 +415,8 @@ defmodule ElixirLS.Mix do
362415
else
363416
IO.puts(
364417
:stderr,
365-
"Lockfile conflict. Please clean up your mix install directory #{install_dir}"
418+
"External lockfile support requires Elixir 1.14+. " <>
419+
"Please upgrade or remove the :lockfile option from Mix.install/2"
366420
)
367421

368422
System.halt(1)
@@ -401,13 +455,24 @@ defmodule ElixirLS.Mix do
401455
end
402456
end
403457

404-
Mix.State.put(:installed, id)
458+
if restore_dir do
459+
remove_leftover_deps(install_project_dir)
460+
end
461+
462+
Mix.State.put(:installed, {id, dynamic_config})
405463
:ok
406464
after
407465
Mix.ProjectStack.pop()
466+
# Clear all tasks invoked during installation, since there
467+
# is no reason to keep this in memory. Additionally this
468+
# allows us to rerun tasks for the dependencies later on,
469+
# such as recompilation (available in Elixir 1.14+)
470+
if Version.match?(System.version(), ">= 1.14.0-dev") do
471+
Mix.Task.clear()
472+
end
408473
end
409474

410-
^id when not force? ->
475+
{^id, _dynamic_config} when not force? ->
411476
:ok
412477

413478
_ ->
@@ -460,12 +525,61 @@ defmodule ElixirLS.Mix do
460525
:ignore
461526
end
462527

528+
defp remove_leftover_deps(install_project_dir) do
529+
build_lib_dir = Path.join([install_project_dir, "_build", "dev", "lib"])
530+
531+
deps = File.ls!(build_lib_dir)
532+
533+
loaded_deps =
534+
for {app, _description, _version} <- Application.loaded_applications(),
535+
into: MapSet.new(),
536+
do: Atom.to_string(app)
537+
538+
# We want to keep :mix_install, but it has no application
539+
loaded_deps = MapSet.put(loaded_deps, "mix_install")
540+
541+
for dep <- deps, not MapSet.member?(loaded_deps, dep) do
542+
remove_dep(install_project_dir, dep)
543+
end
544+
end
545+
546+
defp remove_dep(install_project_dir, dep) do
547+
build_lib_dir = Path.join([install_project_dir, "_build", "dev", "lib"])
548+
deps_dir = Path.join(install_project_dir, "deps")
549+
550+
build_path = Path.join(build_lib_dir, dep)
551+
File.rm_rf(build_path)
552+
dep_path = Path.join(deps_dir, dep)
553+
File.rm_rf(dep_path)
554+
end
555+
556+
defp install_project_config(dynamic_config) do
557+
[
558+
version: "1.0.0",
559+
build_per_environment: true,
560+
build_path: "_build",
561+
lockfile: "mix.lock",
562+
deps_path: "deps",
563+
app: :mix_install,
564+
erlc_paths: [],
565+
elixirc_paths: [],
566+
prune_code_paths: false
567+
] ++ dynamic_config
568+
end
569+
463570
defp install_dir(cache_id) do
464571
install_root =
465572
System.get_env("MIX_INSTALL_DIR") ||
466573
Path.join(Mix.Utils.mix_cache(), "installs")
467574

468-
version = "elixir-#{System.version()}-erts-#{:erlang.system_info(:version)}"
575+
# Use shorter path format in Elixir 1.19+
576+
version =
577+
if Version.match?(System.version(), ">= 1.19.0-dev") do
578+
"ex-#{System.version()}-erl-#{:erlang.system_info(:version)}"
579+
else
580+
"elixir-#{System.version()}-erts-#{:erlang.system_info(:version)}"
581+
end
582+
469583
Path.join([install_root, version, cache_id])
470584
end
471585

scripts/test_installer.sh

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Test installer.exs across Elixir versions using Docker
5+
# Usage: ELIXIR_VERSION=1.13 ./test_installer.sh
6+
# Or: ./test_installer.sh 1.13
7+
8+
ELIXIR_VERSION="${1:-${ELIXIR_VERSION:-latest}}"
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
11+
echo "Testing installer.exs with Elixir ${ELIXIR_VERSION}..."
12+
13+
# Run test in Docker
14+
docker run --rm \
15+
-v "${SCRIPT_DIR}:/scripts:ro" \
16+
-w /tmp \
17+
"elixir:${ELIXIR_VERSION}" \
18+
bash -c '
19+
set -e
20+
21+
echo "==> Elixir version: $(elixir --version | grep Elixir)"
22+
23+
echo "==> Installing Hex..."
24+
mix local.hex --force
25+
26+
echo "==> Compiling installer.exs..."
27+
elixir --no-halt -e "Code.compile_file(\"/scripts/installer.exs\"); System.halt(0)"
28+
29+
echo "==> Testing Mix.install with simple dependency..."
30+
mix run --no-mix-exs -e "
31+
Code.require_file(\"/scripts/installer.exs\")
32+
33+
# Test basic installation
34+
ElixirLS.Mix.install([{:jason, \"~> 1.0\"}], verbose: true, stop_started_applications: false)
35+
36+
# Verify Jason is available
37+
unless Code.ensure_loaded?(Jason) do
38+
IO.puts(:stderr, \"ERROR: Jason not loaded after install\")
39+
System.halt(1)
40+
end
41+
42+
# Test JSON encoding to ensure it works
43+
result = Jason.encode!(%{test: \"success\"})
44+
IO.puts(\"✓ Successfully installed and used Jason: #{result}\")
45+
46+
# Test that install_project_dir works
47+
if function_exported?(Mix, :installed?, 0) and Mix.installed?() do
48+
IO.puts(\"✓ Mix.installed?() = true\")
49+
else
50+
IO.puts(\"✓ Mix.install state tracked (older API)\")
51+
end
52+
"
53+
54+
echo "==> All tests passed for Elixir ${ELIXIR_VERSION}!"
55+
'
56+
57+
echo "✓ Tests completed successfully for Elixir ${ELIXIR_VERSION}"

0 commit comments

Comments
 (0)