From b6f34754c2634b5e1f407ddf29eb13e0912ffd38 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:30:27 -0600 Subject: [PATCH 1/8] Implement with expressions --- assets/js/interpreter.mjs | 45 ++++++- lib/hologram/compiler/encoder.ex | 8 +- lib/hologram/compiler/ir.ex | 9 +- lib/hologram/compiler/transformer.ex | 31 ++++- .../elixir/hologram/compiler/encoder_test.exs | 28 +++- .../hologram/compiler/transformer_test.exs | 20 ++- test/javascript/interpreter_test.mjs | 125 +++++++++++++++++- 7 files changed, 238 insertions(+), 28 deletions(-) diff --git a/assets/js/interpreter.mjs b/assets/js/interpreter.mjs index 0e2c4b6305..5ee14c29bd 100644 --- a/assets/js/interpreter.mjs +++ b/assets/js/interpreter.mjs @@ -209,7 +209,8 @@ export default class Interpreter { // case() has no unit tests in interpreter_test.mjs, only feature tests in test/features/test/control_flow/case_test.exs // Unit test maintenance in interpreter_test.mjs would be problematic because tests would need to be updated // each time Hologram.Compiler.Encoder's implementation changes. - static case(condition, clauses, context) { + static case(condition, clauses, context, errorCallback) { + errorCallback = errorCallback || Interpreter.raiseCaseClauseError; if (typeof condition === "function") { condition = condition(context); } @@ -226,7 +227,7 @@ export default class Interpreter { } } - Interpreter.raiseCaseClauseError(condition); + errorCallback(condition); } static cloneContext(context) { @@ -822,6 +823,12 @@ export default class Interpreter { Interpreter.raiseError("CaseClauseError", message); } + static raiseWithClauseError(arg) { + const message = "no with clause matching: " + Interpreter.inspect(arg); + + Interpreter.raiseError("WithClauseError", message); + } + static raiseCompileError(message) { Interpreter.raiseError("CompileError", message); } @@ -924,11 +931,35 @@ export default class Interpreter { return context; } - // TODO: finish implementing - static with() { - throw new HologramInterpreterError( - '"with" expression is not yet implemented in Hologram', - ); + static with(body, clauses, elseClauses, context) { + for (const clause of clauses) { + const contextClone = Interpreter.cloneContext(context); + const condition = clause.body(contextClone); + + if (Interpreter.isMatched(clause.match, condition, contextClone)) { + Interpreter.updateVarsToMatchedValues(contextClone); + + if (Interpreter.#evaluateGuards(clause.guards, contextClone)) { + context = contextClone; + } else { + return Interpreter.case( + condition, + elseClauses, + context, + Interpreter.raiseWithClauseError, + ); + } + } else { + return Interpreter.case( + condition, + elseClauses, + context, + Interpreter.raiseWithClauseError, + ); + } + } + + return body(context); } static #areBitstringsEqual(bitstring1, bitstring2) { diff --git a/lib/hologram/compiler/encoder.ex b/lib/hologram/compiler/encoder.ex index ed9ea04d3d..2fd5272646 100644 --- a/lib/hologram/compiler/encoder.ex +++ b/lib/hologram/compiler/encoder.ex @@ -435,9 +435,11 @@ defmodule Hologram.Compiler.Encoder do encode_var(name, version) end - # TODO: finish implementing - def encode_ir(%IR.With{}, _context) do - "Interpreter.with()" + def encode_ir(%IR.With{body: body, clauses: clauses, else_clauses: else_clauses}, context) do + body_js = encode_closure(body, context) + clauses_js = encode_as_array(clauses, context) + else_clauses_js = encode_as_array(else_clauses, context) + "Interpreter.with(#{body_js}, #{clauses_js}, #{else_clauses_js}, context)" end @doc """ diff --git a/lib/hologram/compiler/ir.ex b/lib/hologram/compiler/ir.ex index 5ce8ff040b..f6a472824d 100644 --- a/lib/hologram/compiler/ir.ex +++ b/lib/hologram/compiler/ir.ex @@ -384,13 +384,16 @@ defmodule Hologram.Compiler.IR do @type t :: %__MODULE__{name: atom, version: integer | nil} end - # TODO: finish implementing defmodule With do @moduledoc false - defstruct [] + defstruct [:clauses, :body, :else_clauses] - @type t :: %__MODULE__{} + @type t :: %__MODULE__{ + clauses: list(IR.Clause.t()), + body: IR.Block.t(), + else_clauses: list(IR.Clause.t()) + } end @doc """ diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index 75c72fa0d7..04a30a6246 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -442,9 +442,34 @@ defmodule Hologram.Compiler.Transformer do |> build_tuple_type_ir(context) end - # TODO: finish implementing - def transform({:with, _meta, parts}, _context) when is_list(parts) do - %IR.With{} + def transform({:with, _meta, parts}, context) when is_list(parts) do + initial_acc = %IR.With{ + clauses: [], + else_clauses: [], + body: nil + } + + Enum.reduce( + parts, + initial_acc, + fn + do_and_else, acc when is_list(do_and_else) -> + do_part = + do_and_else + |> Keyword.get(:do) + |> transform(context) + + else_part = + do_and_else + |> Keyword.get(:else, []) + |> Enum.map(&transform(&1, context)) + + %{acc | body: do_part, else_clauses: else_part} + + clause, acc -> + %{acc | clauses: acc.clauses ++ [transform(clause, context)]} + end + ) end # --- PRESERVE ORDER (BEGIN) --- diff --git a/test/elixir/hologram/compiler/encoder_test.exs b/test/elixir/hologram/compiler/encoder_test.exs index 2c059b35ec..8eb608e177 100644 --- a/test/elixir/hologram/compiler/encoder_test.exs +++ b/test/elixir/hologram/compiler/encoder_test.exs @@ -2146,9 +2146,33 @@ defmodule Hologram.Compiler.EncoderTest do end end - # TODO: finish implementing test "with" do - assert encode_ir(%IR.With{}) == "Interpreter.with()" + ir = %IR.With{ + body: %IR.AtomType{value: :ok}, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [ + %IR.Clause{ + match: %IR.AtomType{value: :error}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => Type.atom("ok"), [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { + return Type.atom("error"); + }}], context)\ + """) + + assert encode_ir(ir) == expected end describe "encode_as_class_name/1" do diff --git a/test/elixir/hologram/compiler/transformer_test.exs b/test/elixir/hologram/compiler/transformer_test.exs index bf9d56cd6c..65fe24b7c4 100644 --- a/test/elixir/hologram/compiler/transformer_test.exs +++ b/test/elixir/hologram/compiler/transformer_test.exs @@ -6452,15 +6452,29 @@ defmodule Hologram.Compiler.TransformerTest do end end - # TODO: finish implementing test "with" do ast = ast(""" - with true <- true do + with {:ok, x} when not is_nil(x) <- {:ok, foo(y)}, + {x, bar} <- baz(x), + :test = :test do :ok + else + :error -> + {:error, :wrong_data} + {:error, value} -> + {:error, :wrong_data, value} end """) - assert transform(ast, %Context{}) == %Hologram.Compiler.IR.With{} + assert %Hologram.Compiler.IR.With{ + body: body, + clauses: [first_clause, _second, _third], + else_clauses: [first_else, _second_else] + } = transform(ast, %Context{}) + + assert body == %IR.AtomType{value: :ok} + assert %IR.Clause{} = first_clause + assert %IR.Clause{} = first_else end end diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index 91c5110eca..baa513ae54 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6855,12 +6855,123 @@ describe("Interpreter", () => { assert.deepStrictEqual(result, expected); }); - // TODO: finish implementing - it("with()", () => { - assert.throw( - () => Interpreter.with(), - Error, - '"with" expression is not yet implemented in Hologram', - ); + describe("with()", () => { + const context = contextFixture({ + vars: { + a: Type.atom("ok"), + }, + }); + const body = (context) => { + return Type.tuple([context.vars.a, context.vars.b]); + }; + + it("successful match returns body result", () => { + // with b <- a do + // {a, b} + // end + const result = Interpreter.with( + body, + [ + { + match: Type.variablePattern("b"), + guards: [], + body: (context) => context.vars.a, + }, + ], + [], + context, + ); + + const expected = Type.tuple([Type.atom("ok"), Type.atom("ok")]); + assert.deepStrictEqual(result, expected); + }); + + it("can fail to match on match condition", () => { + // with :error <- a do + // else + // :ok -> {:error, :nomatch} + // end + const expected = Type.tuple([Type.atom("error"), Type.atom("nomatch")]); + const result = Interpreter.with( + body, + [ + { + match: Type.atom("error"), + guards: [], + body: (context) => context.vars.a, + }, + ], + [ + { + match: Type.atom("ok"), + guards: [], + body: (_context) => expected, + }, + ], + context, + ); + + assert.deepStrictEqual(result, expected); + }); + + it("can fail to match on guard", () => { + // with b when b == :no <- a do + // else + // :ok -> {:error, :nomatch} + // end + const guard = (context) => + Erlang["/=/2"](context.vars.b, Type.atom("no")); + const expected = Type.tuple([Type.atom("error"), Type.atom("nomatch")]); + const result = Interpreter.with( + body, + [ + { + match: Type.atom("error"), + guards: [guard], + body: (context) => context.vars.a, + }, + ], + [ + { + match: Type.atom("ok"), + guards: [], + body: (_context) => expected, + }, + ], + context, + ); + + assert.deepStrictEqual(result, expected); + }); + // Case 4: No else clause matches + it("throws error if no else clauses match", () => { + // with b when b == :no <- a do + // else + // :ok -> {:error, :nomatch} + // end + assertBoxedError( + () => + Interpreter.with( + body, + [ + { + match: Type.atom("error"), + guards: [], + body: (context) => context.vars.a, + }, + ], + [ + { + match: Type.atom("other"), + guards: [], + body: (_context) => expected, + }, + ], + context, + ), + "WithClauseError", + "no with clause matching: :ok", + ); + }); }); }); From 6e7063c834fae07b0d002d83a47ce54f3c992b13 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:54:09 -0600 Subject: [PATCH 2/8] Improve tests and types for with clauses --- lib/hologram/compiler/ir.ex | 2 +- lib/hologram/compiler/transformer.ex | 5 ++++- test/javascript/interpreter_test.mjs | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/hologram/compiler/ir.ex b/lib/hologram/compiler/ir.ex index f6a472824d..8e0725ecc2 100644 --- a/lib/hologram/compiler/ir.ex +++ b/lib/hologram/compiler/ir.ex @@ -391,7 +391,7 @@ defmodule Hologram.Compiler.IR do @type t :: %__MODULE__{ clauses: list(IR.Clause.t()), - body: IR.Block.t(), + body: IR.t(), else_clauses: list(IR.Clause.t()) } end diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index 04a30a6246..6fb61d4ff7 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -467,9 +467,12 @@ defmodule Hologram.Compiler.Transformer do %{acc | body: do_part, else_clauses: else_part} clause, acc -> - %{acc | clauses: acc.clauses ++ [transform(clause, context)]} + %{acc | clauses: [transform(clause, context) | acc.clauses]} end ) + |> then(fn %{clauses: clauses} = ir -> + %{ir | clauses: Enum.reverse(clauses)} + end) end # --- PRESERVE ORDER (BEGIN) --- diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index baa513ae54..fa14f72e1a 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6926,7 +6926,7 @@ describe("Interpreter", () => { body, [ { - match: Type.atom("error"), + match: Type.variablePattern("b"), guards: [guard], body: (context) => context.vars.a, }, @@ -6943,7 +6943,6 @@ describe("Interpreter", () => { assert.deepStrictEqual(result, expected); }); - // Case 4: No else clause matches it("throws error if no else clauses match", () => { // with b when b == :no <- a do // else @@ -6964,7 +6963,7 @@ describe("Interpreter", () => { { match: Type.atom("other"), guards: [], - body: (_context) => expected, + body: (_context) => null, }, ], context, From 680f641f0b8d7ce2ea6b15cde67210f8de3e5d12 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:35:55 -0600 Subject: [PATCH 3/8] Fix test for with guards --- test/javascript/interpreter_test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index fa14f72e1a..a7a36b72d6 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6920,8 +6920,9 @@ describe("Interpreter", () => { // :ok -> {:error, :nomatch} // end const guard = (context) => - Erlang["/=/2"](context.vars.b, Type.atom("no")); + Erlang["==/2"](context.vars.b, Type.atom("no")); const expected = Type.tuple([Type.atom("error"), Type.atom("nomatch")]); + const result = Interpreter.with( body, [ @@ -6944,9 +6945,9 @@ describe("Interpreter", () => { assert.deepStrictEqual(result, expected); }); it("throws error if no else clauses match", () => { - // with b when b == :no <- a do + // with :error <- a do // else - // :ok -> {:error, :nomatch} + // :other -> {:error, :nomatch} // end assertBoxedError( () => From 8f903f1b4f3cdfc07c7b5e3cc9aa9ad0d9a75b71 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:49:10 -0600 Subject: [PATCH 4/8] Improve readability of transform with --- lib/hologram/compiler/transformer.ex | 40 +++++++++++++++------------- test/javascript/interpreter_test.mjs | 2 +- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index 6fb61d4ff7..62f07b2ca4 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -449,26 +449,10 @@ defmodule Hologram.Compiler.Transformer do body: nil } - Enum.reduce( - parts, + parts + |> Enum.reduce( initial_acc, - fn - do_and_else, acc when is_list(do_and_else) -> - do_part = - do_and_else - |> Keyword.get(:do) - |> transform(context) - - else_part = - do_and_else - |> Keyword.get(:else, []) - |> Enum.map(&transform(&1, context)) - - %{acc | body: do_part, else_clauses: else_part} - - clause, acc -> - %{acc | clauses: [transform(clause, context) | acc.clauses]} - end + &transform_with_clause(&1, &2, context) ) |> then(fn %{clauses: clauses} = ir -> %{ir | clauses: Enum.reverse(clauses)} @@ -948,4 +932,22 @@ defmodule Hologram.Compiler.Transformer do %IR.Variable{name: name, version: version} end end + + defp transform_with_clause(do_and_else, acc, context) when is_list(do_and_else) do + do_part = + do_and_else + |> Keyword.get(:do) + |> transform(context) + + else_part = + do_and_else + |> Keyword.get(:else, []) + |> Enum.map(&transform(&1, context)) + + %{acc | body: do_part, else_clauses: else_part} + end + + defp transform_with_clause(clause, acc, context) do + %{acc | clauses: [transform(clause, context) | acc.clauses]} + end end diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index a7a36b72d6..9b248a99ff 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6922,7 +6922,7 @@ describe("Interpreter", () => { const guard = (context) => Erlang["==/2"](context.vars.b, Type.atom("no")); const expected = Type.tuple([Type.atom("error"), Type.atom("nomatch")]); - + const result = Interpreter.with( body, [ From 9b864868cf5178fa580321d7691b0602703a7b71 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:12:46 +0000 Subject: [PATCH 5/8] Handle struct matches in with clauses Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- lib/hologram/compiler/transformer.ex | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index 62f07b2ca4..dca4b373ce 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -947,6 +947,30 @@ defmodule Hologram.Compiler.Transformer do %{acc | body: do_part, else_clauses: else_part} end + defp transform_with_clause( + {:<-, _meta_1, [{:when, _meta_2, [match, guards]}, body]}, + acc, + context + ) do + clause = %IR.Clause{ + match: transform(match, %{context | pattern?: true}), + guards: transform_guards(guards, context), + body: transform(body, context) + } + + %{acc | clauses: [clause | acc.clauses]} + end + + defp transform_with_clause({:<-, _meta, [match, body]}, acc, context) do + clause = %IR.Clause{ + match: transform(match, %{context | pattern?: true}), + guards: [], + body: transform(body, context) + } + + %{acc | clauses: [clause | acc.clauses]} + end + defp transform_with_clause(clause, acc, context) do %{acc | clauses: [transform(clause, context) | acc.clauses]} end From ef10dc66dfcc7d2c8a98b52f29b53a8261c321c2 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:46:04 -0600 Subject: [PATCH 6/8] Wrap plain clauses in with --- lib/hologram/compiler/transformer.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index dca4b373ce..dc832eaa78 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -972,6 +972,13 @@ defmodule Hologram.Compiler.Transformer do end defp transform_with_clause(clause, acc, context) do - %{acc | clauses: [transform(clause, context) | acc.clauses]} + clause = + %IR.Clause{ + match: %IR.MatchPlaceholder{}, + guards: [], + body: transform(clause, context) + } + + %{acc | clauses: [clause | acc.clauses]} end end From 29f2f318d8fd39f560f601a16b4d94f9b93c3bd3 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:46:02 -0600 Subject: [PATCH 7/8] Add more with clause tests --- assets/js/interpreter.mjs | 75 ++-- lib/hologram/compiler/encoder.ex | 8 + lib/hologram/compiler/ir.ex | 9 + lib/hologram/compiler/transformer.ex | 12 +- .../elixir/hologram/compiler/encoder_test.exs | 283 +++++++++++-- .../hologram/compiler/transformer_test.exs | 383 ++++++++++++++++-- test/javascript/interpreter_test.mjs | 185 ++++++++- 7 files changed, 864 insertions(+), 91 deletions(-) diff --git a/assets/js/interpreter.mjs b/assets/js/interpreter.mjs index 5ee14c29bd..b8b245e752 100644 --- a/assets/js/interpreter.mjs +++ b/assets/js/interpreter.mjs @@ -209,25 +209,13 @@ export default class Interpreter { // case() has no unit tests in interpreter_test.mjs, only feature tests in test/features/test/control_flow/case_test.exs // Unit test maintenance in interpreter_test.mjs would be problematic because tests would need to be updated // each time Hologram.Compiler.Encoder's implementation changes. - static case(condition, clauses, context, errorCallback) { - errorCallback = errorCallback || Interpreter.raiseCaseClauseError; - if (typeof condition === "function") { - condition = condition(context); - } - - for (const clause of clauses) { - const contextClone = Interpreter.cloneContext(context); - - if (Interpreter.isMatched(clause.match, condition, contextClone)) { - Interpreter.updateVarsToMatchedValues(contextClone); - - if (Interpreter.#evaluateGuards(clause.guards, contextClone)) { - return clause.body(contextClone); - } - } - } - - errorCallback(condition); + static case(condition, clauses, context) { + return Interpreter.#checkAllClauses( + condition, + clauses, + context, + Interpreter.raiseCaseClauseError, + ); } static cloneContext(context) { @@ -932,9 +920,10 @@ export default class Interpreter { } static with(body, clauses, elseClauses, context) { + const originalContext = context; for (const clause of clauses) { const contextClone = Interpreter.cloneContext(context); - const condition = clause.body(contextClone); + const condition = clause.expression(contextClone); if (Interpreter.isMatched(clause.match, condition, contextClone)) { Interpreter.updateVarsToMatchedValues(contextClone); @@ -942,20 +931,10 @@ export default class Interpreter { if (Interpreter.#evaluateGuards(clause.guards, contextClone)) { context = contextClone; } else { - return Interpreter.case( - condition, - elseClauses, - context, - Interpreter.raiseWithClauseError, - ); + return Interpreter.#withElse(condition, elseClauses, originalContext); } } else { - return Interpreter.case( - condition, - elseClauses, - context, - Interpreter.raiseWithClauseError, - ); + return Interpreter.#withElse(condition, elseClauses, originalContext); } } @@ -1062,6 +1041,26 @@ export default class Interpreter { ); } + static #checkAllClauses(condition, clauses, context, errorFun) { + if (typeof condition === "function") { + condition = condition(context); + } + + for (const clause of clauses) { + const contextClone = Interpreter.cloneContext(context); + + if (Interpreter.isMatched(clause.match, condition, contextClone)) { + Interpreter.updateVarsToMatchedValues(contextClone); + + if (Interpreter.#evaluateGuards(clause.guards, contextClone)) { + return clause.body(contextClone); + } + } + } + + errorFun(condition); + } + static #comparePids(pid1, pid2) { for (let i = 2; i >= 0; --i) { if (pid1.segments[i] === pid2.segments[i]) { @@ -1563,6 +1562,18 @@ export default class Interpreter { "no cond clause evaluated to a truthy value", ); } + + static #withElse(condition, elseClauses, context) { + if (elseClauses.length === 0) { + return condition; + } + return Interpreter.#checkAllClauses( + condition, + elseClauses, + context, + Interpreter.raiseWithClauseError, + ); + } } const $ = Interpreter; diff --git a/lib/hologram/compiler/encoder.ex b/lib/hologram/compiler/encoder.ex index 2fd5272646..c46c6120f1 100644 --- a/lib/hologram/compiler/encoder.ex +++ b/lib/hologram/compiler/encoder.ex @@ -442,6 +442,14 @@ defmodule Hologram.Compiler.Encoder do "Interpreter.with(#{body_js}, #{clauses_js}, #{else_clauses_js}, context)" end + def encode_ir(%IR.WithClause{} = clause, context) do + match = encode_ir(clause.match, %{context | pattern?: true}) + guards = encode_as_array(clause.guards, context, &encode_closure/2) + expression = encode_closure(clause.expression, context) + + "{match: #{match}, guards: #{guards}, expression: #{expression}}" + end + @doc """ Encodes Elixir term into JavaScript. If the term can be encoded into JavaScript then the result is in the shape of {:ok, js}. diff --git a/lib/hologram/compiler/ir.ex b/lib/hologram/compiler/ir.ex index 8e0725ecc2..6c4d132040 100644 --- a/lib/hologram/compiler/ir.ex +++ b/lib/hologram/compiler/ir.ex @@ -47,6 +47,7 @@ defmodule Hologram.Compiler.IR do | IR.TupleType.t() | IR.Variable.t() | IR.With.t() + | IR.WithClause.t() defmodule AnonymousFunctionCall do @moduledoc false @@ -396,6 +397,14 @@ defmodule Hologram.Compiler.IR do } end + defmodule WithClause do + @moduledoc false + + defstruct [:match, :guards, :expression] + + @type t :: %__MODULE__{match: IR.t(), guards: list(IR.t()), expression: IR.Block.t()} + end + @doc """ Aggregates function clauses from a module definition. diff --git a/lib/hologram/compiler/transformer.ex b/lib/hologram/compiler/transformer.ex index dc832eaa78..c35683e247 100644 --- a/lib/hologram/compiler/transformer.ex +++ b/lib/hologram/compiler/transformer.ex @@ -952,20 +952,20 @@ defmodule Hologram.Compiler.Transformer do acc, context ) do - clause = %IR.Clause{ + clause = %IR.WithClause{ match: transform(match, %{context | pattern?: true}), guards: transform_guards(guards, context), - body: transform(body, context) + expression: transform(body, context) } %{acc | clauses: [clause | acc.clauses]} end defp transform_with_clause({:<-, _meta, [match, body]}, acc, context) do - clause = %IR.Clause{ + clause = %IR.WithClause{ match: transform(match, %{context | pattern?: true}), guards: [], - body: transform(body, context) + expression: transform(body, context) } %{acc | clauses: [clause | acc.clauses]} @@ -973,10 +973,10 @@ defmodule Hologram.Compiler.Transformer do defp transform_with_clause(clause, acc, context) do clause = - %IR.Clause{ + %IR.WithClause{ match: %IR.MatchPlaceholder{}, guards: [], - body: transform(clause, context) + expression: transform(clause, context) } %{acc | clauses: [clause | acc.clauses]} diff --git a/test/elixir/hologram/compiler/encoder_test.exs b/test/elixir/hologram/compiler/encoder_test.exs index 8eb608e177..90a9af68d8 100644 --- a/test/elixir/hologram/compiler/encoder_test.exs +++ b/test/elixir/hologram/compiler/encoder_test.exs @@ -2146,33 +2146,268 @@ defmodule Hologram.Compiler.EncoderTest do end end - test "with" do - ir = %IR.With{ - body: %IR.AtomType{value: :ok}, - clauses: [ - %IR.Clause{ - match: %IR.Variable{name: :x}, - body: %IR.Variable{name: :y}, - guards: [] - } - ], - else_clauses: [ - %IR.Clause{ - match: %IR.AtomType{value: :error}, - guards: [], - body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + describe "with" do + test "minimal with" do + ir = %IR.With{ + body: %IR.Block{ + expressions: [] + }, + clauses: [], + else_clauses: [] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => { + return Type.atom("nil"); + }, [], [], context)\ + """) + + assert encode_ir(ir) === expected + end + + test "with body, clause, and else clause" do + ir = %IR.With{ + body: %IR.AtomType{value: :ok}, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [ + %IR.Clause{ + match: %IR.AtomType{value: :error}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => Type.atom("ok"), [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { + return Type.atom("error"); + }}], context)\ + """) + + assert encode_ir(ir) == expected + end + + test "with empty body" do + ir = %IR.With{ + body: %IR.AtomType{value: nil}, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [ + %IR.Clause{ + match: %IR.AtomType{value: :error}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => Type.atom("nil"), [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { + return Type.atom("error"); + }}], context)\ + """) + + assert encode_ir(ir) == expected + end + + test "with multi expression body" do + ir = %IR.With{ + body: %IR.Block{ + expressions: [ + %IR.MatchOperator{ + left: %IR.Variable{name: :x}, + right: %IR.IntegerType{value: 5} + }, + %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.Variable{name: :x} + ] + } + ] + }, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [ + %IR.Clause{ + match: %IR.AtomType{value: :error}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => { + Interpreter.matchOperator(Type.integer(5n), Type.variablePattern("x"), context); + Interpreter.updateVarsToMatchedValues(context); + return Erlang_["foo/1"](context.vars.x); + }, [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { + return Type.atom("error"); + }}], context)\ + """) + + assert encode_ir(ir) == expected + end + + test "without else clauses" do + ir = %IR.With{ + body: %IR.Block{ + expressions: [ + %IR.MatchOperator{ + left: %IR.Variable{name: :x}, + right: %IR.IntegerType{value: 5} + }, + %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.Variable{name: :x} + ] + } + ] + }, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => { + Interpreter.matchOperator(Type.integer(5n), Type.variablePattern("x"), context); + Interpreter.updateVarsToMatchedValues(context); + return Erlang_["foo/1"](context.vars.x); + }, [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [], context)\ + """) + + assert encode_ir(ir) == expected + end + + test "with multiple else clauses" do + ir = %IR.With{ + body: %IR.AtomType{value: :ok}, + clauses: [ + %IR.Clause{ + match: %IR.Variable{name: :x}, + body: %IR.Variable{name: :y}, + guards: [] + } + ], + else_clauses: [ + %IR.Clause{ + match: %IR.AtomType{value: :error}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + }, + %IR.Clause{ + match: %IR.AtomType{value: :timeout}, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + + expected = + normalize_newlines(""" + Interpreter.with((context) => Type.atom("ok"), [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { + return Type.atom("error"); + }}, {match: Type.atom("timeout"), guards: [], body: (context) => { + return Type.atom("error"); + }}], context)\ + """) + + assert encode_ir(ir) == expected + end + end + + describe "WithClause" do + test "clause without guard" do + ir = %IR.WithClause{ + match: %IR.Variable{name: :x}, + guards: [], + expression: %IR.Variable{name: :y} + } + + assert encode_ir(ir) == + ~s|{match: Type.variablePattern("x"), guards: [], expression: (context) => context.vars.y}| + end + + test "clause with guard" do + ir = %IR.WithClause{ + match: %IR.Variable{ + name: :i + }, + guards: [ + %IR.LocalFunctionCall{ + args: [%IR.Variable{name: :i}], + function: :is_integer + } + ], + expression: %IR.Variable{name: :x} + } + + assert encode_ir(ir) =~ ~s|guards: [(context) => Erlang_["is_integer/1"](context.vars.i)]| + end + + test "with plain expression" do + ir = %IR.WithClause{ + match: %IR.MatchPlaceholder{}, + guards: [], + expression: %IR.MatchOperator{ + left: %IR.Variable{name: :x}, + right: %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.IntegerType{value: 5} + ] + } } - ] - } + } - expected = - normalize_newlines(""" - Interpreter.with((context) => Type.atom("ok"), [{match: Type.variablePattern("x"), guards: [], body: (context) => context.vars.y}], [{match: Type.atom("error"), guards: [], body: (context) => { - return Type.atom("error"); - }}], context)\ - """) + assert encode_ir(ir) == + ~s|{match: Type.matchPlaceholder(), guards: [], expression: (context) => Interpreter.matchOperator(Erlang_[\"foo/1\"](Type.integer(5n)), Type.variablePattern(\"x\"), context)}| + end - assert encode_ir(ir) == expected + test "with plain expression (function call)" do + ir = %IR.WithClause{ + match: %IR.MatchPlaceholder{}, + guards: [], + expression: %IR.LocalFunctionCall{ + function: :baz, + args: [ + %IR.IntegerType{value: 5} + ] + } + } + + assert encode_ir(ir) == + ~s|{match: Type.matchPlaceholder(), guards: [], expression: (context) => Erlang_["baz/1"](Type.integer(5n))}| + end end describe "encode_as_class_name/1" do diff --git a/test/elixir/hologram/compiler/transformer_test.exs b/test/elixir/hologram/compiler/transformer_test.exs index 65fe24b7c4..c2e475b272 100644 --- a/test/elixir/hologram/compiler/transformer_test.exs +++ b/test/elixir/hologram/compiler/transformer_test.exs @@ -6452,29 +6452,364 @@ defmodule Hologram.Compiler.TransformerTest do end end - test "with" do - ast = - ast(""" - with {:ok, x} when not is_nil(x) <- {:ok, foo(y)}, - {x, bar} <- baz(x), - :test = :test do - :ok - else - :error -> - {:error, :wrong_data} - {:error, value} -> - {:error, :wrong_data, value} - end - """) - - assert %Hologram.Compiler.IR.With{ - body: body, - clauses: [first_clause, _second, _third], - else_clauses: [first_else, _second_else] - } = transform(ast, %Context{}) - - assert body == %IR.AtomType{value: :ok} - assert %IR.Clause{} = first_clause - assert %IR.Clause{} = first_else + describe "with" do + test "empty with" do + ast = + ast(""" + with do + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + clauses: [], + else_clauses: [], + body: %IR.Block{expressions: []} + } + end + + test "multiple clauses and else clauses" do + ast = + ast(""" + with {:ok, x} when not is_nil(x) <- {:ok, foo(y)}, + {x, bar} <- baz(x), + :test = :test do + :ok + else + :error -> + {:error, :wrong_data} + {:error, value} -> + {:error, :wrong_data, value} + end + """) + + assert %Hologram.Compiler.IR.With{ + body: body, + clauses: [first_clause, _second, _third], + else_clauses: [first_else, _second_else] + } = transform(ast, %Context{}) + + assert body == %IR.AtomType{value: :ok} + assert %IR.WithClause{} = first_clause + assert %IR.Clause{} = first_else + end + + test "clause without guards" do + ast = + ast(""" + with :ok <- y do + end + """) + + assert %IR.With{ + clauses: [clause], + else_clauses: [], + body: body + } = transform(ast, %Context{}) + + assert body == %IR.Block{expressions: []} + + assert clause == %IR.WithClause{ + match: %IR.AtomType{value: :ok}, + guards: [], + expression: %IR.Variable{name: :y} + } + end + + test "clause with guards" do + ast = + ast(""" + with i when is_integer(i) <- x, do: x + """) + + assert %{clauses: [clause], body: body} = transform(ast, %Context{}) + + assert clause == %IR.WithClause{ + match: %IR.Variable{ + name: :i + }, + guards: [ + %IR.LocalFunctionCall{ + args: [%IR.Variable{name: :i}], + function: :is_integer + } + ], + expression: %IR.Variable{name: :x} + } + + assert body == %IR.Variable{name: :x} + end + + test "with compound guard calls" do + ast = + ast(""" + with x when is_integer(x) and x > 5 <- y, do: nil + """) + + assert transform(ast, %Context{}) == %IR.With{ + body: %IR.AtomType{value: nil}, + else_clauses: [], + clauses: [ + %IR.WithClause{ + match: %IR.Variable{ + name: :x + }, + expression: %IR.Variable{name: :y}, + guards: [ + %IR.LocalFunctionCall{ + function: :and, + args: [ + %IR.LocalFunctionCall{ + function: :is_integer, + args: [ + %IR.Variable{ + name: :x + } + ] + }, + %IR.LocalFunctionCall{ + function: :>, + args: [ + %IR.Variable{ + name: :x + }, + %IR.IntegerType{ + value: 5 + } + ] + } + ] + } + ] + } + ] + } + end + + test "with plain expressions" do + ast = + ast(""" + with x = foo(5), + y <- x do + end + """) + + assert %{clauses: [plain_clause, arrow_clause]} = transform(ast, %Context{}) + + assert plain_clause == %IR.WithClause{ + match: %IR.MatchPlaceholder{}, + guards: [], + expression: %IR.MatchOperator{ + left: %IR.Variable{name: :x}, + right: %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.IntegerType{value: 5} + ] + } + } + } + + assert arrow_clause == %IR.WithClause{ + match: %IR.Variable{name: :y}, + guards: [], + expression: %IR.Variable{name: :x} + } + end + + test "with plain expression (function call)" do + ast = + ast(""" + with baz(5) do + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + body: %IR.Block{expressions: []}, + clauses: [ + %IR.WithClause{ + match: %IR.MatchPlaceholder{}, + guards: [], + expression: %IR.LocalFunctionCall{ + function: :baz, + args: [ + %IR.IntegerType{value: 5} + ] + } + } + ], + else_clauses: [] + } + end + + test "with single expression do block" do + ast = + ast(""" + with {:ok, x} <- y do + foo(x) + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + clauses: [ + %IR.WithClause{ + match: %IR.TupleType{ + data: [ + %IR.AtomType{value: :ok}, + %IR.Variable{name: :x} + ] + }, + guards: [], + expression: %IR.Variable{name: :y} + } + ], + else_clauses: [], + body: %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.Variable{name: :x} + ] + } + } + end + + test "with multi-expression do block" do + ast = + ast(""" + with x <- y do + x = 5 + foo(x) + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + clauses: [ + %IR.WithClause{ + match: %IR.Variable{name: :x}, + guards: [], + expression: %IR.Variable{name: :y} + } + ], + else_clauses: [], + body: %IR.Block{ + expressions: [ + %IR.MatchOperator{ + left: %IR.Variable{name: :x}, + right: %IR.IntegerType{value: 5} + }, + %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.Variable{name: :x} + ] + } + ] + } + } + end + + test "single clause else block" do + ast = + ast(""" + with do + else + {:error, other} -> + :error + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + body: %IR.Block{expressions: []}, + clauses: [], + else_clauses: [ + %IR.Clause{ + match: %IR.TupleType{ + data: [ + %IR.AtomType{value: :error}, + %IR.Variable{name: :other} + ] + }, + guards: [], + body: %IR.Block{expressions: [%IR.AtomType{value: :error}]} + } + ] + } + end + + test "multi clause else block" do + ast = + ast(""" + with do + else + {:error, :timeout} -> + foo(y) + {:error, other} -> + other + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + body: %IR.Block{expressions: []}, + clauses: [], + else_clauses: [ + %IR.Clause{ + match: %IR.TupleType{ + data: [ + %IR.AtomType{value: :error}, + %IR.AtomType{ + value: :timeout + } + ] + }, + guards: [], + body: %IR.Block{ + expressions: [ + %IR.LocalFunctionCall{ + function: :foo, + args: [ + %IR.Variable{name: :y} + ] + } + ] + } + }, + %IR.Clause{ + match: %IR.TupleType{ + data: [ + %IR.AtomType{value: :error}, + %IR.Variable{name: :other} + ] + }, + guards: [], + body: %IR.Block{ + expressions: [ + %IR.Variable{ + name: :other + } + ] + } + } + ] + } + end + + test "without else block" do + ast = + ast(""" + with x <- y do + end + """) + + assert transform(ast, %Context{}) == %IR.With{ + body: %IR.Block{expressions: []}, + clauses: [ + %IR.WithClause{ + match: %IR.Variable{name: :x}, + guards: [], + expression: %IR.Variable{name: :y} + } + ], + else_clauses: [] + } + end end end diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index 9b248a99ff..156f96116c 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6865,6 +6865,21 @@ describe("Interpreter", () => { return Type.tuple([context.vars.a, context.vars.b]); }; + it("handles an empty with", () => { + // with do: nil + const result = Interpreter.with( + (context) => { + return Type.atom("nil"); + }, + [], + [], + context, + ); + + const expected = Type.atom("nil"); + assert.deepStrictEqual(result, expected); + }); + it("successful match returns body result", () => { // with b <- a do // {a, b} @@ -6875,7 +6890,7 @@ describe("Interpreter", () => { { match: Type.variablePattern("b"), guards: [], - body: (context) => context.vars.a, + expression: (context) => context.vars.a, }, ], [], @@ -6898,7 +6913,7 @@ describe("Interpreter", () => { { match: Type.atom("error"), guards: [], - body: (context) => context.vars.a, + expression: (context) => context.vars.a, }, ], [ @@ -6913,7 +6928,6 @@ describe("Interpreter", () => { assert.deepStrictEqual(result, expected); }); - it("can fail to match on guard", () => { // with b when b == :no <- a do // else @@ -6929,7 +6943,7 @@ describe("Interpreter", () => { { match: Type.variablePattern("b"), guards: [guard], - body: (context) => context.vars.a, + expression: (context) => context.vars.a, }, ], [ @@ -6944,6 +6958,167 @@ describe("Interpreter", () => { assert.deepStrictEqual(result, expected); }); + it("handle plain expressions", () => { + // with b = a do + // {a, b} + // end + const expected = Type.tuple([Type.atom("ok"), Type.atom("ok")]); + const result = Interpreter.with( + body, + [ + { + match: Type.variablePattern("b"), + guards: [], + expression: (context) => + Interpreter.matchOperator( + context.vars.a, + Type.variablePattern("a"), + context, + ), + }, + ], + [], + context, + ); + + assert.deepStrictEqual(result, expected); + }); + it("gives the original context to the else clauses", () => { + // x = :original + // with x <- a, + // :fail <- :mismatch do + // :body + // else + // _ -> + // x + // end + const contextWithX = contextFixture({ + vars: { + a: Type.atom("ok"), + x: Type.atom("original"), + }, + }); + const expected = Type.atom("original"); + const result = Interpreter.with( + body, + [ + { + match: Type.variablePattern("x"), + guards: [], + expression: (context) => context.vars.a, + }, + { + match: Type.atom("fail"), + guards: [], + expression: (_context) => Type.atom("mismatch"), + }, + ], + [ + { + match: Type.matchPlaceholder(), + guards: [], + body: (context) => context.vars.x, + }, + ], + contextWithX, + ); + + assert.deepStrictEqual(result, expected); + }); + it("gives the original context to the else clauses (guard mismatch)", () => { + // x = :original + // with x <- a, + // i when false <- :mismatch do + // :body + // else + // _ -> + // x + // end + const contextWithX = contextFixture({ + vars: { + a: Type.atom("ok"), + x: Type.atom("original"), + }, + }); + const expected = Type.atom("original"); + const result = Interpreter.with( + body, + [ + { + match: Type.variablePattern("x"), + guards: [], + expression: (context) => context.vars.a, + }, + { + match: Type.variablePattern("i"), + guards: [(_context) => Type.atom("false")], + expression: (_context) => Type.atom("mismatch"), + }, + ], + [ + { + match: Type.matchPlaceholder(), + guards: [], + body: (context) => context.vars.x, + }, + ], + contextWithX, + ); + + assert.deepStrictEqual(result, expected); + }); + it("returns the unmatched value directly if there are no else clauses", () => { + // with :error <- a do + // x + // end + const expected = Type.atom("ok"); + const result = Interpreter.with( + body, + [ + { + match: Type.atom("error"), + guards: [], + expression: (context) => context.vars.a, + }, + ], + [], + context, + ); + + assert.deepStrictEqual(result, expected); + }); + + it("handles multiple else clauses", () => { + const expected = Type.atom("second"); + const result = Interpreter.with( + body, + [ + { + match: Type.atom("error"), + guards: [], + expression: (context) => context.vars.a, + }, + ], + [ + { + match: Type.atom("a"), + guards: [], + body: (context) => Type.atom("first"), + }, + { + match: Type.atom("ok"), + guards: [], + body: (_context) => Type.atom("second"), + }, + { + match: Type.atom("c"), + guards: [], + body: (_context) => Type.atom("third"), + }, + ], + context, + ); + }); it("throws error if no else clauses match", () => { // with :error <- a do // else @@ -6957,7 +7132,7 @@ describe("Interpreter", () => { { match: Type.atom("error"), guards: [], - body: (context) => context.vars.a, + expression: (context) => context.vars.a, }, ], [ From 88e3895aba45295204c9be483a6e2cad4a2a1382 Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:02:26 -0600 Subject: [PATCH 8/8] Address eslints in interpreter_test.mjs --- test/javascript/interpreter_test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/javascript/interpreter_test.mjs b/test/javascript/interpreter_test.mjs index 156f96116c..063f2f46fa 100644 --- a/test/javascript/interpreter_test.mjs +++ b/test/javascript/interpreter_test.mjs @@ -6868,7 +6868,7 @@ describe("Interpreter", () => { it("handles an empty with", () => { // with do: nil const result = Interpreter.with( - (context) => { + (_context) => { return Type.atom("nil"); }, [], @@ -7103,7 +7103,7 @@ describe("Interpreter", () => { { match: Type.atom("a"), guards: [], - body: (context) => Type.atom("first"), + body: (_context) => Type.atom("first"), }, { match: Type.atom("ok"), @@ -7118,6 +7118,8 @@ describe("Interpreter", () => { ], context, ); + + assert.deepStrictEqual(result, expected); }); it("throws error if no else clauses match", () => { // with :error <- a do