Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/debugger-strict.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ jobs:

- name: Run debugger template corpus
working-directory: ide
run: mix test test/ide/mcp/debugger_template_corpus_test.exs --only template_corpus
run: mix test test/ide/mcp/debugger_template_corpus_test.exs --only template_corpus --max-cases 1

- name: Run debugger template corpus subscription steps
working-directory: ide
run: mix test test/ide/mcp/debugger_template_corpus_test.exs --only template_corpus_step
run: mix test test/ide/mcp/debugger_template_corpus_test.exs --only template_corpus_step --max-cases 1

elmx-compiled-elixir:
name: elmx compile + compiled_elixir corpus
Expand Down
49 changes: 28 additions & 21 deletions elmx/lib/elmx/backend/elixir_codegen/emit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -324,18 +324,19 @@ defmodule Elmx.Backend.ElixirCodegen.Emit do

{body_code, _, _} = compile_expr(body, lambda_env, 0)

param_refs =
Enum.map_join(args, ", ", fn arg ->
binding_ref(param_name(arg), lambda_env)
code =
Enum.reduce(Enum.reverse(args), body_code, fn arg, inner ->
param = binding_ref(param_name(arg), lambda_env)
["fn ", param, " -> ", inner, " end"]
end)

{[
"fn ",
param_refs,
" -> ",
body_code,
" end"
], Map.put(env, name, true), counter}
code =
case args do
[] -> ["fn _ -> ", body_code, " end"]
[_ | _] -> code
end

{code, Map.put(env, name, true), counter}
end

@comparison_ops ~w(__eq__ __neq__ __lt__ __lte__ __gt__ __gte__)
Expand Down Expand Up @@ -439,20 +440,26 @@ defmodule Elmx.Backend.ElixirCodegen.Emit do
end

defp compile_user_call(name, args, env, counter) when is_binary(name) and is_list(args) do
if Map.get(env, String.to_atom(name)) == true do
{arg_parts, env, c1} = compile_arg_parts(args, env, counter)
{[binding_ref(name, env), ".(", Enum.intersperse(arg_parts, ", "), ")"], env, c1}
else
case compile_basics_unqualified(name, args, env, counter) do
{:ok, code, env, c} ->
{code, env, c}
if Map.get(env, String.to_atom(name)) == true do
{arg_parts, env, c1} = compile_arg_parts(args, env, counter)

:error ->
{arg_parts, env, c1} = compile_arg_parts(args, env, counter)
{compile_module_call(name, arg_parts, env), env, c1}
code =
Enum.reduce(arg_parts, binding_ref(name, env), fn arg, acc ->
[acc, ".(", arg, ")"]
end)

{[code], env, c1}
else
case compile_basics_unqualified(name, args, env, counter) do
{:ok, code, env, c} ->
{code, env, c}

:error ->
{arg_parts, env, c1} = compile_arg_parts(args, env, counter)
{compile_module_call(name, arg_parts, env), env, c1}
end
end
end
end

defp compile_basics_unqualified("max", args, env, counter),
do: QualifiedEmit.compile_basics_qualified("Basics.max", args, env, counter)
Expand Down
16 changes: 15 additions & 1 deletion elmx/lib/elmx/runtime/core.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ defmodule Elmx.Runtime.Core do
%{"ctor" => "Just", "args" => [value]} -> value
%{ctor: :Nothing} -> default
%{ctor: :Just, args: [value]} -> value
%{"ctor" => "Err"} -> default
{:Err, _} -> default
nil -> default
other -> other
end
Expand Down Expand Up @@ -88,11 +90,23 @@ defmodule Elmx.Runtime.Core do

@spec random_int(map()) :: integer()
def random_int(%{low: low, high: high}) when is_integer(low) and is_integer(high) do
low + rem(:rand.uniform(max(high - low + 1, 1)), max(high - low + 1, 1))
case corpus_fixed_random_int() do
n when is_integer(n) -> clamp_int(n, low, high)
_ -> low + rem(:rand.uniform(max(high - low + 1, 1)), max(high - low + 1, 1))
end
end

defp corpus_fixed_random_int do
Process.get(:elmx_corpus_fixed_random_int) ||
Application.get_env(:elmx, :corpus_fixed_random_int)
end

def random_int(%{"low" => low, "high" => high}), do: random_int(%{low: low, high: high})

defp clamp_int(n, low, high) when is_integer(n) and is_integer(low) and is_integer(high) do
min(max(n, low), high)
end

@doc """
Elm `List.head` — returns `Just` / `Nothing` in the shape generated `case` expects.
"""
Expand Down
6 changes: 4 additions & 2 deletions elmx/lib/elmx/runtime/core/strings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ defmodule Elmx.Runtime.Core.Strings do
@spec to_int(term()) :: term()
def to_int(text) when is_binary(text) do
case Integer.parse(String.trim(text)) do
{n, ""} -> {:Ok, n}
_ -> {:Err, "NOT_AN_INT"}
{n, ""} -> {:Just, n}
_ -> :Nothing
end
end

def to_int(_), do: :Nothing

@spec to_float(term()) :: term()
def to_float(text) when is_binary(text) do
case Float.parse(String.trim(text)) do
Expand Down
3 changes: 2 additions & 1 deletion elmx/test/case_pattern_emit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ defmodule Elmx.CasePatternEmitTest do
{code, _, _} = Emit.compile_expr(expr, env, 0)
source = IO.iodata_to_binary(code)

assert source =~ "label.(8, 9, \"hi\")"
assert source =~ "label.(8).(9).(\"hi\")"
refute source =~ "label.(8, 9, \"hi\")"
refute source =~ "elmx_fn_Main_label"
end

Expand Down
28 changes: 28 additions & 0 deletions elmx/test/core_strings_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Elmx.CoreStringsTest do
use ExUnit.Case, async: true

alias Elmx.Runtime.Core
alias Elmx.Runtime.Core.Strings

test "String.toInt returns Maybe values" do
assert Strings.to_int("42") == {:Just, 42}
assert Strings.to_int("") == :Nothing
assert Strings.to_int("nope") == :Nothing
end

test "Maybe.withDefault unwraps String.toInt for invalid input" do
assert Core.maybe_with_default(0, Strings.to_int("")) == 0
assert Core.maybe_with_default(0, Strings.to_int("12")) == 12
assert Core.maybe_with_default(0, %{"ctor" => "Err", "args" => ["NOT_AN_INT"]}) == 0
end

test "corpus_fixed_random_int overrides random_int when configured" do
Process.put(:elmx_corpus_fixed_random_int, 99)

try do
assert Core.random_int(%{low: 1, high: 100}) == 99
after
Process.delete(:elmx_corpus_fixed_random_int)
end
end
end
32 changes: 32 additions & 0 deletions elmx/test/pebble_ui_helper_emit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,36 @@ defmodule Elmx.PebbleUiHelperEmitTest do
assert IO.iodata_to_binary(code) =~ "cmd_none"
refute IO.iodata_to_binary(code) =~ ~r/\bnone\(/
end

test "let-bound multi-arg helper calls are curried" do
label =
%{
op: :lambda,
args: ["x", "y", "text_"],
body: %{op: :int_literal, value: 0}
}

body =
%{
op: :call,
name: "label",
args: [
%{op: :int_literal, value: 8},
%{op: :int_literal, value: 36},
%{op: :string_literal, value: "hi"}
]
}

expr = %{op: :let_in, name: "label", value_expr: label, in_expr: body}
env = Emit.function_env("Main", ["model"])

{code, _, _} = Emit.compile_expr(expr, env, 0)
emitted = IO.iodata_to_binary(code)

assert emitted =~ "fn x ->"
assert emitted =~ "fn y ->"
assert emitted =~ "fn text_ ->"
assert emitted =~ "label.(8).(36).(\"hi\")"
refute emitted =~ "label.(8, 36, \"hi\")"
end
end
80 changes: 76 additions & 4 deletions ide/lib/ide/mcp/debugger_template_corpus.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do
raise ArgumentError, "unknown template #{inspect(template_key)}"
end

seed_corpus_random!()

slug = Keyword.get(opts, :slug) || unique_slug(template_key)
cleanup? = Keyword.get(opts, :cleanup, true)

Expand Down Expand Up @@ -183,6 +185,8 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do

@spec bootstrap(String.t(), Projects.Project.t(), String.t()) :: :ok | {:error, term()}
defp bootstrap(slug, project, template_key) do
seed_corpus_random!()

with {:ok, _} <- Tools.call("debugger.start", %{"slug" => slug}, @capabilities),
{:ok, _} <-
Tools.call(
Expand Down Expand Up @@ -424,19 +428,41 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do
|> Enum.map(fn row ->
type = Map.get(row, :type) || Map.get(row, "type")
message = Map.get(row, :message) || Map.get(row, "message")
"#{type}:#{message}"
"#{type}:#{normalize_timeline_message(message)}"
end)
end

@spec normalize_timeline_message(String.t() | nil) :: String.t()
defp normalize_timeline_message(message) when is_binary(message) do
Regex.replace(~r/^RandomGenerated \d+$/, message, "RandomGenerated <seed>")
end

defp normalize_timeline_message(message), do: to_string(message || "")

@spec normalize_snapshot(map()) :: map()
def normalize_snapshot(snapshot) when is_map(snapshot) do
snapshot
|> normalize_model_field("watch_model")
|> normalize_model_field("runtime_model")
|> normalize_render_tree_field()
|> Map.update("preview_ops", [], &normalize_preview_ops/1)
|> Map.update("timeline_init_messages", [], &normalize_timeline_messages/1)
end

@spec normalize_timeline_messages([String.t()]) :: [String.t()]
defp normalize_timeline_messages(messages) when is_list(messages) do
Enum.map(messages, &normalize_timeline_entry/1)
end

defp normalize_timeline_entry(entry) when is_binary(entry) do
case String.split(entry, ":", parts: 2) do
[type, message] -> "#{type}:#{normalize_timeline_message(message)}"
_ -> entry
end
end

defp normalize_timeline_entry(entry), do: entry

@spec normalize_model_field(map(), String.t()) :: map()
defp normalize_model_field(snapshot, key) do
Map.update(snapshot, key, %{}, &normalize_model/1)
Expand Down Expand Up @@ -472,11 +498,30 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do
"debugger_contract",
"debugger_contract_b64",
"runtime_model_sha256",
"runtime_view_output_model_sha256",
"runtime_view_tree_sha256",
"last_path",
"last_source",
"last_runtime_step_message",
"last_runtime_step_op",
"runtime_last_message"
"runtime_last_message",
"revision",
"launch_context",
"simulator_settings",
"runtime_execution",
"runtime_execution_mode",
"runtime_model_source",
"runtime_message_cursor",
"runtime_message_source",
"runtime_known_messages",
"runtime_update_branches",
"runtime_view_output",
"source_root",
"status",
"supports_color",
"screen_height",
"screen_width",
"_debugger_steps"
])
|> Enum.reject(fn {key, _} ->
key = to_string(key)
Expand Down Expand Up @@ -522,20 +567,40 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do
defp normalize_render_tree(list) when is_list(list),
do: Enum.map(list, &normalize_render_tree/1)

defp normalize_render_tree(other), do: other
defp normalize_render_tree(other), do: normalize_value(other)

@spec normalize_preview_ops(list()) :: list()
defp normalize_preview_ops(ops) when is_list(ops) do
Enum.map(ops, &normalize_value/1)
end

@snapshot_hash_keys ~w(
runtime_view_output_model_sha256
runtime_view_tree_sha256
runtime_model_sha256
)

@spec normalize_value(term()) :: term()
defp normalize_value(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_string(k), normalize_value(v)} end)
map
|> Map.drop(@snapshot_hash_keys)
|> Map.new(fn {k, v} -> {to_string(k), normalize_value(v)} end)
end

defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)
defp normalize_value({a, b}), do: [normalize_value(a), normalize_value(b)]

defp normalize_value(atom) when is_atom(atom) do
case atom do
true -> true
false -> false
nil -> nil
other -> Atom.to_string(other)
end
end

defp normalize_value(n) when is_float(n), do: Float.round(n, 2)

defp normalize_value(other), do: other

@spec watch_profile_for(String.t()) :: String.t()
Expand Down Expand Up @@ -846,4 +911,11 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do
end)
|> Enum.join("\n")
end

@spec seed_corpus_random!() :: :ok
defp seed_corpus_random! do
:rand.seed(:exsss, {0, 0, 1})
Application.put_env(:elmx, :corpus_fixed_random_int, 42_424_242)
:ok
end
end
Loading
Loading