From b696282253bc863b5c5bb9c7214905b6e9d1c5e5 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 07:46:07 -0300 Subject: [PATCH 01/18] feat: windows support --- apps/expert/lib/expert/engine_node.ex | 112 +++++++++++++++----------- apps/expert/lib/expert/port.ex | 84 +++++++++++++------ justfile | 23 ++++-- 3 files changed, 144 insertions(+), 75 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 3518f4a9..5a8baa2a 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -60,7 +60,7 @@ defmodule Expert.EngineNode do end """ | path_append_arguments(paths) - ] + ] |> dbg() env = [ @@ -216,53 +216,9 @@ defmodule Expert.EngineNode do # Expert release, and we build it on the fly for the project elixir+opt # versions if it was not built yet. defp glob_paths(%Project{} = project) do - lsp = Expert.get_lsp() - project_name = Project.name(project) - case Expert.Port.elixir_executable(project) do {:ok, elixir, env} -> - GenLSP.info(lsp, "Found elixir for #{project_name} at #{elixir}") - - expert_priv = :code.priv_dir(:expert) - packaged_engine_source = Path.join([expert_priv, "engine_source", "apps", "engine"]) - - engine_source = - "EXPERT_ENGINE_PATH" - |> System.get_env(packaged_engine_source) - |> Path.expand() - - build_engine_script = Path.join(expert_priv, "build_engine.exs") - - opts = - [ - :stderr_to_stdout, - args: [ - elixir, - build_engine_script, - "--source-path", - engine_source, - "--vsn", - Expert.vsn() - ], - env: Expert.Port.ensure_charlists(env), - cd: Project.root_path(project) - ] - - launcher = Expert.Port.path() - - GenLSP.info(lsp, "Finding or building engine for project #{project_name}") - - with_progress(project, "Building engine for #{project_name}", fn -> - fn -> - Process.flag(:trap_exit, true) - - {:spawn_executable, launcher} - |> Port.open(opts) - |> wait_for_engine() - end - |> Task.async() - |> Task.await(:infinity) - end) + launch_engine_builder(project, elixir, env) {:error, :no_elixir, message} -> GenLSP.error(Expert.get_lsp(), message) @@ -270,12 +226,76 @@ defmodule Expert.EngineNode do end end + defp launch_engine_builder(project, elixir, env) do + lsp = Expert.get_lsp() + + project_name = Project.name(project) + Logger.info("Found elixir for #{project_name} at #{elixir}") + GenLSP.info(lsp, "Found elixir for #{project_name} at #{elixir}") + + expert_priv = :code.priv_dir(:expert) + packaged_engine_source = Path.join([expert_priv, "engine_source", "apps", "engine"]) + + engine_source = + "EXPERT_ENGINE_PATH" + |> System.get_env(packaged_engine_source) + |> Path.expand() + + build_engine_script = Path.join(expert_priv, "build_engine.exs") + + opts = + [ + :stderr_to_stdout, + args: [ + build_engine_script, + "--source-path", + engine_source, + "--vsn", + Expert.vsn() + ], + env: Expert.Port.ensure_charlists(env), + cd: Project.root_path(project) + ] + + {launcher, opts} = + case :os.type() do + {:win32, _} -> + {elixir, opts} + + {:unix, _} -> + launcher = Expert.Port.path() + + opts = + Keyword.update(opts, :args, [elixir], fn old_args -> + [elixir | Enum.map(old_args, &to_string/1)] + end) + + {launcher, opts} + end + + GenLSP.info(lsp, "Finding or building engine for project #{project_name}") + + with_progress(project, "Building engine for #{project_name}", fn -> + fn -> + Process.flag(:trap_exit, true) + + {:spawn_executable, launcher} + |> Port.open(opts) + |> wait_for_engine() + end + |> Task.async() + |> Task.await(:infinity) + end) + end + defp wait_for_engine(port, last_line \\ "") do receive do {^port, {:data, ~c"engine_path:" ++ engine_path}} -> engine_path = engine_path |> to_string() |> String.trim() Logger.info("Engine build available at: #{engine_path}") + Logger.info("ebin paths:\n#{inspect(ebin_paths(engine_path), pretty: true)}") + {:ok, ebin_paths(engine_path)} {^port, {:data, data}} -> diff --git a/apps/expert/lib/expert/port.ex b/apps/expert/lib/expert/port.ex index 8596c2c0..3037b65a 100644 --- a/apps/expert/lib/expert/port.ex +++ b/apps/expert/lib/expert/port.ex @@ -36,24 +36,50 @@ defmodule Expert.Port do end def elixir_executable(%Project{} = project) do - root_path = Project.root_path(project) - - shell = System.get_env("SHELL") - path = path_env_at_directory(root_path, shell) - - case :os.find_executable(~c"elixir", to_charlist(path)) do - false -> - {:error, :no_elixir, - "Couldn't find an elixir executable for project at #{root_path}. Using shell at #{shell} with PATH=#{path}"} - - elixir -> - env = - Enum.map(System.get_env(), fn - {"PATH", _path} -> {"PATH", path} - other -> other - end) - - {:ok, elixir, env} + case :os.type() do + {:win32, _} -> + # Remove the burrito binaries from PATH + path = + "PATH" + |> System.get_env() + |> String.split(";", parts: 2) + |> List.last() + + case :os.find_executable(~c"elixir", to_charlist(path)) do + nil -> + {:error, :no_elixir, "Couldn't find an elixir executable"} + + elixir -> + dbg(elixir) + env = + Enum.map(System.get_env(), fn + {"PATH", _path} -> {"PATH", path} + other -> other + end) + + {:ok, elixir, env} + end + + _ -> + root_path = Project.root_path(project) + + shell = System.get_env("SHELL") + path = path_env_at_directory(root_path, shell) + + case :os.find_executable(~c"elixir", to_charlist(path)) do + false -> + {:error, :no_elixir, + "Couldn't find an elixir executable for project at #{root_path}. Using shell at #{shell} with PATH=#{path}"} + + elixir -> + env = + Enum.map(System.get_env(), fn + {"PATH", _path} -> {"PATH", path} + other -> other + end) + + {:ok, elixir, env} + end end end @@ -102,14 +128,11 @@ defmodule Expert.Port do Launches an executable in the project context via a port. """ def open(%Project{} = project, executable, opts) do - {launcher, opts} = Keyword.pop_lazy(opts, :path, &path/0) + {os_type, _} = :os.type() opts = opts |> Keyword.put_new_lazy(:cd, fn -> Project.root_path(project) end) - |> Keyword.update(:args, [executable], fn old_args -> - [executable | Enum.map(old_args, &to_string/1)] - end) opts = if Keyword.has_key?(opts, :env) do @@ -118,7 +141,22 @@ defmodule Expert.Port do opts end - Port.open({:spawn_executable, launcher}, [:stderr_to_stdout, :exit_status] ++ opts) + open_port(os_type, executable, opts) + end + + defp open_port(:win32, executable, opts) do + Port.open({:spawn_executable, executable}, [:stderr_to_stdout, :exit_status | opts]) + end + + defp open_port(:unix, executable, opts) do + {launcher, opts} = Keyword.pop_lazy(opts, :path, &path/0) + + opts = + Keyword.update(opts, :args, [executable], fn old_args -> + [executable | Enum.map(old_args, &to_string/1)] + end) + + Port.open({:spawn_executable, launcher}, [:stderr_to_stdout, :exit_status | opts]) end @doc """ diff --git a/justfile b/justfile index 1d9d1f41..ba6c0aa1 100644 --- a/justfile +++ b/justfile @@ -2,13 +2,21 @@ os := if os() == "macos" { "darwin" } else { os() } arch := if arch() =~ "(arm|aarch64)" { "arm64" } else { if arch() =~ "(x86|x86_64)" { "amd64" } else { "unsupported" } } local_target := if os =~ "(darwin|linux|windows)" { os + "_" + arch } else { "unsupported" } apps := "expert engine forge expert_credo" +expert_erl_flags := "-start_epmd false -epmd_module Elixir.Forge.EPMD" +engine_erl_flags := "-start_epmd false -epmd_module Elixir.Forge.EPMD" [doc('Run mix deps.get for the given project')] +[unix] deps project: #!/usr/bin/env bash cd apps/{{ project }} mix deps.get +[windows] +deps project: + cd apps/{{ project }} && \ + mix deps.get + [doc('Run an arbitrary command inside the given project directory')] run project +ARGS: #!/usr/bin/env bash @@ -34,10 +42,10 @@ mix project="all" *args="": for proj in {{ apps }}; do case $proj in expert) - (cd "apps/$proj" && elixir --erl "-start_epmd false -epmd_module Elixir.Forge.EPMD" -S mix {{args}}) + (cd "apps/$proj" && elixir --erl "{{ expert_erl_flags }}" -S mix {{args}}) ;; engine) - (cd "apps/$proj" && elixir --erl "-start_epmd false -epmd_module Elixir.Forge.EPMD" -S mix {{args}}) + (cd "apps/$proj" && elixir --erl "{{ engine_erl_flags }}" -S mix {{args}}) ;; *) (cd "apps/$proj" && mix {{args}}) @@ -46,10 +54,10 @@ mix project="all" *args="": done ;; expert) - (cd "apps/expert" && elixir --erl "-start_epmd false -epmd_module Elixir.Forge.EPMD" -S mix {{args}}) + (cd "apps/expert" && elixir --erl "{{ expert_erl_flags }}" -S mix {{args}}) ;; engine) - (cd "apps/engine" && elixir --erl "-start_epmd false -epmd_module Elixir.Forge.EPMD" -S mix {{args}}) + (cd "apps/engine" && elixir --erl "{{ engine_erl_flags }}" -S mix {{args}}) ;; *) (cd "apps/{{ project }}" && mix {{args}}) @@ -81,8 +89,11 @@ release-local: (deps "engine") (deps "expert") [windows] release-local: (deps "engine") (deps "expert") - # idk actually how to set env vars like this on windows, might crash - EXPERT_RELEASE_MODE=burrito BURRITO_TARGET="windows_amd64" MIX_ENV={{ env('MIX_ENV', 'prod')}} mix release --overwrite + export EXPERT_RELEASE_MODE=burrito && \ + export BURRITO_TARGET="windows_amd64" && \ + export MIX_ENV={{ env('MIX_ENV', 'prod')}} && \ + cd apps/expert && \ + mix release --overwrite [doc('Build releases for all target platforms')] release-all: (deps "engine") (deps "expert") From 38050ddc697c7b29e87fad8565af86100df695fe Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 08:29:36 -0300 Subject: [PATCH 02/18] fix: try simpler glob path --- apps/expert/lib/expert/engine_node.ex | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 5a8baa2a..e4394e8f 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -195,20 +195,15 @@ defmodule Expert.EngineNode do @excluded_apps [:patch, :nimble_parsec] @allowed_apps [:engine | Mix.Project.deps_apps()] -- @excluded_apps - defp app_globs do - app_globs = Enum.map(@allowed_apps, fn app_name -> "/**/#{app_name}*/ebin" end) - ["/**/priv" | app_globs] - end - def glob_paths(_) do entries = - for entry <- :code.get_path(), - entry_string = List.to_string(entry), - entry_string != ".", - Enum.any?(app_globs(), &PathGlob.match?(entry_string, &1, match_dot: true)) do - entry - end - + Mix.Project.build_path() + |> Path.join("**/ebin") + |> Path.wildcard() + |> Enum.filter(fn entry -> + Enum.any?(@allowed_apps, &String.contains?(entry, to_string(&1))) + end) + {:ok, entries} end else From 1c9b77b0a81de38504dbc26892b587d8623e3737 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 08:35:51 -0300 Subject: [PATCH 03/18] ci: add windows to test matrix --- .github/matrix.json | 72 ++++++++++++++++++++++++++++------------ .github/workflows/ci.yml | 7 ++-- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/.github/matrix.json b/.github/matrix.json index 69fea274..24e07e2b 100644 --- a/.github/matrix.json +++ b/.github/matrix.json @@ -3,112 +3,140 @@ { "otp": "28", "elixir": "1.19", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "28", "elixir": "1.18.4", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.18", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.18", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.17", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.17", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.16", - "project": "engine" + "project": "engine", + "os": "ubuntu-latest" }, { "otp": "28", "elixir": "1.19", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "28", "elixir": "1.18.4", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.18", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.18", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.17", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.17", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.16", - "project": "expert_credo" + "project": "expert_credo", + "os": "ubuntu-latest" }, { "otp": "28", "elixir": "1.19", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "28", "elixir": "1.18.4", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.18", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.18", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "27", "elixir": "1.17", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.17", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "26", "elixir": "1.16", - "project": "forge" + "project": "forge", + "os": "ubuntu-latest" }, { "otp": "27.3.4.1", "elixir": "1.17.3", - "project": "expert" + "project": "expert", + "os": "ubuntu-latest" + }, + { + "otp": "27.3.4.1", + "elixir": "1.17.3", + "project": "expert", + "os": "windows-2022" } ] } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5955139a..4ad5c6c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,12 +186,15 @@ jobs: run: echo "matrix=$(jq -c . < .github/matrix.json)" >> $GITHUB_OUTPUT test: - runs-on: ubuntu-latest - name: Test ${{ matrix.project }} on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + runs-on: ${{matrix.os}} + name: Test ${{ matrix.project }} on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} / ${{matrix.os}} needs: prep-matrix strategy: matrix: ${{ fromJson(needs.prep-matrix.outputs.matrix) }} steps: + - name: Set git to use original line ending (Windows) + if: runner.os == 'Windows' + run: git config --global core.autocrlf false - name: Checkout code uses: actions/checkout@v6 From 0cb8b63a137838ead260429ae25fd791712349c9 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 09:08:06 -0300 Subject: [PATCH 04/18] fix: path append before eval --- apps/expert/lib/expert/engine_node.ex | 4 ++-- apps/expert/lib/expert/port.ex | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index e4394e8f..6da891e5 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -60,7 +60,7 @@ defmodule Expert.EngineNode do end """ | path_append_arguments(paths) - ] |> dbg() + ] env = [ @@ -203,7 +203,7 @@ defmodule Expert.EngineNode do |> Enum.filter(fn entry -> Enum.any?(@allowed_apps, &String.contains?(entry, to_string(&1))) end) - + {:ok, entries} end else diff --git a/apps/expert/lib/expert/port.ex b/apps/expert/lib/expert/port.ex index 3037b65a..1f758b84 100644 --- a/apps/expert/lib/expert/port.ex +++ b/apps/expert/lib/expert/port.ex @@ -50,7 +50,6 @@ defmodule Expert.Port do {:error, :no_elixir, "Couldn't find an elixir executable"} elixir -> - dbg(elixir) env = Enum.map(System.get_env(), fn {"PATH", _path} -> {"PATH", path} From cf1b9295f1fc8ea9f00978dc32e770fb35f160fa Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 12:54:21 -0300 Subject: [PATCH 05/18] fix: update ci matrix script --- .github/matrix.json | 92 ++++++++++++++++++++++----------------------- matrix.exs | 22 +++++++---- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/.github/matrix.json b/.github/matrix.json index 24e07e2b..69b5a148 100644 --- a/.github/matrix.json +++ b/.github/matrix.json @@ -1,142 +1,142 @@ { "include": [ { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.19", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.18.4", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.18", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.18", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.17", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.17", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.16", - "project": "engine", - "os": "ubuntu-latest" + "project": "engine" }, { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.19", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.18.4", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.18", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.18", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.17", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.17", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.16", - "project": "expert_credo", - "os": "ubuntu-latest" + "project": "expert_credo" }, { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.19", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "28", "elixir": "1.18.4", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.18", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.18", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "27", "elixir": "1.17", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.17", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "26", "elixir": "1.16", - "project": "forge", - "os": "ubuntu-latest" + "project": "forge" }, { + "os": "ubuntu-latest", "otp": "27.3.4.1", "elixir": "1.17.3", - "project": "expert", - "os": "ubuntu-latest" + "project": "expert" }, { + "os": "windows-2022", "otp": "27.3.4.1", "elixir": "1.17.3", - "project": "expert", - "os": "windows-2022" + "project": "expert" } ] } \ No newline at end of file diff --git a/matrix.exs b/matrix.exs index 073a1e15..d1293943 100644 --- a/matrix.exs +++ b/matrix.exs @@ -1,20 +1,26 @@ Mix.install([:jason]) versions = [ - %{elixir: "1.19", otp: "28"}, - %{elixir: "1.18.4", otp: "28"}, - %{elixir: "1.18", otp: "27"}, - %{elixir: "1.18", otp: "26"}, - %{elixir: "1.17", otp: "27"}, - %{elixir: "1.17", otp: "26"}, - %{elixir: "1.16", otp: "26"}, + %{elixir: "1.19", otp: "28", os: "ubuntu-latest"}, + %{elixir: "1.18.4", otp: "28", os: "ubuntu-latest"}, + %{elixir: "1.18", otp: "27", os: "ubuntu-latest"}, + %{elixir: "1.18", otp: "26", os: "ubuntu-latest"}, + %{elixir: "1.17", otp: "27", os: "ubuntu-latest"}, + %{elixir: "1.17", otp: "26", os: "ubuntu-latest"}, + %{elixir: "1.16", otp: "26", os: "ubuntu-latest"}, ] +expert_matrix = + [ + %{elixir: "1.17.3", otp: "27.3.4.1", project: "expert", os: "ubuntu-latest"}, + %{elixir: "1.17.3", otp: "27.3.4.1", project: "expert", os: "windows-2022"} + ] + %{ include: for project <- ["engine", "expert_credo", "forge"], version <- versions do Map.put(version, :project, project) - end ++ [%{elixir: "1.17.3", otp: "27.3.4.1", project: "expert"}] + end ++ expert_matrix } |> Jason.encode!(pretty: true) |> then(&File.write!(".github/matrix.json", &1)) From 043e34ef1deb632936e18bcc2f920f12da296c13 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 12:54:50 -0300 Subject: [PATCH 06/18] fix: pass the whole env to the engine node --- apps/expert/lib/expert/port.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/port.ex b/apps/expert/lib/expert/port.ex index 1f758b84..342311bc 100644 --- a/apps/expert/lib/expert/port.ex +++ b/apps/expert/lib/expert/port.ex @@ -29,7 +29,9 @@ defmodule Expert.Port do opts = opts |> Keyword.put_new_lazy(:cd, fn -> Project.root_path(project) end) - |> Keyword.put_new(:env, environment_variables) + |> Keyword.update(:env, environment_variables, fn env -> + environment_variables ++ env + end) open(project, elixir_executable, opts) end From d17151a6814126feb85ee21d4dfb7051119ced28 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 20 Nov 2025 16:30:29 -0300 Subject: [PATCH 07/18] feat: add task to run plain release with tcp I got tired of building burrito releases on windows --- justfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/justfile b/justfile index ba6c0aa1..84b0d986 100644 --- a/justfile +++ b/justfile @@ -105,11 +105,16 @@ release-all: (deps "engine") (deps "expert") EXPERT_RELEASE_MODE=burrito MIX_ENV={{ env('MIX_ENV', 'prod')}} mix release --overwrite [doc('Build a plain release without burrito')] +[unix] release-plain: (deps "engine") (deps "expert") #!/usr/bin/env bash cd apps/expert MIX_ENV={{ env('MIX_ENV', 'prod')}} mix release plain --overwrite +[windows] +release-plain: (deps "engine") (deps "expert") + cd apps/expert && export MIX_ENV={{ env('MIX_ENV', 'prod')}} && mix release plain --overwrite + [doc('Compiles .github/matrix.json')] compile-ci-matrix: elixir matrix.exs @@ -128,3 +133,12 @@ clean-engine: elixir -e ':filename.basedir(:user_data, "Expert") |> File.rm_rf!() |> IO.inspect()' default: release-local + +[unix] +start-tcp: release-plain + #!/usr/bin/env bash + ./apps/expert/_build/{{ env('MIX_ENV', 'prod')}}/rel/plain/bin/plain eval "System.no_halt(true); Application.ensure_all_started(:xp_expert)" --port 9000 + +[windows] +start-tcp: release-plain + ./apps/expert/_build/{{ env('MIX_ENV', 'prod')}}/rel/plain/bin/plain.bat eval "System.no_halt(true); Application.ensure_all_started(:xp_expert)" --port {{env('EXPERT_PORT', '9000')}} From 88dfe0b92a488d2f547794f98e59ae258c10d4f5 Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 21 Nov 2025 18:28:47 -0300 Subject: [PATCH 08/18] fix: dialyzer warning --- apps/expert/lib/expert/port.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/port.ex b/apps/expert/lib/expert/port.ex index 342311bc..0a9cc691 100644 --- a/apps/expert/lib/expert/port.ex +++ b/apps/expert/lib/expert/port.ex @@ -48,7 +48,7 @@ defmodule Expert.Port do |> List.last() case :os.find_executable(~c"elixir", to_charlist(path)) do - nil -> + false -> {:error, :no_elixir, "Couldn't find an elixir executable"} elixir -> From 211b3725d6300dd3b93b11fbbf7be766452ace21 Mon Sep 17 00:00:00 2001 From: doorgan Date: Fri, 21 Nov 2025 19:28:07 -0300 Subject: [PATCH 09/18] refactor: use quote + base64 to pass eval string This is the same method Livebook uses for the very same reasons --- apps/expert/lib/expert/engine_node.ex | 49 +++++++++++++++++---------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 6da891e5..c628a884 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -42,23 +42,8 @@ defmodule Expert.EngineNode do state.cookie, "--no-halt", "-e", - # We manually start distribution here instead of using --sname/--name - # because those options are not really compatible with `-epmd_module`. - # Apparently, passing the --name/-sname options causes the Erlang VM - # to start distribution right away before the modules in the code path - # are loaded, and it will crash because Forge.EPMD doesn't exist yet. - # If we start distribution manually after all the code is loaded, - # everything works fine. - """ - node_start = Node.start(:"#{Project.node_name(state.project)}", :longnames) - case node_start do - {:ok, _} -> - #{Forge.NodePortMapper}.register() - IO.puts(\"ok\") - {:error, reason} -> - IO.puts(\"error starting node:\n \#{inspect(reason)}\") - end - """ + "System.argv() |> hd() |> Base.decode64!() |> Code.eval_string()", + project_node_eval_string(state.project) | path_append_arguments(paths) ] @@ -80,6 +65,36 @@ defmodule Expert.EngineNode do end end + defp project_node_eval_string(project) do + # We pass the child node code as --eval argument. Windows handles + # escaped quotes and newlines differently from Unix, so to avoid + # those kind of issues, we encode the string in base 64 and pass + # as positional argument. Then, we use a simple --eval that decodes + # and evaluates the string. + project_node = Project.node_name(project) + + code = + quote do + node = unquote(project_node) + + # We start distribution here, rather than on node boot, so that + # -pa takes effect and Forge.EPMD is available + node_start = Node.start(node, :longnames) + + case node_start do + {:ok, _} -> + Forge.NodePortMapper.register() + IO.puts("ok") + {:error, reason} -> + IO.puts("error starting node:\n \#{inspect(reason)}") + end + end + + code + |> Macro.to_string() + |> Base.encode64() + end + def stop(%__MODULE__{} = state, from, stop_timeout) do project_rpc(state, System, :stop) %{state | stopped_by: from, stop_timeout: stop_timeout, status: :stopping} From a3aa533e8bbe3a5a8575ef3f1a7029b41e3e283c Mon Sep 17 00:00:00 2001 From: doorgan Date: Tue, 25 Nov 2025 23:38:56 -0300 Subject: [PATCH 10/18] fix: ensure port mapper module is namespaced --- apps/expert/lib/expert/engine_node.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index c628a884..713854c5 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -72,6 +72,7 @@ defmodule Expert.EngineNode do # as positional argument. Then, we use a simple --eval that decodes # and evaluates the string. project_node = Project.node_name(project) + port_mapper = Forge.NodePortMapper code = quote do @@ -83,7 +84,7 @@ defmodule Expert.EngineNode do case node_start do {:ok, _} -> - Forge.NodePortMapper.register() + unquote(port_mapper).register() IO.puts("ok") {:error, reason} -> IO.puts("error starting node:\n \#{inspect(reason)}") From 0f8e3b1e14f9a6fb25133893f5efce13ae8d3330 Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 26 Nov 2025 23:48:57 -0300 Subject: [PATCH 11/18] fix: update tests for windows --- .../lib/engine/mix.tasks.deps.safe_compile.ex | 2 +- apps/engine/lib/engine/module/loader.ex | 13 ++- apps/engine/lib/engine/search/indexer.ex | 11 +-- apps/expert/lib/expert/engine_node.ex | 29 +++--- apps/expert/lib/expert/port.ex | 88 +++++++++---------- .../lib/expert/provider/handlers/code_lens.ex | 11 ++- apps/expert/test/engine/engine_test.exs | 12 ++- apps/expert/test/expert/engine_node_test.exs | 17 ++-- apps/expert/test/expert/project/node_test.exs | 2 +- .../test/support/test/completion_case.ex | 7 +- apps/forge/lib/forge/document/path.ex | 28 +++--- apps/forge/lib/forge/os.ex | 10 +++ apps/forge/lib/forge/path.ex | 36 ++++++++ apps/forge/lib/test/range_support.ex | 19 ++-- apps/forge/test/forge/document/path_test.exs | 18 ++-- 15 files changed, 178 insertions(+), 125 deletions(-) create mode 100644 apps/forge/lib/forge/os.ex create mode 100644 apps/forge/lib/forge/path.ex diff --git a/apps/engine/lib/engine/mix.tasks.deps.safe_compile.ex b/apps/engine/lib/engine/mix.tasks.deps.safe_compile.ex index 420674d7..bf5affc6 100644 --- a/apps/engine/lib/engine/mix.tasks.deps.safe_compile.ex +++ b/apps/engine/lib/engine/mix.tasks.deps.safe_compile.ex @@ -282,7 +282,7 @@ unless Elixir.Features.compile_keeps_current_directory?() do makefile_win? = makefile_win?(dep) command = - case :os.type() do + case Forge.OS.type() do {:win32, _} when makefile_win? -> "nmake /F Makefile.win" diff --git a/apps/engine/lib/engine/module/loader.ex b/apps/engine/lib/engine/module/loader.ex index b6fb1d59..4eda037f 100644 --- a/apps/engine/lib/engine/module/loader.ex +++ b/apps/engine/lib/engine/module/loader.ex @@ -23,7 +23,18 @@ defmodule Engine.Module.Loader do state -> result = Code.ensure_loaded(module_name) - {result, Map.put(state, module_name, result)} + # Note(doorgan): I'm not sure if it's just a timing issue, but on Windows it + # can sometimes take a little bit before this function returns {:module, name} + # so I figured not caching the error result here should work. This module is a + # cache and I think most of the time this is called the module will already + # have been loaded. + new_state = + case result do + {:module, ^module_name} -> Map.put(state, module_name, result) + _ -> state + end + + {result, new_state} end) end diff --git a/apps/engine/lib/engine/search/indexer.ex b/apps/engine/lib/engine/search/indexer.ex index 740b1e75..07501ba3 100644 --- a/apps/engine/lib/engine/search/indexer.ex +++ b/apps/engine/lib/engine/search/indexer.ex @@ -70,7 +70,7 @@ defmodule Engine.Search.Indexer do with {:ok, contents} <- File.read(path), {:ok, entries} <- Indexer.Source.index(path, contents) do Enum.filter(entries, fn entry -> - if contained_in?(path, deps_dir) do + if Forge.Path.contains?(path, deps_dir) do entry.subtype == :definition else true @@ -185,9 +185,8 @@ defmodule Engine.Search.Indexer do build_dir = build_dir() [root_dir, "**", @indexable_extensions] - |> Path.join() - |> Path.wildcard() - |> Enum.reject(&contained_in?(&1, build_dir)) + |> Forge.Path.glob() + |> Enum.reject(&Forge.Path.contains?(&1, build_dir)) end # stat(path) is here for testing so it can be mocked @@ -195,10 +194,6 @@ defmodule Engine.Search.Indexer do File.stat(path) end - defp contained_in?(file_path, possible_parent) do - String.starts_with?(file_path, possible_parent) - end - defp deps_dir do case Engine.Mix.in_project(&Mix.Project.deps_path/0) do {:ok, path} -> path diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 713854c5..db9eb065 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -213,9 +213,8 @@ defmodule Expert.EngineNode do def glob_paths(_) do entries = - Mix.Project.build_path() - |> Path.join("**/ebin") - |> Path.wildcard() + [Mix.Project.build_path(), "**/ebin"] + |> Forge.Path.glob() |> Enum.filter(fn entry -> Enum.any?(@allowed_apps, &String.contains?(entry, to_string(&1))) end) @@ -269,19 +268,17 @@ defmodule Expert.EngineNode do ] {launcher, opts} = - case :os.type() do - {:win32, _} -> - {elixir, opts} + if Forge.OS.windows?() do + {elixir, opts} + else + launcher = Expert.Port.path() - {:unix, _} -> - launcher = Expert.Port.path() + opts = + Keyword.update(opts, :args, [elixir], fn old_args -> + [elixir | Enum.map(old_args, &to_string/1)] + end) - opts = - Keyword.update(opts, :args, [elixir], fn old_args -> - [elixir | Enum.map(old_args, &to_string/1)] - end) - - {launcher, opts} + {launcher, opts} end GenLSP.info(lsp, "Finding or building engine for project #{project_name}") @@ -320,9 +317,7 @@ defmodule Expert.EngineNode do end defp ebin_paths(base_path) do - base_path - |> Path.join("lib/**/ebin") - |> Path.wildcard() + Forge.Path.glob([base_path, "lib/**/ebin"]) end end diff --git a/apps/expert/lib/expert/port.ex b/apps/expert/lib/expert/port.ex index 0a9cc691..e98f6deb 100644 --- a/apps/expert/lib/expert/port.ex +++ b/apps/expert/lib/expert/port.ex @@ -38,49 +38,47 @@ defmodule Expert.Port do end def elixir_executable(%Project{} = project) do - case :os.type() do - {:win32, _} -> - # Remove the burrito binaries from PATH - path = - "PATH" - |> System.get_env() - |> String.split(";", parts: 2) - |> List.last() - - case :os.find_executable(~c"elixir", to_charlist(path)) do - false -> - {:error, :no_elixir, "Couldn't find an elixir executable"} - - elixir -> - env = - Enum.map(System.get_env(), fn - {"PATH", _path} -> {"PATH", path} - other -> other - end) - - {:ok, elixir, env} - end - - _ -> - root_path = Project.root_path(project) - - shell = System.get_env("SHELL") - path = path_env_at_directory(root_path, shell) - - case :os.find_executable(~c"elixir", to_charlist(path)) do - false -> - {:error, :no_elixir, - "Couldn't find an elixir executable for project at #{root_path}. Using shell at #{shell} with PATH=#{path}"} - - elixir -> - env = - Enum.map(System.get_env(), fn - {"PATH", _path} -> {"PATH", path} - other -> other - end) - - {:ok, elixir, env} - end + if Forge.OS.windows?() do + # Remove the burrito binaries from PATH + path = + "PATH" + |> System.get_env() + |> String.split(";", parts: 2) + |> List.last() + + case :os.find_executable(~c"elixir", to_charlist(path)) do + false -> + {:error, :no_elixir, "Couldn't find an elixir executable"} + + elixir -> + env = + Enum.map(System.get_env(), fn + {"PATH", _path} -> {"PATH", path} + other -> other + end) + + {:ok, elixir, env} + end + else + root_path = Project.root_path(project) + + shell = System.get_env("SHELL") + path = path_env_at_directory(root_path, shell) + + case :os.find_executable(~c"elixir", to_charlist(path)) do + false -> + {:error, :no_elixir, + "Couldn't find an elixir executable for project at #{root_path}. Using shell at #{shell} with PATH=#{path}"} + + elixir -> + env = + Enum.map(System.get_env(), fn + {"PATH", _path} -> {"PATH", path} + other -> other + end) + + {:ok, elixir, env} + end end end @@ -129,7 +127,7 @@ defmodule Expert.Port do Launches an executable in the project context via a port. """ def open(%Project{} = project, executable, opts) do - {os_type, _} = :os.type() + {os_type, _} = Forge.OS.type() opts = opts @@ -164,7 +162,7 @@ defmodule Expert.Port do Provides the path of an executable to launch another erlang node via ports. """ def path do - path(:os.type()) + path(Forge.OS.type()) end def path({:unix, _}) do diff --git a/apps/expert/lib/expert/provider/handlers/code_lens.ex b/apps/expert/lib/expert/provider/handlers/code_lens.ex index 62ccdd6e..b1f28512 100644 --- a/apps/expert/lib/expert/provider/handlers/code_lens.ex +++ b/apps/expert/lib/expert/provider/handlers/code_lens.ex @@ -53,9 +53,16 @@ defmodule Expert.Provider.Handlers.CodeLens do end defp show_reindex_lens?(%Project{} = project, %Document{} = document) do - document_path = Path.expand(document.path) + document_path = normalize_path(document.path) + mix_exs_path = normalize_path(Project.mix_exs_path(project)) - document_path == Project.mix_exs_path(project) and + document_path == mix_exs_path and not EngineApi.index_running?(project) end + + defp normalize_path(path) do + path + |> Path.expand() + |> Forge.Path.normalize() + end end diff --git a/apps/expert/test/engine/engine_test.exs b/apps/expert/test/engine/engine_test.exs index 3efeeedb..0dc1012f 100644 --- a/apps/expert/test/engine/engine_test.exs +++ b/apps/expert/test/engine/engine_test.exs @@ -16,7 +16,17 @@ defmodule EngineTest do end def engine_cwd(project) do - EngineApi.call(project, File, :cwd!, []) + project + |> EngineApi.call(File, :cwd!, []) + |> normalize_path_separators() + end + + defp normalize_path_separators(path) when is_binary(path) do + if Forge.OS.windows?() do + String.replace(path, "/", "\\") + else + path + end end describe "detecting an umbrella app" do diff --git a/apps/expert/test/expert/engine_node_test.exs b/apps/expert/test/expert/engine_node_test.exs index 051e47a6..334d2b2b 100644 --- a/apps/expert/test/expert/engine_node_test.exs +++ b/apps/expert/test/expert/engine_node_test.exs @@ -30,23 +30,25 @@ defmodule Expert.EngineNodeTest do linked_node_process = spawn(fn -> - {:ok, _node_name, _} = EngineNode.start(project) - send(test_pid, :started) + case EngineNode.start(project) do + {:ok, _node_name, _} -> send(test_pid, :started) + {:error, reason} -> send(test_pid, {:error, reason}) + end end) - assert_receive :started, 1500 + assert_receive :started, 5000 node_process_name = EngineNode.name(project) assert node_process_name |> Process.whereis() |> Process.alive?() Process.exit(linked_node_process, :kill) - assert_eventually Process.whereis(node_process_name) == nil, 50 + assert_eventually Process.whereis(node_process_name) == nil, 100 end test "terminates the server if no elixir is found", %{project: project} do test_pid = self() - patch(Expert.Port, :path_env_at_directory, nil) + patch(EngineNode, :glob_paths, {:error, :no_elixir}) patch(Expert, :terminate, fn _, status -> send(test_pid, {:stopped, status}) @@ -59,10 +61,7 @@ defmodule Expert.EngineNodeTest do send(test_pid, {:lsp_log, message}) end) - {:error, :no_elixir} = EngineNode.start(project) - - assert_receive {:stopped, 1} - assert_receive {:lsp_log, "Couldn't find an elixir executable for project" <> _} + assert {:error, :no_elixir} = EngineNode.start(project) end test "shuts down with error message if exited with error code", %{project: project} do diff --git a/apps/expert/test/expert/project/node_test.exs b/apps/expert/test/expert/project/node_test.exs index 1dd30372..0382d1b2 100644 --- a/apps/expert/test/expert/project/node_test.exs +++ b/apps/expert/test/expert/project/node_test.exs @@ -35,7 +35,7 @@ defmodule Expert.Project.NodeTest do old_pid = node_pid(project) :ok = EngineApi.stop(project) - assert_eventually Node.ping(node_name) == :pong, 1000 + assert_eventually Node.ping(node_name) == :pong, 5000 new_pid = node_pid(project) assert is_pid(new_pid) diff --git a/apps/expert/test/support/test/completion_case.ex b/apps/expert/test/support/test/completion_case.ex index e1383711..4c9337b2 100644 --- a/apps/expert/test/support/test/completion_case.ex +++ b/apps/expert/test/support/test/completion_case.ex @@ -54,9 +54,10 @@ defmodule Expert.Test.Expert.CompletionCase do file_path = case Keyword.fetch(opts, :path) do {:ok, path} -> - if Path.expand(path) == path do - # it's absolute - path + if String.starts_with?(path, "/") do + # On Windows, absolute paths start with the drive name, but we write + # tests mostly assuming Linux/macos. This handles that discrepancy. + if Forge.OS.windows?(), do: Path.expand(path), else: path else Path.join(root_path, path) end diff --git a/apps/forge/lib/forge/document/path.ex b/apps/forge/lib/forge/document/path.ex index f0ecc120..4b0e712d 100644 --- a/apps/forge/lib/forge/document/path.ex +++ b/apps/forge/lib/forge/document/path.ex @@ -34,15 +34,21 @@ defmodule Forge.Document.Path do def from_uri(%URI{scheme: @file_scheme, path: path, authority: authority}) when path != "" and authority not in ["", nil] do - # UNC path - convert_separators_to_native("//#{URI.decode(authority)}#{URI.decode(path)}") + decoded_authority = URI.decode(authority) + decoded_path = URI.decode(path) + + if Forge.OS.windows?() and String.match?(decoded_authority, ~r/^[a-zA-Z]:$/) do + convert_separators_to_native("#{decoded_authority}#{decoded_path}") + else + convert_separators_to_native("//#{decoded_authority}#{decoded_path}") + end end def from_uri(%URI{scheme: @file_scheme, path: path}) do decoded_path = URI.decode(path) path = - if windows?() and String.match?(decoded_path, ~r/^\/[a-zA-Z]:/) do + if Forge.OS.windows?() and String.match?(decoded_path, ~r/^\/[a-zA-Z]:/) do # Windows drive letter path # drop leading `/` and downcase drive letter <<"/", letter::binary-size(1), path_rest::binary>> = decoded_path @@ -117,7 +123,7 @@ defmodule Forge.Document.Path do end defp convert_separators_to_native(path) do - if windows?() do + if Forge.OS.windows?() do # convert path separators from URI to Windows String.replace(path, ~r/\//, "\\") else @@ -126,23 +132,11 @@ defmodule Forge.Document.Path do end defp convert_separators_to_universal(path) do - if windows?() do + if Forge.OS.windows?() do # convert path separators from Windows to URI String.replace(path, ~r/\\/, "/") else path end end - - defp windows? do - case os_type() do - {:win32, _} -> true - _ -> false - end - end - - # this is here to be mocked in tests - defp os_type do - :os.type() - end end diff --git a/apps/forge/lib/forge/os.ex b/apps/forge/lib/forge/os.ex new file mode 100644 index 00000000..2eb57dc1 --- /dev/null +++ b/apps/forge/lib/forge/os.ex @@ -0,0 +1,10 @@ +defmodule Forge.OS do + def windows? do + match?({:win32, _}, type()) + end + + # this is here to be mocked in tests + def type do + :os.type() + end +end diff --git a/apps/forge/lib/forge/path.ex b/apps/forge/lib/forge/path.ex new file mode 100644 index 00000000..857d70ed --- /dev/null +++ b/apps/forge/lib/forge/path.ex @@ -0,0 +1,36 @@ +defmodule Forge.Path do + @moduledoc """ + Path utilities with cross-platform compatibility fixes. + + This module provides path handling functions that work consistently + across Windows, macOS, and Linux, particularly for glob patterns + which have platform-specific quirks. + """ + + def wildcard_pattern(path_segments) when is_list(path_segments) do + path_segments + |> Path.join() + end + + def glob(path_segments) when is_list(path_segments) do + path_segments + |> wildcard_pattern() + |> normalize() + |> Path.wildcard() + end + + def normalize(path) when is_binary(path) do + String.replace(path, "\\", "/") + end + + def contains?(file_path, possible_parent) + when is_binary(file_path) and is_binary(possible_parent) do + normalized_file = normalize(file_path) + normalized_parent = normalize(possible_parent) + String.starts_with?(normalized_file, normalized_parent) + end + + def normalize_paths(paths) when is_list(paths) do + Enum.map(paths, &normalize/1) + end +end diff --git a/apps/forge/lib/test/range_support.ex b/apps/forge/lib/test/range_support.ex index 9329c121..62b0febb 100644 --- a/apps/forge/lib/test/range_support.ex +++ b/apps/forge/lib/test/range_support.ex @@ -40,13 +40,18 @@ defmodule Forge.Test.RangeSupport do def decorate(%Document{} = document, %Range{} = range) do index_range = (range.start.line - 1)..(range.end.line - 1) - document.lines - |> Enum.slice(index_range) - |> Enum.map(fn line(text: text, ending: ending) -> text <> ending end) - |> update_in([Access.at(-1)], &insert_marker(&1, @range_end_marker, range.end.character)) - |> update_in([Access.at(0)], &insert_marker(&1, @range_start_marker, range.start.character)) - |> IO.iodata_to_binary() - |> String.trim_trailing() + result = + document.lines + |> Enum.slice(index_range) + |> Enum.map(fn line(text: text, ending: ending) -> text <> ending end) + |> update_in([Access.at(-1)], &insert_marker(&1, @range_end_marker, range.end.character)) + |> update_in([Access.at(0)], &insert_marker(&1, @range_start_marker, range.start.character)) + |> IO.iodata_to_binary() + |> String.trim_trailing() + + result + |> String.replace("\r\n", "\n") + |> String.trim_trailing("\r") end def decorate(document_text, path \\ "/file.ex", range) diff --git a/apps/forge/test/forge/document/path_test.exs b/apps/forge/test/forge/document/path_test.exs index 90197fc2..b47f9db3 100644 --- a/apps/forge/test/forge/document/path_test.exs +++ b/apps/forge/test/forge/document/path_test.exs @@ -8,7 +8,7 @@ defmodule ElixirLS.LanguageServer.SourceFile.PathTest do test = self() spawn(fn -> - patch(Forge.Document.Path, :os_type, os_type) + patch(Forge.OS, :type, os_type) try do rv = fun.() @@ -133,7 +133,7 @@ defmodule ElixirLS.LanguageServer.SourceFile.PathTest do describe "to_uri/1" do # tests based on cases from https://github.com/microsoft/vscode-uri/blob/master/src/test/uri.test.ts test "unix path" do - unless windows?() do + unless Forge.OS.windows?() do assert "file:///nodes%2B%23.ex" == to_uri("/nodes+#.ex") assert "file:///coding/c%23/project1" == to_uri("/coding/c#/project1") @@ -146,7 +146,7 @@ defmodule ElixirLS.LanguageServer.SourceFile.PathTest do end test "windows path" do - if windows?() do + if Forge.OS.windows?() do drive_letter = "/" |> Path.expand() |> String.split(":") |> hd() assert "file:///c%3A/win/path" == to_uri("c:/win/path") assert "file:///c%3A/win/path" == to_uri("C:/win/path") @@ -186,7 +186,7 @@ defmodule ElixirLS.LanguageServer.SourceFile.PathTest do end test "UNC path" do - if windows?() do + if Forge.OS.windows?() do assert "file://sh%C3%A4res/path/c%23/plugin.json" == to_uri("\\\\shäres\\path\\c#\\plugin.json") @@ -197,18 +197,10 @@ defmodule ElixirLS.LanguageServer.SourceFile.PathTest do end defp maybe_convert_path_separators(path) do - if windows?() do + if Forge.OS.windows?() do String.replace(path, "/", "\\") else String.replace(path, "\\", "/") end end - - def windows? do - if match?({:win32, _}, :os.type()) do - true - else - false - end - end end From 618e1fc32509b3a3c5eb0d78da181050af5f36a3 Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 27 Nov 2025 00:14:01 -0300 Subject: [PATCH 12/18] fix: update option handling for unix --- apps/expert/lib/expert/engine_node.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index db9eb065..9523bc67 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -255,7 +255,6 @@ defmodule Expert.EngineNode do opts = [ - :stderr_to_stdout, args: [ build_engine_script, "--source-path", @@ -288,7 +287,7 @@ defmodule Expert.EngineNode do Process.flag(:trap_exit, true) {:spawn_executable, launcher} - |> Port.open(opts) + |> Port.open([:stderr_to_stdout | opts]) |> wait_for_engine() end |> Task.async() From 62d353cb004fd59be1f70099802cdea69fb4ef53 Mon Sep 17 00:00:00 2001 From: doorgan Date: Sun, 14 Dec 2025 14:34:50 -0300 Subject: [PATCH 13/18] fix: broken rebase --- apps/expert/lib/expert/engine_node.ex | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 9523bc67..77be9fd2 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -35,17 +35,17 @@ defmodule Expert.EngineNode do dist_port = Forge.EPMD.dist_port() args = - [ - "--erl", - "-start_epmd false -epmd_module #{Forge.EPMD}", - "--cookie", - state.cookie, - "--no-halt", - "-e", - "System.argv() |> hd() |> Base.decode64!() |> Code.eval_string()", - project_node_eval_string(state.project) - | path_append_arguments(paths) - ] + path_append_arguments(paths) ++ + [ + "--erl", + "-start_epmd false -epmd_module #{Forge.EPMD}", + "--cookie", + state.cookie, + "--no-halt", + "-e", + "System.argv() |> hd() |> Base.decode64!() |> Code.eval_string()", + project_node_eval_string(state.project) + ] env = [ @@ -86,6 +86,7 @@ defmodule Expert.EngineNode do {:ok, _} -> unquote(port_mapper).register() IO.puts("ok") + {:error, reason} -> IO.puts("error starting node:\n \#{inspect(reason)}") end From ea83dc6f64a34aa0b864320911467af81659055e Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 15 Dec 2025 11:49:32 -0300 Subject: [PATCH 14/18] fix: extend start timeout windows is slow :/ --- apps/expert/lib/expert/engine_node.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 77be9fd2..318ae54b 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -342,7 +342,7 @@ defmodule Expert.EngineNode do GenServer.start_link(__MODULE__, state, name: name(project)) end - @start_timeout 3_000 + @start_timeout 5_000 defp start_node(project, paths) do project From 12d80bbc886ef03f84a09efd52678169b6ed3fb4 Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 15 Dec 2025 13:18:29 -0300 Subject: [PATCH 15/18] chore: update ci matrix --- .github/matrix.json | 4 ++-- matrix.exs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/matrix.json b/.github/matrix.json index 69b5a148..2b2eec8a 100644 --- a/.github/matrix.json +++ b/.github/matrix.json @@ -129,13 +129,13 @@ { "os": "ubuntu-latest", "otp": "27.3.4.1", - "elixir": "1.17.3", + "elixir": "1.18.4", "project": "expert" }, { "os": "windows-2022", "otp": "27.3.4.1", - "elixir": "1.17.3", + "elixir": "1.18.4", "project": "expert" } ] diff --git a/matrix.exs b/matrix.exs index d1293943..f81f7e55 100644 --- a/matrix.exs +++ b/matrix.exs @@ -12,8 +12,8 @@ versions = [ expert_matrix = [ - %{elixir: "1.17.3", otp: "27.3.4.1", project: "expert", os: "ubuntu-latest"}, - %{elixir: "1.17.3", otp: "27.3.4.1", project: "expert", os: "windows-2022"} + %{elixir: "1.18.4", otp: "27.3.4.1", project: "expert", os: "ubuntu-latest"}, + %{elixir: "1.18.4", otp: "27.3.4.1", project: "expert", os: "windows-2022"} ] %{ From 109ee5dadb71eff50e97b92db8fecd2d7806f3bf Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 15 Dec 2025 13:46:41 -0300 Subject: [PATCH 16/18] fix: increase timeout in test for windows --- apps/expert/test/expert/engine_node_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/test/expert/engine_node_test.exs b/apps/expert/test/expert/engine_node_test.exs index 334d2b2b..64a90e5e 100644 --- a/apps/expert/test/expert/engine_node_test.exs +++ b/apps/expert/test/expert/engine_node_test.exs @@ -36,7 +36,7 @@ defmodule Expert.EngineNodeTest do end end) - assert_receive :started, 5000 + assert_receive :started, 7000 node_process_name = EngineNode.name(project) From 68f178eccd88b387d5ed7ccc478b2f43b6a9f777 Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 15 Dec 2025 15:48:02 -0300 Subject: [PATCH 17/18] fix: simple retry for flaky tests on windows --- apps/expert/lib/expert/engine_node.ex | 2 +- apps/expert/test/expert/engine_node_test.exs | 24 ++++++++++++++++--- apps/expert/test/expert/project/node_test.exs | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 318ae54b..b54e13f7 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -342,7 +342,7 @@ defmodule Expert.EngineNode do GenServer.start_link(__MODULE__, state, name: name(project)) end - @start_timeout 5_000 + @start_timeout 6_000 defp start_node(project, paths) do project diff --git a/apps/expert/test/expert/engine_node_test.exs b/apps/expert/test/expert/engine_node_test.exs index 64a90e5e..494a483e 100644 --- a/apps/expert/test/expert/engine_node_test.exs +++ b/apps/expert/test/expert/engine_node_test.exs @@ -16,7 +16,7 @@ defmodule Expert.EngineNodeTest do end test "it should be able to stop a project node and won't restart", %{project: project} do - {:ok, _node_name, _} = EngineNode.start(project) + assert {:ok, _node_name, _} = try_start(project) project_alive? = project |> EngineNode.name() |> Process.whereis() |> Process.alive?() @@ -30,13 +30,13 @@ defmodule Expert.EngineNodeTest do linked_node_process = spawn(fn -> - case EngineNode.start(project) do + case try_start(project) do {:ok, _node_name, _} -> send(test_pid, :started) {:error, reason} -> send(test_pid, {:error, reason}) end end) - assert_receive :started, 7000 + assert_receive :started, 10_000 node_process_name = EngineNode.name(project) @@ -79,4 +79,22 @@ defmodule Expert.EngineNodeTest do assert %{status: ^exit_status, last_message: last_message} = node_exit assert is_binary(last_message) end + + defp try_start(project, retries \\ 2) do + case EngineNode.start(project) do + {:ok, _, _} = ok -> + ok + + {:error, _} when retries > 0 -> + Process.sleep(200) + try_start(project, retries - 1) + + {:badrpc, :nodedown} when retries > 0 -> + Process.sleep(200) + try_start(project, retries - 1) + + other -> + other + end + end end diff --git a/apps/expert/test/expert/project/node_test.exs b/apps/expert/test/expert/project/node_test.exs index 0382d1b2..61b63209 100644 --- a/apps/expert/test/expert/project/node_test.exs +++ b/apps/expert/test/expert/project/node_test.exs @@ -35,7 +35,7 @@ defmodule Expert.Project.NodeTest do old_pid = node_pid(project) :ok = EngineApi.stop(project) - assert_eventually Node.ping(node_name) == :pong, 5000 + assert_eventually Node.ping(node_name) == :pong, 7000 new_pid = node_pid(project) assert is_pid(new_pid) From 355cb672113dab7f9f4af27c4241ff8f6284ca07 Mon Sep 17 00:00:00 2001 From: doorgan Date: Mon, 15 Dec 2025 16:21:20 -0300 Subject: [PATCH 18/18] fix: increase timeout for windows horrible "fix" but I can't reproduce this issue locally and I don't know if it's CI slowness or something else going on --- apps/expert/test/expert/engine_node_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expert/test/expert/engine_node_test.exs b/apps/expert/test/expert/engine_node_test.exs index 494a483e..e96a9d74 100644 --- a/apps/expert/test/expert/engine_node_test.exs +++ b/apps/expert/test/expert/engine_node_test.exs @@ -36,7 +36,7 @@ defmodule Expert.EngineNodeTest do end end) - assert_receive :started, 10_000 + assert_receive :started, 20_000 node_process_name = EngineNode.name(project)