A compiler whose output is meant to be read by the person who inherits it.
Most transpilers optimise for producing something that runs. This one optimises for producing something a developer will own for the next decade — which changes almost every design decision underneath it. The pipeline, the intermediate representation, the runtime boundary and the constraints they impose are set out here in full, so you can judge the engineering rather than take it on trust.
Readable output is the constraint, and it is what keeps your options open.
There are two ways to build a 4GL-to-Java translator. One emits whatever runs and treats the Java as a build artefact nobody reads. The other emits Java a developer can review — and therefore take over, whenever the team decides to.
This compiler is built for the second, and the reason is optionality rather than ideology. Keep authoring 4GL and let the build translate it for as long as that suits you ; take the Java over and maintain it by hand ; or run both while a transition lasts. Output nobody can read forecloses that choice on day one, before anyone knows enough to make it. The constraint it imposes is that the result has to survive code review by somebody who never wrote a line of 4GL.
Structure survives
One .4gl module becomes one Java class. One FUNCTION becomes one method, in source order. Nothing is merged, split or reordered — so a reviewer can diff generated output against the 4GL original and follow it line for line.
Comments and names survive
All three 4GL comment forms are carried on a hidden token channel rather than discarded, so they are re-emitted in place. Function, variable and parameter names map across unchanged unless they collide with a Java keyword, and then by a documented, stable rule — a regenerated file must not churn names. The module name is the one exception : it is capitalised to satisfy Java’s class-naming convention, so credit.4gl becomes Credit.java.
The runtime is the seam
Generated code stays deliberately thin and calls into a runtime library that reproduces 4GL semantics. Inlining that behaviour to make the output look more "native" is precisely what makes twenty thousand lines of generated Java unreviewable.
Deterministic by requirement
The same input produces the same output — no hash-ordered iteration, no incidental reordering. Without it a regenerate-and-review workflow drowns in spurious diffs. The one intentional exception is the header, which records when each file was produced, so nobody has to guess whether the Java in front of them predates a change to the 4GL.
How a program is compiled
Why an IR of sealed records, and why emission is written by hand.
The intermediate representation is built from Java 21 sealed interfaces
and records, with pattern-matching switch in every pass.
That buys exhaustiveness checking from the compiler itself: when a new
statement kind is added, the build lists every pass that must now
handle it. Across a language surface as broad as 4GL, that property is
worth more than any amount of test coverage bolted on afterwards.
Emission is a hand-written emitter rather than a template engine. A template engine is the obvious choice for output whose shape is known in advance, and a translated 4GL program is not that: the emitter has to decide, statement by statement, whether a local must be boxed because a dialog callback captures it, what decimal scale an expression carries, and which of several record shapes a target list refers to. Those are decisions taken from the IR while emitting, and they are clearer as ordinary Java than as template logic pushed into a second language.
What the reviewer gets instead of a source map is structure that did not
move. One module becomes one class, one FUNCTION becomes
one method in source order, and all three 4GL comment forms are carried
on a hidden token channel and re-emitted in place. The answer to
where did this come from is the corresponding position in the
original file — which is a stronger guarantee than a mapping table,
because it survives being read by a human.
Java is the more verbose language. The compiler is what closes the gap.
4GL is a smaller language than Java, and an honest migration argument has
to start by conceding it. A 4GL function declares its parameters without
types, returns whatever it likes, and says owed + order_total
where Java needs to know whether that is integer arithmetic, floating
point, or a decimal carrying two places of scale.
A naive translator resolves that by giving up : every parameter becomes
Object, every return becomes Object, and the
body fills with unboxing. It runs, and it is unreadable — which for a
codebase your team is about to own for a decade is the same as failing.
So the compiler does the analysis instead. Parameter types are recovered
from the DEFINE block that follows the signature ; the return
type is inferred from every RETURN statement in the function
and narrowed to the most specific type that covers them all ; decimal
scale is carried from the column or the declaration through each
expression that touches it. The result is a signature a Java developer
would have written by hand.
A credit-limit rule, before and after
A real pair : the 4GL on the left was written against the Informix demonstration schema and the Java on the right is the compiler’s verbatim output for it, not an illustration. The rule is the point — whether an order breaches a customer’s credit limit, expressed once, in a function, and written down nowhere else. The function keeps its name, its parameter names and every one of its comments, in place — 4GL has three comment forms and all three are carried across. The SQL crosses over intact with the host variable become a bind parameter. What does not become idiomatic Java is deliberate : a MONEY(10,2) keeps its precision and scale as a declared type, and 4GL arithmetic, comparison and NULL semantics resolve to named runtime calls, because Java does not mean the same things by +, > and == on a money value that may be null.
FUNCTION check_credit_limit(cust_num, order_total, credit_limit)
DEFINE cust_num INTEGER,
order_total MONEY(10,2),
credit_limit MONEY(10,2),
owed MONEY(10,2)
-- Would this order put the customer past their credit limit?
-- The rule lives here and nowhere else: one function, called from
-- the order entry screen and from the nightly batch alike.
# What the customer already owes us: every unpaid order, summed.
# Unpaid means paid_date IS NULL - there is no status column.
SELECT SUM(i.total_price) INTO owed
FROM orders o, items i
WHERE o.customer_num = cust_num
AND o.order_num = i.order_num
AND o.paid_date IS NULL
-- SUM over no rows is NULL, not zero. A customer with no unpaid
-- orders owes nothing, so the NULL has to become zero here or the
-- comparison below silently fails.
IF owed IS NULL THEN
LET owed = 0
END IF
{ The rule itself. Note it is >, not >=: landing exactly on the limit is allowed. }
IF owed + order_total > credit_limit THEN
RETURN "BLOCKED"
END IF
RETURN "OK"
END FUNCTION/**
* Translated from {@code FUNCTION check_credit_limit}.
*
* <p>Source: {@code credit.4gl} line 9.
*
* @param cust_num 4GL INTEGER
* @param order_total 4GL MONEY(10,2)
* @param credit_limit 4GL MONEY(10,2)
* @return the value of the 4GL {@code RETURN} statement, or {@code null} if it returns nothing
*/
public String check_credit_limit(Integer cust_num, BigDecimal order_total$in, BigDecimal credit_limit$in) {
// DEFINE
FglDecimal order_total = FglDecimal.money(10, 2).set(order_total$in);
FglDecimal credit_limit = FglDecimal.money(10, 2).set(credit_limit$in);
FglDecimal owed = FglDecimal.money(10, 2);
// END DEFINE
// Would this order put the customer past their credit limit?
// The rule lives here and nowhere else: one function, called from
// the order entry screen and from the nightly batch alike.
// What the customer already owes us: every unpaid order, summed.
// Unpaid means paid_date IS NULL - there is no status column.
fgl.query("""
SELECT SUM(i.total_price)
FROM orders o, items i
WHERE o.customer_num = ?
AND o.order_num = i.order_num
AND o.paid_date IS NULL
""", cust_num).into(owed);
// SUM over no rows is NULL, not zero. A customer with no unpaid
// orders owes nothing, so the NULL has to become zero here or the
// comparison below silently fails.
if (Fgl.isNull(owed)) {
owed.set(0);
}
// The rule itself. Note it is >, not >=: landing exactly on the limit is allowed.
if (Fgl.gt(Fgl.plus(owed, order_total), credit_limit)) {
return "BLOCKED";
}
return "OK";
}Parameters keep their types
4GL declares parameter names in the signature and their types in the DEFINE block below it. Both are read, so cust_num arrives as an Integer and a MONEY(10,2) becomes a declared decimal that carries its own precision and scale — not Object with a cast on the first line of the body.
Return types are inferred
The return type comes from analysing every RETURN in the function and taking the most specific type that covers them all. A function that only ever returns text is declared to return String, so its callers type-check at compile time rather than failing in production.
Decimal scale is carried, not guessed
A MONEY(10,2) stays a two-place decimal through every expression it enters, because the scale is read from the catalogue at translation time and applied at each step. Rounding differences in financial totals are the defects a migration cannot afford, and they are the hardest to find after go-live.
Every function says where it came from
The generated javadoc names the 4GL file and line, and records each parameter’s original 4GL type. A reviewer comparing the two never has to search for the original.
Money arithmetic that still adds up.
4GL was designed for commercial computing, so it does the arithmetic an
ERP needs correctly and without being asked. MONEY(10,2) is a
decimal with two places, it stays a decimal with two places through every
expression it enters, and rounding happens where the type says it happens.
A developer writing unit_price * qty in 4GL does not have to
think about it, and for thirty years has not had to.
Java can do this arithmetic — BigDecimal exists — but
nothing does it by default : Java’s operators and primitive
numeric types do not carry Informix’s declared precision, scale,
rounding and NULL semantics, so a translator must
reproduce them explicitly at every operation. A translator that
instead maps MONEY to double produces a
program that runs, passes a smoke test, and is wrong by a cent
somewhere in the ledger. That
class of defect does not announce itself. It surfaces as a reconciliation
that will not balance, months after go-live, in a system nobody is
willing to change quickly.
So decimal handling is not a detail of this compiler ; it is one of the
reasons it exists. Precision and scale are read from the catalogue at
translation time, carried through every operation, and applied at each
assignment. Arithmetic resolves to named runtime operations rather than
Java operators, because + on two decimals has to mean what
Informix means by it, including how it treats null.
The same three lines, and why the type matters
A worked case in the arithmetic every invoice performs : a price, a quantity, a tax rate. The 4GL is on the left and the compiler’s output is on the right. Read the right-hand column for what is not there — no floating point, no manual rounding call, no scale argument a developer has to remember to pass. The declared type carries its own precision and scale, so every assignment rounds where Informix rounds.
DEFINE unit_price MONEY(10,2),
qty INTEGER,
line_total MONEY(10,2),
vat MONEY(10,2),
gross MONEY(10,2)
LET unit_price = 0.10
LET qty = 3
LET line_total = unit_price * qty
LET vat = line_total * 0.21
LET gross = line_total + vatFglDecimal unit_price = FglDecimal.money(10, 2);
Integer qty = 0;
FglDecimal line_total = FglDecimal.money(10, 2);
FglDecimal vat = FglDecimal.money(10, 2);
FglDecimal gross = FglDecimal.money(10, 2);
unit_price.set("0.10");
qty = 3;
line_total.set(Fgl.times(unit_price, qty));
vat.set(Fgl.times(line_total, new BigDecimal("0.21")));
gross.set(Fgl.plus(line_total, vat));What those three lines produce
| Mapped to Java double, as a naive translation does | Translated by this compiler | |
|---|---|---|
| line_total | 0.30000000000000004 | ✓ 0.30 |
| vat | 0.063 | ✓ 0.06 |
| gross | 0.36300000000000004 | ✓ 0.36 |
| On an invoice | Cents appear and disappear ; totals do not reconcile against the ledger | ✓ Identical to what the 4GL produced yesterday |
| When you find out | After go-live, in a period close, with no obvious cause | ✓ Before cut-over : arithmetic is compared against the original binaries on real data |
One boundary, one direction of dependency
The only division that survived scrutiny is what a migrated application ships against, versus what only the build needs. An earlier structure of nine components was collapsed to this : most of those boundaries described how the code was written rather than anything a customer deploys.
The runtime — what your application links against
The Lanterna dialog and screen engine, the 4GL type system, the report driver, the .per form compiler and catalogue access. This is the larger half of the project by a wide margin, and deliberately so: a runtime that reproduces 4GL semantics exactly is what allows the generated code above it to stay thin and readable.
The compiler — what only the build needs
The 4GL grammar, the parse facade and diagnostics, the sealed IR, the code generator and program linker, and the command-line front end. Your deployed application never sees any of it. A thin Maven plugin sits beside it so the same translator can run inside an ordinary build — see build and package.
The arrow runs one way
The compiler depends on the runtime; never the reverse. Anything that would invert it — a runtime needing the emitters — is a design error, and is treated as one.
The screen engine, and why it is hand-written.
4GL is absolute-positioned character cells on a fixed 80×24 grid with reserved lines for messages, errors and prompts. General-purpose terminal layout managers actively fight that model, so the field engine is written directly against the low-level screen API instead.
The consequences are the kind of detail that decides whether a
migration is accepted: values are clipped to the field's width
rather than the value's, the form is clipped so it can never scribble
over the reserved lines, and reserved-line positions are data — so
OPTIONS MESSAGE LINE n means what it says. Each of those is
pinned by a test, because each is the sort of thing users notice
immediately and specifications never record.
A screen form, before and after
A real pair from the reference corpus. The .per format is a character grid : the field positions exist only as the column at which a bracket appears in the ASCII art. The form compiler recovers that geometry and re-expresses the screen as a fluent builder — so a screen that needs changing after the migration is edited in Java, by a developer who has never seen a .per file, rather than in a format the team has left behind.
DATABASE stores
SCREEN
{
[h ]
Code Topic What it shows
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
[a0][f001 ][f002 ]
}
TABLES
customer
ATTRIBUTES
h = FORMONLY.heading, COLOR = CYAN;
a0 = FORMONLY.code;
f001 = FORMONLY.title, COLOR = YELLOW;
f002 = FORMONLY.note;
INSTRUCTIONS
SCREEN RECORD s_topics[10]
(FORMONLY.code, FORMONLY.title, FORMONLY.note)public static Form fPicker() {
return FormBuilder.named("f_picker")
.database("stores")
.table("customer")
.line(3, " Code Topic What it shows")
.field("h")
.at(1, 2).width(77)
.formOnly("heading")
.colour("CYAN")
.add()
.field("a0")
.at(4, 4).width(2).rows(10)
.formOnly("code")
.add()
.field("f001")
.at(4, 8).width(22).rows(10)
.formOnly("title")
.colour("YELLOW")
.add()
.field("f002")
.at(4, 32).width(46).rows(10)
.formOnly("note")
.add()
.screenRecord("s_topics", 10, "FORMONLY.code", "FORMONLY.title", "FORMONLY.note")
.build();
}Fidelity is measured against the real compiler.
The principal risk of any source migration is that the translation is subtly unfaithful, so it is treated as an engineering measurement. Translated programs run against a real Informix 7.51 installation alongside the original 4GL binaries, on the same data, and the output is compared byte for byte. A difference is a defect.
Alongside that sit a corpus parse gate that must stay at zero
diagnostics, a ratcheted ambiguity budget on the grammar, form-compiler
tests over real .per files, schema-resolution tests over
live LIKE references, and a feature suite that asserts
behaviour against live data. In an assessment you see the current
measurements against your own programs, not a rounded claim.
Fidelity, coverage and what you depend on.
How the arithmetic is proven, what happens at the edges of the language, and what the running application actually rests on.
How do we know the money arithmetic is right?
Because it is compared, not asserted. Decimal precision and scale are read from the catalogue at translation time and carried through every operation, and arithmetic resolves to named runtime operations that reproduce Informix semantics rather than to Java operators. The result is then run against the original 4GL binaries on the same data and compared. This is the defect class a migration can least afford — a cent that appears in a total is not found by a smoke test — so it is measured rather than reasoned about.
What happens when a construct needs a human decision?
It is marked in place, and the file translates and compiles around it — the surrounding statements are unaffected, and the marker identifies itself clearly. Every marker is counted per program, so the work is a number you can plan and price against from the start. That is a deliberate design choice : a translator that stops on the first construct it wants help with turns a measurable piece of work into an open-ended one.
How is coverage extended?
By measured frequency in real estates rather than by tidiness — the constructs that appear most often in production 4GL are implemented first, because supporting eight statements in ten is not the same as running eight programs in ten. The reference corpus translates and compiles in full, reports included, and every extension is held to the same standard : compiling is not the same as behaving identically, so fidelity is proven separately against the original binaries before it counts as done.
What is the stack, and what does the application depend on at runtime?
A generated parser for both the 4GL grammar and the form language, a hand-written Java emitter, Lanterna for the terminal screen engine, JDBC for database access, Maven for the optional in-build translation, and JUnit 5 with a Python-driven feature suite for testing. The build targets Java 21 — sealed types, records and pattern-matching switch in the compiler; a current LTS for the applications it produces. Note the split : that toolchain is what the compiler needs. What your deployed application depends on is a JDK and the runtime library, and nothing more.
Does the terminal UI have to stay?
No, but it is preserved by default, and separating the two decisions is deliberate. Removing the language dependency and changing the user interface are different projects with different risks. Once the logic is ordinary Java, putting an HTTP or API surface in front of it is normal work rather than a second migration.
What the output reads like.
The Java you will be reviewing, why it reads the way it does, and what happens to the constructs that do not map cleanly.
What does the generated Java actually look like?
Ordinary Java, structured like your 4GL. One class per module, one method per function, in source order, with the original comments carried across in place and a header naming the source file it came from. It calls a runtime library for 4GL semantics rather than inlining them — that is what keeps it legible at scale. The fastest way to judge this is to see your own code translated.
Why is the generated Java readable at all? Java is the more verbose language.
Because the compiler resolves what a naive translator erases, and keeps the shape of the program it was given. Parameter types come from the DEFINE block, return types are inferred from every RETURN, decimal precision and scale are carried as declared types, and names, statement order and all three 4GL comment forms stay in place — SQL included, emitted as a text block laid out the way it was written. The Java reads beside the original almost operation for operation.
Only the arithmetic is deliberately unidiomatic : a MONEY(10,2) stays a declared money type and 4GL comparison and NULL handling become named runtime calls, because Java does not mean the same things by +, > and == on a money that may be null.
The alternative — everything typed as Object, every statement wrapped in casts, every query escaped onto one line — produces something that runs and that nobody can review. For a codebase your team is about to own, those are the same outcome.
The same front end drives a second backend.
Everything above — the parser, the sealed-record intermediate
representation, LIKE resolution against the live
catalogue, module linking — stops one step short of Java. What
consumes the representation after that is replaceable, and there is
now a second consumer of it : instead of emitting Java source, it
builds executable nodes and runs the program on GraalVM as a
language of its own.
That is why the split is worth the architecture it costs. Two backends over one front end means 4GL is understood once, and the two backends cannot drift on what the language means : they call the same runtime helpers, place SQL placeholders through the same binder, and are tested against each other on the same programs. See 4GL on GraalVM for the execution model, the embedding contract and the measured gaps.