JuLC Compiler Developer Guide
A comprehensive guide for developers who maintain, debug, or extend the JuLC compiler — the Java-to-UPLC compiler for Cardano smart contracts.
1. Introduction
Section titled “1. Introduction”Purpose
Section titled “Purpose”This guide covers the internals of the JuLC compiler — the component that transforms a Java source subset into Untyped Plutus Lambda Calculus (UPLC) programs that run on the Cardano blockchain. It is intended for contributors who need to fix bugs, add language features, register new builtins, or understand compilation behavior.
Prerequisites
Section titled “Prerequisites”- Java 21+ features: sealed interfaces, records, pattern matching
- Basic lambda calculus: application, abstraction, substitution, De Bruijn indices
- Familiarity with the Cardano transaction model (UTxOs, validators, datums, redeemers)
Existing User-Facing Docs
Section titled “Existing User-Facing Docs”| Guide | Audience |
|---|---|
docs/getting-started.md |
First-time users writing validators |
docs/api-reference.md |
On-chain API reference |
docs/stdlib-guide.md |
Standard library usage |
docs/library-developer-guide.md |
Writing @OnchainLibrary modules |
docs/for-loop-patterns.md |
Loop compilation patterns |
docs/performance-guide.md |
Script size and cost optimization |
docs/troubleshooting.md |
Common errors and solutions |
docs/advanced-guide.md |
Low-level programming patterns |
docs/type-method-compilation-comparison.md |
Type method compilation details |
2. Conceptual Background: UPLC, PIR, and Data Encoding
Section titled “2. Conceptual Background: UPLC, PIR, and Data Encoding”2.1 What is UPLC?
Section titled “2.1 What is UPLC?”Untyped Plutus Lambda Calculus (UPLC) is the on-chain language that Cardano nodes execute. It is a minimal lambda calculus with 10 term variants, defined in julc-core/.../Term.java:
| # | Variant | Description |
|---|---|---|
| 1 | Var(NamedDeBruijn name) |
Variable reference (De Bruijn indexed) |
| 2 | Lam(String paramName, Term body) |
Lambda abstraction |
| 3 | Apply(Term function, Term argument) |
Function application |
| 4 | Force(Term term) |
Force evaluation of a delayed (polymorphic) term |
| 5 | Delay(Term term) |
Delay evaluation (create a thunk) |
| 6 | Const(Constant value) |
Constant value (integer, bytestring, string, bool, unit, data) |
| 7 | Builtin(DefaultFun fun) |
Reference to one of 101 released builtins or the retained future tag 101 |
| 8 | Error() |
Halt evaluation (transaction fails) |
| 9 | Constr(long tag, List<Term> fields) |
Constructor application (Plutus V3, SOPs) |
| 10 | Case(Term scrutinee, List<Term> branches) |
Case/pattern matching (Plutus V3, SOPs) |
De Bruijn indexing. Variables are referenced by how many lambda binders you must cross to reach the binding site. Index 1 means “the immediately enclosing lambda”:
\x -> \y -> x ≡ Lam("x", Lam("y", Var(2))) ^^^ cross y(1) then x(2)Force/Delay. UPLC is strict (call-by-value). Polymorphic builtins like IfThenElse need Force to instantiate type variables, and branches need Delay to prevent premature evaluation:
-- if True then "yes" else "no"Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), Const(True)), Delay(Const("yes"))), Delay(Const("no"))))2.2 What is PIR?
Section titled “2.2 What is PIR?”Plutus Intermediate Representation (PIR) is the typed bridge between Java and UPLC. It has 15 term variants defined in julc-compiler/.../pir/PirTerm.java:
| # | Variant | Description |
|---|---|---|
| 1 | Var(String name, PirType type) |
Named, typed variable |
| 2 | Let(String name, PirTerm value, PirTerm body) |
Let binding |
| 3 | LetRec(List<Binding> bindings, PirTerm body) |
Recursive let (for loops, self-recursion) |
| 4 | Lam(String param, PirType paramType, PirTerm body) |
Typed lambda |
| 5 | App(PirTerm function, PirTerm argument) |
Application |
| 6 | Const(Constant value) |
Constant |
| 7 | Builtin(DefaultFun fun) |
Builtin function |
| 8 | IfThenElse(PirTerm cond, PirTerm then, PirTerm else) |
Conditional |
| 9 | DataConstr(int tag, PirType type, List<PirTerm> fields) |
Data constructor |
| 10 | DataMatch(PirTerm scrutinee, List<MatchBranch> branches) |
Pattern match |
| 11 | Error(PirType type) |
Typed error |
| 12 | Trace(PirTerm message, PirTerm body) |
Trace debug output |
| 13 | ListMatch(scrutinee, headName, tailName, nilBranch, consBranch) |
Native list match; raw head/tail bindings scoped to cons branch |
| 14 | PairMatch(scrutinee, pairType, firstName, secondName, body) |
Proven native pair; raw first/second bindings scoped to body |
| 15 | IntegerCase(scrutinee, branches) |
Generator-local: branch i for native integer scrutinee i, no binders; created only during DataMatch lowering (ADR-041) |
ListMatch performs no Data decoding. The for-each producer retains its
NullList representation guard and places the match only in the non-empty
branch, preserving failures for unchecked non-list casts. Head/tail binding
must not move partial element decoders or loop effects. PV11 UPLC branch order
is cons first, nil second, with the cons branch applied to head then tail.
The guard makes the emitted nil branch unreachable; it is Error.
Map/native pair loops and unconditional-break loops without a tail use retain
their existing form. NONE/BASELINE produce the historical PIR directly.
See ADR-034 for the eligibility and validation contract.
PairDestructuringPass runs immediately before UPLC generation in all compiler
entry points. It replaces a strict Let p = UnConstrData(d) only when every use
of that lexical binding is a correctly typed direct FstPair(p) or SndPair(p),
with both projections present. This covers constructor decomposition in strict
record, sum, Boolean and Optional boundaries. UnConstrData remains strict and
runs once; PairMatch binds raw tag and fields, leaving all partial decoders,
traces and branches in place. The one UPLC branch takes first then second.
Aliases, escaping pairs, one-sided projections, unproven producers and
Data-encoded pair-like values are excluded. The pass and lowering both require
the exact PV11 target and a safe optimization level; NONE/BASELINE retain their
historical bytes. Rule provenance is pv11.o4.case-pair (ADR-036).
ADR-038 also constructs PairMatch directly inside generateDataMatch, after
preserving the strict once-bound Data scrutinee. This producer proves the native
pair by its own UnConstrData; the earlier proof pass cannot see this late
expansion. Tag dispatch and branch-local field decoding remain unchanged. The
dispatch binders are reserved # names chosen against every branch (ADR-060), so
no branch can refer to them; the former fallback for a direct-PIR reference to
__match_pair is gone. The producer and PairMatch consumer share the exact O4
gate and rule identity. This changes safe-profile direct-PIR output as well as
source switches.
ValueConversionSharingPass (ADR-042, O8) runs just before PairDestructuringPass
at the same three entry points. For a scope in which one variable is converted to
a native Value at least twice (Builtins.unValueData(x) or
NativeValueLib.fromData(x), outside any rebinding of x), it binds the
conversion once, let #value-N = C(x) in …, exactly when that conversion is the
first non-trivial evaluation on every path through the scope: function position
before argument, a Let value before its body, a condition or scrutinee before
its branches, with variables, constants, lambdas and under-saturated builtin or
once-bound library-lambda spines counted as trivial. Branch bodies, traces,
errors, recursive bindings and lambdas never lead, so nothing is hoisted across
them and a binding never wraps a lambda. The shared conversion already ran first
on every path, so results, traces, failure points and failure text are unchanged;
a path that reaches no second occurrence pays one lambda, one application and one
variable lookup. Gate: the exact PV11 target and a safe level as for O4/O5, but
on VALUE_CONSTANTS instead of CASE_ON_BUILTIN_CONSTANTS; rule provenance is
pv11.o8.value-sharing; NONE/BASELINE keep their bytes.
ListIndexPromotionPass (ADR-043, O9) runs between the two passes above and is
the first costed-only rule: its gate is the exact PV11 target,
OptimizationLevel.pv11CostedRulesEnabled() (the opt-in PV11_COSTED level,
independent of cost profiles), ARRAY_CONSTANTS, and both array builtins on the resolved feature
profile. For a Let, lambda-parameter or match-field binding of a list variable
whose scope holds the recursive JulcList.get lowering
(PirHelpers.recursiveListGet, recognised structurally through the shared
PirHelpers.RECURSIVE_LIST_GET binding) at two or more sites, or at one site
inside a recursive binding value (a loop body or an inlined list-operation
callback), it rewrites those sites to IndexArray(a, i) and inserts
let a = ListToArray(xs) at the innermost sub-term that contains every use of
a and is evaluated at most once per evaluation of the scope: never inside a
recursive binding value, and inside a lambda body only along the parameter
chain that directly follows the binder. Sites under a rebinding of the variable
belong to the inner binding; the loop lowering’s post-loop let xs = xs
self-alias is transparent. Only a proven binding is promoted: a Let whose value
is a list by construction under the proven-name environment at its binder
(producesList: a decode, a list builtin, a proven variable or a full call of a
list-returning method whose body the analysis proved), a list-typed match field,
a list-match tail, a root parameter, a parameter of a lambda chain applied on the
spot whose argument is proven (the validator handler under its method-binding
lets), or a method parameter that call-site provenance proves. That analysis runs
over the whole term before the rewrite: a parameter of a Let/LetRec-bound
lambda chain is proven only while every use of the method’s name is a call that
passes it a list by construction (fewer arguments or a non-call use refute what
they leave unbound), and a list-typed result only while the body produces a list
under that environment; both start optimistic and are refuted to a greatest
fixpoint, so recursive helpers and loops that hand their own list or its tail
back to themselves keep their proof. A callback lambda’s parameter is never
trusted: the list operations apply callbacks to raw Data elements. A cast to
JulcList lowers to its Data-typed inner term, so the local it binds is unproven
and so is every alias of it, including the loop lowering’s post-loop
let xs = xs, and so is every helper parameter or result such a value reaches.
ListToArray is total and pure on a list, so placement is a budget decision; an
out-of-range index fails at IndexArray instead of inside the traversal (the
ADR-043 failure contract, costed profile only). Rule provenance is pv11.o9.list-to-array; NONE/BASELINE/PV11_SAFE keep
their bytes, and MultiIndexArray is never emitted.
ADR-044 (O15) generalises ValueConversionSharingPass from one unit class to
three, under the same leading rule. A unit is a closed, deterministic
computation of one variable x: the native Value conversion of ADR-042; a
record field projection D(headList(tailList^k(sndPair(unConstrData(x)))))
where D is exactly one PirHelpers.wrapDecode arm (none, unIData,
unBData, unListData, unMapData, the Bool form
equalsInteger(fstPair(unConstrData(·)), 1) or the String form
decodeUtf8(unBData(·))), matched outermost arm first so the raw chain inside
a decode is never a unit of its own; and the fields prefix
sndPair(unConstrData(x)). Two occurrences are the same unit when the root
variable’s name, the depth and the arm agree. Rounds run field chains to a
fixed point (a shared inner projection is a variable and roots the outer
chains of b.inner().x() / b.inner().y()), then the prefix over whatever
distinct chains remain, then Value conversions, which may now apply to a
shared projection. Two refinements apply to every class: the body of a single
recursive binding of a lambda can lead (building the closure is a fixed number
of pure steps, the fact the UPLC optimiser also relies on; the per-site
JulcList.get lowering wraps each site this way; the multi-binding lowering
never leads), and
units inside a lambda binding the live program never references are neither
counted nor rewritten, so a repeat that sits only in an uncalled helper records
no provenance (every uncalled library method is such a binding; a unit shared
in a live helper the optimiser later inlines still records). A matched unit is
a leaf for the collector and the rewriter alike: the raw chain inside a decode
belongs to the decode, so a field used both raw and decoded in one scope shares
each form as its own unit. Root-level
projections of an entrypoint’s own record parameters are already bound once by
the strict boundary, and a switch pattern variable’s fields reuse the match
binders, so neither produces chains; nested projections (ctx.txInfo(),
txInfo.outputs() on a local or parameter, loop items, cast locals) do. Gate:
the exact PV11 target and a safe level, no capability (the builtins predate
PV11; the gate is rollout policy); provenance pv11.o15.projection-sharing.
Because O15 binds a projected list once, ADR-043 sees that binding as a proven
list and promotes b.items().get(i) sites at the costed profile that it could
not reach before.
ADR-045 (O14) adds ValueLiteralFoldPass, which runs first among the PIR-to-PIR
passes (its machinery is the abstract LiteralFoldPass, shared with ADR-046’s
ArrayLiteralFoldPass below). Builtins.emptyValue() lowers to a UPLC Value constant, typed
NativeValueType, gated by the VALUE_CONSTANTS capability;
Builtins.singletonValue(...) and lovelaceValue(...) inline InsertCoin into
it (intrinsics, so a program that does not call them is unchanged: a library
source method would be compiled and bound whether called or not). The pass folds a
saturated call of one of the seven Value builtins whose arguments are all
literals (constants, or names bound once by a Let to a literal) into the
literal it evaluates to, spelled as the bare builtin or through a once-bound
wrapper whose body is that builtin over its parameters and constants (every
NativeValueLib method). The result comes from NativeValueSemantics in
julc-core, the same code ValueBuiltins runs in the VM: a call the semantics
reject stays exactly as written, so failure text and failure point are
untouched; a trace, an error or a runtime variable in argument position blocks
the fold. A wrapper must use every parameter and every call-site argument must
be a literal, used or not (the strict application evaluates them all).
Objective: the literal’s FLAT encoding must not be longer in bits than the
term it replaces, measured as it stands in the artifact: the builtin or, for
a wrapper call, the wrapper variable, applied to the call-site arguments, a
constant counting as itself and a literal local as a variable reference (its
constant lives once in its binding). A local bound directly to a constant
whose every remaining occurrence the call consumes dies with the fold and the
optimizer drops its binding, so it counts as its constant once; a local still
used elsewhere, and an alias of a local (its binding holds only a reference),
count as a reference, so a call that would copy the constant stays (toData(emptyValue()),
fromData of a Data literal with five or more tokens, a user wrapper that
carries constants in its body and a literal scale of a local shared with
another call therefore stay). Gate: exact PV11
target, safe level, the capability, the switch; provenance
pv11.o14.value-literal-fold. No program compiled before ADR-045 contains a
Value or Data literal argument to these builtins, so the rule is additive.
ADR-046 (O10) adds ArrayLiteralFoldPass, the second domain of LiteralFoldPass,
run right after the Value domain. JulcArray.of(a, b, ...) lowers in the
registry to ListToArray over the JulcList.of list literal (MkCons over
wrapEncoded elements; ListToArray is the requirement, PV11 only). The pass
reads a list literal whose elements are constants wrapped exactly as
wrapEncode wraps them (or a once-bound local holding one) to a list data
constant and folds ListToArray of it to an array constant, LengthOfArray of
an array constant to its length and IndexArray at a literal index to the
element, by ArraySemantics in julc-core (shared with the VM’s
ArrayBuiltins); an out-of-range literal index stays and fails at runtime with
the builtin’s text. Because get wraps the access in a decode, the pass also
folds UnIData/UnBData/UnListData/UnMapData/UnConstrData/FstPair/
DecodeUtf8 and the Bool form’s EqualsInteger only over constants it
produced (an identity set), never over a Data constant that was in the
program already; a mismatched element keeps its decode. Gate: exact PV11
target, safe level, the ARRAY_CONSTANTS capability, the switch; provenance
pv11.o10.array-literal-fold; additive for existing programs.
ADR-047 (O11) adds no pass; it types the BLS12-381 surface. PirType gains
NativeG1Type, NativeG2Type, NativeMlResultType and NativeListType(elem)
(elem is IntegerType, NativeG1Type or NativeG2Type), all native-opaque
like NativeValueType, so the O7 isolation rules (JULC0041/JULC0042),
the blueprint schema, the strict boundary and the verification projection
treat them the same way; the Java markers are JulcG1, JulcG2,
JulcMlResult, JulcScalars, JulcG1Points, JulcG2Points in julc-core.
TypeInferenceHelper types every BLS builtin by its group (compress is the
only way to a byte string, uncompress the only way back) and a BLS or
native-list constant by its universe. The registry’s
nativeArgumentSignature table says which native type each argument of a
Builtins method must have (Values, points, Miller results, native lists),
and validateNativeValueArguments rejects anything else at compile time with
the expected type in the message (requires G1, requires NativeList[Integer]); the variadic producers check every element
(requires Integer for scalars), the converters check the Data list’s
static element type, and PirGenerator.checkNativeBoundary applies the same
native comparison to initializers, same-class helper arguments, return
expressions against the declared return type (a lambda’s own return is
exempt), the else branch of a conditional against its then branch, and,
through LoopBodyGenerator, declarations inside a loop body and assignments to
accumulators and loop-body locals (a bare nested block is spliced into the loop
body with its own declarations renamed apart, which is the lowering of the
braceless body byte for byte; an assignment the loop body generators do not
bind, in expression position or to an undeclared name, is rejected by the
expression generator rather than lowered to its right-hand side), so no route
inside a class launders a point into byte[] or Data. The native lists for bls12_381_G1_multiScalarMul and
its G2 form come only from the Builtins intrinsics scalars/g1Points/
g2Points (a list constant when every element is a constant, else MkCons
over the empty native list constant, bound once so inference reads the list
type off the Var) and scalarsFromList/g1PointsFromCompressed/
g2PointsFromCompressed (a LetRec decoding loop applied to the Data list
outside the loop’s own binding, so a user variable named like the loop is never
captured, decoding each element with UnIData, or UnBData then
uncompress); no Data encoder can appear
between an element and its list. MSM semantics are the VM’s: all scalars
validated first, zip to the shorter list, empty sum is the identity. No
fusion of scalarMul/add chains exists (the crossover is seven points on
the pinned profile, ADR-047); the producers are intrinsics so programs that do
not use them keep their bytes.
Each PIR-to-PIR rule can be switched off on its own:
CompilerOptions.disableOptimizationRule(id) for pv11.o8.value-sharing,
pv11.o15.projection-sharing, pv11.o9.list-to-array,
pv11.o14.value-literal-fold and pv11.o10.array-literal-fold
(CompilationContext.switchableOptimizationRules()); any other identifier
fails with JULC0043 before compilation, and the rules implemented inside
UplcGenerator/UplcOptimizer are selected by the level only. The switch
exists so that a rule can be reviewed and measured in isolation (the O9 golden
suite pins its bytes with O15 off, the benchmark compares a level with a rule
off against the same level with it on); the level remains the supported way to
choose a rollout, and the Gradle plugin and CLI do not expose the switch.
ADR-041 replaces the tag dispatch itself under the same gate. When a DataMatch
has two or more constructors, generateDataMatch emits a generator-local
IntegerCase on the decoded tag binder instead of the EqualsInteger/IfThenElse
chain; it lowers to one UPLC Case with one argument-free branch per constructor
in tag order, so branch i runs for tag i and any other tag fails at selection
without evaluating a branch. Single-constructor matches are unchanged. Rule
provenance is pv11.o5.case-integer. The strict typed boundary rejects invalid
tags before user dispatch, so the only observable change for validators is the
smaller, flat dispatch cost; direct-PIR consumers see the VM’s Case out-of-range
failure text instead of the chain’s error term on a malformed tag.
Run ./gradlew :julc-compiler:pairCaseTest for the dedicated Java/Truffle/Scalus
pair suite. It is included in check/build; its additional Truffle dependency
does not change default VM selection in the existing compiler tests.
Side-by-side compilation example:
// Javaint x = 5;int y = x + 3;return y;// PIRLet("x", Const(Integer(5)), Let("y", App(App(Builtin(AddInteger), Var("x", IntegerType)), Const(Integer(3))), Var("y", IntegerType)))// UPLC (Let → Apply(Lam, val), names erased to De Bruijn)Apply(Lam("x", Apply(Lam("y", Var(1)), Apply(Apply(Builtin(AddInteger), Var(1)), Const(Integer(3))))), Const(Integer(5)))2.3 The Data Encoding
Section titled “2.3 The Data Encoding”On the Cardano ledger, all values are encoded as Data — a universal representation with 5 constructors:
| Constructor | Encodes | Encode builtin | Decode builtin |
|---|---|---|---|
Constr(tag, fields) |
Records, sealed interfaces, booleans, Optional | ConstrData |
UnConstrData |
Map(pairs) |
Map<K,V> |
MapData |
UnMapData |
List(items) |
List<T> |
ListData |
UnListData |
I(integer) |
int, long, BigInteger |
IData |
UnIData |
B(bytestring) |
byte[], hash types, TokenName |
BData |
UnBData |
The compiler inserts encode/decode wrappers at type boundaries. The two key helpers in PirHelpers.java:
wrapDecode(PirTerm data, PirType targetType) — Extract a typed value from raw Data:
IntegerType→UnIData(data)ByteStringType→UnBData(data)ListType→UnListData(data)MapType→UnMapData(data)BoolType→EqualsInteger(FstPair(UnConstrData(data)), 1)(tag 1 = True, 0 = False)StringType→DecodeUtf8(UnBData(data))DataType,RecordType,SumType→ pass through (already Data)
wrapEncode(PirTerm value, PirType type) — Wrap a typed value back into Data:
IntegerType→IData(value)ByteStringType→BData(value)BoolType→IfThenElse(value, ConstrData(1,[]), ConstrData(0,[]))StringType→BData(EncodeUtf8(value))ListType→ListData(value)MapType→MapData(value)DataType,RecordType,SumType→ pass through
Boolean encoding: Booleans map to Constr(1, []) (True) and Constr(0, []) (False) — matching the Haskell convention.
Record encoding: A record Point(int x, int y) with x=10, y=20 encodes as:
Constr(0, [I(10), I(20)])Sealed interface encoding: Variants get ascending tags:
sealed interface Shape { record Circle(int radius) implements Shape {} // tag 0 record Rect(int w, int h) implements Shape {} // tag 1}// Circle(5) → Constr(0, [I(5)])// Rect(3,4) → Constr(1, [I(3), I(4)])3. Module Architecture
Section titled “3. Module Architecture”Module Dependency Diagram
Section titled “Module Dependency Diagram” ┌────────────────┐ │ julc-bom │ (Bill of Materials) └────────────────┘
┌─────────────┐ ┌────────────────┐ ┌──────────────────┐ │ julc-core │ ◄── │ julc-compiler │ ──► │ julc-stdlib │ │ (UPLC AST) │ │ (Java→UPLC) │ │ (StdlibRegistry) │ └──────┬──────┘ └───────┬────────┘ └──────────────────┘ │ │ ┌──────┴──────┐ ┌──────┴────────┐ ┌──────────────────┐ │ julc-vm │ │julc-ledger-api│ │ julc-onchain-api │ │ (VM SPI) │ │ (ScriptCtx) │ │ (Annotations) │ └──────┬──────┘ └───────────────┘ └──────────────────┘ │ ┌──────┴──────────┐ │ julc-vm-scalus │ │ (Scalus backend)│ └─────────────────┘
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │ julc-testkit │ │ julc-annotation-proc │ │ julc-gradle-plugin │ │ (Test framework)│ │ (Build-time compile) │ │ (Gradle integration│ └─────────────────┘ └──────────────────────┘ └────────────────────┘
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │ julc-examples │ │ julc-cardano-client │ │ julc-e2e-tests │ │ (Sample code) │ │ (CCL integration) │ │ (E2E tests) │ └─────────────────┘ └──────────────────────┘ └────────────────────┘
┌─────────────────┐ │ julc-plugin-test│ │ (Plugin tests) │ └─────────────────┘Module Roles
Section titled “Module Roles”| Module | Role |
|---|---|
julc-core |
UPLC AST (Term.java), constants, DefaultFun enum (101 released builtins plus one future tag), FLAT/CBOR serialization, Program wrapper |
julc-compiler |
The compiler itself — parsing, validation, type resolution, PIR generation, loop desugaring, pattern matching, UPLC lowering, optimization |
julc-stdlib |
StdlibRegistry — PIR term builders for ~65 stdlib methods (builtins, HOFs, math) |
julc-vm |
VM SPI interface — pluggable execution backend |
julc-vm-scalus |
Scalus-based VM implementation (evaluates UPLC programs) |
julc-ledger-api |
Java records for Cardano ledger types (TxInfo, ScriptContext, TxOut, etc.) |
julc-onchain-api |
Annotations (@SpendingValidator, @MintingValidator, @Entrypoint, @Param, @OnchainLibrary) + off-chain stubs for stdlib classes |
julc-testkit |
ValidatorTest base class, SourceDiscovery for test-time compilation |
julc-annotation-processor |
Java annotation processor — compiles validators at build time |
julc-gradle-plugin |
Gradle plugin wrapping the annotation processor |
julc-cardano-client-lib |
Integration with cardano-client-lib v0.7.1 |
julc-examples |
Example validators and library code |
julc-e2e-tests |
End-to-end integration tests (CIP-113, etc.) |
julc-plugin-test |
Tests for the Gradle plugin |
julc-bom |
Bill of Materials for dependency management |
Internal Package Structure of julc-compiler
Section titled “Internal Package Structure of julc-compiler”org.julclang.compiler/├── JulcCompiler.java # Main pipeline orchestrator├── LibraryCompiler.java # Library compilation sub-pipeline├── CompileResult.java # Compilation result record├── CompilerException.java # Fatal compiler error├── CompilerOptions.java # Compilation options├── LibrarySourceResolver.java # Classpath scanning + transitive resolution├── codegen/ # ValidatorWrapper, strict boundary checks├── desugar/ # LoopDesugarer, PatternMatchDesugarer├── error/ # CompilerDiagnostic, DiagnosticCollector├── pir/ # PIR generation subsystem│ ├── PirGenerator.java # Core PIR generation│ ├── LoopBodyGenerator.java # Loop body compilation│ ├── AccumulatorTypeAnalyzer.java # Accumulator type analysis│ ├── TypeInferenceHelper.java # Read-only type inference│ ├── TypeMethodRegistry.java # Instance method dispatch│ ├── PirHelpers.java # wrapDecode/wrapEncode + utilities│ ├── PirHofBuilders.java # HOF PIR builders│ ├── PirTerm.java, PirType.java # PIR AST + type system│ └── StdlibLookup.java, CompositeStdlibLookup.java # Stdlib resolution├── resolve/ # TypeResolver, TypeRegistrar, SymbolTable,│ # LedgerSourceLoader, LibraryMethodRegistry├── uplc/ # UplcGenerator, UplcOptimizer└── validate/ # SubsetValidator4. The Type System
Section titled “4. The Type System”4.1 PirType Hierarchy
Section titled “4.1 PirType Hierarchy”The PIR type system is defined as a sealed interface in julc-compiler/.../pir/PirType.java with 13 variants:
Primitive types (6):
| Variant | Java types | On-chain |
|---|---|---|
IntegerType |
int, long, BigInteger |
Arbitrary-precision integer |
ByteStringType |
byte[], PubKeyHash, TxId, PolicyId, TokenName, etc. |
Raw bytes |
StringType |
String |
UTF-8 text |
BoolType |
boolean, Boolean |
Constr(0/1) |
UnitType |
void |
Unit constant |
DataType |
PlutusData, ConstrData, MapData, ListData, IntData, BytesData |
Raw untyped Data |
Container types (4):
| Variant | Fields | Java type |
|---|---|---|
ListType(PirType elemType) |
Element type | List<T> |
PairType(PirType first, PirType second) |
First, second | Internal (pair tuples) |
MapType(PirType keyType, PirType valueType) |
Key, value types | Map<K,V> |
OptionalType(PirType elemType) |
Element type | Optional<T> |
Function type (1):
| Variant | Fields | Use |
|---|---|---|
FunType(PirType paramType, PirType returnType) |
Param, return | Method signatures (curried) |
Algebraic data types (2):
| Variant | Fields | Java type |
|---|---|---|
RecordType(String name, List<Field> fields) |
Name, fields | record declarations |
SumType(String name, List<Constructor> ctors) |
Name, constructors | sealed interface with record variants |
Helper records:
Field(String name, PirType type)— record fieldConstructor(String name, int tag, List<Field> fields)— sum type variant
4.2 Java-to-PIR Type Mapping
Section titled “4.2 Java-to-PIR Type Mapping”Defined in TypeResolver.resolve(Type):
| Java Type | PIR Type | Notes |
|---|---|---|
boolean |
BoolType |
|
int, long |
IntegerType |
|
void |
UnitType |
|
var |
DataType |
Placeholder; PirGenerator infers actual type from initializer |
byte[] |
ByteStringType |
Special-cased array type |
BigInteger |
IntegerType |
|
String |
StringType |
|
Boolean |
BoolType |
Boxed |
PlutusData, ConstrData, MapData, ListData, IntData, BytesData |
DataType |
All raw data types |
List<T> |
ListType(resolve(T)) |
Generic |
Map<K,V> |
MapType(resolve(K), resolve(V)) |
Generic |
Optional<T> |
OptionalType(resolve(T)) |
Generic |
JulcArray<T> |
ArrayType(resolve(T)) |
PV11 only; O(1) random access (CIP-138) |
PubKeyHash, ScriptHash, ValidatorHash, PolicyId, TokenName, DatumHash, TxId |
ByteStringType |
Ledger hash types |
StakingCredential, ScriptPurpose, Vote, Voter, DRep, Delegatee, GovernanceActionId, GovernanceAction, ProposalProcedure, TxCert, Rational, ProtocolVersion, Committee |
DataType |
Opaque ledger types |
| Registered records | RecordType(...) |
User-defined or ledger records |
| Registered sealed interfaces | SumType(...) |
User-defined or ledger sum types |
4.3 Type Registration
Section titled “4.3 Type Registration”Types must be registered before they can be resolved. Registration happens in two places:
1. LedgerTypeRegistry.registerAll(TypeResolver) — Pre-registers all Cardano ledger types in 4 tiers:
| Tier | Types | Examples |
|---|---|---|
| Tier 1: Simple leaf records | Records with only primitive fields | TxOutRef, IntervalBound, Value |
| Tier 2: Sealed interfaces | Sum types | Credential, OutputDatum, ScriptInfo, IntervalBoundType |
| Tier 3: Composite records | Records referencing Tier 1/2 types | Address, TxOut, TxInInfo, Interval |
| Tier 4: Top-level types | Types referencing Tier 3 | TxInfo (16 fields), ScriptContext (3 fields) |
2. TypeRegistrar.registerAll(List<CompilationUnit>, TypeResolver) — Registers user-defined types via topological sort:
- Collect — Scan all compilation units for
recordandsealed interfacedeclarations - Validate — Check for duplicate type names across compilation units
- Build dependency graph — Record fields of type
OtherRecordcreate a dependency edge - Topological sort (Kahn’s algorithm):
- Compute in-degree for each type
- Emit types with in-degree 0 (no dependencies)
- Decrement dependents, repeat
- If remaining types exist after queue empties → cycle detected, throw
CompilerException
- Register — Call
typeResolver.registerRecord()orregisterSealedInterface()in dependency order
4.4 The DataType Escape Hatch
Section titled “4.4 The DataType Escape Hatch”DataType is the “untyped” PIR type. The compiler uses it when:
- The Java type is
PlutusDataor any of its subtypes - The Java type is an opaque ledger type (e.g.,
StakingCredential) - The Java type is
varand the initializer type cannot be inferred - The type is unknown to the resolver
Pitfalls:
DataTypevalues pass through encode/decode unchanged — no type checking- Cross-method calls may infer
DataTypewhere a specific type was expected (see Known Limitations, Section 25) @Paramfields are always raw Data at runtime regardless of declared Java type
Part II: The Compilation Pipeline
Section titled “Part II: The Compilation Pipeline”5. Pipeline Overview
Section titled “5. Pipeline Overview”The compilation pipeline is orchestrated by JulcCompiler.compile(). Here is the complete flow:
Java Source(s) │ ▼┌─────────────────────────────────────┐│ 1. Parse │ JavaParser → CompilationUnit ASTs│ 2. Validate │ SubsetValidator rejects unsupported constructs│ 3. Library Check │ Ensure libraries don't contain validator annotations│ 4. Annotated Class Discovery │ Find class with a supported validator annotation│ 5. Script Purpose Detection │ Map annotation → SPENDING/MINTING/WITHDRAW/...│ 6. Type Registration │ LedgerTypeRegistry + TypeRegistrar (topo sort)│ 7. @Param Field Detection │ Find @Param-annotated fields│ 8. Static Field Detection │ Find static fields with initializers│ 9. Entrypoint Discovery │ Find @Entrypoint method│ 10. Parameter Validation │ Check param count (2 or 3 for spending, 2 others)│ 11. Library Compilation │ Multi-pass compile library methods to PIR│ 12. Compose Lookups │ CompositeStdlibLookup(stdlib, libraries)│ 13. Symbol Table Setup │ Define @Params, static fields, static methods│ 14. Helper Method Generation │ Generate PIR for all static helper methods│ 15. Entrypoint Generation │ Generate PIR for the @Entrypoint method│ 16. Helper Wrapping │ Wrap helpers as Let bindings│ 17. Static Field Wrapping │ Wrap static field initializers as Let bindings│ 18. Library Wrapping │ Wrap library methods as Let/LetRec (topo sorted)│ 19. Validator Wrapping │ Add ScriptContext decoder + bool→unit/error│ 20. @Param Wrapping │ Add outer lambda per @Param (Data → decoded)│ 21. UPLC Generation │ UplcGenerator: PIR → UPLC│ 22. Optimization │ UplcOptimizer: 6 passes, fixpoint│ 23. Program Creation │ Program.plutusV3(uplcTerm)│ 24. ParamInfo Creation │ Build param metadata list└─────────────────────────────────────┘ │ ▼ CompileResult(program, params, diagnostics)6. Phase 1: Parsing and Validation
Section titled “6. Phase 1: Parsing and Validation”Parsing
Section titled “Parsing”The compiler uses JavaParser configured for Java 21 language level:
StaticJavaParser.getParserConfiguration() .setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21);Each source string is parsed into a CompilationUnit AST. Parse errors are reported as CompilerDiagnostic entries.
SubsetValidator
Section titled “SubsetValidator”SubsetValidator (validate/SubsetValidator.java) extends JavaParser’s VoidVisitorAdapter to walk the AST and reject unsupported Java constructs. It collects non-fatal diagnostics so multiple errors can be reported at once.
Rejected constructs:
| Category | Construct | Error Message | Suggestion |
|---|---|---|---|
| Statements | try/catch |
“try/catch is not supported on-chain” | “Use if/else checks instead of exception handling” |
throw |
“throw is not supported on-chain” | “Return false from the validator to reject a transaction” | |
synchronized |
“synchronized is not supported on-chain” | “On-chain code is single-threaded” | |
for(;;) |
“C-style for loops are not supported” | “Use for-each over a list or while loops” | |
do-while |
“do-while loops are not supported” | “Use while loops or for-each” | |
break (outside loop) |
“break is only supported inside for-each or while” | — | |
| Expressions | null |
“null is not supported on-chain” | “Use Optional<T> to represent absence” |
this |
“‘this’ is not supported on-chain” | “Use static methods instead” | |
super |
“‘super’ is not supported on-chain” | “Use sealed interfaces and pattern matching” | |
new T[] |
“arrays are not supported on-chain” | “Use List<T> instead” |
|
arr[i] |
“array access is not supported” | “Use List<T> with list operations” |
|
| Types | float, double |
“floating point types not supported” | “Use BigInteger or Rational” |
| Classes | extends (non-Object) |
“class inheritance is not supported” | “Use sealed interfaces with record variants” |
Allowed constructs:
for-eachloops (desugared to fold)whileloops (desugared to recursion)- Nested loops (while-in-while, for-each-in-for-each, mixed)
breakinside for-each/whilenew RecordType(...)(record construction)switchexpressions on sealed interfaces (pattern matching)instanceofwith pattern variables
7. Phase 2: Type Registration
Section titled “7. Phase 2: Type Registration”TypeRegistrar
Section titled “TypeRegistrar”TypeRegistrar.registerAll() processes all compilation units (validator + libraries) together:
1. Collect all record and sealed interface declarations2. Validate no duplicate type names across CUs3. Build dependency graph: - Record field types create edges (e.g., TxOut.address → Address) - Sealed interface → variant record edges - Generic type args extracted recursively (List<ProofStep> → ProofStep)4. Topological sort (Kahn's algorithm): - Compute in-degree for each type - Queue types with in-degree 0 - Dequeue → register → decrement dependents → repeat - Remaining types after queue empties → cycle error5. Register in dependency order (skip already-registered ledger types)LedgerTypeRegistry
Section titled “LedgerTypeRegistry”LedgerTypeRegistry.registerAll() is called before TypeRegistrar to pre-register all Cardano ledger types. The 4-tier ordering ensures types are available when referenced as field types.
Key types registered:
| Type | Fields | Tier |
|---|---|---|
TxOutRef |
txId: ByteString, index: Integer |
1 |
Value |
inner: Map<ByteString, Map<ByteString, Integer>> |
1 |
Credential |
PubKeyCredential(0, hash) / ScriptCredential(1, hash) | 2 |
ScriptInfo |
MintingScript(0) / SpendingScript(1) / RewardingScript(2) / CertifyingScript(3) / VotingScript(4) / ProposingScript(5) | 2 |
Address |
credential: Credential, stakingCredential: Data |
3 |
TxOut |
address: Address, value: Value, datum: OutputDatum, referenceScript: Data |
3 |
TxInInfo |
outRef: TxOutRef, resolved: TxOut |
3 |
TxInfo |
16 fields (inputs, outputs, fee, mint, signatories, …) | 4 |
ScriptContext |
txInfo: TxInfo, redeemer: Data, scriptInfo: ScriptInfo |
4 |
8. Phase 3: PIR Generation
Section titled “8. Phase 3: PIR Generation”This is the heart of the compiler. PirGenerator transforms JavaParser AST nodes into PIR terms.
8.1 PirGenerator Architecture
Section titled “8.1 PirGenerator Architecture”After the ADR-018 refactoring, PirGenerator (2,147 lines) delegates to three extracted helper classes:
AccumulatorTypeAnalyzer— Pure AST analysis: detects accumulator variables in loops, refines types (e.g., distinguishingListTypevsMapTypeaccumulators based on pair operation evidence)TypeInferenceHelper— Read-only type inference:resolveExpressionType(),inferPirType(),inferBuiltinReturnType(), etc. Takes read-only dependencies (SymbolTable, TypeResolver, StdlibLookup, TypeMethodRegistry)LoopBodyGenerator— Loop body compilation: 9 body-processing methods across 5 loop paths (single/multi accumulator × break/no-break), plus pack/unpack helpers and nested loop handling
All three are package-private, final classes in the pir/ package.
Constructor dependencies:
PirGenerator(TypeResolver typeResolver, SymbolTable symbolTable, StdlibLookup stdlibLookup, TypeMethodRegistry typeMethodRegistry, String libraryClassName) // non-null when compiling @OnchainLibraryTwo entry points:
-
generateMethod(MethodDeclaration)— Compiles an entrypoint or helper method:- Pushes a new scope
- Registers parameters
- Compiles the method body via
generateBlock() - Wraps result in nested lambdas:
\p1 -> \p2 -> ... -> body
-
generateExpression(Expression)(public) — Compiles any expression node to a PIR term. Dispatches by expression type to specialized handlers.
Callback surface for LoopBodyGenerator:
LoopBodyGenerator calls back to PirGenerator via 7 methods (1 already public, 6 widened to package-private):
generateExpression()(public),generateStatement(),generateWhileStmt(),generateForEachStmt(),inferType(),enrichedError()(static),detectForEachAccumulators()
Error collection:
- Non-fatal:
collectError(msg, suggestion, node)→ addsCompilerDiagnostic, returnsPirTerm.Error - Fatal:
enrichedError(msg, suggestion, node)→ throwsCompilerExceptionimmediately - Fuzzy method name matching via Levenshtein distance for “did you mean?” suggestions
8.2 Expression Compilation
Section titled “8.2 Expression Compilation”Literals:
| Java | PIR |
|---|---|
42 / 42L |
Const(Integer(42)) |
0xFF |
Const(Integer(255)) |
true |
Const(Bool(true)) |
"hello" |
Const(String("hello")) |
Variables:
name→Var(name, type)where type is looked up from the SymbolTable
Binary operations (type-aware dispatch):
| Op | Default | String | ByteString |
|---|---|---|---|
+ |
AddInteger |
AppendString |
AppendByteString |
- |
SubtractInteger |
— | — |
* |
MultiplyInteger |
— | — |
/ |
QuotientInteger |
— | — |
% |
RemainderInteger |
— | — |
== |
EqualsInteger / EqualsByteString / EqualsString / EqualsData |
||
< |
LessThanInteger |
— | — |
<= |
LessThanEqualsInteger |
— | — |
> |
Swap operands + LessThanInteger |
— | — |
>= |
Swap operands + LessThanEqualsInteger |
— | — |
&& |
IfThenElse(left, right, false) |
— | — |
|| |
IfThenElse(left, true, right) |
— | — |
Unary operations:
!x→IfThenElse(x, false, true)-x→SubtractInteger(0, x)
8.3 Method Call Dispatch
Section titled “8.3 Method Call Dispatch”generateMethodCall() implements a 5-level cascading dispatch:
Level 1: Special cases
BigInteger.valueOf(n)→ identity (pass through)
Level 2: Stdlib static methods
- Check if scope is a class name (e.g.,
ListsLib,Builtins,Math) - Look up in
StdlibLookup→ returns PIR term - Example:
Builtins.headList(xs)→App(Builtin(HeadList), xs)
Level 3: Instance methods via TypeMethodRegistry
- Resolve scope expression type
- Dispatch to registered handler
- Example:
list.size()→PirHelpers.generateListLength(list)
Level 4: Record field access (zero-arg methods)
- Check if scope variable has
RecordTypeorOptionalType(RecordType) - Check if field name is in current scope (from pattern destructuring)
- Otherwise generate field extraction:
HeadList(TailList^n(SndPair(UnConstrData(data))))
Level 5: Helper method / library method calls
- Look up method name in SymbolTable
- For library classes, try qualified name
ClassName.methodName - Apply scope + args
- If not found → error with fuzzy suggestion
8.4 Record Construction and Field Access
Section titled “8.4 Record Construction and Field Access”Construction:
new Point(10, 20)→
DataConstr(0, RecordType("Point", [...]), [Const(10), Const(20)])For sum type variants, the tag comes from the constructor’s position in the sealed interface.
Field access:
point.x() // field 0point.y() // field 1→
// Field 0 (x): HeadList after UnConstrDataLet("__fields", SndPair(UnConstrData(point)), wrapDecode(HeadList(__fields), IntegerType))
// Field 1 (y): TailList then HeadListLet("__fields", SndPair(UnConstrData(point)), wrapDecode(HeadList(TailList(__fields)), IntegerType))8.5 Control Flow
Section titled “8.5 Control Flow”If/else:
if (cond) { thenBody } else { elseBody }→ IfThenElse(cond, thenBody, elseBody)
Fallthrough optimization: If the then-branch returns and there’s no explicit else but following statements exist, the following statements become the else-branch:
if (cond) { return x; }return y; // ← becomes the else branch→ IfThenElse(cond, x, y)
instanceof pattern:
if (shape instanceof Circle c) { ... }→ Tag equality check: EqualsInteger(FstPair(UnConstrData(shape)), circleTag)
The pattern variable c and its fields are bound in the then-scope.
Switch expressions:
switch (shape) { case Circle c -> c.radius(); case Rect r -> r.w() * r.h();}→ DataMatch with branches ordered by tag, delegated to PatternMatchDesugarer
Ternary: cond ? a : b → IfThenElse(cond, a, b)
8.6 Statement Compilation
Section titled “8.6 Statement Compilation”Variable declaration:
int x = 5;// rest of block...→ Let("x", Const(5), rest)
Type inference: explicit type if declared, otherwise inferred from initializer expression type, then PIR term type. Uninitialized variables are rejected.
Return/yield:
return expr; // or: yield expr;→ generateExpression(expr) — becomes the final term in the Let chain
Expression statement:
someCall();// rest...→ Let("_", someCall, rest) — evaluate and discard
8.7 Names: generated binders and the method namespace (ADR-060)
Section titled “8.7 Names: generated binders and the method namespace (ADR-060)”UPLC resolves a variable to its nearest binder, so a binder the compiler wraps around user code must never share a name with anything that code refers to. The rule, from ADR-060, applies to every lowering:
- A binder you invent is named
"#" + name. No Java identifier contains#, so a source name can never resolve to it. Keep existing names one-to-one ("#" + historical name): the name-counting PV11 passes compare names. - If terms built elsewhere sit inside the binder, choose the name with
PirHelpers.hygienicName("#base", avoid). That means terms from another builder, which may mention the same generated name, or from another frontend.avoidholds the free variables of those terms (PirHelpers.freeVariables) and the names of any caller-supplied binders placed between your binder and your references to it.hygienicNamerejects a base without#. A fixed#name is enough when nothing in the scope can mention it, for example Java source only. - A loop must not update a shared field. A loop assignment to a static field
or
@Paramrebinds the name only in the rest of the method’s current run, so it is rejected (JULC0056) unless the field is not final, the method is an entry method that no method calls, the loop is not in a lambda, and no other method reads the field. A method refers to a name when the name is free in its generated PIR oncelet x = xis removed, so a variable that shadows the field is not a read (PirGenerator.requireUnsharedFields). A new pipeline must declare its entry methods withPirGenerator.setEntryMethods, or no method may update a field. A body local that shadows a field is a local, not an accumulator. - Choose names while building the term. Never rename afterwards: source maps and Java debug provenance key on PIR node identity.
- A binder that rebinds a source name keeps it exactly (method parameters,
locals, accumulators,
Let p = decode(p)); block-local renames usename'N. - Methods have their own namespace. Declare them with
SymbolTable.declareMethod(sourceName, owner + "." + sourceName, type)and resolve calls withlookupMethodSignature; never look a method up as a variable. - Other frontends must not bind or reference
#names.
Three tests enforce the rule. GeneratedBinderNameLintTest parses the compiler
and stdlib sources and fails on a binder name outside the namespace. Test tasks
set julc.verifyBinderNamespace, so every compilation in tests checks each
binder that reaches UPLC generation. BinderNameIndependenceTest renames source
names to the old internal names and requires identical output.
9. Phase 4: Loop Desugaring
Section titled “9. Phase 4: Loop Desugaring”Loops are transformed into recursive LetRec patterns by LoopDesugarer. The desugarer assigns unique names to each loop function (#loop__forEach__0, #loop__while__1, etc.) to support nesting.
Before choosing a lowering path, LoopBodyGenerator.validateConditionalLocalUpdates
checks lexical scopes (ADR-048, #155). An if returns only the loop’s accumulators,
so an assignment to a loop-body local declared outside that branch is rejected
with a source location and rewrite hints. Bare blocks do not introduce joins;
nested loops have their own locals but retain enclosing conditional restrictions.
The check emits no PIR and mutates neither AST nor compiler state. A general
join-point lowering for these locals remains future work.
generateSwitchExpr also calls validateSwitchExpressionUpdates: each arm may
only assign variables it owns, because a switch exports its yielded value rather
than enclosing variable rebindings. The check applies even without an enclosing
loop or if; nested switch arms start a new boundary. Branch-local pattern
bindings are scoped to their valid branch. Switch case-pattern reassignment is
rejected (#162) to avoid stale cached field projections; this does not change
supported instanceof bindings.
Outside specialized loop-body lowering, generateIfStmt uses its existing
return/yield continuation path for branches containing statement-level loops
(ADR-050, #161). The continuation is generated in the enclosing scope and bound
once as a join lambda before the conditional. Its parameters are the source-order
union of enclosing accumulators updated by statement-level loops in either branch;
lexical analysis excludes branch/loop locals and pattern/iteration bindings.
Each falling-through branch calls it under its final accumulator bindings; an
unchanged path passes incoming values. Zero-accumulator joins take a unit argument
so the continuation is not evaluated before the branch. Parameters retain native
representations without Data packing. Sequential guarded loops therefore grow
linearly in serialized size rather than duplicating the entire tail at each if.
Branch locals and instanceof pattern bindings reuse the existing deterministic
local renaming so they cannot capture join arguments (for example, a branch local
shadowing a class field that the other branch updates). It also protects the
existing inline return/yield continuation path. Diagnostics use source names.
Nested branches propagate the continuation; loops inside expressions belong to
their own switch/lambda boundary. No new PIR node is introduced.
Branches without owned exits or statement-level loops keep their
sequencing shape. Recompiling affected sources can change bytes, hashes and costs
at every optimization level.
9.1 For-Each Loops
Section titled “9.1 For-Each Loops”ADR-048’s outer-loop guard traverses a switch expression’s selector, not its arms. The dedicated arm-ownership check prevents enclosing-variable updates; each loop inside an arm still validates its own body. Arm-local conditionals therefore use ADR-050’s continuation lowering even when the switch is inside an outer loop, without weakening restrictions on actual loop-body locals.
5 compilation paths based on accumulator count and break usage:
Path A: Single accumulator, no break
long sum = 0;for (var item : items) { sum = sum + item;}→
LetRec([#loop__forEach__0 = \xs \acc -> IfThenElse(NullList(xs), acc, Let(item, wrapDecode(HeadList(xs), elemType), #loop__forEach__0(TailList(xs), acc + item)))], #loop__forEach__0(items, 0))Path B: Single accumulator, with break
long result = 0;for (var item : items) { if (item > 100) break; result = result + item;}→ Uses a bodyBuilder(continueFn, accVar) function:
break→ returnaccVar(no recursion)- Continue →
continueFn.apply(newAcc)(recurse withloop(TailList(xs), newAcc))
Path C: Multi-accumulator (2+)
long sum = 0, count = 0;for (var item : items) { sum = sum + item; count = count + 1;}→ Accumulators packed into a Data list tuple:
pack: MkCons(IData(sum), MkCons(IData(count), MkNilData))unpack: Let(sum, UnIData(HeadList(tuple)), Let(count, UnIData(HeadList(TailList(tuple))), body))Path D: No accumulator → Uses unit accumulator, discards result.
Path E: Multi-accumulator with break → Combines tuple packing (Path C) with break-aware body generation (Path B).
9.2 While Loops
Section titled “9.2 While Loops”While loops follow the same accumulator detection patterns but use a condition check instead of list exhaustion:
long n = x;while (n > 0) { n = n - 1;}→
LetRec([#loop__while__0 = \n -> IfThenElse(n > 0, #loop__while__0(n - 1), n)], #loop__while__0(x))Accumulator type refinement (AccumulatorTypeAnalyzer.refineAccumulatorTypes()): The compiler distinguishes ListType vs MapType accumulators by looking for evidence (this logic was extracted from PirGenerator into AccumulatorTypeAnalyzer as part of ADR-018):
| Evidence | Inferred Type |
|---|---|
mkNilPairData() assignment |
MapType |
tailList() on unMapData() result |
MapType |
mkCons() with mkPairData() items |
MapType |
fstPair()/sndPair() on headList(cursor) |
MapType |
mkNilData(), mkCons(), tailList() (without pair evidence) |
ListType |
Nested loop unique naming:
Each loop gets a unique counter-based name. A loopCounter field in LoopDesugarer increments per loop:
Outer: #loop__forEach__0Inner: #loop__forEach__1bodyUsesPairOpsOnCursor fix: The compiler now checks pair operations specifically on the cursor’s headList result variable, not anywhere in the loop body. This prevents incorrect MapType inference when pair operations appear on unrelated variables.
10. Phase 5: Pattern Matching
Section titled “10. Phase 5: Pattern Matching”PatternMatchDesugarer transforms Java pattern matching into PIR DataMatch terms.
Switch on Sealed Interfaces
Section titled “Switch on Sealed Interfaces”switch (credential) { case PubKeyCredential pkc -> pkc.hash(); case ScriptCredential sc -> sc.hash();}- For each constructor in the
SumType, find the matching switch case - Extract binding names from pattern variables
- Order branches by constructor tag (tag 0, tag 1, …)
- Fill missing branches with
Error - Build
DataMatch(scrutinee, orderedBranches)
UPLC Lowering of DataMatch
Section titled “UPLC Lowering of DataMatch”Field bindings use PirHelpers.wrapDecode, the same Data-to-native codec as
ordinary record access (ADR-051, #166). This includes Bool and String, not only
Integer/ByteString/List/Map. The selected arm strictly decodes its bound fields
in order before executing its body; unselected arms remain unevaluated. Keep this
codec shared: a cached pattern-field binding must have its declared runtime type.
This decoding is not a substitute for strict typed-boundary validation.
UplcGenerator lowers DataMatch to:
Let(pair, UnConstrData(scrutinee), Let(tag, FstPair(pair), Let(fields, SndPair(pair), IfThenElse(tag == 0, Let(binding0, decode(HeadList(fields)), Let(__rest, TailList(fields), Let(binding1, decode(HeadList(__rest)), body0))), IfThenElse(tag == 1, ... body1 ..., Error)))))That is the NONE/BASELINE shape. Under the PV11 safe profile the pair
binding becomes a native pair Case (ADR-038) and, for two or more
constructors, the dispatch becomes one integer Case on the tag (ADR-041):
Case(UnConstrData(scrutinee), [\tag -> \fields -> Case(tag, [ Let(binding0, decode(HeadList(fields)), ... body0 ...), ... body1 ...])])Branch i is the branch for constructor tag i; a tag outside 0..n-1 fails at
selection. The equality chain and its terminal Error are gone, so the dispatch
cost no longer grows with the constructor’s position.
Instanceof Chains
Section titled “Instanceof Chains”if (cred instanceof PubKeyCredential pkc) { return pkc.hash();} else if (cred instanceof ScriptCredential sc) { return sc.hash();}Converted to DataMatch with the same structure. Missing variants are filled with the else-branch or Error.
11. Phase 6: Validator Wrapping
Section titled “11. Phase 6: Validator Wrapping”ValidatorWrapper (in codegen/) wraps the compiled entrypoint with ScriptContext decoding and bool-to-unit/error conversion.
Script Purposes and Annotations
Section titled “Script Purposes and Annotations”| Purpose | Annotations | Param count |
|---|---|---|
SPENDING |
@SpendingValidator |
2 or 3 |
MINTING |
@MintingValidator |
2 |
WITHDRAW |
@WithdrawValidator |
2 |
CERTIFYING |
@CertifyingValidator |
2 |
VOTING |
@VotingValidator |
2 |
PROPOSING |
@ProposingValidator |
2 |
2-Param Wrapper (Non-spending or spending without datum)
Section titled “2-Param Wrapper (Non-spending or spending without datum)”\#scriptContextData -> let #ctxFields__ = SndPair(UnConstrData(#scriptContextData)) let redeemer = HeadList(TailList(#ctxFields__)) // field 1 let result = validate(redeemer, #scriptContextData) in IfThenElse(result, Unit, Error)3-Param Spending Wrapper (with datum)
Section titled “3-Param Spending Wrapper (with datum)”Extracts datum from ScriptInfo.SpendingScript:
\#scriptContextData -> let #ctxFields__ = SndPair(UnConstrData(#scriptContextData)) let redeemer = HeadList(TailList(#ctxFields__)) // field 1 let scriptInfo = HeadList(TailList(TailList(#ctxFields__))) // field 2 let siFields = SndPair(UnConstrData(scriptInfo)) let optDatum = HeadList(TailList(siFields)) // SpendingScript field 1 let datum = HeadList(SndPair(UnConstrData(optDatum))) // unwrap Some let result = validate(datum, redeemer, #scriptContextData) in IfThenElse(result, Unit, Error)@Param Lambda Wrapping
Section titled “@Param Lambda Wrapping”Each @Param field gets an outer lambda that accepts raw Data and decodes it:
\#param1__raw -> Let(param1, UnIData(#param1__raw), \#param2__raw -> Let(param2, UnBData(#param2__raw), <validator body>))Bool → Unit/Error
Section titled “Bool → Unit/Error”All validators return boolean in Java. The wrapper converts:
true→Unit(script succeeds)false→Error(script fails, transaction rejected)
12. Phase 7: UPLC Generation and Optimization
Section titled “12. Phase 7: UPLC Generation and Optimization”12.1 UplcGenerator
Section titled “12.1 UplcGenerator”UplcGenerator lowers PIR to UPLC by eliminating named variables, Let bindings, LetRec, and typed constructs.
Let → Application:
Let(x, val, body) → Apply(Lam("x", body'), val')LetRec → Z-combinator:
The Z-combinator (strict fixed-point combinator) enables self-recursion:
fix = \f -> (\x -> f (\v -> x x v)) (\x -> f (\v -> x x v))For LetRec([name = body], expr):
- Create the Z-combinator term
- Create recursive lambda:
Lam(name, body')where body referencesname - Apply:
Apply(Lam(name, expr'), Apply(fix, Lam(name, body')))
Multi-binding LetRec (Bekic’s theorem):
UplcGenerator handles multi-binding LetRec via dependency analysis:
- Build a dependency graph between bindings
- Topological sort: non-mutual bindings are nested as single
LetRec/Letin topo order - For 2-binding mutual recursion, apply Bekic’s theorem to decompose into nested single-binding
LetRecs - Limitation: mutual recursion with >2 bindings is not yet supported
IfThenElse → Delay/Force:
IfThenElse(c, t, e) → Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), c'), Delay(t')), Delay(e')))Both branches are Delayed to prevent premature evaluation (UPLC is strict). The outer Force evaluates the selected branch.
DataConstr → ConstrData:
DataConstr(tag, type, [f1, f2])→ ConstrData(tag, MkCons(encode(f1), MkCons(encode(f2), MkNilData)))DataMatch → UnConstrData + tag dispatch: (See Section 10)
De Bruijn computation: Variables are converted from named to De Bruijn indexed. A scope stack tracks active lambda bindings. Index 1 = innermost lambda:
int deBruijnIndex(String name) { int index = 1; for (var n : scope) { if (n.equals(name)) return index; index++; } throw new CompilerException("Unbound variable: " + name);}Force count table:
Polymorphic builtins need Force wrappers to instantiate type variables:
| Forces | Builtins |
|---|---|
| 2 (∀ a b) | FstPair, SndPair, ChooseList |
| 1 (∀ a) | IfThenElse, ChooseUnit, Trace, ChooseData, MkCons, HeadList, TailList, NullList, DropList, LengthOfArray, ListToArray, IndexArray |
| 0 (mono) | All arithmetic, comparisons, Data encode/decode, SerialiseData, crypto, string/bytestring ops |
UplcGeneratorTest.ForceCountTable checks every DefaultFun against
BuiltinSemantics.Sig.typeArity; the VM’s BuiltinSemanticsCrossCheckTest checks
that metadata against runtime signatures. Experimental MultiIndexArray also
requires one force, but is unavailable to the current compiler target.
12.2 UplcOptimizer
Section titled “12.2 UplcOptimizer”The optimizer runs 6 passes in a fixpoint loop (max 20 iterations, stops when term is unchanged):
Pass 1: Force/Delay cancellation
Force(Delay(t)) → tPass 2: Constant folding
Apply(Apply(Builtin(AddInteger), Const(3)), Const(4)) → Const(7)Supported: AddInteger, SubtractInteger, MultiplyInteger, EqualsInteger, LessThanInteger, LessThanEqualsInteger, EqualsByteString, AppendByteString.
Pass 3: Dead code elimination
Apply(Lam(x, body), val) → body (when x unused in body and val has no side effects)Side-effect safety: Trace is considered side-effecting. Dead code with trace calls is preserved.
Pass 4: Beta reduction
Apply(Lam(x, body), arg) → substitute(body, arg) (when x used exactly once and arg is simple)“Simple” = Const, Var, Builtin, or Force of these. Complex args (applications) are not inlined to avoid duplicating computation.
Pass 5: Eta reduction
Lam(x, Apply(f, Var(1))) → f (when x not free in f and f is a value)The “f is a value” check prevents breaking the Z-combinator. Values = Var, Const, Builtin, Lam, Delay. Non-values = Apply (unevaluated computation).
Pass 6: Constr/Case reduction
Case(Constr(tag, [a, b]), [br0, br1, br2]) → Apply(Apply(br_tag, a), b)13. Phase 8: Library Compilation
Section titled “13. Phase 8: Library Compilation”Multi-Pass Retry Strategy
Section titled “Multi-Pass Retry Strategy”Library methods may depend on each other across compilation units. Library compilation was extracted into LibraryCompiler.java (138 lines) as part of ADR-018. JulcCompiler delegates via new LibraryCompiler(options).compile(...). The strategy is multi-pass:
1. Create progressive LibraryMethodRegistry (starts empty)2. Create CompositeStdlibLookup(stdlibRegistry, libraryMethodRegistry)3. For each pass: a. For each library CU not yet compiled: - Try to compile all static methods - If successful, register methods in LibraryMethodRegistry - If fails (unresolved reference), skip for this pass b. If no progress this pass (no new CUs compiled), stop4. Report errors for any remaining uncompiled CUsThis ensures that if library A depends on library B, B will be compiled first (possibly in an earlier pass), and A will succeed in a later pass.
LibraryMethodRegistry
Section titled “LibraryMethodRegistry”Each compiled library method is stored as a LibraryMethod(className, methodName, type, body). When looked up during validator compilation, it returns a Var reference. The actual PIR body is emitted as a Let (or LetRec for self-recursive methods) binding that wraps the validator term.
Type-aware coercion: When the caller’s argument type doesn’t match the library method’s parameter type, LibraryMethodRegistry.lookup(className, method, args, argTypes) inserts decode wrappers. For example, if the caller has DataType but the library expects IntegerType, it wraps with UnIData.
Topological Sorting of Library Methods
Section titled “Topological Sorting of Library Methods”Library methods are topologically sorted before wrapping as Let bindings:
- Build a dependency graph: method A depends on method B if A’s PIR body contains
Var("B.methodName") - Kahn’s algorithm sorts dependencies first
- Self-recursive methods (detected via
containsVarRef) are wrapped inLetRecinstead ofLet - The outermost binding has no dependencies; the innermost is the validator
LibrarySourceResolver
Section titled “LibrarySourceResolver”LibrarySourceResolver handles automatic discovery of library sources:
scanClasspathSources(ClassLoader)— ScansMETA-INF/plutus-sources/index.txtfor library entries. Falls back to filesystem directory scan in development.resolve(String source, Map<String,String> availableLibraries)— BFS traversal from root source’s imports, transitively resolving all referenced libraries.
CompositeStdlibLookup
Section titled “CompositeStdlibLookup”Chains multiple StdlibLookup implementations. First match wins:
new CompositeStdlibLookup(stdlibRegistry, libraryMethodRegistry)When the compiler encounters SomeClass.method(args):
- Try
stdlibRegistryfirst (builtins, HOFs, math) - Try
libraryMethodRegistry(compiled @OnchainLibrary methods) - If neither matches, fall through to helper method / error handling
Part III: Deep Dives
Section titled “Part III: Deep Dives”14. The Symbol Table
Section titled “14. The Symbol Table”SymbolTable (resolve/SymbolTable.java) manages lexical scoping during PIR generation.
Data Structure
Section titled “Data Structure”Deque<Map<String, PirType>> scopes; // Variable scope stackMap<String, MethodInfo> methods; // Helper method registryScope Lifecycle
Section titled “Scope Lifecycle”| Event | Operation |
|---|---|
| Constructor | Push global scope |
| Method entry | pushScope() → define parameters |
| Block statement | pushScope() → compile statements → popScope() |
| Switch case | pushScope() → define pattern variable + fields → popScope() |
| Lambda expression | pushScope() → define parameters → popScope() |
| For-each loop | pushScope() → define item + accumulators → popScope() |
The global scope is never popped.
lookup vs lookupCurrentScope
Section titled “lookup vs lookupCurrentScope”lookup(name)— Searches from innermost scope outward. ReturnsOptional<PirType>.lookupCurrentScope(name)— Searches only the innermost scope.
This distinction is critical for the field name collision fix: When accessing txOut.datum(), if there’s an outer parameter named datum, lookup("datum") would find the parameter instead of the record field. lookupCurrentScope ensures only pattern-destructured fields in the current scope are matched.
Method Registration
Section titled “Method Registration”defineMethod(String name, PirType type, PirTerm body)Registers a helper method in the methods map and also adds it to the global scope (methods are first-class values in PIR). allMethods() returns all registered methods for final Let wrapping.
15. The Type Method Registry
Section titled “15. The Type Method Registry”TypeMethodRegistry (pir/TypeMethodRegistry.java) maps (PirType, methodName) pairs to instance method handlers.
InstanceMethodHandler Pattern
Section titled “InstanceMethodHandler Pattern”@FunctionalInterfaceinterface InstanceMethodHandler { PirTerm handle(PirTerm scope, List<PirTerm> args, PirType scopeType, List<PirType> argTypes);}Each handler receives:
scope— The compiled receiver (e.g., the list, the integer)args— Compiled argument termsscopeType— The receiver’s PIR typeargTypes— Argument PIR types
ReturnTypeResolver
Section titled “ReturnTypeResolver”@FunctionalInterfaceinterface ReturnTypeResolver { PirType resolve(PirType scopeType);}Used for type inference in chained method calls. For example, list.tail() returns the same ListType as the receiver.
Registered Instance Methods
Section titled “Registered Instance Methods”IntegerType (15 methods):
| Method | Implementation |
|---|---|
abs() |
if x < 0 then 0 - x else x |
negate() |
0 - x |
max(other) |
if a < b then b else a |
min(other) |
if a <= b then a else b |
equals(other) |
EqualsInteger (with Data→Integer coercion) |
add(other) |
AddInteger |
subtract(other) |
SubtractInteger |
multiply(other) |
MultiplyInteger |
divide(other) |
QuotientInteger |
remainder(other) |
RemainderInteger |
mod(other) |
ModInteger |
signum() |
if x < 0 then -1 else if x == 0 then 0 else 1 |
compareTo(other) |
if a < b then -1 else if a == b then 0 else 1 |
intValue() |
Identity |
longValue() |
Identity |
ByteStringType (2 methods):
| Method | Implementation |
|---|---|
length() |
LengthOfByteString |
equals(other) |
EqualsByteString (with Data→ByteString coercion) |
StringType (2 methods):
| Method | Implementation |
|---|---|
length() |
LengthOfByteString(EncodeUtf8(s)) |
equals(other) |
EqualsString |
DataType / RecordType / SumType (1 method each):
| Method | Implementation |
|---|---|
equals(other) |
EqualsData |
ListType (16 methods):
| Method | Implementation | Return Type |
|---|---|---|
size() |
Foldl-based length | IntegerType |
isEmpty() |
NullList |
BoolType |
head() |
wrapDecode(HeadList(list), elemType) |
elemType |
tail() |
TailList(list) |
same ListType |
get(index) |
LetRec nth traversal | elemType |
contains(target) |
LetRec recursive search | BoolType |
prepend(elem) |
MkCons(wrapEncode(elem), list) |
same ListType |
reverse() |
Foldl-based reversal | same ListType |
concat(other) |
Recursive append | same ListType |
take(n) |
LetRec traversal | same ListType |
drop(n) |
LetRec traversal | same ListType |
map(f) |
Delegates to StdlibRegistry HOF builder via lambda inference | ListType(DataType) |
filter(pred) |
Delegates to StdlibRegistry HOF builder via lambda inference | same ListType |
any(pred) |
Delegates to StdlibRegistry HOF builder via lambda inference | BoolType |
all(pred) |
Delegates to StdlibRegistry HOF builder via lambda inference | BoolType |
find(pred) |
Delegates to StdlibRegistry HOF builder via lambda inference | elemType |
OptionalType (3 methods):
| Method | Implementation | Return Type |
|---|---|---|
isPresent() |
FstPair(UnConstrData(x)) == 0 (Some tag) |
BoolType |
isEmpty() |
FstPair(UnConstrData(x)) == 1 (None tag) |
BoolType |
get() |
wrapDecode(HeadList(SndPair(UnConstrData(x))), elemType) |
elemType |
MapType (8 methods):
| Method | Implementation | Return Type |
|---|---|---|
get(key) |
LetRec lookup, returns Optional | OptionalType(valueType) |
containsKey(key) |
LetRec search | BoolType |
size() |
generateListLength(UnMapData(map)) |
IntegerType |
isEmpty() |
NullList(map) |
BoolType |
keys() |
Foldl-based key extraction | ListType(keyType) |
values() |
Foldl-based value extraction | ListType(valueType) |
insert(k, v) |
MkCons(MkPairData(k, v), map) |
same MapType |
delete(k) |
LetRec filtering | same MapType |
16. The Stdlib Registry
Section titled “16. The Stdlib Registry”StdlibRegistry (julc-stdlib/.../StdlibRegistry.java) provides PIR term builders for ~65 standard library methods.
StdlibLookup Interface
Section titled “StdlibLookup Interface”The lookup contract includes both untyped and typed lookup methods. This excerpt shows the import-name metadata alongside them; target-aware lookup and legality requirements remain separate.
public interface StdlibLookup { Optional<PirTerm> lookup(String className, String methodName, List<PirTerm> args);
Optional<PirTerm> lookup(String className, String methodName, List<PirTerm> args, List<PirType> argTypes);
default Set<String> registeredClassNames() { return Set.of(); }}registeredClassNames() exposes importable FQCNs served by a lookup, excluding
simple-name aliases. StdlibRegistry derives these names from registrations;
composites union their delegates’ names and decorators should preserve this
metadata. Custom lookups with FQCN-only registrations should override it.
Both compiler entry points combine lookup-owned names with parsed source/type
names. This matters for the explicit-library-list overloads: they do not scan
the classpath, but registry-only methods such as ListsLib.any,
ContextsLib.trace, and MathLib.floorDiv must still resolve. Adding a name does
not bypass lookup or target-legality checks. See ADR-035 for the contract.
Registered Methods
Section titled “Registered Methods”Builtins (raw UPLC operations):
- List:
headList,tailList,nullList,mkCons,mkNilData - Pair:
fstPair,sndPair,mkPairData,mkNilPairData - Data encode:
constrData,iData,bData,listData,mapData - Data decode:
unConstrData,unIData,unBData,unListData,unMapData - Comparison:
equalsData - Error/trace:
error,trace - ByteString:
indexByteString,consByteString,sliceByteString,lengthOfByteString,appendByteString,equalsByteString,lessThanByteString,lessThanEqualsByteString,integerToByteString,byteStringToInteger,encodeUtf8,decodeUtf8,serialiseData,replicateByte,emptyByteString - Crypto:
sha2_256,blake2b_256,verifyEd25519Signature,sha3_256,blake2b_224,keccak_256,verifyEcdsaSecp256k1Signature,verifySchnorrSecp256k1Signature,ripemd_160 - Bitwise:
andByteString,orByteString,xorByteString,complementByteString,readBit,writeBits,shiftByteString,rotateByteString,countSetBits,findFirstSetBit - Data decomposition:
constrTag,constrFields - Math:
expModInteger(PV11)
ListsLib HOF methods (require lambda/LetRec — cannot be compiled from Java source):
| Method | Signature | Implementation |
|---|---|---|
any(predicate, list) |
(T→Bool, List<T>) → Bool |
Left fold, short-circuit |
all(predicate, list) |
(T→Bool, List<T>) → Bool |
Left fold, short-circuit |
find(predicate, list) |
(T→Bool, List<T>) → Optional<T> |
LetRec recursion |
foldl(f, init, list) |
((A,T)→A, A, List<T>) → A |
LetRec recursion |
map(f, list) |
(T→U, List<T>) → List<U> |
reverse(foldl(...)) |
filter(pred, list) |
(T→Bool, List<T>) → List<T> |
reverse(foldl(...)) |
zip(a, b) |
(List<T>, List<U>) → List<Pair<T,U>> |
LetRec, stops at shorter list |
Math delegates (allows Math.abs(x) syntax):
| Method | Implementation |
|---|---|
Math.abs(x) |
if x < 0 then 0 - x else x |
Math.max(a, b) |
if a < b then b else a |
Math.min(a, b) |
if a <= b then a else b |
Tracing a Stdlib Call End-to-End
Section titled “Tracing a Stdlib Call End-to-End”Example: ListsLib.foldl(fn, init, list) in user Java code:
- PirGenerator.generateMethodCall() detects scope =
ListsLib(class name) - Level 2 dispatch: Calls
stdlibLookup.lookup("ListsLib", "foldl", compiledArgs) - StdlibRegistry finds the registered
PirTermBuilderforListsLib.foldl - Builder generates a LetRec PIR pattern:
LetRec([go = \acc \lst ->IfThenElse(NullList(lst), acc,go(App(App(fn, acc), HeadList(lst)), TailList(lst)))], App(App(go, init), list))
- UplcGenerator lowers LetRec using Z-combinator
- UplcOptimizer applies Force/Delay cancellation, potentially beta-reduces
HOF Compilation Pipeline (Instance Methods)
Section titled “HOF Compilation Pipeline (Instance Methods)”Instance HOF calls like list.map(x -> x + 1) are compiled via PirHofBuilders.java:
- TypeMethodRegistry detects HOF method (
map,filter,any,all,find) on aListTypevariable - Lambda inference: Lambda parameter type is auto-inferred from the list’s element type
- PirHofBuilders generates the PIR pattern (same builders as
StdlibRegistrystatic HOFs) - hofUnwrappedVars: For
ByteStringTypeelements (e.g.,JulcList<PubKeyHash>), the lambda param is tracked as pre-unwrapped to prevent doubleUnBDatain.hash()calls - Result:
mapwraps results to Data;filter/any/all/findpreserve element types
foldl is only available as ListsLib.foldl(...) (static), not as an instance method, due to the complexity of 2-parameter lambdas with an init value.
17. Error Reporting
Section titled “17. Error Reporting”CompilerDiagnostic
Section titled “CompilerDiagnostic”public record CompilerDiagnostic( Level level, // ERROR, WARNING, INFO String message, String fileName, int line, int column, String suggestion // optional)Error Collection Strategies
Section titled “Error Collection Strategies”Non-fatal errors (recoverable — compilation continues):
// In PirGenerator:private PirTerm collectError(String message, String suggestion, Node node) { collectedErrors.add(new CompilerDiagnostic( Level.ERROR, message, fileName, line, column, suggestion)); return new PirTerm.Error(new PirType.DataType());}Returns PirTerm.Error as a placeholder. Multiple errors can be collected and reported to the user in a single compilation run.
Fatal errors (structural — compilation aborts):
private CompilerException enrichedError(String message, String suggestion, Node node) { throw new CompilerException(message + " at " + fileName + ":" + line + ":" + column + (suggestion != null ? " (suggestion: " + suggestion + ")" : ""));}Suggestion Generation
Section titled “Suggestion Generation”Fuzzy method name matching: When a method is not found, the compiler computes Levenshtein edit distance against all known methods and suggests the closest match:
ERROR MyValidator.java:15:8 - Unknown method 'filterr'. Did you mean 'filter'?Construct-specific suggestions: Each rejected construct in SubsetValidator has a tailored suggestion (e.g., “Use Optional<T> to represent absence of a value” for null).
Part IV: How-To Recipes
Section titled “Part IV: How-To Recipes”18. How to Add a New Instance Method
Section titled “18. How to Add a New Instance Method”Goal: Add list.last() returning the last element of a list.
Step 1: Register in TypeMethodRegistry (pir/TypeMethodRegistry.java):
Find the ListType section in defaultRegistry() and add:
reg.register("ListType", "last", (scope, args, scopeType, argTypes) -> { var lt = (PirType.ListType) scopeType; // LetRec: go(lst) = if NullList(TailList(lst)) then HeadList(lst) else go(TailList(lst)) var goName = "go__last"; var lstParam = new PirTerm.Var("__lst", lt); var tail = new PirTerm.App(new PirTerm.Builtin(DefaultFun.TailList), lstParam); var head = new PirTerm.App(new PirTerm.Builtin(DefaultFun.HeadList), lstParam); var nullTail = new PirTerm.App(new PirTerm.Builtin(DefaultFun.NullList), tail); var recurse = new PirTerm.App(new PirTerm.Var(goName, new PirType.FunType(lt, lt.elemType())), tail); var body = new PirTerm.IfThenElse(nullTail, PirHelpers.wrapDecode(head, lt.elemType()), recurse); var goLam = new PirTerm.Lam("__lst", lt, body); return new PirTerm.LetRec( List.of(new PirTerm.Binding(goName, goLam)), new PirTerm.App(new PirTerm.Var(goName, new PirType.FunType(lt, lt.elemType())), scope));}, scopeType -> ((PirType.ListType) scopeType).elemType());Step 2: Add off-chain stub (julc-onchain-api/.../Builtins.java or the relevant stub class):
public static <T> T last(List<T> list) { // Off-chain stub for IDE support return list.get(list.size() - 1);}Step 3: Write a test in TypeMethodRegistryTest.java or TypeMethodsTest.java:
@Testvoid testListLast() { var source = """ @SpendingValidator public class Test { @Entrypoint public static boolean validate(PlutusData redeemer, PlutusData ctx) { var items = Builtins.mkNilData(Builtins.unit()); items = Builtins.mkCons(Builtins.iData(10), items); items = Builtins.mkCons(Builtins.iData(20), items); items = Builtins.mkCons(Builtins.iData(30), items); return Builtins.unIData(items.last()) == 10; } } """; compileAndAssertTrue(source);}19. How to Add a New Builtin Function
Section titled “19. How to Add a New Builtin Function”Goal: Register a new Plutus builtin (e.g., from a Plutus version upgrade).
Step 1: Add to DefaultFun enum (julc-core/.../DefaultFun.java):
NewBuiltin(102), // CIP-XXX: descriptionThe FLAT code must match the Plutus specification exactly.
Step 2: Set force count (uplc/UplcGenerator.java):
In the forceCount() method, add:
case NewBuiltin -> 0; // or 1 or 2 depending on polymorphismStep 3: Register in StdlibRegistry (julc-stdlib/.../StdlibRegistry.java):
In registerBuiltins():
reg.register("Builtins", "newBuiltin", args -> { requireArgs("newBuiltin", args, 2); return builtinApp2(DefaultFun.NewBuiltin, args.get(0), args.get(1));});Step 4: Add off-chain stub (julc-onchain-api/.../Builtins.java):
public static PlutusData newBuiltin(PlutusData a, PlutusData b) { throw new UnsupportedOperationException("On-chain only");}Step 5: Write a compile+eval test verifying the builtin works end-to-end.
20. How to Add a New Java Type Mapping
Section titled “20. How to Add a New Java Type Mapping”Goal: Map a new Java type (e.g., Rational) to a PIR type.
Step 1: Define the PirType variant (if needed) in pir/PirType.java:
record RationalType() implements PirType {}Or use an existing type — Rational is currently mapped to DataType (opaque).
Step 2: Register in TypeResolver (resolve/TypeResolver.java):
In the resolve() method, add a case:
case "Rational" -> new PirType.DataType(); // or your new typeStep 3: Add wrapDecode/wrapEncode (pir/PirHelpers.java):
case RationalType() -> /* your decode logic */;Step 4: Handle in UplcGenerator (uplc/UplcGenerator.java):
If the new type needs special DataConstr encoding or DataMatch decoding, add cases in wrapDataEncode() and the DataMatch lowering.
Step 5: Write tests covering construction, field access, and passing the type across function boundaries.
21. How to Add a New Stdlib Library
Section titled “21. How to Add a New Stdlib Library”Goal: Create a new on-chain library (e.g., SortLib).
Step 1: Write the on-chain Java source (julc-stdlib/src/main/java/.../onchain/SortLib.java):
@OnchainLibrarypublic class SortLib { public static List<PlutusData> sort(List<PlutusData> items) { // Implementation using supported Java subset // Use while loops, for-each, Builtins.* calls }}Step 2: Write the off-chain stub (julc-onchain-api/src/main/java/.../SortLib.java):
@OnchainLibrarypublic class SortLib { public static List<PlutusData> sort(List<PlutusData> items) { throw new UnsupportedOperationException("On-chain only"); }}Step 3: If the library needs HOF methods (lambdas as parameters), register PIR builders in StdlibRegistry:
reg.register("SortLib", "sortBy", args -> { // Build PIR term with LetRec recursion});Step 4: Write tests in StdlibCompileEvalTest.java or StdlibIntegrationTest.java:
@Testvoid testSort() { var source = """ @SpendingValidator public class Test { @Entrypoint public static boolean validate(PlutusData r, PlutusData ctx) { var items = /* build list */; var sorted = SortLib.sort(items); return /* verify sorted order */; } } """; compileAndAssertTrue(source);}Step 5: Ensure the library source is discoverable by adding it to META-INF/plutus-sources/index.txt in the stdlib jar.
22. How to Add a New Ledger Type
Section titled “22. How to Add a New Ledger Type”Goal: Register a new Cardano ledger type (e.g., from a hard fork).
Step 1: Create the Java record (julc-ledger-api/src/main/java/.../):
public record NewLedgerType(BigInteger field1, byte[] field2) {}Step 2: Register in LedgerTypeRegistry (resolve/LedgerTypeRegistry.java):
Add to the appropriate tier:
typeResolver.registerLedgerRecord("NewLedgerType", List.of( new PirType.Field("field1", new PirType.IntegerType()), new PirType.Field("field2", new PirType.ByteStringType())));Step 3: Create off-chain stub (julc-onchain-api/src/main/java/.../):
public record NewLedgerType(BigInteger field1, byte[] field2) {}Step 4: Write tests verifying field access compiles and evaluates correctly:
@Testvoid testNewLedgerTypeAccess() { // Build ScriptContext with the new type, verify field extraction works}23. How to Add a New Optimization Pass
Section titled “23. How to Add a New Optimization Pass”Goal: Add a new UPLC optimization pass.
Step 1: Add the pass method in UplcOptimizer (uplc/UplcOptimizer.java):
private Term myNewOptimization(Term term) { return switch (term) { case Term.Apply(var f, var a) -> { var optF = myNewOptimization(f); var optA = myNewOptimization(a); // Your pattern matching logic here yield new Term.Apply(optF, optA); } // ... handle all Term variants recursively default -> term; };}Step 2: Register in the pass list. In runAllPasses(), add your pass in the appropriate position. Consider ordering:
- Passes that create opportunities for other passes should run earlier
- Passes that clean up artifacts should run later
- All passes run in a fixpoint loop, so cascading effects are handled
Step 3: Preserve De Bruijn indices. If your pass removes or adds lambda bindings, use shiftDown()/shiftUp() to adjust variable indices.
Step 4: Check side-effect safety. If your pass removes terms, ensure dropped terms don’t contain Trace (use hasSideEffect()).
Step 5: Write tests in UplcOptimizerTest.java:
@Testvoid testMyNewOptimization() { var input = /* construct UPLC term matching the pattern */; var expected = /* expected optimized term */; var result = optimizer.optimize(input); assertEquals(expected, result);}24. How to Trace Through a Compilation
Section titled “24. How to Trace Through a Compilation”Goal: Understand what happens when a simple validator is compiled.
Source
Section titled “Source”@SpendingValidatorpublic class SimpleValidator { @Entrypoint public static boolean validate(PlutusData redeemer, PlutusData scriptContext) { int x = 5; int y = x + 3; return y == 8; }}Phase 1: Parse
Section titled “Phase 1: Parse”JavaParser produces an AST with:
ClassOrInterfaceDeclaration“SimpleValidator” with@SpendingValidatorMethodDeclaration“validate” with@Entrypoint- Two parameters:
redeemer: PlutusData,scriptContext: PlutusData - Body: three statements (two var decls, one return)
Phase 2: Validate
Section titled “Phase 2: Validate”SubsetValidator walks the AST. No rejected constructs found.
Phase 5: Script Purpose
Section titled “Phase 5: Script Purpose”@SpendingValidator -> SPENDING. Two parameters -> 2-param wrapper.
Phase 6: Type Registration
Section titled “Phase 6: Type Registration”No user-defined records or sealed interfaces. LedgerTypeRegistry pre-registers standard types.
Phase 13-15: PIR Generation
Section titled “Phase 13-15: PIR Generation”Symbol table has: redeemer: DataType, scriptContext: DataType
Statement 1: int x = 5;
Let("x", Const(Integer(5)),Statement 2: int y = x + 3;
Let("y", App(App(Builtin(AddInteger), Var("x", IntegerType)), Const(Integer(3))),Statement 3: return y == 8;
App(App(Builtin(EqualsInteger), Var("y", IntegerType)), Const(Integer(8)))))Wrapped in entrypoint lambdas:
Lam("redeemer", DataType, Lam("scriptContext", DataType, Let("x", Const(5), Let("y", App(App(Builtin(AddInteger), Var("x")), Const(3)), App(App(Builtin(EqualsInteger), Var("y")), Const(8))))))Phase 19: Validator Wrapping
Section titled “Phase 19: Validator Wrapping”2-param spending wrapper adds ScriptContext decoding and bool→unit/error:
Lam("#scriptContextData", DataType, Let("#ctxFields__", SndPair(UnConstrData(Var("#scriptContextData"))), Let("#redeemer__", HeadList(TailList(Var("#ctxFields__"))), Let("#result__", App(App(<entrypoint>, Var("#redeemer__")), Var("#scriptContextData")), IfThenElse(Var("#result__"), Const(Unit), Error)))))Phase 21: UPLC Generation
Section titled “Phase 21: UPLC Generation”All Let bindings become Apply(Lam(...), ...). Variables become De Bruijn indices:
Lam("__scd", Apply(Lam("#ctxFields__", Apply(Lam("__red", Apply(Lam("__result", Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), Var(1)), Delay(Const(Unit))), Delay(Error)))), Apply(Apply( Lam("redeemer", Lam("scriptContext", Apply(Lam("x", Apply(Lam("y", Apply(Apply(Builtin(EqualsInteger), Var(1)), Const(8))), Apply(Apply(Builtin(AddInteger), Var(1)), Const(3)))), Const(5)))), Var(1)), // __red Var(3)))), // __scd Apply(Apply(Force(Force(Builtin(HeadList))), // ... extraction chainPhase 22: Optimization
Section titled “Phase 22: Optimization”The optimizer applies fixpoint passes:
- Constant folding:
AddInteger(5, 3)→8 - Constant folding:
EqualsInteger(8, 8)→True - Beta reduction: inline single-use variables
- Dead code elimination: remove unused let bindings
- Force/Delay cancellation:
Force(Delay(True))→True
After optimization, the validator becomes equivalent to: \ctx -> Unit (always succeeds).
Part V: Troubleshooting and Reference
Section titled “Part V: Troubleshooting and Reference”25. Known Compiler Limitations
Section titled “25. Known Compiler Limitations”No Return Inside Multi-Accumulator While Body
Section titled “No Return Inside Multi-Accumulator While Body”Root cause: The multi-accumulator while loop compilation packs accumulators into a tuple and generates a single LetRec. A return inside the body would need to escape the entire loop + tuple unpacking, which the current LetRec pattern doesn’t support.
Workaround: Use break to exit loops early, or restructure to avoid early return.
Cross-Method Type Inference
Section titled “Cross-Method Type Inference”Root cause: When method A calls helper method B with a long parameter, the compiler may generate EqualsData instead of EqualsInteger because the parameter type is inferred as DataType at the boundary.
Workaround: Use Data-level equality (EqualsData on wrapped values) or ensure helper methods use the same primitive types as callers.
@Param BytesData Bug
Section titled “@Param BytesData Bug”Root cause: @Param values are always raw Data at runtime, but declaring @Param PlutusData.BytesData tells the compiler the value is already a ByteString. This causes double-wrapping with bData() and incorrect cross-library calls.
Workaround: Always use @Param PlutusData for @Param fields. Decode manually with Builtins.unBData() if needed.
Post-While-Loop Variable Access
Section titled “Post-While-Loop Variable Access”Root cause: Variables defined before a multi-accumulator while loop may be corrupted after the loop. The LetRec transformation overwrites variable bindings in the scope.
Workaround: Extract post-loop logic into a separate helper method that receives the needed values as parameters.
Cross-Library BytesData Param Bug
Section titled “Cross-Library BytesData Param Bug”Root cause: When calling a stdlib library method that takes BytesData-typed params, if the caller also has a BytesData variable, the compiler sees matching types and skips conversion. But compiled libraries expect Data args at the UPLC boundary.
Workaround: Pass PlutusData (not BytesData) args to cross-library calls. For stdlib methods taking BytesData params, use a same-project local wrapper or ensure call-site variables are typed as PlutusData.
26. Debugging Techniques
Section titled “26. Debugging Techniques”Reading Compiler Diagnostics
Section titled “Reading Compiler Diagnostics”Diagnostics follow the format:
ERROR fileName.java:15:8 - Message text (suggestion: hint text)The CompileResult contains both the compiled Program (if successful) and a list of CompilerDiagnostic entries (errors + warnings).
Common Error Patterns
Section titled “Common Error Patterns”| Error | Likely Cause | Fix |
|---|---|---|
| “Unknown method ‘X’” | Method not in stdlib, TypeMethodRegistry, or helper methods | Check spelling, add import, or register method |
| “Unknown type: ‘X’” | Type not registered in TypeResolver | Register record/sealed interface, add to LedgerTypeRegistry |
| “Unbound variable: ‘X’” | Variable used outside its scope, or De Bruijn index wrong | Check scope push/pop lifecycle |
| “Unsupported statement” | Java construct not in supported subset | Rewrite using supported subset (see SubsetValidator) |
| “Cycle detected in type dependencies” | Mutually recursive record types | Break cycle with DataType fields |
PIR Debugging
Section titled “PIR Debugging”To inspect intermediate PIR, add a breakpoint or print statement after generateMethod() in JulcCompiler. PIR terms have a readable toString():
PirTerm pir = generator.generateMethod(entrypoint);System.out.println(pir); // Prints PIR structureUPLC Evaluation
Section titled “UPLC Evaluation”Use julc-vm-scalus to evaluate UPLC terms and inspect results:
var result = compiler.compile(source, List.of());var program = result.program();// Use VM to evaluate with test dataSize Regression Testing
Section titled “Size Regression Testing”Track script sizes across changes:
var cbor = program.toCborHex();System.out.println("Script size: " + cbor.length() / 2 + " bytes");27. Testing Patterns
Section titled “27. Testing Patterns”Test Classes (25 in julc-compiler)
Section titled “Test Classes (25 in julc-compiler)”| Test Class | Coverage Area |
|---|---|
JulcCompilerTest |
Full pipeline integration |
EndToEndTest |
Complete validator scenarios |
PirGeneratorTest |
PIR generation from Java AST |
PirTypeTest |
PirType equality and structure |
PirTermTest |
PirTerm construction |
SubsetValidatorTest |
Java subset validation |
TypeResolverTest |
Java-to-PIR type mapping |
TypeMethodRegistryTest |
Instance method dispatch |
TypeMethodsTest |
Instance method compilation + eval |
UplcGeneratorTest |
PIR-to-UPLC lowering |
UplcOptimizerTest |
Optimization passes |
SealedInterfaceTest |
Sum type compilation |
PatternMatchTest |
Switch/instanceof compilation |
HelperMethodTest |
Static helper methods |
RecursionTest |
Recursive method compilation |
LoopDesugarTest |
Loop desugaring |
StdlibIntegrationTest |
Stdlib method integration |
StdlibCompileEvalTest |
Stdlib compile + evaluate |
LedgerTypeAccessTest |
Ledger type field access |
LambdaExpressionTest |
Lambda expression compilation |
ByteStringLibTest |
ByteString operations |
BigIntegerCompatTest |
BigInteger method support |
ParameterizedValidatorTest |
@Param validators |
MultiFileCompilerTest |
Multi-file / library compilation |
LibrarySourceResolverTest |
Library source resolution |
Standard Test Pattern
Section titled “Standard Test Pattern”@Testvoid testFeature() { // 1. Define Java source var source = """ @SpendingValidator public class Test { @Entrypoint public static boolean validate(PlutusData redeemer, PlutusData ctx) { // Test logic that should return true return someCondition; } } """;
// 2. Compile var result = compiler.compile(source, List.of());
// 3. Assert compilation succeeded assertNotNull(result.program()); assertTrue(result.diagnostics().stream().noneMatch(CompilerDiagnostic::isError));
// 4. Evaluate with test data var evalResult = vm.evaluate(result.program(), testScriptContext);
// 5. Check result assertTrue(evalResult.isSuccess());}Test Types
Section titled “Test Types”Unit tests: Test individual components (TypeResolver, SymbolTable, PirHelpers) in isolation.
Integration tests: Compile Java source → assert PIR structure or UPLC evaluation result.
End-to-end tests: Full lifecycle including ScriptContext construction, validator execution, and result verification (see julc-e2e-tests/ for CIP-113 tests).
28. Architecture Decision Records
Section titled “28. Architecture Decision Records”| ADR | Title | Status |
|---|---|---|
| ADR-001 | Plutus-Java Architecture | Accepted |
| ADR-002 | Milestone 2 — V3 Ledger Types | Proposed |
| ADR-003 | Milestone 3 — Compiler MVP | Proposed |
| ADR-004 | Milestone 8 — Developer Experience | — |
| ADR-005 | Production Readiness & Stdlib Completion | Completed |
| ADR-006 | Library Discovery + Stdlib-in-Java Rewrite | Accepted |
| ADR-007 | Convert Legacy PIR to Java Source | Accepted |
| ADR-008 | Rename to JuLC | Proposed |
| ADR-009 | CIP-113 Compiler Fixes | Completed |
| ADR-010 | Beta Release Review | — |
| ADR-011 | Post-Review Fixes | — |
| ADR-012 | Compiler Core Simplification | Completed |
| ADR-013 | Beta Release Readiness Review | — |
| ADR-014 | Release Readiness Review | — |
| ADR-015 | CompileMethod Param Support | — |
| ADR-016 | Feature Roadmap Prioritization | — |
| ADR-017 | 1.0 Release Readiness Assessment | — |
| ADR-018 | PirGenerator Refactoring | Completed |
ADR files are in the adr/ directory at the project root.
29. Glossary
Section titled “29. Glossary”| Term | Definition |
|---|---|
| UPLC | Untyped Plutus Lambda Calculus — the on-chain execution language |
| PIR | Plutus Intermediate Representation — typed bridge between Java and UPLC |
| De Bruijn | Variable indexing scheme where variables reference lambda binders by distance (1 = innermost) |
| Force | Evaluate a delayed/polymorphic term |
| Delay | Defer evaluation (create a thunk) |
| Data | Universal on-chain value encoding with 5 constructors (Constr, Map, List, I, B) |
| CEK | Count-Evaluate-Kont — the Plutus virtual machine (abstract machine) |
| ScriptContext | Cardano ledger data passed to every validator (contains TxInfo, redeemer, script info) |
| SumType | Tagged union — a sealed interface with record variants |
| RecordType | Product type — a record with named typed fields |
| LetRec | Recursive let binding — used for loops and self-recursive functions |
| Accumulator | Variable modified across loop iterations (packed into Data tuples for multi-acc) |
| wrapDecode | PirHelpers method to extract a typed value from raw Data |
| wrapEncode | PirHelpers method to wrap a typed value back into Data |
| Z-combinator | Strict fixed-point combinator enabling recursion in UPLC |
| Kahn’s algorithm | Topological sort algorithm used for type registration and library ordering |
| StdlibLookup | Interface for resolving static method calls to PIR terms |
| TypeMethodRegistry | Registry mapping (PirType, method) pairs to instance method handlers |
| @OnchainLibrary | Annotation marking a class as a reusable on-chain library |
| @Param | Annotation marking a validator field as a deployment-time parameter |
| @Entrypoint | Annotation marking the main validator method |
| FLAT | Binary encoding format for UPLC programs on-chain |
| SOPs | Sums of Products — Plutus V3 constructor/case terms |
30. Quick Reference: File Index
Section titled “30. Quick Reference: File Index”| File | Lines | Role |
|---|---|---|
julc-compiler/.../JulcCompiler.java |
1,221 | Main pipeline orchestrator — 24-phase compilation |
julc-compiler/.../LibraryCompiler.java |
138 | Library compilation sub-pipeline (extracted from JulcCompiler, ADR-018) |
julc-compiler/.../CompileResult.java |
— | Compilation result (program + diagnostics + params) |
julc-compiler/.../CompilerException.java |
— | Fatal compiler error |
julc-compiler/.../CompilerOptions.java |
— | Compilation options |
julc-compiler/.../LibrarySourceResolver.java |
— | Classpath scanning + transitive BFS library resolution |
julc-compiler/.../codegen/ValidatorWrapper.java |
— | ScriptContext decoding + bool→unit/error wrapping |
julc-compiler/.../desugar/LoopDesugarer.java |
— | For-each/while → LetRec transformation |
julc-compiler/.../desugar/PatternMatchDesugarer.java |
— | Switch/instanceof → DataMatch transformation |
julc-compiler/.../error/CompilerDiagnostic.java |
— | Diagnostic record (level, message, location, suggestion) |
julc-compiler/.../error/DiagnosticCollector.java |
— | Structured error collection with source location |
julc-compiler/.../pir/PirGenerator.java |
2,147 | Java AST → PIR — core expression/statement compilation |
julc-compiler/.../pir/LoopBodyGenerator.java |
531 | Loop body compilation — 5 paths × break/no-break (extracted, ADR-018) |
julc-compiler/.../pir/AccumulatorTypeAnalyzer.java |
432 | Accumulator type analysis — pair list detection heuristics (extracted, ADR-018) |
julc-compiler/.../pir/TypeInferenceHelper.java |
282 | Read-only type inference — resolveExpressionType, inferPirType (extracted, ADR-018) |
julc-compiler/.../pir/TypeMethodRegistry.java |
905 | Instance method dispatch (~50 methods across 11 types) |
julc-compiler/.../pir/PirHelpers.java |
356 | wrapDecode/wrapEncode + list length/contains/foldl + blockStmts |
julc-compiler/.../pir/PirHofBuilders.java |
244 | HOF PIR builders (map, filter, any, all, find, foldl, zip) |
julc-compiler/.../pir/CompositeStdlibLookup.java |
— | Chains multiple StdlibLookup instances (first match wins) |
julc-compiler/.../pir/PirTerm.java |
— | PIR term AST (12 variants) |
julc-compiler/.../pir/PirType.java |
— | PIR type system (13 variants) |
julc-compiler/.../pir/StdlibLookup.java |
— | Interface for stdlib method resolution |
julc-compiler/.../pir/PirFormatter.java |
— | PIR pretty-printing |
julc-compiler/.../pir/PirSubstitution.java |
— | PIR variable substitution |
julc-compiler/.../resolve/TypeResolver.java |
— | Java → PIR type mapping |
julc-compiler/.../resolve/TypeRegistrar.java |
— | Topological type registration (Kahn’s algorithm) |
julc-compiler/.../resolve/SymbolTable.java |
— | Scope stack for variable/method management |
julc-compiler/.../resolve/LedgerSourceLoader.java |
— | Dynamic ledger type loading from META-INF |
julc-compiler/.../resolve/LibraryMethodRegistry.java |
— | Compiled library method storage + typed coercion |
julc-compiler/.../resolve/ImportResolver.java |
— | Import resolution |
julc-compiler/.../uplc/UplcGenerator.java |
— | PIR → UPLC lowering (Let→Apply, LetRec→Z-combinator) |
julc-compiler/.../uplc/UplcOptimizer.java |
— | 6-pass UPLC optimizer with fixpoint iteration |
julc-compiler/.../validate/SubsetValidator.java |
— | Java subset enforcement |
julc-compiler/.../util/MethodDependencyResolver.java |
— | Method dependency graph construction |
julc-compiler/.../util/StringUtils.java |
— | String utilities (Levenshtein distance, etc.) |
julc-core/.../DefaultFun.java |
— | 101 released Plutus builtins (tags 0-100) plus future MultiIndexArray (tag 101) |
julc-core/.../Term.java |
— | UPLC term AST (10 variants) |
julc-stdlib/.../StdlibRegistry.java |
— | PIR term builders for ~65 stdlib methods |
