Syntax Gotchas

This document records unintuitive consequences of Eucalypt's syntax design decisions that can lead to subtle bugs or confusion.

Operator Precedence Issues

Eucalypt's catenation (juxtaposition) operator has very low precedence (20) — lower than all arithmetic and comparison operators. This means infix operators bind more tightly than catenation, which can produce surprising parses.

The precedence hierarchy (highest to lowest):

PrecedenceNameOperators
95 (head prefix)
90lookup/call. (lookup), f(x, y)
88bool-unary! (not)
85exp^ (power)
80prod*, /, ÷, %
75sum+, -
50cmp<, >, <=, >=
45append++
40eq=, !=
35bool-prod (and)
30bool-sum (or)
20catcatenation (juxtaposition)
15clause=>, (cond clause)
10apply
5meta` (metadata)

Field Access vs Catenation

Problem: The lookup operator (.) has higher precedence (90) than catenation (precedence 20), which can lead to unexpected parsing.

Gotcha: Writing objects head.id is parsed as objects (head.id) rather than (objects head).id.

Example:

# This doesn't work as expected:
objects: range(0, 5) map({ id: _ })
result: objects head.id  # Parsed as: objects (head.id)

# Correct syntax requires parentheses:
result: (objects head).id  # Explicitly groups the field access

Error Message: When this occurs, you may see confusing errors like:

  • cannot return function into case table without default
  • bad index 18446744073709551615 into environment (under memory pressure)

Solution: Always use parentheses to group the expression you want to access fields from:

  • Use (expression).field instead of expression target.field
  • Be explicit about precedence when combining catenation with field access

Arithmetic After a Pipeline

Problem: Infix operators like +, -, * have higher precedence than catenation. When an infix operator follows a pipeline (catenation chain), it binds to the last function in the chain, not to the result of the whole pipeline.

Gotcha: Writing xs foldl(f, 0) + 1 is not parsed as (xs foldl(f, 0)) + 1. Instead, the shunting-yard algorithm sees:

[xs, cat@20, foldl, call@90, (f,0), +@75, 1]

Because + (75) has higher precedence than cat (20), the + binds first: foldl(f, 0) + 1 is evaluated (adding 1 to a partial application — a type error), and then the result is applied to xs.

Example:

# WRONG — fails at runtime:
n: xs foldl(+, 0) + 1        # Parsed as: (foldl(+, 0) + 1)(xs)

# CORRECT — use parentheses:
n: (xs foldl(+, 0)) + 1      # Explicit grouping

# CORRECT — split into two bindings:
m: xs foldl(+, 0)
n: m + 1

Error Messages:

  • cannot return function into case table without default — the + intrinsic receives a function (the partial application) instead of a number.

Rule of thumb: If a pipeline result feeds into an infix operator, always use parentheses around the pipeline or bind it to a name first.

Anaphora and Function Syntax

Lambda Syntax Does Not Exist

Problem: Eucalypt does not have lambda expressions or arrow functions. There is no syntax for anonymous multi-parameter functions.

Gotcha: The -> token is the const operator (returns its left operand, ignoring the right). Writing x -> x + 1 does not create a function — it evaluates x, discards x + 1, and returns x.

Invalid Examples:

# NONE of these create functions in Eucalypt:
map(\x -> x + 1)     # Invalid syntax
map(|x| x + 1)       # Invalid syntax
map(fn(x) => x + 1)  # Invalid syntax
filter(x -> x > 0)   # -> is const, not lambda!

Correct Approach: Use sections, anaphora (_, _0, _1), named functions, or partial application:

# Operator sections (preferred for simple cases):
map(+ 1)             # Section: adds 1
map(^ 2)             # Section: squares
filter(> 0)          # Section: positive?

# Identity anaphor — passes an identity function:
map(_)               # Same as map(identity)

# Anaphora in expressions:
map(_ + 1)           # Anonymous single-parameter function
filter(_ > 0)        # Anonymous predicate

# Multiple `_` — each is a separate parameter:
f: (_ * _)           # f is a 2-arg multiply function
f(3, 4)              # => 12

# Numbered anaphora for same-param reuse:
sq: (_0 * _0)        # sq(5) => 25

# Using named function + partial application:
add-one(x): x + 1
map(add-one)

# For multi-parameter needs, define a named helper:
has-y(y, h): first(h) = y
filter(has-y(target-y))   # Partial application

Important: Each _ introduces a separate parameter. _ * _ is a 2-arg function (like (_0 * _1)), not (_0 * _0). Use numbered anaphora _0 * _0 when you need to reference the same parameter twice.

Reference: See Anaphora for detailed explanation of anaphora usage.

Anaphor Scoping: Parens Are Opaque

Problem: Parentheses create an anaphor scope boundary. Anaphors inside parens form a lambda at the paren level, not at the enclosing expression.

Scoping rules:

  1. Parens are opaque by default — anaphors inside parens create a lambda scoped to that paren group. This applies to both _ and _0/_1.

  2. Subsumption — if the enclosing expression already has direct anaphors, inner paren groups become transparent (their anaphors join the outer scope).

  3. ArgTuples follow the same rules — function call arguments are opaque unless subsumed by an outer anaphoric scope.

Examples:

# Parens opaque — paren group forms its own lambda:
(_ + 1)               # λ(a). a + 1
(_ * _)               # λ(a, b). a * b
(_ = :quux) ∘ tag     # (λ(a). a = :quux) ∘ tag  ✓ composition works

# Without parens — whole expression is the lambda body:
_ + 1                 # λ(a). a + 1   (same result here)
_ * _                 # λ(a, b). a * b

# Parens opaque breaks cross-operator average:
(_0 + _1) / 2         # (λ(a,b). a+b) / 2  — type error!
# Correct idiom (no parens):
_0 + _1 / 2           # λ(a, b). a + b/2  — note: b is halved, not sum

# Subsumption: outer _ makes inner (_ * _) transparent:
_ + (_ * _)           # λ(a, b, c). a + (b * c)  ✓

# ArgTuple opaque by default:
map(_ + 1)            # map(λ(a). a + 1)   ✓
filter(_ > 0)         # filter(λ(a). a > 0)  ✓

# ArgTuple subsumed when outer has anaphors:
_0 * (_1 + 2)         # λ(a, b). a * (b + 2)  ✓

Common mistake: Expecting (_0 + _1) / 2 to be a 2-argument averaging function. Under the opaque parens model, (_0 + _1) forms its own lambda and / 2 tries to divide that lambda by 2 — a type error. Use a named helper instead:

avg2(a, b): (a + b) / 2
zip-with(avg2, xs, ys)

Expanding scope with subsumption: The subsumption rule can be exploited to make the outer expression anaphoric, causing inner paren groups to become transparent. A common idiom is to place a direct anaphor — such as a not-nil check _0✓ — at the outer level:

# _0✓ makes the whole expression anaphoric in _0,
# so _0 inside count(_0) is subsumed — both refer to the same parameter.
_0✓ && count(_0) >= 4    # λ(a). a != null && count(a) >= 4

Without the outer _0✓, the _0 inside count(_0) would form a separate lambda at the ArgTuple level, and the outer && would see a function value rather than a boolean. The not-nil postfix is often the most natural way to introduce the outer anaphor when you want to also guard against null.

Metadata vs Comments

Backtick Is Metadata, Not a Comment

Problem: The backtick (`) attaches metadata to the next declaration. It is not a comment syntax.

Gotcha: Writing ` "some text" with no declaration following it causes a parse error, often reported at an unexpected location (e.g., line 1).

Example:

# WRONG — dangling metadata with nothing to attach to:
` "This is not a comment"

# CORRECT — use # for comments:
# This is a comment

# CORRECT — metadata attached to a declaration:
` "Documentation for my-fn"
my-fn(x): x + 1

Rule: Use # for comments. Only use ` when you intend to attach metadata to the immediately following declaration.

Single Quote Identifiers

Single Quotes Are Not String Delimiters

Problem: Single quotes (') in Eucalypt are used to create identifiers, not strings.

Gotcha: Coming from languages where single quotes delimit strings, developers might expect 'text' to be a string literal.

Key Rules:

  • Single quotes create normal identifiers that can contain any characters
  • The identifier name is the content between the quotes (quotes are stripped)
  • This is the only use of single quotes in Eucalypt
  • String literals use double quotes (") only

Examples:

# Single quotes create identifiers (variable names):
'my-file.txt': "content"     # Creates identifier: my-file.txt
home: {
  '.bashrc': false           # Creates identifier: .bashrc
  '.emacs.d': false          # Creates identifier: .emacs.d
  'notes.txt': true          # Creates identifier: notes.txt
}

# Access using lookup:
z: home.'notes.txt'          # Looks up identifier: notes.txt

# NOT string literals:
'hello' = 'hello'            # Compares two variable references (not strings)
"hello" = "hello"            # Compares two string literals (correct)

Division Operators

/ Is Floor Division

Problem: The / operator performs floor division (integer division), not precise division.

Gotcha: 7 / 2 evaluates to 3, not 3.5.

Example:

7 / 2    # => 3 (floor division)
7 ÷ 2    # => 3.5 (exact division)

Rule: Use ÷ for exact/precise division. Use / only when you want integer (floor) division.

Cons Patterns vs Normal Lists

The [h : t] cons pattern is only valid in a function parameter position. A colon inside a list literal in an expression context is not valid:

# Valid — cons pattern in a function parameter
list-head([h : t]): h

# Invalid — colon is not a list separator in expression context
bad: [1 : rest]   # parse error

In expression context, use the cons operator (precedence 55) or the cons prelude function:

xs: [1, 2, 3]
first: xs head         # = 1
rest: xs tail          # = [2, 3]
built: 1 ‖ [2, 3]     # = [1, 2, 3]
also: cons(1, [2, 3])  # = [1, 2, 3]

Block-Dot Lookup Applies to Any Block Literal

The generalised lookup syntax {...}.field and {...}.(expr) works on any block literal, not only IO monadic blocks. The dot binds the lookup to the block immediately to its left:

# Field lookup on a plain block
{ x: 1, y: 2 }.x          # → 1

# Parenthesised expression scoped over the block's bindings
{ x: 1, y: 2 }.(x + y)    # → 3

# The same syntax is used for IO monadic block return expressions
{ :io r: io.shell("echo hello") }.(r.stdout)
{ :io r: io.shell("echo hello") }.r.stdout

Precedence: . binds tightly (precedence 90), so the lookup attaches to the block literal, not to the result of any surrounding expression. Use parentheses when you need to apply a lookup to a computed value:

# Parsed as: list (head.name)  — probably not what you want
list head.name

# Correct: (list head).name
(list head).name

IO monadic block desugaring: { :io r: cmd }.(expr) desugars to io.bind(cmd, lambda(r). io.return(expr)). The .() return expression is part of the general block-dot syntax, not IO-specific.

Bare-expression files: A .eu file containing only a block-dot expression (no outer key: declaration) is supported when the expression starts with a block literal {...}:

# This works as a standalone .eu file:
{ :io r: io.shell("echo hello") }.(r.stdout)

Block Field Names Shadow Outer Bindings — {x: x} Is Always Self-Reference

Problem: Every declaration inside a block literal introduces a new binding that is visible to all other expressions in that block, including its own right-hand side. The name on the left of : immediately shadows any outer binding with the same name, so {x: x} does not copy an outer x into the block — it creates a self-referential binding that refers to itself:

# WRONG — infinite loop: cmd refers to itself, not the function parameter
shell-spec(cmd): {:io-shell cmd: cmd, timeout: 30}

Running eu -e '{cmd: cmd}' gives error: infinite loop detected: binding refers to itself.

Why it bites functions: When a function parameter has the same name as a block field you want to populate, the RHS expression sees the block's own binding rather than the parameter:

fn(cmd): {cmd: cmd}   # cmd: cmd is self-reference — fn's parameter is invisible

Fix: Use a different name for the function parameter so it is not shadowed:

# Correct: parameter c is not shadowed by the block field cmd
shell-spec(c): {:io-shell cmd: c, timeout: 30}

Rule: Never write {key: key} expecting it to read an outer binding named key. If you need a block field to hold a value from an outer scope, the outer name and the field name must differ.

Sequential Bindings: Use :let Blocks, Not let … in

Eucalypt has no let … in expression syntax (as in Haskell or Standard ML). Writing result: let x: 1 in x + 2 is a runtime error — let is a prelude value (the identity monad) and in is unresolved.

The eucalypt idiom for sequential, non-self-referential bindings is a :let block:

result: {
  :let x: 1
  y: x + 2
}.y

The :let prefix marker desugars to a monadic bind, so y can safely reference x without the self-reference gotcha that affects ordinary block bindings ({ x: expr y: x + 1 } would make x and y mutually self-referential).

A :let binding may even reuse a name from the enclosing scope — the one case where name: name … is not self-reference — because its right-hand side is evaluated in the outer scope, before the new binding takes effect:

normalise(xs): { :let xs: xs map(_ + 1) }.xs
shifted: normalise([1, 2, 3])   # => [2, 3, 4]

This is the idiom to reach for when an ordinary { name: name … } would self-reference — common when a renderer maps over a parameter it would rather keep calling by its own name.

Why this matters: Before let was defined as a prelude name, attempts to write let x: 1 in x + 2 gave a diagnostic error "eucalypt has no 'let' expression". That specific hint is no longer raised; the error now falls through to "unresolved variable 'in'". The :let block pattern is the correct replacement.

Multiple Imports Go in One Metadata Block

A file has one unit metadata block — the first block expression at the top of the file. If you need multiple imports, list them in that single block. Do NOT write separate blocks for each import.

# WRONG — the second block is a separate expression, not more metadata
{ import: "state.eu" }
{ import: "lens.eu" }

# RIGHT — one metadata block with a list of imports
{ import: ["state.eu", "lens.eu"] }

# RIGHT — one block with imports and other metadata
{ import: ["state.eu", "lens.eu"]
  doc: "My module" }

The wrong pattern causes a compiler error because only the first block is unit metadata — the second becomes an anonymous block declaration catenated into the metadata expression.

Type Annotation String Escaping

Record Braces in Type Strings Require {{ Escaping

Type annotation values (type:, types:) are ordinary eucalypt strings. { inside a eucalypt string starts string interpolation — it does not produce a literal { for the type parser.

Any record type written in a type annotation string must escape braces with {{ and }}:

# WRONG — {..r} triggers string interpolation
` { type: "{..r} -> {..r}" }
pass-through(x): x

# RIGHT — use {{ and }} for literal braces
` { type: "{{..r}} -> {{..r}}" }
pass-through(x): x

# WRONG — {name: string} triggers interpolation
` { type: "{name: string, age: number, ..} -> string" }
greet(p): "Hello, {p.name}!"

# RIGHT
` { type: "{{name: string, age: number, ..}} -> string" }
greet(p): "Hello, {p.name}!"

The same applies to types: unit metadata values:

# RIGHT — escape braces in types: values too
{ types: { Person: "{{name: string, age: number}}" } }

When escaping is NOT needed: type strings that contain no { at all never need escaping. Simple function types and built-in type constructors are fine as-is:

` { type: "number -> number" }           # fine — no braces
` { type: "[a] -> a" }                   # fine
` { type: "IO(block)" }                  # fine — parens, not braces
` { type: "Lens(a, b) -> a -> b" }       # fine
` { type-def: "Point" }                  # fine — just a name, no braces

Flow-Sensitive Narrowing and User-Defined Branchers

=> (Clause) vs //=> (Assertion)

The => / operator (precedence 15) builds a cond clause (condition => result). It looks similar to the assertion meta-operator //=> (precedence 5) but has a completely different purpose:

# CLAUSE — builds a cond branch; result is a list element
cond[x > 0 => "positive", "non-positive"]

# ASSERTION — checks a value equals the RHS at meta-evaluation time
` { result //=> "positive" }
result: "positive"

Mixing these up produces confusing type or parse errors.

Non-Structural Brancher Wrappers Disable Narrowing

Flow-sensitive narrowing only applies when the checker can verify that a user function is a structural pass-through of if — i.e. its body is exactly if(condition, true-branch, false-branch) with arguments forwarded in order. A wrapper that ignores one branch, reorders arguments, or adds logic is treated as an opaque function:

# STRUCTURAL — recognised as If-shaped; narrowing fires
my-if(c, t, f): if(c, t, f)

` { type: "(number | string) → number" }
safe-add(x): my-if(x number?, x + 1, 0)   # no warning

# NON-STRUCTURAL — not a brancher; narrowing does NOT fire
always-true(c, t, _f): t

` { type: "(number | string) → number" }
safe2(x): always-true(x number?, x + 1, 0)   # x is still number|string in body

The second example does not produce false-positive warnings because always-true is not narrowing-aware — x retains its declared type number | string throughout the body, which is type-safe here.

head on an Empty List Produces a Type Warning

The type checker tracks list emptiness. [] synthesises as List(Never), which means head [] is flagged as a potential error:

# WARNING — [] is List(Never); head on an empty list
bad: [] head

# OK — non-empty literal is Tuple([number]); head gives number
ok: [42] head

To use head safely on a list of unknown size, guard with nil?:

` { type: "[number] → number" }
safe-head(xs): if(xs nil?, 0, xs head)   # xs is NonEmpty([number]) in the false branch

Or annotate the input as NonEmpty([T]) when the caller guarantees non-emptiness.

:suppress Leaks Through References

` :suppress hides a binding from output, but the flag travels with the binding's value. Reference that value somewhere else and the field it lands in silently disappears too.

# WRONG — log4j is suppressed, so the @id field it feeds is dropped
` :suppress
log4j: "pkg:maven/log4j-core@2.17.1"

entry: { '@id': log4j }     # renders as {} — @id silently gone

Suppress a block rather than a value you reference; members looked up out of it are clean:

` :suppress
purls: { log4j: "pkg:maven/log4j-core@2.17.1" }

entry: { '@id': purls.log4j }   # => { "@id": "pkg:maven/log4j-core@2.17.1" }

Rule: only ` :suppress a binding you don't reference elsewhere; to hide values you do reference, group them in a block and suppress that.


Monad Bracket Restrictions

Empty Monad Brackets Are an Error

Monad brackets (e.g. ⟦⟧) cannot be empty. An empty monadic block has no declarations to bind, so it is meaningless. The desugarer produces an EmptyMonadicBlock error:

# ERROR — empty monad brackets
result: ⟦⟧.42

# CORRECT — at least one declaration required
result: ⟦ x: some-action ⟧.x

This also applies to block-metadata monadic blocks:

# ERROR — empty monadic block
result: { :io }.42

# CORRECT
result: { :io r: io.return(42) }.r