diff --git a/.github/workflows/debugger-strict.yml b/.github/workflows/debugger-strict.yml index 7740cc7c..f137194f 100644 --- a/.github/workflows/debugger-strict.yml +++ b/.github/workflows/debugger-strict.yml @@ -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 diff --git a/elmx/lib/elmx/backend/elixir_codegen/emit.ex b/elmx/lib/elmx/backend/elixir_codegen/emit.ex index f815fd78..52d09d2b 100644 --- a/elmx/lib/elmx/backend/elixir_codegen/emit.ex +++ b/elmx/lib/elmx/backend/elixir_codegen/emit.ex @@ -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__) @@ -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) diff --git a/elmx/lib/elmx/runtime/core.ex b/elmx/lib/elmx/runtime/core.ex index 91024510..ac709ded 100644 --- a/elmx/lib/elmx/runtime/core.ex +++ b/elmx/lib/elmx/runtime/core.ex @@ -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 @@ -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. """ diff --git a/elmx/lib/elmx/runtime/core/strings.ex b/elmx/lib/elmx/runtime/core/strings.ex index 1c3d7fb2..c9749be2 100644 --- a/elmx/lib/elmx/runtime/core/strings.ex +++ b/elmx/lib/elmx/runtime/core/strings.ex @@ -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 diff --git a/elmx/test/case_pattern_emit_test.exs b/elmx/test/case_pattern_emit_test.exs index 63fcd4a8..dac996ec 100644 --- a/elmx/test/case_pattern_emit_test.exs +++ b/elmx/test/case_pattern_emit_test.exs @@ -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 diff --git a/elmx/test/core_strings_test.exs b/elmx/test/core_strings_test.exs new file mode 100644 index 00000000..cffa4dab --- /dev/null +++ b/elmx/test/core_strings_test.exs @@ -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 diff --git a/elmx/test/pebble_ui_helper_emit_test.exs b/elmx/test/pebble_ui_helper_emit_test.exs index 4938320c..caa24f4f 100644 --- a/elmx/test/pebble_ui_helper_emit_test.exs +++ b/elmx/test/pebble_ui_helper_emit_test.exs @@ -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 diff --git a/ide/lib/ide/mcp/debugger_template_corpus.ex b/ide/lib/ide/mcp/debugger_template_corpus.ex index f93f8430..610a5cca 100644 --- a/ide/lib/ide/mcp/debugger_template_corpus.ex +++ b/ide/lib/ide/mcp/debugger_template_corpus.ex @@ -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) @@ -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( @@ -424,10 +428,17 @@ 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 ") + 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 @@ -435,8 +446,23 @@ defmodule Ide.Mcp.DebuggerTemplateCorpus do |> 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) @@ -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) @@ -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() @@ -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 diff --git a/ide/test/fixtures/debugger_template_corpus/companion-demo-calendar.json b/ide/test/fixtures/debugger_template_corpus/companion-demo-calendar.json index 1648ef6a..de490684 100644 --- a/ide/test/fixtures/debugger_template_corpus/companion-demo-calendar.json +++ b/ide/test/fixtures/debugger_template_corpus/companion-demo-calendar.json @@ -85,78 +85,87 @@ { "children": [], "color": 255, - "label": "", + "kind": "clear", + "label": "clear", "text": null, "type": "clear" }, { + "bounds": { + "h": 18, + "text": null, + "w": 128, + "x": 8, + "y": 36 + }, "children": [], - "font_id": 1, - "h": 18, - "label": "", + "font": "DefaultFont", + "kind": "text", + "label": "08:53", + "options": [], "text": "