Release v0.6.3 - #214
Merged
Merged
Conversation
… fixes
Reported on the forum: `v : myStruct := (a := 1.0, b := 2.0)` failed with
`Expected RParen, found :=`. The syntax is correct — IEC 61131-3 Annex B.1.4.3
`structure_initialization` — the production was simply not implemented anywhere
in the grammar. Two related defects surfaced while investigating it.
Structure initialization (IEC 61131-3 B.1.4.3, B.1.3.3)
Parser gains `structInitializer`, reached through `primaryExpression` and gated
on `( NAME :=` (which is never a valid parenthesised expression, so nothing else
is affected). Going through `primaryExpression` means element values are ordinary
expressions, so nesting and array literals compose without a second initializer
grammar. Supported in every declaration position: VAR_GLOBAL (file-level and
CONFIGURATION), PROGRAM / FUNCTION_BLOCK / FUNCTION / METHOD variables, STRUCT
element defaults, inside array literals, and for function block instances
(`t : TON := (PT := T#1s)`, the same production the standard uses for
`fb_name_decl`).
Lowering lives in one place, `backend/struct-init-codegen.ts`, shared by
`codegen.ts` and `type-codegen.ts`:
strucpp::iec_struct_init<POINT>([](auto& v0) { v0.Y = 2.0; v0.X = 1.0; })
Elements may be written in any order and may be omitted, with an omitted element
keeping the default from its own declaration — which a braced aggregate
initializer cannot express in C++17 (no designated initializers). The new runtime
helper default-constructs the value, applying every element's own default, and
the lambda overwrites only the named elements. Nested levels take their type from
`decltype(v0.MEMBER)`, so library types and inline array members need no metadata
lookup.
Also implements the type-level default forms of B.1.3.3
(`Setpoint : REAL := 25.0;`, `Origin : Point := (x := 0.0);`) — previously a
parse error even for elementary types. A single post-build pass copies the
default onto declarations that have no initializer, so every downstream consumer
sees an ordinary initializer instead of each declaration path learning about type
defaults. It re-runs on the merged unit so a TYPE and its uses can live in
different files.
PROGRAM variable composite initializers were silently dropped
`arr : ARRAY[0..2] OF INT := [1, 2, 3]` in a PROGRAM compiled clean and ran with
a zero-filled array: the project model flattened initializers to strings via
`expressionToString`, which had no case for array literals. It now carries the
AST expression, so these initializers reach codegen and go through the one
expression emitter. That removes the parallel string-lowering pass in
`getDefaultValue` (based literals, digit separators, typed prefixes, time and
calendar literals, strings) — declaration initializers and statement bodies can
no longer disagree. `getDefaultValue` is now `getTypeDefaultValue`, covering only
the no-initializer case, and the two duplicated PROGRAM constructor loops
collapse into `projectVarInitializer`, shared with the file-scope globals.
Two initializers change shape as a result, both still valid C++ with the same
value and now identical to the statement path: `INT#5` emits
`static_cast<IEC_INT>(5)` instead of `5`, and `1.5E3` emits `1500.0`.
File-level VAR_GLOBAL was invisible to VAR_EXTERNAL
`VAR_EXTERNAL` resolution only consulted CONFIGURATION globals, so declaring a
file-level global (a GVL) that way failed with "no matching VAR_GLOBAL
declaration"; the same global was reachable if the declaration was omitted. Such
a reference now validates, including the type check, and is dropped from the
pointer-plumbing list: file-level globals are plain file-scope storage the body
already reaches by name, and a `GlobalVar<V>*` member would shadow the very
global being referenced. The CONFIGURATION path is unchanged. `collectFBExternals`
applies the same rule for function blocks and now serves both the header and
implementation paths, replacing a near-duplicate inline collector.
Tests: 79 new cases across parser/AST, codegen, the shared lowering, semantics,
and g++ integration. The integration tests run the binary and check the values,
since compiling alone cannot show that out-of-order elements land on the right
members or that omitted elements keep their defaults. Net effect on codegen.ts is
-10 lines despite the new feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IEC 61131-3 Annex B.1.4.3 `array_initial_elements`:
array_initial_elements ::= array_initial_element
| integer '(' [array_initial_element] ')'
`[10(0)]` stands for ten copies of `0`. Previously a parse error
(`Expected RBracket, found (`).
The parser gains `arrayInitialElements`, used by both `arrayLiteral` and
`initializerExpression`, so repetition works in the bracketed form and in the
bracket-less form OpenPLC emits (`:= 2(3), 2(4)`). The alternative is gated on an
integer immediately followed by `(`, which is never an expression — ST has no
implicit multiplication and only an identifier can be called — so a function call
as an array element (`[F(2), 3]`) is unaffected.
The AST builder expands repetition groups into plain element lists, so semantic
analysis, the project model and codegen need no support of their own; each repeat
gets its own expression node so per-element annotations cannot collide. The
repeated value is a full expression, so it may be a structure initializer
(`[2((x := 1.5, y := 2.5))]`) or a nested array literal.
Counts use IEC integer notation (`[16#4(7)]`), a zero count expands to nothing,
and a count above 65536 throws rather than truncating — expansion is linear in the
count, so a typo would otherwise exhaust memory, and silently dropping the tail
would leave wrong values in the array.
The optional-element form `[10()]` (ten copies of the element default) is not
accepted. It has no positional lowering in C++17 — a braced initializer list
cannot skip a slot, and mixing a value-initialised element into the list breaks
`initializer_list<U>` deduction — and neither matiec nor CODESYS accepts it
either. Documented under a new "Initialization gaps" table.
Two further initialization gaps found while auditing and documented there, not
fixed here: a nested array initializer for a multi-dimensional array
(`[[1, 2], [3, 4]]`), and any 3D array initializer, which fails to build because
the runtime `Array3D` has no initializer-list constructor.
Also registers the rules added by the previous commit (`structInitializer`,
`structElementInitializer`) in the parser error-message provider, so a syntax
error inside a structure initializer names the construct being parsed.
Tests: 16 parser/AST cases plus 4 g++ integration cases that run the binary and
check the slot values, including a repeated structure initializer, a 2D array, a
STRING array and a file-level VAR_GLOBAL. Regression cases cover a function call
as an element, scalar and arithmetic initialisers, and a CONSTANT still resolving
as an array dimension through the same rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ape their strings
Two defects in the type generator, both silent until now.
An array-literal default on a STRUCT element lost its values:
TYPE Buf : STRUCT data : ARRAY[0..3] OF INT := [7, 8, 9, 10]; END_STRUCT; END_TYPE
emitted `Array1D<IEC_INT, 0, 3> DATA{}` — zero-filled, no diagnostic. The type
generator's expression emitter has no array-literal case, so the value fell
through to its `0` fallback and the "arrays can't be `= 0`" guard rewrote that as
`{}`. STRUCT element defaults now route through the shared
`generateInitializerValue`, which already handles array literals, structure
initializers and repetition groups and delegates everything else to
`expressionToCpp` — so the branch added alongside the structure-initializer work
disappears rather than growing a second case.
Emitting those values then exposed the second defect: the type generator wrote
STRING and WSTRING literal bodies verbatim, so an embedded `"` closed the C++
string early and a `$`-escape was never translated. It went unnoticed because
scalar STRING defaults are rare and array defaults were being dropped — OSCAT's
HTML-entity tables (`ARRAY[1..4] OF STRING` full of quotes) hit it immediately.
`translateIECString` moves from a private method on the expression emitter to
`codegen-utils`, so a STRING literal lowers identically in a statement, a
variable initialiser and a STRUCT element default. Its doc comment, which had
drifted onto the wrong function, moves with it.
Tests: five cases covering array-literal and repetition defaults on a STRUCT
element, the no-default case still value-initialising, and quote/`$T` escaping in
both a scalar and an array-literal element default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ug table
The debug pointer table emitted one subscript per dimension, so a 2D array came
out as `G_MATRIX.value[0][0]`. `Array2D` / `Array3D` take every index in a single
`operator()` call, so that has no matching operator and the generated
`generated_debug.cpp` fails to compile — reported from an AVR build:
error: no match for 'operator[]' (operand types are
'strucpp::IEC_ARRAY_2D<...>' and 'int')
Any project with a multi-dimensional array and debug enabled hit this; only 1D
arrays worked, which is why it went unnoticed.
`walkArrayDims` now collects the indices across all dimensions and renders the
access once at the innermost level through a new shared
`formatArrayElementAccess`, which sits next to `formatArrayType` because it has
to agree with the container that function picks per rank: `[i]` for `Array1D`,
`(i, j)` / `(i, j, k)` for `Array2D` / `Array3D`, and one subscript per dimension
again for 4+ dimensions (nested `Array1D`). The accessors stay unchecked rather
than `.at()` because only they are constexpr, which is what lets `&arr[i]` be a
constant expression — required for the table's PROGMEM placement on AVR.
The IEC display path in the debug map keeps its `[i][j]` form, which is what the
editor's debug UI shows.
Tests: 2D, 3D and 1D pointer expressions, no chained subscript in any pointer
expression, and the display paths unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…invocation
Three gaps found in the initialization audit.
3D arrays were unusable, not just uninitialisable
`IEC_ARRAY_3D` had neither an initializer-list constructor nor `at()`, so
`ARRAY[0..1,0..1,0..1] OF INT := [1, …]` failed to build — and so did any
subscript in a body, because codegen emits the bounds-checked `.at()`. It now
matches 1D/2D: flat row-major initializer list, `at()` in both const and mutable
form, iterators, and the `dimN_size` / `dimN_lower` / `dimN_upper` helpers. (The
dormant C++ runtime test already called `dim1_size()` on a 3D array, so that file
could not have compiled either; it can now.)
Nested array initializers
IEC 61131-3 Annex B.1.4.3 allows an `array_initialization` as an
`array_initial_element`, which is the natural way to write a multi-dimensional
initializer: `[[1, 2, 3], [4, 5, 6]]`. Implemented in the runtime rather than by
teaching codegen to detect ranks, so plain C++ overload resolution picks the right
constructor and the notation works in every scope — globals, PROGRAM/FB/FUNCTION
locals, STRUCT element defaults, and nested inside a structure initializer:
- `Array2D` / `Array3D` gain row- and plane-nested initializer lists. Each level
fills from its own lower bound, so a short inner list leaves the rest of that
row at its default instead of shifting the next row up — the semantic
difference from writing the same values flat.
- `Array1D` gains an element-typed initializer list, so an array whose element
is itself composite works too: `ARRAY[0..1] OF Row := [[1,2,3],[4,5,6]]`, and
the nested-`Array1D` chain a 4+-dimensional array lowers to. The deducing
template can't serve these — `U` has nothing to deduce from a braced element —
and it still wins for a scalar list, so the two don't compete.
Codegen only had to stop descending the element type twice for a nested list: the
inner lists of a multi-dimensional array hold the *same* element type, and
descending produced `typename typename …::element_type::element_type`, which is
not valid C++ at all.
Function block array invocation
`units[0](step := 2.0)` did not parse — the statement was taken as an assignment
target, which then demanded `:=`. A new `instanceCallStatement`, gated on `(`
following the closing `]` directly, claims exactly the element invocation and
leaves `arr[0].m(…)` (which could equally be a method call on the element) to the
existing rules. `FunctionCallExpression` gains an optional `instance` expression;
`functionName` still carries the base variable name, so the declared type resolves
the usual way and the FB type comes from its array element type. Input assignment,
the call, VAR_IN_OUT copy-back and `=>` capture then all work against that
expression unchanged, including a variable or multi-dimensional index.
Declaring an FB array and reading a member already worked — an earlier report that
those were broken was a name collision in the test with the library FBs `RAMP` and
`RS`, not a defect.
Still not parsed, and now recorded: a *method* call on an array element
(`units[0].M()`), which needs the expression grammar's method-call lookahead to
accept a subscripted object.
Tests: 11 parser cases for element invocation (including a variable index, a 2D
index, a nested subscript, and inside a FOR loop) plus regression cases for
assignments and plain invocations; 8 g++ integration cases that run the binary and
check values — 3D init and element access, 2D/3D nesting, short rows keeping their
defaults, array-of-array-type nesting, structure initializers nested in a 2D
array, and per-element FB state after a scan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A TYPE may name a function block — `AccumGrid : ARRAY[0..1,0..1] OF Accum` emits
`using ACCUMGRID = Array2D<ACCUM, 0, 1, 0, 1>` — but the user-defined types block
was emitted before the POU forward declarations, so `ACCUM` was undeclared at that
point and the header failed to compile:
error: use of undeclared identifier 'ACCUM'
error: unknown type name 'ACCUMGRID'
The forward declarations now also precede the types block. An incomplete type is
enough for the alias, which instantiates nothing; the instantiation happens where
the alias is used as a member, well after the full definition. The existing
forward-declaration block stays where it was — repeating a class declaration is
legal — and both call the same helper so they can't drift.
Found while adding a named ARRAY-OF-function-block type to the end-to-end test
project, which is also where the inline form (`ARRAY[0..1,0..1] OF Accum` written
directly in a VAR block) can't be used: the OpenPLC editor's variable parser
rejects a comma inside the type, so a named type is the only way to declare a
multi-dimensional array there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ordinary mistakes escaped the compiler. A nesting or rank error surfaced as
a C++ error against generated code, and an over-long initializer was silently
truncated by the runtime container's constructor — the array came out with values
missing and no diagnostic anywhere:
ARRAY[0..2] OF INT := [1,2,3,4,5,6] -> compiled, kept 1,2,3
ARRAY[0..1,0..1] OF INT := [[1,2,3],[4,5,6]] -> compiled, dropped 3 and 6
ARRAY[0..1,0..1,0..2] OF INT := [[1,2,3],[4,5,6]]
-> g++: no matching constructor
p[0,0] on a 3-dimensional array -> g++: no matching call to 'at'
Now reported against the source, with line and column:
fmt.st:3:29: error: Initializer for 'A' has 6 values but the array holds 3.
The extra values would be discarded.
fmt.st:7:8: error: 'M' has 2 dimensions but is indexed with 1 index.
Checks
- **Over-long** — a flat list against the total element count, and each level of
a nested list against its own dimension. Repetition groups are already
expanded by then, so `[10(7)]` into a 3-element array is caught.
- **Nesting** — depth must match the rank. A flat list at the outermost level
still fills the whole array row-major (IEC allows it at any rank), but once
nesting starts each level descends exactly one dimension: stopping early
leaves dimensions unaccounted for and no container constructor matches.
Nesting past the rank is caught too, as is mixing nested and flat entries.
Nesting into an array whose element type is itself an array stays legal.
- **Subscript count** — one index per dimension. Walks the ordered access chain
rather than the flat `subscripts` list, because only the chain distinguishes
`a[0][1]` (two steps into an array of arrays) from `a[0,1]` (one two-index
step into a 2D array); the flat list reports 2 for both.
Deliberately conservative
Every check is skipped rather than guessed at when the shape isn't statically
known — variable-length `ARRAY[*]`, a non-constant bound, a type that doesn't
resolve, or a dereference in the access chain — so it can only add diagnostics for
definite mistakes. A scalar initializer on an array is also left alone: it is
meaningful on a STRUCT element (`data : ARRAY[…] OF INT := 0` value-initialises),
so rejecting it would flag working code.
Initializers are checked wherever a declaration can appear: file-level and
CONFIGURATION VAR_GLOBAL, PROGRAM / FUNCTION_BLOCK / FUNCTION / METHOD variables,
and STRUCT element defaults. Subscripts are checked in every POU body, against
the POU's own variables with globals as the fallback. One diagnostic per bad
declaration rather than one per row.
`evalIntConst` moves from `debug-table-gen` to `type-utils` alongside the new
shape resolver, so the two consumers share one implementation.
Tests: 31 cases split between rejected and accepted — the accepted half is the
point, since a false positive here would block valid code. Full suite 2163
passing, including the OSCAT and SoftMotion library builds, and the 44-assertion
end-to-end project still compiles clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…placed structure initializers
Both from PR review.
Integer literals lost precision above 2^53
------------------------------------------
`formatIntegerLiteral` re-emitted from `String(value)`, where `value` is a JS
`number` off `parseInt`. LINT and ULINT span the full 64 bits, so the digits the
source wrote did not survive:
x : LINT := 9007199254740993; -> X(9007199254740992) wrong value
y : LINT := 9223372036854775807; -> Y(9223372036854776000) past INT64_MAX
z : ULINT := 18446744073709551615; -> Z(18446744073709552000) g++ rejects it
Only the first is silent. The other two are the type's own bounds — the values
most likely to be written as sentinels — and they broke the C++ build outright.
A regression for PROGRAM/CONFIGURATION VAR initializers, which used to route
through a separate string-lowering pass that passed the raw digits along. The
statement path had always been wrong, and so had file-level VAR_GLOBAL.
`exactIntegerLiteralValue` (new `src/literal-utils.ts`, at the root because both
the backend and the analyzer need it) parses the literal as a `bigint`, and
`formatIntegerLiteral` lowers from that.
Re-emitting the raw digits instead would have been the smaller change, but it
reintroduces a defect this branch had already fixed: a leading zero is an octal
prefix in C++, so `0010` becomes 8 and `008` does not compile. The value is
normalized through the `bigint` rather than copied, so both stay fixed.
A value above INT64_MAX gets a `ULL` suffix. A C++ decimal literal is only ever
given a signed type (C++17 [lex.icon]/3), so `18446744073709551615` unsuffixed
names no type at all — that is why the old output was a build failure and not
merely a wrong number.
`type-codegen` had no INT case at all; its `default:` branch carried the same
defect, so STRUCT element defaults were affected too. Both emitters now share the
one lowering, alongside the STRING/array lowering they already shared.
A literal wider than any IEC integer type is now a diagnostic rather than an
opaque g++ error. LINT and ULINT are the widest, so a value outside
[LINT_MIN, ULINT_MAX] is wrong against every declared type and can be reported
without knowing which one it initialises. LINT_MIN parses as unary minus over a
magnitude that exceeds LINT_MAX, so the unsigned bound is what keeps it accepted.
Structure initializers reached codegen from statement positions
---------------------------------------------------------------
`structure_initialization` (Annex B.1.4.3) belongs to `var_init_decl` — it is not
an expression, and its lowering needs the target's C++ type, which only a
declaration supplies. Three real statement positions reached the expression
emitter anyway, where the fallback value-initialised:
arr := [(x := 1.0), (x := 2.0)]; -> ARR = {{}, {}};
f(P := (x := 3.0)); -> F.P = {};
s := (x := 1.0); -> S = {};
Each compiled clean and ran with every written element discarded, the members
left at whatever their own declarations defaulted to — plausible values rather
than obviously-wrong zeros, and no diagnostic anywhere.
- The analyzer rejects the form in any statement position, with line and
column. The walk prunes at each initial value a declaration can carry:
`VarDeclaration.initialValue` and `TypeDeclaration.defaultValue` — the
type-level default (Annex B.1.3.3) is the third legal position and is easy to
miss, since it is not a VarDeclaration.
- A TEST var block is a declaration, so `p : Point := (x := 1.0)` is legal
there; it went through the plain expression emitter and produced
`POINT P = {};`. It now routes through `generateInitializer` like every other
declaration.
- Codegen throws instead of returning `{}`. `compile()` returns before codegen
on any error and the .stst path is analyzed too, so this is now an internal
invariant rather than a live path — but silent value-initialisation was the
wrong failure mode for it regardless.
Verification
------------
Suite 2201 passing (90 files), up from 2163 — 38 new, split between rejected and
accepted cases, the accepted half being the point for both checks. Typecheck
clean, no net new lint warnings, coverage thresholds pass.
The integration tests run the binaries and compare values: `9007199254740993`
lowered to ...992 compiles perfectly, so only execution catches it. They build
with -Werror=implicitly-unsigned-literal -Werror=overflow, since an unsuffixed
ULINT_MAX is a GCC extension that warns rather than fails, and the warning is
exactly the failure mode under test. Disabling the fix fails 14 of the 22
literal tests, three of them as g++ errors.
Also validated end-to-end through the CLI on a project combining 64-bit
accumulators with structure initializers in every legal position: builds under
-Wall -Wextra -Werror and prints every value exactly, and the illegal forms
produce five precisely located diagnostics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…egen does A variable declared with its own type's name — `RunningLights : RunningLights`, which CODESYS allows and real projects use — is emitted by codegen as `RUNNINGLIGHTS_`, because GCC rejects a member that changes the meaning of its type name. The debug table addresses members by name and did not apply the same rule, so every entry for such an instance named a member that does not exist: generated_debug.cpp:26: error: 'class strucpp::Program_MAIN' has no member named 'RUNNINGLIGHTS'; did you mean 'RUNNINGLIGHTS_'? The ST compiles, the C++ for the program compiles, and then the build dies in the generated debug table — so the failure only appears in a full firmware build, never in `strucpp file.st`. The table now mangles identically. Only the C++ expression changes; the trailing comment keeps the ST path the editor shows the user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion feat: IEC 61131-3 structure and array initialization (forum report), plus six initialization fixes
…s named
From PR review. The previous commit fixed one of six sites, and did it with a
name-only comparison that broke a second case in the opposite direction.
A member's C++ name differs from its ST name for two reasons: the name matches
its own type's name (GCC rejects a member that changes the meaning of its type
name), or it matches an interface method the owning FB implements. Every emitter
that writes or addresses a member has to agree, because they all name the same
C++ entity. The rule existed as five copies with three different conditions, and
a sixth site had no rule at all.
Under-mangling: three of four sites
-----------------------------------
`debug-table-gen` builds a member expression in four places; only `visitVarDecl`
mangled. So `Motor : Motor` in a PROGRAM was fixed while the identical
declaration one scope in was not:
FB member -> g++: no member named 'MOTOR' in 'strucpp::RIG'
STRUCT field -> g++: no member named 'INNER' in 'strucpp::RIG'
The FB path is the live one — user-defined FBs surface their members through
`varBlocks`, library FBs through the symbol's flat interface arrays. No bundled
library declares a colliding interface member (588 sources scanned), so the
library path is fixed for symmetry rather than urgency.
Half the rule
-------------
Only the type collision was replicated, not the interface-method one. An FB
implementing `IMotor` with a `Start` method and a `VAR Start : BOOL` gets
`START_` from codegen and `.START` from the table, which addresses the *method*:
g++: cannot create a non-constant pointer to member function
Over-mangling: a regression
---------------------------
The name-only comparison dropped codegen's `isUserDefinedType` guard, so it
mangled names codegen leaves alone. Elementary type names are not reserved —
`Time : TIME` and `Word : WORD` are ordinary declarations, emitted as plain
`TIME` / `WORD`:
g++: no member named 'TIME_' in 'strucpp::Program_MAIN'
That build works on development today, so this was a regression, in the inverse
direction of the bug being fixed.
A sixth site
------------
Found by building the REPL end to end: `repl-main-gen` emits
`&instance.MEMBER` for every VarDescriptor with no mangling at all, so a project
using the pattern would not link. ST is case-insensitive, so `rig : Rig` collides
too — wider than the exact-case spelling suggests.
main.cpp: error: no member named 'RIG' in 'strucpp::Program_MAIN'
The rule
--------
`member-mangling.ts` owns it: `mangledMemberName(name, typeName, ctx)`, carrying
both collisions. Callers supply what they can resolve through the context —
codegen its `known*Types` sets, the debug table the symbol tables and AST,
`repl-main-gen` the AST via the shared `userDefinedTypeNames`. `type-codegen`
gains an injectable `isUserDefinedType` so struct fields resolve FB and program
names too, defaulting to its previous "not elementary" behaviour for standalone
use. `test-codegen`'s inlined copy now calls `needsFieldMangling`.
Tests
-----
19 new, split between the collisions that must mangle and the names that must
not — the second half is what the regression needed.
`tests/integration/debug-table-cpp.test.ts` is new and compiles the generated
debug table with g++, which nothing did before: `strucpp file.st` emits no table
and `--build` does not include one, so this whole class of failure only ever
appeared in a firmware build, in a file the user never wrote. Every case above
fails that test without this change.
Suite 2029 passing, up from 2010. Verified end to end: `strucpp --build` now
links a project combining every collision and the REPL reads and writes its
variables, and linking the debug table into a binary confirms all 13 entries
point at the member their ST path names, nested cases included
(`RIG_.MOTOR_.RUN`, `FRAME_.INNER_.W`, `DRIVE_.START_`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-member-mangling # Conflicts: # src/backend/codegen.ts # src/backend/debug-table-gen.ts # src/backend/type-codegen.ts
A seventh site, surfaced by building a project end to end against development with PR #205 merged. `generateProgramHeaderFromModel` declares a colliding member mangled, but the constructor's initializer list named the raw declaration name. Harmless until a colliding member also carried an *initialiser* — before #205 that mostly meant scalars, and structure initializers make it ordinary: airange : AiRange := (hi := 22.0); header: AIRANGE AIRANGE_; .cpp: : AIRANGE(strucpp::iec_struct_init<AIRANGE>(...)) g++: error: member initializer 'AIRANGE' does not name a non-static data member or base class Pre-existing on development, and reproducible there without this branch. ST names are case-insensitive, so `airange : AiRange` collides exactly as `AiRange : AiRange` does — the pattern is easier to hit than the exact-case spelling suggests. The FUNCTION_BLOCK constructor already mangled its initializer list; only the program-from-model path did not. Both program paths (with and without VAR_EXTERNAL parameters) now go through the same rule. Also drops `mangleMemberIfNeeded`'s `_cppType` parameter, unused since the comparison moved to the ST type name. Four call sites passed a computed C++ type that went nowhere, which invites the next reader to think it participates. Tests ----- `tests/backend/member-mangling.test.ts` asserts the agreement itself rather than each emitter alone: declaration vs constructor initializer list, declaration vs statement body, declaration vs STRUCT field emission, and the interface-method case. Every bug in this area has been one emitter drifting from another, and only a cross-emitter assertion catches that. The 2D/3D debug-table cases deferred earlier are enabled now that #205's `formatArrayElementAccess` is in, including one that puts both rules on the same expression (`BANK(0, 0).MOTOR_.RUN`). The end-to-end REPL case gains an initialised colliding member, which is what exercises the constructor path. Verified end to end on the merged tree: `strucpp --build` links a project combining every collision with #205's multi-dimensional arrays, structure initializers and 64-bit literals, the REPL reads and writes its variables, and linking the debug table into a binary confirms all 32 entries address the member their ST path names — `RIG_.MOTOR_.RUN`, `COUNTS(1, 1)`, `CUBE(1, 1, 1)`, `AIRANGE_.HI`, and plain `TIME` / `WORD` alike. Suite 2234 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An eighth site, found by sweeping every remaining member-expression
construction in the backend rather than waiting for the next build to fail.
Invoking a function block reaches its parameters through `instance.MEMBER` in
four places — named input assignment, positional input assignment, VAR_IN_OUT
copy-back, and `=>` output capture — none of which applied the mangling rule. A
parameter named after its own type, or after an interface method the FB
implements, is declared with the trailing underscore, so the bare name reaches
nothing:
FUNCTION_BLOCK Sensor
VAR_INPUT Reading : Reading; END_VAR
header: struct READING READING_;
.cpp: S.READING = INP;
g++: error: no member named 'READING' in 'strucpp::SENSOR'
Pre-existing, and independent of the debug table — this one breaks the program's
own C++, so unlike the other seven it does not need debug enabled to bite.
`fbParamMemberName` resolves the parameter's declared type through
`resolveMemberType` and defers to `needsFieldMangling`, the same "reach a member
through a named owner" path the body and the debug table use. Skipped when the
FB type does not resolve, which only disables the check.
Tests
-----
Five cases in `member-mangling.test.ts` covering all four invocation forms, both
collisions, and an elementary-named input that must stay untouched.
`debug-table-cpp.test.ts` now compiles the program's `generated.cpp` alongside
the debug table in the same pass. The invocation code only appears there, so the
table alone could never have caught this — and every case in that file gets the
wider check for free.
Suite 2240 passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ngling fix(debug-table): mangle a member whose name matches its type, as codegen does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
marconetsf
self-requested a review
August 18, 2026 10:50
marconetsf
approved these changes
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merges
developmentintomainfor the v0.6.3 release.Highlights
Structure & array initialization (#205)
STRUCTinitializers in variable declarations, plus two composite-init fixes.[N(value)]syntax in the frontend.STRUCTelements are preserved, and their strings are correctly escaped.Debug table member mangling (#213)
PROGRAMconstructor initializer list is mangled too.operator()in the debug table.Release chore
0.6.3acrosspackage.json,package-lock.json, and the generatedsrc/version-build.ts.Verification
Run locally against this branch:
npm run buildversion-build.tssynced to 0.6.3npm testnpm run typechecknpm run lintPost-merge
Tag
v0.6.3onmainto trigger the Build & Release workflow, which produces the Linux/Windows/macOS binaries.🤖 Generated with Claude Code