Skip to content

Comments

Implement with expressions#692

Open
prehnRA wants to merge 6 commits intobartblast:devfrom
prehnRA:implement-with
Open

Implement with expressions#692
prehnRA wants to merge 6 commits intobartblast:devfrom
prehnRA:implement-with

Conversation

@prehnRA
Copy link
Contributor

@prehnRA prehnRA commented Feb 10, 2026

Addresses #690

Summary by CodeRabbit

  • New Features

    • Fully implemented the "with" control construct with multi-clause matching, guards, and else-branch behavior.
  • Bug Fixes

    • Improved and unified error handling for unmatched with/case scenarios, making failures consistent and allowing custom error callbacks.
  • Tests

    • Added comprehensive tests covering successful matches, guard failures, else-branching, and unmatched-error conditions.

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds full Elixir-style with: defines IR.With, implements transformer and encoder paths, implements Interpreter.with with clause/guard matching, introduces raiseWithClauseError, updates case signature for error callbacks, and adds corresponding tests and encoder changes.

Changes

Cohort / File(s) Summary
IR Definition
lib/hologram/compiler/ir.ex
Adds %IR.With{} struct with fields :clauses, :body, :else_clauses and updates @type.
Transformer
lib/hologram/compiler/transformer.ex
Implements with transform building IR.With with clauses, else_clauses, and body; adds transform_with_clause/3 logic and preserves clause order.
Encoder
lib/hologram/compiler/encoder.ex
Replaces placeholder encoding with Interpreter.with(<body_js>, <clauses_js>, <else_clauses_js>, context) emission.
JS Interpreter
assets/js/interpreter.mjs
Implements Interpreter.with(body, clauses, elseClauses, context), adds raiseWithClauseError, and extends case to accept an errorCallback.
Elixir Compiler Tests
test/elixir/hologram/compiler/encoder_test.exs, test/elixir/hologram/compiler/transformer_test.exs
Replaces TODOs with concrete tests asserting IR.With structure and encoder output for with/else scenarios.
JS Runtime Tests
test/javascript/interpreter_test.mjs
Adds tests for Interpreter.with: successful match, match failure with else, guard failure with else, and no-else error case.
Manifest
mix.exs
Minor manifest update referenced by test changes.

Sequence Diagram

sequenceDiagram
    participant Source as "Elixir source"
    participant Transformer as "Transformer"
    participant IR as "IR.With"
    participant Encoder as "Encoder"
    participant JS as "Generated JS"
    participant Interpreter as "Interpreter.with"
    participant Context as "Context"

    Source->>Transformer: parse & transform `with` form
    Transformer->>IR: build `IR.With` (clauses, body, else_clauses)
    IR->>Encoder: request JS encoding
    Encoder->>JS: emit `Interpreter.with(body, clauses, else_clauses, context)`
    JS->>Interpreter: invoke `with(...)`
    Interpreter->>Context: clone context per clause
    Interpreter->>Interpreter: evaluate pattern match & guards
    alt clause matches and guards pass
        Interpreter->>Context: merge bindings
        Interpreter->>Interpreter: evaluate `body(context)`
    else clause fails or guards fail
        Interpreter->>Interpreter: evaluate `else_clauses` or call `raiseWithClauseError`/`errorCallback`
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through clauses, guard by guard,
Cloned tiny contexts in my yard,
Else chased shadows when matches fled,
I nudged the error with a gentle tread,
Now with hops forward — tidy and hard.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement with expressions' directly and clearly summarizes the main objective of this pull request, which is to fully implement the with/2 construct across multiple layers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@lib/hologram/compiler/ir.ex`:
- Around line 390-396: The IR.With struct's type spec currently sets body:
IR.Block.t(), but the transformer (transform/1) can return any IR.t() (e.g., for
single-expression do-blocks), so update the `@type` t for %IR.With{} to use body:
IR.t() instead of IR.Block.t(); change the spec in the IR.With definition (the
defstruct and `@type` for IR.With) to body: IR.t() to match transformer output and
avoid Dialyzer mismatches.

In `@test/javascript/interpreter_test.mjs`:
- Around line 6934-6962: The test's else clause body references an undefined
identifier expected which would raise a ReferenceError instead of the intended
WithClauseError; update the else clause body in the Interpreter.with call to
return a literal (e.g., a literal atom/object) or define a local fallback
variable and use that instead of expected so the test asserts the
WithClauseError properly when no else clause matches (locate the
Interpreter.with invocation and the else clause body in this test and replace
expected with a defined literal/fallback).
- Around line 6905-6933: The test currently exercises match-failure because the
first clause in Interpreter.with uses match: Type.atom("error") against an :ok
value; change that clause to match: Type.atom("ok") and make the guard express
equality to :no so the clause matches but the guard fails (e.g. replace
Erlang["/=/2"](context.vars.b, Type.atom("no")) with a positive equality check
such as Erlang["==/2"](context.vars.b, Type.atom("no"))), ensuring the
Interpreter.with call (the clause with match/guards/body) now tests
guard-failure rather than match-failure.
🧹 Nitpick comments (2)
assets/js/interpreter.mjs (1)

212-213: Consider using ?? (nullish coalescing) instead of || for the default.

|| would also catch any other falsy value (e.g., 0, "", false). While errorCallback is realistically only ever a function or undefined, using ?? better communicates intent:

- errorCallback = errorCallback || Interpreter.raiseCaseClauseError;
+ errorCallback = errorCallback ?? Interpreter.raiseCaseClauseError;
lib/hologram/compiler/transformer.ex (1)

445-472: Avoid O(n²) clause accumulation in with transformation.

acc.clauses ++ [item] grows quadratically. Prepend and reverse once to keep it linear.

♻️ Suggested refactor
-    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
-    )
+    result =
+      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: [transform(clause, context) | acc.clauses]}
+        end
+      )
+
+    %{result | clauses: Enum.reverse(result.clauses)}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@lib/hologram/compiler/transformer.ex`:
- Around line 936-952: The with-clause transformer is treating the left side of
`<-` as an expression instead of a pattern because transform/2 is called without
pattern context; update transform_with_clause to set pattern?: true in the
context when transforming the pattern part of a `<-` match (i.e., when
transforming the match/pattern node inside the clause). Specifically, in
transform_with_clause (the clause-handling clause where you currently do %{acc |
clauses: [transform(clause, context) | acc.clauses]}), decompose the clause into
its match and expression parts and call transform(match, Map.put(context,
:pattern?, true)) (or otherwise set context.pattern? = true) before transforming
the RHS, so struct patterns are emitted as pattern-matching IR rather than
__struct__/1 constructors.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@lib/hologram/compiler/transformer.ex`:
- Around line 974-976: The transform_with_clause/3 currently prepends raw
transformed nodes for bare with-clauses which breaks the interpreter that
expects IR.Clause structs; change transform_with_clause to check the result of
transform(clause, context) and if it is not an %IR.Clause{} wrap it in an
%IR.Clause{match: transformed_node, guards: [], body: fn _ctx ->
transformed_node end} (or equivalent body function that returns the node in the
interpreter's expected shape), then prepend that clause into acc.clauses; keep
using transform_with_clause, IR.Clause, and transform to locate the code to
modify.

@prehnRA
Copy link
Contributor Author

prehnRA commented Feb 12, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartblast bartblast self-requested a review February 16, 2026 16:17
Copy link
Owner

@bartblast bartblast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @prehnRA, thanks for picking this up! Great to see with getting implemented. I do have a number of concerns that need to be addressed before we can merge though. Since with is a special form - one of the core building blocks of the language that tons of Elixir code relies on - we really need to nail the implementation and match Elixir's semantics exactly. It has quite a few behavioral nuances (hexdocs) and getting any of them wrong would cause subtle, hard-to-debug issues for users.

Bug

No-else-clause match failure raises WithClauseError instead of returning the non-matched value. When there's no else block and a <- match fails, Elixir returns the non-matched value directly:

with {:ok, x} <- :error do
  x
end
# => :error  (returned directly, no exception)

The current implementation always routes through Interpreter.case(condition, elseClauses, ...). When elseClauses is [], this falls through to raiseWithClauseError(). Fix: when elseClauses is empty, return condition directly.

Design concerns

  1. IR.Clause.body semantic inversion - Reusing IR.Clause for with/<- clauses creates a confusing inversion: in case/-> clauses body is the result (called after match succeeds), but in with/<- clauses body is the input (evaluated before matching). The same field means the opposite thing depending on context. Since with clauses can also include plain expression clauses (e.g., double_width = width * 2) intermixed with <- clauses - and they must stay in a single ordered list - we'd need a dedicated struct (e.g., IR.WithClause with an expression field) for the <- clauses, while plain expression clauses could use a separate type. Both would coexist in the clauses list.

  2. case() coupling - Adding an errorCallback parameter to case() makes it serve double duty. Let's extract the matching loop into a private helper that both case() and with() call independently, keeping their error semantics separate.

  3. Variable scoping in else - In Elixir, variables bound in with clauses are not accessible in the else block (it's a compile error). Currently, when the Nth clause fails, the context passed to the else branch already contains bindings from clauses 1..N-1. In practice this doesn't blow up because the Elixir compiler prevents the else AST from referencing those variables - but it means the JS runtime is silently leaking bindings that shouldn't exist. This needs to be handled properly: pass the original context (before any with clause bindings) to the else branch, so the scoping semantics actually match Elixir's.

Test coverage

The current tests are too basic - we need robust, granular tests for each function changed or introduced.

Transformer tests - We need both "AST from source code" and "AST from BEAM file" variants, since the AST structure can differ between the two. The current test only covers source code. Each clause type needs its own test:

  • <- clause without guards
  • <- clause with guards
  • Plain expression clause (e.g., x = val, bare function call)
  • do block (single and multi-expression)
  • else block with multiple clauses
  • No else block
  • etc.

Encoder tests - Same clause variants as above to verify correct JS generation for each.

JS interpreter tests - Need dedicated tests for each with behavior from the hexdocs:

  • Match failure without else → returns non-matched value (this would have caught the bug above)
  • Guard failure without else → returns non-matched value
  • Multiple chained clauses with variable propagation (variable bound in clause 1 used in clause 2)
  • Plain (non-<-) expressions mixed with <- clauses
  • Multi-expression do body
  • else clause matching the correct failed value
  • etc.

Down the road we'll also need browser-level tests that double as Elixir/JS consistency tests, but that can come once the core implementation is solid.

@prehnRA
Copy link
Contributor Author

prehnRA commented Feb 16, 2026

@bartblast Perfect. Thanks for the detailed feedback. It all makes sense to me.

I'm mostly offline for the next few days, but I'll pick it back up later this week or next one.

@prehnRA
Copy link
Contributor Author

prehnRA commented Feb 23, 2026

Update: I've been working on this and have almost completed the requested updates-- fixed the bug, made design changes, added a bunch of Elixir tests. I'm in progress adding more JS tests, and then I'll clean everything up and push updates.

@bartblast How would you prefer the plain clauses in the with be modeled?

Option A:

%WithClause{
  match: %MatchPlaceholder{},
  guards: [],
  expression: ... # whatever the IR is for the plain expression itself
}

Option B:

%PlainWithClause{
  expression: ... # the IR for the plain expression
}

Option C: directly use the IR for the plain expression.

Pros / Cons (in my view):

Pros Cons
A No additional encoder or interpreter code required Less immediately "readable" as a plain expression when a dev is looking at the IR, possible unintended side-effects of using MatchPlaceholder this way (?)
B Immediately labeled as a plain expression (easy for dev to read IR) Requires some additional encoder and interpreter logic, but not much
C Doesn't require a special structure or additional cases in encoder/transformer Might make interpreter logic more complex, feels like bugs will be harder to catch if we generate improper IR or encode it improperly

Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants