- **FREE, DCL-S (CHAR, VARCHAR, INT, PACKED, ZONED, IND, DATE, TIME, TIMESTAMP, POINTER, UNS, FLOAT, BINDEC)
- DCL-S keywords: DIM, DIM(*VAR:max), DIM(*AUTO:max), LIKE, INZ, CONST, STATIC, TEMPLATE, BASED, ASCEND, DESCEND
- DCL-C, DCL-F (stub), DCL-DS/END-DS (QUALIFIED, DIM, DIM(*VAR), LIKEDS, OVERLAY, POS, PREFIX, EXTNAME stub)
- DCL-SUBF, DCL-PARM
- DCL-PR/END-PR, DCL-PROC/END-PROC, DCL-PI/END-PI (VALUE, return types, LIKEDS params)
- DCL-ENUM/END-ENUM (QUALIFIED) (Test 71)
- EXPORT, IMPORT, EXTPGM, EXTPROC, NOMAIN (Tests 48/49)
- OPTIONS(*NOPASS) (Test 46), OPTIONS(*OMIT) (Test 64)
- EVAL, EVAL-CORR, EVALR, DSPLY, RETURN, CALLP, LEAVESR
- IF/ELSEIF/ELSE/ENDIF, DOW/ENDDO, DOU/ENDDO
- FOR/ENDFOR (TO, DOWNTO, BY), FOR-EACH (Test 58)
- SELECT/WHEN/OTHER/ENDSL, ITER, LEAVE
- MONITOR/ON-ERROR/ENDMON, BEGSR/ENDSR/EXSR
- RESET, CLEAR, SORTA, DEALLOC
- ON-EXIT (Test 42), TEST(D/T/Z) (Test 47)
- XML-INTO (direct mode, %XML with case options) (Test 87)
- XML-INTO Phase 2: array DS, PATH option, nested DS/LIKEDS subfields (Tests 88-89)
- *INZSR (Test 37), *PSSR (Test 66/67)
- Arithmetic: +, -, *, /, ** (exponentiation, Test 68)
- Comparison: =, <>, <, >, <=, >=
- Logical: AND, OR, NOT
- IN operator (Test 58)
- String: %CHAR, %TRIM, %TRIML, %TRIMR, %LEN, %SUBST, %SCAN, %SCANR (Test 69), %SCANRPL, %XLATE, %REPLACE, %CHECK, %CHECKR, %LOWER, %UPPER, %SPLIT, %CONCATARR, %CONCAT (Test 74), %LEFT, %RIGHT, %STR, %EDITC, %EDITW
- Numeric: %DEC, %INT, %FLOAT, %UNS, %INTH, %DECH, %DECPOS, %SQRT, %ABS, %DIV, %REM, %EDITFLT (Test 70), %UNSH (Test 70), %PARMNUM (Test 71)
- Array: %ELEM, %ELEM(arr)=n (Test 72), %LOOKUP, %LOOKUPLT/GE/LE/GT (Test 52), %TLOOKUP/LT/GT/LE/GE (Test 75), %XFOOT, %SUBARR, %MAXARR, %MINARR, %LIST, %RANGE, %SIZE
- Bitwise: %BITAND, %BITNOT, %BITOR, %BITXOR (Test 68)
- Date/Time: %DATE, %TIME, %TIMESTAMP, %DIFF, %DAYS, %MONTHS, %YEARS, %HOURS, %MINUTES, %SECONDS, %MSECONDS, %SUBDT
- Memory/Pointer: %ALLOC, %REALLOC, %ADDR, %PADDR, %PROC, %STR
- Other: %PARMS, %STATUS, %ERROR, %FOUND, %EOF, %PASSED, %OMITTED, %MAX, %MIN, %GETENV (Test 85), %FKEY (Test 353), %MATCHES/%FIND/%COUNTMATCHES (Test 362), %IF (Test 386), %COMPCORR (Test 411), %REPEAT (Test 418), %PROGNAME (Test 422), %TOHEX/%HEX/%FROMHEX (Test 435)
| Code | Description | Applicable |
|---|---|---|
| 0 | No error | ✅ |
| 100 | Value out of range for string operation | ✅ |
| 101 | Negative square root | ✅ |
| 102 | Divide by zero | ✅ |
| 103 | Intermediate result overflow | ✅ |
| 104 | Float underflow | ✅ |
| 112 | Invalid date/time/timestamp value | ✅ |
| 113 | Date overflow/underflow | ✅ |
| 114 | Date mapping errors | ✅ |
| 115 | Variable-length field has invalid length | ✅ |
| 120 | Table/array out of sequence | N/A (legacy) |
| 121 | Array index not valid | ✅ |
| 122 | OCCUR value not valid | N/A (legacy) |
| 202 | Called program failed | ✅ |
| 211 | Error calling program | ✅ |
| 221 | Called program not found | ✅ |
| 222 | Pointer/parameter error | ✅ |
| 231 | Called program halted | ✅ |
| 232 | Halt indicator on in called program | ✅ |
| 233 | Halt indicator on when RETURN | ✅ |
| 299 | RPG IV runtime error | ✅ |
| 301-399 | File I/O errors | N/A (using SQL) |
| 401 | Data area not found | ✅ |
| 402 | Data area type/length mismatch | N/A (file-based, no schema) |
| 411 | Data area not locked | N/A (locking is a no-op) |
| 412 | Data area lock error | N/A (locking is a no-op) |
| 413 | Error updating data area | ✅ |
| 414 | User not authorized to data area | N/A (IBM i user profiles) |
| 415 | Error accessing data area | ✅ |
| 421 | Error calling DSPLY | ✅ |
| 431 | Error calling SND-MSG | N/A (IBM i) |
| 450 | Character conversion error | ✅ |
| 500 | Failure to allocate storage | ✅ |
| 802 | Failure in sort | ✅ |
| 803 | Error during dump | N/A (IBM i) |
| 804 | Error in *PSSR | ✅ |
| 907 | Decimal data error | ✅ |
| 1021-1026 | XML parser errors | N/A (IBM i) |
| 1211-1299 | XML-SAX/XML-INTO errors | N/A (IBM i) |
| 9001 | Program exception | ✅ |
| 9999 | General ILE RPG error | ✅ |
- Date: *ISO, *USA, *EUR, *JIS, *MDY, *DMY, *YMD, *JUL, *CYMD, *CMDY, *CDMY, *LONGJUL
- Time: *HMS, *ISO, *USA, *EUR, *JIS
- Timestamp: *ISO, *ISO0
- On
MOVE/MOVELa format may also carry its separator (*MDY/,*MDY-,*MDY.,*MDY,,*MDY&) or a trailing0for none at all (*MDY0,*ISO0) — Tests 208-211.*JOBRUNis refused (Test 213).
- *BLANKS, *ZEROS, *HIVAL, *LOVAL, *ON, *OFF, *NULL, *INLR, *ALL'x', *OMIT
- *USER — Current user profile (Test 101)
- Quoted strings:
'text'with''escape for embedded apostrophe - Hex literals:
X'0A0D'— converted to byte string at compile time (Test 112)
- *IN01-*IN99 — Fully functional
- *INLR — Compatibility only (accepted/ignored; no RPG cycle to end)
- /COPY, /INCLUDE, /DEFINE, /UNDEFINE, /IF DEFINED, /IF NOT DEFINED, /ELSE, /ELSEIF, /ENDIF
- /EOF, /EJECT, /SPACE, /SET, /RESTORE, /TITLE (compatibility)
- NOMAIN (Test 48), MAIN(procname) (Test 56)
- DATFMT(fmt), TIMFMT(fmt) (Test 57)
- DFTACTGRP, ACTGRP, OPTION, DEBUG, DECEDIT, CCSID, TEXT, THREAD, COPYRIGHT — accepted/ignored
- None open. The last one, an array of LIKEDS subfields
(
sub LIKEDS(t) DIM(n)), was fixed 2026-10-27 (tests 447-448):DIMgoes before or afterLIKEDSorLIKE,%SIZEof a data structure is its length in bytes rather than the C++ object's size, andCLEARtakes a subfield or element (CLEAR ds.sub,CLEAR ds.sub(i)). The 26-vote idea ofLIKEthrough such an array,LIKE(ds.arr.field), needs IBM i to accept it first.
| # | Feature | Section |
|---|---|---|
| 1 | ✅ Operation extenders (E), (H), (N), (M), (R), (P) | Opcodes |
| 2 | ✅ DUMP opcode (debug) | Opcodes |
| 3 | Opcodes | |
| 4 | ✅ IN / OUT — Data area operations | Opcodes |
| 5 | ✅ *USER — Current user profile figurative constant | Constants |
| 6 | ✅ PSDS — Program Status Data Structure | DS Keywords |
| 7 | ✅ %ELEM(*ALLOC) / %ELEM(*KEEP) — Varying array control | BIFs |
| 8 | ✅ SND-MSG — Message operations (7.5+) | Modern |
| 9 | ✅ DATA-INTO / DATA-GEN (%DATA) | Modern |
| 10 | ✅ OVERLOAD — Overloaded procedures (7.4+) | Modern |
| 11 | ✅ Data area operations (IN/OUT/UNLOCK, DTAARA, *LDA/*GDA/*PDA) | Data Areas |
| 12 | ✅ SQL indicator variables (:var :ind) |
Embedded SQL |
| 13 | ✅ RPG status codes (full %STATUS value set) | Error Handling |
DCL-F, CHAIN, READ, WRITE, UPDATE, DELETE, SETLL, SETGT, READE — implemented via ODBC (Tests 103-108)
A second, column-based frontend alongside free-format — real legacy RPG is
often still fixed-format H/F/D/C specs, sometimes with C-spec logic already
modernized into /free.../end-free blocks while H/F/D stay fixed-format
(a common incremental-modernization pattern). The AST and codegen are
already format-agnostic, so this is purely a new parsing frontend — every
existing language feature becomes reachable from fixed-format source once
it builds the same AST nodes, no codegen changes needed. Source format is
content-sniffed (columns 1-6 of the first substantive line), not extension-
gated — **FREE was never a hard mode switch here either.
- H-spec (control options — MAIN/NOMAIN/DATFMT/TIMFMT, same as CTL-OPT)
- F-spec (externally-described DISK/WORKSTN files; keyword-tail continuation)
- D-spec (standalone fields; basic non-OCCURS DS/subfields; name continuation via trailing
...) - C-spec via
/free.../end-freeonly — the block body is parsed by the existing free-format lexer/parser unchanged (re-invoked via flex'syy_scan_string), not a new C-spec grammar - Implemented via a hand-written column-slicer (
src/fixed_columns.h,src/fixed_reader.cpp), not a new flex/bison grammar (Tests 116-119) - Deliberately excluded from Phase 1 (see "Legacy / Fixed-Format" below for what's rejected outright vs. deferred): traditional fixed- column C-spec, extended-factor-2 C-spec, I-specs, O-specs — all real engineering, deferred rather than rejected; revisit as a later phase if there's demand
Closes D-spec gaps left basic in Phase 1 — all pure fixed-format parsing
gaps (same thesis as Phase 1: the AST/codegen already supported every one
of these via the free-format grammar, so fixed_reader.cpp is the only
file this phase touches). Motivated by a concrete pain point Phase 1 hit in
its own testing: test118's DISK CHAIN needed an integer key instead of
the natural VARCHAR key, since fixed-format D-spec had no way to declare
one.
VARYING{(2|4)}keyword-tail keyword →RPGType::VARCHAR(standalone fields and DS subfields) — verified against SC09-2508'sVARYING{(2|4)}entry: keyword-tail only, "not used in a free-form definition" (that's whatVARCHAR(n)is for), applies uniformly regardless of standalone- vs-subfield context. Test122 rebuilds Phase 1's test118 DISKCHAINmilestone with a realVARCHARkey, closing that gap.DIM(n)on aDclDSitself (array of DS elements, e.g.items(1).qty)- Per-subfield
OVERLAY(field),OVERLAY(field:pos),POS(n),LIKEDS(name) - Found but out of scope: per-subfield
LIKE(...)and per-subfieldDIM(...)(an array within one subfield) have no grammar support in free-format either, andDSFieldhas no fields to hold them — extending these would mean growing the free-format language surface itself, not just this frontend. Deferred as a follow-on, not silently dropped. - Tests 120-124. Note: DS-subfield
CHARvsVARCHARcurrently has no differentiated codegen behavior in this compiler (true for free-format DS subfields too — confirmed by code search, not a fixed-format-specific gap) — test121 is a round-trip check, not a type-distinction check like test120's standalone-field version.
Goal shifted from "cover the common cases" (Phases 1-2) to accepting most
real fixed-format RPG IV source, not just H/F/D specs paired with already-
modernized /free C-spec logic. Ranked by how much real legacy source each
item unblocks, most-needed first:
1. Traditional / extended-factor-2 fixed-column C-spec ✅ (V1).
Implemented via a transpile-and-bridge design, not a new grammar:
src/fixed_cspec.h/.cpp transpiles each native C-spec line (Factor1/
Opcode/Factor2/Result, or Extended Factor 2's whole-expression style) into
its exact free-form-equivalent text, buffers a contiguous run of them
exactly like an implicit /free block, and flushes through the same
parse_free_block() bridge Phase 1 already built for explicit
/free.../end-free — zero new AST nodes, zero new bison grammar, no
hand-rolled expression parser. Verified against SC09-2508 (p.559-567:
Traditional Syntax, Extended Factor 2 Syntax, continuation rules) —
citations live in fixed_columns.h's CSpec namespace.
- V1 opcode set (Tests 125-134): bare —
ELSE/ENDDO/ENDFOR/ENDIF/ENDMON/ENDSL/ENDSR/ITER/LEAVE/LEAVESR/OTHER/SELECT/MONITOR; extended-factor-2 —IF/ELSEIF/DOW/DOU/WHEN/EVAL/EVALR/EVAL-CORR/RETURN/CALLP/FOR/FOR-EACH/ON-ERROR; traditional Factor1/Factor2/Result —BEGSR/EXSR/CLEAR/RESET/DSPLY/SORTA. Extended-Factor-2 continuation across physical lines works (Test 133); native C-spec freely mixes with explicit/freeblocks in the same file (Test 134). - Rejected, not silently dropped (clear, distinct compile errors —
Tests 135-138): non-blank control level (RPG-cycle-only,
L0/L1-L9/LR;AN/ORwere rejected here too but have since shipped with item #6 — they are conditioning syntax, not cycle syntax), non-blank conditioning indicators (positions 9-11 — since shipped, see item #6), resulting indicators/inline field length (free-form drops these too, so this isn't a new gap), any opcode outside the V1 set — with a distinct message for known-but-deferred opcodes (e.g.SND-MSG) vs. never-planned legacy ones (e.g.ADD). - Deferred fast-follow:
ON-EXIT/ON-EXCP;XML-INTO/XML-SAX/DATA-INTO/DATA-GEN/SND-MSG; conditioning indicators (positions 9-11 — since shipped as item #6, exactly as sketched here: the transpiled statement wrapped inIF {N}*INnn; ... ENDIF;);EXEC SQLin fixed columns (architecturally distinct — the SQL capture is a dedicated lexer<SQL>start-condition, not a column-mapped opcode, so it doesn't fit this transpile model cleanly). Traditional legacy opcodes (ADD/SUB/MOVE/GOTO/etc.) remain item #3 below, untouched by this pass.
1b. RLA opcodes in native C-spec ✅. CHAIN, READ, READP,
READE, READPE, WRITE, UPDATE, DELETE, SETLL, SETGT — the
same TRADITIONAL-shape mechanism item #1 already built, just more
opcode-table entries in src/fixed_cspec.cpp; no new architecture.
Verified against this compiler's own parser.y grammar rather than the
full IBM manual, since the free-format RLA grammar here is already a
simplified subset: no data-structure Result operand on any of these
(every *_stmt rule ends at IDENTIFIER SEMICOLON, nothing after the
file name), and DELETE takes no key at all — it deletes the
last-fetched record, matching tests/test105's own usage, not a
delete-by-key Factor 1. SETLL/SETGT have no KW_*_EXT lexer rule, so
(unlike the other eight) they don't accept an operation extender. Direct
fixed-format analogues of Tests 103-106: Tests 144-147.
2. /COPY//INCLUDE outside /free blocks ✅. Implemented as a
recursive, depth-limited line-splicing preprocessing pass
(expandCopyDirectives() in src/fixed_reader.cpp) that runs once,
before spec-type dispatch begins — so a copied D-spec, F-spec, H-spec, or
native C-spec line is indistinguishable from one that was physically
present in the outer file. Matches the free-format lexer's own
/COPY//INCLUDE convention exactly (src/lexer.l): the text after the
directive keyword is a literal filename, opened relative to the process's
current working directory — no library/member catalog, no search path.
Nesting is capped at the same depth (10) as the free-format lexer's own
MAX_INCLUDE_DEPTH, for consistency rather than because either number is
load-bearing. Lines between an explicit /FREE.../END-FREE pair are
deliberately left untouched by this pass — parse_free_block()
re-invokes the real free-format lexer on that text, which already has its
own working /COPY//INCLUDE handling, so expanding here too would
double-process it (Test 142 proves the two paths coexist correctly). Line
numbers after an expansion point are relative to the flattened line
stream, not the original file — the same "best effort, not exact"
precision the free-format lexer already has today (yylineno isn't
saved/restored across its own buffer switch either), not a new
regression. Tests 139-143.
3. Traditional legacy opcodes ✅ (V1): GOTO/TAG, ADD/SUB/MULT/
DIV/Z-ADD/Z-SUB. For genuinely old, pre-modern-opcode fixed-format
source — distinct from #1, which targets shops already writing modern
opcodes in column form; this targets shops that never left MOVE/ADD/
GOTO-style code. The one part of this whole "Fixed-Format Source
Support" effort that couldn't just reuse the transpile-and-bridge trick
as-is, since GOTO/TAG have no free-form syntax at all (SC09-2508
says so outright for both: "not allowed — use other operation codes, such
as LEAVE, LEAVESR, ITER, and RETURN").
GOTO/TAG: real newGotoStmt/TagStmtAST nodes +parser.ygrammar (src/ast.h,src/codegen.cpp→goto/label in the generated C++ directly), gated byg_allow_goto_tag(src/free_bridge.h) so they're only accepted when the text being parsed was synthesized by the fixed-format C-spec transpiler (fixed_reader.cpp'sflushCRun) — genuine free-form text,**FREEtop-level or an explicit/freeblock even inside an otherwise fixed-format file, still correctly rejects them (Test 152), matching the manual exactly rather than silently extending free-form beyond the documented spec. Subroutines (BEGSR/ENDSR) codegen as C++ lambdas, which happens to enforce real RPG's ownGOTOscoping rule (can't cross a subroutine boundary) for free, with no extra validation needed — aTAG/GOTOcrossing that boundary anyway just surfaces as a real (if less-polished) C++ compile error, not silently wrong behavior. No "undefined/duplicate label" validation either — C++'s own compiler already rejects that when it compiles the generated code.ADD/SUB/MULT/DIV/Z-ADD/Z-SUB: no free-form keyword either ("not allowed — use the+/+=operator", etc.) but the manual itself prescribes theEVAL/expression equivalent, so these transpile straight to synthesizedEVALtext — zero new grammar, same mechanism as items #1/1b. Verified blank-Factor-1 semantics against SC09-2508: Factor 1 present →Result = Factor1 op Factor2; Factor 1 blank →Result = Result op Factor2(accumulate into the result field) forADD/SUB/MULT/DIV;Z-ADD/Z-SUBnever use Factor 1 at all (Result = Factor2/Result = -Factor2). Extenders (e.g.(H)half-adjust) pass straight through onto the synthesizedEVAL, which already supports them.- Deferred, not V1 (documented, not silently dropped):
MOVE/MOVEL— real semantic risk, not just unwired plumbing: traditional fixed- length right/left-adjusted move with truncation/padding, plus date/ time-format conversion viaD()/T()/Z()Factor-2 suffixes, none of which map onto this compiler's plain-assignmentEVAL— shipping an approximate version risks silently-wrong output for edge cases rather than a clear compile error, which this project avoids.COMP— its only real effect (settingHI/LO/EQresulting indicators) has no free-form target at all, since free-form drops resulting indicators entirely (an existing limitation, not new).CASxx/CABxx— conditional branch-to-tag/subroutine; meaningfully more parsing work for thexxcomparison-mnemonic suffix, and withGOTO/TAG/IFall now available, the same logic is expressible asIF cond; GOTO label; ENDIF;.CALL/PARM/PLIST— old-style program calls, a genuinely different mechanism fromCALLPneeding cross-line state to build aPLIST's parameter list across multiple physicalPARMlines (similar complexity to F-spec continuation);CALLPalready covers modern program calls.DO— the manual's own guidance is "not allowed — use theFORoperation code," already fully supported. Does not revive the RPG cycle itself (detail/total calc, LR-driven implicit read loop) — that stays rejected regardless of any of this item's future scope.
4. I-specs / O-specs (program-described file I/O) ✅. Unlike items
1/1b/2/3, this couldn't reuse existing infrastructure — every existing
file opcode (RLA — CHAIN/READ/WRITE/etc.) is 100% SQL/ODBC against a
database table, but program-described I-specs/O-specs describe flat
files with byte-position field layout, so this needed a genuinely new
raw fixed-length-record file I/O runtime
(runtime/rpg_flatfile_runtime.h) built from scratch. Real IBM i
program-described files have no in-file delimiters (pure fixed-width
bytes on a record-oriented file system); since there's no equivalent on
macOS/Linux/Windows, records are newline-delimited fixed-width text
instead — an OpenRPG-specific portable convention, not literal IBM i
on-disk semantics, documented as such in the runtime header. READ/
WRITE/UPDATE reuse the same ReadStmt/WriteStmt/UpdateStmt AST
nodes RLA already uses (a program just writes READ MYFILE; regardless
of which kind of file MYFILE is) — visit() for each gained a new
branch checked before the existing RLA/"no schema" fallback, mirroring
the pattern the WORKSTN branch already used in the same methods.
- In scope: record identification via character-part (
C) tests (position/not/character; zoneZand digitDtests added 2026-10-21, see below), field description (from/to byte position, data type, decimals, field name, field indicators for plus/minus/zero-or-blank), sequentialREADwith multi-record-type dispatch,UPDATE(rewrite the last-read record in place). One O-spec record format per file (field/constant placement by end-position — width inferred from the gap to the previous field's end position, not by cross-referencing the field's own declared length; edit codes reuse the existingrpg_editc()used by%EDITC),WRITE. - Deferred, documented not dropped (found or reconfirmed during
implementation, beyond the two flagged when this item was scoped):
zone/digit ((done 2026-10-21: compared by EBCDIC zone or digit half, CCSID 37; as aZ/D) record-identification testsZtest character&also matches zone C (A-I) and-zone D (J-R), and no other character does; a record is the FIRST type whose codes it matches, not every one -- rpgc applied every match until then; all checked on PUB400; Test 159); matching fields / multi-file record merging (RPG-cycle-adjacent, permanently rejected elsewhere);CHAIN/SETLL/SETGT/DELETEon program-described files (real keyed access needs F-spec key-field columns not modeled for program-described files); O-spec spacing/skip and printer paging (this compiler has zero PRINTER-output runtime at all, for any file type — a separate, standalone feature, not something O-specs are specifically blocked on); control-level (L1-L9) indicator-on-value-change — parses and is accepted, but doesn't yet set anything at codegen time: wiring it in turned out to need real extensions to this compiler's indicator system (general indicators are a flatbool[100]array with no L1-L9 concept at all, and *INL1-style expression syntax doesn't exist), a meaningfully bigger, separate task once actually reached; multiple O-spec record formats per file — real DDS disambiguates via record-type/EXCEPT names, both already deferred above, so a file needs exactly one O-spec format (a second one is rejected with a clear error, not silently overwritten — found via this session's own test155); date/time/timestamp (D/T/Z) and graphic/indicator-format (G/N) I-spec data formats — rejected at parse time (CHARtext still works fine for a date field if you don't need real date parsing); O-spec relative end positions (+n/-n) — only absolute end positions are supported; sequence checking (I-spec positions 17-20) — not implemented, must be blank. - Tests 154-160.
5. Per-subfield LIKE(...)/DIM(...) ✅. Found during Phase 2: neither
existed at the subfield level in free-format either, so — unlike items
#1-#4, which were pure fixed-format parsing gaps — this needed real
ds_field grammar productions in parser.y (DSField gained like_var/
dim members) before fixed_reader.cpp had anything to populate.
- DIM(n): the subfield becomes an array within the DS
(
std::array<T,n>), distinct from DIM on the DS itself (an array of DS elements, Phase 2). Accessed via newds.field(idx)grammar — added at thepostfix_expr/eval_targetlevel, not as a flatIDENTIFIER DOT IDENTIFIER LPAREN...production, since the latter would shift/reduce- conflict with plainds.field(no index) inprimary_expr's existingIDENTIFIERreduction; going through the already-reducedpostfix_exprnonterminal first sidesteps that (verified no new conflicts vs. this grammar's existing 2 shift/reduce + 1 reduce/reduce baseline). - LIKE(other): resolves against a field already declared earlier in
the same DS — the reliable, tested case (Test 163's
price LIKE(unitPrice)inside the same DS). Referencing an outer standalone field also has fallback code (mirroring top-levelDCL-S ... LIKE), but doesn't fire reliably at main/top-line scope:visit(Program)emits every DS struct definition before any standalone field's own DclS (DS structs are needed for procedure params), regardless of the two declarations' textual order — found the hard way via Test 162's first version, which referenced an outer field and silently got the fallback placeholder type. Works fine inside aDCL-PROCbody, where declarations emit in textual order. - Deferred, not V1:
combiningdone 2026-10-27 (test447);LIKEandDIMon the same subfield (e.g.qty LIKE(other) DIM(5))DIM(*VAR:n)/DIM(*AUTO:n)on a subfield (fixed-size only, matching the DS-level DIM(n) case this mirrors, not the *VAR/*AUTO forms DCL-S standalone fields support). - Tests 161-162 (fixed-format D-spec — declaration only, since
fixed_reader.cppbuildsDSFielddirectly rather than through the bison grammar); Test 163 (free-format — the one that actually exercises the newds_fieldproductions, since 161/162's fixed-format source never touchesparser.yon the declaration side, only via the/freeblock's access expressions).
6. Conditioning indicators (positions 9-11) ✅. The highest-value
entry on the deferred fast-follow list below when it was written — common
in real legacy source and, unlike the RPG cycle's control-level field
(positions 7-8), completely independent of cycle semantics. Same
transpile-and-bridge design as item #1: position 9 (blank or N) and
positions 10-11 (the indicator) become an IF {NOT }*INnn; <stmt> ENDIF;
wrapper emitted onto the same buffer line the physical source line
already owns, so parse_free_block's line numbers stay aligned exactly as
before. No new AST nodes, no grammar change — *IN01-*IN99 were already
ordinary expressions (IndicatorExpr, parser.y).
- Conditionable only where the wrapper is exactly equivalent — that
is, self-contained statements:
EVAL/EVALR/EVAL-CORR/RETURN/CALLP;ITER/LEAVE/LEAVESR; every traditional-shape opcode (EXSR/CLEAR/RESET/DSPLY/SORTA, the RLA set,GOTO, and theADD/SUB/MULT/DIV/Z-ADD/Z-SUBarithmetic set). - Rejected on block-structure opcodes (
IF/ELSEIF/ELSE/ENDIF,DOW/DOU/ENDDO,FOR/FOR-EACH/ENDFOR,SELECT/WHEN/OTHER/ENDSL,MONITOR/ON-ERROR/ENDMON,BEGSR/ENDSR) — anIF/ENDIFwrapper around one half of a block leaves the block unbalanced, so there is no honest transpilation; a distinct compile error says so and suggests folding the test into the block's own condition (Test 165).TAGis rejected for a related but different reason: wrapping a label would bury it inside a nested scope its ownGOTOs cannot legally jump into. - Rejected on indicators this compiler has no representation for —
LR,MR,RT,OV,1P,L1-L9,H1-H9,U1-U8,KA-KY,OA-OG— recognized by name specifically so they get a "not supported here" diagnostic pointing at "Indicator Types" under Not Planned, rather than being reported as a typo (Test 166).00and any column-misaligned entry get their own messages (Test 136, repurposed from its previous "conditioning indicators rejected outright" role to the misalignment case its source actually exercises). AN/ORmulti-indicator groups (positions 7-8) — RPG IV's C-spec has room for exactly one indicator per line, soAN/ORon a following line is the only way to write a multi-indicator condition; the group's operation code sits on its last line. Terms accumulate inCSpecRunState::condGroups(outer = AND-groups, ORed together) and collapse into one expression when that operation line arrives. RPG relates them as an OR of AND-groups, not left-to-right, so each multi-term AND-group is parenthesized — Test 167 pins this with a(10 AND 30) OR 20case that only passes under the correct grouping. Note this corrects a wrong claim in the oldfixed_columns.hControlLeveldoc string, which lumpedAN/ORin withL0/L1-L9/LRas "RPG-cycle-only" — they have nothing to do with the cycle, and that mischaracterization is likely why they were rejected alongside it. A group whose last line never arrives, anAN/ORwith nothing before it, and anAN/ORline missing its indicator each get their own error (Tests 168-169).- Extended-factor-2 continuation needed the one piece of new state:
the wrapper's
ENDIFcan't be emitted until whoever appends the statement's terminating;runs, which for a continued statement is several physical lines later — henceCSpecRunState::pendingSuffix. - Still not conditionable from fixed columns: the O-spec's own
conditioning indicators (positions 21-29) remain rejected in
fixed_reader.cpp— a separate mechanism belonging to item #4, not to this one. - Tests 164 (positive: both polarities, all three opcode shapes,
conditioned
GOTO, conditionedLEAVE/ITERinsideDOWloops, and a conditioned statement continued across a physical line), 167 (AN/ORgroups), 165-166 and 168-169 (rejections).
7. MOVE/MOVEL — character moves ✅. Deferred out of item #3 for
semantic risk rather than missing plumbing, so this ships the part that
can be made exact and refuses the rest outright.
- What makes it not plain assignment:
MOVEaligns factor 2 against the RIGHT end of the result,MOVELagainst the LEFT, and the part of the result the move does not reach is left unchanged (the(P)extender blanks it instead). An over-long factor 2 truncates on the side away from the alignment. That needs the result's declared length, so unlike every other opcode here this could not be a pure text transpile. - Where the type check lives:
fixed_cspec.cpphas no symbol table, so codegen — the first stage that can see declared types — is what refuses a non-character operand. That needed a diagnostic channel after parsing:report_semantic_error(free_bridge.h) reusesreport_fixed_format_error's stderr channel and error counter, andmain.cppnow re-checks the count once codegen returns. - Character-to-character only (superseded by item #13, which added the numeric forms). Numeric, date/time and varying-length results were rejected here, as was a numeric or date/time factor 2, and so is the date/time-conversion form that puts a format in Factor 1 — that one is caught in the transpiler, since it needs no type info (Test 172). Digit-alignment and format-conversion semantics had no representation in this compiler at the time, and approximating them is exactly the silently-wrong-output risk this item was deferred over. Date/time results and the Factor-1 form are still refused.
- New
MoveStmtAST node plus amove_stmtrule gated the wayGOTO/TAGalready were —MOVE/MOVELlikewise have no free-form syntax at all (Test 173). The gate flag was renamedg_allow_goto_tag→g_allow_fixed_only_stmtsnow that it guards four opcodes, not two. - Cost of making them keywords:
MOVE/MOVELare now lexer keywords, so free-format source can no longer use either as a variable name (DCL-S move CHAR(10)is a syntax error today, where it parsed before). Same tradeoffGOTO/TAGalready carry, and both are reserved opcode names in RPG, so real source is unlikely to hit it — noted rather than discovered later. - Fixed a latent gap this exposed: a CHAR field's generated
std::stringis not kept at its declared length (a plainEVALassigns a shorter string), but real RPG fixed-length fields always are, andMOVEalignment depends on it. Codegen now wraps a CHAR factor 2 inrpg_fixed_len(...), and the runtime normalizes the result field before moving, soMOVELof aCHAR(5)holding'AB'correctly moves five characters, not two. OnlyMOVE/MOVELare corrected here — the broader "CHAR is not padding-faithful" behavior is untouched elsewhere. - Test 170 covers both directions, both truncation sides,
(P)on each, and a literal factor 2 (which keeps its own length rather than being padded to a declared one). Test 153 was repurposed fromMOVEtoCALL, still deferred, so deferred-opcode rejection stays covered.
8. CALL/PARM — traditional program calls ✅. Old-style program
calls, pervasive in pre-ILE source and the kind of thing that fails a
whole member. V1 supports a CALL with its PARM lines inline
immediately after it; named PLISTs were deferred here and shipped in
item #14 below.
- How a prototype-less call gets a signature:
CALLPworks becauseDCL-PR ... EXTPGMdeclared the parameter types; a traditionalCALLhas no prototype at all. Codegen synthesizes the callee's signature from thePARMoperands' own declared types — every parameter aT&, since RPG passes by reference, which is exactly what a non-VALUEDCL-PIparameter generates in the called member. The declaration is emitted at block scope (legal C++, external linkage), so no hoisting pass or output-stream refactor was needed. - Mismatches fail at link time, not silently: if caller and callee
disagree on types, the two C++ mangled names differ and the link fails.
An ugly error, but an honest one — and the same failure mode
EXTPGMalready has. - The program name may be a variable (since #23): program calls are
resolved at run time from shared libraries built with
rpgc -shared, soCALL pgmvaris supported (Test 177). - Cross-line state:
CSpecRunStategains a pending-CALLblock. The assembled statement is written back into theCALL's own buffer line once thePARMrun ends (any other opcode, or the end of the C-spec run), keeping the one-buffer-line-per-physical-line invariant thatparse_free_block's line numbers depend on. Blank and comment lines betweenCALLand itsPARMs are fine — they never reachfeedCSpecLine. A conditioning indicator on theCALLwraps the whole thing; one on aPARMis refused, since aPARMline is part of the call's parameter list rather than a statement of its own — SC09-2508 p.928 forbids one there outright, which item #14 later confirmed. - New
CallStmtAST node, gated ong_allow_fixed_only_stmtslikeGOTO/TAG/MOVE— free-form hasCALLPinstead (Test 179). As withMOVE, this makesCALLa lexer keyword, so free-format source can no longer use it as a variable name;CALLPis unaffected (flex takes the longest match). - Tests 174 (the NOMAIN callee module it links against, same pattern as
tests 48/49) and 175 (call, by-reference mutation of both an
INTand aCHARparameter, plus a conditionedCALLin both polarities); 176-179 (rejections).
9. Modern opcodes in native C-spec ✅. XML-INTO, DATA-INTO,
DATA-GEN and SND-MSG are now reachable from fixed columns. This turned
out to be four table entries and nothing else — the item's framing was
more pessimistic than the work.
- Extended factor 2 is the right shape, and that is the whole trick.
Their operands are free-form expressions by nature (
%XML(doc:opts),%DATA(...) %PARSER(...),SND-MSG's message-type operand), which is exactly what columns 36-80 plus continuation lines carry through to the free-format parser untouched. Factor 2's traditional 14-column width could never have held a realistic one — reading them as extended factor 2 sidesteps that entirely. No new grammar, AST node, or codegen change. ON-EXITwas mis-filed on this list and is not a port at all.parser.yhas no standalone rule for it — it appears only inside theDCL-PROCproductions — and fixed-format source cannot declare a procedure, since this reader has no P-spec support. It therefore needs a/freeblock either way, and now says so in its own error (Test 181) instead of promising a fast-follow that would not help.XML-SAXandON-EXCPwere also mis-filed: they are not implemented anywhere in this compiler (no lexer token, no parser rule), so there was never anything to reach from fixed columns. They stay indeferredOpcodespurely to earn the friendlier "planned" message, andXML-SAX's real home is Modern/Stretch item 46.- Test 180 (
DATA-INTOwith its expression continued across a physical line, plusSND-MSG); all four opcodes share the one code path with no per-opcode logic. Test 137 was repurposed fromSND-MSGtoXML-SAX, which is still deferred, so the deferred-opcode message stays covered.
10. CASxx / CABxx ✅. Both are comparison-mnemonic opcodes, so
neither can be an opcode-table entry — the mnemonic is glued onto the
opcode itself (CASGT), and they are matched by prefix ahead of the table
lookup. EQ/NE/LT/LE/GT/GE map to =/<>/</<=/>/>=,
and the bare CAS/CAB forms are the unconditional ones.
CABxxis self-contained: a comparison guarding a branch, so it transpiles toIF f1 <op> f2; GOTO label; ENDIF;— or a plainGOTO label;for bareCAB. It can carry a conditioning indicator.CASxxis a group, and that is the real work here: the lines chain likeSELECT/WHEN(the first true comparison runs its subroutine, the rest are skipped), so a group transpiles to oneIF/ELSEIF/ELSEchain thatENDCScloses withENDIF. State lives inCSpecRunStatealongside theAN/ORand pending-CALLaccumulators. A conditioning indicator on a single arm is refused — it would leave the chain unbalanced, the same reasoning as the block-structure opcodes.- Degenerate groups are caught, not miscompiled: a group led by an
unconditional
CASneeds noIFand so gets noENDIF; arms after an unconditional one are unreachable and rejected; an orphanENDCS(Test 183), a group left unclosed at the end of the run (Test 184), and an unrecognized mnemonic (Test 185) each get their own message. - Test 182 runs the same group four times, hitting each arm including the
ELSE, then exercises bothCABxxforms.
11. COMP ✅ — and the reason it was deferred turned out to be wrong.
The old entry said COMP's only effect, setting the HI/LO/EQ
resulting indicators, "has no free-form target at all". That is true of
resulting indicators as a column feature — free-form drops them — but
not of COMP, because *INnn is an assignable target in this compiler.
Each non-blank slot simply becomes one indicator assignment:
*IN10 = (a > b); *IN20 = (a < b); *IN30 = (a = b);. Exact, not
approximate.
COMPis therefore the one opcode allowed to fill positions 71-76; every other opcode still rejects them. The three slots got their ownColSpecs (71-72 high, 73-74 low, 75-76 equal) and reuse the conditioning indicators' validation, so*INLRand friends are refused the same way there as anywhere else.- A
COMPwith all three slots blank does nothing at all and is rejected rather than silently emitted (Test 187). - Test 186 reads the results back through conditioning indicators, exercising both halves of the indicator support against each other.
12. EXEC SQL in fixed columns ✅. C/EXEC SQL … C+ … C/END-EXEC.
The old entry called this "architecturally distinct — the SQL capture is a
dedicated lexer <SQL> start-condition, not a column-mapped opcode, so it
doesn't fit the transpile-and-bridge model". The start-condition part is
right; the conclusion was not. Gathering the C+ lines and emitting one
free-form EXEC SQL …; hands the text to that very start condition, which
captures it exactly as it does in free-format source — so the bridge model
fits perfectly and no SQL parsing was added at all.
- Handled ahead of the ordinary column layout, since position 7 carries
the
/or+marker that the control-level field would otherwise reject. The gathered statement is written into theC/EXEC SQLline's own buffer entry, keeping the one-line-per-physical-line invariant. - An orphan
C/END-EXEC(Test 189) and a block never terminated (Test 190, plus the same check at run flush) each get their own error. - Test 188 goes end to end against SQLite: connect, create, two inserts,
a
SELECT … INTOhost variable, and aCOUNT(*), with theCREATE TABLEcontinued across twoC+lines.
13. MOVE/MOVEL — numeric operands ✅. The rest of item #7, and the
one the deferred list called the most commonly hit in real legacy source.
The reason it was deferred — "digit-alignment semantics have no
representation in this compiler" — turned out to be a runtime gap, not a
semantic one: the semantics are exact and citable, they just needed a
digit string to act on.
- The rule that decides everything: SC09-2508 p.633, "if move
operations are specified between numeric fields, the decimal positions
specified for the factor 2 field are ignored. For example, if 1.00 is
moved into a three-position numeric field with one decimal position,
the result is 10.0." So a numeric
MOVEis not an assignment and not a scaling conversion — it is the same positional move the character form already does, run over the field's digits instead of its characters. Neither operand's decimal point participates. Test 191 opens with that exact example. - Reducing a numeric to digits is the whole implementation. A
PACKED/ZONEDfield is a C++doublehere, which carries neither a digit count nor a decimal place, so codegen passes both declared numbers in (var_digits_/var_decimals_, with the samevar_lengths_fallback%SIZEalready uses) andrpg_num_digits(v, digits, dec)builds the fixed-width digit string. The move then runs positionally on that string exactly asrpg_move_fixeddoes on characters, and the result is read back through the result field's decimal places. Same three cases as the character form (factor 2 longer / shorter / equal), same(P)behaviour except that the pad character is'0'for a numeric result and stays blank for a character one. llround, not a cast, when scaling by 10^dec — binary floating point lands an exact1.00at99.999999, and a truncating cast would turn the manual's own worked example into099. Found by running it.- The sign rules are asymmetric and are implemented as written, not
approximated:
MOVEalways moves factor 2's rightmost position, which is where the sign lives, so factor 2's sign always wins.MOVEL(p.905) "retains the sign of the result field except when factor 2 is as long as or longer than the result field", so a shorter factor 2 leaves the result negative if it already was. Test 191 pins all four combinations. - Character→numeric reads EBCDIC digit portions and zones (changed
2026-10-13, against PUB400). "The digit portion of each character" is
the low half of its EBCDIC code, so
'A'(x'C1') moves as 1 with no data error, and'A1 'moves as110(Test 196). The result is negative when the rightmost character's zone is D ('J'-'R','}'). The bytes are read through CCSID 37 (rpg_ebcdic37). "Blanks are transferred as zeros", so aCHAR(5)holding'123'moves the digits12300(Test 192). A digit portion above 9 (.<(+*and others) is a decimal data error, status 907, that ends the program unless MONITOR handles it, with the result unchanged (checked on PUB400 2026-10-21 for ten such characters; Test 440). - Numeric→character carries the sign in the last digit's zone
(changed 2026-10-13, against PUB400), as a zoned number does on IBM i:
-12345moves as1234N, a negative last digit reading}for 0 andJ-Rfor 1-9 (rpg_digits_text; Test 193). - Still refused, each with its own message: float operands (the
manual disallows them outright — Test 194), date/time results and
factor 2s, and a decimal literal as factor 2 (Test 195) — it reaches
codegen as a
doublehaving lost the trailing zeros that decide its digit count, and'1.00'and'1.0'move differently. An integer literal is accepted, since the digits written are the digits it has.
14. Named PLIST and PARM factor 1/2 ✅. The two thirds of the
deferred PLIST entry that did not need any new signature machinery.
- A named
PLISTforces a collect-then-substitute pass, and that is the whole reason it was deferred: "a parameter list is ended when an operation other than PARM is encountered" (SC09-2508 p.930), but nothing requires thePLISTto appear before theCALLthat names it — real source routinely groups everyPLISTat the bottom. A linear transpiler cannot resolve that in line order, so aCALLwith a name in its Result field now records its buffer index and is filled in byflushCSpecRun, once everyPLISTin the run has been seen. Test 197 puts the definition after all five calls that use it, which is the case that fails without the pass. plistsis the one piece ofCSpecRunStatethat survives a flush. APLISTis a program-wide declaration, but a C-spec run ends at any interleaved spec type, so a run boundary between the definition and a later use would otherwise lose it. The reverse order — aCALLin an earlier run than itsPLIST— is still refused, since the substitution happens at that earlier run's flush; it gets a real diagnostic naming the list (Test 200), never a silently argument-less call.PARMfactor 1/2 turned out to be two plain assignments. The deferral said the direction semantics were unverified, and p.929 settles them: on the call "the contents of the factor 2 field ... are copied into the result field", and on return "the contents of the result field ... are copied into the factor 1 field" — each "in the same way as data is moved using the EVAL operation code". So factor 2 is a seed-in and factor 1 a harvest-out, andbuildCallTextbrackets theCALLwith one assignment apiece. Both land on theCALL's own buffer line, because thePARMlines sit after it in the source and their own entries could never hold statements that must run before it. Test 198 runs all three shapes (both operands, factor 2 only, factor 1 only).- Result-field restrictions are enforced, not assumed. p.929 bars
*IN/*INxx, literals, named constants and table names from aPARMResult field, and bars a literal from factor 1 (which is written to on return). Indicators and literals are refused by name (Test 202); a named constant is indistinguishable from an ordinary name at this stage and reaches codegen, which has the symbol table. - One diagnostic per mistake. A rejected
PLISTline setsplistSuppress, so its orphanedPARMlines stay quiet instead of each reporting itself — without it,*ENTRY PLISTreported two errors for one cause. Duplicate names (Test 201) and an emptyPLIST(Test 176, repurposed from the old "named PLISTs are rejected" test) each get their own message. *ENTRY PLISTshipped separately as item #15 — it turned out to be a different problem entirely, and needed a design decision rather than more transpiler work.
15. *ENTRY PLIST ✅. The last of the deferred PLIST entry, and the
one that was mis-scoped: the old note said it "needs the main-program
signature machinery", but there is no such machinery. DCL-PI appears
only inside DCL-PROC in parser.y, and a compiled program is an
int main() taking no arguments — free-format has no way to give the main
program parameters either. So this was never a port; it was a decision
about what a program with parameters compiles to at all.
- A member with an
*ENTRY PLISTcompiles to a callable function, not amain().void <NAME>(T1&, T2&, ...), holding the mainline, with every parameter a reference because RPG passes by address — which is exactly the signature a caller's traditionalCALLalready synthesizes from its ownPARMoperands (item #8), so the two link with no new machinery on the calling side. The alternative, filling parameters fromargv, was rejected: a process cannot write back to its caller, so the output half of*ENTRYsemantics would be silently lost, and output parameters are the common case in real entry lists. - The name comes from the source file, upper-cased with directory and
extension stripped:
ORD100.rpgleis reachable asCALL 'ORD100'. Fixed-format source has no P-spec to name a procedure with, so the file name is the only thing both sides can agree on. A file name that is not a usable symbol is refused with an explicit "rename the source file" message rather than emitting an uncompilable one. This is why the test callee istests/ADDTWO.rpgleand not atestNNNname. - The parameters are declared twice and stored once. They are ordinary
D-spec fields and the function's C++ parameters, so codegen registers
their types as usual but suppresses the local declaration
(
entry_params_) — leaving the caller's storage as the single location the body reads and writes, which is what p.929's "each parameter field has only one storage location" requires. - Factor 1 and factor 2 both work. p.929 step 3 puts the
result→factor 1 copy "after it receives control and after any normal
program initialization", so it is emitted once, after
*INZSR, before the first executable statement. Step 4's factor 2→result copy happens on return, at every exit point. It was refused until 2026-10-21; it is now an object at the top of the entry function (RpgOnReturn) whose destructor makes the copies, so every way out runs it -- aRETURNin the calculations or a subroutine, or the end -- and an error ending the program does not (Test 205, calleeCOPYBACK). NOMAINwith*ENTRYis an error (Test 199, repurposed):NOMAINdiscards the mainline, which is the very thing*ENTRYturns into the function, so together they compiled to an empty file.- Fixed a pre-existing bug this exposed: a bare
RETURNemittedreturn 0;regardless of the enclosing function's return type, so it did not compile inside anyvoidfunction — aDCL-PROCwith no return type already had this,*ENTRYjust made it unavoidable (a fixed-format mainline almost always ends inC RETURN). Codegen now tracksvoid_return_and emits a barereturn;. - Tests 203 (the
ADDTWOcallee) and 204, which calls it twice — once with inlinePARMs and once through a namedPLISTdefined after the call — so items #14 and #15 meet end to end. 205-207 are the rejections.
16. MOVE/MOVEL date/time conversion ✅. The last of the deferred
fast-follow list, and what items #7 and #13 both left out: a date, time or
timestamp on either side of the move, plus the Factor-1 format form
(C *MDY MOVE chr datefld) that #7 rejected outright.
- It turned out not to be a different kind of move at all. The reason
this was deferred — "genuine format conversions rather than positional
moves" — held only for the conversion. SC09-2508 p.406 settles the
rest: "If character or numeric data is longer than required, only the
leftmost data (rightmost for the MOVE operation) is used." That is the
same left/right positional rule #7 and #13 already implement. So the
conversion produces a text exactly as wide as the format defines, and
rpg_move_fixed/rpg_move_num_fixedthen place it unchanged — one new step in front of machinery that already existed, not a second move implementation. Test 211 pins both directions of that. - Thirteen combinations, and the manual lists them by name (p.405): each of Date/Time/Timestamp to its own type, Date and Time to a timestamp, Timestamp to a date and to a time, each of the three to character or numeric, and character or numeric to each of the three. Date-to-Time and Time-to-Date are absent, and are refused with a message pointing at the timestamp, which is the only thing that carries both (Test 171, repurposed from #7's date rejection).
- Factor 1 is the format of the character or numeric operand, never of
the date field. "Factor 1 must be blank if both the source and the
target of the move are Date, Time or Timestamp fields. If factor 1 is
blank, the format of the Date, Time, or Timestamp field is used." Both
halves are implemented: a factor 1 with two date/time operands is an
error (as is a factor 1 with neither — Test 172, likewise repurposed),
and a blank one falls back to the field's own DATFMT/TIMFMT, then the
H-spec's, then
*ISO. - A format carries its own separator.
*MDY/names one explicitly,*MDY0says the character field has none at all, and a bare*MDYtakes the format's default. Codegen validates the separator against the set the format allows (Tables 13, 15 and 16) — and needs no separate default-separator table, since the default is the first entry in each of those sets. Numeric operands never carry separators, per "If the result field is numeric, separator characters will be removed." - The year-range rules are enforced, not just documented. A 2-digit
year format reaches 1940-2039 and a 3-digit one 1900-2899 (p.193), and
a value outside the result field's range is error 114 with the result
left unchanged — which is what Figure 287's own
*HIVALmove produces. Like 112 (not a valid date), it ends the program unless MONITOR handles it: MOVE has no (E), and the error indicator in positions 73-74 is not supported here (changed 2026-10-13, against PUB400; Tests 208, 212, 439). This is checked even on a plain date-to-date move, where the internal value is format-independent here, so that example reproduces exactly. - Fixed a pre-existing bug this uncovered:
*CYMD/*CMDY/*CDMYwere being rendered and parsed asc/yy/mm/dd— ten characters with the century digit separated — where Table 15 givescyy/mm/dd, nine, with the century digit joined to the year. The century digit was also treated as a 19xx/20xx flag rather than its documented1900 + c*100range. Both were wrong in%CHAR(date:*CYMD)and%DATE(str:*CYMD)too, so the fix is in the shared legacy helpers, not just the new path. - Fixed-format D-specs now read per-field
DATFMT/TIMFMT. Only the H-spec forms were parsed before; the free-formatDCL-Shas always had the per-field keywords, and a blank factor 1 needs them. - Still refused, each with its own message:
*JOBRUN(Test 213), which takes its format and separator from runtime job attributes this compiler has no equivalent of; time format*USAagainst a numeric field, which the manual disallows outright and whose AM/PM suffix is not a digit anyway (Test 214); and a varying-length result, which was already out of scope for the character form. (P)on a date/time result is accepted and does nothing — there is no remainder to blank in a date field. It still pads a character result the conversion did not fill (Figure 289's*ISO0timestamp).- Tests 208-210 reproduce Figures 287, 288 and 289 value for value, so the manual is the oracle rather than this compiler's own output. 211 is the alignment/truncation pinning above, 212 the runtime failures (status 112, result unchanged), 213-214 the rejections.
Items explicitly deferred (not silently dropped) out of items #1 and #3 above when each shipped — kept here as their own trackable list instead of staying buried in a completed item's writeup. Both entries are now closed.
— done: item #16 above.MOVE/MOVELdate/time conversion.Named— done: items #14 and #15 above. Factor 2 on anPLIST,*ENTRY PLIST, andPARMfactor 1/2.*ENTRYPARM, the one piece left behind, was done 2026-10-21 with a scope guard (Test 205). Writing its test found two bugs in called programs with subroutines: a RETURN in a subroutine of an*ENTRYprogram generatedreturn 0in a void function and did not compile, and the subroutine-return flag was shared with the caller, so a callee's subroutine RETURN ended the caller too. Both fixed.
Native record format / INFSR / legacy PLIST-based file I/O — item #4
above does now implement program-described (byte-position) record I/O
for fixed-format source; what stays not-planned here is INFSR (file
exception/error subroutines) and legacy PLIST-based parameter-list file
operations, neither of which item #4 touches.
| # | Feature | Details |
|---|---|---|
| 18 | ✅ EXEC SQL parsing | Lexer <SQL> start condition, captures raw SQL until ; as EXEC_SQL_TEXT token |
| 19 | ✅ Host variables | Extract :varName references, replace with ? parameter markers |
| 20 | ✅ ExecSqlStmt AST node | Single node with SqlStmtKind enum (avoids 15+ separate node classes) |
| 21 | ✅ SQL utility functions | src/sql_utils.h/.cpp — extractHostVariables(), replaceHostVarsWithMarkers(), classifySqlStmt() |
| 22 | ✅ ODBC runtime wrapper | runtime/rpg_sql_runtime.h — RpgSqlEnv class (connection, cursor, prepared stmt management) |
| # | Feature | RPG Syntax | ODBC Mapping |
|---|---|---|---|
| 23 | ✅ SELECT INTO | EXEC SQL SELECT col INTO :var FROM tbl WHERE ...; |
SQLPrepare + SQLBindCol + SQLExecute + SQLFetch |
| 24 | ✅ INSERT | EXEC SQL INSERT INTO tbl VALUES(:v1, :v2); |
SQLPrepare + SQLBindParameter + SQLExecute |
| 25 | ✅ UPDATE | EXEC SQL UPDATE tbl SET col = :v WHERE ...; |
Same as INSERT |
| 26 | ✅ DELETE | EXEC SQL DELETE FROM tbl WHERE ...; |
Same as INSERT |
| 27 | ✅ COMMIT | EXEC SQL COMMIT; |
SQLEndTran(SQL_COMMIT) |
| 28 | ✅ ROLLBACK | EXEC SQL ROLLBACK; |
SQLEndTran(SQL_ROLLBACK) |
| 29 | ✅ SQLCODE / SQLSTATE | SQLCOD / SQLSTT variables |
SQLGetDiagRec → __sql_env.sqlcode / .sqlstate |
| # | Feature | RPG Syntax | ODBC Mapping |
|---|---|---|---|
| 30 | ✅ DECLARE CURSOR | EXEC SQL DECLARE C1 CURSOR FOR SELECT ...; |
SQLPrepare (store handle in cursor map) |
| 31 | ✅ OPEN | EXEC SQL OPEN C1; |
SQLExecute on cursor's stmt handle |
| 32 | ✅ FETCH | EXEC SQL FETCH C1 INTO :v1, :v2; |
SQLBindCol + SQLFetch |
| 33 | ✅ CLOSE | EXEC SQL CLOSE C1; |
SQLFreeStmt(SQL_CLOSE) |
| # | Feature | RPG Syntax | ODBC Mapping |
|---|---|---|---|
| 34 | ✅ CONNECT | EXEC SQL CONNECT TO :dsn USER :u USING :p; |
SQLConnect |
| 34a | ✅ CONNECT (conn string) | EXEC SQL CONNECT USING :connStr; |
SQLDriverConnect |
| 34b | ✅ CONNECT RESET | EXEC SQL CONNECT RESET; |
SQLDisconnect |
| 35 | ✅ DISCONNECT | EXEC SQL DISCONNECT; |
SQLDisconnect |
| 36 | — SET CONNECTION | Not supported (single connection per program) | — |
| # | Feature | RPG Syntax | ODBC Mapping |
|---|---|---|---|
| 37 | ✅ PREPARE | EXEC SQL PREPARE S1 FROM :sqlStr; |
SQLPrepare (store handle) |
| 38 | ✅ EXECUTE | EXEC SQL EXECUTE S1 USING :v1, :v2; |
SQLBindParameter + SQLExecute |
| 39 | ✅ EXECUTE IMMEDIATE | EXEC SQL EXECUTE IMMEDIATE :sqlStr; |
SQLExecDirect |
| # | Feature | RPG Syntax | ODBC Mapping |
|---|---|---|---|
| 40 | ✅ Indicator variables | :var :ind |
SQLLEN indicator in bind calls |
| 41 | ✅ GET DIAGNOSTICS | EXEC SQL GET DIAGNOSTICS :rc = ROW_COUNT; |
SQLGetDiagRec / SQLGetDiagField |
| 42 | ✅ CALL procedures | EXEC SQL CALL proc(:p1, :p2); |
SQLPrepare("{CALL proc(?,?)}") + bind |
| 43 | ✅ SAVEPOINT | EXEC SQL SAVEPOINT sp1; |
SQL passthrough (driver-dependent) |
| 44 | ✅ Multiple-row FETCH | FETCH ... FOR :n ROWS |
Loop with SQLFetch into array elements |
| 45 | ✅ Multiple-row INSERT | INSERT ... FOR :n ROWS |
Loop with SQLExecute per array element |
- Lexer:
EXEC SQLtriggers exclusive start condition<SQL>, captures until; - Parser: Single
exec_sql_stmtrule; classification via utility function (not a full SQL parser) - AST: Single
ExecSqlStmtnode withSqlStmtKindenum - Runtime: Separate
rpg_sql_runtime.h(programs without SQL don't need ODBC) - Linking:
-lodbcadded only when SQL is used (codegen setsuses_sql_flag) - SELECT INTO:
INTO :var1, :var2clause stripped from SQL sent to ODBC (usesSQLBindColinstead)
| # | Feature |
|---|---|
| 46 | XML-SAX (%HANDLER) |
What this is: the test corpus is compiled on a real IBM i (PUB400,
V7R5M0) by IBM's own ILE RPG compiler, and its accept/reject verdict is
compared against rpgc's. This is the only check that can find the one class of
bug the local suite structurally cannot: source rpgc accepts that real RPG
rejects. tests/expected_output/ is regenerated from rpgc's own output by
run_tests.sh --update, so the local suite can only ever verify what rpgc
already does. An independent compiler is the only oracle that breaks that
circularity.
| Script | Purpose |
|---|---|
scripts/ibmi-probe.sh |
One-shot recon: what works on the box and how. Not needed routinely; kept because every parameter in it was found by failure. |
scripts/ibmi-conformance.sh |
Uploads the corpus, compiles each source, reports accept/reject grouped by diagnostic. Writes ibmi-conformance-report.txt + -transcript.txt. |
scripts/fix-fixed-format-columns.py |
Corrects fixed-format sources to what IBM actually requires (see findings 1 and 2 below). Re-runnable. |
- Source must be tagged CCSID 819. UTF-8/1208 fails
RNS9339 "unable to open file", which reads like an authority problem and is not. TGTCCSID(*JOB)is mandatory. The job CCSID is 273 (German; PUB400 is German-hosted) despite the profile being LANGID ENU / CNTRYID US. The default target is*SRC(500), under which[ ] { }in literals come back asÄ Ü ä üin real program output.TGTCCSID(37)is worse — it breaks!.- Each
systeminvocation is its own job. QTEMP does not survive between calls andCHGJOBdoes not stick. Compile into a real library;CRTLIBis not authorized, but the profile's current library (LONGDM1, notLONGDM) works — read it fromDSPUSRPRF, do not derive it from the profile name. - Compile listings arrive on stdout, severity totals included. No spool
retrieval, no
EVFEVENT, noCPYSPLFneeded. DFTACTGRP(*NO)is required for prototypes/subprocedures/ON-EXIT;NOMAINsources needCRTRPGMOD, notCRTBNDRPG.- DSPLY output is unreachable from stdout — it posts to the job's external
message queue, which dies with the job (verified against all 14
systemflags).printfviaBNDDIR(QC2LE)reaches real stdout; EBCDIC newline isx'25'. This is what a future differential execution run would need. - PASE
grepis AIX-derived: no-o.
221 compiled, 72 accepted. That headline number is not a quality metric: it conflates four unrelated things, and a large share of the rejections are the correct outcome.
| Bucket | Files | Meaning |
|---|---|---|
| A. Needs IBM i objects | 17 | *FILE/*DTAARA referenced via EXTDESC/DTAARA don't exist on the box. Not a language issue. |
| B. rpgc leniency — real findings | 35 | rpgc accepts source IBM rejects. The actual deliverable. |
| C. Not a valid standalone program | 22 | Declaration-only tests, spec-ordering. RNF7023 "cannot determine how the program can end". |
| D. Negative tests | 43 | IBM rejecting may be correct — see caveat below. |
| E. Individual review | 19 | — |
| SQL dialect | 13 | CONNECT USING :connStr is ODBC-flavoured; DB2 for i wants CONNECT TO :rdb USER :u USING :pw (SQL0199). |
- Column adjustment is not enforced.
fixed_columns.h::extractCol()trims both ends, so"0 "and" 0"are indistinguishable. IBM requires numeric entries right-adjusted and reportsRNF0263at severity 20. 89 D-specs across 67 corpus files were malformed and nothing ever flagged them. Corpus fixed; rpgc still does not diagnose this. - Blank Definition-Type accepted as "standalone". IBM reads blank
positions 24-25 as subfield of the enclosing group; with no group open
that is
RNF3703, severity 20. rpgc infers "standalone". 94 declarations across 72 files. Corpus fixed; rpgc still does not diagnose this. - BIFs permitted in Factor 1 of traditional C-specs.
C %CHAR(i) DSPLYis accepted by rpgc; IBM allows built-ins only in extended Factor 2 (RNF0372+RNF5261, 16 files). Not corrected — the fix restructures the test (EVALto a temp, thenDSPLY) rather than reformatting it, so it needs a decision about what those tests are meant to demonstrate. DSPLYcaps at 52 characters, enforced at compile time on the declared length, not the runtime value (RNF7016). 8 files still declare wider fields. No expected-output line exceeds 40 chars, so%SUBST(f:1:52)is viable.- UTF-8 em-dashes cannot survive the required CCSID 819 tag (U+2014 has no Latin-1 code point). 37 files carry them in comments; the conformance script folds them to ASCII at staging time.
Decision needed on 1-3: should rpgc enforce these, or only warn? Being more permissive than IBM costs nothing for the goal of eating real-world source — real source is well-formed. But silently accepting is exactly what let 183 malformed declarations accumulate unnoticed across 72 test files. Recommendation: warn, don't reject.
scripts/ibmi-setup-objects.sh creates everything the corpus references, all
of it derived rather than hardcoded: tables from the .extdesc sidecars,
program-described record lengths from the F-specs, data areas from DTAARA()
keywords. Re-runnable. NOSUCHDA96 is deliberately NOT created —
test96_da_status401 asserts the data-area-not-found status.
Created: 12 tables, 7 program-described PFs, 1 data area, 1 printer file.
Effect: RNF2120 "external description not found" went from 13 files to 2, and
the record-format clash is gone. The accepted count barely moved (72 -> 73)
because those files each carry several independent problems; clearing the
dependency layer exposes the next one rather than flipping the file green.
Three more parameters that are not guessable, all found by failure:
DECIMAL(9,2)is a parse error on this box. The job is German-locale, so9,2lexes as the number 9.2.DECIMAL(9 , 2)— spaces around the comma — parses. Do not "tidy" those spaces away.RCDFMTis required onCREATE TABLE. SQL names the record format after the table, and RPG rejects a format whose name matches the file (RNF2121+RNF2109).RUNSQLSTMneedsERRLVL(30).SQL0204(dropping a table that isn't there) is severity 30 and the default stops the whole script at the first one.
- Data area names longer than 10 characters.
RPGCTEST97DAandRPGCTEST98DAare 12 chars; IBM i object names cap at 10 (CPD0074), so these objects cannot exist. rpgc accepts arbitrary-length names. Fix is to rename in the corpus — not creatable on the IBM i side. EXTDESCliterals are case-sensitive on IBM i. RPG uses the quoted literal verbatim as the object name, and IBM i object names are uppercase, so a lowercase literal can never resolve (RNF2120). rpgc treats it case-insensitively and generates lowercasetable=lines in the.extdescsidecars it writes. Corpus fixed (11 files uppercased, rpgc output verified byte-identical); rpgc's sidecar generator still emits lowercase.- The
.extdescmodel cannot express CHAR vs VARCHAR. Both are recorded asstd::string(N), so rpgc cannot detect aVARCHARkey chained against aCHARkey field. IBM enforces it (RNF7080"Factor 1 field is not the same type as first key field"). The tests' ownEXEC SQL CREATE TABLEdeclaresVARCHAR, which is what the setup script now creates.
CRTSQLRPGI ... OPTION(*PERIOD). The verification job is German-locale, so the
SQL precompiler read , as a decimal point and VALUES(1,'Alpha') failed with
SQL0104 "Token ,1 was not valid". *PERIOD pins the decimal point regardless
of job locale. Note DECMPT() is not a parameter of this command — the
decimal point is an OPTION value, which is not obvious from the name.
Effect: Token ,NNN errors went from pervasive to one. It did not move the
accepted count, because it was masking the real blocker rather than being it —
but it converted "16 files failing for unclear reasons" into "16 files failing
for one well-understood reason", which is what made the next finding visible.
What is left in the SQL group is SQL0199 "Keyword USING not expected" on:
EXEC SQL CONNECT USING :connStr;
EXEC SQL DISCONNECT;
OpenRPG connects with an ODBC connection string
(Driver={SQLite3};Database=/tmp/x.sqlite). DB2 for i connects to a
relational database named in the RDB directory:
CONNECT TO :rdb USER :u USING :pw. A connection string has no expression as
an RDB name, so this is not a syntax difference that could be reconciled — the
two connect to different kinds of thing.
Verified by removing just the connection statements from test79_sql_core:
every SQL error at severity >= 20 cleared. (An RNF0637 remained from the RPG
side, in SQL-generated code; likely an artefact of the crude line removal, not
chased.)
Same category as record formats and free-format DDS: a divergence that follows
from the target platform, not a defect. 14 CONNECT and 13 DISCONNECT
statements across the tests.
Both compilers do support an ambient connection, which is worth stating
because it bounds the problem: IBM i's job is implicitly connected to its local
database, and rpgc reads RPGC_DSN (src/conf.cpp). Source carrying no
CONNECT compiles on both — 8 of the 24 SQL tests are already in that shape,
and it is how most shop RPG is written.
DECISION (2026-08-30): the 16 CONNECT USING tests stay as they are.
Connecting by connection string is a real rpgc feature and needs test coverage;
rewriting those tests to use RPGC_DSN would make them compile on IBM i but
would stop testing the thing they exist to test. The 16 files are counted as a
platform divergence permanently, not as work.
SUPERSEDED (2026-09-25): both compilers now accept the same files. rpgc
predefines the condition *OPENRPG, and the 18 tests wrap their CONNECT USING/DISCONNECT in /IF DEFINED(*OPENRPG). rpgc still compiles and runs
the connection-string path, so it is still tested. IBM i skips those lines and
uses the job's own connection. Probed on PUB400 before changing anything:
CRTSQLRPGIignores/IFby default and parses the SQL in both branches.RPGPPOPT(*LVL2)is required: it runs the RPG preprocessor first.ibmi-conformance.shnow passes it.- IBM accepts an unknown
*condition as simply undefined, so*OPENRPGis safe there. - A bare
DISCONNECTfails too (SQL0104) and must be guarded as well.CONNECT RESETis valid Db2 for i and stays unguarded. - A static statement against a missing table is only
SQL1103severity 10, and the program is still created. Tables need not exist to compile.
The "reject" entries for these tests are gone from tests/ibmi-expected.txt.
With SQL0199 gone, the first IBM i run (36088083268) accepted 12 of the 18
and found real differences in the other 6:
test78: procedures needCTL-OPT DFTACTGRP(*NO)(RNF1520). Test fixed.test82: Db2 for i requiresSAVEPOINT sp1 ON ROLLBACK RETAIN CURSORS. rpgc now accepts it and sends the portableSAVEPOINT sp1(portableSavepoint()insql_utils).test83: IBM i moves multiple rows only through a host structure array (a DS array), and spells the blocked INSERTINSERT INTO t (cols) :n ROWS VALUES(:ds). rpgc now supports both. One array per column with a trailingFOR :n ROWSstill works and is still tested, under/IF DEFINED(*OPENRPG).test105,test219,test238:RNF5063, the database record-format divergence below. Not changed, by decision (2026-09-25): no/IFworkaround either. Those tests andtest146are expected IBM rejections intests/ibmi-expected.txt.
Found on the way, fixed separately: CLEAR generated nothing for a data
structure, a DS array or a FLOAT, and invalid C++ (ARR = 0;) for a
standalone array. It now clears every element and subfield, including through
a LIKEDS parameter, and clears CHAR to blanks rather than to an empty string.
RESET had the same gaps (and RESET of an array with INZ referred to a
shadow copy that was never declared); it now restores whole arrays, CTDATA
arrays, varying arrays and data structures to their starting values.
Still open (small): whether rpgc should also accept CONNECT TO :rdb USER :u USING :pw so shop code carrying the IBM spelling compiles. Narrow — it only
affects source connecting to a remote database, since local access needs no
CONNECT at all. Same coverage-versus-conformance question as database record
formats. Not started.
11 .rpgle files carry embedded EXEC SQL and were being sent to CRTBNDRPG,
which parsed EXEC SQL ... as an EVAL (RNF5347). That looked like a
language finding and was purely a routing bug. Routing is now by file content.
rpgc now diagnoses the divergences the IBM i oracle found. All are warnings, not errors: being more permissive than IBM costs nothing for real-world source, which is well formed, but silently accepting malformed source is what let 183 bad declarations accumulate across 72 test files unnoticed.
New machinery:
report_fixed_format_warning()(parser.y) — prints, does not touchg_error_count, so it cannot fail a build.extractColRaw()(fixed_columns.h) — untrimmed slice.extractCol()trims both ends, which is precisely why adjustment was invisible to this compiler.warnIfNotRightAdjusted()(fixed_reader.cpp) — shared across F/D/I/O specs.
Checks added, each naming the IBM diagnostic it mirrors:
| Spec | Check | IBM |
|---|---|---|
| F | Record-Length, Key-Field-Length right-adjusted | RNF0263 |
| F | File-Type (pos 17) is I/O/U/C | RNF2003 |
| F | File-Format (pos 22) is F or E | RNF2006 |
| D | From-Position, To/Length, Decimal-Positions right-adjusted | RNF0263 |
| D | blank Definition-Type with no open group | RNF3703 |
| I | From/To-Position, Decimals, record-ID positions right-adjusted | RNF0263 |
| O | End-Position right-adjusted | RNF0263 |
Also a correctness fix, not just a diagnostic: a non-blank definition type
now ends an open DS group. Previously only a non-D specification did, so an
S standalone declared after a data structure was silently absorbed into it as
a subfield — which is not what IBM does.
Validated both directions: replaying the pre-fix corpus (git show 21b987e^)
raises the warnings on the files IBM rejected, and the current corpus raises
exactly 24 warnings across exactly the 7 files IBM still flags
(test154-test160) — an independent match with the oracle.
Note tests/run_tests.sh discards stderr for passing tests, so warnings do not
surface there. Run rpgc directly to see them.
Finding 9 — a conflict, not a leniency (fixed 2026-08-30). Two positions where rpgc required blank and IBM requires a value, so no source could satisfy both:
| Position | was (rpgc) | IBM | now |
|---|---|---|---|
| I-spec Sequence 17-18 | error if non-blank | required, RNF4008 if blank | alphabetic accepted (means "no sequence checking", which is what rpgc does); numeric still rejected |
| O-spec record Type 17 | error if non-blank | required, RNF6005 if blank | D (detail) accepted; H/T/E still rejected (need cycle timing) |
This is the other direction of the conformance matrix — rpgc rejecting valid RPG rather than over-accepting — and the first coverage fix the oracle produced.
Corpus (13 files) brought to IBM-acceptable form, with the derivation rules
encoded in fix-fixed-format-columns.py rather than hand-edited: File-Type from
what the source does (I-specs only -> I, O-specs only -> O, both -> U plus A),
File-Designation F for I/U/C and blank for O, File-Format F where a record
length is present, Sequence AA, record Type D. Result: test159 is now
fully accepted by IBM, test160 is down to its own deliberate error
(RNF4059), and bucket B fell 53 -> 48 as those files moved into bucket C with
only structural problems left.
All transforms now share one fixed_only() guard. Column 6 is meaningless in a
/FREE block, so record.id = 1; looks exactly like an O-spec — that hazard
corrupted a test three separate times during development before the guard was
centralised.
Enforcement (2026-08-30). All conformance checks are now errors, not
warnings. IBM reports every one at severity 20 (program not created), so source
that trips them would not compile on a real IBM i either — rejecting it cannot
break valid real-world code. report_fixed_format_warning() was removed once it
had no callers.
Findings 10-12, all fixed on both sides:
- Free-format code inside a fixed-format source must start at position 8.
Positions 1-5 are the sequence area and 6-7 the form type and comment flag.
The corpus indented
/FREEbodies by two spaces, soNAME = 'x';putNAMin the sequence area andEin the form-type column — RNF0257 at severity 30, across 24 files and 162 lines. rpgc accepted any column. Both fixed: blocks shifted (relative indentation preserved, longest resulting line 62 columns), and rpgc now rejects code before position 8. **FREEis a file-level declaration, so a/COPYmember is read as fixed-format unless it declares**FREEitself — exactly as on IBM i. rpgc's lexer returnedKW_FREEas a token the grammar only accepts at program start, so a copy member could not declare its own format. Fixed: the directive is consumed inside an include.- Specification order. O-specs must follow the calculations, and
main-procedure calculations must precede any
DCL-PROC— statements afterEND-PROCare "between procedures" (RNF0256, severity 30). rpgc does not care about either. Corpus fixed; rpgc still does not enforce order.
Also recorded: test61_no_free asserts that rpgc accepts free-format without
the **FREE directive. IBM requires it, so IBM rejecting that test is the
CORRECT outcome — it belongs in the extension quadrant, not the work queue.
Progress: accepted 73 -> 88 of 221. Bucket C (structural) fell 26 -> 8; RNF0256 is gone entirely, RNF0257 8 -> 4 files, RNF7023 16 -> 4.
Finding 3 resolved (2026-08-30), and the rule is broader than first recorded. Tested directly on IBM i 7.5 rather than inferred:
| form | IBM |
|---|---|
built-in in Factor 1 (DSPLY, IFGT) |
rejected |
built-in in traditional Factor 2 (MOVE) |
rejected |
built-in in extended Factor 2 (EVAL) |
accepted |
| field or literal in Factor 1 | accepted |
So built-ins are illegal in both traditional-syntax factors, not just
Factor 1 — they are expressions, and traditional factors take a field, literal
or constant. rpgc now rejects them there (containsBuiltIn() in
fixed_cspec.cpp, applied only in the TRADITIONAL branch, skipping % inside
character literals so '100% done' is not misread).
Corpus: 44 DSPLY sites across 19 files rewritten as
EVAL TMPDSP = %CHAR(n) followed by DSPLY TMPDSP, with one CHAR(52) work
field per file — every built-in used was %CHAR or %TRIM, both character, and
DSPLY caps at 52 anyway. No site carried a conditioning indicator, and the
transform bails out if one ever does rather than silently dropping the
condition. Copybook fragments get the rewrite but not the declaration, since
their parent declares it.
Progress: accepted 88 -> 100 of 221; bucket B 46 -> 29.
RLA C-spec group resolved (2026-08-30), 3 of 5.
KEYED is a free-form DCL-F keyword. In a fixed-format F-spec, keyed
access is the Record-Address-Type entry: K in position 34. IBM rejects the
keyword with RNF2367 at severity 20. rpgc accepted it in the keyword tail as
a deliberate extension (the comment in finalizeFSpec said so), and never read
position 34 at all — FSpec::RecAddrType was defined and unused.
Both fixed: rpgc now reads position 34 and rejects the keyword in fixed
format; five F-specs moved. The RNF7055 "Factor 1 ... is not valid" errors on
their CHAIN statements turned out to be knock-on from the malformed F-spec and
cleared with it — not the CHAR/VARCHAR mismatch first suspected. Verified on the
machine before changing anything.
Accepted 100 -> 103.
Still open in bucket B:
test118_fixed_rla_chainis not an RLA problem: it fails the SQL precompile on the decimal-comma locale, so it belongs in bucket E.
WRITE, UPDATE and DELETE against an externally-described file take the
record FORMAT name in Factor 2 on IBM i, not the file name (RNF5063,
RNF5198 on test146_fixed_cspec_write_upd_del).
This is not something to "fix" in rpgc. Record formats exist on IBM i
because DB2 for i tables are native *FILE objects underneath — CREATE TABLE produces a file with a record format (named for the table, or set by
RCDFMT). On SQLite, PostgreSQL, DB2 LUW — anywhere rpgc actually runs — no
such layer exists. A table is a table. rpgc's RLA resolves against table= in
the .extdesc sidecar, and there is no format name to resolve because the
concept has no referent off IBM i.
(The IRecordFormat/ORecordFormat types in the AST are unrelated: they model
I-spec/O-spec record identification for program-described flat files.)
DECISION (2026-08-30): rpgc does not support record formats for database files. It does support them for display files.
The line is drawn where the concept has a referent:
| File type | Record formats | Why |
|---|---|---|
| DISK (database) | no | The target is an SQL table. SQLite, PostgreSQL and DB2 LUW have no record-format layer, so there is nothing for a format name to denote. RLA resolves against table= in the .extdesc sidecar. |
| WORKSTN (display) | yes | A display file genuinely is a set of named record formats, and EXFMT must name one. Already modelled: ExFmt::format in ast.h, and OpenDSPF's descriptor carries records[]. |
Not a gap to close later — a deliberate boundary. Supporting DB record formats
would mean inventing an IBM i concept the runtime cannot honour, and coupling
source to whatever RCDFMT a file happened to be created with.
Consequence, stated plainly: shop RPG that names a database record format in
WRITE/UPDATE/DELETE/READ will not compile unmodified; the file name has
to be substituted. That is a real coverage cost on the "compile real-world RPG"
goal, accepted knowingly.
test146 counts as a platform divergence alongside free-format DDS, not as a
defect in the work queue.
test118_fixed_rla_chainis not an RLA problem: it fails the SQL precompile on the decimal-comma locale, so it belongs in bucket E.- Bucket F grew to 18 as files shed their bulk diagnostics and reached single, individual causes — that is progress, not regression.
TAG is the mirror image of GOTO: GOTO names its target in Factor 2,
TAG declares its label in Factor 1 with Factor 2 blank. rpgc treated the two
identically and wanted the label in Factor 2 for both, which IBM rejects with
RNF5009 ("Factor 1 operand is required") plus RNF5025 ("Factor 2 entry is
not blank").
Verified on the machine before changing anything: C LOOPTOP TAG compiles,
C TAG LOOPTOP does not. Fixed in fixed_cspec.cpp and in 6 lines across 4
tests. Accepted 103 -> 106.
Still open, one file: test182_fixed_cspec_cas_cab uses a bare CAB for an
unconditional branch:
C CAB done
IBM requires both factors even with no comparison operator (RNF5009 +
RNF5023), though a bare CAS on the same file is accepted. Either supply the
factors or use GOTO for the unconditional case. Narrow, one file, not chased.
Bucket D mixes two things the diagnostics cannot separate:
- Genuinely invalid RPG (
test11a_missing_semi,test11c_unmatched_if,test184_err_cas_unclosed) — IBM rejecting is the pass condition. - The
unsupported/quadrant (test135_err_ctllevel,test138_err_legacy,test148_goto_tag) — valid RPG that rpgc rejects on purpose. IBM ought to accept these. It didn't:test135returnedRNF5002, which suggests the test's own control-level usage is malformed rather than IBM agreeing with rpgc. Some of bucket D is hiding further source bugs.
Splitting these requires reading each test's intent; it is not inferable from the output.
- Object setup for bucket A — generate DDL from the
.extdescsidecars and create the*FILE/*DTAARAobjects once inLONGDM1. Cheapest real win: 17 files stop failing for reasons unrelated to the language. - Bucket B → diagnostic work on the fixed-format reader (findings 1-3).
- Cache the oracle. Commit
sha256(source) -> {verdict, diagnostics}so CI compares against cached results offline and PUB400 is consulted only for changed files. This is what turns the exercise into a build step. - Human pass over bucket D (above).
- Differential execution — built 2026-09-26; see "Differential
execution" below. It routes DSPLY to a message queue rather than
rewriting it to
printf.
Three pieces turn the manual expedition into something CI runs.
| Piece | Role |
|---|---|
ibmi-conformance-baseline.json |
The record: SHA-256 of every source plus the verdict IBM gave it — and, for a rejection, the root-cause reason, IBM's messages (code, line, text) and the date it was last compiled. Committed — this, not the transcript, is the durable artefact. |
scripts/conformance-baseline.py |
check (offline gate), changed (what to send), update (merge a run, report regressions, record reasons), rpgc (record rpgc's own verdicts — compile-only, rpgc -c, as IBM compiles each source on its own; linking made the ten caller/callee halves of multi-program tests count as rejections). |
scripts/conformance_lib.py |
Shared: reading a transcript, extracting IBM's messages (wrapped text joined), tracing a rejection to its root cause. |
scripts/conformance-wiki.py |
Renders the wiki page IBM-i-Conformance-Results: every program, whether it should compile on IBM i, whether it did, whether rpgc compiles it, and IBM's reason when not. The workflow builds rpgc and records its verdicts first. |
tests/ibmi-expected.txt |
Exceptions to the should-compile rule (an error-mode test should be rejected, everything else should compile), each with its reason. |
scripts/conformance-diff.py |
Writes ibmi-conformance-differences.md: every file where rpgc and IBM disagree. |
.github/workflows/ibmi-conformance.yml |
offline job on every push/PR; verify job on demand only (the weekly schedule was removed 2026-09-23). changed, full or smoke; after changed/full it commits the refreshed baseline back to main (not on a regression; the report and differences files only after full) and publishes the results page to the wiki. |
The offline gate needs no network and no secrets, so it runs on fork pull requests too. It fails when a source has changed since it was last verified — i.e. when someone edits a test without re-verifying it. That is the check that makes the whole thing durable: without it the baseline silently rots.
The online job sends only sources whose hash moved. A no-op run exits immediately without contacting the machine at all.
What fails a build is a REGRESSION — a source IBM accepted before and rejects now. The absolute accepted count is deliberately not a gate: it conflates missing IBM i objects, deliberate negative tests, and real findings, so gating on it would fail builds for reasons that say nothing about rpgc.
Setup required before the verify job can run: two repository secrets,
PUB400_SSH_KEY (the private key matching what ssh-copy-id installed) and
PUB400_USER. Until they exist the job fails fast with a clear message rather
than timing out. The workflow pins the host key with ssh-keyscan instead of
trusting on first use.
Note the transcript is per-run, not cumulative: a --changed-only run leaves a
transcript covering only those files. The baseline is what accumulates.
The wiki's IBM-i-Compatibility page, generated by hand with
scripts/conformance-summary.py, was removed along with the script: nothing
kept it current, and it had fallen far behind the data. IBM-i-Conformance-Results
(compiling) and IBM-i-Execution-Results (running) are the published results.
The headline "accepted N of 221" understates agreement, because a rejection can be the correct outcome. Comparing both compilers' verdicts as of 2026-08-30:
| rpgc accepts | rpgc rejects | |
|---|---|---|
| IBM accepts | 88 | 12 |
| IBM rejects | 80 | 41 |
129 of 221 agree (58%). The 41 mutual rejections are negative tests working
as intended. The 80 over-acceptances are the real work queue. The 12 nominal
coverage gaps are partly a measurement artefact — several are link failures
from compiling callee modules (ADDTWO, test48_nomain, test174,
test197) standalone, not compile errors; a compile-only measurement would
shrink that column.
Full corpus re-run on PUB400 (265 sources). Agreement with IBM rose from
58% (2026-08-30) to 165 of 265 (62%). scripts/conformance-diff.py
writes ibmi-conformance-differences.md: every disagreement, grouped by root
cause, with IBM's message, source line and text.
-
The run's five "regressions" were environmental. PUB400 had deleted the test files and data areas in
LONGDM1;ibmi-setup-objects.shrecreates them. It now also honours the.extdesccolumn kind (CHARvsVARCHAR,DOUBLEfor a float column). -
Enforced in rpgc, matching IBM's message IDs:
%DIV/%REMby a literal 0 (RNF0552)- a
CHAR/VARCHARINZlonger than the field (RNF3431) PREFIXon a program-described DS (RNF3529)ds.fieldon a DS that isn'tQUALIFIED(RNF7030)
Tests 239, 250 and 253, written this week, had used three of these; they are corrected. Test 63, whose premise was
PREFIXon a program-described DS, is now an error test. Tests 255-257 are new. IBM was re-run on all seven: every one agrees. -
The remaining leniency queue, from the differences report, largest first:
- declarations after calculations (17)
DSPLYof a field declared longer than 52 (10)- an unquoted
DTAARA(name)(4) - operations after
ENDSR(3) - type mismatches (3)
LIKEof an unqualified sibling subfield (2)
Two DSPLY rules, both found by test 87 (whose XML-INTO IBM accepted all
along — every error was in the DSPLY lines printing the results):
- An expression must be in parentheses (RNF0637).
DSPLY's operands are blank-separated (message, message queue, response), soDSPLY 'Total: ' + x;does not parse on IBM i;DSPLY ('Total: ' + x);does. Expressions carry aparenthesizedflag for this. - The message is at most 52 characters, judged from declarations
(RNF7016).
CodeGen::displayLengthcomputes the maximum as IBM does: literals, declaredCHAR/VARCHARlengths,%CHARof a number (digits + sign + point; 11 for an INT, 21 for integer arithmetic), of a date (10), time (8), timestamp (26), float (14/23), indicator (1),%TRIM/%SUBST, and concatenation. It matched IBM's reported length on every flagged line in the corpus.
36 tests broke one rule or both. The free-form ones were fixed by script
(operand parenthesized; an over-long message assigned to a VARCHAR(52)
work field first); the fixed-format report fields were shrunk from 60/70 to
52, changing only trailing blanks. Test 217's detail line was 59 characters
of real content, so it now prints over two lines with every value kept.
On PUB400, 15 of the edited tests now compile — among them XML-INTO tests
87-89 — and IBM rejects negative tests 262-263 as rpgc does. Known wrinkle:
in a fixed-format C-spec run, rpgc can report a statement's line a few
lines late (test 217: 113 for 104).
A procedure interface's name is *N or the procedure's own name; IBM i
rejects anything else with RNF3767. Written with no name (DCL-PI INT(10);)
IBM reads the return type as the name, so the same message covers both.
rpgc accepted both; the pi_name rule now checks as soon as DCL-PI is
read, so the error carries the DCL-PI's line, as IBM's does. Tests 08 and
78 and four docs/GUIDE.md examples were unnamed; test 08 now compiles on
IBM i. Tests 264-265 are the rejected forms, both matched on PUB400.
Once a procedure's first BEGSR appears, only more subroutines may follow in
it; IBM i ignores each calculation after an ENDSR with RNF5005 (severity
30). rpgc ran such statements as mainline. checkSubroutinesLast now reports
each one, in the main procedure and in each subprocedure, skipping
declarations (RNF0724/0725's to report), subprocedure definitions and
fixed-format I/O layouts. Tests 13, 66 and 67 had mainline after their
subroutines; with the subroutines moved to the end, all three compile on
IBM i, output unchanged. Test 266 is the rejected form; IBM and rpgc flag
the same three lines.
+=, -=, *=, /= and **= are parsed as target = target op (value),
with cloneExpr copying the target, for a plain assignment, EVAL and
EVAL(x), so codegen needed nothing new. rpgc had none of them, and IBM i
accepts them all; test 267 covers each operator, arrays, qualified
subfields, strings and EVAL(H), and compiles on PUB400. Found alongside:
fixed-format C EVAL *INLR = *ON was a syntax error in rpgc; it works
now (rechecked 2026-10-27).
*INLR = *ON; was the only form rpgc knew, and it compiled to RETURN —
so the statements after it never ran, where IBM i runs the rest of the
calculations and ends the program where the mainline ends. *INLR = *OFF,
IF *INLR, NOT *INLR and fixed-format EVAL *INLR = *ON were syntax
errors. *INLR is now an indicator (IndicatorExpr::LR, emitted as
rpg_inlr), and fixed-format LR/NLR conditioning and COMP resulting
indicators accept it. As on IBM i, a program that tests LR without setting
it anywhere is rejected (RNF7030); test 166 now fails for that reason, the
same line and code as IBM. Tests 268-269 compile on PUB400.
With LR off at the end of the mainline, an IBM i cycle program with no primary file runs its calculations again; rpgc does too since 2026-10-27 (test453, checked on PUB400).
Found alongside, both done 2026-10-27 after probing PUB400:
- IBM rejects any indicator referred to but never set (RNF7030), not only LR: 01-99 and H1-H9 as rpgc has them. An assignment, a resulting indicator, an I-spec or a display file sets one; passing it as a parameter does not. Any use of the *IN array as such (*IN(i), %ADDR(*IN), CLEAR *IN) turns IBM's check off; rpgc has no *IN array. Tests 449-450.
- Factor 1, factor 2 and the result field must each be left-adjusted (RNF0262, severity 20); an indented extended factor 2 is fine. Tests 451-452.
- Also found: rpgc had no SETON/SETOFF and no *IN array; both done 2026-10-27 (tests 454-455): *IN(i) as a value or target (status 121 outside 1-99), *IN = value and CLEAR *IN. Then BASED data structures (free and fixed, with LEN), indicators held as '0'/'1' bytes so one can be based on %ADDR(*IN), %LOOKUP over *IN, and a pointer's INZ, which was dropped (tests 456-458); then LIKEDS, LIKE and DIM subfields inside one, OVERLAY(ds:pos) on one, and bare DIM subfields of any data structure without QUALIFIED, which were a compile error (tests 459-462). Not done: the indicators U1-U8, KA-KY, L1-L9, MR, RT, 1P, OA-OG, OV, which IBM i takes and rpgc will not (2026-10-27).
rpgc accepted a BOOLEAN data type as an alias for IND, added in the
belief that IBM shipped one with DCL-ENUM in 7.5 TR3 / 7.4 TR9. It did not:
IBM i rejects DCL-S x BOOLEAN; (RNF3308). Removed; use IND.
DCL-ENUM constants were written RED = 1;, or RED; for an automatic
0, 1, 2...; IBM i accepts neither. Its form is DCL-C's: RED 1; or
RED CONST(1);, with the value required (RNF3905 without one). Checked on
PUB400: character values ('O'), negative values and x IN enum are all
valid, and rpgc now handles each — constants are typed from their value as
DCL-C constants are, and IN expands the enum into its constants. An
operation-code name (open) cannot be a constant on IBM i; rpgc does not
check that. Test 71 now compiles on IBM i; test 270 is the rejected form.
rpgc turned OVERLOAD into C++ overloading, so C++ picked the closest
candidate: abs(-7) went to an INT(10) candidate over a FLOAT(8) one. IBM
i does not rank candidates. A call must fit exactly one, and all three of
IBM's OVERLOAD rules are now enforced, each matched on PUB400:
- the overloaded prototype is one statement, no END-PR (RNF3551);
- every candidate returns the overload's type (RNF3244);
- a call that fits no candidate is RNF3245, one that fits several RNF3246. A VALUE/CONST parameter takes any argument of its type family (number, character, date, time, timestamp, indicator, pointer); a by-reference parameter takes only a variable of exactly its type (checked on PUB400: a literal fits neither of two by-reference candidates).
resolveOverload sends each call straight to the one candidate it fits.
When an argument's type cannot be worked out in codegen, it falls back to
the old C++ wrappers rather than report a false error. Test 110 was
rewritten: INT-vs-FLOAT dispatch through VALUE is ambiguous on IBM i. It
now overloads by type family and by number of parameters, and compiles
on PUB400. Tests 271-274 are the four rejected forms.
Found alongside: typed literals (D'2024-01-15', T'...', Z'...') and
%DATE(string : *ISO) with a format were syntax errors in rpgc; both work
now (rechecked 2026-10-27).
scripts/ibmi-execution.sh compiles every standalone run test on PUB400,
runs it, and compares what it displays with tests/expected_output/. This
is the oracle for behaviour: the compile check cannot see a program both
compilers accept but run differently, and expected_output/ is written by
rpgc itself. On demand: gh workflow run ibmi-conformance.yml -f mode=execute.
Results: ibmi-execution-differences.md, from ibmi-execution-baseline.json
(IBM's output per source, keyed by SHA-256), and the wiki page
IBM-i-Execution-Results (scripts/execution-wiki.py, published by the
execute run). Why each program differs is kept by hand in
tests/ibmi-execution-notes.txt (rpgc / platform / test / open); its
rpgc entries are the work queue, and a fix deletes its line.
How, all verified on the machine first:
- DSPLY goes to a message queue, not
printf. DSPLY's second operand names a message queue.scripts/exec_transform.pyadds'RPGCOUT'to every DSPLY (free, fixed, conditioned, multi-line); the harness reads the queue back in order (QSYS2.MESSAGE_QUEUE_INFO). Nothing else in the source changes, so this checks IBM's own DSPLY formatting. Theprintfroute would have replaced DSPLY with%CHARand hidden exactly that. - INQMSGRPY is
*RQD. An unhandled runtime error sends an inquiry to the system operator and the job waits forever (found by hanging one, which had to be ENDJOBed). Programs run through a CL driver that setsINQMSGRPY(*DFT)and reportsRPGCRUN-ERROR <msgid>. - A watchdog ends a program still running after 60s. The driver records its job in a data area, and the watchdog ENDJOBs it.
- Program-described files keep their records between runs, so each DISK
file a test names is cleared (
CLRPFM) first. - Other LONGDM jobs (
TLS,CORS) run on the box concurrently. A first version deleted every spool file the profile created during the run, which took four of their job logs. It now only reports its own driver jobs' leftovers.
First run (full, 175 programs): 124 same, 31 differ, 19 runtime errors, 1 timeout. 8 run tests not run (linked pairs, IBM rejects). A first-pass triage, not yet investigated test by test:
| Group | Tests |
|---|---|
%EDITC/%EDITW keep their fixed width (leading blanks); rpgc trims |
215, 220, 221, 222, 223, 224, 225, 32 |
%CHAR of FLOAT is +1.500000000000000E+000 |
60, 70 |
%CHAR keeps an expression's decimals (112.50, not 112.500000); zero is .00 |
161, 162, 163, 23, 39 |
| DS without INZ starts as blanks: decimal data error (MCH1202), blank INT reads as 1077952576 | 249, 91, 230, 236, 23 |
| One-off behaviours | 26 (%SIZE of pointer 16), 33 (%REPLACE), 50, 69 (%SCANR), 67 (*PSSR), 90 (PSDS user/job), 93/96 (data areas), 102 (SND-MSG escape), 155/216 (I-spec record ID), 193/196/208/212 (MOVE), 228 (loops forever), 267, 283, 87/88 |
| Probably platform divergence | DATA-INTO/DATA-GEN name rpgc's built-in parsers (RNX0355: 99, 100, 112, 113, 114, 180, 87?); 54 (program name); 115 (DUMP goes to spool) |
Fixed 2026-09-29: DUMP only when IBM i would dump (test115, test313, test314). DUMP writes a dump only with CTL-OPT DEBUG(*YES)/DEBUG(*DUMP) (or H DEBUG), or as DUMP(A); otherwise it does nothing -- rpgc had always dumped. Where the dump goes stays a platform difference by decision: IBM i writes a spooled file, rpgc prints it (tests 313 and 314). Found alongside: the fixed-format C-spec DUMP operation is not supported.
*Fixed 2026-09-29: SND-MSG ESCAPE (test312; test102 partly). By default an *ESCAPE goes to the caller: the sending procedure ends and its own MONITOR and *PSSR do not see it; the caller gets status 202 (CPF9898 in the PSDS), and from the main procedure it ends the program. %TARGET(*SELF) sends it to the procedure itself, status 9999. rpgc had thrown it at the sender's own MONITOR and printed it. Verified on PUB400. *INFO/*DIAG/*COMP still print to stderr, by decision: rpgc has no job log (test102 is a platform difference).
Fixed 2026-09-29: data area locks and errors (test93, test96). OUT needs the data area locked (IN *LOCK) -- status 412 without; OUT releases the lock, OUT *LOCK keeps it, UNLOCK releases it (unlocking one not locked is not an error). *LDA/*GDA/*PDA need no lock. An error (401 not found, 412, 413, 415) ends the program unless (E) or a MONITOR handles it; rpgc had only set %STATUS. IN(E), OUT(E) and UNLOCK(E) are now accepted. All verified on PUB400.
Fixed 2026-09-29: XML-INTO matches as IBM i does (test87, test88). The options decide the match and a mismatch is status 353: case=lower (the default) / upper / any; allowmissing=no (default) needs an element or attribute per subfield, =yes keeps the old value of one without; allowextra=no (default) needs a subfield per element; without a path the outermost element is named as the variable, and a path names the element to read from the root (for an array, the repeated one: path=items/item). rpgc had matched loosely, set missing subfields to zero and ignored extras. For arrays IBM ignores surplus elements (no error): a fixed array takes its dimension, a DIM(*VAR) array its current count (unchanged), a DIM(*AUTO) array up to its maximum and its count becomes the number read. All verified on PUB400.
Fixed 2026-09-29: the PSDS, and %SUBST out of range (test90, test91, test311). The PSDS used an invented layout (program name at 81, user at 91, job number at 101...), where IBM i has the library at 81 and exception data at 91. It now uses IBM's positions -- status 11, previous status 16, parameters 37, exception type/number 40/43, exception data 91, job 244-269, run date/time 276/282, program 334, module 344, current user 358 -- and is refreshed at every ON-ERROR and *PSSR, so a handler sees the error's status and ID; it had been filled once, at start. %SUBST was plain substr(): a start past the end threw a C++ exception (status 0 in a MONITOR, an abort outside one) and a length past the end quietly shortened the result. Both are status 100 now. All verified on PUB400.
Found alongside: %SUBST(x : 1 : 3) = ... (%SUBST as an assignment target)
is not supported; and IBM rejects a constant %SUBST start beyond the declared
length at compile time (RNF0364), which rpgc does not check.
*Fixed 2026-09-29: PSSR endings and errors leaving a procedure (test67, test239, test310). A *PSSR run because of an error that reaches ENDSR (no RETURN) ends the program in error (RNX9001) -- rpgc had ended normally, so nothing could tell the program failed. In a procedure, the procedure ends in error instead. An error that leaves a procedure reaches its caller as status 202 ("called program or procedure failed"), whatever it was inside; fitting a VALUE argument is part of the call and keeps its own status (103). RETURN in any subroutine now returns from the procedure or program (above). All verified on PUB400.
Found alongside: the one-line DCL-PI *N INT(10) END-PI; (no parameters) is
valid on IBM i and a syntax error in rpgc.
Fixed 2026-09-28: %SCAN / %SCANR (test69). Both search a portion: from the start (default 1) for the length (default: to the end), and a match must fit inside it. A %SCANR start begins the portion; rpgc had searched only up to it. The length argument was ignored for both. A start outside the source (even one past the end) or a length past the end is status 100; with no start, an empty source is 0. All verified on PUB400.
Fixed 2026-09-28: %DECPOS (test50). It is the operand's declared decimal positions, a constant worked out at compile time -- 2 for a PACKED(7:2) whatever it holds -- not the scale of the value (it counted 3.5 as 1). A decimal literal keeps the scale it is written with (1.50 is 2, not 1), + and
- take the larger scale, * the sum, %DEC(x:p:s) s. All verified on PUB400.
Fixed 2026-09-28: %REPLACE (test33). Without a length it replaces as many characters as the replacement has, cut off at the end of the source; it had inserted. An omitted start is 1; a start outside the source, or a length given that runs past its end, is status 100 (all verified on PUB400).
Fixed 2026-09-26: ON-ERROR codes, keyword names, array bounds (150 of 180).
ON-ERRORtakes status codes, named constants,*PROGRAM(100-999),*FILE(1000-9999) and*ALL, and a MONITOR may have several clauses. The first match handles the error; an error no clause lists goes on up (test305). Only a bareON-ERROR;parsed before.- Names: IBM reserves almost no words (probed on PUB400: every keyword
and operation code tried was a valid DCL-S name except NOT). rpgc
reserved 139. Now a keyword is a name everywhere, with IBM's two
exceptions for free-form operation codes (IN, OUT, READ, TEST...): at the
start of a statement one is that operation, so the assignment needs EVAL
(test307), and as a subfield it needs DCL-SUBF (test308). The grammar's
kw_name/op_namerules are generated from the lexer's keyword list. MOVE, MOVEL, CALL and GOTO count as operation codes here (IBM i does not have them in free form), since rpgc parses them as free-form statements.TYPE(andCONST(cannot start a call or array element, where they are keywords (SND-MSG TYPE, an enum's CONST). - Array indexes are checked: out of range is status 121, not a read or write past the end. A DIM(*AUTO) array grows to the index, up to its maximum.
<windows.h>defines IN, OUT, DELETE and CONST as macros; the SQL runtime #undefs them so fields with those names survive on Windows.
Fixed 2026-09-26: formatting and INZ (124 -> 148 of 178 same).
%EDITClays out the field's full edited width for every edit code (1-4, A-D, J-Q, X, Y, Z): commas, zero suppression, CR / trailing / floating minus. Codes 3 and 4 had wrongly attachedCR. The code must be a literal or named constant (RNF0355, now enforced; test304).%EDITWuses the operand's decimals, not a fixed two. O-spec edited fields end at their end position.%CHARof a decimal has no leading zero (.00,-.05) and keeps an expression's scale (+/-the larger,*the sum). FLOAT is+1.500000000000000E+000(8-byte) or+1.5000000E+00(4-byte), and so is%EDITFLT.- DSPLY of a numeric field shows IBM's form: digits right-adjusted, no decimal point, trailing minus. DSPLY of FLOAT, DATE and TIME works (DATE and TIME did not compile).
- A data structure without INZ starts as blanks (decision 2026-09-26:
match IBM rather than zero-fill). Integer subfields read as blank bytes
decode (INT(10) 1077952576), and packed/zoned subfields raise a decimal
data error (907) when used. DS-level
INZ,INZ(*EXTDFT),INZ(*LIKEDS)and subfieldINZ(value)are now supported, free and fixed. Tests 230, 249 and 267 gainedINZ. test226 went from "reject" to "run". Every test output involved was checked against IBM's. - A data structure declared inside a procedure had no instance at all, so the generated C++ did not compile. It is now declared and initialized where it is declared.
INT(n)/UNS(n)keep their digit count (it was dropped), so%EDITCand blank storage know an INT(5) from an INT(10).- Not modelled: a blank DATE, TIME, VARCHAR or indicator subfield (they keep their defaults), and comparisons do not raise 907 (arithmetic, assignment and display do).
PUB400 is a free community box run on donated hardware. This is a manual job, never a per-PR gate: serial, objects deleted as they go. (A weekly schedule existed until 2026-09-23; it never committed its results, so it only re-sent the same files each Monday.) Once the oracle cache (step 3) exists, day-to-day CI needs no network at all.
Source: https://ibm-power-systems.ideas.ibm.com/ideas/?category=7078724330326335155
Append &sort=popular&page=N to walk the list vote-ranked, highest first
(~290 ideas over 29 pages as of the last pull).
Last pulled: 2026-10-21 — vote-sorted walk down to a 20-vote floor (61 ideas, pages 1-7 of 29). Eighteen ideas above the floor were in no part of this file; each was judged from its description and, where it might already work, by compiling a program with rpgc.
Why this list exists: the portal is a free, continuously-updated, vote-ranked backlog of what real RPG shops actually want from the language. IBM marks many of the highest-voted entries "Not under consideration" — usually because of ILE/IBM i compatibility constraints this compiler doesn't carry. Those are the highest-value entries here, not the lowest: they're things RPG developers demonstrably want and will never get from IBM.
- Walk
…&sort=popular&page=Nfrom N=1 upward, one page at a time, collecting each idea's title, vote count and IBM status. Stop once vote counts drop below 20 (~6 pages, ~60 ideas). If page 1 comes back not vote-ordered, the sort parameter has broken — say so, and walk unsorted pages 1–10 instead. - Diff against this whole file, not just this section. Skip anything already covered under Implemented Features, Remaining Work, or Not Planned, or already listed in a tier below. Vote counts drift — refresh them in place rather than adding duplicate rows.
- For genuinely new ideas, judge fit by reading the actual code
(
src/lexer.l,src/parser.y,src/codegen.cpp,runtime/,OpenDSPF/) before estimating effort — don't guess. Keep each row's note concrete and specific to this codebase; effort notes here are first-read judgments against the code, not designs. - Sort into the tiers below. Tier 1 (IBM declined it, and it's cheap here) is the highest-value tier, not the lowest — those are things RPG shops demonstrably want and will never get from IBM, because IBM carries ILE/IBM i compatibility constraints this compiler doesn't.
- Update the Last pulled date in this section's header.
- If nothing changed, don't open a PR — just report that. Otherwise commit to a
branch
ideas-pull-YYYY-MMand open a PR againstmaintitledTODO: IBM Ideas portal pull YYYY-MM, whose body lists what was added, which vote counts moved, and anything deliberately skipped and why.TODO.mdonly — the re-pull never changes code.
The differentiators. Every row is "Not under consideration" at IBM.
| Votes | Idea | Why it's cheap here |
|---|---|---|
| 93 | ✅ RPG block comments (/* … */) — done 2026-10-06 (test350-352) |
Highest-voted RPG idea in the whole portal. One flex rule in lexer.l (alongside the existing "//".*). Caveat: fixed-format needs separate handling in fixed_reader.cpp, where * in column 7 is already the comment marker. |
| 90 | ✅ DSPF definitions from an open format (JSON) — done 2026-10-06 (OpenDSPF test30); PRTF is not built | OpenDSPF already compiles DDS → a JSON descriptor. Accepting that JSON as dspfc input is mostly plumbing — shipping the exact thing IBM declined. |
| 40 | ✅ %FKEY built-in function — done 2026-10-06 (test353, OpenDSPF test31) |
rpg_dspf_runtime.h already decodes KEY_F(1)–KEY_F(24) into a function-key number; this is a BIF over state already tracked. |
| 39 | ✅ Multiple definitions in one DCL-S — done 2026-10-06 (test354) |
Grammar-only change. |
| 37 | ✅ String interpolation, f'...{expr}...' — done 2026-10-06 (test355-357) |
Lexer change + desugar to concatenation in codegen. No runtime work. |
| 30 | ✅ Procedure inside a procedure — done 2026-10-06 (test358-359) | Subroutines already codegen as C++ lambdas — the mechanism exists. |
| 30 | ✅ A NOT IN operator — done 2026-10-06 (test360; IN an array fixed too, test361) |
IN already ships (Test 58). |
| 28 | ✅ Regular-expression BIFs: %MATCHES, %FIND, %COUNTMATCHES — done 2026-10-06 (test362-363) |
std::regex — the compiler already uses it (sql_utils.cpp); generated code would too. |
| 27 | ✅ Relax the %SUBST "length exceeds data" error: CTL-OPT OPTION(*NOLENCHK) — done 2026-10-06 (test364-365) |
Diagnostic policy, not new machinery. |
| 25 | ✅ %CHAR with %EDITC formatting: %CHAR(n : *NOZEROSUPPRESS) — done 2026-10-06 (test366-367) |
Both BIFs already ship; this merges them. |
| 15 | ✅ Multiple conditions in one /IF directive: AND, OR, NOT, parentheses — done 2026-10-06 (test369-371; free-form /ELSEIF after an untaken /IF fixed too, test368) |
Directive handling already lives in the lexer. |
| 13 | ✅ *TRUE / *FALSE figurative constants — done 2026-10-06 (test372-373) |
Aliases of *ON / *OFF for IND. |
| 26 | LIKE of a subfield of a nested DS array: LIKE(DS_A.DS_Arr_B.field1) (IBMI-I-2973) |
LIKE through nested data structures works (test443); through an array of them it is blocked by the known gap of LIKEDS + DIM subfields (see Known gaps). |
| 21 | Time format *HM (hours and minutes) for %TIME, %DEC and DDS TIMFMT (IBMI-I-2139) |
One more entry in the time-format tables (rpg_runtime.h), and in OpenDSPF's TIMFMT for the DDS half. |
| Votes | Idea | Note |
|---|---|---|
| 93 | ✅ Conditional (ternary) operator, as %IF(cond : a : b) — done 2026-10-06 (test386-388); ?: itself would clash with : between BIF operands |
Ties for #1 overall. C++ has it natively, so codegen is a passthrough; grammar is the work. |
| 49 | ✅ Keyword parameters in prototyped calls, name => value — done 2026-10-06 (test390-394; a real argument for an *OMIT parameter fixed too, test389) |
|
| 49 | ✅ %SCANRPL limited to *FIRST / *LAST occurrence — done 2026-10-06 (test397; its start and length, ignored until now, test396; an empty scan string, test398) |
SCANRPL already in codegen.cpp. |
| 48 | ✅ OPTION(*UPPER) / OPTION(*LOWER) on parameters — done 2026-10-06 (test400-401; OPTIONS(*TRIM), parsed but never applied, done too, test399) |
|
| 44 | ✅ Dynamic strings: DCL-S text CHAR; — done 2026-10-07 (test402-404) |
CHAR is already a std::string. |
| 41 | ✅ Initialize arrays with %LIST: INZ(%LIST(...)) — done 2026-10-07 (test405-407) |
%LIST already ships. |
| 39 | ✅ %XFOOT over subfields of a DS array: %XFOOT(ds(*).subf) — done 2026-10-07 (test409-410; ds(*).subf itself in SORTA and %LOOKUP, standard RPG, test408) |
|
| 38 | ✅ BIF for comparing data structures: %COMPCORR(ds1 : ds2 {: n}) — done 2026-10-07 (test411-413) |
|
| 35 | ✅ DEPRECATED keyword on procedures — done 2026-10-07 (test415; a call to a procedure defined later with no DCL-PR fixed too, test414) |
Just a compiler warning. |
| 35 | ✅ %LOOKUP searching more than one subfield: %LOOKUP(%KDS(key) : ds(*)) — done 2026-10-07 (test416-417) |
|
| 29 | ✅ %REPEAT BIF — done 2026-10-07 (test418-419) |
|
| 28 | ✅ Default values for *NOPASS / *OMIT parameters: DEFAULT(value) — done 2026-10-07 (test420-421) |
|
| 27 | ✅ %PROGNAME BIF — done 2026-10-07 (test422) |
|
| 26 | ✅ Binary literals, like the hex form: B'10100001' — done 2026-10-07 (test424-425; character operands for %BITAND/%BITOR/%BITXOR/%BITNOT fixed too, test423) |
Hex literals already ship (Test 112). |
| 22 | ✅ Named index of the current FOR-EACH iteration: INDEX(i) — done 2026-10-07 (test427-428; arr(*NEXT) on a DIM(*AUTO) array, standard RPG, done too, test426) |
|
| 22 | ✅ Comparison procedure for SORTA / %LOOKUPxx: %PADDR(proc) — done 2026-10-07 (test429-430) |
|
| 22 | ✅ WHEN-IS-NOT on the newer SELECT, with WHEN-NOT-IN — done 2026-10-07 (test432-433; SELECT operand / WHEN-IS / WHEN-IN itself, standard RPG, test431) |
Needs SELECT/WHEN-IS first. |
| 18 | ✅ %SCANRPL first/last (duplicate filing of the 49-vote entry) — done with it |
|
| 16 | ✅ *EMPTY figurative constant — done 2026-10-07 (test434) |
|
| 14 | ✅ %HEX / %TOHEX / %FROMHEX — done 2026-10-07 (test435) |
|
| 12 | ✅ /MESSAGE compiler directive: /MESSAGE {*WARNING | *ERROR} 'text' — done 2026-10-07 (test436-438) |
|
| 38 | EXTENDS(base) on a DS template: the base's subfields plus its own (IBMI-I-3447) |
LIKEDS already copies a template's subfields; this is that plus the DS's own, in one declaration. |
| 36 | %PARMLEN(parm): the passed length of an OPTIONS(*VARSIZE) parameter (IBMI-I-2956) |
*VARSIZE is parsed; a character parameter is a std::string, so its length is already at hand. |
| 32 | LIKEFLD / LIKE(file.format/field : +n): a field defined from a field reference file (IBMI-I-4705) |
The .extdesc sidecars (src/extdesc.cpp) already describe external files' fields; this reads a field's type from one. |
| 25 | Hide the procedures behind an OVERLOAD from direct calls (IBMI-I-2550) |
A compile-time check on calls to an OVERLOAD's candidates (the OVERLOAD-with-prototypes bug this pull found is fixed, test445). |
| 22 | A case-sensitivity check: every reference spelled as declared (IBMI-I-1842) | Names stay case-insensitive; a CTL-OPT keyword makes a reference spelled differently from its declaration a diagnostic. |
| Votes | Idea | Note |
|---|---|---|
| 87 | Restrict global-variable use in subprocedures | A DCL-PROC keyword plus a codegen-time scope check — the report_semantic_error channel built for MOVE is the right home. |
| 45 | FOR-EACH over record-level access |
RLA over ODBC already ships (Tests 103–108); this is a real but tractable iterator. |
| 38 / 34 | Consistent null handling / full NULL support | Partly touched by SQL indicator variables; genuinely deep otherwise. |
| 43 | Namespaces for exports and prototypes, code::proc() (IBMI-I-1129; also IBMI-I-921, 19 votes) |
Generated code is C++, which has namespaces, but every name crosses the CALL/EXTPROC and shared-object boundaries too. Not under consideration at IBM. |
| 29 | Encapsulation: methods on objects, My.setValue(...) (IBMI-I-2700) |
Object orientation is a language design project, not a feature. Not under consideration at IBM. |
| 21 | Runtime reflection, %VARINFO(var): a variable's type, length, decimals, subfields (IBMI-I-5078) |
Codegen knows all of it and could emit it as data; the work is the shape of the result. IBM: "functionality already exists" in the compile listing. |
| Votes | Idea | Status in this compiler |
|---|---|---|
| 60 | Make %DEC() 2nd & 3rd parms optional |
Already true — %DEC codegen is static_cast<double>(…) and ignores digits/decimals entirely. |
| 30 | Expand the 16,773,104-byte limit within data structures | No such limit exists here. |
| 26 | Raise maximum variable length to 4GB | No such limit exists here — strings are std::string. |
| 30 | String templates, `My name is ${ name }` (IBMI-I-1525) |
f'My name is {name}' (test355-357) does the interpolation; the multi-line backtick form is not provided. |
| 27 | DTAARA in subprocedures (IBMI-I-1822) |
Already compiles and runs: IN/OUT of a data area declared in a DCL-PROC (checked 2026-10-21). |
| 24 | XML files over 2GB for XML-INTO/XML-SAX (IBMI-I-1826) |
No 2GB cap by construction (the document is a std::string); not tested at that size. |
| 23 | Indicators told apart from strings in OVERLOAD / OPTIONS(*EXACT) for IND (IBMI-I-4227) |
IND is its own type here, so OVERLOAD(x_string : x_ind) already picks x_ind for an indicator argument (checked 2026-10-21). |
| 22 | IN %SPLIT(...) (IBMI-I-3775) |
Already works: if r in %split('admin;developer;keyUser' : ';') (checked 2026-10-21). |
| 22 | DATA-INTO JSON with no root object (IBMI-I-2055) |
Already works: rpgc's JSON parser reads {"name": ..., "age": ...} into a DS (checked 2026-10-21). |
Cross-check against ❌ Not Planned below before re-adding any of these.
| Votes | Idea | Why not |
|---|---|---|
| 76 | Rename "ILE/RPG" to "RPG for i" | Not a compiler feature. |
| 38 / 1 | Native CLOB/BLOB in RPG | Already declined under Embedded SQL — Not Planned (LOB support varies wildly by ODBC driver). |
| 36 | Control joblog writes with ON-ERROR |
IBM i runtime. |
| 35 | Mixed-case DB2 column names in DS | IBM i catalog behavior. |
| 34 | EXTNAME/LIKEREC(*NULL) SQL indicator subfields |
LIKEREC/EXTNAME options are already Not Planned. |
| 27 | *NODEBUGSQL control option |
IBM i compilation directive. |
| 24 | Exclude hidden fields from externally-described DS | IBM i catalog behavior. |
| 23 | DECFLOAT data type |
Needs a real decimal library — large, standalone. |
| 20 | Better UTF-8 (CCSID 1208) support | CCSID is already Not Planned; real work, deep. |
| 16 | Prohibit changing a program's activation group | Activation groups are parsed for compatibility only. |
| 37 | SQL table name for an EXTNAME DS (IBMI-I-1893) |
EXTNAME is already Not Planned. |
| 21 | RCV-MSG opcode, message data on ON-EXCP/ON-EXIT (IBMI-I-3974) |
Receives from the IBM i job log, which has no equivalent here. |
These features are IBM i-specific, legacy, or otherwise not applicable:
- USRPRF (adopted authority) and VALIDATE (SQL validation of the ILE program object) belong to IBM i's object model, which a host executable has no counterpart for. Decided 2026-10-27. Like CTL-OPT's other IBM i-only keywords, both are accepted and ignored, so source written for IBM i still compiles.
- SQLTYPE (CLOB, BLOB, XML) — LOB support varies wildly by ODBC driver
- SQLDA (Descriptor Area) — Complex, rarely used, requires dynamic memory allocation
- WHENEVER — Legacy error handling; programs should check SQLCODE directly
- SET OPTION — IBM i compilation directive, no C++ equivalent
- DESCRIBE — Requires SQLDA support
- DBCLOB — Double-byte, IBM i specific
- XML variants (XML_BLOB, XML_CLOB) — IBM i specific
- %GRAPH, %UCS2, %SHTDN — IBM i encoding/shutdown
- CCSID — Character set conversion IDs
- FROMFILE/TOFILE — Compile-time array file keywords
- SERIALIZE — Serialized procedure access (job locking)
- ACTGRP semantics — Activation group (parsed for compatibility)
- INFDS — File Information Data Structure
- LIKEREC, LIKEFILE, EXTNAME options (*ALL/*INPUT/*OUTPUT/*KEY)
- ALIGN — Subfield alignment (C++ handles natively)
- LEN(n) — Explicit DS length
- NULLIND — Null indicator association
- OPDESC — Operational descriptors
- RTNPARM, NOOPT — Procedure/variable keywords
- %KDS, %FIELDS, %EQUAL, %NULLIND — File/Record BIFs
- BNDDIR — Binding directory
- STGMDL, ALLOC(*TERASPACE) — Storage model
- Open Access / Handler programs
- *ENTRY — Entry point parameter list
- %THIS — Java object reference
- ALWNULL, ALTSEQ, AUT, CHARCOUNT, COPYNEST, CURSYM, DATEDIT, DECPREC
- DFTNAME, ENBPFRCOL, EXPROPTS, EXTBININT, FIXNBR, FLTDIV, FORMSALIGN
- GENLVL, INDENT, INTPREC, LANGID, OPTIMIZE, OPENOPT, PRFDTA, REQPREXP
- SRTSEQ, TGTCCSID, TGTRLS, TRUNCNBR
- OPTIONS(*VARSIZE/*STRING/*TRIM/*RIGHTADJ/*CONVERT/*EXACT)
- VARYING, CCSID(n), DTAARA, PERRCD, EXTFMT
- RPG cycle processing (detail calc, total calc, LR indicator) — rejected outright, independent of any fixed-format C-spec work below
- CTDATA, ALT(array), OCCURS/%OCCUR
- Traditional/extended-factor-2 fixed-column C-spec (modern opcodes in column form) and truly legacy opcodes (ADD, SUB, MULT, DIV, MOVE, COMP, GOTO, TAG, CALL, PARM, PLIST, etc.) are no longer rejected outright — see "Fixed-Format Source Support — Next Steps" above, ranked #1 and #3 respectively, now that the goal is accepting most real fixed-format source rather than just the common cases
- I-specs, O-specs (fixed-format record I/O) — deferred, not rejected outright; see "Fixed-Format Source Support" above (ranked #4). Real shops overwhelmingly use externally-described files, so this covers little missed value for a lot of engineering (two full column layouts each, program- vs. externally-described, with cross-line state)
Note: /FREE.../END-FREE itself is not rejected — see "Fixed-Format
Source Support" above. What's rejected here is reviving the RPG cycle and
legacy fixed-column opcode semantics, not the ability for a fixed-format
member's C-spec to host modern free-format statements.
- *INKx, *INOx, *INLx, *INUx, *INRT, *INMR
| Test | Description |
|---|---|
| 01 | Hello World |
| 02 | Arithmetic |
| 03 | Types |
| 04 | BIFs |
| 05 | IF |
| 06 | Loops |
| 07 | Select |
| 08 | Procedures |
| 09 | Data Structures |
| 10 | Expanded BIFs |
| 11 | Error Reporting |
| 12 | Monitor |
| 13 | Subroutines |
| 14 | Indicators |
| 15 | DOU Loop |
| 16 | /COPY Include |
| 17 | Named Constants |
| 18 | Date/Time |
| 19 | Math BIFs |
| 20 | Memory BIFs |
| 21 | %PARMS |
| 22 | %STATUS/%ERROR |
| 23 | RESET/CLEAR |
| 24 | %MAX/%MIN |
| 25 | DCL-F |
| 26 | Pointers |
| 27 | Arrays |
| 28 | Conditional Compilation |
| 29 | DCL-SUBF/DCL-PARM |
| 30 | LIKE |
| 31 | LOOKUP and SORTA |
| 32 | EDITC/EDITW |
| 33 | REPLACE |
| 34 | CHECK/CHECKR |
| 35 | EVAL-CORR |
| 36 | DS Params |
| 37 | *INZSR |
| 38 | CTL-OPT |
| 39 | Figurative Constants |
| 40 | EVALR/LEAVESR |
| 41 | String/Math BIFs |
| 42 | ON-EXIT |
| 43 | STATIC |
| 44 | ALLOC/DEALLOC |
| 45 | Array BIFs |
| 46 | OPTIONS(*NOPASS) |
| 47 | TEST Opcode |
| 48 | NOMAIN Module |
| 49 | EXTPROC/IMPORT |
| 50 | Numeric BIFs |
| 51 | String BIFs |
| 52 | Array BIFs |
| 53 | Date/Time BIFs |
| 54 | Memory/Pointer BIFs |
| 55 | Figurative Constants |
| 56 | MAIN(procname) |
| 57 | DATFMT/TIMFMT |
| 58 | FOR-EACH and IN |
| 59 | %PASSED/%OMITTED |
| 60 | Data Types |
| 61 | No **FREE |
| 62 | OVERLAY/POS |
| 63 | PREFIX |
| 64 | OPTIONS(*OMIT) |
| 65 | DFTACTGRP/ACTGRP |
| 66 | *PSSR |
| 67 | *PSSR Error |
| 68 | Bitwise & Power |
| 69 | %SCANR |
| 70 | %EDITFLT & %UNSH |
| 71 | Enum & indicator |
| 72 | DIM(*VAR) |
| 73 | Date Formats |
| 74 | %CONCAT |
| 75 | %TLOOKUP & %ELEM |
| 76 | DS DIM(*VAR) |
| 77 | Embedded SQL |
| 78 | SQL in Procedures |
| 79 | SQL Core Statements |
| 80 | SQL Cursors |
| 81 | Dynamic SQL |
| 82 | SQL Advanced |
| 83 | SQL Multi-row |
| 84 | SQL Connect |
| 85 | %GETENV |
| 86 | SQL End-to-End (DS FETCH, cursors) |
| 87 | XML-INTO (direct mode, %XML, case options) |
| 88 | XML-INTO array DS (fixed DIM, DIM(*VAR), path option) |
| 89 | XML-INTO path option + nested DS (LIKEDS subfields) |
| 90 | PSDS Basic |
| 91 | PSDS + MONITOR |
| 92 | Data Area *LDA round-trip |
| 93 | Data Area named |
| 94 | Extender (H) Half-Adjust |
| 95 | Extender (E) Error |
| 96 | DA Status 401 (not found) |
| 97 | DA Status 415 (cannot read) |
| 98 | DA Status 413 (cannot write) |
| 99 | DATA-INTO JSON parsing |
| 100 | DATA-GEN JSON generation |
| 101 | *USER figurative constant |
| 102 | SND-MSG |
| 103 | RLA CHAIN / %FOUND |
| 104 | RLA READ sequential |
| 105 | RLA WRITE/UPDATE/DELETE |
| 106 | RLA SETLL/READE |
| 107 | SQL via rpgc.conf (no CONNECT) |
| 108 | RLA via rpgc.conf (no CONNECT) |
| 109 | SQL indicator variables |
| 110 | OVERLOAD procedures |
| 111 | %ELEM(*ALLOC)/%ELEM(*KEEP) |
| 112 | DATA-INTO CSV parsing (%PARSER('CSV')) |
| 113 | DATA-GEN CSV generation (%PARSER('CSV')) |
| 114 | DATA-INTO/GEN explicit %PARSER('JSON') |
| 115 | DUMP opcode |
| 116 | Fixed-format: H+D specs only |
| 117 | Fixed-format: H+D+C(/free) hello world |
| 118 | Fixed-format: H+F+D+C DISK CHAIN, full Phase 1 milestone |
| 119 | Fixed-format: syntax error inside /free |
| 120 | Fixed-format: VARCHAR standalone field (VARYING) |
| 121 | Fixed-format: VARCHAR DS subfield (VARYING) |
| 122 | Fixed-format: DISK CHAIN with a VARCHAR key |
| 123 | Fixed-format: DIM(n) array of DS |
| 124 | Fixed-format: subfield OVERLAY/POS |
| 125 | Fixed C-spec: IF/ELSEIF/ELSE/ENDIF |
| 126 | Fixed C-spec: DOW/DOU/ENDDO |
| 127 | Fixed C-spec: FOR/ENDFOR (TO/DOWNTO/BY) |
| 128 | Fixed C-spec: SELECT/WHEN/OTHER/ENDSL |
| 129 | Fixed C-spec: BEGSR/EXSR/ENDSR |
| 130 | Fixed C-spec: EVAL/EVALR/CALLP/RETURN/LEAVE/ITER |
| 131 | Fixed C-spec: CLEAR/RESET/DSPLY/SORTA |
| 132 | Fixed C-spec: MONITOR/ON-ERROR/ENDMON |
| 133 | Fixed C-spec: Extended-Factor-2 continuation across lines |
| 134 | Fixed C-spec: mixed with an explicit /free block |
| 135 | Fixed C-spec: reject non-blank control level |
| 136 | Fixed C-spec: reject misaligned cond indicator |
| 137 | Fixed C-spec: reject deferred opcode (CHAIN) with distinct message |
| 138 | Fixed C-spec: reject legacy opcode (ADD) |
| 139 | Fixed-format /COPY: D-spec copybook |
| 140 | Fixed-format /INCLUDE: C-spec copybook mid-run |
| 141 | Fixed-format /COPY: nested copybooks |
| 142 | Fixed-format /COPY inside an explicit /free block |
| 143 | Fixed-format /COPY: missing file rejected |
| 144 | Fixed C-spec: CHAIN by key, %FOUND |
| 145 | Fixed C-spec: READ sequential, %EOF |
| 146 | Fixed C-spec: WRITE/UPDATE/DELETE |
| 147 | Fixed C-spec: SETLL/READE |
| 148 | Fixed C-spec: GOTO/TAG backward loop + forward skip |
| 149 | Fixed C-spec: GOTO/TAG inside BEGSR |
| 150 | Fixed C-spec: ADD/SUB/MULT/DIV (both Factor 1 forms) |
| 151 | Fixed C-spec: Z-ADD/Z-SUB |
| 152 | Fixed C-spec: reject GOTO inside explicit /free block |
| 153 | Fixed C-spec: reject deferred legacy opcode (CALL) |
| 154 | Fixed I-spec: single record type, sequential READ |
| 155 | Fixed I-spec: multi record type dispatch |
| 156 | Fixed I-spec: field indicators (plus/minus/zero) |
| 157 | Fixed I-spec: UPDATE rewrites record in place |
| 158 | Fixed O-spec: field placement + edit code |
| 159 | Fixed I-spec: zone and digit record identification codes |
| 160 | Fixed I-spec: reject matching fields (M1) |
| 161 | Fixed D-spec: per-subfield DIM(n) |
| 162 | Fixed D-spec: per-subfield LIKE(field) |
| 163 | Free-format: per-subfield LIKE/DIM declaration + access |
| 164 | Fixed C-spec: conditioning indicators |
| 165 | Fixed C-spec: reject cond ind on block opcode |
| 166 | Fixed C-spec: reject unsupported cond indicator |
| 167 | Fixed C-spec: AN/OR indicator groups |
| 168 | Fixed C-spec: reject orphan AN line |
| 169 | Fixed C-spec: reject dangling cond ind line |
| 170 | Fixed C-spec: MOVE/MOVEL character move |
| 171 | Fixed C-spec: reject MOVE date to time |
| 172 | Fixed C-spec: reject MOVE format, no date operand |
| 173 | Free-format: reject MOVE (fixed-format only) |
| 174 | CALL callee module (NOMAIN) |
| 175 | Fixed C-spec: CALL/PARM program call |
| 176 | Fixed C-spec: reject PLIST with no PARM |
| 177 | Fixed C-spec: reject dynamic CALL name |
| 178 | Fixed C-spec: reject PARM without CALL |
| 179 | Free-format: reject CALL (fixed-format only) |
| 180 | Fixed C-spec: DATA-INTO / SND-MSG |
| 181 | Fixed C-spec: reject ON-EXIT (proc-only) |
| 182 | Fixed C-spec: CASxx chain + CABxx branch |
| 183 | Fixed C-spec: reject orphan ENDCS |
| 184 | Fixed C-spec: reject unclosed CASxx group |
| 185 | Fixed C-spec: reject bad CASxx mnemonic |
| 186 | Fixed C-spec: COMP resulting indicators |
| 187 | Fixed C-spec: reject COMP with no indicators |
| 188 | Fixed C-spec: embedded SQL (C/EXEC SQL) |
| 189 | Fixed C-spec: reject orphan C/END-EXEC |
| 190 | Fixed C-spec: reject unterminated EXEC SQL |
| 191 | Fixed C-spec: MOVE numeric to numeric |
| 192 | Fixed C-spec: MOVE character to numeric |
| 193 | Fixed C-spec: MOVE numeric to character |
| 194 | Fixed C-spec: reject MOVE on float field |
| 195 | Fixed C-spec: reject MOVE decimal literal |
| 196 | Fixed C-spec: MOVE characters by EBCDIC digit portion |
| 197 | Fixed C-spec: named PLIST |
| 198 | Fixed C-spec: PARM factor 1/factor 2 |
| 199 | Fixed C-spec: reject *ENTRY with NOMAIN |
| 200 | Fixed C-spec: reject undefined PLIST name |
| 201 | Fixed C-spec: reject duplicate PLIST name |
| 202 | Fixed C-spec: reject literal PARM result |
| 203 | *ENTRY callee module (ADDTWO) |
| 204 | Fixed C-spec: *ENTRY PLIST call |
| 205 | Fixed C-spec: *ENTRY PARM factor 2 copies back on return |
| 206 | Fixed C-spec: reject undeclared *ENTRY parm |
| 207 | Fixed C-spec: reject CALL naming *ENTRY |
| 208 | Fixed C-spec: MOVE date conversions (Figure 287) |
| 209 | Fixed C-spec: MOVE date/time without separators (Figure 288) |
| 210 | Fixed C-spec: MOVE timestamp (Figure 289) |
| 211 | Fixed C-spec: MOVE date alignment/truncation |
| 212 | Fixed C-spec: MOVE invalid date -> 112 |
| 213 | Fixed C-spec: reject MOVE *JOBRUN |
| 214 | Fixed C-spec: reject MOVE time *USA to numeric |
The Implemented Features list claims /DEFINE, /UNDEFINE,
/IF DEFINED, /IF NOT DEFINED, /ELSEIF, /ELSE and /ENDIF. They used
to work in free-format source only. In fixed-format source they were
silently dropped and both branches compiled — no error, no warning.
Cause: fixed_reader.cpp's expandCopyDirectives() flattened /COPY and
/INCLUDE itself and never routed anything else through the lexer's
conditional machinery.
The fix. That pass (expandMember) now resolves every directive,
evaluating conditionals interleaved with copy expansion:
- An
/IFcan guard a/COPY; an inactive/COPYis never opened. - A copy member can contain
/IFand/DEFINE. /DEFINEis global to the compile, as on IBM i.- A nested group inside an inactive branch stays inactive.
/ELSEIFtakes only the first true branch./EOFends the current member only.- An
/IFgroup must close in the member that opened it. Stray or unclosed directives are errors, and errors inside a copy member name the member. - Directive lines and inactive lines become empty lines, so the including member's own line numbers stay put.
Inside /FREE blocks. Directives there are resolved by the same pass.
That fixes the "conditionals inside a /FREE block are a hard syntax
error" item below and keeps one define set for the whole compile. This
includes /COPY: a member spliced into a /FREE block drops its own
**FREE line and is shifted to position 8, since its code may start in
position 1, which the block's RNF0257 rule would reject.
/EOF in free format. A /COPY member ending in /EOF used to
terminate the whole compile (yyterminate() instead of popping the
include buffer). It silently discarded every line of the including
program, with exit status 0. /EOF now pops the buffer inside a member.
Tests 232 (fixed-format directives, with tests/fixed_copybook_cond.rpgle),
233-234 (stray /ENDIF, /IF left open in a copy member) and 235 (/EOF
in a free-format copy member).
Still open:
- No
-Dflag on rpgc, and nothing predefined (*CRTBNDRPG,*ILERPG,*VxRxMx). A symbol can only be defined by the source that uses it, which removes most of the reason to have/IFat all. IBM hasDEFINE()on CRTBNDRPG. /SETis accepted and dropped, so/SET DATFMT(*DMY)changes nothing with no diagnostic.
Writing OpenDSPF/tests/TEST27_CUSMNT.rpgle — a full subfile maintenance
program — turned these up before it would compile at all. The first two are
the serious ones.
A subprocedure cannot see a global. ✅ Fixed 2026-09-01 — see the
section below. Every RPG global was emitted as a local of main(), and
subprocedures as free functions above it, so any reference from a
subprocedure failed to compile:
DCL-S gcount INT(5);
DCL-PROC Bump;
DCL-PI Bump; END-PI;
gcount = gcount + 1; // error: use of undeclared identifier 'GCOUNT'
*IN10 = *ON; // error: use of undeclared identifier 'rpg_indicators'
END-PROC;
In RPG, anything declared in the main source section is global to every
subprocedure in the module. As it stands subprocedures are usable only as
pure functions over their parameters. It rules out WORKSTN programs
entirely — EXFMT and READC move data through the record format's
fields, which are globals — which is why TEST27 is written with
subroutines.
EXSR only resolves backwards. Subroutines become auto sr_X = [&](){}
lambdas emitted in source order inside main(), so a subroutine may only
call one defined textually above it. RPG places no ordering requirement on
subroutines, and the conventional layout — mainline first, then subroutines
in the order the reader meets them — is exactly the one that fails. Mutual
recursion is impossible. TEST27 orders its subroutines leaf-first to work
around it.
Character comparison does not blank-pad. ✅ Fixed 2026-09-22 (Test
229). It compiled to plain std::string ==, so if myfield = ' ' — the
standard way to ask whether a field is blank — was false for any
CHAR(n) with n > 1 that holds blanks. *BLANKS in a comparison emitted an
undeclared RPG_BLANKS and did not compile.
The codegen has no expression types, so the fix lives in the runtime. Every
relational operator now emits rpg_eq/rpg_ne/rpg_lt/rpg_gt/rpg_le/
rpg_ge. These templates compare two character operands (std::string or
std::string_view only, so a pointer against *NULL stays a pointer
comparison) with the shorter one padded with blanks, and fall back to the
plain C++ operator for anything else. *BLANKS/*ZEROS/*HIVAL/*LOVAL
are now runtime RpgFigConst objects. In a comparison they take the other
operand's length, so *ZEROS beside a CHAR(3) is '000' and beside a
number is 0. %LOOKUP/%LOOKUPxx/%TLOOKUPxx and IN use the same
helpers, so a CHAR(3) array searched for 'AB' now finds it.
Still open, found alongside:
-
A hex literal containing
X'00'was truncated at the NUL. ✅ Fixed 2026-09-22 (Test 242). It was lost at three layers:- the lexer's C-string token (
X'C1004142'became'A') - the parser
- the emitted
std::string("…")
A hex literal holding a 00 byte now travels as its hex digits behind a marker, decoded by
rpg_decode_lexed_string(ast.h) where it becomes a value, and is emitted with its length. Found alongside:- Bytes were emitted as greedy
\xNNescapes, so a control byte followed by a hex-digit character merged into one wrong byte (X'0A'+'B'). Both emitters now use octal. - The two
*ALLcode paths pasted the pattern raw into the C++, so a"or\in it broke the build. rpg_alllooped forever on an empty pattern.
- the lexer's C-string token (
-
A figurative constant anywhere other than a comparison or an assignment still doesn't compile. Examples are a concatenation (
'x' + *BLANKS) and a BIF argument. -
*HIVAL/*LOVALagainst a numeric are±DBL_MAX, not the extremes of the field's declared digits. That only matters for equality. -
Ordering uses ASCII, not EBCDIC, collation (as before). Digits sort below letters here, above them on IBM i.
DS subfields lose their declared attributes. ✅ Fixed 2026-09-22
(Test 230). A CHAR(n) subfield was emitted as an uninitialised
std::string, so it started empty rather than as n blanks. A PACKED(9:2)
subfield's scale was invisible to codegen, so %CHAR(ds.field) printed
1250.000000. Both came from codegen keying declarations on bare names
(var_types_/var_lengths_/var_decimals_), which a reference through a
DS never matches.
- Subfields now get the same initial value as a standalone field of their
type, as a default member initializer. That covers each DS-array
element, each
LIKEDScopy, andDIM'dCHARsubfields (viarpg_filled_array). CodeGen::attrsOf(expr)returns the declared type, length, digits and scale for a bare name ords.f,ds(i).f, ords.nested.fthroughLIKEDS.%CHAR,%SIZE,EVAL(H)rounding, and*BLANKS/*ALL'x'assignment use it now.*BLANKSassigned to aCHARsubfield used to be treated as a numeric target, emitting= 0into astd::string.- Seven goldens (tests 9, 29, 86-89, 99) had recorded the six-decimal output and were regenerated. Every changed line matches its subfield's declared scale.
Found alongside, all verified:
- No
CHAR/PACKEDtarget keeps its declared length or scale on assignment. ✅ Fixed 2026-09-22 (Test 231) — see "Assignment fits the declaration" below. - The subfield grammar is a hand-enumerated list covering only
INT,CHAR,VARCHARandPACKED(with a few keyword combinations each). SoZONED,IND,DATE,UNS,FLOATand others are syntax errors in a DS. This subsumes theINDentry below. ✅ Fixed 2026-09-22 (Test 249).ds_fieldis now one rule: a name, a type fromparam_type(shared with procedure parameters) orLIKEDS/LIKE, then any ofPOS/OVERLAY/DIM. Every scalar type is accepted, and each starts at its type's initial value (INDoff,UNS/FLOATzero,POINTERnull). Still open:INZon a subfield, whichDSFieldhas nowhere to store (test 226 rejects it rather than dropping it). TEMPLATEonDCL-DSis a syntax error. ✅ Fixed 2026-09-22 (Test 251). TheDCL-DSheader is now any order ofQUALIFIED/TEMPLATE/DIM/PREFIX, withLIKEDSandPSDS/SDSin slots of their own, replacing 18 fixed sequences. ATEMPLATEDS emits its struct type forLIKEDSbut no instance.- Dropped: the fieldless
DCL-DS x PSDS;(noEND-DS), which isn't valid RPG. It made a PSDS's first subfield ambiguous with a statement once the header was generalized; dropping it removed the grammar's reduce/reduce conflict (2 S/R remain). - Found alongside: PSDS subfields were filled raw at program start, so a
CHAR(10)program-name field was 4 bytes long; they are fitted now.
- Dropped: the fieldless
- A nested
a.b.cis readable but is a syntax error as an assignment target. ✅ Fixed 2026-09-22 (Test 252). The dotted target forms are one left-recursive chain (eval_target DOT IDENTIFIER [ (idx) ]), soa.b.c,ds(i).sub.fanda.sub.arr(i)all assign, and are fitted to the nested field's declaration. The expression side'sds.field(idx)likewise takes any chain of names as its base now. Still open: an element of aDIMsubfield under an indexed base,items(1).f(2). - A standalone
CHAR(n) DIM(m)array starts as empty strings, not blanks. ✅ Fixed 2026-09-22 (Test 243). Found alongside it, and worse: a numeric array declared inside a procedure was a localstd::arraywith no initializer, so its elements held leftover stack contents (the test's negative control read 448, 64 × a previous call's 7s). Standalone arrays now start blank-filled (CHAR) or zeroed (everything else), andDIM'd numeric subfields are zeroed too.INZon an array andCHAR(n) DIM(*VAR:m), syntax errors when this was written, were fixed with theDCL-Srewrite (Test 250).
Smaller, all verified:
-
DCL-PROCwith noDCL-PIis a syntax error. IBM allows the interface to be omitted for a procedure with no parameters and no return value. ✅ Fixed 2026-09-22 (Test 248), with and withoutON-EXIT. -
DCL-PI *N;is a syntax error.*Nis IBM's standard unnamed-interface form and the recommended one in modern code. ✅ Fixed 2026-09-22 (Test 244).*Nis lexed as a name only as the first token afterDCL-PI(thePI_NAMElexer state); elsewheretotal*Nis still multiplication. Still open: a top-levelDCL-PI(a free-format program's own entry parameters) isn't in the grammar at all; only*ENTRY PLISTis. -
A bare
procname(args);statement is a syntax error;CALLPis required. IBM madeCALLPoptional in free form. ✅ Fixed 2026-09-22 (Test 245). Only the one-argument form failed:proc(x);reads like the start ofarr(x) = …;, and the parser committed to the assignment. A dedicated one-argument call rule lets the token after)decide.proc();andproc(a : b);already parsed. -
An unqualified DS subfield cannot be referenced by its bare name — the subfield is emitted inside the struct but referenced unqualified. ✅ Fixed 2026-09-22 (Tests 253-254).
visit(DclDS)records each subfield of a DS withoutQUALIFIED(not an array or aTEMPLATE), under itsPREFIXed name where there is one. A bare name that isn't a standalone field resolves toDS.field, in emitted code and inattrsOf, so it is fitted like any subfield. The table is scoped per procedure. Fixed-format data structures, unqualified by default, are the main beneficiary. Still open: an unqualified subfield used as an SQL host variable (:name) is emitted by its bare name. -
INDis not accepted as a DS subfield type. ✅ Fixed 2026-09-22 (Test 249) — see the subfield-grammar entry below. -
CONSTon a procedure-interface parameter is a syntax error. ✅ Fixed 2026-09-22 (Tests 246-247). The parameter grammar spelled out 30 alternatives per rule, one per type × keyword combination, and none hadCONST. It is now oneparam_declrule: a name, a type (param_type, which also gainedUNS,ZONED,IND,DATE,TIME,TIMESTAMPandPOINTER) orLIKEDS, then any ofVALUE/CONST/OPTIONS(...)in any order. ACONSTparameter binds asconst T&, so a literal or an expression can be passed, and the body sees aconstlocal fitted to the declared type (CHAR(10) CONSTpassed'AB'holds'AB'plus eight blanks). Assigning to one is an RPG diagnostic. Found alongside:- A call with every
*NOPASSparameter omitted,M(), generatedM(, 0, 0)(fixed). OPTIONS(*NOPASS)on a by-reference parameter, newly expressible, has no codegen yet (a C++ reference cannot be absent) and now gets a clear diagnostic;VALUEandCONSTones work.- Passing a real variable to an
*OMITparameter, rather than*OMITitself, is untested and likely broken (the parameter is a pointer and call sites don't take the argument's address).
- A call with every
-
INZon aZONEDstandalone is a syntax error, thoughZONEDitself works. ✅ Fixed 2026-09-22 (Test 250).DCL-Sis now one rule — a type, then any of its keywords in any order — replacing 48 alternatives that each paired one type with a hand-picked keyword subset. Newly expressible combinations got codegen:INZon an array fills every element.- A varying array's
%ELEMgrowth uses the element's initial value (blanks forCHAR). - Every
INZvalue is fitted to its declaration:INZ('ABCDEFG')on aCHAR(5)is'ABCDE'. Tests 3 and 115 had recorded unpaddedCHARinitial values.
Found alongside: the typed date literal
D'2026-09-22'is a syntax error. -
EXFMTandREADCare absent from the fixed-format C-spec opcode set, so no legacy interactive program can be compiled in native fixed columns at all. -
%EDITC(p:'3')emits thousands separators. Worth checking against SC09-2508 — edit code 3 should suppress them.
RPG scopes everything declared in the main source section to the whole
module — the mainline and every subprocedure see it. rpgc emitted those
declarations as locals of main() and subprocedures as free functions
above it, so a subprocedure referencing any of them failed to compile with
use of undeclared identifier. That ruled out modular RPG generally and
WORKSTN programs specifically, since EXFMT and READC move data through
the record format's fields, which are globals.
visit(Program&) now emits standalone fields, named constants, DCL-F
declarations, data-structure instances and the indicator array into an
anonymous namespace ahead of the procedures, instead of into main().
The anonymous namespace is what keeps them internal to the module, which
matters as soon as two modules are linked together — two members that each
declare a field called I must not collide, while genuinely shared
variables keep going through EXPORT/IMPORT at true file scope.
Three details the change had to get right:
- Declarations only. Some declarations also emit something that has to
run — a
DIM(*VAR)array'sreserve, a PSDS's field initialisation. Those are deferred into the top ofmain()rather than hoisted, viadeferred_init_. - The
_dspf.hinclude moved.visit(DclF&)used to emit#include "NAME_dspf.h"from the middle ofmain()'s body. It now goes with the other includes: the declarations it types are at file scope, and a header that itself includes<string>must not end up inside the anonymous namespace. *ENTRY PLISTparameters resolve earlier. They become C++ reference parameters and their D-specs are suppressed rather than declared, so that has to be settled before any declaration is emitted.
Guarded by test227, which reads and writes a scalar, an array, a DS subfield, a named constant and an indicator from two subprocedures and has the mainline observe the results. Negative control: with the change stashed it fails to compile, exactly as before.
Also from the same finding, and now fixed too: EXSR resolves in either
direction — see the next section.
Subroutines are emitted as auto sr_X = [&](){ ... }; lambdas, in source
order, inside main(). A lambda is not in scope until its declaration is
reached, so a subroutine can only call one defined textually above it:
BEGSR DoOpts;
EXSR FindCus; // error: use of undeclared identifier 'sr_FINDCUS'
ENDSR;
BEGSR FindCus;
...
ENDSR;
RPG places no ordering requirement on subroutines at all, and the
conventional layout — mainline first, then subroutines in the order a
reader meets them — is exactly the one that fails. Mutual recursion is
impossible. OpenDSPF/tests/TEST27_CUSMNT.rpgle has its subroutines
ordered leaf-first purely to work around this, which is not how anyone
writes RPG.
Hoisting the globals to file scope removed the reason subroutines had to be
lambdas: they captured [&] to reach main()'s locals, and there are no
longer any locals worth reaching. Each of the mainline's subroutines is now
an ordinary static void sr_X() at file scope — the whole set
forward-declared before the procedures, defined after them so a subroutine
may also call a procedure — and both the ordering restriction and mutual
recursion fall out for free.
A subroutine inside a DCL-PROC is a different thing: scoped to that
procedure and needing its locals, so that case keeps the lambda.
*INZSR and *PSSR keep their special handling — *INZSR auto-called
before the first executable statement, *PSSR invoked from main()'s
catch block.
Guarded by test228: a driver subroutine calling two leaves defined below
it, plus a Ping/Pong pair that call each other, which no ordering of
definitions can satisfy. Without the change it fails to compile with
use of undeclared identifier 'sr_LEAF'.
Fixed 2026-09-29: RETURN inside a subroutine returned only from the
subroutine; IBM returns from the procedure, or ends the program. It now
records the return (and its value) and the EXSR that called the subroutine
returns in turn. A procedure's *PSSR doing RETURN -1 had returned
garbage.
Declarations were right, but assignment was a plain C++ assignment. A
CHAR(5) assigned 'AB' held two bytes and one assigned 'ABCDEFG' held
seven. A PACKED(9:2) assigned 1.239 held 1.239, which %CHAR then
rounded to 1.24; RPG truncates to 1.23. Concatenation, %LEN and
%SUBST all saw the wrong value.
The fix. EVAL wraps the assigned value whenever
CodeGen::attrsOf knows the target's declaration:
CHAR(n)→rpg_fit_char: pad with blanks, or truncate on the right.VARCHAR(n)→rpg_fit_varchar: truncate past n, never pad.PACKED/ZONED→rpg_trunc_dec: drop decimals past the scale, unless(H)asked for rounding.
That covers standalone fields, array elements, subfields, DIM'd subfield
elements, and elements of a DS array. EVALR benefits too, since it
right-adjusts to the target's (now always declared) length.
rpg_trunc_dec nudges by a few ULPs before truncating, so 0.29 (stored as
0.28999…) stays 0.29. A first version used a relative epsilon, which
pushed 99999999.99 past a whole unit (Test 221 caught it).
What it took alongside:
- Procedure declarations are now scoped. The attribute tables were
flat maps, so a procedure-local sharing a global's name replaced the
global's entry for the rest of the compile. In test 8, main's
RESULT INT(10)was fitted as the procedure'sRESULT VARCHAR(100)and failed to compile.visit(DclProc)now saves and restores them. LIKEcopies digits and decimals, not only type and length. A LIKE copy of aPACKED(9:2)had been printing, and would now have been truncated, as a whole number. Test 30's golden had recorded3for3.14.- SQL string parameters are bound without trailing blanks. A padded
key
'C002 'no longer matched'C002'on SQLite. DB2 compares character values blank-padded, so trimming reproduces its result. The cost: aVARCHARhost variable deliberately ending in blanks loses them. - The
READE/READPEkey check usesrpg_eqinstead of==.
53 goldens were regenerated:
- 42 differ only in trailing blanks from
DSPLYof a now-paddedCHARfield. - 11 are corrections:
%LENof aCHAR(20)is 20,LIKE/DIMscales print correctly, and report columns now include their fields' padding, as they do on IBM i.
Still open, all verified or read from the code:
-
Only
EVAL/EVALRfit. ✅ Mostly fixed 2026-09-22 (Tests 236, 237). Every declared store now goes throughCodeGen::fitValue:- Procedures: parameters are registered in the attribute tables
(inside the procedure's scope), so
EVALinto one fits.VALUEparameters are fitted on entry; by-reference ones are the caller's storage and are left alone.RETURNfits to the declared return type, which the parser used to discard —pi_return_typenow carries the length and scale, so every interface no longer comes out with 0 for both. A call's value also has its return type as far asattrsOfis concerned, so%CHAR(proc())formats at the declared scale. - SQL: host variables filled by
SELECT INTO/FETCH(including multi-rowFETCHandDStargets) are fitted after the fetch. XML-INTO/DATA-INTO(XML, JSON and CSV) fit each subfield as it is filled.DSPLYhas no response-variable form here at all, so there was nothing to fit (an earlier note listing it was wrong).
Record-level access ✅ (fixed 2026-09-22, Test 238). Reads into externally described fields (
CHAIN/READ/READEand friends) assigned the raw column value into an unsizedstd::string. The fix:.extdescnow records each column's kind, as an optional trailing token:char(fixed-length — padded on IBM i),varchar,decimal,int,floatordatetime. An older rpgc reads three tokens and ignores it; this rpgc reading an older cache sees no kind and declares and reads the field exactly as before.- A
charfield is declared blank-filled.char/varchar/decimal/intfields are registered in the attribute tables, so reads,EVALand%CHARall use the column's shape. Afloatcolumn is never truncated, since the scale a driver reports for one isn't a scale. - The live refresh reads
SQLColumns'TYPE_NAMEas well asDATA_TYPE. SQLite's ODBC driver reportsCHAR(6)asSQL_VARCHAR(SQLite has no fixed-length type) andDECIMAL(7,2)as a 2-byte string, butTYPE_NAMEcarries the declaration. On a driver that reports types exactly, the two agree and nothing changes. - The 13 committed test caches gained their kinds. Regenerating four of them live from SQLite produced the same kinds.
- Procedures: parameters are registered in the attribute tables
(inside the procedure's scope), so
-
Integer-digit overflow isn't detected. ✅ Fixed 2026-09-22 (Tests 239-241).
- What was wrong: RPG
EVALof 123456 into aPACKED(5:0)is RNX0103 (status 103), and it used to store the value. Division by zero, listed as done in the status table, wasn't raised either:a / 0producedinfand carried on, and%DIV(x:0)crashed with SIGFPE. - The mechanism:
rpg_raisesets%STATUSand throwsRpgError, whichMONITOR/*PSSRcatch. Unmonitored, a terminate handler prints the RNX message and exits 1, as a batch job ends on IBM i. - Where it is raised: EVAL,
RETURNandVALUEparameters intoPACKED/ZONEDraise 103 when the integer digits don't fit, after truncation or(H)rounding (999.995 half-adjusted into aPACKED(5:2)is 1000.00 and raises).INT/UNSraise 103 outside their 4-byte range, since the declared width isn't tracked./,%DIVand%REMby zero raise 102. - The fixed-format arithmetic opcodes (
ADD,SUB,MULT,DIV,Z-ADD,Z-SUB) do not raise. SC09-2508 has them drop the result's high-order digits, and they are transpiled to EVAL, so the transpiler marks that EVAL with an internal(T)extender, whichparser.yrejects in hand-written source. Test 221's golden had recorded 99,999,999.99 in a9S 2field viaZ-ADD; it is now 9,999,999.99, as IBM would give. - Stores from SQL, XML and file reads still only fit length and scale. On IBM i those report a too-small target through their own channels (SQLCODE and so on), not status 103.
- Found alongside: integer literals beyond
intrange wereatoi'd into anintand silently wrapped (3000000000 became -1294967296). The lexer now returns such a literal as a decimal literal. - Still open:
INT(3)/INT(5)/INT(20)all compile to a 4-byteint, so their own ranges aren't enforced (INT(20) can't even hold its range). Integer arithmetic that overflowsintbefore the assignment still wraps in C++.
- What was wrong: RPG
-
Float literals are emitted with 10 fixed decimals (
setprecision(10)), so99999999.99becomes99999999.9899999946in the generated C++. Truncation absorbs it, but the literal itself is lossy. -
DCL-PI *Nis still a syntax error. Test 231 names its interface.