From f660bc28f010b6e16daba966f7253a234e091e1b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 11:01:40 -0400 Subject: [PATCH 01/13] feat(frontend,backend): structure initialization + two composite-init fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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([](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(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*` 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) --- docs/IEC_COMPLIANCE.md | 8 +- src/ast-utils.ts | 16 + src/backend/codegen.ts | 394 ++++++++-------- src/backend/struct-init-codegen.ts | 153 ++++++ src/backend/type-codegen.ts | 30 +- src/frontend/ast-builder.ts | 143 ++++-- src/frontend/ast.ts | 38 +- src/frontend/parser.ts | 62 +++ src/frontend/type-defaults.ts | 101 ++++ src/merge.ts | 8 + src/project-model.ts | 204 +++++--- src/runtime/include/iec_struct.hpp | 26 +- .../codegen-structure-initialization.test.ts | 423 +++++++++++++++++ .../backend/codegen-var-initializers.test.ts | 33 +- tests/backend/struct-init-codegen.test.ts | 209 +++++++++ .../frontend/structure-initialization.test.ts | 348 ++++++++++++++ .../structure-initialization-cpp.test.ts | 434 ++++++++++++++++++ tests/integration/test-helpers.ts | 1 + .../var-external-file-scope-globals.test.ts | 178 +++++++ 19 files changed, 2487 insertions(+), 322 deletions(-) create mode 100644 src/backend/struct-init-codegen.ts create mode 100644 src/frontend/type-defaults.ts create mode 100644 tests/backend/codegen-structure-initialization.test.ts create mode 100644 tests/backend/struct-init-codegen.test.ts create mode 100644 tests/frontend/structure-initialization.test.ts create mode 100644 tests/integration/structure-initialization-cpp.test.ts create mode 100644 tests/semantic/var-external-file-scope-globals.test.ts diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index ea73ac6c..394862f5 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -27,6 +27,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | TYPE ... END_TYPE | Supported | Type aliases | | STRUCT ... END_STRUCT | Supported | With nested structs | | Enumerations | Supported | With optional base type | +| Initialized type declarations | Supported | A type may carry its own default (`Setpoint : REAL := 25.0;`, `Origin : Point := (x := 0.0);`), inherited by every declaration of the type that has no initializer | | ARRAY (1D) | Supported | Arbitrary bounds: ARRAY[1..10] OF INT | | ARRAY (2D) | Supported | ARRAY[1..3, 1..4] OF REAL | | ARRAY (3D) | Supported | ARRAY[1..3, 1..4, 1..5] OF INT | @@ -41,6 +42,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Type | Notes | |------|-------| | UNION | CODESYS extension | +| Array repetition initializer | `:= [10(0)]` — write the elements out instead | ## Program Organization Units @@ -59,14 +61,16 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | VAR_INPUT | Supported | Input parameters | | VAR_OUTPUT | Supported | Output parameters | | VAR_IN_OUT | Supported | Pass-by-reference parameters | -| VAR_EXTERNAL | Supported | External references to VAR_GLOBAL | -| VAR_GLOBAL | Supported | Global variables | +| VAR_EXTERNAL | Supported | References either a CONFIGURATION or a file-level VAR_GLOBAL | +| VAR_GLOBAL | Supported | Global variables (CONFIGURATION-scoped or file-level) | | CONSTANT | Supported | Compile-time constants | | RETAIN | Supported | Tracked in retain variable table | | NON_RETAIN | Supported | | | AT %IX0.0 | Supported | Located variables (I/Q/M areas, X/B/W/D/L sizes) | | Multiple names | Supported | `a, b, c : INT := 0;` | | Initialization | Supported | `:= expression` | +| Array initialization | Supported | `:= [1, 2, 3]` and the legacy `:= 1, 2, 3` | +| Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | ## Operators and Expressions diff --git a/src/ast-utils.ts b/src/ast-utils.ts index 14f64e6c..53ef04ce 100644 --- a/src/ast-utils.ts +++ b/src/ast-utils.ts @@ -52,6 +52,8 @@ import type { DrefExpression, NewExpression, ArrayLiteralExpression, + StructInitializerExpression, + StructElementInitializer, AssertCall, MockFunctionStatement, MockVerifyCallCountStatement, @@ -344,6 +346,7 @@ const EXPRESSION_KINDS = new Set([ "DrefExpression", "NewExpression", "ArrayLiteralExpression", + "StructInitializerExpression", ]); function isExpression(node: ASTNode): boolean { @@ -455,6 +458,7 @@ function getChildren(node: ASTNode): ASTNode[] { case "TypeDeclaration": { const td = node as TypeDeclaration; children.push(td.definition); + if (td.defaultValue) children.push(td.defaultValue); break; } @@ -652,6 +656,18 @@ function getChildren(node: ASTNode): ASTNode[] { break; } + case "StructInitializerExpression": { + const sie = node as StructInitializerExpression; + children.push(...sie.elements); + break; + } + + case "StructElementInitializer": { + const sei = node as StructElementInitializer; + children.push(sei.value); + break; + } + // --- Test framework --- case "AssertCall": { const ac = node as AssertCall; diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index 245e5e95..b8686e9c 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -40,12 +40,14 @@ import type { ProjectModel, ConfigurationDecl, ProgramDecl, + ProjectVarDeclaration, } from "../project-model.js"; import type { LibraryChunk, StlibArchive, } from "../library/library-manifest.js"; import { + collectFileScopeGlobals, getProjectNamespace, parseDateLiteralToDays, parseDtLiteralToNs, @@ -60,10 +62,16 @@ import { getTypeCategory, isImplicitlyConvertible, resolveFieldType as resolveFieldTypeUtil, + resolveArrayElementType as resolveArrayElementTypeUtil, typeName as typeNameUtil, buildEnumMemberMap, type EnumMemberEntry, } from "../semantic/type-utils.js"; +import { + generateInitializerValue, + isStructInitializerValue, + type StructInitEmitter, +} from "./struct-init-codegen.js"; // ============================================================================= // Located Variable Support @@ -357,6 +365,12 @@ export class CodeGenerator { /** Reverse map: enum member name (upper case) → owning enum type (for bare enum qualification) */ protected enumMemberToType: Map = new Map(); + /** Lazily built hooks for structure-initializer lowering (see getStructInitEmitter). */ + private structInitEmitter?: StructInitEmitter; + + /** Lazily built set of file-level VAR_GLOBAL names (see fileScopeGlobalNames). */ + private fileScopeGlobalNameCache?: Set; + /** Library FB field type map: "FBNAME.FIELDNAME" → type name (for field mangling in test codegen) */ private libraryFBFieldTypes: Map = new Map(); @@ -1064,6 +1078,7 @@ export class CodeGenerator { this.emitHeader('#include "iec_located.hpp"'); this.emitHeader('#include "iec_std_lib.hpp"'); this.emitHeader('#include "iec_enum.hpp"'); + this.emitHeader('#include "iec_struct.hpp"'); this.emitHeader('#include "iec_memory.hpp"'); this.emitHeader('#include "iec_pointer.hpp"'); this.emitHeader('#include "iec_string.hpp"'); @@ -1169,7 +1184,11 @@ export class CodeGenerator { for (const name of decl.names) { this.emitHeaderChunkMarker("begin", "inlineGlobal", name); if (decl.initialValue) { - const initExpr = this.generateExpression(decl.initialValue); + const initExpr = this.generateInitializer( + decl.initialValue, + cppType, + decl.type.name, + ); this.emitHeader( `${constQualifier}inline ${cppType} ${name} = ${initExpr};`, ); @@ -1425,26 +1444,47 @@ export class CodeGenerator { } /** - * Collect a function block's VAR_EXTERNAL references (name + resolved C++ - * type). IEC 61131-3 lets an FB access configuration globals this way; each - * becomes a `GlobalVar*` bound to the file-scope canonical. + * Collect a function block's VAR_EXTERNAL references to CONFIGURATION + * VAR_GLOBALs. IEC 61131-3 lets an FB access globals this way; each becomes a + * `GlobalVar*` bound to the file-scope canonical. + * + * References to a **file-level** VAR_GLOBAL are excluded: that storage is a + * plain file-scope object the FB body already reaches by name, so it needs no + * pointer member — and adding one would shadow the global it references. Same + * rule the project model applies to PROGRAMs (see `addVarExternal`). */ private collectFBExternals( fb: CompilationUnit["functionBlocks"][0], - ): Array<{ name: string; cppType: string }> { - const externals: Array<{ name: string; cppType: string }> = []; + ): Array<{ name: string; typeName: string; cppType: string }> { + const fileScopeGlobals = this.fileScopeGlobalNames(); + const externals: Array<{ + name: string; + typeName: string; + cppType: string; + }> = []; for (const block of fb.varBlocks) { if (block.blockType !== "VAR_EXTERNAL") continue; for (const decl of block.declarations) { const cppType = this.mapTypeRefToCpp(decl.type); for (const name of decl.names) { - externals.push({ name, cppType }); + if (fileScopeGlobals.has(name.toUpperCase())) continue; + externals.push({ name, typeName: decl.type.name, cppType }); } } } return externals; } + /** Upper-case names of the compilation unit's file-level VAR_GLOBALs. */ + private fileScopeGlobalNames(): Set { + if (!this.fileScopeGlobalNameCache) { + this.fileScopeGlobalNameCache = this.ast + ? new Set(collectFileScopeGlobals(this.ast).keys()) + : new Set(); + } + return this.fileScopeGlobalNameCache; + } + /** * Generate header declaration for a function block. */ @@ -1968,12 +2008,11 @@ export class CodeGenerator { if (block.blockType === "VAR" || block.blockType === "VAR_TEMP") { for (const decl of block.declarations) { for (const name of decl.names) { + const cppType = this.mapTypeRefToCpp(decl.type); const initValue = decl.initialValue - ? ` = ${this.generateExpression(decl.initialValue)}` + ? ` = ${this.generateInitializer(decl.initialValue, cppType, decl.type.name)}` : ""; - this.emit( - ` ${this.mapTypeRefToCpp(decl.type)} ${name}${initValue};`, - ); + this.emit(` ${cppType} ${name}${initValue};`); } } } @@ -2052,7 +2091,11 @@ export class CodeGenerator { for (const block of prog.varBlocks) { for (const decl of block.declarations) { if (decl.initialValue !== undefined) { - const initExpr = this.generateExpression(decl.initialValue); + const initExpr = this.generateInitializer( + decl.initialValue, + this.mapTypeRefToCpp(decl.type), + decl.type.name, + ); for (const name of decl.names) { this.emit(` ${name} = ${initExpr};`); } @@ -2098,13 +2141,7 @@ export class CodeGenerator { // VAR_EXTERNAL: body access (operator(), methods, properties) is rewritten // to go through the GlobalVar pointer (g->read()/write()/with_lock), exactly // like a PROGRAM. Set for the whole implementation, cleared at the end. - const externalDecls = fb.varBlocks - .filter((b) => b.blockType === "VAR_EXTERNAL") - .flatMap((b) => - b.declarations.flatMap((d) => - d.names.map((n) => ({ name: n, typeName: d.type.name })), - ), - ); + const externalDecls = this.collectFBExternals(fb); this.programExternals = new Set( externalDecls.map((e) => e.name.toUpperCase()), ); @@ -2132,9 +2169,13 @@ export class CodeGenerator { if (block.blockType === "VAR_EXTERNAL") continue; for (const decl of block.declarations) { if (decl.initialValue) { - const initExpr = this.generateExpression(decl.initialValue); + const cppType = this.mapTypeRefToCpp(decl.type); + const initExpr = this.generateInitializer( + decl.initialValue, + cppType, + decl.type.name, + ); for (const name of decl.names) { - const cppType = this.mapTypeRefToCpp(decl.type); const memberName = this.mangleMemberIfNeeded( name, cppType, @@ -2217,12 +2258,11 @@ export class CodeGenerator { if (block.blockType === "VAR" || block.blockType === "VAR_TEMP") { for (const decl of block.declarations) { for (const name of decl.names) { + const cppType = this.mapTypeRefToCpp(decl.type); const initValue = decl.initialValue - ? ` = ${this.generateExpression(decl.initialValue)}` + ? ` = ${this.generateInitializer(decl.initialValue, cppType, decl.type.name)}` : ""; - this.emit( - ` ${this.mapTypeRefToCpp(decl.type)} ${name}${initValue};`, - ); + this.emit(` ${cppType} ${name}${initValue};`); } } } @@ -2521,22 +2561,7 @@ export class CodeGenerator { // Initializer list const inits: string[] = []; for (const decl of prog.varDeclarations) { - // References (REF_TO / REFERENCE TO) and pointers (POINTER TO) wrap a - // pointer internally and must be default-constructed (unbound/null) — - // `name(0)` is ambiguous for IEC_REF_TO, and now also for IEC_Ptr, - // which gained an integer-address ctor (the `0` literal matches both - // the nullptr_t and the uintptr_t overload). The default ctor sets the - // pointer to nullptr, which is exactly the IEC default. References are - // bound later via REF= / := REF(); pointers via := ADR()/&. - if ( - decl.referenceKind === "ref_to" || - decl.referenceKind === "reference_to" || - decl.referenceKind === "pointer_to" - ) { - continue; - } - const initVal = this.getDefaultValue(decl.typeName, decl.initialValue); - // Skip user-defined types (empty initVal) - they use default constructors + const initVal = this.projectVarInitializer(decl); if (initVal) { inits.push(`${decl.name}(${initVal})`); } @@ -2561,22 +2586,7 @@ export class CodeGenerator { // Initializer list for local variables const inits: string[] = []; for (const decl of prog.varDeclarations) { - // References (REF_TO / REFERENCE TO) and pointers (POINTER TO) wrap a - // pointer internally and must be default-constructed (unbound/null) — - // `name(0)` is ambiguous for IEC_REF_TO, and now also for IEC_Ptr, - // which gained an integer-address ctor (the `0` literal matches both - // the nullptr_t and the uintptr_t overload). The default ctor sets the - // pointer to nullptr, which is exactly the IEC default. References are - // bound later via REF= / := REF(); pointers via := ADR()/&. - if ( - decl.referenceKind === "ref_to" || - decl.referenceKind === "reference_to" || - decl.referenceKind === "pointer_to" - ) { - continue; - } - const initVal = this.getDefaultValue(decl.typeName, decl.initialValue); - // Skip user-defined types (empty initVal) - they use default constructors + const initVal = this.projectVarInitializer(decl); if (initVal) { inits.push(`${decl.name}(${initVal})`); } @@ -2675,22 +2685,15 @@ export class CodeGenerator { if (seen.has(key)) continue; seen.add(key); - const cppType = this.mapTypeRefToCpp({ - name: gvar.typeName, - ...(gvar.maxLength !== undefined - ? { maxLength: gvar.maxLength } - : {}), - ...(gvar.arrayDimensions !== undefined - ? { arrayDimensions: gvar.arrayDimensions } - : {}), - ...(gvar.elementTypeName !== undefined - ? { elementTypeName: gvar.elementTypeName } - : {}), - ...(gvar.referenceKind !== undefined - ? { referenceKind: gvar.referenceKind } - : {}), - }); - const initVal = this.getDefaultValue(gvar.typeName, gvar.initialValue); + const cppType = this.mapTypeRefToCpp(this.projectVarToTypeRef(gvar)); + // GlobalVar's initialising constructor is a template + // (`template explicit GlobalVar(T)`), so a bare braced list + // has nothing to deduce from — name the type for aggregate initialisers + // (array literals) and pass everything else straight through. + const rawInit = this.projectVarInitializer(gvar) ?? ""; + const initVal = rawInit.startsWith("{") + ? `${cppType}${rawInit}` + : rawInit; if (!emittedAny) { this.emitHeader( @@ -3580,6 +3583,13 @@ export class CodeGenerator { const elements = expr.elements.map((e) => this.generateExpression(e)); return `{${elements.join(", ")}}`; } + case "StructInitializerExpression": + // A structure initializer needs the target's C++ type, which only the + // declaration site knows — see generateInitializer. Reaching here means + // one appeared where no type is available (it is not an expression IEC + // allows in a statement), so value-initialise rather than emit code that + // would not compile. + return "{}"; } } @@ -4699,7 +4709,7 @@ export class CodeGenerator { ) { args.push(this.emitOutputTempVar(param.typeName)); } else { - args.push(this.getDefaultValue(param.typeName)); + args.push(this.getTypeDefaultValue(param.typeName)); } } } @@ -4755,7 +4765,11 @@ export class CodeGenerator { blockType: block.blockType, }; if (decl.initialValue) { - entry.defaultExpr = this.generateExpression(decl.initialValue); + entry.defaultExpr = this.generateInitializer( + decl.initialValue, + this.mapTypeRefToCpp(decl.type), + decl.type.name, + ); } params.push(entry); } @@ -4857,7 +4871,8 @@ export class CodeGenerator { ) { result[i] = this.emitOutputTempVar(param.typeName); } else { - result[i] = param.defaultExpr ?? this.getDefaultValue(param.typeName); + result[i] = + param.defaultExpr ?? this.getTypeDefaultValue(param.typeName); } } } @@ -4871,7 +4886,7 @@ export class CodeGenerator { ) { return this.emitOutputTempVar(param.typeName); } - return param.defaultExpr ?? this.getDefaultValue(param.typeName); + return param.defaultExpr ?? this.getTypeDefaultValue(param.typeName); }); } @@ -5560,95 +5575,109 @@ export class CodeGenerator { } /** - * Get the default value for a type. + * Initialiser for a project-model variable, or undefined when the member + * should be left to its default constructor. + * + * Shared by the PROGRAM constructor initialiser lists and the file-scope + * VAR_GLOBAL definitions so all three agree on how a declaration initialises. */ - private getDefaultValue(typeName: string, initialValue?: string): string { - if (initialValue) { - // Convert enum dot-notation (TRAFFICSTATE.RED) to C++ scoped access (TRAFFICSTATE::RED) - const dotIdx = initialValue.indexOf("."); - if (dotIdx > 0) { - const prefix = initialValue.substring(0, dotIdx).toUpperCase(); - if (this.enumTypeMembers.has(prefix)) { - return initialValue.replace(".", "::"); - } - } - // Bare enum initializer: Stopped → Irrigation_State::Stopped - const bareEntry = this.enumMemberToType.get(initialValue.toUpperCase()); - if (bareEntry?.typeName) { - return `${bareEntry.typeName}::${initialValue}`; - } - // Convert TIME/LTIME literals (T#30s, TIME#1m2s) to nanoseconds - const upperInit = initialValue.toUpperCase(); - if ( - upperInit.startsWith("T#") || - upperInit.startsWith("TIME#") || - upperInit.startsWith("LTIME#") || - upperInit.startsWith("LT#") - ) { - const timeVal = parseTimeLiteral(initialValue); - return `${timeVal.nanoseconds}LL`; - } - // Convert temporal calendar literals at the PROGRAM-init path — - // FB initialisers route through `generateExpression` which - // handles these in `generateLiteralExpression`, but PROGRAM VAR - // initialisers come through this helper with the literal as a - // raw string. Without these branches the PROGRAM constructor - // emits `D(DATE#1970-01-15)` verbatim and the C++ side fails - // to compile. Lowering rule matches the literal-expression - // path: DATE → days, TOD → ns since midnight, DT → ns since - // epoch. Same rule the runtime helpers consume. - if (upperInit.startsWith("D#") || upperInit.startsWith("DATE#")) { - return `${parseDateLiteralToDays(initialValue)}LL`; - } - if ( - upperInit.startsWith("TOD#") || - upperInit.startsWith("TIME_OF_DAY#") - ) { - return `${parseTodLiteralToNs(initialValue)}LL`; - } - if ( - upperInit.startsWith("DT#") || - upperInit.startsWith("DATE_AND_TIME#") - ) { - return `${parseDtLiteralToNs(initialValue)}LL`; - } - // Convert IEC BOOL literals to C++ bool literals - if (upperInit === "TRUE") return "true"; - if (upperInit === "FALSE") return "false"; - // Convert IEC string literals to the matching C++ literal shape: - // 'foo' (STRING) → "foo" (const char*) - // "foo" (WSTRING) → u"foo" (const char16_t*, what IECWStringVar - // binds to — `L"…"` is wchar_t and - // 32-bit on Linux/AVR, wrong type) - // The two literal kinds are NOT interchangeable per IEC 61131-3; - // a mismatch (e.g. WSTRING := 'foo') is a type error and is the - // type-checker's responsibility, not codegen's. Codegen just - // mirrors the literal it was handed. - if (initialValue.startsWith("'") && initialValue.endsWith("'")) { - const inner = initialValue.slice(1, -1); - const escaped = this.translateIECString(inner); - return `"${escaped}"`; - } - if (initialValue.startsWith('"') && initialValue.endsWith('"')) { - const inner = initialValue.slice(1, -1); - const escaped = this.translateIECString(inner); - return `u"${escaped}"`; - } - // Lower IEC numeric literals (based 16#FF/8#17/2#1010, decimals - // with underscore separators, typed prefixes like INT#5, optional - // sign). PROGRAM/GLOBAL VAR initialisers arrive here as raw IEC - // strings; without this they're emitted verbatim (`X(16#FF)`, - // `X(1_000)`, `X(INT#5)`) and the C++ build fails. Mirrors the - // expression-statement path (formatIntegerLiteral). Returns null - // for non-numeric initialisers (enum names, constants), which then - // pass through unchanged. - const numeric = this.lowerNumericInitializer(initialValue); - if (numeric !== null) { - return numeric; - } - return initialValue; + private projectVarInitializer( + decl: ProjectVarDeclaration, + ): string | undefined { + // References (REF_TO / REFERENCE TO) and pointers (POINTER TO) wrap a + // pointer internally and must be default-constructed (unbound/null) — + // `name(0)` is ambiguous for IEC_REF_TO, and also for IEC_Ptr, which has an + // integer-address ctor (the `0` literal matches both the nullptr_t and the + // uintptr_t overload). The default ctor sets the pointer to nullptr, which + // is exactly the IEC default. References are bound later via REF= / := + // REF(); pointers via := ADR()/&. + if ( + decl.referenceKind === "ref_to" || + decl.referenceKind === "reference_to" || + decl.referenceKind === "pointer_to" + ) { + return undefined; + } + if (decl.initialValue) { + return this.generateInitializer( + decl.initialValue, + this.mapTypeRefToCpp(this.projectVarToTypeRef(decl)), + decl.typeName, + ); } + // Composite types (struct, enum, array, FB instance) report no default here + // (empty string) and are skipped, so their own default constructor runs. + const typeDefault = this.getTypeDefaultValue(decl.typeName); + return typeDefault === "" ? undefined : typeDefault; + } + /** + * Emit C++ for a declaration initialiser. + * + * Everything but a structure initializer is an ordinary expression; + * `structure_initialization` additionally needs the target's C++ type, which + * only the declaration site knows, so it routes through + * {@link generateInitializerValue}. + */ + protected generateInitializer( + value: Expression, + cppType: string, + stTypeName: string | undefined, + ): string { + if (!isStructInitializerValue(value)) { + return this.generateExpression(value); + } + return generateInitializerValue( + value, + cppType, + stTypeName, + this.getStructInitEmitter(), + ); + } + + /** + * Hooks {@link generateInitializerValue} uses to resolve element names and + * nested element types. Reuses the same member-mangling and field-resolution + * helpers the statement path uses, so `p.X` in a body and `X :=` in an + * initializer always name the same C++ member. + */ + private getStructInitEmitter(): StructInitEmitter { + this.structInitEmitter ??= { + emitValue: (value: Expression): string => this.generateExpression(value), + memberName: ( + fieldName: string, + ownerTypeName: string | undefined, + ): string => + this.needsFieldMangling( + fieldName, + this.resolveMemberType(ownerTypeName, fieldName), + ownerTypeName, + ) + ? `${fieldName}_` + : fieldName, + fieldTypeName: ( + fieldName: string, + ownerTypeName: string | undefined, + ): string | undefined => this.resolveMemberType(ownerTypeName, fieldName), + arrayElementTypeName: ( + typeName: string | undefined, + ): string | undefined => + typeName !== undefined && typeName !== "" && this.ast + ? resolveArrayElementTypeUtil(typeName, this.ast) + : undefined, + }; + return this.structInitEmitter; + } + + /** + * Value-initialisation for a type that has no declared initialiser. + * + * Returns an empty string for composite types (structs, enums, arrays, FB + * instances), whose default constructor already does the right thing — the + * callers use that to skip the member entirely in a constructor initialiser + * list. + */ + private getTypeDefaultValue(typeName: string): string { const upperType = typeName.toUpperCase(); if (upperType === "BOOL") return "false"; if (upperType === "REAL" || upperType === "LREAL") return "0.0"; @@ -5689,45 +5718,6 @@ export class CodeGenerator { return ""; } - /** - * Lower an IEC numeric literal initializer string to a C++ literal. - * - * Handles based literals (16#FF, 8#17, 2#1010), decimals/reals with - * IEC underscore separators (1_000, 16#FF_FF), an optional leading - * sign (-5, +3), and an optional IEC type prefix (INT#5, BYTE#16#AB, - * REAL#1.5). Reuses {@link iecBaseToCppLiteral}, the same helper the - * expression path uses, so declaration initialisers and statement - * bodies lower identically. - * - * Returns `null` when `raw` is not a recognised numeric literal, so - * non-numeric initialisers (enum names, named constants) pass through - * unchanged at the call site. - */ - private lowerNumericInitializer(raw: string): string | null { - let s = raw.trim(); - let sign = ""; - if (s.startsWith("-") || s.startsWith("+")) { - sign = s[0]!; - s = s.slice(1).trimStart(); - } - // Strip an optional IEC type prefix (TYPE#...). The leading - // identifier must start with a letter/underscore, which excludes - // radix markers like `16#` whose left side is numeric. - const typePrefix = /^[A-Za-z_][A-Za-z0-9_]*#(.+)$/.exec(s); - if (typePrefix) { - s = typePrefix[1]!; - } - const isNumeric = - /^16#[0-9A-Fa-f][0-9A-Fa-f_]*$/.test(s) || - /^8#[0-7][0-7_]*$/.test(s) || - /^2#[01][01_]*$/.test(s) || - /^[0-9][0-9_]*(\.[0-9][0-9_]*)?([eE][+-]?[0-9]+)?$/.test(s); - if (!isNumeric) { - return null; - } - return sign + iecBaseToCppLiteral(s); - } - /** * Collect all program instances from a configuration. */ diff --git a/src/backend/struct-init-codegen.ts b/src/backend/struct-init-codegen.ts new file mode 100644 index 00000000..8e1b1177 --- /dev/null +++ b/src/backend/struct-init-codegen.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * STruC++ Structure Initializer Code Generation + * + * Lowers IEC 61131-3 `structure_initialization` (Annex B.1.4.3) to C++17. + * + * p : Point := (y := 2.0, x := 1.0); + * + * strucpp::iec_struct_init([](auto& v0) { v0.Y = 2.0; v0.X = 1.0; }) + * + * Elements may be written in any order and may be omitted (an omitted element + * keeps the default from its own declaration), which rules out a plain braced + * aggregate initializer — C++17 has no designated initializers. The runtime + * helper default-constructs the value and the lambda overwrites exactly the + * elements the initializer names. + * + * Nested levels take their type from the member being assigned + * (`decltype(v0.INNER)`) rather than from resolved metadata, so this works + * unchanged for library types and inline array members. + * + * Shared by `codegen.ts` (variable declarations) and `type-codegen.ts` (STRUCT + * element defaults) through the {@link StructInitEmitter} hooks, so there is one + * lowering for structure initializers regardless of where they appear. + */ + +import type { + Expression, + StructInitializerExpression, +} from "../frontend/ast.js"; + +/** + * Host-supplied hooks. `codegen.ts` wires these to its full type resolution; + * `type-codegen.ts`, which has no AST, supplies only what it knows. + */ +export interface StructInitEmitter { + /** Emit C++ for a value that is neither a structure initializer nor an array literal. */ + emitValue(value: Expression): string; + /** + * C++ member name for `fieldName` on structure/FB type `ownerTypeName`, + * including the `_` collision mangle generated members may carry. + */ + memberName(fieldName: string, ownerTypeName: string | undefined): string; + /** ST type name of `fieldName` on `ownerTypeName`, when resolvable. */ + fieldTypeName( + fieldName: string, + ownerTypeName: string | undefined, + ): string | undefined; + /** ST element type of the array type `typeName`, when resolvable. */ + arrayElementTypeName(typeName: string | undefined): string | undefined; +} + +/** + * Emit C++ for a declaration initialiser. + * + * `cppTypeExpr` is a C++ type expression for the value being initialised — a + * type name at the top level, a `decltype(...)` further down. It is only needed + * for structure initializers; scalar and array-of-scalar initialisers ignore it. + * `stTypeName` is the corresponding ST type name, used to resolve element names + * and nested element types. + */ +export function generateInitializerValue( + value: Expression, + cppTypeExpr: string | undefined, + stTypeName: string | undefined, + emitter: StructInitEmitter, + depth = 0, +): string { + if (value.kind === "StructInitializerExpression") { + return generateStructInitializer( + value, + cppTypeExpr, + stTypeName, + emitter, + depth, + ); + } + + if (value.kind === "ArrayLiteralExpression") { + // `typename ::element_type` names the element type of every + // Array1D/2D/3D, so an array of STRUCTs needs no metadata lookup. + const elementCppType = + cppTypeExpr !== undefined && cppTypeExpr !== "" + ? `typename ${cppTypeExpr}::element_type` + : undefined; + const elementStType = emitter.arrayElementTypeName(stTypeName); + const elements = value.elements.map((element) => + generateInitializerValue( + element, + elementCppType, + elementStType, + emitter, + depth, + ), + ); + return `{${elements.join(", ")}}`; + } + + return emitter.emitValue(value); +} + +/** + * Emit `strucpp::iec_struct_init([](auto& vN) { … })` for one structure + * initializer level. + * + * Without a usable `cppTypeExpr` there is no type to construct, so the + * initializer degrades to value-initialisation rather than emitting code that + * would not compile. + */ +function generateStructInitializer( + expr: StructInitializerExpression, + cppTypeExpr: string | undefined, + stTypeName: string | undefined, + emitter: StructInitEmitter, + depth: number, +): string { + if ( + cppTypeExpr === undefined || + cppTypeExpr === "" || + expr.elements.length === 0 + ) { + return "{}"; + } + + const target = `v${depth}`; + const assignments = expr.elements.map((element) => { + const member = `${target}.${emitter.memberName(element.name, stTypeName)}`; + const memberStType = emitter.fieldTypeName(element.name, stTypeName); + const rhs = generateInitializerValue( + element.value, + `decltype(${member})`, + memberStType, + emitter, + depth + 1, + ); + return `${member} = ${rhs};`; + }); + + return `strucpp::iec_struct_init<${cppTypeExpr}>([](auto& ${target}) { ${assignments.join(" ")} })`; +} + +/** + * True when `value` needs {@link generateInitializerValue} rather than the plain + * expression path — i.e. it is (or contains) a structure initializer, whose + * lowering needs the target's C++ type. + */ +export function isStructInitializerValue(value: Expression): boolean { + if (value.kind === "StructInitializerExpression") return true; + if (value.kind === "ArrayLiteralExpression") { + return value.elements.some(isStructInitializerValue); + } + return false; +} diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index 451a710e..6ad81160 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -32,6 +32,11 @@ import { buildEnumMemberMap, type EnumMemberEntry, } from "../semantic/type-utils.js"; +import { + generateInitializerValue, + isStructInitializerValue, + type StructInitEmitter, +} from "./struct-init-codegen.js"; /** * Options for type code generation @@ -137,6 +142,22 @@ export class TypeCodeGenerator { /** Reverse map: enum member name (upper case) → owning enum type */ private enumMemberToType: Map = new Map(); + /** + * Hooks for structure-initializer lowering (a STRUCT element whose own default + * is a structure initializer: `origin : Point := (x := 0.0);`). + * + * The type generator works from one type definition at a time and has no + * cross-type field index, so it cannot resolve nested element types or the + * member-name collision mangle. Nested levels take their type from + * `decltype(...)` of the member being assigned, which needs no metadata. + */ + private structInitEmitter: StructInitEmitter = { + emitValue: (value: Expression): string => this.expressionToCpp(value), + memberName: (fieldName: string): string => fieldName, + fieldTypeName: (): undefined => undefined, + arrayElementTypeName: (): undefined => undefined, + }; + constructor(options: Partial = {}) { this.options = { ...defaultTypeCodeGenOptions, ...options }; } @@ -308,7 +329,14 @@ export class TypeCodeGenerator { ? `${fieldName}_` : fieldName; if (field.initialValue) { - const initVal = this.expressionToCpp(field.initialValue); + const initVal = isStructInitializerValue(field.initialValue) + ? generateInitializerValue( + field.initialValue, + cppType, + field.type.name, + this.structInitEmitter, + ) + : this.expressionToCpp(field.initialValue); // Array types can't be initialized with = 0; use {} instead const isArrayType = /^Array[123]D t.startOffset > assign.startOffset) + : undefined; + if (!defaultToken) return undefined; + return { + kind: "VariableExpression", + sourceSpan: tokenToSourceSpan(defaultToken), + name: defaultToken.image, + subscripts: [], + fieldAccess: [], + isDereference: false, }; } @@ -1299,6 +1341,40 @@ export class ASTBuilder { }; } + /** + * Build the expression behind an `initializerExpression` CST node. + * + * A single expression is used as-is; the legacy comma-separated form + * (`arr : ARRAY[0..3] OF INT := 0, 31, 59, 90;`) collapses into an + * ArrayLiteralExpression. Structure initializers need no special handling + * here — they are ordinary primary expressions. + * + * Returns undefined when there is no initialiser. + */ + private buildInitializerExpression( + initExprNode: CstNode | undefined, + ): Expression | undefined { + if (!initExprNode) return undefined; + const initChildren = initExprNode.children as CstChildren; + const exprNodes = getAllNodes(initChildren.expression); + if (exprNodes.length > 1) { + const elements: Expression[] = []; + for (const en of exprNodes) { + const e = this.buildExpression(en); + if (e) elements.push(e); + } + return { + kind: "ArrayLiteralExpression", + sourceSpan: nodeToSourceSpan(initExprNode), + elements, + }; + } + if (exprNodes.length === 1) { + return this.buildExpression(exprNodes[0]!); + } + return undefined; + } + /** * Build a VarDeclaration from a CST node. */ @@ -1339,31 +1415,9 @@ export class ASTBuilder { } // Get initial value if present (from initializerExpression rule) - let initialValue: Expression | undefined; - const initExprNode = getFirstNode(children.initializerExpression); - if (initExprNode) { - const initChildren = initExprNode.children as CstChildren; - const exprNodes = getAllNodes(initChildren.expression); - if (exprNodes.length > 1) { - // Multiple expressions → ArrayLiteralExpression - const elements: Expression[] = []; - for (const en of exprNodes) { - const e = this.buildExpression(en); - if (e) elements.push(e); - } - initialValue = { - kind: "ArrayLiteralExpression", - sourceSpan: nodeToSourceSpan(initExprNode), - elements, - }; - } else if (exprNodes.length === 1) { - // Single expression → use directly - const expr = this.buildExpression(exprNodes[0]!); - if (expr) { - initialValue = expr; - } - } - } + const initialValue = this.buildInitializerExpression( + getFirstNode(children.initializerExpression), + ); // Get address if present (AT %IX0.0) let address: string | undefined; @@ -2303,6 +2357,13 @@ export class ASTBuilder { } as ArrayLiteralExpression; } + // Check for structure initializer (field := value, ...) + if (children.structInitializer) { + return this.buildStructInitializerExpression( + getFirstNode(children.structInitializer)!, + ); + } + // Check for __NEW(type) or __NEW(type, size) expression if (children.newExpression) { return this.buildNewExpression(getFirstNode(children.newExpression)!); @@ -2362,6 +2423,36 @@ export class ASTBuilder { return this.tryBuildDirectExpression(node); } + /** + * Build a StructInitializerExpression from a structInitializer CST node. + * Element order is preserved as written; codegen assigns element by element. + */ + buildStructInitializerExpression(node: CstNode): StructInitializerExpression { + const children = node.children as CstChildren; + const elements: StructElementInitializer[] = []; + + for (const elemNode of getAllNodes(children.structElementInitializer)) { + const elemChildren = elemNode.children as CstChildren; + const nameNode = getFirstNode(elemChildren.identifierOrKeyword); + const valueNode = getFirstNode(elemChildren.expression); + if (!nameNode || !valueNode) continue; + const value = this.buildExpression(valueNode); + if (!value) continue; + elements.push({ + kind: "StructElementInitializer", + sourceSpan: nodeToSourceSpan(elemNode), + name: getIdentifierOrKeywordImage(nameNode), + value, + }); + } + + return { + kind: "StructInitializerExpression", + sourceSpan: nodeToSourceSpan(node), + elements, + }; + } + /** * Build a RefExpression from a CST node. */ diff --git a/src/frontend/ast.ts b/src/frontend/ast.ts index 374a21f4..c1396fdb 100644 --- a/src/frontend/ast.ts +++ b/src/frontend/ast.ts @@ -253,6 +253,16 @@ export interface TypeDeclaration extends ASTNode { kind: "TypeDeclaration"; name: string; definition: TypeDefinition; + /** + * Default value attached to the type itself + * (`TYPE Temp : REAL := 25.0; END_TYPE`, `TYPE Origin : Point := (x := 0.0);`). + * + * IEC 61131-3 Annex B.1.3.3 `initialized_simple_type_declaration` / + * `initialized_structure` / `initialized_array_type_declaration`. Applied to + * every declaration of the type that does not carry its own initialiser — see + * `applyTypeDefaults` in the AST builder. + */ + defaultValue?: Expression; } /** @@ -569,7 +579,8 @@ export type Expression = | RefExpression | DrefExpression | NewExpression - | ArrayLiteralExpression; + | ArrayLiteralExpression + | StructInitializerExpression; /** * Binary operator @@ -735,6 +746,31 @@ export interface ArrayLiteralExpression extends TypedNode { elements: Expression[]; } +/** + * One `element := value` pair inside a structure initializer. + * IEC 61131-3 Annex B.1.4.3 `structure_element_initialization`. + */ +export interface StructElementInitializer extends ASTNode { + kind: "StructElementInitializer"; + /** Structure element (field) name, as written in the source. */ + name: string; + value: Expression; +} + +/** + * Structure initializer: `(field := value, field := value)` + * + * IEC 61131-3 Annex B.1.4.3 `structure_initialization`. Used to initialise + * STRUCT-typed variables in a declaration and to set the initial inputs of a + * function block instance (`t : TON := (PT := T#1s)`). Elements may be given in + * any order and may be omitted, in which case the element keeps the default + * from its own declaration. + */ +export interface StructInitializerExpression extends TypedNode { + kind: "StructInitializerExpression"; + elements: StructElementInitializer[]; +} + // ============================================================================= // Test Framework Types // ============================================================================= diff --git a/src/frontend/parser.ts b/src/frontend/parser.ts index 88e01115..630fb7d9 100644 --- a/src/frontend/parser.ts +++ b/src/frontend/parser.ts @@ -484,6 +484,38 @@ export class STParser extends CstParser { this.CONSUME(tokens.RBracket); }); + /** + * Structure initializer: `(field := value, field := value)` + * + * IEC 61131-3 Annex B.1.4.3 `structure_initialization`, used to initialise a + * STRUCT-typed variable (`p : Point := (x := 1.0, y := 2.0)`) or the inputs of + * a function block instance (`t : TON := (PT := T#1s)`). + * + * Reached through `primaryExpression`, which is what lets element values be + * arbitrary expressions — including a nested structure initializer or an array + * literal — without a second grammar for initialisers. + */ + public structInitializer = this.RULE("structInitializer", () => { + this.CONSUME(tokens.LParen); + this.AT_LEAST_ONE_SEP({ + SEP: tokens.Comma, + DEF: () => this.SUBRULE(this.structElementInitializer), + }); + this.CONSUME(tokens.RParen); + }); + + /** + * One `element := value` pair of a structure initializer. + */ + public structElementInitializer = this.RULE( + "structElementInitializer", + () => { + this.SUBRULE(this.identifierOrKeyword); + this.CONSUME(tokens.Assign); + this.SUBRULE(this.expression); + }, + ); + // ========================================================================== // Type declarations // ========================================================================== @@ -534,6 +566,14 @@ export class STParser extends CstParser { ], IGNORE_AMBIGUITIES: true, }); + // Default value carried by the type itself: `Temp : REAL := 25.0;`, + // `Origin : Point := (x := 0.0, y := 0.0);`. IEC 61131-3 Annex B.1.3.3 + // (`initialized_simple_type_declaration` and friends). Every declaration of + // the type inherits it unless it supplies its own initialiser. + this.OPTION4(() => { + this.CONSUME(tokens.Assign); + this.SUBRULE(this.initializerExpression); + }); // Semicolon is optional after END_STRUCT END_TYPE (CODESYS tolerance) this.OPTION3(() => { this.CONSUME(tokens.Semicolon); @@ -977,6 +1017,21 @@ export class STParser extends CstParser { ); } + /** + * Lookahead helper: does a structure initializer start here? + * + * `(NAME :=` can only be `structure_initialization` — `:=` is not an operator + * inside an expression, so this never competes with a parenthesised + * expression. + */ + private isStructInitializerAhead(): boolean { + return ( + this.LA(1).tokenType === tokens.LParen && + this.isIdentifierOrKeywordToken(this.LA(2).tokenType) && + this.LA(3).tokenType === tokens.Assign + ); + } + /** * Lookahead helper to detect if the current position starts a CASE label. * Scans forward looking for a bare Colon (:) before finding Assign (:=), @@ -1470,6 +1525,13 @@ export class STParser extends CstParser { ALT: () => this.SUBRULE(this.arrayLiteral), GATE: () => this.LA(1).tokenType === tokens.LBracket, }, + // Structure initializer `(field := value, ...)` — must precede the + // parenthesised-expression alternative below, which would otherwise + // consume the `(` and then demand `)` at the `:=`. + { + ALT: () => this.SUBRULE(this.structInitializer), + GATE: () => this.isStructInitializerAhead(), + }, // functionCall and variable both start with Identifier; // functionCall needs Ident( lookahead to disambiguate { ALT: () => this.SUBRULE(this.functionCall) }, diff --git a/src/frontend/type-defaults.ts b/src/frontend/type-defaults.ts new file mode 100644 index 00000000..3eadaa77 --- /dev/null +++ b/src/frontend/type-defaults.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * STruC++ Type Default Propagation + * + * IEC 61131-3 Annex B.1.3.3 lets a TYPE declaration carry its own default + * value — `initialized_simple_type_declaration`, `initialized_structure` and + * `initialized_array_type_declaration`: + * + * TYPE + * Setpoint : REAL := 25.0; + * Origin : Point := (x := 0.0, y := 0.0); + * Light : (RED, GREEN) := GREEN; + * END_TYPE + * + * Every declaration of such a type that does not supply its own initialiser + * starts from the type's default. Rather than teaching each of the many + * declaration paths (globals, PROGRAM/FB/FUNCTION locals, struct fields …) about + * type defaults, this single pass copies the default onto those declarations + * right after the AST is built, so every downstream consumer — semantic + * analysis, the project model, codegen — sees an ordinary initialiser. + */ + +import type { + CompilationUnit, + Expression, + VarBlock, + VarDeclaration, +} from "./ast.js"; +import { walkAST } from "../ast-utils.js"; + +/** Guard against a cyclic alias chain (`TYPE A : B; B : A; END_TYPE`). */ +const MAX_ALIAS_DEPTH = 32; + +/** + * Copy TYPE-level default values onto every declaration of those types that + * lacks its own initialiser. Mutates `unit` in place. + * + * Idempotent: a declaration that already has an initialiser is never touched, + * so running the pass again (for instance on a merged multi-file unit) is safe. + */ +export function applyTypeDefaults(unit: CompilationUnit): void { + const defaults = new Map(); + /** Alias target of each type, for chains like `Celsius : Setpoint;`. */ + const aliasTargets = new Map(); + + for (const td of unit.types) { + const key = td.name.toUpperCase(); + if (td.defaultValue) defaults.set(key, td.defaultValue); + if (td.definition.kind === "TypeReference") { + aliasTargets.set(key, td.definition.name.toUpperCase()); + } + } + if (defaults.size === 0) return; + + walkAST(unit, (node): boolean => { + // VAR_EXTERNAL names a global declared elsewhere and VAR_IN_OUT is bound by + // the caller; neither owns storage to initialise. + if (node.kind === "VarBlock") { + const block = node as VarBlock; + return ( + block.blockType !== "VAR_EXTERNAL" && block.blockType !== "VAR_IN_OUT" + ); + } + if (node.kind !== "VarDeclaration") return true; + + const decl = node as VarDeclaration; + // A reference binds to existing storage — it has no value of its own. + if ( + decl.initialValue === undefined && + (!decl.type.referenceKind || decl.type.referenceKind === "none") + ) { + const defaultValue = resolveDefault( + decl.type.name, + defaults, + aliasTargets, + ); + if (defaultValue) decl.initialValue = defaultValue; + } + return true; + }); +} + +/** + * Find the default for `typeName`, following alias chains until one is found. + */ +function resolveDefault( + typeName: string, + defaults: Map, + aliasTargets: Map, +): Expression | undefined { + let current = typeName.toUpperCase(); + for (let depth = 0; depth < MAX_ALIAS_DEPTH; depth++) { + const own = defaults.get(current); + if (own !== undefined) return own; + const target = aliasTargets.get(current); + if (target === undefined || target === current) return undefined; + current = target; + } + return undefined; +} diff --git a/src/merge.ts b/src/merge.ts index f7011c93..e59cdc57 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -9,6 +9,7 @@ import type { CompilationUnit } from "./frontend/ast.js"; import { createCompilationUnit } from "./frontend/ast.js"; +import { applyTypeDefaults } from "./frontend/type-defaults.js"; /** * Merge multiple CompilationUnits into a single unit. @@ -41,5 +42,12 @@ export function mergeCompilationUnits( // Use the source span from the first unit merged.sourceSpan = units[0]!.sourceSpan; + // A TYPE with a default value (`Origin : Point := (x := 0.0)`) and the + // declarations that use it may live in different files, so the per-unit pass + // run by the AST builder can't see across. Re-run it on the merged unit; the + // pass only fills declarations that still have no initialiser, so declarations + // already resolved per-unit are untouched. + applyTypeDefaults(merged); + return merged; } diff --git a/src/project-model.ts b/src/project-model.ts index a63632f2..1ccbf5ec 100644 --- a/src/project-model.ts +++ b/src/project-model.ts @@ -68,7 +68,16 @@ export interface ProjectVarDeclaration { name: string; typeName: string; maxLength?: number | string; // For STRING(n) / WSTRING(n) parameterized length; string for constant names - initialValue?: string; + /** + * Declared initialiser, kept as the AST expression. + * + * Codegen lowers it with the same expression emitter it uses for statement + * bodies. An earlier version flattened this to a string here, which silently + * dropped every composite initialiser (array literals, structure + * initializers) — `expressionToString` had no case for them — and forced + * codegen to re-implement literal lowering for the string form. + */ + initialValue?: Expression; isConstant: boolean; isRetain: boolean; address?: string; @@ -226,6 +235,75 @@ export function toQualifiedCppName(name: string): string { return name.replace(/\./g, "::"); } +/** + * Convert an AST VarDeclaration to a ProjectVarDeclaration. + * + * Module-level so both the project-model builder and + * {@link collectFileScopeGlobals} produce identical records. + */ +export function toProjectVarDeclaration( + name: string, + decl: VarDeclaration, + block: VarBlock, +): ProjectVarDeclaration { + // Use conditional spreading for optional properties to comply with exactOptionalPropertyTypes + return { + name, + typeName: decl.type.name, + isConstant: block.isConstant, + isRetain: block.isRetain, + ...(decl.initialValue !== undefined + ? { initialValue: decl.initialValue } + : {}), + ...(decl.address !== undefined ? { address: decl.address } : {}), + ...(decl.type.maxLength !== undefined + ? { maxLength: decl.type.maxLength } + : {}), + // Carry inline-array metadata through so codegen can rebuild + // Array1D instead of falling through to mapVarTypeToCpp's + // IEC_${name} branch (which produces IEC___INLINE_ARRAY_). + ...(decl.type.arrayDimensions !== undefined + ? { arrayDimensions: decl.type.arrayDimensions } + : {}), + ...(decl.type.elementTypeName !== undefined + ? { elementTypeName: decl.type.elementTypeName } + : {}), + ...(decl.type.referenceKind !== undefined && + decl.type.referenceKind !== "none" + ? { referenceKind: decl.type.referenceKind } + : {}), + }; +} + +/** + * Collect the file-level VAR_GLOBAL blocks of a compilation unit, keyed by + * upper-case name. + * + * These are a different animal from CONFIGURATION VAR_GLOBALs: they are emitted + * as plain file-scope storage that every POU already reaches by name, with no + * `GlobalVar` wrapper and no mutex. A VAR_EXTERNAL that names one therefore + * needs no pointer member and no pointer threading — the declaration only + * documents the access, and the body resolves straight to the global. Both the + * project model (which drops such externals from a POU's pointer-plumbing list) + * and codegen (same, for function blocks) use this to tell the two apart. + */ +export function collectFileScopeGlobals( + ast: CompilationUnit, +): Map { + const globals = new Map(); + for (const block of ast.globalVarBlocks) { + for (const decl of block.declarations) { + for (const name of decl.names) { + globals.set( + name.toUpperCase(), + toProjectVarDeclaration(name, decl, block), + ); + } + } + } + return globals; +} + /** * Result of building the project model. */ @@ -372,6 +450,9 @@ export class ProjectModelBuilder { private functionBlocks: Map = new Map(); private configurations: ConfigurationDecl[] = []; + /** File-level VAR_GLOBALs by upper-case name — see collectFileScopeGlobals. */ + private fileScopeGlobals: Map = new Map(); + /** * Build the project model from an AST. */ @@ -382,6 +463,7 @@ export class ProjectModelBuilder { this.functions = new Map(); this.functionBlocks = new Map(); this.configurations = []; + this.fileScopeGlobals = collectFileScopeGlobals(ast); // First pass: collect all program, function, and function block declarations for (const prog of ast.programs) { @@ -442,15 +524,17 @@ export class ProjectModelBuilder { // Carry type-shape metadata through so codegen can rebuild // Array1D<...> / IEC_Ptr<...> instead of falling through to // mapVarTypeToCpp's IEC_${name} default. - varExternal.push(this.convertVarExternal(varName, decl)); + this.addVarExternal( + varExternal, + this.convertVarExternal(varName, decl), + `program '${prog.name}'`, + ); } } } else { for (const decl of block.declarations) { for (const varName of decl.names) { - varDeclarations.push( - this.convertVarDeclaration(varName, decl, block), - ); + varDeclarations.push(toProjectVarDeclaration(varName, decl, block)); } } } @@ -485,7 +569,7 @@ export class ProjectModelBuilder { if (block.blockType === "VAR_INPUT") { for (const decl of block.declarations) { for (const varName of decl.names) { - parameters.push(this.convertVarDeclaration(varName, decl, block)); + parameters.push(toProjectVarDeclaration(varName, decl, block)); } } } @@ -526,7 +610,11 @@ export class ProjectModelBuilder { if (block.blockType === "VAR_EXTERNAL") { for (const decl of block.declarations) { for (const varName of decl.names) { - varExternal.push(this.convertVarExternal(varName, decl)); + this.addVarExternal( + varExternal, + this.convertVarExternal(varName, decl), + `function block '${fb.name}'`, + ); } } continue; @@ -543,7 +631,7 @@ export class ProjectModelBuilder { for (const decl of block.declarations) { for (const varName of decl.names) { - target.push(this.convertVarDeclaration(varName, decl, block)); + target.push(toProjectVarDeclaration(varName, decl, block)); } } } @@ -569,7 +657,7 @@ export class ProjectModelBuilder { if (block.blockType === "VAR_GLOBAL") { for (const decl of block.declarations) { for (const varName of decl.names) { - globalVars.push(this.convertVarDeclaration(varName, decl, block)); + globalVars.push(toProjectVarDeclaration(varName, decl, block)); } } } @@ -694,6 +782,37 @@ export class ProjectModelBuilder { }; } + /** + * Record a POU's VAR_EXTERNAL reference. + * + * A reference to a **file-level** VAR_GLOBAL is validated here and then + * dropped: those globals are plain file-scope storage that the POU body + * already resolves to by name, so keeping them would make codegen add a + * `GlobalVar*` member and shadow the very global being referenced. A + * reference to a CONFIGURATION VAR_GLOBAL is kept for the pointer plumbing and + * validated later by {@link validateExternalReferences}, once every + * configuration has been processed. + */ + private addVarExternal( + varExternal: VarExternalDeclaration[], + ext: VarExternalDeclaration, + ownerLabel: string, + ): void { + const fileScope = this.fileScopeGlobals.get(ext.name.toUpperCase()); + if (!fileScope) { + varExternal.push(ext); + return; + } + if (fileScope.typeName.toUpperCase() !== ext.typeName.toUpperCase()) { + this.addError( + `Type mismatch for VAR_EXTERNAL '${ext.name}' in ${ownerLabel}: expected '${fileScope.typeName}' but found '${ext.typeName}'`, + ext.sourceSpan?.startLine ?? 0, + ext.sourceSpan?.startCol ?? 0, + ext.sourceSpan?.file, + ); + } + } + /** * Validate VAR_EXTERNAL references against VAR_GLOBAL declarations. */ @@ -811,46 +930,6 @@ export class ProjectModelBuilder { }; } - /** - * Convert an AST VarDeclaration to a ProjectVarDeclaration. - */ - private convertVarDeclaration( - name: string, - decl: VarDeclaration, - block: VarBlock, - ): ProjectVarDeclaration { - let initialValue: string | undefined; - if (decl.initialValue) { - initialValue = this.expressionToString(decl.initialValue); - } - - // Use conditional spreading for optional properties to comply with exactOptionalPropertyTypes - return { - name, - typeName: decl.type.name, - isConstant: block.isConstant, - isRetain: block.isRetain, - ...(initialValue !== undefined ? { initialValue } : {}), - ...(decl.address !== undefined ? { address: decl.address } : {}), - ...(decl.type.maxLength !== undefined - ? { maxLength: decl.type.maxLength } - : {}), - // Carry inline-array metadata through so codegen can rebuild - // Array1D instead of falling through to mapVarTypeToCpp's - // IEC_${name} branch (which produces IEC___INLINE_ARRAY_). - ...(decl.type.arrayDimensions !== undefined - ? { arrayDimensions: decl.type.arrayDimensions } - : {}), - ...(decl.type.elementTypeName !== undefined - ? { elementTypeName: decl.type.elementTypeName } - : {}), - ...(decl.type.referenceKind !== undefined && - decl.type.referenceKind !== "none" - ? { referenceKind: decl.type.referenceKind } - : {}), - }; - } - /** * Extract a TIME value from an expression. */ @@ -884,33 +963,6 @@ export class ProjectModelBuilder { return undefined; } - /** - * Convert an expression to a string representation. - */ - private expressionToString(expr: Expression): string { - if (expr.kind === "LiteralExpression") { - const lit = expr; - return lit.rawValue; - } - if ( - expr.kind === "UnaryExpression" && - (expr.operator === "-" || expr.operator === "+") - ) { - // Preserve the sign on numeric literal initialisers (e.g. -5). - // Without this the operand is dropped and the initialiser silently - // falls back to the type's default (0). Codegen lowers the result. - const inner = this.expressionToString(expr.operand); - return inner === "" ? "" : `${expr.operator}${inner}`; - } - if (expr.kind === "VariableExpression") { - if (expr.fieldAccess.length > 0) { - return `${expr.name}.${expr.fieldAccess.join(".")}`; - } - return expr.name; - } - return ""; - } - /** * Add an error message. */ diff --git a/src/runtime/include/iec_struct.hpp b/src/runtime/include/iec_struct.hpp index 660a38a0..7236df64 100644 --- a/src/runtime/include/iec_struct.hpp +++ b/src/runtime/include/iec_struct.hpp @@ -26,12 +26,36 @@ namespace strucpp { class IEC_STRUCT_Base { public: virtual ~IEC_STRUCT_Base() = default; - + // Optional: type name for debugging/reflection // Subclasses can override to return their type name virtual const char* type_name() const noexcept { return "STRUCT"; } }; +/** + * Build a value of T from an IEC 61131-3 structure initializer + * (`p : Point := (y := 2.0, x := 1.0)`). + * + * `T v{}` gives every element the default from its own declaration; `setter` + * then overwrites only the elements the initializer actually names. That is + * exactly the semantics the standard asks for, and it is why this is a helper + * rather than a braced aggregate initializer: elements may appear in any order + * and may be omitted, and C++17 has no designated initializers to express that. + * + * Works for anything default-constructible and movable, so a STRUCT, an array + * of STRUCTs, and a function block instance all initialise through this one + * path: + * + * inline POINT ORIGIN = iec_struct_init( + * [](POINT& v0) { v0.Y = 2.0; v0.X = 1.0; }); + */ +template +inline T iec_struct_init(Setter&& setter) { + T value{}; + setter(value); + return value; +} + /* * Example generated structure: * diff --git a/tests/backend/codegen-structure-initialization.test.ts b/tests/backend/codegen-structure-initialization.test.ts new file mode 100644 index 00000000..3294b463 --- /dev/null +++ b/tests/backend/codegen-structure-initialization.test.ts @@ -0,0 +1,423 @@ +/** + * Code-generation tests for IEC 61131-3 `structure_initialization` + * (Annex B.1.4.3) and for composite initialisers on PROGRAM variables. + * + * A structure initializer lowers to `strucpp::iec_struct_init([](auto& v0){…})` + * rather than a braced aggregate initializer: elements may be written in any + * order and may be omitted (an omitted element keeps the default from its own + * declaration), and C++17 has no designated initializers to express that. The + * runtime helper default-constructs the value — which applies every element's own + * default — and the lambda overwrites only the elements that are named. + * + * Nested levels take their type from `decltype(v0.MEMBER)`, so no metadata + * lookup is needed for library types or inline array members. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +function compileST(source: string): { + cppCode: string; + headerCode: string; + success: boolean; + errors: { message: string }[]; +} { + const result = compile(source); + return { + cppCode: result.cppCode, + headerCode: result.headerCode, + success: result.success, + errors: result.errors as { message: string }[], + }; +} + +function expectOk(result: { success: boolean; errors: { message: string }[] }) { + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); +} + +/** The constructor initializer-list line of a generated class. */ +function initList(cpp: string): string { + return cpp.split("\n").find((l) => l.trimStart().startsWith(": ")) ?? ""; +} + +const POINT_TYPE = ` + TYPE + Point : STRUCT + x : REAL; + y : REAL; + END_STRUCT; + END_TYPE +`; + +describe("structure initializers — file-level VAR_GLOBAL", () => { + it("initialises a struct global through the runtime helper", () => { + const result = compileST(` + ${POINT_TYPE} + VAR_GLOBAL + origin : Point := (x := 1.0, y := 2.0); + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain( + "inline POINT ORIGIN = strucpp::iec_struct_init([](auto& v0) { v0.X = 1.0; v0.Y = 2.0; });", + ); + }); + + it("emits elements in the order written, not in declaration order", () => { + // The helper assigns, so source order is preserved and harmless — unlike a + // positional aggregate initializer, which would silently swap the values. + const result = compileST(` + ${POINT_TYPE} + VAR_GLOBAL + p : Point := (y := 2.0, x := 1.0); + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain("v0.Y = 2.0; v0.X = 1.0;"); + }); + + it("leaves an omitted element to its own declared default", () => { + const result = compileST(` + TYPE + Scale : STRUCT + lo : REAL := 4.0; + hi : REAL := 20.0; + END_STRUCT; + END_TYPE + VAR_GLOBAL + s : Scale := (hi := 22.0); + END_VAR + `); + expectOk(result); + // The struct keeps its own member defaults … + expect(result.headerCode).toContain("IEC_REAL LO = 4;"); + // … and only the named element is overwritten. + expect(result.headerCode).toContain( + "strucpp::iec_struct_init([](auto& v0) { v0.HI = 22.0; })", + ); + }); + + it("keeps a CONSTANT struct global const-qualified", () => { + const result = compileST(` + ${POINT_TYPE} + VAR_GLOBAL CONSTANT + origin : Point := (x := 0.0, y := 0.0); + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain("const inline POINT ORIGIN ="); + }); +}); + +describe("structure initializers — CONFIGURATION VAR_GLOBAL", () => { + it("initialises the GlobalVar wrapper's value", () => { + const result = compileST(` + ${POINT_TYPE} + CONFIGURATION Cfg + VAR_GLOBAL + origin : Point := (x := 1.0, y := 2.0); + END_VAR + END_CONFIGURATION + `); + expectOk(result); + expect(result.headerCode).toContain( + "inline GlobalVar ORIGIN{strucpp::iec_struct_init([](auto& v0) { v0.X = 1.0; v0.Y = 2.0; })};", + ); + }); + + it("names the type for an array initialiser, which GlobalVar cannot deduce", () => { + // GlobalVar's initialising ctor is `template GlobalVar(T)`, so a + // bare `{1, 2, 3}` has nothing to deduce from. + const result = compileST(` + CONFIGURATION Cfg + VAR_GLOBAL + arr : ARRAY[0..2] OF INT := [1, 2, 3]; + END_VAR + END_CONFIGURATION + `); + expectOk(result); + expect(result.headerCode).toContain( + "inline GlobalVar> ARR{Array1D{1, 2, 3}};", + ); + }); +}); + +describe("structure initializers — PROGRAM variables", () => { + it("initialises a struct member in the constructor initialiser list", () => { + const result = compileST(` + ${POINT_TYPE} + PROGRAM Main + VAR p : Point := (x := 1.0, y := 2.0); END_VAR + p.x := p.y; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain( + "P(strucpp::iec_struct_init([](auto& v0) { v0.X = 1.0; v0.Y = 2.0; }))", + ); + }); + + it("nests through decltype of the member being assigned", () => { + const result = compileST(` + TYPE + Inner : STRUCT a : INT; END_STRUCT; + Outer : STRUCT + i : Inner; + b : INT; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer := (i := (a := 5), b := 7); END_VAR + o.b := o.i.a; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain( + "O(strucpp::iec_struct_init([](auto& v0) { " + + "v0.I = strucpp::iec_struct_init([](auto& v1) { v1.A = 5; }); " + + "v0.B = 7; }))", + ); + }); + + it("initialises an array of structs element by element", () => { + const result = compileST(` + ${POINT_TYPE} + PROGRAM Main + VAR + pts : ARRAY[0..1] OF Point := [(x := 1.0, y := 2.0), (x := 3.0, y := 4.0)]; + END_VAR + pts[0].x := pts[1].y; + END_PROGRAM + `); + expectOk(result); + const inits = initList(result.cppCode); + // The element type comes from the array type, so no metadata lookup. + expect(inits).toContain( + "typename Array1D::element_type>([](auto& v0) { v0.X = 1.0; v0.Y = 2.0; })", + ); + expect(inits).toContain( + "typename Array1D::element_type>([](auto& v0) { v0.X = 3.0; v0.Y = 4.0; })", + ); + }); + + it("initialises a function block instance's inputs", () => { + // IEC 61131-3 uses the same `structure_initialization` production for + // `fb_name_decl`, so an FB instance sets its initial inputs this way. + const result = compileST(` + FUNCTION_BLOCK Ramp + VAR_INPUT + step : REAL := 1.0; + period : TIME := T#100ms; + END_VAR + step := step; + END_FUNCTION_BLOCK + PROGRAM Main + VAR r : Ramp := (period := T#1s, step := 2.5); END_VAR + r(); + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain( + "R(strucpp::iec_struct_init([](auto& v0) { v0.PERIOD = 1000000000LL; v0.STEP = 2.5; }))", + ); + }); +}); + +describe("structure initializers — FUNCTION_BLOCK, FUNCTION and METHOD", () => { + it("initialises a function block member", () => { + const result = compileST(` + ${POINT_TYPE} + FUNCTION_BLOCK FB + VAR p : Point := (x := 1.0, y := 2.0); END_VAR + p.x := p.y; + END_FUNCTION_BLOCK + `); + expectOk(result); + expect(initList(result.cppCode)).toContain( + "P(strucpp::iec_struct_init([](auto& v0) { v0.X = 1.0; v0.Y = 2.0; }))", + ); + }); + + it("initialises a function local", () => { + const result = compileST(` + ${POINT_TYPE} + FUNCTION F : REAL + VAR p : Point := (x := 1.5, y := 2.5); END_VAR + F := p.x; + END_FUNCTION + `); + expectOk(result); + expect(result.cppCode).toContain( + "POINT P = strucpp::iec_struct_init([](auto& v0) { v0.X = 1.5; v0.Y = 2.5; });", + ); + }); + + it("initialises a method local", () => { + const result = compileST(` + ${POINT_TYPE} + FUNCTION_BLOCK FB + METHOD M : REAL + VAR p : Point := (x := 1.5); END_VAR + M := p.x; + END_METHOD + END_FUNCTION_BLOCK + `); + expectOk(result); + expect(result.cppCode).toContain( + "POINT P = strucpp::iec_struct_init([](auto& v0) { v0.X = 1.5; });", + ); + }); +}); + +describe("structure initializers — STRUCT element defaults", () => { + it("lowers a nested structure default on a STRUCT element", () => { + const result = compileST(` + TYPE + Inner : STRUCT a : INT; END_STRUCT; + Outer : STRUCT + i : Inner := (a := 5); + b : INT := 7; + END_STRUCT; + END_TYPE + VAR_GLOBAL + o : Outer; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain( + "INNER I = strucpp::iec_struct_init([](auto& v0) { v0.A = 5; });", + ); + }); +}); + +describe("structure initializers — type-level defaults", () => { + it("applies an initialised structure type's default to a declaration", () => { + const result = compileST(` + ${POINT_TYPE} + TYPE + Origin : Point := (x := 0.0, y := 0.0); + END_TYPE + PROGRAM Main + VAR p : Origin; END_VAR + p.x := p.y; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain( + "P(strucpp::iec_struct_init([](auto& v0) { v0.X = 0.0; v0.Y = 0.0; }))", + ); + }); + + it("applies an initialised simple type's default to a declaration", () => { + const result = compileST(` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + PROGRAM Main + VAR s : Setpoint; END_VAR + s := s; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain("S(25.0)"); + }); + + it("applies a type default across files (resolved on the merged unit)", () => { + // The TYPE and the declaration that uses it can live in different files, so + // the per-unit pass can't see across — the merge re-runs it. + const result = compile( + ` + PROGRAM Main + VAR p : Origin; END_VAR + p.x := p.y; + END_PROGRAM + `, + { + additionalSources: [ + { + source: ` + ${POINT_TYPE} + TYPE + Origin : Point := (x := 1.5, y := 2.5); + END_TYPE + `, + fileName: "types.st", + }, + ], + }, + ); + expect( + result.errors.map((e) => (e as { message: string }).message), + ).toEqual([]); + expect(result.success).toBe(true); + expect(initList(result.cppCode)).toContain( + "P(strucpp::iec_struct_init([](auto& v0) { v0.X = 1.5; v0.Y = 2.5; }))", + ); + }); + + it("qualifies a simple enum's default value", () => { + const result = compileST(` + TYPE + Light : (RED, GREEN) := GREEN; + END_TYPE + PROGRAM Main + VAR l : Light; END_VAR + l := l; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain("L(LIGHT::GREEN)"); + }); +}); + +describe("composite initialisers on PROGRAM variables", () => { + // These were silently dropped: PROGRAM variables reached codegen through a + // stringified copy of the initializer that had no case for array literals, so + // the constructor came out empty with no diagnostic. + it("emits a bracketed array literal initialiser", () => { + const result = compileST(` + PROGRAM Main + VAR arr : ARRAY[0..2] OF INT := [1, 2, 3]; END_VAR + arr[0] := 0; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain("ARR({1, 2, 3})"); + }); + + it("emits the legacy comma-separated array initialiser", () => { + const result = compileST(` + PROGRAM Main + VAR arr : ARRAY[0..3] OF INT := 0, 31, 59, 90; END_VAR + arr[0] := 0; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain("ARR({0, 31, 59, 90})"); + }); + + it("emits a 2D array literal initialiser", () => { + const result = compileST(` + PROGRAM Main + VAR m : ARRAY[0..1, 0..1] OF INT := [1, 2, 3, 4]; END_VAR + m[0, 0] := 0; + END_PROGRAM + `); + expectOk(result); + expect(initList(result.cppCode)).toContain("M({1, 2, 3, 4})"); + }); + + it("still skips composite types that have no initialiser", () => { + const result = compileST(` + ${POINT_TYPE} + PROGRAM Main + VAR p : Point; arr : ARRAY[0..2] OF INT; END_VAR + p.x := 1.0; + END_PROGRAM + `); + expectOk(result); + // Nothing to initialise → default constructors, so no initialiser list. + expect(initList(result.cppCode)).toBe(""); + }); +}); diff --git a/tests/backend/codegen-var-initializers.test.ts b/tests/backend/codegen-var-initializers.test.ts index 121d8538..995c0e31 100644 --- a/tests/backend/codegen-var-initializers.test.ts +++ b/tests/backend/codegen-var-initializers.test.ts @@ -2,14 +2,17 @@ * Regression tests for issue #133 — numeric literal lowering in VAR * initializers. * - * IEC numeric literals used as VAR initial values reach the program / - * global codegen path as raw IEC strings (project-model stringifies the - * initializer expression). They must be lowered to valid C++ the same - * way the expression-statement path lowers them — otherwise the - * constructor initializer list emits e.g. `X(16#FF)` / `X(INT#5)` / - * `X(1_000)` verbatim and the generated C++ fails to compile - * (`stray '#' in program`, bad digit separators), or a signed literal - * like `-5` is silently dropped to the type default. + * IEC numeric literals used as VAR initial values must be lowered to valid C++ + * the same way the expression-statement path lowers them — otherwise the + * constructor initializer list emits e.g. `X(16#FF)` / `X(INT#5)` / `X(1_000)` + * verbatim and the generated C++ fails to compile (`stray '#' in program`, bad + * digit separators), or a signed literal like `-5` is silently dropped to the + * type default. + * + * The project model now carries the initializer as the AST expression rather + * than a stringified copy, so these initializers go through the one expression + * emitter instead of a parallel string-lowering pass. The expected output below + * is therefore exactly what the same literal produces in a statement body. */ import { describe, it, expect } from "vitest"; @@ -89,10 +92,12 @@ describe("issue #133: VAR initializer literal lowering", () => { `); expect(success).toBe(true); const inits = initList(cppCode); - expect(inits).toContain("A(5)"); - expect(inits).toContain("B(0x10)"); - expect(inits).toContain("C(0xAB)"); - expect(inits).toContain("D(1.5)"); + // A typed literal keeps its type via the same static_cast the expression + // path emits; what matters is that the IEC `TYPE#` syntax is gone. + expect(inits).toContain("A(static_cast(5))"); + expect(inits).toContain("B(static_cast(0x10))"); + expect(inits).toContain("C(static_cast(0xAB))"); + expect(inits).toContain("D(static_cast(1.5))"); expect(inits).not.toContain("#"); }); @@ -129,7 +134,9 @@ describe("issue #133: VAR initializer literal lowering", () => { const inits = initList(cppCode); expect(inits).toContain("D(255)"); expect(inits).toContain("R(1.5)"); - expect(inits).toContain("E(1.5E3)"); + // Scientific notation is normalised to a plain C++ double literal, as in a + // statement body — still valid C++ with the same value. + expect(inits).toContain("E(1500.0)"); expect(inits).toContain("T(true)"); }); diff --git a/tests/backend/struct-init-codegen.test.ts b/tests/backend/struct-init-codegen.test.ts new file mode 100644 index 00000000..b090630a --- /dev/null +++ b/tests/backend/struct-init-codegen.test.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for the shared structure-initializer lowering. + * + * `codegen.ts` and `type-codegen.ts` both drive this module through the + * {@link StructInitEmitter} hooks, and they supply different amounts of type + * information — codegen resolves element types and the member-name collision + * mangle from the AST, the type generator resolves neither. These tests pin the + * contract at both ends, including what happens when the target's C++ type is + * unknown (value-initialise rather than emit code that would not compile). + */ + +import { describe, it, expect } from "vitest"; +import { + generateInitializerValue, + isStructInitializerValue, + type StructInitEmitter, +} from "../../src/backend/struct-init-codegen.js"; +import type { + Expression, + StructInitializerExpression, +} from "../../src/frontend/ast.js"; + +const SPAN = { + file: "t.st", + startLine: 1, + startCol: 1, + endLine: 1, + endCol: 1, +}; + +function literal(raw: string): Expression { + return { + kind: "LiteralExpression", + sourceSpan: SPAN, + literalType: "INT", + value: Number(raw), + rawValue: raw, + }; +} + +function structInit( + elements: Array<[string, Expression]>, +): StructInitializerExpression { + return { + kind: "StructInitializerExpression", + sourceSpan: SPAN, + elements: elements.map(([name, value]) => ({ + kind: "StructElementInitializer", + sourceSpan: SPAN, + name, + value, + })), + }; +} + +function arrayLiteral(elements: Expression[]): Expression { + return { kind: "ArrayLiteralExpression", sourceSpan: SPAN, elements }; +} + +/** The minimal emitter — what `type-codegen.ts` supplies. */ +const bareEmitter: StructInitEmitter = { + emitValue: (value) => + value.kind === "LiteralExpression" ? value.rawValue : "?", + memberName: (fieldName) => fieldName, + fieldTypeName: () => undefined, + arrayElementTypeName: () => undefined, +}; + +describe("isStructInitializerValue", () => { + it("is true for a structure initializer", () => { + expect(isStructInitializerValue(structInit([["A", literal("1")]]))).toBe( + true, + ); + }); + + it("is true for an array literal containing one", () => { + expect( + isStructInitializerValue( + arrayLiteral([structInit([["A", literal("1")]])]), + ), + ).toBe(true); + }); + + it("is false for a plain array literal", () => { + expect(isStructInitializerValue(arrayLiteral([literal("1")]))).toBe(false); + }); + + it("is false for a scalar expression", () => { + expect(isStructInitializerValue(literal("1"))).toBe(false); + }); +}); + +describe("generateInitializerValue", () => { + it("emits the runtime helper for a structure initializer", () => { + expect( + generateInitializerValue( + structInit([ + ["A", literal("1")], + ["B", literal("2")], + ]), + "POINT", + "Point", + bareEmitter, + ), + ).toBe( + "strucpp::iec_struct_init([](auto& v0) { v0.A = 1; v0.B = 2; })", + ); + }); + + it("takes a nested level's type from decltype of the member", () => { + expect( + generateInitializerValue( + structInit([["I", structInit([["A", literal("5")]])]]), + "OUTER", + "Outer", + bareEmitter, + ), + ).toBe( + "strucpp::iec_struct_init([](auto& v0) { " + + "v0.I = strucpp::iec_struct_init([](auto& v1) { v1.A = 5; }); })", + ); + }); + + it("names array elements through the array type's element_type", () => { + expect( + generateInitializerValue( + arrayLiteral([ + structInit([["X", literal("1")]]), + structInit([["X", literal("2")]]), + ]), + "Array1D", + "__INLINE_ARRAY_POINT", + bareEmitter, + ), + ).toBe( + "{strucpp::iec_struct_init::element_type>([](auto& v0) { v0.X = 1; }), " + + "strucpp::iec_struct_init::element_type>([](auto& v0) { v0.X = 2; })}", + ); + }); + + it("delegates a scalar value to the host emitter", () => { + expect( + generateInitializerValue(literal("7"), "IEC_INT", "INT", bareEmitter), + ).toBe("7"); + }); + + it("emits a plain braced list for an array literal of scalars", () => { + expect( + generateInitializerValue( + arrayLiteral([literal("1"), literal("2")]), + "Array1D", + undefined, + bareEmitter, + ), + ).toBe("{1, 2}"); + }); + + it("value-initialises when the target's C++ type is unknown", () => { + // No type to instantiate the helper with, so emit `{}` rather than code that + // would not compile. + expect( + generateInitializerValue( + structInit([["A", literal("1")]]), + undefined, + "Point", + bareEmitter, + ), + ).toBe("{}"); + }); + + it("value-initialises an empty structure initializer", () => { + expect( + generateInitializerValue(structInit([]), "POINT", "Point", bareEmitter), + ).toBe("{}"); + }); + + it("value-initialises array elements when the array type is unknown", () => { + expect( + generateInitializerValue( + arrayLiteral([structInit([["X", literal("1")]])]), + undefined, + undefined, + bareEmitter, + ), + ).toBe("{{}}"); + }); + + it("uses the host's member name and element-type resolution", () => { + // What `codegen.ts` supplies: a mangled member name and a resolved element + // type for the nested level. + const resolvingEmitter: StructInitEmitter = { + ...bareEmitter, + memberName: (fieldName, ownerTypeName) => + ownerTypeName === "Outer" && fieldName === "INNER" + ? "INNER_" + : fieldName, + fieldTypeName: (fieldName) => + fieldName === "INNER" ? "Inner" : undefined, + }; + expect( + generateInitializerValue( + structInit([["INNER", structInit([["A", literal("5")]])]]), + "OUTER", + "Outer", + resolvingEmitter, + ), + ).toContain("v0.INNER_ = strucpp::iec_struct_init"); + }); +}); diff --git a/tests/frontend/structure-initialization.test.ts b/tests/frontend/structure-initialization.test.ts new file mode 100644 index 00000000..184b8b5e --- /dev/null +++ b/tests/frontend/structure-initialization.test.ts @@ -0,0 +1,348 @@ +/** + * Parser + AST-builder tests for IEC 61131-3 `structure_initialization` + * (Annex B.1.4.3) and the type-level default forms of Annex B.1.3.3. + * + * p : Point := (x := 1.0, y := 2.0); -- structure initializer + * o : Outer := (i := (a := 5), b := 7); -- nested + * pts : ARRAY[0..1] OF Point := [(x := 1.0)]; -- inside an array literal + * t : TON := (PT := T#1s); -- FB instance initialisation + * TYPE Origin : Point := (x := 0.0); END_TYPE -- type carries the default + * + * Before this was implemented the parser reached the parenthesised-expression + * alternative, parsed the element name as a variable and then demanded `)`, + * failing with `Expected RParen, found :=`. + */ + +import { describe, it, expect } from "vitest"; +import { parse } from "../../src/frontend/parser.js"; +import { buildAST } from "../../src/frontend/ast-builder.js"; +import { uppercaseSource } from "../../src/frontend/lexer.js"; +import type { + ArrayLiteralExpression, + CompilationUnit, + Expression, + StructInitializerExpression, + VarDeclaration, +} from "../../src/frontend/ast.js"; + +const POINT_TYPE = ` + TYPE + Point : STRUCT + x : REAL; + y : REAL; + END_STRUCT; + END_TYPE +`; + +function parseOk(source: string): CompilationUnit { + const { cst, errors } = parse(uppercaseSource(source)); + expect(errors.map((e) => e.message)).toEqual([]); + const ast = buildAST(cst!); + expect(ast).toBeDefined(); + return ast; +} + +/** First declaration of the first VAR block of the first program. */ +function firstProgramVar(ast: CompilationUnit): VarDeclaration { + const decl = ast.programs[0]?.varBlocks[0]?.declarations[0]; + expect(decl).toBeDefined(); + return decl!; +} + +function asStructInit( + expr: Expression | undefined, +): StructInitializerExpression { + expect(expr?.kind).toBe("StructInitializerExpression"); + return expr as StructInitializerExpression; +} + +/** Element names and, for scalar values, their raw literal text. */ +function elementPairs( + init: StructInitializerExpression, +): Array<[string, string]> { + return init.elements.map((element) => [ + element.name, + element.value.kind === "LiteralExpression" + ? element.value.rawValue + : element.value.kind, + ]); +} + +describe("structure_initialization — parsing", () => { + it("parses a structure initializer in a VAR_GLOBAL declaration", () => { + const ast = parseOk(` + ${POINT_TYPE} + VAR_GLOBAL + origin : Point := (x := 1.0, y := 2.0); + END_VAR + `); + const decl = ast.globalVarBlocks[0]!.declarations[0]!; + expect(elementPairs(asStructInit(decl.initialValue))).toEqual([ + ["X", "1.0"], + ["Y", "2.0"], + ]); + }); + + it("parses a structure initializer with no space before the paren", () => { + // The form reported on the forum: `:=(a:=1.0,b:=2.0)`. + const ast = parseOk(` + ${POINT_TYPE} + PROGRAM Main + VAR p : Point :=(x:=1.0,y:=2.0); END_VAR + END_PROGRAM + `); + expect( + elementPairs(asStructInit(firstProgramVar(ast).initialValue)), + ).toEqual([ + ["X", "1.0"], + ["Y", "2.0"], + ]); + }); + + it("preserves element order as written, including partial initialisers", () => { + const ast = parseOk(` + ${POINT_TYPE} + PROGRAM Main + VAR p : Point := (y := 2.0); END_VAR + END_PROGRAM + `); + expect( + elementPairs(asStructInit(firstProgramVar(ast).initialValue)), + ).toEqual([["Y", "2.0"]]); + }); + + it("parses nested structure initializers", () => { + const ast = parseOk(` + TYPE + Inner : STRUCT a : INT; END_STRUCT; + Outer : STRUCT + i : Inner; + b : INT; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer := (i := (a := 5), b := 7); END_VAR + END_PROGRAM + `); + const outer = asStructInit(firstProgramVar(ast).initialValue); + expect(outer.elements.map((e) => e.name)).toEqual(["I", "B"]); + expect(elementPairs(asStructInit(outer.elements[0]!.value))).toEqual([ + ["A", "5"], + ]); + }); + + it("parses structure initializers inside an array literal", () => { + const ast = parseOk(` + ${POINT_TYPE} + PROGRAM Main + VAR + pts : ARRAY[0..1] OF Point := [(x := 1.0, y := 2.0), (x := 3.0, y := 4.0)]; + END_VAR + END_PROGRAM + `); + const init = firstProgramVar(ast).initialValue; + expect(init?.kind).toBe("ArrayLiteralExpression"); + const elements = (init as ArrayLiteralExpression).elements; + expect(elements).toHaveLength(2); + expect(elementPairs(asStructInit(elements[0]))).toEqual([ + ["X", "1.0"], + ["Y", "2.0"], + ]); + expect(elementPairs(asStructInit(elements[1]))).toEqual([ + ["X", "3.0"], + ["Y", "4.0"], + ]); + }); + + it("parses an array element value inside a structure initializer", () => { + const ast = parseOk(` + TYPE + Buf : STRUCT + data : ARRAY[0..2] OF INT; + n : INT; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR b : Buf := (data := [1, 2, 3], n := 3); END_VAR + END_PROGRAM + `); + const init = asStructInit(firstProgramVar(ast).initialValue); + expect(init.elements[0]!.name).toBe("DATA"); + expect(init.elements[0]!.value.kind).toBe("ArrayLiteralExpression"); + }); + + it("parses a function block instance initialiser", () => { + const ast = parseOk(` + PROGRAM Main + VAR t : TON := (PT := T#1s); END_VAR + END_PROGRAM + `); + expect( + asStructInit(firstProgramVar(ast).initialValue).elements[0]!.name, + ).toBe("PT"); + }); + + it("parses a structure initializer as a STRUCT element default", () => { + const ast = parseOk(` + TYPE + Inner : STRUCT a : INT; END_STRUCT; + Outer : STRUCT + i : Inner := (a := 5); + END_STRUCT; + END_TYPE + `); + const outer = ast.types.find((t) => t.name === "OUTER")!; + expect(outer.definition.kind).toBe("StructDefinition"); + const field = + outer.definition.kind === "StructDefinition" + ? outer.definition.fields[0]! + : undefined; + expect(elementPairs(asStructInit(field?.initialValue))).toEqual([ + ["A", "5"], + ]); + }); + + it("still parses a parenthesised expression, which also starts with `(`", () => { + // The structure-initializer alternative is gated on `( NAME :=`, so an + // ordinary parenthesised expression must be unaffected. + const ast = parseOk(` + PROGRAM Main + VAR a : INT := 1; b : INT; END_VAR + b := (a + 2) * 3; + END_PROGRAM + `); + expect(ast.programs[0]!.body).toHaveLength(1); + }); + + it("still parses named arguments in a function block invocation", () => { + const ast = parseOk(` + PROGRAM Main + VAR t : TON; END_VAR + t(IN := TRUE, PT := T#1s); + END_PROGRAM + `); + expect(ast.programs[0]!.body).toHaveLength(1); + }); +}); + +describe("type-level default values (IEC 61131-3 B.1.3.3)", () => { + it("attaches a simple type's default to the TYPE declaration", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + `); + const decl = ast.types[0]!; + expect(decl.defaultValue?.kind).toBe("LiteralExpression"); + }); + + it("applies a simple type default to declarations that have no initialiser", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + PROGRAM Main + VAR s : Setpoint; END_VAR + END_PROGRAM + `); + const init = firstProgramVar(ast).initialValue; + expect(init?.kind).toBe("LiteralExpression"); + expect(init && "rawValue" in init ? init.rawValue : undefined).toBe("25.0"); + }); + + it("does not override a declaration's own initialiser", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + PROGRAM Main + VAR s : Setpoint := 30.0; END_VAR + END_PROGRAM + `); + const init = firstProgramVar(ast).initialValue; + expect(init && "rawValue" in init ? init.rawValue : undefined).toBe("30.0"); + }); + + it("applies a structure default from an initialised structure type", () => { + const ast = parseOk(` + ${POINT_TYPE} + TYPE + Origin : Point := (x := 0.0, y := 0.0); + END_TYPE + PROGRAM Main + VAR p : Origin; END_VAR + END_PROGRAM + `); + expect( + elementPairs(asStructInit(firstProgramVar(ast).initialValue)), + ).toEqual([ + ["X", "0.0"], + ["Y", "0.0"], + ]); + }); + + it("follows an alias chain to find the default", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + RoomSetpoint : Setpoint; + END_TYPE + PROGRAM Main + VAR s : RoomSetpoint; END_VAR + END_PROGRAM + `); + const init = firstProgramVar(ast).initialValue; + expect(init && "rawValue" in init ? init.rawValue : undefined).toBe("25.0"); + }); + + it("terminates on a cyclic alias chain instead of hanging", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + A : B; + B : A; + END_TYPE + PROGRAM Main + VAR x : A; END_VAR + END_PROGRAM + `); + expect(firstProgramVar(ast).initialValue).toBeUndefined(); + }); + + it("applies a simple enum's default value", () => { + const ast = parseOk(` + TYPE + Light : (RED, GREEN) := GREEN; + END_TYPE + PROGRAM Main + VAR l : Light; END_VAR + END_PROGRAM + `); + const init = firstProgramVar(ast).initialValue; + expect(init?.kind).toBe("VariableExpression"); + expect(init && "name" in init ? init.name : undefined).toBe("GREEN"); + }); + + it("leaves VAR_EXTERNAL alone — it names storage owned elsewhere", () => { + const ast = parseOk(` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + VAR_GLOBAL + gs : Setpoint; + END_VAR + PROGRAM Main + VAR_EXTERNAL gs : Setpoint; END_VAR + gs := 1.0; + END_PROGRAM + `); + const external = ast.programs[0]!.varBlocks.find( + (b) => b.blockType === "VAR_EXTERNAL", + ); + expect(external!.declarations[0]!.initialValue).toBeUndefined(); + // The global itself still gets the default. + expect(ast.globalVarBlocks[0]!.declarations[0]!.initialValue?.kind).toBe( + "LiteralExpression", + ); + }); +}); diff --git a/tests/integration/structure-initialization-cpp.test.ts b/tests/integration/structure-initialization-cpp.test.ts new file mode 100644 index 00000000..a15035d7 --- /dev/null +++ b/tests/integration/structure-initialization-cpp.test.ts @@ -0,0 +1,434 @@ +/** + * End-to-end tests for IEC 61131-3 `structure_initialization` and composite + * declaration initialisers: the generated C++ must compile with g++ -std=c++17 + * AND hold the values the ST source asked for. + * + * Compiling is not enough on its own here. The lowering has to get three things + * right that a syntax check cannot see: elements written out of declaration + * order must land on the right members, omitted elements must keep the default + * from their own declaration, and array/nested cases must not silently + * value-initialise. Each test therefore runs the binary and prints the values. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { compile } from "../../src/index.js"; +import { loadStlibFromFile } from "../../src/node/library-loader.js"; +import { + hasGpp, + createPCH, + compileWithGpp as compileWithGppHelper, + compileAndRunStandalone, +} from "./test-helpers.js"; + +/** The IEC standard FB library, for the `t : TON := (PT := T#1s)` case. */ +const IEC_STDLIB_PATH = path.resolve( + __dirname, + "../../libs/iec-standard-fb.stlib", +); +const iecStdlib = fs.existsSync(IEC_STDLIB_PATH) + ? loadStlibFromFile(IEC_STDLIB_PATH) + : undefined; + +const describeIfGpp = hasGpp ? describe : describe.skip; + +describeIfGpp("structure initializers — generated C++", () => { + let tempDir: string; + let pchPath: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-structinit-")); + pchPath = createPCH(tempDir); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + /** Compile ST, then compile the generated C++ and run it, returning stdout. */ + function run(source: string, mainBody: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return compileAndRunStandalone({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName, + mainCode: `#include \n\nint main() {\n using namespace strucpp;\n${mainBody}\n return 0;\n}\n`, + }); + } + + /** Compile ST and syntax-check the generated C++. */ + function compileOnly( + source: string, + testName: string, + ): { success: boolean; error?: string } { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return compileWithGppHelper({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName, + }); + } + + const SCALE_TYPE = ` + TYPE + Scale : STRUCT + lo : REAL := 4.0; + hi : REAL := 20.0; + END_STRUCT; + END_TYPE + `; + + it("initialises a file-level struct global with both elements", () => { + // The case from the forum report. + const output = run( + ` + ${SCALE_TYPE} + VAR_GLOBAL + s : Scale := (lo := 4.0, hi := 22.0); + END_VAR + PROGRAM Main + VAR d : REAL; END_VAR + d := s.hi - s.lo; + END_PROGRAM + `, + ` std::cout << S.LO.get() << " " << S.HI.get() << std::endl;`, + "structinit_global", + ); + expect(output).toBe("4 22"); + }); + + it("applies elements written out of declaration order to the right members", () => { + const output = run( + ` + ${SCALE_TYPE} + VAR_GLOBAL + s : Scale := (hi := 22.0, lo := 5.0); + END_VAR + `, + ` std::cout << S.LO.get() << " " << S.HI.get() << std::endl;`, + "structinit_order", + ); + expect(output).toBe("5 22"); + }); + + it("leaves an omitted element at its own declared default", () => { + const output = run( + ` + ${SCALE_TYPE} + VAR_GLOBAL + s : Scale := (hi := 22.0); + END_VAR + `, + ` std::cout << S.LO.get() << " " << S.HI.get() << std::endl;`, + "structinit_partial", + ); + // lo keeps 4.0 from the STRUCT declaration, not 0. + expect(output).toBe("4 22"); + }); + + it("initialises a PROGRAM struct variable", () => { + const output = run( + ` + ${SCALE_TYPE} + PROGRAM Main + VAR s : Scale := (lo := 1.5, hi := 9.5); END_VAR + s.lo := s.lo; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.S.LO.get() << " " << p.S.HI.get() << std::endl;`, + "structinit_program", + ); + expect(output).toBe("1.5 9.5"); + }); + + it("initialises nested structs", () => { + const output = run( + ` + TYPE + Inner : STRUCT a : INT := 1; b : INT := 2; END_STRUCT; + Outer : STRUCT + i : Inner; + c : INT := 3; + END_STRUCT; + END_TYPE + VAR_GLOBAL + o : Outer := (i := (b := 20), c := 30); + END_VAR + `, + ` std::cout << O.I.A.get() << " " << O.I.B.get() << " " << O.C.get() << std::endl;`, + "structinit_nested", + ); + // i.a keeps its own default 1; i.b and c are overwritten. + expect(output).toBe("1 20 30"); + }); + + it("initialises an array of structs", () => { + const output = run( + ` + TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + END_TYPE + PROGRAM Main + VAR + pts : ARRAY[0..1] OF Point := [(x := 1.0, y := 2.0), (x := 3.0, y := 4.0)]; + END_VAR + pts[0].x := pts[0].x; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.PTS[0].X.get() << " " << p.PTS[0].Y.get() << " " + << p.PTS[1].X.get() << " " << p.PTS[1].Y.get() << std::endl;`, + "structinit_array_of_struct", + ); + expect(output).toBe("1 2 3 4"); + }); + + it("initialises an array element inside a structure initializer", () => { + const output = run( + ` + TYPE + Buf : STRUCT + data : ARRAY[0..2] OF INT; + n : INT; + END_STRUCT; + END_TYPE + VAR_GLOBAL + b : Buf := (data := [7, 8, 9], n := 3); + END_VAR + `, + ` std::cout << B.DATA[0].get() << " " << B.DATA[2].get() << " " << B.N.get() << std::endl;`, + "structinit_array_member", + ); + expect(output).toBe("7 9 3"); + }); + + it("initialises a function block instance's inputs", () => { + const output = run( + ` + FUNCTION_BLOCK Ramp + VAR_INPUT + step : REAL := 1.0; + limit : REAL := 100.0; + END_VAR + VAR_OUTPUT value : REAL; END_VAR + value := value + step; + END_FUNCTION_BLOCK + PROGRAM Main + VAR r : Ramp := (step := 2.5); END_VAR + r(); + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.R.STEP.get() << " " << p.R.LIMIT.get() << std::endl;`, + "structinit_fb_instance", + ); + // step is set by the initializer; limit keeps its VAR_INPUT default. + expect(output).toBe("2.5 100"); + }); + + it("initialises a struct element whose own default is a structure initializer", () => { + const output = run( + ` + TYPE + Inner : STRUCT a : INT; END_STRUCT; + Outer : STRUCT + i : Inner := (a := 5); + b : INT := 7; + END_STRUCT; + END_TYPE + VAR_GLOBAL + o : Outer; + END_VAR + `, + ` std::cout << O.I.A.get() << " " << O.B.get() << std::endl;`, + "structinit_field_default", + ); + expect(output).toBe("5 7"); + }); + + it("applies an initialised structure TYPE's default to a declaration", () => { + const output = run( + ` + TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + Origin : Point := (x := 1.5, y := 2.5); + END_TYPE + VAR_GLOBAL + p : Origin; + END_VAR + `, + ` std::cout << P.X.get() << " " << P.Y.get() << std::endl;`, + "structinit_type_default", + ); + expect(output).toBe("1.5 2.5"); + }); + + it("applies an initialised simple TYPE's default to a declaration", () => { + const output = run( + ` + TYPE + Setpoint : REAL := 25.0; + END_TYPE + VAR_GLOBAL + s : Setpoint; + END_VAR + `, + // An alias of an elementary type is the raw C++ type, not an IECVar. + ` std::cout << S << std::endl;`, + "structinit_simple_type_default", + ); + expect(output).toBe("25"); + }); + + it.skipIf(!iecStdlib)( + "initialises a standard-library FB instance (TON) from the library archive", + () => { + // The forum-adjacent CODESYS form. TON comes from a compiled .stlib, so its + // element types are resolved from library metadata, not the local AST. + const result = compile( + ` + PROGRAM Main + VAR t : TON := (PT := T#1s); END_VAR + t(IN := TRUE); + END_PROGRAM + `, + { + headerFileName: "generated.hpp", + libraries: iecStdlib ? [iecStdlib] : [], + }, + ); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + expect(result.cppCode).toContain( + "T(strucpp::iec_struct_init([](auto& v0) { v0.PT = 1000000000LL; }))", + ); + const output = compileAndRunStandalone({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName: "structinit_ton", + mainCode: `#include \n\nint main() {\n using namespace strucpp;\n Program_MAIN p;\n std::cout << p.T.PT.get() << std::endl;\n return 0;\n}\n`, + }); + expect(output).toBe("1000000000"); + }, + ); + + it("initialises a CONFIGURATION struct global", () => { + const result = compileOnly( + ` + ${SCALE_TYPE} + PROGRAM Main + VAR_EXTERNAL s : Scale; END_VAR + s.lo := s.hi; + END_PROGRAM + CONFIGURATION Cfg + VAR_GLOBAL + s : Scale := (lo := 4.0, hi := 22.0); + END_VAR + RESOURCE Res ON PLC + TASK T(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM P WITH T : Main; + END_RESOURCE + END_CONFIGURATION + `, + "structinit_config_global", + ); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + }); +}); + +describeIfGpp( + "composite initialisers on PROGRAM variables — generated C++", + () => { + let tempDir: string; + let pchPath: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-arrayinit-")); + pchPath = createPCH(tempDir); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function run(source: string, mainBody: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return compileAndRunStandalone({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName, + mainCode: `#include \n\nint main() {\n using namespace strucpp;\n${mainBody}\n return 0;\n}\n`, + }); + } + + it("carries a bracketed array literal into a PROGRAM variable", () => { + // These initialisers used to be dropped silently — the program compiled and + // ran with a zero-filled array. + const output = run( + ` + PROGRAM Main + VAR arr : ARRAY[0..3] OF INT := [10, 20, 30, 40]; END_VAR + arr[0] := arr[0]; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.ARR[0].get() << " " << p.ARR[3].get() << std::endl;`, + "arrayinit_bracket", + ); + expect(output).toBe("10 40"); + }); + + it("carries the legacy comma-separated array initialiser", () => { + const output = run( + ` + PROGRAM Main + VAR days : ARRAY[0..3] OF INT := 0, 31, 59, 90; END_VAR + days[0] := days[0]; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.DAYS[1].get() << " " << p.DAYS[3].get() << std::endl;`, + "arrayinit_comma", + ); + expect(output).toBe("31 90"); + }); + + it("carries a 2D array initialiser", () => { + const output = run( + ` + PROGRAM Main + VAR m : ARRAY[0..1, 0..1] OF INT := [1, 2, 3, 4]; END_VAR + m[0, 0] := m[0, 0]; + END_PROGRAM + `, + // Array2D indexes with operator(), and the flat brace list fills row-major. + ` Program_MAIN p; + std::cout << p.M(0, 0).get() << " " << p.M(1, 1).get() << std::endl;`, + "arrayinit_2d", + ); + expect(output).toBe("1 4"); + }); + }, +); diff --git a/tests/integration/test-helpers.ts b/tests/integration/test-helpers.ts index 88fe367f..16e803e2 100644 --- a/tests/integration/test-helpers.ts +++ b/tests/integration/test-helpers.ts @@ -29,6 +29,7 @@ export const PCH_INCLUDES = `#pragma once #include "iec_located.hpp" #include "iec_std_lib.hpp" #include "iec_enum.hpp" +#include "iec_struct.hpp" #include "iec_memory.hpp" #include "iec_string.hpp" #include "iec_wstring.hpp" diff --git a/tests/semantic/var-external-file-scope-globals.test.ts b/tests/semantic/var-external-file-scope-globals.test.ts new file mode 100644 index 00000000..276b9018 --- /dev/null +++ b/tests/semantic/var-external-file-scope-globals.test.ts @@ -0,0 +1,178 @@ +/** + * VAR_EXTERNAL resolution against **file-level** VAR_GLOBAL blocks. + * + * STruC++ emits the two kinds of global differently: + * + * - a CONFIGURATION VAR_GLOBAL becomes `inline GlobalVar` (value + mutex), + * reached by each POU through a `GlobalVar*` member; + * - a file-level VAR_GLOBAL (a GVL) becomes plain file-scope storage that every + * POU in the unit already reaches by name. + * + * VAR_EXTERNAL resolution used to consider only the first kind, so declaring a + * file-level global via VAR_EXTERNAL — which IEC 61131-3 not only allows but + * expects — failed with "no matching VAR_GLOBAL declaration". For the second kind + * the declaration is documentation: it must validate, and it must NOT add a + * pointer member, which would shadow the very global being referenced. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +function compileST(source: string) { + return compile(source); +} + +function errorMessages(result: { errors: { message: string }[] }): string[] { + return result.errors.map((e) => e.message); +} + +describe("VAR_EXTERNAL against a file-level VAR_GLOBAL", () => { + it("resolves in a PROGRAM", () => { + const result = compileST(` + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + PROGRAM Main + VAR_EXTERNAL gx : REAL; END_VAR + gx := gx * 2.0; + END_PROGRAM + `); + expect(errorMessages(result)).toEqual([]); + expect(result.success).toBe(true); + // Plain file-scope storage, and the body writes it directly. + expect(result.headerCode).toContain("inline IEC_REAL GX = 1.0;"); + expect(result.cppCode).toContain("GX = GX * 2.0;"); + }); + + it("adds no pointer member or constructor parameter for it", () => { + const result = compileST(` + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + PROGRAM Main + VAR_EXTERNAL gx : REAL; END_VAR + gx := 2.0; + END_PROGRAM + `); + expect(result.success).toBe(true); + // A GlobalVar* member would shadow the file-scope global. + expect(result.headerCode).not.toContain("GlobalVar* GX"); + expect(result.headerCode).not.toContain("GX_ref"); + // No pointer to bind → the default constructor stays parameterless. + expect(result.headerCode).toContain("Program_MAIN();"); + }); + + it("resolves in a FUNCTION_BLOCK", () => { + const result = compileST(` + VAR_GLOBAL + counter : INT := 0; + END_VAR + FUNCTION_BLOCK Ticker + VAR_EXTERNAL counter : INT; END_VAR + counter := counter + 1; + END_FUNCTION_BLOCK + `); + expect(errorMessages(result)).toEqual([]); + expect(result.success).toBe(true); + expect(result.headerCode).not.toContain("GlobalVar* COUNTER"); + expect(result.cppCode).toContain("COUNTER = COUNTER + 1;"); + }); + + it("resolves a struct-typed global and reads its fields", () => { + const result = compileST(` + TYPE + Scale : STRUCT + lo : REAL; + hi : REAL; + END_STRUCT; + END_TYPE + VAR_GLOBAL + s : Scale := (lo := 4.0, hi := 22.0); + out : REAL; + END_VAR + PROGRAM Main + VAR_EXTERNAL + s : Scale; + out : REAL; + END_VAR + out := s.hi - s.lo; + END_PROGRAM + `); + expect(errorMessages(result)).toEqual([]); + expect(result.success).toBe(true); + expect(result.cppCode).toContain("OUT = S.HI - S.LO;"); + }); + + it("reports a type mismatch against the file-level global", () => { + const result = compileST(` + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + PROGRAM Main + VAR_EXTERNAL gx : INT; END_VAR + gx := 2; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(errorMessages(result)).toContain( + "Type mismatch for VAR_EXTERNAL 'GX' in program 'MAIN': expected 'REAL' but found 'INT'", + ); + }); + + it("reports a type mismatch in a FUNCTION_BLOCK too", () => { + const result = compileST(` + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + FUNCTION_BLOCK FB + VAR_EXTERNAL gx : INT; END_VAR + gx := 2; + END_FUNCTION_BLOCK + `); + expect(result.success).toBe(false); + expect(errorMessages(result)).toContain( + "Type mismatch for VAR_EXTERNAL 'GX' in function block 'FB': expected 'REAL' but found 'INT'", + ); + }); + + it("still reports a VAR_EXTERNAL that matches no global at all", () => { + const result = compileST(` + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + PROGRAM Main + VAR_EXTERNAL missing : REAL; END_VAR + missing := 2.0; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(errorMessages(result)).toContain( + "VAR_EXTERNAL 'MISSING' in program 'MAIN' has no matching VAR_GLOBAL declaration", + ); + }); + + it("keeps the GlobalVar plumbing for CONFIGURATION globals", () => { + // The configuration path is unchanged: a pointer member, a constructor + // parameter, and locked access in the body. + const result = compileST(` + PROGRAM Main + VAR_EXTERNAL gx : REAL; END_VAR + gx := 2.0; + END_PROGRAM + CONFIGURATION Cfg + VAR_GLOBAL + gx : REAL := 1.0; + END_VAR + RESOURCE Res ON PLC + TASK T(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM P WITH T : Main; + END_RESOURCE + END_CONFIGURATION + `); + expect(errorMessages(result)).toEqual([]); + expect(result.success).toBe(true); + expect(result.headerCode).toContain("inline GlobalVar GX{1.0};"); + expect(result.headerCode).toContain("GlobalVar* GX = nullptr;"); + expect(result.cppCode).toContain("GX->write(2.0);"); + }); +}); From bc50ec962971afad87411b671fbbf9d3f4b389e6 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 11:25:42 -0400 Subject: [PATCH 02/13] feat(frontend): array repetition initializer `[N(value)]` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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) --- docs/IEC_COMPLIANCE.md | 12 +- src/frontend/ast-builder.ts | 124 ++++++-- src/frontend/parser-error-message-provider.ts | 3 + src/frontend/parser.ts | 66 +++- .../array-repetition-initializer.test.ts | 283 ++++++++++++++++++ .../structure-initialization-cpp.test.ts | 74 +++++ 6 files changed, 520 insertions(+), 42 deletions(-) create mode 100644 tests/frontend/array-repetition-initializer.test.ts diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index 394862f5..f8b9cb08 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -42,7 +42,6 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Type | Notes | |------|-------| | UNION | CODESYS extension | -| Array repetition initializer | `:= [10(0)]` — write the elements out instead | ## Program Organization Units @@ -69,9 +68,18 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | AT %IX0.0 | Supported | Located variables (I/Q/M areas, X/B/W/D/L sizes) | | Multiple names | Supported | `a, b, c : INT := 0;` | | Initialization | Supported | `:= expression` | -| Array initialization | Supported | `:= [1, 2, 3]` and the legacy `:= 1, 2, 3` | +| Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take a flat row-major list | +| Array repetition | Supported | `:= [10(0)]`, `:= [3(1), 2(5)]`, `:= [7, 4(2), 9]`. The repeated value may be a structure initializer. Max count 65536 | | Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | +### Initialization gaps + +| Form | Notes | +|------|-------| +| Nested array initializer | `:= [[1, 2], [3, 4]]` for a multi-dimensional array. Use the flat row-major form `:= [1, 2, 3, 4]` | +| 3D array initializer | `ARRAY[0..1, 0..1, 0..1] OF INT := [1, 2, ...]` — the runtime `Array3D` has no initializer-list constructor, so any 3D initializer fails to build | +| Repetition with no value | `:= [10()]` (ten copies of the element default) — write `:= [10(0)]`, or omit the elements entirely. Matches matiec and CODESYS, which also require a value | + ## Operators and Expressions | Category | Operators | Status | diff --git a/src/frontend/ast-builder.ts b/src/frontend/ast-builder.ts index 9c568419..9092d6f6 100644 --- a/src/frontend/ast-builder.ts +++ b/src/frontend/ast-builder.ts @@ -263,6 +263,25 @@ function getAllIdentifierOrKeywordImages( return nodes.map(getIdentifierOrKeywordImage); } +/** + * Upper bound on an array repetition count (`[N(value)]`). + * + * Repetition groups are expanded element by element, so the count directly sizes + * the AST and the generated initialiser list. The limit is far above any + * practical PLC array and exists only so a typo cannot exhaust memory. + */ +const MAX_ARRAY_REPETITION = 65536; + +/** + * True when an `arrayInitialElements` node is a repetition group `count(value)` + * rather than a single value. + */ +function isRepetitionGroup(entry: CstNode): boolean { + return ( + getFirstToken((entry.children as CstChildren).IntegerLiteral) !== undefined + ); +} + /** * Parse an IEC 61131-3 integer literal that may use based notation (16#FF, 8#77, 2#1010). */ @@ -330,10 +349,18 @@ export class ASTBuilder { const initExprNode = getFirstNode(declChildren.initializerExpression); if (!initExprNode || names.length === 0) continue; const initChildren = initExprNode.children as CstChildren; - const exprNodes = getAllNodes(initChildren.expression); - if (exprNodes.length !== 1) continue; + // Only a lone scalar initialiser can be a dimension constant; a list or + // a repetition group is an array initialiser. + const entryNodes = getAllNodes(initChildren.arrayInitialElements); + if (entryNodes.length !== 1 || isRepetitionGroup(entryNodes[0]!)) { + continue; + } + const valueNode = getFirstNode( + (entryNodes[0]!.children as CstChildren).expression, + ); + if (!valueNode) continue; try { - const expr = this.buildExpression(exprNodes[0]!); + const expr = this.buildExpression(valueNode); if (!expr) continue; let val: number | undefined; if (expr.kind === "LiteralExpression") { @@ -1344,10 +1371,11 @@ export class ASTBuilder { /** * Build the expression behind an `initializerExpression` CST node. * - * A single expression is used as-is; the legacy comma-separated form + * A single value is used as-is; the bracket-less comma-separated form * (`arr : ARRAY[0..3] OF INT := 0, 31, 59, 90;`) collapses into an - * ArrayLiteralExpression. Structure initializers need no special handling - * here — they are ordinary primary expressions. + * ArrayLiteralExpression, as does a lone repetition group (`:= 4(0)`). + * Structure initializers need no special handling here — they are ordinary + * primary expressions. * * Returns undefined when there is no initialiser. */ @@ -1356,23 +1384,67 @@ export class ASTBuilder { ): Expression | undefined { if (!initExprNode) return undefined; const initChildren = initExprNode.children as CstChildren; - const exprNodes = getAllNodes(initChildren.expression); - if (exprNodes.length > 1) { - const elements: Expression[] = []; - for (const en of exprNodes) { - const e = this.buildExpression(en); - if (e) elements.push(e); - } - return { - kind: "ArrayLiteralExpression", - sourceSpan: nodeToSourceSpan(initExprNode), - elements, - }; + const entryNodes = getAllNodes(initChildren.arrayInitialElements); + const elements = this.buildArrayInitialElements(entryNodes); + // A single plain value is the variable's initialiser, not a one-element + // array. A repetition group always means an array, even on its own. + if ( + elements.length === 1 && + entryNodes.length === 1 && + !isRepetitionGroup(entryNodes[0]!) + ) { + return elements[0]; } - if (exprNodes.length === 1) { - return this.buildExpression(exprNodes[0]!); + if (elements.length === 0) return undefined; + return { + kind: "ArrayLiteralExpression", + sourceSpan: nodeToSourceSpan(initExprNode), + elements, + }; + } + + /** + * Expand a list of `arrayInitialElements` CST nodes into the element values + * they stand for. + * + * A repetition group `count(value)` (IEC 61131-3 Annex B.1.4.3) contributes + * `count` copies of the value. Expanding here keeps every consumer — + * semantic analysis, the project model, codegen — working on a plain list of + * element expressions, so the repetition form needs no support of its own + * downstream. + */ + private buildArrayInitialElements(entryNodes: CstNode[]): Expression[] { + const elements: Expression[] = []; + for (const entry of entryNodes) { + const entryChildren = entry.children as CstChildren; + const valueNode = getFirstNode(entryChildren.expression); + if (!valueNode) continue; + const value = this.buildExpression(valueNode); + if (!value) continue; + + const countToken = getFirstToken(entryChildren.IntegerLiteral); + if (!countToken) { + elements.push(value); + continue; + } + const count = parseIECInteger(countToken.image); + if (!Number.isFinite(count) || count < 0) continue; + if (count > MAX_ARRAY_REPETITION) { + // Expansion is linear in the count, so a runaway count would exhaust + // memory. Fail loudly rather than silently dropping the tail — the + // compile driver turns this into a reported error. + throw new Error( + `Array repetition count ${count} exceeds the supported maximum of ${MAX_ARRAY_REPETITION}`, + ); + } + for (let i = 0; i < count; i++) { + // Each repeat gets its own node: consumers may annotate elements + // (resolvedType, and codegen's per-element lowering), and sharing one + // object across positions would make those annotations collide. + elements.push(i === 0 ? value : this.buildExpression(valueNode)!); + } } - return undefined; + return elements; } /** @@ -2344,16 +2416,12 @@ export class ASTBuilder { if (children.arrayLiteral) { const litNode = getFirstNode(children.arrayLiteral)!; const litChildren = litNode.children as CstChildren; - const exprNodes = getAllNodes(litChildren.expression); - const elements: Expression[] = []; - for (const en of exprNodes) { - const e = this.buildExpression(en); - if (e) elements.push(e); - } return { kind: "ArrayLiteralExpression", sourceSpan: nodeToSourceSpan(litNode), - elements, + elements: this.buildArrayInitialElements( + getAllNodes(litChildren.arrayInitialElements), + ), } as ArrayLiteralExpression; } diff --git a/src/frontend/parser-error-message-provider.ts b/src/frontend/parser-error-message-provider.ts index 9a957d6d..aa4733fa 100644 --- a/src/frontend/parser-error-message-provider.ts +++ b/src/frontend/parser-error-message-provider.ts @@ -93,6 +93,9 @@ const RULE_DESCRIPTIONS: Record = { varDeclaration: "parsing a variable declaration", initializerExpression: "parsing a variable initializer", arrayLiteral: "parsing an array literal", + arrayInitialElements: "parsing an array initializer element", + structInitializer: "parsing a structure initializer", + structElementInitializer: "parsing a structure initializer element", dataType: "parsing a data type", singleTypeDeclaration: "parsing a type declaration", structType: "parsing a STRUCT type", diff --git a/src/frontend/parser.ts b/src/frontend/parser.ts index 630fb7d9..37626d0e 100644 --- a/src/frontend/parser.ts +++ b/src/frontend/parser.ts @@ -459,31 +459,59 @@ export class STParser extends CstParser { }); /** - * Initializer expression: single expression or comma-separated list for array init. - * Handles: x := 5; and arr := 0, 31, 59, 90, ...; + * Initializer expression: a single value, or a comma-separated list for the + * bracket-less array-initialiser form OpenPLC emits. + * Handles: `x := 5;`, `arr := 0, 31, 59, 90;` and `arr := 4(0), 31;` */ public initializerExpression = this.RULE("initializerExpression", () => { - this.SUBRULE(this.expression); - this.MANY(() => { - this.CONSUME(tokens.Comma); - this.SUBRULE2(this.expression); + this.AT_LEAST_ONE_SEP({ + SEP: tokens.Comma, + DEF: () => this.SUBRULE(this.arrayInitialElements), }); }); /** - * Array literal: [expr, expr, ...] - * Bracket-enclosed comma-separated expressions for array initialization. + * Array literal: `[value, value, ...]` + * + * IEC 61131-3 Annex B.1.4.3 `array_initialization`. */ public arrayLiteral = this.RULE("arrayLiteral", () => { this.CONSUME(tokens.LBracket); - this.SUBRULE(this.expression); - this.MANY(() => { - this.CONSUME(tokens.Comma); - this.SUBRULE2(this.expression); + this.AT_LEAST_ONE_SEP({ + SEP: tokens.Comma, + DEF: () => this.SUBRULE(this.arrayInitialElements), }); this.CONSUME(tokens.RBracket); }); + /** + * One entry of an array initialiser: a single value, or a repetition group + * `count(value)` standing for `count` copies of that value. + * + * IEC 61131-3 Annex B.1.4.3 `array_initial_elements`: + * + * arr : ARRAY[0..9] OF INT := [10(0)]; + * arr : ARRAY[0..4] OF INT := [3(1), 2(5)]; + * pts : ARRAY[0..1] OF Point := [2((x := 1.0, y := 2.0))]; + * + * The repeated value is a full expression, so a repetition group may itself + * hold a structure initializer or a nested array literal. + */ + public arrayInitialElements = this.RULE("arrayInitialElements", () => { + this.OR([ + { + ALT: () => { + this.CONSUME(tokens.IntegerLiteral); + this.CONSUME(tokens.LParen); + this.SUBRULE(this.expression); + this.CONSUME(tokens.RParen); + }, + GATE: () => this.isArrayRepetitionAhead(), + }, + { ALT: () => this.SUBRULE2(this.expression) }, + ]); + }); + /** * Structure initializer: `(field := value, field := value)` * @@ -1032,6 +1060,20 @@ export class STParser extends CstParser { ); } + /** + * Lookahead helper: does an array repetition group `count(value)` start here? + * + * An integer immediately followed by `(` is never an expression — ST has no + * implicit multiplication and only an identifier can be called — so this never + * competes with a function call or a parenthesised sub-expression. + */ + private isArrayRepetitionAhead(): boolean { + return ( + this.LA(1).tokenType === tokens.IntegerLiteral && + this.LA(2).tokenType === tokens.LParen + ); + } + /** * Lookahead helper to detect if the current position starts a CASE label. * Scans forward looking for a bare Colon (:) before finding Assign (:=), diff --git a/tests/frontend/array-repetition-initializer.test.ts b/tests/frontend/array-repetition-initializer.test.ts new file mode 100644 index 00000000..c31abfc9 --- /dev/null +++ b/tests/frontend/array-repetition-initializer.test.ts @@ -0,0 +1,283 @@ +/** + * Parser + AST-builder tests for the array repetition initializer. + * + * IEC 61131-3 Annex B.1.4.3: + * + * array_initial_elements ::= array_initial_element + * | integer '(' [array_initial_element] ')' + * + * `[10(0)]` stands for ten copies of `0`. The AST builder expands repetition + * groups into plain element lists, so nothing downstream — semantic analysis, + * the project model, codegen — needs to know the form exists. + * + * The optional-element form `[10()]` (ten copies of the element default) is + * deliberately not accepted; see the compliance notes. + */ + +import { describe, it, expect } from "vitest"; +import { parse } from "../../src/frontend/parser.js"; +import { buildAST } from "../../src/frontend/ast-builder.js"; +import { uppercaseSource } from "../../src/frontend/lexer.js"; +import type { + ArrayLiteralExpression, + CompilationUnit, + Expression, + VarDeclaration, +} from "../../src/frontend/ast.js"; + +function parseOk(source: string): CompilationUnit { + const { cst, errors } = parse(uppercaseSource(source)); + expect(errors.map((e) => e.message)).toEqual([]); + return buildAST(cst!); +} + +function firstProgramVar(ast: CompilationUnit): VarDeclaration { + const decl = ast.programs[0]?.varBlocks[0]?.declarations[0]; + expect(decl).toBeDefined(); + return decl!; +} + +/** Raw literal text of every element of an array-literal initialiser. */ +function elementLiterals(init: Expression | undefined): string[] { + expect(init?.kind).toBe("ArrayLiteralExpression"); + return (init as ArrayLiteralExpression).elements.map((element) => + element.kind === "LiteralExpression" ? element.rawValue : element.kind, + ); +} + +/** Declaration `index` of the first VAR block, by position. */ +function programVar(ast: CompilationUnit, index: number): VarDeclaration { + const decl = ast.programs[0]?.varBlocks[0]?.declarations[index]; + expect(decl).toBeDefined(); + return decl!; +} + +describe("array repetition initializer — parsing and expansion", () => { + it("expands a whole-array repetition", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..9] OF INT := [10(0)]; END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual( + Array(10).fill("0"), + ); + }); + + it("expands several repetition groups in order", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..4] OF INT := [3(1), 2(5)]; END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual([ + "1", + "1", + "1", + "5", + "5", + ]); + }); + + it("mixes repetition groups with single values", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..5] OF INT := [7, 4(2), 9]; END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual([ + "7", + "2", + "2", + "2", + "2", + "9", + ]); + }); + + it("accepts repetition in the bracket-less initialiser form", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..3] OF INT := 2(3), 2(4); END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual([ + "3", + "3", + "4", + "4", + ]); + }); + + it("treats a lone repetition group as an array initialiser", () => { + // `:= 4(0)` has no brackets and no comma, but it is still a list. + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..3] OF INT := 4(0); END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual([ + "0", + "0", + "0", + "0", + ]); + }); + + it("repeats a structure initializer", () => { + const ast = parseOk(` + TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + END_TYPE + PROGRAM Main + VAR pts : ARRAY[0..1] OF Point := [2((x := 1.5, y := 2.5))]; END_VAR + END_PROGRAM + `); + const elements = ( + firstProgramVar(ast).initialValue as ArrayLiteralExpression + ).elements; + expect(elements).toHaveLength(2); + expect( + elements.every((e) => e.kind === "StructInitializerExpression"), + ).toBe(true); + // Each repeat is its own node, so per-element annotations cannot collide. + expect(elements[0]).not.toBe(elements[1]); + }); + + it("repeats a nested array literal", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..3] OF INT := [2([1, 2])]; END_VAR + END_PROGRAM + `); + const elements = ( + firstProgramVar(ast).initialValue as ArrayLiteralExpression + ).elements; + expect(elements.map((e) => e.kind)).toEqual([ + "ArrayLiteralExpression", + "ArrayLiteralExpression", + ]); + }); + + it("accepts a based-notation repetition count", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..3] OF INT := [16#4(7)]; END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual([ + "7", + "7", + "7", + "7", + ]); + }); + + it("expands a zero count to nothing", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..1] OF INT := [0(9), 4]; END_VAR + END_PROGRAM + `); + expect(elementLiterals(firstProgramVar(ast).initialValue)).toEqual(["4"]); + }); + + it("rejects a count beyond the expansion limit instead of truncating", () => { + const { cst, errors } = parse( + uppercaseSource(` + PROGRAM Main + VAR a : ARRAY[0..9] OF INT := [99999999(0)]; END_VAR + END_PROGRAM + `), + ); + expect(errors).toHaveLength(0); + expect(() => buildAST(cst!)).toThrow(/exceeds the supported maximum/); + }); + + it("does not accept the optional-element form", () => { + // `[10()]` (ten copies of the element default) has no positional lowering + // in C++17 and is supported by neither matiec nor CODESYS. + const { errors } = parse( + uppercaseSource(` + PROGRAM Main + VAR a : ARRAY[0..9] OF INT := [10()]; END_VAR + END_PROGRAM + `), + ); + expect(errors.length).toBeGreaterThan(0); + }); +}); + +describe("array repetition initializer — no effect on other syntax", () => { + it("still parses a function call as an array element", () => { + // `F(2)` starts with an identifier, not an integer, so it is a call. + const ast = parseOk(` + FUNCTION F : INT + VAR_INPUT x : INT; END_VAR + F := x; + END_FUNCTION + PROGRAM Main + VAR a : ARRAY[0..1] OF INT := [F(2), 3]; END_VAR + END_PROGRAM + `); + const elements = ( + firstProgramVar(ast).initialValue as ArrayLiteralExpression + ).elements; + expect(elements[0]!.kind).toBe("FunctionCallExpression"); + }); + + it("still parses a scalar initialiser as a single value", () => { + const ast = parseOk(` + PROGRAM Main + VAR x : INT := 5; END_VAR + END_PROGRAM + `); + expect(firstProgramVar(ast).initialValue?.kind).toBe("LiteralExpression"); + }); + + it("still parses an arithmetic initialiser containing parentheses", () => { + const ast = parseOk(` + PROGRAM Main + VAR + x : INT := 2 * (3 + 4); + END_VAR + END_PROGRAM + `); + expect(firstProgramVar(ast).initialValue?.kind).toBe("BinaryExpression"); + }); + + it("still resolves a CONSTANT used as an array dimension", () => { + // The constant scanner reads the same initializer rule; a scalar CONSTANT + // must still be picked up for `ARRAY[0..SIZE]`. + const ast = parseOk(` + PROGRAM Main + VAR CONSTANT SIZE : INT := 4; END_VAR + VAR a : ARRAY[0..SIZE] OF INT; END_VAR + END_PROGRAM + `); + const arrayDecl = ast.programs[0]!.varBlocks[1]!.declarations[0]!; + expect(arrayDecl.type.arrayDimensions).toEqual([{ start: 0, end: 4 }]); + }); + + it("keeps multiple declarations in one block independent", () => { + const ast = parseOk(` + PROGRAM Main + VAR + a : ARRAY[0..2] OF INT := [3(1)]; + b : INT := 9; + c : ARRAY[0..1] OF INT := [2(2)]; + END_VAR + END_PROGRAM + `); + expect(elementLiterals(programVar(ast, 0).initialValue)).toEqual([ + "1", + "1", + "1", + ]); + expect(programVar(ast, 1).initialValue?.kind).toBe("LiteralExpression"); + expect(elementLiterals(programVar(ast, 2).initialValue)).toEqual([ + "2", + "2", + ]); + }); +}); diff --git a/tests/integration/structure-initialization-cpp.test.ts b/tests/integration/structure-initialization-cpp.test.ts index a15035d7..dc9f5bf8 100644 --- a/tests/integration/structure-initialization-cpp.test.ts +++ b/tests/integration/structure-initialization-cpp.test.ts @@ -430,5 +430,79 @@ describeIfGpp( ); expect(output).toBe("1 4"); }); + + it("expands repetition groups to the right values in the right slots", () => { + const output = run( + ` + PROGRAM Main + VAR + a : ARRAY[0..4] OF INT := [3(1), 2(5)]; + b : ARRAY[0..5] OF INT := [7, 4(2), 9]; + c : ARRAY[0..3] OF INT := 2(3), 2(4); + END_VAR + a[0] := a[0]; + END_PROGRAM + `, + ` Program_MAIN p; + for (int i = 0; i < 5; ++i) std::cout << p.A[i].get(); + std::cout << " "; + for (int i = 0; i < 6; ++i) std::cout << p.B[i].get(); + std::cout << " "; + for (int i = 0; i < 4; ++i) std::cout << p.C[i].get(); + std::cout << std::endl;`, + "arrayinit_repetition", + ); + expect(output).toBe("11155 722229 3344"); + }); + + it("repeats a structure initializer across array elements", () => { + const output = run( + ` + TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + END_TYPE + PROGRAM Main + VAR pts : ARRAY[0..1] OF Point := [2((x := 1.5, y := 2.5))]; END_VAR + pts[0].x := pts[0].x; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.PTS[0].X.get() << " " << p.PTS[1].Y.get() << std::endl;`, + "arrayinit_repetition_struct", + ); + expect(output).toBe("1.5 2.5"); + }); + + it("repeats into a 2D array and a STRING array", () => { + const output = run( + ` + PROGRAM Main + VAR + m : ARRAY[0..1, 0..1] OF INT := [4(6)]; + s : ARRAY[0..3] OF STRING := [4('hi')]; + END_VAR + m[0, 0] := m[0, 0]; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.M(0, 0).get() << p.M(1, 1).get() << " " + << p.S[0].get().c_str() << p.S[3].get().c_str() << std::endl;`, + "arrayinit_repetition_2d_string", + ); + expect(output).toBe("66 hihi"); + }); + + it("expands a repetition in a file-level VAR_GLOBAL", () => { + const output = run( + ` + VAR_GLOBAL + g : ARRAY[0..3] OF INT := [4(8)]; + END_VAR + `, + ` std::cout << G[0].get() << G[3].get() << std::endl;`, + "arrayinit_repetition_global", + ); + expect(output).toBe("88"); + }); }, ); From c9257f6c8a55d39791d46df887c0ffc460fb1ba1 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 12:20:42 -0400 Subject: [PATCH 03/13] fix(codegen): keep array-literal defaults on STRUCT elements, and escape their strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- docs/IEC_COMPLIANCE.md | 1 + src/backend/codegen-utils.ts | 74 +++++++++++++++ src/backend/codegen.ts | 78 ++-------------- src/backend/type-codegen.ts | 31 ++++--- .../codegen-structure-initialization.test.ts | 89 +++++++++++++++++++ 5 files changed, 190 insertions(+), 83 deletions(-) diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index f8b9cb08..e5e3e218 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -71,6 +71,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take a flat row-major list | | Array repetition | Supported | `:= [10(0)]`, `:= [3(1), 2(5)]`, `:= [7, 4(2), 9]`. The repeated value may be a structure initializer. Max count 65536 | | Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | +| STRUCT element defaults | Supported | Scalar, array-literal and structure-initializer defaults on a STRUCT element all carry their values | ### Initialization gaps diff --git a/src/backend/codegen-utils.ts b/src/backend/codegen-utils.ts index ffc4a552..399779cd 100644 --- a/src/backend/codegen-utils.ts +++ b/src/backend/codegen-utils.ts @@ -52,3 +52,77 @@ export function formatArrayType( } return result; } + +/** + * Translate IEC 61131-3 `$`-escape sequences in a string literal's body to C++ + * escape sequences, and escape what C++ needs escaped. + * + * Handles `$N`/`$n` (newline), `$L`/`$l` (line feed), `$R`/`$r` (CR), `$T`/`$t` + * (tab), `$P`/`$p` (form feed), `$$` (literal `$`), `$'` (single quote), `$XX` + * (hex byte) and `''` (doubled single quote), then escapes backslash and + * double-quote so the result is safe inside a C++ `"…"` literal. + * + * Shared by the expression emitter and the type generator: a STRING literal has + * to lower identically whether it appears in a statement, a variable + * initialiser, or a STRUCT element default. + */ +export function translateIECString(inner: string): string { + let result = ""; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]!; + if (ch === "$" && i + 1 < inner.length) { + const next = inner[i + 1]!; + switch (next.toUpperCase()) { + case "N": + case "L": + result += "\\n"; + i++; + break; + case "R": + result += "\\r"; + i++; + break; + case "T": + result += "\\t"; + i++; + break; + case "P": + result += "\\f"; + i++; + break; + case "$": + result += "$"; + i++; + break; + case "'": + result += "'"; + i++; + break; + default: + // $XX hex escape: two hex digits + if ( + i + 2 < inner.length && + /^[0-9A-Fa-f]{2}$/.test(inner.substring(i + 1, i + 3)) + ) { + result += "\\x" + inner.substring(i + 1, i + 3); + i += 2; + } else { + // Unknown $-escape, pass through + result += "\\\\$"; + } + break; + } + } else if (ch === "'" && i + 1 < inner.length && inner[i + 1] === "'") { + // ST doubled-quote → single quote + result += "'"; + i++; + } else if (ch === "\\") { + result += "\\\\"; + } else if (ch === '"') { + result += '\\"'; + } else { + result += ch; + } + } + return result; +} diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index b8686e9c..e3803d4c 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -56,7 +56,11 @@ import { } from "../project-model.js"; import { isElementaryType, TypeRegistry } from "../semantic/type-registry.js"; import { TypeCodeGenerator, IEC_TO_CPP_VAR_TYPE } from "./type-codegen.js"; -import { formatArrayType, iecBaseToCppLiteral } from "./codegen-utils.js"; +import { + formatArrayType, + iecBaseToCppLiteral, + translateIECString, +} from "./codegen-utils.js"; import { getTypeBits, getTypeCategory, @@ -3624,7 +3628,7 @@ export class CodeGenerator { case "STRING": { // rawValue includes surrounding single quotes: 'hello' → strip them const inner = expr.rawValue.replace(/^'|'$/g, ""); - const escaped = this.translateIECString(inner); + const escaped = translateIECString(inner); return `"${escaped}"`; } case "WSTRING": { @@ -3633,7 +3637,7 @@ export class CodeGenerator { // (wchar_t — wchar_t is 32-bit on Linux/AVR, so L"…" wouldn't // bind to IECWStringVar's char16_t* constructor). const wInner = expr.rawValue.replace(/^["']|["']$/g, ""); - const wEscaped = this.translateIECString(wInner); + const wEscaped = translateIECString(wInner); return `u"${wEscaped}"`; } case "TIME": { @@ -3659,13 +3663,6 @@ export class CodeGenerator { } } - /** - * Translate IEC 61131-3 $-escape sequences to C++ escape sequences. - * Handles: $N/$n (newline), $L/$l (line feed), $R/$r (CR), $T/$t (tab), - * $P/$p (form feed), $$ (literal $), $' (single quote), $XX (hex byte), - * '' (doubled single quote), and C++ escaping for backslash and double-quote. - */ - private formatIntegerLiteral(rawValue: string, value: number): string { // Based literals (16#FF, 8#77, 2#1010) → C++ notation; plain decimals use numeric value const upper = rawValue.toUpperCase().replace(/_/g, ""); @@ -3679,67 +3676,6 @@ export class CodeGenerator { return String(value); } - private translateIECString(inner: string): string { - let result = ""; - for (let i = 0; i < inner.length; i++) { - const ch = inner[i]!; - if (ch === "$" && i + 1 < inner.length) { - const next = inner[i + 1]!; - switch (next.toUpperCase()) { - case "N": - case "L": - result += "\\n"; - i++; - break; - case "R": - result += "\\r"; - i++; - break; - case "T": - result += "\\t"; - i++; - break; - case "P": - result += "\\f"; - i++; - break; - case "$": - result += "$"; - i++; - break; - case "'": - result += "'"; - i++; - break; - default: - // $XX hex escape: two hex digits - if ( - i + 2 < inner.length && - /^[0-9A-Fa-f]{2}$/.test(inner.substring(i + 1, i + 3)) - ) { - result += "\\x" + inner.substring(i + 1, i + 3); - i += 2; - } else { - // Unknown $-escape, pass through - result += "\\\\$"; - } - break; - } - } else if (ch === "'" && i + 1 < inner.length && inner[i + 1] === "'") { - // ST doubled-quote → single quote - result += "'"; - i++; - } else if (ch === "\\") { - result += "\\\\"; - } else if (ch === '"') { - result += '\\"'; - } else { - result += ch; - } - } - return result; - } - /** * Generate C++ for a variable expression. */ diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index 6ad81160..c03c5eb1 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -21,7 +21,7 @@ import type { UnaryExpression, } from "../frontend/ast.js"; import { TypeRegistry, isElementaryType } from "../semantic/type-registry.js"; -import { formatArrayType } from "./codegen-utils.js"; +import { formatArrayType, translateIECString } from "./codegen-utils.js"; import { parseDateLiteralToDays, parseDtLiteralToNs, @@ -34,7 +34,6 @@ import { } from "../semantic/type-utils.js"; import { generateInitializerValue, - isStructInitializerValue, type StructInitEmitter, } from "./struct-init-codegen.js"; @@ -329,14 +328,18 @@ export class TypeCodeGenerator { ? `${fieldName}_` : fieldName; if (field.initialValue) { - const initVal = isStructInitializerValue(field.initialValue) - ? generateInitializerValue( - field.initialValue, - cppType, - field.type.name, - this.structInitEmitter, - ) - : this.expressionToCpp(field.initialValue); + // Routes composite initialisers (array literals, structure + // initializers) through the shared lowering and everything else + // through expressionToCpp. Before this, an array-literal default on a + // STRUCT element fell through to expressionToCpp's `0` fallback and + // the `isArrayType` guard below turned it into `{}` — the declared + // values were dropped with no diagnostic. + const initVal = generateInitializerValue( + field.initialValue, + cppType, + field.type.name, + this.structInitEmitter, + ); // Array types can't be initialized with = 0; use {} instead const isArrayType = /^Array[123]D { "INNER I = strucpp::iec_struct_init([](auto& v0) { v0.A = 5; });", ); }); + + it("keeps the values of an array-literal default on a STRUCT element", () => { + // These were dropped to `{}` with no diagnostic: the type generator's + // expression emitter had no array-literal case, so the value fell through to + // its `0` fallback and the array guard rewrote that as `{}`. + const result = compileST(` + TYPE + Buf : STRUCT + data : ARRAY[0..3] OF INT := [7, 8, 9, 10]; + n : INT := 4; + END_STRUCT; + END_TYPE + VAR_GLOBAL + b : Buf; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain( + "Array1D DATA = {7, 8, 9, 10};", + ); + }); + + it("expands a repetition group in a STRUCT element default", () => { + const result = compileST(` + TYPE + Buf : STRUCT + data : ARRAY[0..3] OF INT := [4(7)]; + END_STRUCT; + END_TYPE + VAR_GLOBAL + b : Buf; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain( + "Array1D DATA = {7, 7, 7, 7};", + ); + }); + + it("escapes a STRING element default the way the expression path does", () => { + // The type generator used to emit the literal body verbatim, so an embedded + // `"` closed the C++ string early. Latent until array-literal defaults + // started emitting (OSCAT's HTML-entity tables are STRING arrays full of + // quotes and backslashes). + const result = compileST(` + TYPE + Msg : STRUCT + quoted : STRING := 'say "hi"'; + tabbed : STRING := 'a$Tb'; + END_STRUCT; + END_TYPE + VAR_GLOBAL + m : Msg; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain('QUOTED = "say \\"hi\\""'); + expect(result.headerCode).toContain('TABBED = "a\\tb"'); + }); + + it("escapes STRING elements inside an array-literal default", () => { + const result = compileST(` + TYPE + Table : STRUCT + names : ARRAY[0..1] OF STRING := ['a"b', 'plain']; + END_STRUCT; + END_TYPE + VAR_GLOBAL + t : Table; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain('{"a\\"b", "plain"}'); + }); + + it("still value-initialises an array element with no default", () => { + const result = compileST(` + TYPE + Buf : STRUCT + data : ARRAY[0..3] OF INT; + END_STRUCT; + END_TYPE + VAR_GLOBAL + b : Buf; + END_VAR + `); + expectOk(result); + expect(result.headerCode).toContain("Array1D DATA{};"); + }); }); describe("structure initializers — type-level defaults", () => { From 97d82ad8f4552d1308708f05a3badd8ff4695b4c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 12:42:58 -0400 Subject: [PATCH 04/13] fix(debug): index multi-dimensional arrays with operator() in the debug table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/backend/codegen-utils.ts | 24 ++++++++++++ src/backend/debug-table-gen.ts | 18 ++++++++- tests/backend/debug-table-gen.test.ts | 55 +++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/backend/codegen-utils.ts b/src/backend/codegen-utils.ts index 399779cd..16363509 100644 --- a/src/backend/codegen-utils.ts +++ b/src/backend/codegen-utils.ts @@ -53,6 +53,30 @@ export function formatArrayType( return result; } +/** + * Append an unchecked element access for one full set of array indices, + * matching the container {@link formatArrayType} picked for that rank. + * + * `Array1D` subscripts with `operator[]`; `Array2D` / `Array3D` take all indices + * at once through `operator()`; 4+ dimensions are nested `Array1D`, so they + * subscript once per dimension. Getting this wrong doesn't just read the wrong + * element — `arr[i][j]` on an `Array2D` has no matching operator and fails to + * compile. + * + * Unchecked (rather than `.at()`) because these accessors are `constexpr`, which + * is what lets `&arr[i]` be a constant expression — required for the debug + * pointer table's PROGMEM placement on AVR. + */ +export function formatArrayElementAccess( + base: string, + indices: number[], +): string { + if (indices.length === 2 || indices.length === 3) { + return `${base}(${indices.join(", ")})`; + } + return base + indices.map((i) => `[${i}]`).join(""); +} + /** * Translate IEC 61131-3 `$`-escape sequences in a string literal's body to C++ * escape sequences, and escape what C++ needs escaped. diff --git a/src/backend/debug-table-gen.ts b/src/backend/debug-table-gen.ts index 3773649f..a255fd5a 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -29,6 +29,7 @@ import type { } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { SymbolTables } from "../semantic/symbol-table.js"; +import { formatArrayElementAccess } from "./codegen-utils.js"; // --------------------------------------------------------------------------- // Type tags — MUST match TypeTag enum in runtime/include/debug_dispatch.hpp. @@ -426,17 +427,29 @@ export function generateDebugTable( } }; + /** + * Enumerate every element of an array, emitting one debug entry per element. + * + * Indices are collected across all dimensions and only turned into C++ at the + * innermost level, because the accessor depends on the array's rank: + * `Array2D`/`Array3D` take every index in one `operator()` call, so emitting a + * subscript per dimension as we descend would produce `arr[i][j]` — which has + * no matching operator on those containers and fails to compile. + * {@link formatArrayElementAccess} owns that rank rule. The IEC display path + * stays `[i][j]`, which is what the debug UI shows. + */ const walkArrayDims = ( path: string, cppExpr: string, dims: Array<{ start: number; end: number }>, dimIdx: number, elementTypeName: string, + indices: number[] = [], ): void => { if (dimIdx >= dims.length) { // Innermost element — visit as a TypeReference with the element type // name. Manufacture a minimal TypeReference for recursion. - visitTypeRef(path, cppExpr, { + visitTypeRef(path, formatArrayElementAccess(cppExpr, indices), { kind: "TypeReference", name: elementTypeName, isReference: false, @@ -448,10 +461,11 @@ export function generateDebugTable( for (let i = start; i <= end; i++) { walkArrayDims( `${path}[${i}]`, - `${cppExpr}[${i}]`, + cppExpr, dims, dimIdx + 1, elementTypeName, + [...indices, i], ); } }; diff --git a/tests/backend/debug-table-gen.test.ts b/tests/backend/debug-table-gen.test.ts index 62e19fea..c8349d65 100644 --- a/tests/backend/debug-table-gen.test.ts +++ b/tests/backend/debug-table-gen.test.ts @@ -219,6 +219,61 @@ END_CONFIGURATION } }); + it("uses operator() for multi-dimensional array elements", () => { + // Array2D/Array3D take every index in one operator() call. Emitting a + // subscript per dimension gives `arr[i][j]`, which has no matching operator + // on those containers — the generated debug table then fails to compile + // (reported from an AVR build: "no match for 'operator[]'"). + const source = ` +TYPE + Matrix2 : ARRAY[0..1, 0..1] OF INT; + Cube : ARRAY[0..1, 0..1, 0..1] OF INT; +END_TYPE + +PROGRAM main + VAR + m : Matrix2; + c : Cube; + flat : ARRAY[0..2] OF INT; + END_VAR + m[0, 0] := 1; +END_PROGRAM + +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK t(INTERVAL := T#20ms, PRIORITY := 1); + PROGRAM p WITH t : main; + END_RESOURCE +END_CONFIGURATION +`; + const result = compile(source); + expect(result.success).toBe(true); + const cpp = result.debugTableCpp!; + + // 2D → one operator() call with both indices. + expect(cpp).toContain(".M(0, 0)"); + expect(cpp).toContain(".M(1, 1)"); + // 3D → one call with all three. + expect(cpp).toContain(".C(0, 0, 0)"); + expect(cpp).toContain(".C(1, 1, 1)"); + // 1D still subscripts. No chained subscripting survives in any pointer + // expression — the trailing comment keeps the IEC `[i][j]` path, so check + // only the code ahead of it. + expect(cpp).toContain(".FLAT[2]"); + const pointerExprs = cpp + .split("\n") + .filter((l) => l.includes("(void*)&")) + .map((l) => l.split("//")[0]!); + expect(pointerExprs.length).toBeGreaterThan(0); + expect(pointerExprs.filter((e) => e.includes("]["))).toEqual([]); + + // The IEC display paths keep the [i][j] form the debug UI shows. + const paths = result.debugMap!.leaves.map((l) => l.path); + expect(paths).toContain("P.M[0][0]"); + expect(paths).toContain("P.C[1][1][1]"); + expect(paths).toContain("P.FLAT[2]"); + }); + it("applies maxEntriesPerArray split when exceeded", () => { // 10 leaves, cap at 4 -> expect 3 buckets (4, 4, 2) const manyVarsSource = ` From a78c5e3fcaeaeef8ad47984bfbac228ecec562b1 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 13:13:56 -0400 Subject: [PATCH 05/13] feat: 3D arrays, nested array initializers, and function-block array invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/IEC_COMPLIANCE.md | 5 +- src/ast-utils.ts | 1 + src/backend/codegen.ts | 36 ++- src/backend/struct-init-codegen.ts | 35 ++- src/frontend/ast-builder.ts | 41 ++++ src/frontend/ast.ts | 9 + src/frontend/parser-error-message-provider.ts | 2 + src/frontend/parser.ts | 53 +++++ src/runtime/include/iec_array.hpp | 129 ++++++++++- src/semantic/analyzer.ts | 5 + tests/frontend/fb-array-invocation.test.ts | 209 ++++++++++++++++++ .../structure-initialization-cpp.test.ts | 204 +++++++++++++++++ 12 files changed, 714 insertions(+), 15 deletions(-) create mode 100644 tests/frontend/fb-array-invocation.test.ts diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index e5e3e218..4dd707dc 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -31,6 +31,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | ARRAY (1D) | Supported | Arbitrary bounds: ARRAY[1..10] OF INT | | ARRAY (2D) | Supported | ARRAY[1..3, 1..4] OF REAL | | ARRAY (3D) | Supported | ARRAY[1..3, 1..4, 1..5] OF INT | +| ARRAY OF function block | Supported | Declaration, member access, and element invocation (`units[i](step := 1.0)`). A *method* call on an element (`units[0].M()`) is not yet parsed | | ARRAY[*] (VLA) | Supported | Variable-length array parameters | | Subranges | Supported | Runtime validation | | REF_TO | Supported | IEC reference type (explicit dereference) | @@ -68,7 +69,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | AT %IX0.0 | Supported | Located variables (I/Q/M areas, X/B/W/D/L sizes) | | Multiple names | Supported | `a, b, c : INT := 0;` | | Initialization | Supported | `:= expression` | -| Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take a flat row-major list | +| Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take either a flat row-major list or a nested one (`:= [[1, 2], [3, 4]]`), where each inner list fills one row from its own bound | | Array repetition | Supported | `:= [10(0)]`, `:= [3(1), 2(5)]`, `:= [7, 4(2), 9]`. The repeated value may be a structure initializer. Max count 65536 | | Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | | STRUCT element defaults | Supported | Scalar, array-literal and structure-initializer defaults on a STRUCT element all carry their values | @@ -77,8 +78,6 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Form | Notes | |------|-------| -| Nested array initializer | `:= [[1, 2], [3, 4]]` for a multi-dimensional array. Use the flat row-major form `:= [1, 2, 3, 4]` | -| 3D array initializer | `ARRAY[0..1, 0..1, 0..1] OF INT := [1, 2, ...]` — the runtime `Array3D` has no initializer-list constructor, so any 3D initializer fails to build | | Repetition with no value | `:= [10()]` (ten copies of the element default) — write `:= [10(0)]`, or omit the elements entirely. Matches matiec and CODESYS, which also require a value | ## Operators and Expressions diff --git a/src/ast-utils.ts b/src/ast-utils.ts index 53ef04ce..84df28d0 100644 --- a/src/ast-utils.ts +++ b/src/ast-utils.ts @@ -596,6 +596,7 @@ function getChildren(node: ASTNode): ASTNode[] { case "FunctionCallExpression": { const fce = node as FunctionCallExpression; + if (fce.instance) children.push(fce.instance); children.push(...fce.arguments); break; } diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index e3803d4c..bae788bf 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -2988,7 +2988,10 @@ export class CodeGenerator { `${indent}${this.generateMethodCallExpression(stmt.call)};`, ); } else { - const fbType = this.getFBInvocationType(stmt.call.functionName); + const fbType = this.getFBInvocationType( + stmt.call.functionName, + stmt.call.instance !== undefined, + ); if (fbType) { this.generateFBInvocation(stmt.call, indent); } else if ( @@ -5165,9 +5168,23 @@ export class CodeGenerator { /** * Check if a function call statement is actually an FB invocation. * Returns the FB type name if it is, undefined otherwise. + * + * `isElementCall` distinguishes `units[0]()` from `units()`: there the + * declared type is the array, so the instance type is its element type. */ - private getFBInvocationType(functionName: string): string | undefined { - const varType = this.currentScopeVarTypes.get(functionName.toUpperCase()); + private getFBInvocationType( + functionName: string, + isElementCall = false, + ): string | undefined { + const declaredType = this.currentScopeVarTypes.get( + functionName.toUpperCase(), + ); + if (!declaredType) return undefined; + const varType = isElementCall + ? this.ast + ? resolveArrayElementTypeUtil(declaredType, this.ast) + : undefined + : declaredType; if ( varType && (this.isFBType(varType) || @@ -5286,14 +5303,23 @@ export class CodeGenerator { ); } + // `units[0](…)` invokes an element rather than a bare instance: the target + // is the subscripted expression, and the FB type is the array's element + // type. Everything below (input assignment, the call, inout copy-back, + // output capture) then works against that expression unchanged. const instanceName = - this.memberMangledNames.get(rawName.toUpperCase()) ?? rawName; + call.instance !== undefined + ? this.generateExpression(call.instance) + : (this.memberMangledNames.get(rawName.toUpperCase()) ?? rawName); // Extract implicit EN/ENO parameters const { enExpr, enoVar, filteredArgs } = this.extractEnEno(call.arguments); // Resolve FB type for positional argument mapping - const fbTypeName = this.currentScopeVarTypes.get(rawName.toUpperCase()); + const fbTypeName = this.getFBInvocationType( + call.functionName, + call.instance !== undefined, + ); const inputParamNames = fbTypeName ? this.fbInputParams.get(fbTypeName.toUpperCase()) : undefined; diff --git a/src/backend/struct-init-codegen.ts b/src/backend/struct-init-codegen.ts index 8e1b1177..061bb2b2 100644 --- a/src/backend/struct-init-codegen.ts +++ b/src/backend/struct-init-codegen.ts @@ -79,10 +79,17 @@ export function generateInitializerValue( if (value.kind === "ArrayLiteralExpression") { // `typename ::element_type` names the element type of every // Array1D/2D/3D, so an array of STRUCTs needs no metadata lookup. - const elementCppType = - cppTypeExpr !== undefined && cppTypeExpr !== "" - ? `typename ${cppTypeExpr}::element_type` - : undefined; + // + // A nested list keeps the outer element type rather than descending again: + // for a multi-dimensional array the inner lists are rows of the *same* + // element type (the container's nested initializer-list constructor fills + // row by row), and where the inner elements really are a further array the + // type is unused because they lower as scalars. Descending twice produced + // `typename typename …::element_type::element_type`, which is not even valid + // C++. + const elementCppType = isArrayLiteralOf(value) + ? cppTypeExpr + : arrayElementCppType(cppTypeExpr); const elementStType = emitter.arrayElementTypeName(stTypeName); const elements = value.elements.map((element) => generateInitializerValue( @@ -99,6 +106,26 @@ export function generateInitializerValue( return emitter.emitValue(value); } +/** True when every element of an array literal is itself an array literal. */ +function isArrayLiteralOf(value: Expression): boolean { + return ( + value.kind === "ArrayLiteralExpression" && + value.elements.length > 0 && + value.elements.every((e) => e.kind === "ArrayLiteralExpression") + ); +} + +/** `typename ::element_type`, or undefined when the type is unknown. */ +function arrayElementCppType( + cppTypeExpr: string | undefined, +): string | undefined { + if (cppTypeExpr === undefined || cppTypeExpr === "") return undefined; + // One `typename` covers a whole qualified name, so never add a second. + return cppTypeExpr.startsWith("typename ") + ? `${cppTypeExpr}::element_type` + : `typename ${cppTypeExpr}::element_type`; +} + /** * Emit `strucpp::iec_struct_init([](auto& vN) { … })` for one structure * initializer level. diff --git a/src/frontend/ast-builder.ts b/src/frontend/ast-builder.ts index 9092d6f6..2405c7df 100644 --- a/src/frontend/ast-builder.ts +++ b/src/frontend/ast-builder.ts @@ -1688,6 +1688,11 @@ export class ASTBuilder { getFirstNode(children.functionCallStatement)!, ); } + if (children.instanceCallStatement) { + return this.buildInstanceCallStatement( + getFirstNode(children.instanceCallStatement)!, + ); + } if (children.methodCallStatement) { return this.buildMethodCallStatement( getFirstNode(children.methodCallStatement)!, @@ -3279,6 +3284,42 @@ export class ASTBuilder { }; } + /** + * Build `units[0](args);` — invoking a function block instance held in an + * array element. + * + * Reuses FunctionCallStatement: `functionName` is the base variable name, so + * the declared type still resolves the usual way, and `instance` carries the + * subscripted expression the invocation is emitted against. + */ + buildInstanceCallStatement(node: CstNode): FunctionCallStatement { + const children = node.children as CstChildren; + const variableNode = getFirstNode(children.variable); + const instance = variableNode + ? this.buildVariableExpression(variableNode) + : undefined; + const args: Argument[] = []; + const argListNode = getFirstNode(children.argumentList); + if (argListNode) { + const argListChildren = argListNode.children as CstChildren; + for (const argNode of getAllNodes(argListChildren.argument)) { + args.push(this.buildArgument(argNode)); + } + } + + return { + kind: "FunctionCallStatement", + sourceSpan: nodeToSourceSpan(node), + call: { + kind: "FunctionCallExpression", + sourceSpan: nodeToSourceSpan(node), + functionName: instance?.name ?? "", + arguments: args, + ...(instance !== undefined ? { instance } : {}), + }, + }; + } + /** * Build a method call statement: instance.method(args); * Maps to FunctionCallStatement with functionName = "instance.method" diff --git a/src/frontend/ast.ts b/src/frontend/ast.ts index c1396fdb..19f4b3cc 100644 --- a/src/frontend/ast.ts +++ b/src/frontend/ast.ts @@ -633,6 +633,15 @@ export interface FunctionCallExpression extends TypedNode { kind: "FunctionCallExpression"; functionName: string; arguments: Argument[]; + /** + * Set when the callee is a function block instance reached through an + * expression rather than a bare name — today an array element, `units[0]()`. + * + * `functionName` still carries the base variable name (`units`), which is what + * resolves the declared type; this expression is what the invocation is + * emitted against. + */ + instance?: Expression; } /** diff --git a/src/frontend/parser-error-message-provider.ts b/src/frontend/parser-error-message-provider.ts index aa4733fa..9d4739aa 100644 --- a/src/frontend/parser-error-message-provider.ts +++ b/src/frontend/parser-error-message-provider.ts @@ -51,6 +51,8 @@ const RULE_DESCRIPTIONS: Record = { assignmentStatement: "parsing an assignment", refAssignStatement: "parsing a reference assignment", functionCallStatement: "parsing a function call", + instanceCallStatement: + "invoking a function block instance in an array element", methodCallStatement: "parsing a method call", ifStatement: "parsing an IF statement", caseStatement: "parsing a CASE statement", diff --git a/src/frontend/parser.ts b/src/frontend/parser.ts index 37626d0e..fbeac154 100644 --- a/src/frontend/parser.ts +++ b/src/frontend/parser.ts @@ -908,6 +908,13 @@ export class STParser extends CstParser { ALT: () => this.SUBRULE(this.methodCallStatement), GATE: () => this.isMethodCallAhead(), }, + // `units[0](…)` — invoking an FB instance in an array element. Must also + // precede assignmentStatement, which would otherwise consume the + // subscripted variable and then demand `:=`. + { + ALT: () => this.SUBRULE(this.instanceCallStatement), + GATE: () => this.isInstanceCallAhead(), + }, // assignmentStatement and functionCallStatement both start with Identifier; // Chevrotain resolves by trying assignmentStatement first (it has := after the LHS) { ALT: () => this.SUBRULE(this.assignmentStatement) }, @@ -1123,6 +1130,37 @@ export class STParser extends CstParser { ); } + /** + * Lookahead helper: does an invocation of a subscripted function block + * instance start here (`units[0](…)`, `grid[i, j]()`)? + * + * Requires the `(` to follow the closing `]` directly, so this claims exactly + * the array-element invocation and leaves `arr[0].m(…)` — which could equally + * be a method call on the element — to the existing rules. + */ + private isInstanceCallAhead(): boolean { + if (!this.isIdentifierOrKeywordToken(this.LA(1).tokenType)) return false; + if (this.LA(2).tokenType !== tokens.LBracket) return false; + + // Walk to the matching `]`, allowing nested subscripts in the index + // expressions (`a[b[i]]`). 64 tokens covers any realistic index list. + const MAX_LOOKAHEAD = 64; + let depth = 0; + for (let i = 2; i <= MAX_LOOKAHEAD; i++) { + const tokenType = this.LA(i)?.tokenType; + if (tokenType === undefined) return false; + if (tokenType === tokens.LBracket) { + depth++; + } else if (tokenType === tokens.RBracket) { + depth--; + if (depth === 0) return this.LA(i + 1)?.tokenType === tokens.LParen; + } else if (tokenType === tokens.Semicolon) { + return false; + } + } + return false; + } + /** * instance.method(args); statement */ @@ -1142,6 +1180,21 @@ export class STParser extends CstParser { this.CONSUME(tokens.Semicolon); }); + /** + * `units[0](args);` — invoke a function block instance held in an array + * element. IEC 61131-3 allows an array of function block instances, and an + * element is invoked like any other instance. + */ + public instanceCallStatement = this.RULE("instanceCallStatement", () => { + this.SUBRULE(this.variable); + this.CONSUME(tokens.LParen); + this.OPTION(() => { + this.SUBRULE(this.argumentList); + }); + this.CONSUME(tokens.RParen); + this.CONSUME(tokens.Semicolon); + }); + /** * SUPER^(); or SUPER^.method(args); statement * Caret is mandatory — SUPER is a pointer to parent (CODESYS semantics). diff --git a/src/runtime/include/iec_array.hpp b/src/runtime/include/iec_array.hpp index c6e72e01..0678caef 100644 --- a/src/runtime/include/iec_array.hpp +++ b/src/runtime/include/iec_array.hpp @@ -68,6 +68,24 @@ class IEC_ARRAY_1D { ++i; } } + + // Element-typed initializer list, for an array whose element is itself a + // composite: `ARRAY[0..1] OF Row := [[1,2,3],[4,5,6]]`, and the nested + // Array1D chain a 4+-dimensional array lowers to. The template above can't + // serve these — `U` has nothing to deduce from a braced element — while + // this overload lets each element convert through its own constructor. + // + // Not ambiguous with the template for a scalar list: `{1, 2, 3}` deduces + // `U = int` exactly, whereas this overload would need a user-defined + // conversion per element, so the template wins. + IEC_ARRAY_1D(std::initializer_list init) noexcept : data_{} { + size_t i = 0; + for (const auto& val : init) { + if (i >= size) break; + data_[i] = val; + ++i; + } + } // Element access (1-based IEC indexing) - no bounds checking. // constexpr so &arr[i] is a constant expression — required by AVR @@ -159,6 +177,28 @@ class IEC_ARRAY_2D { } } + // Row-nested initializer list — IEC 61131-3 Annex B.1.4.3 allows an + // `array_initialization` as an element, which is the natural way to write a + // 2D initializer: `ARRAY[0..1,0..2] OF INT := [[1,2,3],[4,5,6]]`. + // + // Each inner list fills one row from its own lower bound, so a short row + // leaves the rest of that row at its default instead of shifting the next + // row up — which is the difference from writing the same values flat. + template + IEC_ARRAY_2D(std::initializer_list> init) noexcept : data_{} { + size_t row = 0; + for (const auto& rowInit : init) { + if (row >= rows) break; + size_t col = 0; + for (const auto& val : rowInit) { + if (col >= cols) break; + data_[row * cols + col] = val; + ++col; + } + ++row; + } + } + // Element access (1-based IEC indexing) - no bounds checking. // constexpr so &arr(i, j) is a constant expression — see the // matching note on IEC_ARRAY_1D::operator[] above. @@ -236,7 +276,48 @@ class IEC_ARRAY_3D { public: IEC_ARRAY_3D() noexcept : data_{} {} - + + // Flat (row-major) initializer-list constructor — mirrors IEC_ARRAY_1D/2D. + // ST aggregate inits for a 3D array codegen to a flat brace list; fill + // row-major, ignoring any overflow. + template + IEC_ARRAY_3D(std::initializer_list init) noexcept : data_{} { + size_t i = 0; + for (const auto& val : init) { + if (i >= total_size) break; + data_[i] = val; + ++i; + } + } + + // Plane/row-nested initializer list — the 3D form of the nested + // `array_initialization` IEC allows as an element: + // `ARRAY[0..1,0..1,0..1] OF INT := [[[1,2],[3,4]],[[5,6],[7,8]]]`. + // Each level fills from its own lower bound, so a short inner list leaves + // the remainder of that row at its default. + template + IEC_ARRAY_3D( + std::initializer_list>> init) noexcept + : data_{} { + size_t i = 0; + for (const auto& planeInit : init) { + if (i >= dim1) break; + size_t j = 0; + for (const auto& rowInit : planeInit) { + if (j >= dim2) break; + size_t k = 0; + for (const auto& val : rowInit) { + if (k >= dim3) break; + data_[i * dim2 * dim3 + j * dim3 + k] = val; + ++k; + } + ++j; + } + ++i; + } + } + + // Element access (IEC indexing) - no bounds checking. // constexpr so &arr(i, j, k) is a constant expression — see the // matching note on IEC_ARRAY_1D::operator[] above. constexpr var_type& operator()(int64_t i, int64_t j, int64_t k) noexcept { @@ -246,13 +327,55 @@ class IEC_ARRAY_3D { constexpr const var_type& operator()(int64_t i, int64_t j, int64_t k) const noexcept { return data_[to_linear_index(i, j, k)]; } - + + // Bounds-checked access - throws std::out_of_range on invalid index. + // This is what codegen emits for a subscript in a body, so it has to exist + // at every rank, not just 1D/2D. + var_type& at(int64_t i, int64_t j, int64_t k) { + if (!Bounds1::in_bounds(i) || !Bounds2::in_bounds(j) || !Bounds3::in_bounds(k)) { +#if STRUCPP_HAS_EXCEPTIONS + throw std::out_of_range("Array index out of bounds"); +#else + iec_runtime_fault(IecFault::ArrayBounds); +#endif + } + return data_[to_linear_index(i, j, k)]; + } + + const var_type& at(int64_t i, int64_t j, int64_t k) const { + if (!Bounds1::in_bounds(i) || !Bounds2::in_bounds(j) || !Bounds3::in_bounds(k)) { +#if STRUCPP_HAS_EXCEPTIONS + throw std::out_of_range("Array index out of bounds"); +#else + iec_runtime_fault(IecFault::ArrayBounds); +#endif + } + return data_[to_linear_index(i, j, k)]; + } + + // Size information static constexpr size_t size1() noexcept { return dim1; } static constexpr size_t size2() noexcept { return dim2; } static constexpr size_t size3() noexcept { return dim3; } - + static constexpr size_t dim1_size() noexcept { return dim1; } + static constexpr size_t dim2_size() noexcept { return dim2; } + static constexpr size_t dim3_size() noexcept { return dim3; } + static constexpr int64_t dim1_lower() noexcept { return Bounds1::lower; } + static constexpr int64_t dim1_upper() noexcept { return Bounds1::upper; } + static constexpr int64_t dim2_lower() noexcept { return Bounds2::lower; } + static constexpr int64_t dim2_upper() noexcept { return Bounds2::upper; } + static constexpr int64_t dim3_lower() noexcept { return Bounds3::lower; } + static constexpr int64_t dim3_upper() noexcept { return Bounds3::upper; } + + // Raw data access var_type* data() noexcept { return data_.data(); } const var_type* data() const noexcept { return data_.data(); } + + // Iterators (linear traversal) + auto begin() noexcept { return data_.begin(); } + auto end() noexcept { return data_.end(); } + auto begin() const noexcept { return data_.begin(); } + auto end() const noexcept { return data_.end(); } }; // Convenience type aliases diff --git a/src/semantic/analyzer.ts b/src/semantic/analyzer.ts index 930db1e4..608260f0 100644 --- a/src/semantic/analyzer.ts +++ b/src/semantic/analyzer.ts @@ -2783,6 +2783,11 @@ export class SemanticAnalyzer { ); this.checkNameDeclared(objName, scope, ctx, expr.sourceSpan); } + // An FB instance reached through an expression (`units[0]()`) — check + // the instance and its subscripts, which are ordinary variables. + if (expr.instance) { + this.checkExpressionForUndeclaredVars(expr.instance, scope, ctx); + } // Don't check non-dotted function names — they're function/FB symbols for (const arg of expr.arguments) { this.checkExpressionForUndeclaredVars(arg.value, scope, ctx); diff --git a/tests/frontend/fb-array-invocation.test.ts b/tests/frontend/fb-array-invocation.test.ts new file mode 100644 index 00000000..6799dbf2 --- /dev/null +++ b/tests/frontend/fb-array-invocation.test.ts @@ -0,0 +1,209 @@ +/** + * Parsing tests for invoking a function block instance held in an array element. + * + * units[0](step := 2.0); + * grid[i, j](); + * + * IEC 61131-3 allows an array of function block instances, and an element is + * invoked like any other instance. This used to fail in the parser: the + * statement was taken as an assignment target, which then demanded `:=` + * (`Expected Assign, found (`). + * + * The alternative is gated on `(` following the closing `]` directly, so it + * claims exactly the element invocation and leaves `arr[0].m(…)` — which could + * equally be a method call on the element — to the existing rules. + */ + +import { describe, it, expect } from "vitest"; +import { parse } from "../../src/frontend/parser.js"; +import { buildAST } from "../../src/frontend/ast-builder.js"; +import { uppercaseSource } from "../../src/frontend/lexer.js"; +import type { + CompilationUnit, + FunctionCallExpression, + FunctionCallStatement, + Statement, +} from "../../src/frontend/ast.js"; + +function parseOk(source: string): CompilationUnit { + const { cst, errors } = parse(uppercaseSource(source)); + expect(errors.map((e) => e.message)).toEqual([]); + return buildAST(cst!); +} + +function firstStatement(ast: CompilationUnit): Statement { + const stmt = ast.programs[0]?.body[0]; + expect(stmt).toBeDefined(); + return stmt!; +} + +function asCall(stmt: Statement): FunctionCallExpression { + expect(stmt.kind).toBe("FunctionCallStatement"); + const call = (stmt as FunctionCallStatement).call; + expect(call.kind).toBe("FunctionCallExpression"); + return call as FunctionCallExpression; +} + +const FB = ` + FUNCTION_BLOCK Accum + VAR_INPUT step : REAL := 1.0; END_VAR + VAR_OUTPUT val : REAL; END_VAR + val := val + step; + END_FUNCTION_BLOCK +`; + +describe("function block array element invocation — parsing", () => { + it("parses an invocation with no arguments", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR units : ARRAY[0..1] OF Accum; END_VAR + units[0](); + END_PROGRAM + `); + const call = asCall(firstStatement(ast)); + // The base name still resolves the declared type; `instance` is the target. + expect(call.functionName).toBe("UNITS"); + expect(call.arguments).toEqual([]); + expect(call.instance?.kind).toBe("VariableExpression"); + }); + + it("parses named arguments on the element invocation", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR units : ARRAY[0..1] OF Accum; END_VAR + units[1](step := 2.0); + END_PROGRAM + `); + const call = asCall(firstStatement(ast)); + expect(call.arguments).toHaveLength(1); + expect(call.arguments[0]!.name).toBe("STEP"); + }); + + it("parses a variable index", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR units : ARRAY[0..1] OF Accum; i : INT; END_VAR + units[i](); + END_PROGRAM + `); + const call = asCall(firstStatement(ast)); + const instance = call.instance!; + expect(instance.kind).toBe("VariableExpression"); + expect("subscripts" in instance ? instance.subscripts.length : 0).toBe(1); + }); + + it("parses a multi-dimensional index", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR grid : ARRAY[0..1, 0..1] OF Accum; END_VAR + grid[0, 1](); + END_PROGRAM + `); + const instance = asCall(firstStatement(ast)).instance!; + expect("subscripts" in instance ? instance.subscripts.length : 0).toBe(2); + }); + + it("parses a nested subscript in the index expression", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR + units : ARRAY[0..1] OF Accum; + idx : ARRAY[0..1] OF INT; + END_VAR + units[idx[0]](); + END_PROGRAM + `); + expect(asCall(firstStatement(ast)).instance).toBeDefined(); + }); + + it("parses an invocation inside a FOR loop", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR units : ARRAY[0..2] OF Accum; i : INT; END_VAR + FOR i := 0 TO 2 DO + units[i](step := 1.0); + END_FOR; + END_PROGRAM + `); + expect(firstStatement(ast).kind).toBe("ForStatement"); + }); +}); + +describe("function block array element invocation — no effect on other statements", () => { + it("still parses an assignment to an array element", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..1] OF INT; END_VAR + a[0] := 5; + END_PROGRAM + `); + expect(firstStatement(ast).kind).toBe("AssignmentStatement"); + }); + + it("still parses an assignment whose value subscripts an array", () => { + const ast = parseOk(` + PROGRAM Main + VAR a : ARRAY[0..1] OF INT; b : INT; END_VAR + b := a[0]; + END_PROGRAM + `); + expect(firstStatement(ast).kind).toBe("AssignmentStatement"); + }); + + it("still parses a function call whose argument subscripts an array", () => { + const ast = parseOk(` + FUNCTION F : INT + VAR_INPUT x : INT; END_VAR + F := x; + END_FUNCTION + PROGRAM Main + VAR a : ARRAY[0..1] OF INT; b : INT; END_VAR + b := F(a[0]); + END_PROGRAM + `); + expect(firstStatement(ast).kind).toBe("AssignmentStatement"); + }); + + it("still parses a plain function block invocation", () => { + const ast = parseOk(` + ${FB} + PROGRAM Main + VAR unit : Accum; END_VAR + unit(step := 1.0); + END_PROGRAM + `); + const call = asCall(firstStatement(ast)); + expect(call.functionName).toBe("UNIT"); + expect(call.instance).toBeUndefined(); + }); + + it("leaves a method call on an array element unparsed (pre-existing gap)", () => { + // `cs[0].Bump()` is a *method* call on an element, which the expression + // grammar still doesn't accept — `isMethodCallAhead` wants `ident . ident (` + // and this is `ident [ … ] . ident (`. Unchanged by the element-invocation + // rule, whose gate only fires when `(` follows `]` directly. Recorded here + // so the day it starts parsing is a deliberate change, not a surprise. + const { errors } = parse( + uppercaseSource(` + FUNCTION_BLOCK Counter + VAR n : INT; END_VAR + METHOD Bump : INT + n := n + 1; + Bump := n; + END_METHOD + END_FUNCTION_BLOCK + PROGRAM Main + VAR cs : ARRAY[0..1] OF Counter; r : INT; END_VAR + r := cs[0].Bump(); + END_PROGRAM + `), + ); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/integration/structure-initialization-cpp.test.ts b/tests/integration/structure-initialization-cpp.test.ts index dc9f5bf8..f5778e95 100644 --- a/tests/integration/structure-initialization-cpp.test.ts +++ b/tests/integration/structure-initialization-cpp.test.ts @@ -455,6 +455,210 @@ describeIfGpp( expect(output).toBe("11155 722229 3344"); }); + it("initialises a 3D array, and lets its elements be read and written", () => { + // `IEC_ARRAY_3D` had neither an initializer-list constructor nor `at()`, + // so a 3D array was unusable: the initializer failed to build and so did + // any subscript in a body (codegen emits the bounds-checked `.at()`). + const output = run( + ` + PROGRAM Main + VAR + c : ARRAY[0..1, 0..1, 0..1] OF INT := [1, 2, 3, 4, 5, 6, 7, 8]; + x : INT; + END_VAR + c[0, 0, 0] := 9; + x := c[1, 1, 1]; + END_PROGRAM + `, + ` Program_MAIN p; + p.run(); + std::cout << p.C(0, 0, 0).get() << " " << p.C(0, 0, 1).get() << " " + << p.C(1, 1, 1).get() << " " << p.X.get() << std::endl;`, + "arrayinit_3d", + ); + // Flat list fills row-major, then the body writes [0,0,0] and reads [1,1,1]. + expect(output).toBe("9 2 8 8"); + }); + + it("fills a 2D array from a row-nested initializer", () => { + const output = run( + ` + PROGRAM Main + VAR m : ARRAY[0..1, 0..2] OF INT := [[1, 2, 3], [4, 5, 6]]; END_VAR + m[0, 0] := m[0, 0]; + END_PROGRAM + `, + ` Program_MAIN p; + for (int i = 0; i < 2; ++i) + for (int j = 0; j < 3; ++j) std::cout << p.M(i, j).get(); + std::cout << std::endl;`, + "arrayinit_2d_nested", + ); + expect(output).toBe("123456"); + }); + + it("fills each row from its own bound, so a short row does not shift", () => { + // This is the semantic difference from writing the values flat: `[[1],[4]]` + // leaves the rest of each row at its default instead of packing 4 into + // row 0. + const output = run( + ` + PROGRAM Main + VAR m : ARRAY[0..1, 0..2] OF INT := [[1], [4]]; END_VAR + m[0, 0] := m[0, 0]; + END_PROGRAM + `, + ` Program_MAIN p; + for (int i = 0; i < 2; ++i) + for (int j = 0; j < 3; ++j) std::cout << p.M(i, j).get(); + std::cout << std::endl;`, + "arrayinit_2d_nested_short", + ); + expect(output).toBe("100400"); + }); + + it("fills a 3D array from a plane/row-nested initializer", () => { + const output = run( + ` + PROGRAM Main + VAR c : ARRAY[0..1, 0..1, 0..1] OF INT := [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]; END_VAR + c[0, 0, 0] := c[0, 0, 0]; + END_PROGRAM + `, + ` Program_MAIN p; + for (int i = 0; i < 2; ++i) + for (int j = 0; j < 2; ++j) + for (int k = 0; k < 2; ++k) std::cout << p.C(i, j, k).get(); + std::cout << std::endl;`, + "arrayinit_3d_nested", + ); + expect(output).toBe("12345678"); + }); + + it("nests into an array whose element type is itself an array", () => { + // Lowers to a nested Array1D, which needs the element-typed + // initializer-list overload rather than the deducing template. + const output = run( + ` + TYPE Row : ARRAY[0..2] OF INT; END_TYPE + PROGRAM Main + VAR a : ARRAY[0..1] OF Row := [[1, 2, 3], [4, 5, 6]]; END_VAR + a[0][0] := a[0][0]; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.A[0][0].get() << p.A[0][2].get() + << p.A[1][0].get() << p.A[1][2].get() << std::endl;`, + "arrayinit_array_of_array", + ); + expect(output).toBe("1346"); + }); + + it("nests structure initializers inside a 2D array initializer", () => { + // The element type must not descend a second time — that produced + // `typename typename …::element_type::element_type`. + const output = run( + ` + TYPE Point : STRUCT x : INT; y : INT; END_STRUCT; END_TYPE + PROGRAM Main + VAR + g : ARRAY[0..1, 0..1] OF Point := [[(x := 1, y := 2), (x := 3, y := 4)], [(x := 5, y := 6), (x := 7, y := 8)]]; + END_VAR + g[0, 0].x := g[0, 0].x; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.G(0, 0).X.get() << p.G(0, 1).Y.get() + << p.G(1, 0).X.get() << p.G(1, 1).Y.get() << std::endl;`, + "arrayinit_2d_of_struct_nested", + ); + // G[0,0].x=1, G[0,1].y=4, G[1,0].x=5, G[1,1].y=8 + expect(output).toBe("1458"); + }); + + it("still fills a multi-dimensional array from a flat list", () => { + const output = run( + ` + PROGRAM Main + VAR + m : ARRAY[0..1, 0..2] OF INT := [1, 2, 3, 4, 5, 6]; + c : ARRAY[0..1, 0..1, 0..1] OF INT := [4(7)]; + END_VAR + m[0, 0] := m[0, 0]; + END_PROGRAM + `, + ` Program_MAIN p; + std::cout << p.M(0, 2).get() << p.M(1, 0).get() << " " + << p.C(0, 0, 0).get() << p.C(0, 1, 1).get() << p.C(1, 0, 0).get() + << std::endl;`, + "arrayinit_multidim_flat", + ); + // Flat still fills row-major; the 3D repetition covers only the first 4 + // slots, leaving the rest at their default. + expect(output).toBe("34 770"); + }); + + it("invokes function block instances held in array elements", () => { + // Each element is its own instance with its own state, so the values after + // the scan are what distinguishes a working element invocation from one + // that accidentally drives a single shared instance. + const output = run( + ` + FUNCTION_BLOCK Accum + VAR_INPUT step : REAL := 1.0; END_VAR + VAR_OUTPUT val : REAL; END_VAR + val := val + step; + END_FUNCTION_BLOCK + PROGRAM Main + VAR + units : ARRAY[0..2] OF Accum; + grid : ARRAY[0..1, 0..1] OF Accum; + i : INT; + total : REAL; + END_VAR + units[0](step := 2.0); + units[1](step := 5.0); + FOR i := 0 TO 2 DO + units[i](step := 1.0); + END_FOR; + grid[0, 1](step := 3.0); + total := units[0].val + units[1].val + grid[0, 1].val; + END_PROGRAM + `, + ` Program_MAIN p; + p.run(); + std::cout << p.UNITS.at(0).VAL.get() << " " << p.UNITS.at(1).VAL.get() << " " + << p.UNITS.at(2).VAL.get() << " " << p.GRID.at(0, 1).VAL.get() + << " " << p.TOTAL.get() << std::endl;`, + "fb_array_invocation", + ); + // units[0]: 2 then +1 in the loop; units[1]: 5 then +1; units[2]: loop only. + expect(output).toBe("3 6 1 3 12"); + }); + + it("initialises function block instances across an array", () => { + const output = run( + ` + FUNCTION_BLOCK Accum + VAR_INPUT step : REAL := 1.0; limit : REAL := 99.0; END_VAR + VAR_OUTPUT val : REAL; END_VAR + val := val + step; + END_FUNCTION_BLOCK + PROGRAM Main + VAR units : ARRAY[0..1] OF Accum := [2((step := 4.0))]; END_VAR + units[0](); + END_PROGRAM + `, + ` Program_MAIN p; + p.run(); + std::cout << p.UNITS.at(0).STEP.get() << " " << p.UNITS.at(1).LIMIT.get() + << " " << p.UNITS.at(0).VAL.get() << std::endl;`, + "fb_array_initialised", + ); + // Both elements get step 4.0; limit keeps its VAR_INPUT default. + expect(output).toBe("4 99 4"); + }); + it("repeats a structure initializer across array elements", () => { const output = run( ` From cee7131f5171384e0c37aca0c421222c75a30380 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 13:21:29 -0400 Subject: [PATCH 06/13] fix(codegen): forward-declare POU classes before the user-defined types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TYPE may name a function block — `AccumGrid : ARRAY[0..1,0..1] OF Accum` emits `using ACCUMGRID = Array2D` — 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) --- src/backend/codegen.ts | 60 ++++++++++++------- .../structure-initialization-cpp.test.ts | 35 +++++++++++ 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index bae788bf..c951af7e 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -1163,6 +1163,17 @@ export class CodeGenerator { this.emitHeader(""); } + // Forward-declare the POU classes before the user-defined types. + // + // A TYPE may name a function block — `AccumGrid : ARRAY[0..1,0..1] OF Accum` + // emits `using ACCUMGRID = Array2D`, and an alias to a class + // template needs the argument to at least be declared. An incomplete type is + // enough here because the alias doesn't instantiate anything; instantiation + // happens where the alias is used as a member, by which point the full + // definition has been emitted. Repeated below with the rest of the forward + // declarations, which is harmless — redundant class declarations are legal. + this.emitPouForwardDeclarations(ast); + // Generate user-defined types (Phase 2.2) if (ast.types.length > 0) { const typeRegistry = new TypeRegistry(); @@ -1243,26 +1254,7 @@ export class CodeGenerator { } // Generate forward declarations - for (const iface of ast.interfaces) { - this.emitHeader(`class ${iface.name};`); - } - for (const fb of ast.functionBlocks) { - this.emitHeader(`class ${fb.name};`); - } - for (const prog of ast.programs) { - this.emitHeader(`class Program_${prog.name};`); - } - for (const config of ast.configurations) { - this.emitHeader(`class Configuration_${config.name};`); - } - if ( - ast.interfaces.length > 0 || - ast.functionBlocks.length > 0 || - ast.programs.length > 0 || - ast.configurations.length > 0 - ) { - this.emitHeader(""); - } + this.emitPouForwardDeclarations(ast); // Generate interface declarations (before FBs since FBs may implement interfaces) for (const iface of ast.interfaces) { @@ -2677,6 +2669,34 @@ export class CodeGenerator { * bodies can name the globals. Also registers located VAR_GLOBALs so the * runtime binds them to the I/O image. */ + /** + * Forward-declare every interface, function block, program and configuration + * class. Emitted twice: once ahead of the user-defined types, which may name a + * function block, and once in the usual forward-declaration block. + */ + private emitPouForwardDeclarations(ast: CompilationUnit): void { + for (const iface of ast.interfaces) { + this.emitHeader(`class ${iface.name};`); + } + for (const fb of ast.functionBlocks) { + this.emitHeader(`class ${fb.name};`); + } + for (const prog of ast.programs) { + this.emitHeader(`class Program_${prog.name};`); + } + for (const config of ast.configurations) { + this.emitHeader(`class Configuration_${config.name};`); + } + if ( + ast.interfaces.length > 0 || + ast.functionBlocks.length > 0 || + ast.programs.length > 0 || + ast.configurations.length > 0 + ) { + this.emitHeader(""); + } + } + private emitFileScopeGlobals(): void { if (!this.projectModel) return; const seen = new Set(); diff --git a/tests/integration/structure-initialization-cpp.test.ts b/tests/integration/structure-initialization-cpp.test.ts index f5778e95..81069d20 100644 --- a/tests/integration/structure-initialization-cpp.test.ts +++ b/tests/integration/structure-initialization-cpp.test.ts @@ -636,6 +636,41 @@ describeIfGpp( expect(output).toBe("3 6 1 3 12"); }); + it("invokes elements of a named ARRAY OF function-block type", () => { + // `AccumGrid : ARRAY[…] OF Accum` emits `using ACCUMGRID = Array2D` + // in the user-types block, which used to precede the POU forward + // declarations — so `ACCUM` was undeclared at that point. + const output = run( + ` + FUNCTION_BLOCK Accum + VAR_INPUT step : REAL := 1.0; END_VAR + VAR_OUTPUT val : REAL; END_VAR + val := val + step; + END_FUNCTION_BLOCK + TYPE + AccumGrid : ARRAY[0..1, 0..1] OF Accum; + AccumRow : ARRAY[0..1] OF Accum; + END_TYPE + PROGRAM Main + VAR + grid : AccumGrid; + row : AccumRow; + total : REAL; + END_VAR + grid[0, 1](step := 3.0); + row[1](step := 7.0); + total := grid[0, 1].val + row[1].val; + END_PROGRAM + `, + ` Program_MAIN p; + p.run(); + std::cout << p.GRID.at(0, 1).VAL.get() << " " << p.ROW.at(1).VAL.get() + << " " << p.TOTAL.get() << std::endl;`, + "fb_array_named_type", + ); + expect(output).toBe("3 7 10"); + }); + it("initialises function block instances across an array", () => { const output = run( ` From bd6f011f976adee01a8b3338f1991c004c4776d5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 16:24:00 -0400 Subject: [PATCH 07/13] feat(semantic): validate array initializer shape and subscript count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/IEC_COMPLIANCE.md | 4 +- src/backend/debug-table-gen.ts | 24 +- src/semantic/analyzer.ts | 311 ++++++++++++++- src/semantic/type-utils.ts | 126 +++++++ tests/semantic/array-shape-validation.test.ts | 354 ++++++++++++++++++ 5 files changed, 792 insertions(+), 27 deletions(-) create mode 100644 tests/semantic/array-shape-validation.test.ts diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index 4dd707dc..c6879047 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -69,7 +69,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | AT %IX0.0 | Supported | Located variables (I/Q/M areas, X/B/W/D/L sizes) | | Multiple names | Supported | `a, b, c : INT := 0;` | | Initialization | Supported | `:= expression` | -| Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take either a flat row-major list or a nested one (`:= [[1, 2], [3, 4]]`), where each inner list fills one row from its own bound | +| Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take either a flat row-major list or a nested one (`:= [[1, 2], [3, 4]]`), where each inner list fills one row from its own bound. Nesting depth and value count are validated against the declared dimensions | | Array repetition | Supported | `:= [10(0)]`, `:= [3(1), 2(5)]`, `:= [7, 4(2), 9]`. The repeated value may be a structure initializer. Max count 65536 | | Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | | STRUCT element defaults | Supported | Scalar, array-literal and structure-initializer defaults on a STRUCT element all carry their values | @@ -96,7 +96,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Parentheses | `( )` | Supported | | Function call | `name(args)` | Supported (positional + named) | | Method call | `obj.method(args)` | Supported | -| Array access | `arr[i]`, `arr[i, j]` | Supported | +| Array access | `arr[i]`, `arr[i, j]` | Supported — the index count is validated against the declared rank | | Field access | `struct.field` | Supported | | Typed literals | `INT#5`, `DINT#42`, `REAL#3.14` | Supported | | NEW | `__NEW(type)`, `__NEW(type, size)` | Supported | diff --git a/src/backend/debug-table-gen.ts b/src/backend/debug-table-gen.ts index a255fd5a..ba699d14 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -30,6 +30,7 @@ import type { import type { ProjectModel } from "../project-model.js"; import type { SymbolTables } from "../semantic/symbol-table.js"; import { formatArrayElementAccess } from "./codegen-utils.js"; +import { evalIntConst } from "../semantic/type-utils.js"; // --------------------------------------------------------------------------- // Type tags — MUST match TypeTag enum in runtime/include/debug_dispatch.hpp. @@ -649,29 +650,6 @@ function renderCpp( // Expression helpers // --------------------------------------------------------------------------- -/** Evaluate a compile-time integer Expression; returns undefined on failure. */ -function evalIntConst(e: unknown): number | undefined { - if (!e || typeof e !== "object") return undefined; - const expr = e as { - kind?: string; - value?: unknown; - operand?: unknown; - operator?: string; - }; - if (expr.kind === "LiteralExpression") { - if (typeof expr.value === "number") return expr.value; - if (typeof expr.value === "bigint") { - const n = Number(expr.value); - if (Number.isSafeInteger(n)) return n; - } - } - if (expr.kind === "UnaryExpression" && expr.operator === "-") { - const inner = evalIntConst(expr.operand); - return inner === undefined ? undefined : -inner; - } - return undefined; -} - // --------------------------------------------------------------------------- // Helpers exposed for tests // --------------------------------------------------------------------------- diff --git a/src/semantic/analyzer.ts b/src/semantic/analyzer.ts index 608260f0..dbe14ffa 100644 --- a/src/semantic/analyzer.ts +++ b/src/semantic/analyzer.ts @@ -9,6 +9,7 @@ import type { Argument, + ArrayLiteralExpression, AssertCall, CompilationUnit, ElementaryType, @@ -39,9 +40,19 @@ import { resolveArrayElementType, buildEnumMemberMap, describeType, + resolveArrayShape, + resolveArrayShapeByName, + arrayDimSize, + arrayTotalSize, + type ArrayShape, type EnumMemberEntry, } from "./type-utils.js"; -import { isEnArgument, isEnoArgument, stripEnEno } from "../ast-utils.js"; +import { + isEnArgument, + isEnoArgument, + stripEnEno, + walkAST, +} from "../ast-utils.js"; // ============================================================================= // Located Variable Address Parsing @@ -670,13 +681,309 @@ export class SemanticAnalyzer { // Validate bit access bounds and ADR l-value targets this.validateExpressions(ast); + // Validate array initializer shape/size and subscript counts + this.validateArrayShapes(ast); + // TODO: Implement additional semantic validation - // - Validate array bounds // - Check CASE statement coverage // - Validate reference operations // - Check for unreachable code } + /** + * Validate array declarations and array accesses against the declared shape: + * + * - an initializer's nesting must match the array's rank + * - an initializer must not supply more values than the array (or a row) holds + * - a subscript must supply one index per dimension + * + * All three were previously invisible here: a nesting or rank mistake surfaced + * as a C++ error against generated code, and an over-long initializer was + * silently truncated by the runtime container's constructor. + * + * Every check is skipped rather than guessed at when the shape isn't statically + * known (variable-length `ARRAY[*]`, non-constant bounds, a type that doesn't + * resolve), so this can only ever add diagnostics for definite mistakes. + */ + private validateArrayShapes(ast: CompilationUnit): void { + // Globals are visible to every POU, and are the fallback when a name isn't + // one of the POU's own variables. + const globals = new Map(); + const addDecls = ( + blocks: VarBlock[], + into: Map, + ): void => { + for (const block of blocks) { + for (const decl of block.declarations) { + for (const name of decl.names) + into.set(name.toUpperCase(), decl.type); + } + } + }; + addDecls(ast.globalVarBlocks, globals); + for (const config of ast.configurations) + addDecls(config.varBlocks, globals); + + // Declaration initializers, everywhere a declaration can appear. + for (const block of ast.globalVarBlocks) { + this.checkVarBlockInitializers(block, ast); + } + for (const config of ast.configurations) { + for (const block of config.varBlocks) { + this.checkVarBlockInitializers(block, ast); + } + } + for (const typeDecl of ast.types) { + if (typeDecl.definition.kind !== "StructDefinition") continue; + for (const field of typeDecl.definition.fields) { + this.checkDeclarationInitializer(field, ast); + } + } + + // Per-POU: initializers plus the subscript counts in its body. + const checkPou = (blocks: VarBlock[], bodies: Statement[][]): void => { + const scope = new Map(globals); + addDecls(blocks, scope); + for (const block of blocks) this.checkVarBlockInitializers(block, ast); + for (const body of bodies) this.checkSubscriptCounts(body, scope, ast); + }; + + for (const prog of ast.programs) checkPou(prog.varBlocks, [prog.body]); + for (const func of ast.functions) checkPou(func.varBlocks, [func.body]); + for (const fb of ast.functionBlocks) { + checkPou(fb.varBlocks, [fb.body]); + for (const method of fb.methods) { + // A method sees its own locals plus the FB's members. + checkPou([...fb.varBlocks, ...method.varBlocks], [method.body]); + } + } + } + + /** Check every declaration in a VAR block. */ + private checkVarBlockInitializers( + block: VarBlock, + ast: CompilationUnit, + ): void { + for (const decl of block.declarations) { + this.checkDeclarationInitializer(decl, ast); + } + } + + /** + * Check one declaration's initializer against its declared array shape. + * + * Only array literals are examined. A scalar initializer on an array is left + * alone: it is meaningful for a STRUCT element (`data : ARRAY[…] OF INT := 0` + * value-initialises), so rejecting it here would flag working code. + */ + private checkDeclarationInitializer( + decl: VarDeclaration, + ast: CompilationUnit, + ): void { + if (!decl.initialValue) return; + if (decl.initialValue.kind !== "ArrayLiteralExpression") return; + const shape = resolveArrayShape(decl.type, ast); + if (!shape) return; + this.checkArrayLiteralShape( + decl.initialValue, + shape, + decl.names.join(", "), + ast, + 0, + ); + } + + /** + * Recursively check an array literal against the dimensions it initialises. + * + * `depth` counts nesting levels already consumed. Returns true once something + * has been reported, so one mistaken declaration yields one diagnostic rather + * than one per row. + */ + private checkArrayLiteralShape( + literal: ArrayLiteralExpression, + shape: ArrayShape, + declName: string, + ast: CompilationUnit, + depth: number, + ): boolean { + const span = literal.sourceSpan; + const where = depth === 0 ? "" : ` at nesting level ${depth + 1}`; + const nestedCount = literal.elements.filter( + (e) => e.kind === "ArrayLiteralExpression", + ).length; + + if (nestedCount > 0 && nestedCount !== literal.elements.length) { + this.addError( + `Initializer for '${declName}' mixes nested and flat values${where}. ` + + `Either give every element its own list, or write the whole array flat.`, + span.startLine, + span.startCol, + span.file, + ); + return true; + } + + if (nestedCount === 0) { + // A flat list at the outermost level fills the whole array row-major, + // which IEC allows for any rank. Once nesting has started, though, each + // level descends exactly one dimension — a flat list part-way down leaves + // dimensions unaccounted for and no container constructor matches it. + if (depth > 0 && shape.dims.length > 1) { + this.addError( + `Initializer for '${declName}' stops nesting at level ${depth + 1}, ` + + `but ${shape.dims.length} dimensions remain. Nest one level per ` + + `dimension, or write the whole array as a single flat list.`, + span.startLine, + span.startCol, + span.file, + ); + return true; + } + const total = arrayTotalSize(shape.dims); + if (total !== undefined && literal.elements.length > total) { + this.addError( + `Initializer for '${declName}' has ${literal.elements.length} values ` + + `but the array holds ${total}. The extra values would be discarded.`, + span.startLine, + span.startCol, + span.file, + ); + return true; + } + return false; + } + + // Nested list — the outer level fills the first dimension. When only one + // dimension remains, the nesting can only be meant for an element type that + // is itself an array. + const outerSize = arrayDimSize(shape.dims[0] ?? null); + if (outerSize !== undefined && literal.elements.length > outerSize) { + this.addError( + `Initializer for '${declName}' has ${literal.elements.length} entries` + + `${where} but that dimension holds ${outerSize}. ` + + `The extra entries would be discarded.`, + span.startLine, + span.startCol, + span.file, + ); + return true; + } + + let innerShape: ArrayShape; + if (shape.dims.length > 1) { + innerShape = { + dims: shape.dims.slice(1), + elementTypeName: shape.elementTypeName, + }; + } else { + const elementShape = resolveArrayShapeByName(shape.elementTypeName, ast); + if (!elementShape) { + this.addError( + `Initializer for '${declName}' is nested ${depth + 2} levels deep, but ` + + `the array has ${depth + 1} dimension${depth === 0 ? "" : "s"} and its ` + + `elements are not arrays. Write the values at one level per dimension.`, + span.startLine, + span.startCol, + span.file, + ); + return true; + } + innerShape = elementShape; + } + + for (const element of literal.elements) { + if ( + this.checkArrayLiteralShape( + element as ArrayLiteralExpression, + innerShape, + declName, + ast, + depth + 1, + ) + ) { + return true; + } + } + return false; + } + + /** + * Walk statements and check that every array subscript supplies one index per + * dimension. `arr[i, j]` on a 1-dimensional array and `arr[i]` on a + * 2-dimensional one are both static mistakes that used to reach g++ as + * "no matching member function for call to 'at'". + */ + private checkSubscriptCounts( + statements: Statement[], + scope: Map, + ast: CompilationUnit, + ): void { + const seen = new Set(); + for (const stmt of statements) { + walkAST(stmt, (node) => { + if (node.kind !== "VariableExpression") return; + const expr = node as VariableExpression; + if (seen.has(expr)) return; + seen.add(expr); + this.checkVariableSubscripts(expr, scope, ast); + }); + } + } + + /** + * Check one variable reference's subscripts, walking its access chain so that + * `a[0][1]` (two single-index steps into an array of arrays) is not confused + * with `a[0, 1]` (one two-index step into a 2D array). + */ + private checkVariableSubscripts( + expr: VariableExpression, + scope: Map, + ast: CompilationUnit, + ): void { + const declared = scope.get(expr.name.toUpperCase()); + if (!declared) return; + + // Only the ordered chain distinguishes the two spellings above; without it + // the flat `subscripts` list is ambiguous, so there is nothing safe to check. + const chain = expr.accessChain; + if (!chain || chain.length === 0) return; + + let currentTypeName: string | undefined = declared.name; + let currentShape = resolveArrayShape(declared, ast); + + for (const step of chain) { + if (step.kind === "subscript") { + if (!currentShape) return; // not a known array — nothing to check + if (step.indices.length !== currentShape.dims.length) { + this.addError( + `'${expr.name}' has ${currentShape.dims.length} dimension` + + `${currentShape.dims.length === 1 ? "" : "s"} but is indexed with ` + + `${step.indices.length} ` + + `${step.indices.length === 1 ? "index" : "indices"}.`, + expr.sourceSpan.startLine, + expr.sourceSpan.startCol, + expr.sourceSpan.file, + ); + return; + } + currentTypeName = currentShape.elementTypeName; + currentShape = currentTypeName + ? resolveArrayShapeByName(currentTypeName, ast) + : undefined; + } else if (step.kind === "field") { + if (!currentTypeName) return; + const fieldType = resolveFieldType(currentTypeName, step.name, ast); + if (!fieldType) return; + currentTypeName = fieldType; + currentShape = resolveArrayShapeByName(fieldType, ast); + } else { + // Dereference — pointer semantics are out of scope for this check. + return; + } + } + } + /** * Validate that no assignments target CONSTANT variables. */ diff --git a/src/semantic/type-utils.ts b/src/semantic/type-utils.ts index 7b27bd9d..058c4371 100644 --- a/src/semantic/type-utils.ts +++ b/src/semantic/type-utils.ts @@ -565,6 +565,132 @@ export function resolveArrayElementType( return undefined; } +/** + * Evaluate a compile-time integer expression; undefined when it isn't one. + * + * Deliberately narrow — array bounds and similar declaration-time integers are + * literals or a negated literal in practice, and anything else is better left + * unresolved than guessed at. + */ +export function evalIntConst(e: unknown): number | undefined { + if (e === null || e === undefined || typeof e !== "object") return undefined; + const expr = e as { + kind?: string; + value?: unknown; + operand?: unknown; + operator?: string; + }; + if (expr.kind === "LiteralExpression") { + if (typeof expr.value === "number") return expr.value; + if (typeof expr.value === "bigint") { + const n = Number(expr.value); + if (Number.isSafeInteger(n)) return n; + } + } + if (expr.kind === "UnaryExpression" && expr.operator === "-") { + const inner = evalIntConst(expr.operand); + return inner === undefined ? undefined : -inner; + } + return undefined; +} + +/** One declared array dimension; `null` when its extent isn't known statically. */ +export type ArrayDimExtent = { start: number; end: number } | null; + +/** The declared shape of an array type: its dimensions and element type name. */ +export interface ArrayShape { + /** One entry per dimension. `null` for a variable-length (`ARRAY[*]`) or + * non-constant bound — the rank is still known, the extent isn't. */ + dims: ArrayDimExtent[]; + elementTypeName: string; +} + +/** Guard against a cyclic alias chain while resolving a type name. */ +const MAX_TYPE_ALIAS_DEPTH = 32; + +/** + * Resolve the declared shape of an array-typed reference, following type + * aliases. Returns undefined when the reference is not an array. + * + * Covers both spellings: an inline `ARRAY[…] OF T` (whose bounds the AST builder + * has already resolved onto the TypeReference) and a named ARRAY type. + */ +export function resolveArrayShape( + type: { + name: string; + arrayDimensions?: Array<{ start: number; end: number }>; + elementTypeName?: string; + }, + ast: CompilationUnit, +): ArrayShape | undefined { + if (type.arrayDimensions && type.arrayDimensions.length > 0) { + return { + dims: type.arrayDimensions.map((d) => ({ start: d.start, end: d.end })), + elementTypeName: type.elementTypeName ?? "", + }; + } + return resolveArrayShapeByName(type.name, ast); +} + +/** + * Resolve the declared shape of a named type, following alias chains. + * Returns undefined when the name doesn't (transitively) name an array. + */ +export function resolveArrayShapeByName( + typeName: string, + ast: CompilationUnit, + depth = 0, +): ArrayShape | undefined { + if (depth >= MAX_TYPE_ALIAS_DEPTH) return undefined; + const upper = typeName.toUpperCase(); + + // Internal marker for an inline array whose bounds live on the declaration; + // the rank isn't recoverable from the name alone. + if (upper.startsWith("__INLINE_ARRAY_")) return undefined; + + for (const td of ast.types) { + if (td.name.toUpperCase() !== upper) continue; + const def = td.definition; + if (def.kind === "ArrayDefinition") { + return { + dims: def.dimensions.map((d) => { + if (d.isVariableLength) return null; + const start = evalIntConst(d.start); + const end = evalIntConst(d.end); + return start === undefined || end === undefined + ? null + : { start, end }; + }), + elementTypeName: def.elementType.name, + }; + } + if (def.kind === "TypeReference") { + // Alias — keep walking toward the underlying array, if any. + return resolveArrayShapeByName(def.name, ast, depth + 1); + } + return undefined; + } + return undefined; +} + +/** Number of elements a dimension holds, or undefined when its extent is unknown. */ +export function arrayDimSize(dim: ArrayDimExtent): number | undefined { + if (!dim) return undefined; + const size = dim.end - dim.start + 1; + return size > 0 ? size : undefined; +} + +/** Total element count across every dimension, or undefined if any is unknown. */ +export function arrayTotalSize(dims: ArrayDimExtent[]): number | undefined { + let total = 1; + for (const d of dims) { + const size = arrayDimSize(d); + if (size === undefined) return undefined; + total *= size; + } + return total; +} + // ============================================================================= // Display Helper // ============================================================================= diff --git a/tests/semantic/array-shape-validation.test.ts b/tests/semantic/array-shape-validation.test.ts new file mode 100644 index 00000000..86172890 --- /dev/null +++ b/tests/semantic/array-shape-validation.test.ts @@ -0,0 +1,354 @@ +/** + * Semantic validation of array declarations and accesses against the declared + * shape: + * + * - an initializer's nesting must match the array's rank + * - an initializer must not supply more values than the array (or a row) holds + * - a subscript must supply one index per dimension + * + * All three used to escape the compiler: a nesting or rank mistake surfaced as a + * C++ error against generated code (`no matching constructor`, `no matching + * member function for call to 'at'`), and an over-long initializer was silently + * truncated by the runtime container's constructor — the array simply came out + * with values missing and no diagnostic anywhere. + * + * The accepted cases matter as much as the rejected ones: every check is skipped + * rather than guessed at when the shape isn't statically known, so this can only + * add diagnostics for definite mistakes. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +function errorsFor(source: string): string[] { + return compile(source).errors.map((e) => e.message); +} + +function expectClean(source: string): void { + expect(errorsFor(source)).toEqual([]); +} + +/** Wrap declarations + body in a PROGRAM. */ +function prog(vars: string, body = " dummy := 0;"): string { + return ` +PROGRAM Main + VAR +${vars} + dummy : INT; + END_VAR +${body} +END_PROGRAM +`; +} + +describe("array initializer: over-long", () => { + it("rejects more values than a 1D array holds", () => { + const errors = errorsFor( + prog(" a : ARRAY[0..2] OF INT := [1,2,3,4,5,6];"), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("has 6 values but the array holds 3"); + }); + + it("rejects a repetition group that expands past the array", () => { + const errors = errorsFor(prog(" a : ARRAY[0..2] OF INT := [10(7)];")); + expect(errors[0]).toContain("has 10 values but the array holds 3"); + }); + + it("rejects a flat list longer than a multi-dimensional array", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [1,2,3,4,5];"), + ); + expect(errors[0]).toContain("has 5 values but the array holds 4"); + }); + + it("rejects a row longer than its dimension", () => { + // Silently truncated before: `3` and `6` were dropped. + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [[1,2,3],[4,5,6]];"), + ); + expect(errors[0]).toContain("has 3 values but the array holds 2"); + }); + + it("rejects more rows than the first dimension holds", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [[1,2],[3,4],[5,6]];"), + ); + expect(errors[0]).toContain("has 3 entries"); + expect(errors[0]).toContain("that dimension holds 2"); + }); + + it("reports one diagnostic per declaration, not one per row", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [[1,2,3],[4,5,6]];"), + ); + expect(errors).toHaveLength(1); + }); + + it("accepts fewer values than the array holds", () => { + // A partial initializer is legal — the remainder keeps its default. + expectClean(prog(" a : ARRAY[0..9] OF INT := [1,2,3];")); + }); + + it("accepts exactly as many values as the array holds", () => { + expectClean(prog(" a : ARRAY[0..3] OF INT := [1,2,3,4];")); + }); +}); + +describe("array initializer: nesting", () => { + it("rejects nesting on a 1D array of scalars", () => { + const errors = errorsFor( + prog(" a : ARRAY[0..3] OF INT := [[1,2],[3,4]];"), + ); + expect(errors[0]).toContain("nested 2 levels deep"); + expect(errors[0]).toContain("has 1 dimension"); + }); + + it("rejects nesting that stops short of the rank", () => { + // Two levels into a 3D array: no container constructor matches, and it used + // to reach g++ as "no matching constructor". + const errors = errorsFor( + prog(" c : ARRAY[0..1, 0..1, 0..2] OF INT := [[1,2,3],[4,5,6]];"), + ); + expect(errors[0]).toContain("stops nesting at level 2"); + expect(errors[0]).toContain("2 dimensions remain"); + }); + + it("rejects nesting deeper than the rank", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [[[1],[2]],[[3],[4]]];"), + ); + expect(errors[0]).toContain("nested 3 levels deep"); + }); + + it("rejects mixing nested and flat values", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT := [1, [2,3]];"), + ); + expect(errors[0]).toContain("mixes nested and flat values"); + }); + + it("accepts a flat list for a multi-dimensional array (row-major)", () => { + expectClean(prog(" m : ARRAY[0..1, 0..2] OF INT := [1,2,3,4,5,6];")); + expectClean( + prog(" c : ARRAY[0..1, 0..1, 0..1] OF INT := [1,2,3,4,5,6,7,8];"), + ); + }); + + it("accepts nesting that matches the rank", () => { + expectClean(prog(" m : ARRAY[0..1, 0..2] OF INT := [[1,2,3],[4,5,6]];")); + expectClean( + prog( + " c : ARRAY[0..1, 0..1, 0..1] OF INT := [[[1,2],[3,4]],[[5,6],[7,8]]];", + ), + ); + }); + + it("accepts short rows — each keeps its own defaults", () => { + expectClean(prog(" m : ARRAY[0..1, 0..2] OF INT := [[1],[4]];")); + }); + + it("accepts nesting into an array whose element type is itself an array", () => { + expectClean(` +TYPE Row : ARRAY[0..2] OF INT; END_TYPE +PROGRAM Main + VAR + a : ARRAY[0..1] OF Row := [[1,2,3],[4,5,6]]; + dummy : INT; + END_VAR + dummy := 0; +END_PROGRAM +`); + }); + + it("accepts structure initializers as the elements of a nested array", () => { + expectClean(` +TYPE Point : STRUCT x : INT; y : INT; END_STRUCT; END_TYPE +PROGRAM Main + VAR + g : ARRAY[0..1, 0..1] OF Point := [[(x:=1),(x:=2)],[(x:=3),(x:=4)]]; + dummy : INT; + END_VAR + dummy := 0; +END_PROGRAM +`); + }); +}); + +describe("array subscripts: index count", () => { + it("rejects too few indices", () => { + const errors = errorsFor( + prog(" m : ARRAY[0..1, 0..1] OF INT;", " dummy := m[0];"), + ); + expect(errors).toHaveLength(1); + // strucpp uppercases identifiers, as its other diagnostics do. + expect(errors[0]).toBe("'M' has 2 dimensions but is indexed with 1 index."); + }); + + it("rejects too many indices", () => { + const errors = errorsFor( + prog(" a : ARRAY[0..3] OF INT;", " dummy := a[0,1];"), + ); + expect(errors[0]).toBe( + "'A' has 1 dimension but is indexed with 2 indices.", + ); + }); + + it("rejects a wrong index count on a 3D array reached through a field", () => { + // The reported case: widening the rank while leaving the accesses at two. + const errors = errorsFor(` +TYPE Point : STRUCT x : REAL; y : REAL; END_STRUCT; END_TYPE +PROGRAM Main + VAR + p : ARRAY[0..1, 0..1, 0..2] OF Point; + r : REAL; + END_VAR + r := p[0,0].x; +END_PROGRAM +`); + expect(errors[0]).toContain("has 3 dimensions but is indexed with 2"); + }); + + it("accepts the right index count at every rank", () => { + expectClean(prog(" a : ARRAY[0..3] OF INT;", " dummy := a[1];")); + expectClean( + prog(" m : ARRAY[0..1, 0..1] OF INT;", " dummy := m[0,1];"), + ); + expectClean( + prog(" c : ARRAY[0..1, 0..1, 0..1] OF INT;", " dummy := c[0,1,0];"), + ); + }); + + it("does not confuse a[0][1] with a[0,1]", () => { + // An array of an array type is indexed one step at a time; the flat + // `subscripts` list can't tell the two apart, so the check walks the + // ordered access chain instead. + expectClean(` +TYPE Row : ARRAY[0..2] OF INT; END_TYPE +PROGRAM Main + VAR + a : ARRAY[0..1] OF Row; + dummy : INT; + END_VAR + dummy := a[0][1]; +END_PROGRAM +`); + }); + + it("accepts a subscript on an array field of a struct", () => { + expectClean(` +TYPE Buf : STRUCT data : ARRAY[0..1, 0..1] OF INT; END_STRUCT; END_TYPE +PROGRAM Main + VAR + b : Buf; + dummy : INT; + END_VAR + dummy := b.data[0,1]; +END_PROGRAM +`); + }); + + it("skips a variable-length array parameter, whose extent is unknown", () => { + expectClean(` +FUNCTION F : INT + VAR_INPUT v : ARRAY[*] OF INT; END_VAR + F := v[0]; +END_FUNCTION +`); + }); + + it("checks a global reached from a POU body", () => { + const errors = errorsFor(` +VAR_GLOBAL + g : ARRAY[0..1, 0..1] OF INT; +END_VAR +PROGRAM Main + VAR dummy : INT; END_VAR + dummy := g[0]; +END_PROGRAM +`); + expect(errors[0]).toContain("has 2 dimensions but is indexed with 1"); + }); + + it("checks a function block member and a method local", () => { + const errors = errorsFor(` +FUNCTION_BLOCK FB + VAR m : ARRAY[0..1, 0..1] OF INT; out : INT; END_VAR + METHOD Mth : INT + VAR n : ARRAY[0..2] OF INT; END_VAR + Mth := n[0,1]; + END_METHOD + out := m[0]; +END_FUNCTION_BLOCK +`); + expect(errors.some((e) => e.includes("'M' has 2 dimensions"))).toBe(true); + expect(errors.some((e) => e.includes("'N' has 1 dimension"))).toBe(true); + }); +}); + +describe("array shape validation: declarations everywhere", () => { + it("checks a file-level VAR_GLOBAL initializer", () => { + const errors = errorsFor(` +VAR_GLOBAL + g : ARRAY[0..1] OF INT := [1,2,3]; +END_VAR +`); + expect(errors[0]).toContain("has 3 values but the array holds 2"); + }); + + it("checks a CONFIGURATION VAR_GLOBAL initializer", () => { + const errors = errorsFor(` +PROGRAM Main + VAR dummy : INT; END_VAR + dummy := 0; +END_PROGRAM +CONFIGURATION Cfg + VAR_GLOBAL + g : ARRAY[0..1] OF INT := [1,2,3]; + END_VAR + RESOURCE Res ON PLC + TASK T(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM P WITH T : Main; + END_RESOURCE +END_CONFIGURATION +`); + expect(errors[0]).toContain("has 3 values but the array holds 2"); + }); + + it("checks a STRUCT element default", () => { + const errors = errorsFor(` +TYPE + Buf : STRUCT + data : ARRAY[0..1] OF INT := [1,2,3]; + END_STRUCT; +END_TYPE +`); + expect(errors[0]).toContain("has 3 values but the array holds 2"); + }); + + it("checks a FUNCTION_BLOCK member and a FUNCTION local", () => { + const errors = errorsFor(` +FUNCTION_BLOCK FB + VAR a : ARRAY[0..1] OF INT := [1,2,3]; out : INT; END_VAR + out := 0; +END_FUNCTION_BLOCK +FUNCTION F : INT + VAR b : ARRAY[0..1] OF INT := [4,5,6]; END_VAR + F := 0; +END_FUNCTION +`); + expect(errors).toHaveLength(2); + }); + + it("leaves a scalar initializer on an array alone", () => { + // Meaningful for a STRUCT element (value-initialises), so rejecting it here + // would flag working code. + expectClean(` +TYPE + Buf : STRUCT + data : ARRAY[0..3] OF INT := 0; + END_STRUCT; +END_TYPE +`); + }); +}); From 070ebf875001a09dd085e7a3c29f12a6fd0d4dd6 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 14 Aug 2026 17:29:50 -0400 Subject: [PATCH 08/13] fix(codegen,semantic): lower integer literals exactly, and reject misplaced structure initializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/IEC_COMPLIANCE.md | 3 +- src/backend/codegen-utils.ts | 40 +++ src/backend/codegen.ts | 42 +-- src/backend/test-codegen.ts | 13 + src/backend/test-main-gen.ts | 9 +- src/backend/type-codegen.ts | 11 +- src/literal-utils.ts | 44 ++++ src/semantic/analyzer.ts | 99 +++++++ .../codegen-integer-literal-exact.test.ts | 244 ++++++++++++++++++ tests/backend/test-main-gen.test.ts | 59 ++++- .../integer-literal-exact-cpp.test.ts | 170 ++++++++++++ .../struct-initializer-placement.test.ts | 237 +++++++++++++++++ 12 files changed, 946 insertions(+), 25 deletions(-) create mode 100644 src/literal-utils.ts create mode 100644 tests/backend/codegen-integer-literal-exact.test.ts create mode 100644 tests/integration/integer-literal-exact-cpp.test.ts create mode 100644 tests/semantic/struct-initializer-placement.test.ts diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index c6879047..273b7214 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -71,7 +71,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Initialization | Supported | `:= expression` | | Array initialization | Supported | `:= [1, 2, 3]` and the bracket-less `:= 1, 2, 3`. Multi-dimensional arrays take either a flat row-major list or a nested one (`:= [[1, 2], [3, 4]]`), where each inner list fills one row from its own bound. Nesting depth and value count are validated against the declared dimensions | | Array repetition | Supported | `:= [10(0)]`, `:= [3(1), 2(5)]`, `:= [7, 4(2), 9]`. The repeated value may be a structure initializer. Max count 65536 | -| Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default | +| Structure initialization | Supported | `:= (x := 1.0, y := 2.0)`; nested, in array literals, and for FB instances. Omitted elements keep their own declared default. Only valid as a declaration's initial value, as in the standard — one written inside a statement is rejected | | STRUCT element defaults | Supported | Scalar, array-literal and structure-initializer defaults on a STRUCT element all carry their values | ### Initialization gaps @@ -99,6 +99,7 @@ STruC++ implements the Structured Text (ST) language from IEC 61131-3. This docu | Array access | `arr[i]`, `arr[i, j]` | Supported — the index count is validated against the declared rank | | Field access | `struct.field` | Supported | | Typed literals | `INT#5`, `DINT#42`, `REAL#3.14` | Supported | +| Integer literals | `9223372036854775807`, `16#FF`, `1_000` | Supported — the full 64-bit LINT/ULINT range is preserved exactly; a value wider than ULINT is rejected | | NEW | `__NEW(type)`, `__NEW(type, size)` | Supported | | DELETE | `__DELETE(ptr)` | Supported | diff --git a/src/backend/codegen-utils.ts b/src/backend/codegen-utils.ts index 16363509..b9563008 100644 --- a/src/backend/codegen-utils.ts +++ b/src/backend/codegen-utils.ts @@ -4,6 +4,8 @@ * Shared utility functions for C++ code generation. */ +import { exactIntegerLiteralValue } from "../literal-utils.js"; + /** * Convert an IEC 61131-3 based numeric string to a C++ literal string. * Handles 16#FF → 0xFF, 8#77 → 077, 2#1010 → 0b1010, and plain decimals. @@ -17,6 +19,44 @@ export function iecBaseToCppLiteral(raw: string): string { return raw.replace(/_/g, ""); } +/** Largest value a C++ *decimal* literal can name without a suffix. */ +const CPP_SIGNED_LITERAL_MAX = 9223372036854775807n; + +/** + * Lower an IEC 61131-3 integer literal to a C++ integer literal. + * + * Based literals (16#FF, 8#77, 2#1010) keep their notation. Plain decimals are + * re-emitted from the *exact* value rather than from the parsed `number`, which + * matters twice over: + * + * - `number` rounds above 2^53, so a LINT/ULINT initializer such as + * `9007199254740993` would silently become ...992, and `ULINT` bounds would + * round past the type's range into a literal g++ rejects outright. + * - passing the raw digits straight through instead would make a leading zero + * an octal prefix in C++ (`0010` → 8, `008` → a compile error), so the + * digits are normalized rather than copied. + * + * A value above `CPP_SIGNED_LITERAL_MAX` gets a `ULL` suffix: a C++ decimal + * literal is only ever given a *signed* type (C++17 [lex.icon]/3), so without + * it `18446744073709551615` names no type at all. + * + * `value` is the pre-parsed fallback for a literal whose raw text is not a + * plain integer (synthesized nodes, for one). + */ +export function formatIntegerLiteral(rawValue: string, value: number): string { + const upper = rawValue.toUpperCase().replace(/_/g, ""); + if ( + upper.startsWith("16#") || + upper.startsWith("8#") || + upper.startsWith("2#") + ) { + return iecBaseToCppLiteral(rawValue); + } + const exact = exactIntegerLiteralValue(rawValue); + if (exact === undefined) return String(value); + return exact > CPP_SIGNED_LITERAL_MAX ? `${exact}ULL` : exact.toString(); +} + /** * Format an array type string from element type and dimension bounds. * diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index c951af7e..373fa7a3 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -58,6 +58,7 @@ import { isElementaryType, TypeRegistry } from "../semantic/type-registry.js"; import { TypeCodeGenerator, IEC_TO_CPP_VAR_TYPE } from "./type-codegen.js"; import { formatArrayType, + formatIntegerLiteral, iecBaseToCppLiteral, translateIECString, } from "./codegen-utils.js"; @@ -3611,12 +3612,19 @@ export class CodeGenerator { return `{${elements.join(", ")}}`; } case "StructInitializerExpression": - // A structure initializer needs the target's C++ type, which only the - // declaration site knows — see generateInitializer. Reaching here means - // one appeared where no type is available (it is not an expression IEC - // allows in a statement), so value-initialise rather than emit code that - // would not compile. - return "{}"; + // A structure initializer needs the target's C++ type, which only a + // declaration supplies — declarations route through + // `generateInitializer` instead. It is not an expression IEC allows in a + // statement either, and the analyzer rejects it there + // (`validateStructInitializerPlacement`), so this is unreachable for any + // unit that got past semantic analysis. Loud rather than silent: the + // previous `return "{}"` value-initialised, which discarded every + // element the initializer named and produced no diagnostic anywhere. + throw new Error( + `Internal error: structure initializer at ${expr.sourceSpan.startLine}:` + + `${expr.sourceSpan.startCol} reached expression codegen, where the ` + + `target type is unknown. It is only valid as a declaration's initial value.`, + ); } } @@ -3629,7 +3637,12 @@ export class CodeGenerator { const cppType = `IEC_${expr.typePrefix}`; const hashIdx = expr.rawValue.indexOf("#"); const valuePart = expr.rawValue.substring(hashIdx + 1); - const cppValue = iecBaseToCppLiteral(valuePart); + // An integer payload goes through the exact lowering too — `LINT#<64-bit>` + // must not round, and `INT#0010` must not become a C++ octal constant. + const cppValue = + expr.literalType === "INT" + ? formatIntegerLiteral(valuePart, expr.value as number) + : iecBaseToCppLiteral(valuePart); return `static_cast<${cppType}>(${cppValue})`; } @@ -3641,7 +3654,7 @@ export class CodeGenerator { ? "true" : "false"; case "INT": { - return this.formatIntegerLiteral(expr.rawValue, expr.value as number); + return formatIntegerLiteral(expr.rawValue, expr.value as number); } case "REAL": { const str = String(expr.value); @@ -3686,19 +3699,6 @@ export class CodeGenerator { } } - private formatIntegerLiteral(rawValue: string, value: number): string { - // Based literals (16#FF, 8#77, 2#1010) → C++ notation; plain decimals use numeric value - const upper = rawValue.toUpperCase().replace(/_/g, ""); - if ( - upper.startsWith("16#") || - upper.startsWith("8#") || - upper.startsWith("2#") - ) { - return iecBaseToCppLiteral(rawValue); - } - return String(value); - } - /** * Generate C++ for a variable expression. */ diff --git a/src/backend/test-codegen.ts b/src/backend/test-codegen.ts index 33abe160..53871c46 100644 --- a/src/backend/test-codegen.ts +++ b/src/backend/test-codegen.ts @@ -187,6 +187,19 @@ export class TestCodeGenerator extends CodeGenerator { return this.generateExpression(expr); } + /** + * Generate C++ for a declaration's initial value, which unlike a plain + * expression may be a structure initializer — that form needs the target's + * C++ type to lower (see `generateInitializer`). + */ + emitInitializer( + expr: Expression, + cppType: string, + stTypeName: string | undefined, + ): string { + return this.generateInitializer(expr, cppType, stTypeName); + } + /** Generate C++ statement(s) and append to output buffer. */ emitStatement(stmt: Statement, indent: string): void { this.generateStatement(stmt, indent); diff --git a/src/backend/test-main-gen.ts b/src/backend/test-main-gen.ts index 661cb32a..bf64f8bf 100644 --- a/src/backend/test-main-gen.ts +++ b/src/backend/test-main-gen.ts @@ -535,8 +535,15 @@ class TestFunctionGenerator { for (const name of decl.names) { if (decl.initialValue) { + // A SETUP/TEST var is a declaration, so `p : Point := (x := 1.0)` is the + // legal form and must go through the initializer lowering — the plain + // expression emitter has no target type and cannot lower it. lines.push( - `${this.indent}${cppType} ${name} = ${this.testCodegen.emitExpression(decl.initialValue)};`, + `${this.indent}${cppType} ${name} = ${this.testCodegen.emitInitializer( + decl.initialValue, + cppType, + decl.type.name, + )};`, ); } else { lines.push(`${this.indent}${cppType} ${name};`); diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index c03c5eb1..7a0f4c41 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -21,7 +21,11 @@ import type { UnaryExpression, } from "../frontend/ast.js"; import { TypeRegistry, isElementaryType } from "../semantic/type-registry.js"; -import { formatArrayType, translateIECString } from "./codegen-utils.js"; +import { + formatArrayType, + formatIntegerLiteral, + translateIECString, +} from "./codegen-utils.js"; import { parseDateLiteralToDays, parseDtLiteralToNs, @@ -571,6 +575,11 @@ export class TypeCodeGenerator { switch (expr.literalType) { case "BOOL": return expr.value === true ? "true" : "false"; + case "INT": + // Same lowering the expression path uses, so a STRUCT element default + // and the identical literal in a body can't disagree — and so a 64-bit + // default keeps every digit (`String(expr.value)` rounds above 2^53). + return formatIntegerLiteral(expr.rawValue, expr.value as number); case "STRING": { // IEC STRING literals carry their surrounding single quotes // in `rawValue` (`'wide hello'`); strip them before wrapping diff --git a/src/literal-utils.ts b/src/literal-utils.ts new file mode 100644 index 00000000..5c98e0eb --- /dev/null +++ b/src/literal-utils.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * Exact handling of IEC 61131-3 integer literals. + * + * LINT and ULINT span the full 64 bits, which a JS `number` cannot represent: + * `9007199254740993` parses as ...992, and `18446744073709551615` rounds past + * the type's own maximum. Anything that has to agree with the source digits — + * codegen's lowering, the analyzer's range check — works from the `bigint` here + * rather than from the parsed `number` on the AST node. + * + * Lives at the root rather than under `backend/` or `semantic/` because both + * layers need it and neither should depend on the other for it. + */ + +/** Widest value any IEC 61131-3 integer type (ULINT) can hold. */ +export const IEC_INTEGER_MAX = 18446744073709551615n; + +/** Narrowest value any IEC 61131-3 integer type (LINT) can hold. */ +export const IEC_INTEGER_MIN = -9223372036854775808n; + +/** + * Exact value of an IEC integer literal, or undefined when `raw` is not one. + * + * Accepts the based forms (16#FF, 8#77, 2#1010), a plain decimal, an optional + * sign, and IEC underscore separators. A typed prefix (`INT#5`) must be + * stripped by the caller — only the value part is parsed here. + */ +export function exactIntegerLiteralValue(raw: string): bigint | undefined { + const upper = raw.trim().toUpperCase().replace(/_/g, ""); + const sign = upper.startsWith("-") ? -1n : 1n; + const digits = /^[+-]/.test(upper) ? upper.slice(1) : upper; + let normalized: string; + if (/^16#[0-9A-F]+$/.test(digits)) normalized = "0x" + digits.slice(3); + else if (/^8#[0-7]+$/.test(digits)) normalized = "0o" + digits.slice(2); + else if (/^2#[01]+$/.test(digits)) normalized = "0b" + digits.slice(2); + else if (/^[0-9]+$/.test(digits)) normalized = digits; + else return undefined; + try { + return sign * BigInt(normalized); + } catch { + return undefined; + } +} diff --git a/src/semantic/analyzer.ts b/src/semantic/analyzer.ts index dbe14ffa..9a51ae97 100644 --- a/src/semantic/analyzer.ts +++ b/src/semantic/analyzer.ts @@ -17,8 +17,10 @@ import type { Expression, FunctionBlockDeclaration, FunctionCallExpression, + LiteralExpression, MethodDeclaration, MockFunctionStatement, + TypeDeclaration, TypeDefinition, TypeReference, VarBlock, @@ -53,6 +55,11 @@ import { stripEnEno, walkAST, } from "../ast-utils.js"; +import { + exactIntegerLiteralValue, + IEC_INTEGER_MAX, + IEC_INTEGER_MIN, +} from "../literal-utils.js"; // ============================================================================= // Located Variable Address Parsing @@ -684,6 +691,12 @@ export class SemanticAnalyzer { // Validate array initializer shape/size and subscript counts this.validateArrayShapes(ast); + // Validate that structure initializers only appear where IEC allows them + this.validateStructInitializerPlacement(ast); + + // Validate that integer literals fit an IEC integer type + this.validateIntegerLiteralRange(ast); + // TODO: Implement additional semantic validation // - Check CASE statement coverage // - Validate reference operations @@ -984,6 +997,92 @@ export class SemanticAnalyzer { } } + /** + * Reject a structure initializer written anywhere but a declaration's initial + * value. + * + * `structure_initialization` (Annex B.1.4.3) belongs to `var_init_decl`; it is + * not an expression, so IEC has no position for it inside a statement. The + * lowering needs the target's C++ type, which only a declaration supplies — + * reaching codegen without one used to value-initialise silently, so + * + * arr := [(x := 1.0), (x := 2.0)]; -> ARR = {{}, {}}; + * f(P := (x := 3.0)); -> F.P = {}; + * + * compiled clean and ran with every written element discarded, the members + * left at their declared defaults. Reported here instead, against the source. + * + * The walk prunes at every initial value a declaration can carry — a variable + * or STRUCT element's (`VarDeclaration.initialValue`) and a type-level default's + * (`TypeDeclaration.defaultValue`, Annex B.1.3.3) — so the legal forms, including + * a structure initializer nested inside an array literal, are never visited. + */ + private validateStructInitializerPlacement(ast: CompilationUnit): void { + // Identity set rather than a node-kind test: only the initializer's own root + // is legal, and pruning there covers everything beneath it. + const declarationInitializers = new Set(); + walkAST(ast, (node) => { + if (node.kind === "VarDeclaration") { + const decl = node as VarDeclaration; + if (decl.initialValue) declarationInitializers.add(decl.initialValue); + } else if (node.kind === "TypeDeclaration") { + const type = node as TypeDeclaration; + if (type.defaultValue) declarationInitializers.add(type.defaultValue); + } + }); + + walkAST(ast, (node) => { + if (declarationInitializers.has(node as Expression)) return false; + if (node.kind !== "StructInitializerExpression") return; + const span = node.sourceSpan; + this.addError( + "A structure initializer '(NAME := value, ...)' is only valid as a " + + "variable's initial value in a declaration, not inside a statement. " + + "Assign the elements individually instead.", + span.startLine, + span.startCol, + span.file, + ); + // One diagnostic per initializer, not one per nesting level. + return false; + }); + } + + /** + * Reject an integer literal that no IEC 61131-3 integer type can hold. + * + * The widest are LINT (signed 64-bit) and ULINT (unsigned 64-bit), so a value + * outside `[LINT_MIN, ULINT_MAX]` is a mistake against *every* declared type + * and can be reported without knowing which one it initialises — the same + * conservative rule the array-shape checks follow. In range but wrong for the + * specific type (`INT := 70000`) is left to the type checker. + * + * Checked on the exact value rather than the parsed `number`, which rounds + * above 2^53; codegen lowers from the same exact value (see + * `formatIntegerLiteral`), so the two agree on what is representable. + */ + private validateIntegerLiteralRange(ast: CompilationUnit): void { + walkAST(ast, (node) => { + if (node.kind !== "LiteralExpression") return; + const literal = node as LiteralExpression; + if (literal.literalType !== "INT") return; + const exact = exactIntegerLiteralValue(literal.rawValue); + if (exact === undefined) return; + // A negative literal parses as unary minus over a positive one, so the + // magnitude LINT_MIN needs the unsigned bound to stay accepted here. + if (exact <= IEC_INTEGER_MAX && exact >= IEC_INTEGER_MIN) return; + const span = literal.sourceSpan; + this.addError( + `Integer literal '${literal.rawValue}' is outside the range of every ` + + `IEC 61131-3 integer type (LINT holds ${IEC_INTEGER_MIN} to ` + + `${-IEC_INTEGER_MIN - 1n}, ULINT holds 0 to ${IEC_INTEGER_MAX}).`, + span.startLine, + span.startCol, + span.file, + ); + }); + } + /** * Validate that no assignments target CONSTANT variables. */ diff --git a/tests/backend/codegen-integer-literal-exact.test.ts b/tests/backend/codegen-integer-literal-exact.test.ts new file mode 100644 index 00000000..90765a08 --- /dev/null +++ b/tests/backend/codegen-integer-literal-exact.test.ts @@ -0,0 +1,244 @@ +/** + * Integer literals must lower to C++ with every digit intact. + * + * LINT and ULINT span the full 64 bits, which a JS `number` cannot hold. The + * lowering used to go through `String(expr.value)` over a `parseInt` result, so + * anything past 2^53 was rounded on the way out: + * + * x : LINT := 9007199254740993; -> X(9007199254740992) wrong value + * y : LINT := 9223372036854775807; -> Y(9223372036854776000) past INT64_MAX + * z : ULINT := 18446744073709551615; -> Z(18446744073709552000) g++ rejects it + * + * The first is silent, the last two break the C++ build — LINT/ULINT bounds are + * exactly the values most likely to be written as sentinels. + * + * Re-emitting the raw digits instead is not the fix: a leading zero is an octal + * prefix in C++, so `0010` would become 8 and `008` a compile error. The value + * is re-derived exactly (bigint) and normalized, and a value above INT64_MAX + * gets a `ULL` suffix because a C++ decimal literal is only ever given a signed + * type (C++17 [lex.icon]/3). + * + * One lowering serves every position — PROGRAM/VAR_GLOBAL initializers, STRUCT + * element defaults, and statement bodies — so a literal cannot mean one thing in + * a declaration and another in an assignment. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +function compileOk(source: string): { cpp: string; header: string } { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return { cpp: result.cppCode, header: result.headerCode }; +} + +/** The constructor initializer-list line for a program. */ +function initList(cpp: string): string { + return cpp.split("\n").find((l) => l.trimStart().startsWith(": ")) ?? ""; +} + +describe("64-bit integer literals keep every digit", () => { + it("keeps a PROGRAM VAR initializer above 2^53 exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := 9007199254740993; END_VAR + x := x; + END_PROGRAM + `); + expect(initList(cpp)).toContain("X(9007199254740993)"); + }); + + it("keeps LINT_MAX and suffixes ULINT_MAX so C++ can name the type", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + y : LINT := 9223372036854775807; + z : ULINT := 18446744073709551615; + END_VAR + y := y; + END_PROGRAM + `); + const inits = initList(cpp); + expect(inits).toContain("Y(9223372036854775807)"); + // Unsuffixed, this decimal names no C++ type at all. + expect(inits).toContain("Z(18446744073709551615ULL)"); + }); + + it("keeps a file-level VAR_GLOBAL definition exact", () => { + const { header } = compileOk(` + VAR_GLOBAL + g : LINT := 9007199254740993; + u : ULINT := 18446744073709551615; + END_VAR + PROGRAM Main + VAR_EXTERNAL g : LINT; END_VAR + g := g; + END_PROGRAM + `); + expect(header).toContain("IEC_LINT G = 9007199254740993;"); + expect(header).toContain("IEC_ULINT U = 18446744073709551615ULL;"); + }); + + it("keeps a STRUCT element default exact", () => { + const { header } = compileOk(` + TYPE + Big : STRUCT + a : LINT := 9007199254740993; + b : ULINT := 18446744073709551615; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR s : Big; END_VAR + s.a := s.a; + END_PROGRAM + `); + expect(header).toContain("IEC_LINT A = 9007199254740993;"); + expect(header).toContain("IEC_ULINT B = 18446744073709551615ULL;"); + }); + + it("keeps a statement-body literal exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT; END_VAR + x := 9007199254740993; + END_PROGRAM + `); + expect(cpp).toContain("X = 9007199254740993;"); + }); + + it("agrees between the declaration and the statement path", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := 9007199254740993; END_VAR + x := 9007199254740993; + END_PROGRAM + `); + expect(initList(cpp)).toContain("X(9007199254740993)"); + expect(cpp).toContain("X = 9007199254740993;"); + }); + + it("keeps a typed-prefix 64-bit literal exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := LINT#9007199254740993; END_VAR + x := x; + END_PROGRAM + `); + expect(initList(cpp)).toContain( + "X(static_cast(9007199254740993))", + ); + }); + + it("leaves 64-bit based literals in their own notation", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR h : ULINT := 16#FFFFFFFFFFFFFFFF; END_VAR + h := h; + END_PROGRAM + `); + // A hex literal may take an unsigned type on its own, so no suffix is needed. + expect(initList(cpp)).toContain("H(0xFFFFFFFFFFFFFFFF)"); + }); +}); + +describe("decimal literals are normalized, never copied raw", () => { + it("does not turn a leading zero into a C++ octal constant", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + a : INT := 007; + b : INT := 0010; + c : INT := 008; + END_VAR + a := a; + END_PROGRAM + `); + const inits = initList(cpp); + // Raw passthrough would give 0010 (octal 8) and 008 (a g++ error). + expect(inits).toContain("A(7)"); + expect(inits).toContain("B(10)"); + expect(inits).toContain("C(8)"); + }); + + it("strips IEC digit separators", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR n : DINT := 1_000_000; END_VAR + n := n; + END_PROGRAM + `); + expect(initList(cpp)).toContain("N(1000000)"); + }); + + it("still lowers based and typed literals as before", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + h : UDINT := 16#FF; + o : UDINT := 8#17; + b : UDINT := 2#1010; + t : INT := INT#5; + END_VAR + h := h; + END_PROGRAM + `); + const inits = initList(cpp); + expect(inits).toContain("H(0xFF)"); + expect(inits).toContain("O(017)"); + expect(inits).toContain("B(0b1010)"); + expect(inits).toContain("T(static_cast(5))"); + }); +}); + +describe("integer literals outside every IEC integer type", () => { + it("rejects a value wider than ULINT", () => { + const result = compile(` + PROGRAM Main + VAR a : ULINT := 99999999999999999999999; END_VAR + a := a; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(result.errors[0]!.message).toContain( + "outside the range of every IEC 61131-3 integer type", + ); + }); + + it("rejects one in a statement body too", () => { + const result = compile(` + PROGRAM Main + VAR a : ULINT; END_VAR + a := 99999999999999999999999; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(result.errors[0]!.message).toContain( + "outside the range of every IEC 61131-3 integer type", + ); + }); + + it("accepts both 64-bit bounds, and LINT_MIN written with a sign", () => { + // LINT_MIN parses as unary minus over 9223372036854775808, whose magnitude + // exceeds LINT_MAX — the check has to allow it or it would flag valid code. + compileOk(` + PROGRAM Main + VAR + lo : LINT := -9223372036854775808; + hi : LINT := 9223372036854775807; + u : ULINT := 18446744073709551615; + END_VAR + lo := lo; + END_PROGRAM + `); + }); + + it("accepts the widest based literal", () => { + compileOk(` + PROGRAM Main + VAR h : ULINT := 16#FFFFFFFFFFFFFFFF; END_VAR + h := h; + END_PROGRAM + `); + }); +}); diff --git a/tests/backend/test-main-gen.test.ts b/tests/backend/test-main-gen.test.ts index a2c7cf19..7e21f527 100644 --- a/tests/backend/test-main-gen.test.ts +++ b/tests/backend/test-main-gen.test.ts @@ -5,8 +5,13 @@ */ import { describe, it, expect } from "vitest"; -import { generateTestMain } from "../../src/backend/test-main-gen.js"; +import { + generateTestMain, + buildPOUInfoFromAST, +} from "../../src/backend/test-main-gen.js"; import type { POUInfo } from "../../src/backend/test-main-gen.js"; +import { compile } from "../../src/index.js"; +import { parseTestFile } from "../../src/testing/test-parser.js"; import type { TestFile } from "../../src/testing/test-model.js"; /** Helper to create a basic POUInfo for a program */ @@ -871,3 +876,55 @@ describe("Test Main Generator", () => { }); }); }); + +describe("declaration initializers in a TEST var block", () => { + /** Compile ST for its types, then generate a test main against a parsed .stst. */ + function generateFor(stSource: string, testSource: string): string { + const compiled = compile(stSource); + expect(compiled.errors.map((e) => e.message)).toEqual([]); + const parsed = parseTestFile(testSource, "t.stst"); + expect(parsed.errors).toEqual([]); + const { pous } = buildPOUInfoFromAST(compiled.ast!); + return generateTestMain([parsed.testFile!], { + headerFileName: "generated.hpp", + pous, + ast: compiled.ast!, + isTestBuild: true, + }); + } + + const PROGRAM = ` + TYPE + Point : STRUCT + x : REAL := 9.0; + y : REAL := 8.0; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR n : INT; END_VAR + n := n + 1; + END_PROGRAM + `; + + it("lowers a structure initializer instead of value-initialising it", () => { + // A TEST var is a declaration, so this form is legal here. It used to go + // through the plain expression emitter, which has no target type and + // emitted `POINT P = {};` — every named element silently discarded. + const code = generateFor( + PROGRAM, + `TEST 'local'\n VAR p : Point := (x := 1.5); END_VAR\n ASSERT_TRUE(TRUE);\nEND_TEST\n`, + ); + expect(code).toContain( + "POINT P = strucpp::iec_struct_init([](auto& v0) { v0.X = 1.5; });", + ); + expect(code).not.toContain("POINT P = {};"); + }); + + it("still emits an ordinary scalar initializer unchanged", () => { + const code = generateFor( + PROGRAM, + `TEST 'local'\n VAR k : INT := 5; END_VAR\n ASSERT_TRUE(TRUE);\nEND_TEST\n`, + ); + expect(code).toContain("IEC_INT K = 5;"); + }); +}); diff --git a/tests/integration/integer-literal-exact-cpp.test.ts b/tests/integration/integer-literal-exact-cpp.test.ts new file mode 100644 index 00000000..ea751b0b --- /dev/null +++ b/tests/integration/integer-literal-exact-cpp.test.ts @@ -0,0 +1,170 @@ +/** + * End-to-end proof that 64-bit integer literals survive the whole pipeline: the + * generated C++ must compile with g++ -std=c++17 AND print back the digits the + * ST source wrote. + * + * Compiling is not sufficient evidence here. `9007199254740993` lowered to + * `9007199254740992` compiles perfectly — the defect is only visible by running + * the binary and comparing values. The two bounds are the other half: they used + * to lower to a decimal past INT64_MAX / UINT64_MAX, which g++ warns on or + * rejects outright, so "it builds" is itself part of the assertion. + * + * -Werror is deliberate: `18446744073709551615` without the `ULL` suffix is a + * GCC extension that warns rather than fails, and a warning is exactly the + * failure mode this test exists to catch. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { compile } from "../../src/index.js"; +import { hasGpp, createPCH, compileAndRunStandalone } from "./test-helpers.js"; + +const describeIfGpp = hasGpp ? describe : describe.skip; + +describeIfGpp("64-bit integer literals — generated C++", () => { + let tempDir: string; + let pchPath: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-intlit-")); + pchPath = createPCH(tempDir); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function run(source: string, mainBody: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return compileAndRunStandalone({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName, + // An unsuffixed `18446744073709551615` is a GCC *extension* that warns + // rather than fails, so the warning has to be the failure here. + extraFlags: ["-Werror=implicitly-unsigned-literal", "-Werror=overflow"], + mainCode: `#include \n\nint main() {\n using namespace strucpp;\n${mainBody}\n return 0;\n}\n`, + }); + } + + it("round-trips a VAR_GLOBAL value above 2^53", () => { + const output = run( + ` + VAR_GLOBAL + big : LINT := 9007199254740993; + END_VAR + `, + ` std::cout << BIG << std::endl;`, + "intlit_global_2p53", + ); + // Rounded through a double this prints ...992. + expect(output).toBe("9007199254740993"); + }); + + it("round-trips both 64-bit bounds", () => { + const output = run( + ` + VAR_GLOBAL + hi : LINT := 9223372036854775807; + lo : LINT := -9223372036854775808; + u : ULINT := 18446744073709551615; + END_VAR + `, + ` std::cout << HI << " " << LO << " " << U << std::endl;`, + "intlit_global_bounds", + ); + expect(output).toBe( + "9223372036854775807 -9223372036854775808 18446744073709551615", + ); + }); + + it("round-trips a PROGRAM variable initializer", () => { + const output = run( + ` + PROGRAM Main + VAR + x : LINT := 9007199254740993; + u : ULINT := 18446744073709551615; + END_VAR + x := x; + END_PROGRAM + `, + ` Program_MAIN p;\n std::cout << p.X.get() << " " << p.U.get() << std::endl;`, + "intlit_program", + ); + expect(output).toBe("9007199254740993 18446744073709551615"); + }); + + it("round-trips a STRUCT element default", () => { + const output = run( + ` + TYPE + Big : STRUCT + a : LINT := 9007199254740993; + b : ULINT := 18446744073709551615; + END_STRUCT; + END_TYPE + VAR_GLOBAL + s : Big; + END_VAR + `, + ` std::cout << S.A << " " << S.B << std::endl;`, + "intlit_struct_default", + ); + expect(output).toBe("9007199254740993 18446744073709551615"); + }); + + it("agrees between a declaration initializer and the same literal assigned in a body", () => { + const output = run( + ` + PROGRAM Main + VAR + declared : LINT := 9007199254740993; + assigned : LINT; + END_VAR + assigned := 9007199254740993; + END_PROGRAM + `, + ` Program_MAIN p;\n p.run();\n std::cout << (p.DECLARED.get() == p.ASSIGNED.get() ? "same" : "differ") << " " << p.ASSIGNED.get() << std::endl;`, + "intlit_decl_vs_body", + ); + expect(output).toBe("same 9007199254740993"); + }); + + it("round-trips the widest based literal", () => { + const output = run( + ` + VAR_GLOBAL + h : ULINT := 16#FFFFFFFFFFFFFFFF; + END_VAR + `, + ` std::cout << H << std::endl;`, + "intlit_based_max", + ); + expect(output).toBe("18446744073709551615"); + }); + + it("does not read a leading-zero decimal as octal", () => { + const output = run( + ` + VAR_GLOBAL + a : INT := 007; + b : INT := 0010; + c : INT := 008; + END_VAR + `, + ` std::cout << A << " " << B << " " << C << std::endl;`, + "intlit_leading_zero", + ); + // Raw digits would make B octal 8, and C would not compile at all. + expect(output).toBe("7 10 8"); + }); +}); diff --git a/tests/semantic/struct-initializer-placement.test.ts b/tests/semantic/struct-initializer-placement.test.ts new file mode 100644 index 00000000..71e4b78b --- /dev/null +++ b/tests/semantic/struct-initializer-placement.test.ts @@ -0,0 +1,237 @@ +/** + * A structure initializer `(NAME := value, ...)` is `structure_initialization` + * (Annex B.1.4.3), part of `var_init_decl` — not an expression. IEC therefore + * has no position for it inside a statement. + * + * It used to reach codegen from three real statement positions, where the + * target's C++ type is unknown and the emitter value-initialised instead: + * + * arr := [(x := 1.0), (x := 2.0)]; -> ARR = {{}, {}}; + * f(P := (x := 3.0)); -> F.P = {}; + * s := (x := 1.0); -> S = {}; + * + * Each compiled clean, produced no diagnostic, and ran with every written + * element discarded — the members left at whatever their own declarations + * defaulted to. Now reported against the source. + * + * The accepted half is the point of the check: every position where IEC *does* + * allow the form has to keep working, or this blocks valid code. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +const POINT = ` +TYPE + Point : STRUCT + x : REAL := 9.0; + y : REAL := 8.0; + END_STRUCT; +END_TYPE +`; + +function errorsFor(source: string): string[] { + return compile(source).errors.map((e) => e.message); +} + +function expectClean(source: string): void { + expect(errorsFor(source)).toEqual([]); +} + +/** The one diagnostic this check emits. */ +const PLACEMENT = "only valid as a variable's initial value in a declaration"; + +describe("structure initializer placement — rejected in statements", () => { + it("rejects a structure initializer assigned to a variable", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR p : Point; END_VAR + p := (x := 1.0); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("rejects structure initializers inside an array literal in a statement", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR arr : ARRAY[0..1] OF Point; END_VAR + arr := [(x := 1.0), (x := 2.0)]; + END_PROGRAM + `); + // One per initializer — they are two separate mistakes. + expect(errors).toHaveLength(2); + expect(errors[0]).toContain(PLACEMENT); + expect(errors[1]).toContain(PLACEMENT); + }); + + it("rejects a structure initializer passed as a named FB argument", () => { + const errors = errorsFor(` + ${POINT} + FUNCTION_BLOCK Sink + VAR_INPUT p : Point; END_VAR + p.x := p.x; + END_FUNCTION_BLOCK + PROGRAM Main + VAR s : Sink; END_VAR + s(p := (x := 3.0)); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("reports one diagnostic for a nested structure initializer, not one per level", () => { + const errors = errorsFor(` + TYPE + Inner : STRUCT v : REAL := 1.0; END_STRUCT; + Outer : STRUCT i : Inner; END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer; END_VAR + o := (i := (v := 2.0)); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + }); + + it("reports the line and column of the initializer", () => { + const result = compile( + `${POINT} +PROGRAM Main + VAR p : Point; END_VAR + p := (x := 1.0); +END_PROGRAM +`, + ); + const err = result.errors[0]!; + expect(err.message).toContain(PLACEMENT); + // Points at the `(` that opens the initializer, not at the statement. + expect(err.line).toBe(11); + expect(err.column).toBe(8); + }); + + it("rejects one inside a control-flow body", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR p : Point; c : BOOL; END_VAR + IF c THEN + p := (x := 1.0); + END_IF; + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("rejects one in a function block body and in a method body", () => { + const errors = errorsFor(` + ${POINT} + FUNCTION_BLOCK Holder + VAR p : Point; END_VAR + METHOD Reset + p := (x := 5.0); + END_METHOD + p := (x := 1.0); + END_FUNCTION_BLOCK + PROGRAM Main + VAR h : Holder; END_VAR + h(); + END_PROGRAM + `); + expect(errors).toHaveLength(2); + for (const e of errors) expect(e).toContain(PLACEMENT); + }); +}); + +describe("structure initializer placement — accepted in declarations", () => { + it("accepts a PROGRAM variable's initial value", () => { + expectClean(` + ${POINT} + PROGRAM Main + VAR p : Point := (x := 1.0, y := 2.0); END_VAR + p.x := p.y; + END_PROGRAM + `); + }); + + it("accepts structure initializers nested in an array literal initial value", () => { + expectClean(` + ${POINT} + PROGRAM Main + VAR arr : ARRAY[0..1] OF Point := [(x := 1.0), (x := 2.0)]; END_VAR + arr[0].x := arr[1].y; + END_PROGRAM + `); + }); + + it("accepts a STRUCT element default", () => { + expectClean(` + TYPE + Point : STRUCT x : REAL := 9.0; END_STRUCT; + Outer : STRUCT p : Point := (x := 5.0); END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer; END_VAR + o.p.x := o.p.x; + END_PROGRAM + `); + }); + + it("accepts a type-level default (Annex B.1.3.3)", () => { + expectClean(` + ${POINT} + TYPE + Origin : Point := (x := 0.0, y := 0.0); + END_TYPE + PROGRAM Main + VAR p : Origin; END_VAR + p.x := p.y; + END_PROGRAM + `); + }); + + it("accepts a file-level VAR_GLOBAL initial value", () => { + expectClean(` + ${POINT} + VAR_GLOBAL + origin : Point := (x := 1.0, y := 2.0); + END_VAR + PROGRAM Main + VAR_EXTERNAL origin : Point; END_VAR + origin.x := origin.y; + END_PROGRAM + `); + }); + + it("accepts a FUNCTION_BLOCK member and a METHOD local initial value", () => { + expectClean(` + ${POINT} + FUNCTION_BLOCK Holder + VAR p : Point := (x := 1.0); END_VAR + METHOD Reset + VAR q : Point := (y := 2.0); END_VAR + p.x := q.y; + END_METHOD + p.x := p.y; + END_FUNCTION_BLOCK + PROGRAM Main + VAR h : Holder; END_VAR + h(); + END_PROGRAM + `); + }); + + it("leaves an ordinary parenthesized expression alone", () => { + expectClean(` + PROGRAM Main + VAR a : INT; b : INT; END_VAR + a := (b + 1) * 2; + END_PROGRAM + `); + }); +}); From 585e5c699cc67bd04477bf746333a4c8b2fc0334 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Sat, 15 Aug 2026 00:48:32 -0400 Subject: [PATCH 09/13] fix(debug-table): mangle a member whose name matches its type, as codegen does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/backend/debug-table-gen.ts | 23 +++++++++++- tests/backend/debug-table-gen.test.ts | 54 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/backend/debug-table-gen.ts b/src/backend/debug-table-gen.ts index 3773649f..1dcc2877 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -456,6 +456,27 @@ export function generateDebugTable( } }; + /** + * C++ member name for a declaration, mirroring codegen's collision mangling. + * + * A variable whose name matches its own type (`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 those members by name, so it has to mangle identically — otherwise + * every entry for that instance names a member that does not exist and the generated + * `generated_debug.cpp` fails to compile, taking the whole build with it. + */ + const memberCppName = ( + varName: string, + typeRef: TypeReference | undefined, + ): string => { + const typeName = typeRef?.name; + return typeof typeName === "string" && + varName.toUpperCase() === typeName.toUpperCase() + ? `${varName}_` + : varName; + }; + const visitVarDecl = ( path: string, cppExpr: string, @@ -464,7 +485,7 @@ export function generateDebugTable( for (const varName of decl.names) { visitTypeRef( `${path}.${varName.toUpperCase()}`, - `${cppExpr}.${varName}`, + `${cppExpr}.${memberCppName(varName, decl.type)}`, decl.type, ); } diff --git a/tests/backend/debug-table-gen.test.ts b/tests/backend/debug-table-gen.test.ts index 62e19fea..8c3a0a0d 100644 --- a/tests/backend/debug-table-gen.test.ts +++ b/tests/backend/debug-table-gen.test.ts @@ -380,3 +380,57 @@ END_CONFIGURATION }); }); }); + +describe("member whose name matches its type", () => { + /** + * CODESYS allows `RunningLights : RunningLights`, and real projects use it. Codegen + * emits that member as `RUNNINGLIGHTS_` because GCC rejects a member that changes the + * meaning of its own type name — so the debug table has to address it by the same + * name. When it did not, every entry for the instance named a member that does not + * exist and `generated_debug.cpp` failed to compile, taking the whole build with it. + */ + const src = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK + +PROGRAM Main +VAR + Motor : Motor; + plain : BOOL; +END_VAR + Motor(run := plain); +END_PROGRAM + +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + + it("addresses it by the mangled name codegen emitted", () => { + const result = compile(src); + expect(result.success).toBe(true); + + const cpp = result.debugTableCpp ?? ""; + // The declaration codegen produced, and the reference the table must match. + expect(result.headerCode ?? "").toContain("MOTOR MOTOR_;"); + expect(cpp).toContain("g_config.INSTANCE0.MOTOR_.RUN"); + // Only the C++ expression is mangled; the trailing comment keeps the ST path the + // editor shows the user. + const addresses = cpp + .split("\n") + .map((line) => line.split("//")[0]) + .join("\n"); + expect(addresses).not.toMatch(/INSTANCE0\.MOTOR\./); + }); + + it("leaves a member whose name differs from its type alone", () => { + const cpp = compile(src).debugTableCpp ?? ""; + expect(cpp).toContain("g_config.INSTANCE0.PLAIN"); + expect(cpp).not.toContain("PLAIN_"); + }); +}); From 67cb04056419add971221028dca6ce658deb03e9 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 17 Aug 2026 16:59:59 -0400 Subject: [PATCH 10/13] fix(backend): one member-mangling rule, applied everywhere a member is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/backend/codegen.ts | 55 +++-- src/backend/debug-table-gen.ts | 104 ++++++++-- src/backend/member-mangling.ts | 110 ++++++++++ src/backend/repl-main-gen.ts | 25 ++- src/backend/test-codegen.ts | 22 +- src/backend/type-codegen.ts | 24 ++- tests/backend/debug-table-gen.test.ts | 154 ++++++++++++++ tests/backend/repl-main-gen.test.ts | 81 ++++++++ tests/integration/debug-table-cpp.test.ts | 237 ++++++++++++++++++++++ tests/integration/repl-runner.test.ts | 48 +++++ 10 files changed, 783 insertions(+), 77 deletions(-) create mode 100644 src/backend/member-mangling.ts create mode 100644 tests/integration/debug-table-cpp.test.ts diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index 245e5e95..87406352 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -55,6 +55,7 @@ import { import { isElementaryType, TypeRegistry } from "../semantic/type-registry.js"; import { TypeCodeGenerator, IEC_TO_CPP_VAR_TYPE } from "./type-codegen.js"; import { formatArrayType, iecBaseToCppLiteral } from "./codegen-utils.js"; +import { mangledMemberName, needsMemberMangling } from "./member-mangling.js"; import { getTypeBits, getTypeCategory, @@ -1152,6 +1153,9 @@ export class CodeGenerator { indent: this.options.indent, lineEnding: this.options.lineEnding, emitChunkMarkers: this.options.emitChunkMarkers ?? false, + // Struct fields must mangle by the same rule as everything else that + // names them, and only codegen knows the FB / program type names. + isUserDefinedType: (t) => this.isUserDefinedType(t), }); const typeCode = typeCodeGen.generateFromRegistry(typeRegistry); for (const line of typeCode.split(this.options.lineEnding)) { @@ -5493,50 +5497,37 @@ export class CodeGenerator { _cppType: string, stTypeName: string, ): string { - // Variable name vs type name collision (GCC -Wchanges-meaning) - if (this.isUserDefinedType(stTypeName)) { - if (name.toUpperCase() === stTypeName.toUpperCase()) { - const mangled = `${name}_`; - this.memberMangledNames.set(name.toUpperCase(), mangled); - return mangled; - } - } - // Variable name vs interface method name collision - if (this.currentFBInterfaceMethods.has(name.toUpperCase())) { - const mangled = `${name}_`; + // Declaring a member of the FB currently being generated, so the interface + // methods in scope are that FB's. + const mangled = mangledMemberName(name, stTypeName, { + isUserDefinedType: (t) => this.isUserDefinedType(t), + interfaceMethods: this.currentFBInterfaceMethods, + }); + if (mangled !== name) { this.memberMangledNames.set(name.toUpperCase(), mangled); - return mangled; } - return name; + return mangled; } /** * Check if a field access needs mangling — true when the field name collides * with its type name (GCC -Wchanges-meaning) or with an interface method name. + * + * Reaching a member through a named owner rather than from inside it, so the + * interface methods come from that owner's entry. */ - private needsFieldMangling( + protected needsFieldMangling( fieldName: string, fieldTypeName: string | undefined, parentTypeName?: string, ): boolean { - // Field name vs type name collision - if ( - fieldTypeName && - this.isUserDefinedType(fieldTypeName) && - fieldName.toUpperCase() === fieldTypeName.toUpperCase() - ) { - return true; - } - // Field name vs interface method name collision - if (parentTypeName) { - const ifaceMethods = this.fbInterfaceMethodNames.get( - parentTypeName.toUpperCase(), - ); - if (ifaceMethods?.has(fieldName.toUpperCase())) { - return true; - } - } - return false; + return needsMemberMangling(fieldName, fieldTypeName, { + isUserDefinedType: (t) => this.isUserDefinedType(t), + interfaceMethods: + parentTypeName !== undefined + ? this.fbInterfaceMethodNames.get(parentTypeName.toUpperCase()) + : undefined, + }); } /** diff --git a/src/backend/debug-table-gen.ts b/src/backend/debug-table-gen.ts index 1dcc2877..98376422 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -29,6 +29,8 @@ import type { } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { SymbolTables } from "../semantic/symbol-table.js"; +import { isElementaryType } from "../semantic/type-registry.js"; +import { mangledMemberName } from "./member-mangling.js"; // --------------------------------------------------------------------------- // Type tags — MUST match TypeTag enum in runtime/include/debug_dispatch.hpp. @@ -221,6 +223,59 @@ export function generateDebugTable( const programByName = new Map(); for (const p of ast.programs) programByName.set(p.name.toUpperCase(), p); + // --- Inputs to the shared member-mangling rule (see member-mangling.ts) ---- + // The table addresses members by the name codegen declared them under, so + // both predicates have to resolve the same way codegen's do. + + const interfaceNames = new Set( + ast.interfaces.map((i) => i.name.toUpperCase()), + ); + + /** + * Mirrors `CodeGenerator.isUserDefinedType`: a function block, interface, + * STRUCT/UDT, or program. Elementary types are excluded explicitly — codegen + * leaves `Time : TIME` unmangled, so mangling it here would name a member + * that does not exist. + */ + const isUserDefinedType = (typeName: string): boolean => { + const upper = typeName.toUpperCase(); + if (isElementaryType(upper)) return false; + return ( + symbolTables.lookupType(upper) !== undefined || + symbolTables.lookupFunctionBlock(upper) !== undefined || + interfaceNames.has(upper) || + programByName.has(upper) + ); + }; + + /** + * FB type name → upper-cased method names of every interface it implements, + * mirroring `CodeGenerator.fbInterfaceMethodNames`. Directly implemented + * interfaces only, which is what codegen consults. + */ + const fbInterfaceMethods = new Map>(); + { + const methodsByInterface = new Map>(); + for (const iface of ast.interfaces) { + methodsByInterface.set( + iface.name.toUpperCase(), + new Set(iface.methods.map((m) => m.name.toUpperCase())), + ); + } + for (const fb of ast.functionBlocks) { + if (!fb.implements || fb.implements.length === 0) continue; + const methods = new Set(); + for (const ifaceName of fb.implements) { + for (const m of methodsByInterface.get(ifaceName.toUpperCase()) ?? []) { + methods.add(m); + } + } + if (methods.size > 0) { + fbInterfaceMethods.set(fb.name.toUpperCase(), methods); + } + } + } + // Buckets of entries — grown in order, flushed at program boundary or size cap. const arrays: Entry[][] = [[]]; const leaves: DebugLeaf[] = []; @@ -376,11 +431,13 @@ export function generateDebugTable( ...fbSym.outputs, ...fbSym.inouts, ]; + // `name` is the FB type declaring these members, so it is the owner for + // both mangling collisions. if (interfaceVars.length > 0) { for (const v of interfaceVars) { visitTypeRef( `${path}.${v.name.toUpperCase()}`, - `${cppExpr}.${v.name}`, + `${cppExpr}.${memberCppName(v.name, v.declaration.type, name)}`, v.declaration.type, ); } @@ -396,7 +453,7 @@ export function generateDebugTable( for (const fieldName of fieldDecl.names) { visitTypeRef( `${path}.${fieldName.toUpperCase()}`, - `${cppExpr}.${fieldName}`, + `${cppExpr}.${memberCppName(fieldName, fieldDecl.type, name)}`, fieldDecl.type, ); } @@ -419,7 +476,9 @@ export function generateDebugTable( for (const fieldName of fieldDecl.names) { visitTypeRef( `${path}.${fieldName.toUpperCase()}`, - `${cppExpr}.${fieldName}`, + // No owner: a STRUCT implements no interfaces, so only the + // field-name-matches-its-type collision can apply. + `${cppExpr}.${memberCppName(fieldName, fieldDecl.type)}`, fieldDecl.type, ); } @@ -457,35 +516,44 @@ export function generateDebugTable( }; /** - * C++ member name for a declaration, mirroring codegen's collision mangling. + * C++ member name for a declaration, by the same rule codegen used to emit it + * (see `member-mangling.ts`). * - * A variable whose name matches its own type (`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 those members by name, so it has to mangle identically — otherwise - * every entry for that instance names a member that does not exist and the generated - * `generated_debug.cpp` fails to compile, taking the whole build with it. + * The table addresses members by name, so it has to agree with the class + * definition exactly, in *both* directions. Mangling too little named a member + * that does not exist (`RunningLights : RunningLights` is declared + * `RUNNINGLIGHTS_`); mangling too much would do the same in reverse, since + * `Time : TIME` is declared plain `TIME`. Either way `generated_debug.cpp` + * fails to compile and takes the whole firmware build with it — and nothing + * catches it earlier, because `strucpp file.st` emits no debug table. + * + * `ownerTypeName` is the type declaring the member, needed for the + * interface-method collision; undefined for a PROGRAM or a STRUCT, neither of + * which can implement an interface. */ const memberCppName = ( varName: string, typeRef: TypeReference | undefined, - ): string => { - const typeName = typeRef?.name; - return typeof typeName === "string" && - varName.toUpperCase() === typeName.toUpperCase() - ? `${varName}_` - : varName; - }; + ownerTypeName?: string, + ): string => + mangledMemberName(varName, typeRef?.name, { + isUserDefinedType, + interfaceMethods: + ownerTypeName !== undefined + ? fbInterfaceMethods.get(ownerTypeName.toUpperCase()) + : undefined, + }); const visitVarDecl = ( path: string, cppExpr: string, decl: VarDeclaration, + ownerTypeName?: string, ): void => { for (const varName of decl.names) { visitTypeRef( `${path}.${varName.toUpperCase()}`, - `${cppExpr}.${memberCppName(varName, decl.type)}`, + `${cppExpr}.${memberCppName(varName, decl.type, ownerTypeName)}`, decl.type, ); } diff --git a/src/backend/member-mangling.ts b/src/backend/member-mangling.ts new file mode 100644 index 00000000..c48837d8 --- /dev/null +++ b/src/backend/member-mangling.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * The one rule for renaming a C++ member whose ST name would not survive + * translation. + * + * Two collisions force a trailing underscore: + * + * 1. **The member's name matches its own type's name.** CODESYS allows + * `RunningLights : RunningLights` and real projects use it, but GCC rejects + * a member that changes the meaning of its type name within the class + * (`-Wchanges-meaning`), so it is emitted as `RUNNINGLIGHTS_`. + * + * 2. **The member's name matches an interface method the owning FB + * implements.** `VAR Start : BOOL` inside a `FUNCTION_BLOCK ... IMPLEMENTS + * IMotor` that declares `METHOD Start` would otherwise redeclare the method + * as a data member. + * + * Every emitter that writes or addresses a member has to agree, because they all + * name the same C++ entity: the class definition (`codegen` / `type-codegen`), + * body expressions (`codegen` / `test-codegen`), and the debugger's pointer + * table (`debug-table-gen`). The rule previously existed as five copies with + * three different conditions; the debug table drifting from the class definition + * broke the build of any project using the pattern, and only in a full firmware + * build, since `strucpp file.st` emits no debug table. + * + * Callers differ in what they can resolve, so both inputs arrive through + * {@link MemberManglingContext} rather than being computed here. + */ + +import type { CompilationUnit } from "../frontend/ast.js"; + +/** + * Upper-cased names of every user-defined type in a compilation unit — function + * blocks, interfaces, TYPE declarations, and programs, matching what + * `CodeGenerator.isUserDefinedType` recognises. + * + * For emitters that have only the AST. It cannot see library-declared types, so + * anything holding symbol tables should consult those as well; no elementary + * name can appear here, since redeclaring one is an error. + */ +export function userDefinedTypeNames(ast: CompilationUnit): Set { + const names = new Set(); + for (const fb of ast.functionBlocks) names.add(fb.name.toUpperCase()); + for (const iface of ast.interfaces) names.add(iface.name.toUpperCase()); + for (const type of ast.types) names.add(type.name.toUpperCase()); + for (const prog of ast.programs) names.add(prog.name.toUpperCase()); + return names; +} + +/** + * What the rule needs to know, supplied by each emitter from whatever it has: + * codegen from its `known*Types` sets, the debug table from the symbol tables + * and AST. + */ +export interface MemberManglingContext { + /** + * True when `typeName` is a user-defined type — function block, interface, + * STRUCT/UDT, or program. + * + * Must be false for elementary types. `Time : TIME` is an ordinary + * declaration that codegen emits unmangled, so mangling it would name a + * `TIME_` member that does not exist. + */ + isUserDefinedType(typeName: string): boolean; + + /** + * Upper-cased method names of every interface implemented by the type that + * *declares* the member — not the member's own type. Omitted where the caller + * has no owner in hand, which skips that check rather than guessing. + */ + interfaceMethods?: ReadonlySet | undefined; +} + +/** + * Whether a member needs the trailing underscore. See the module comment for the + * two collisions. + * + * `memberTypeName` is the member's declared ST type; undefined where the caller + * cannot resolve it, which skips the type-collision check. + */ +export function needsMemberMangling( + memberName: string, + memberTypeName: string | undefined, + ctx: MemberManglingContext, +): boolean { + const upperMember = memberName.toUpperCase(); + + if ( + memberTypeName !== undefined && + memberTypeName !== "" && + upperMember === memberTypeName.toUpperCase() && + ctx.isUserDefinedType(memberTypeName) + ) { + return true; + } + + return ctx.interfaceMethods?.has(upperMember) === true; +} + +/** The member's C++ name: {@link needsMemberMangling} applied. */ +export function mangledMemberName( + memberName: string, + memberTypeName: string | undefined, + ctx: MemberManglingContext, +): string { + return needsMemberMangling(memberName, memberTypeName, ctx) + ? `${memberName}_` + : memberName; +} diff --git a/src/backend/repl-main-gen.ts b/src/backend/repl-main-gen.ts index 2c289428..9d47c48b 100644 --- a/src/backend/repl-main-gen.ts +++ b/src/backend/repl-main-gen.ts @@ -12,6 +12,7 @@ import type { CompilationUnit, VarBlock } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { LineMapEntry } from "../types.js"; import { getProjectNamespace } from "../project-model.js"; +import { mangledMemberName, userDefinedTypeNames } from "./member-mangling.js"; /** * Escape ST source for embedding in a C++ raw string literal with delimiter STRUCPP_SRC. @@ -240,14 +241,30 @@ interface ProgramInfo { /** * Emit VarDescriptor arrays for each program. */ -function emitVarDescriptors(lines: string[], programs: ProgramInfo[]): void { +function emitVarDescriptors( + lines: string[], + programs: ProgramInfo[], + ast: CompilationUnit, +): void { + // Program variables only, and a PROGRAM implements no interfaces, so the + // name-matches-its-own-type collision is the only one that can apply. + const userTypes = userDefinedTypeNames(ast); + const ctx = { + isUserDefinedType: (typeName: string): boolean => + userTypes.has(typeName.toUpperCase()), + }; for (const prog of programs) { if (prog.vars.length > 0) { lines.push(`static VarDescriptor ${prog.varsDescName}[] = {`); for (const v of prog.vars) { const tag = getTypeTag(v.typeName); + // The address must name the member as codegen declared it — a variable + // named after its own type is emitted with a trailing underscore (see + // member-mangling.ts). The descriptor's display name stays the ST name, + // which is what the user types at the REPL prompt. + const member = mangledMemberName(v.name, v.typeName, ctx); lines.push( - ` {"${v.name}", VarTypeTag::${tag}, &${prog.instanceExpr}.${v.name}},`, + ` {"${v.name}", VarTypeTag::${tag}, &${prog.instanceExpr}.${member}},`, ); } lines.push("};"); @@ -323,7 +340,7 @@ function generateStandalone( } lines.push(""); - emitVarDescriptors(lines, programs); + emitVarDescriptors(lines, programs, ast); emitProgramDescriptorsAndMain(lines, programs); } @@ -365,6 +382,6 @@ function generateWithConfiguration( } } - emitVarDescriptors(lines, programs); + emitVarDescriptors(lines, programs, ast); emitProgramDescriptorsAndMain(lines, programs); } diff --git a/src/backend/test-codegen.ts b/src/backend/test-codegen.ts index 33abe160..2c0a536b 100644 --- a/src/backend/test-codegen.ts +++ b/src/backend/test-codegen.ts @@ -159,21 +159,13 @@ export class TestCodeGenerator extends CodeGenerator { const field = path[i]!; if (currentType && this.ast) { const memberType = this.resolveMemberType(currentType, field); - // Check for field name vs type name collision - const typeCollision = - memberType && - this.isUserDefinedType(memberType) && - field.toUpperCase() === memberType.toUpperCase(); - // Check for field name vs interface method name collision - const ifaceCollision = - this.fbInterfaceMethodNames - .get(currentType.toUpperCase()) - ?.has(field.toUpperCase()) ?? false; - if (typeCollision || ifaceCollision) { - parts.push(`${field}_`); - } else { - parts.push(field); - } + // One rule, shared with the class definition and the debug table — + // see member-mangling.ts. + parts.push( + this.needsFieldMangling(field, memberType, currentType) + ? `${field}_` + : field, + ); currentType = memberType; } else { parts.push(field); diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index 451a710e..f2cf77ed 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -22,6 +22,7 @@ import type { } from "../frontend/ast.js"; import { TypeRegistry, isElementaryType } from "../semantic/type-registry.js"; import { formatArrayType } from "./codegen-utils.js"; +import { mangledMemberName } from "./member-mangling.js"; import { parseDateLiteralToDays, parseDtLiteralToNs, @@ -44,6 +45,12 @@ export interface TypeCodeGenOptions { * it can slice per-symbol chunks for tree-shaking. See * `CodeGenOptions.emitChunkMarkers`. */ emitChunkMarkers: boolean; + /** Whether a type name is user-defined, for the shared member-mangling rule + * (see `member-mangling.ts`). `CodeGenerator` injects its own resolution, + * which also recognises function blocks and programs; standalone use falls + * back to "anything that is not elementary", all a bare TypeCodeGenerator + * can tell from a list of type declarations. */ + isUserDefinedType: (typeName: string) => boolean; } /** @@ -53,6 +60,8 @@ export const defaultTypeCodeGenOptions: TypeCodeGenOptions = { indent: " ", lineEnding: "\n", emitChunkMarkers: false, + isUserDefinedType: (typeName: string) => + !isElementaryType(typeName.toUpperCase()), }; /** @@ -299,14 +308,13 @@ export class TypeCodeGenerator { cppType += "*"; } for (const fieldName of field.names) { - // Mangle field name if it matches its user-defined type name - // to avoid GCC -Wchanges-meaning error. Compare against the ST - // type name, not cppType (which may include pointer '*' suffix). - const emitName = - !isElementaryType(field.type.name.toUpperCase()) && - fieldName.toUpperCase() === field.type.name.toUpperCase() - ? `${fieldName}_` - : fieldName; + // One rule, shared with the class definition and the debug table — see + // member-mangling.ts. Compare against the ST type name, not cppType + // (which may carry a pointer '*' suffix). A STRUCT implements no + // interfaces, so only the type collision can apply here. + const emitName = mangledMemberName(fieldName, field.type.name, { + isUserDefinedType: this.options.isUserDefinedType, + }); if (field.initialValue) { const initVal = this.expressionToCpp(field.initialValue); // Array types can't be initialized with = 0; use {} instead diff --git a/tests/backend/debug-table-gen.test.ts b/tests/backend/debug-table-gen.test.ts index 8c3a0a0d..2b2e1ca5 100644 --- a/tests/backend/debug-table-gen.test.ts +++ b/tests/backend/debug-table-gen.test.ts @@ -434,3 +434,157 @@ END_CONFIGURATION`; expect(cpp).not.toContain("PLAIN_"); }); }); + +describe("member mangling agrees with the class definition", () => { + /** + * The table addresses members by name, so it has to name exactly what codegen + * declared — in both directions. Mangling too little names a member that does + * not exist; mangling too much does the same in reverse. Either way + * `generated_debug.cpp` fails to compile and takes the firmware build with it, + * and nothing catches it earlier because `strucpp file.st` emits no table. + * + * The rule now lives in one place (`member-mangling.ts`) and covers both + * collisions — a member named after its own type, and a member named after an + * interface method the owning FB implements — at every site the table builds a + * member expression: PROGRAM variables, FB members, and STRUCT fields. + */ + const CFG = ` +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + + /** Debug-table entry addresses, with the trailing ST-path comments stripped. */ + function addresses(source: string): string { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return (result.debugTableCpp ?? "") + .split("\n") + .map((line) => line.split("//")[0]) + .join("\n"); + } + + function header(source: string): string { + return compile(source).headerCode ?? ""; + } + + const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + + it("mangles a colliding member of a FUNCTION_BLOCK, not just of a PROGRAM", () => { + const src = `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR r : Rig; END_VAR + r(); +END_PROGRAM${CFG}`; + expect(header(src)).toContain("MOTOR MOTOR_;"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.R.MOTOR_.RUN"); + expect(addr).not.toMatch(/\.R\.MOTOR\./); + // A sibling that does not collide is untouched. + expect(addr).toContain("g_config.INSTANCE0.R.IDLE"); + }); + + it("mangles a colliding STRUCT field", () => { + const src = ` +TYPE + Inner : STRUCT v : BOOL; END_STRUCT; + Rig : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.plain := FALSE; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("INNER INNER_"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.R.INNER_.V"); + expect(addr).not.toMatch(/\.R\.INNER\./); + expect(addr).toContain("g_config.INSTANCE0.R.PLAIN"); + }); + + it("mangles a member colliding with an implemented interface method", () => { + // Codegen renames the variable because the method already owns the name; + // addressing `.START` would take the address of the member function instead + // ("cannot create a non-constant pointer to member function"). + const src = ` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_BOOL START_;"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.D.START_"); + expect(addr).not.toMatch(/\.D\.START\b(?!_)/); + expect(addr).toContain("g_config.INSTANCE0.D.OTHER"); + }); + + it("does NOT mangle a variable named after an elementary type", () => { + // `Time : TIME` is an ordinary declaration — codegen emits it as plain + // `TIME`, because the collision rule only applies to user-defined types. A + // name-only comparison would mangle it and address a `TIME_` that does not + // exist, breaking a build that works today. + const src = ` +PROGRAM Main +VAR Time : TIME; Word : WORD; Date : DATE; Real : REAL; END_VAR + Time := T#0s; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_TIME TIME;"); + const addr = addresses(src); + for (const name of ["TIME", "WORD", "DATE", "REAL"]) { + expect(addr).toContain(`g_config.INSTANCE0.${name},`); + } + expect(addr).not.toContain("_,"); + }); + + it("does NOT mangle elementary-named members of an FB or a STRUCT", () => { + const src = ` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR Time : TIME; b : Bag; END_VAR + Time := T#0s; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; g : Bag; END_VAR + h(); +END_PROGRAM${CFG}`; + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.H.TIME,"); + expect(addr).toContain("g_config.INSTANCE0.H.B.TIME,"); + expect(addr).toContain("g_config.INSTANCE0.G.WORD,"); + expect(addr).not.toContain("TIME_"); + expect(addr).not.toContain("WORD_"); + }); + + it("mangles a variable named after an enum type, matching codegen", () => { + const src = ` +TYPE Color : (Red, Green, Blue); END_TYPE +PROGRAM Main +VAR Color : Color; plain : BOOL; END_VAR + plain := FALSE; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_COLOR COLOR_;"); + expect(addresses(src)).toContain("g_config.INSTANCE0.COLOR_"); + }); +}); diff --git a/tests/backend/repl-main-gen.test.ts b/tests/backend/repl-main-gen.test.ts index b25059c2..fac49ddb 100644 --- a/tests/backend/repl-main-gen.test.ts +++ b/tests/backend/repl-main-gen.test.ts @@ -553,3 +553,84 @@ END_PROGRAM`; }); }); }); + +describe('member mangling in VarDescriptor addresses', () => { + /** + * Each descriptor holds `&instance.MEMBER`, so it has to name the member as + * codegen declared it. A variable named after its own type is emitted with a + * trailing underscore (see member-mangling.ts) — without the same rule here + * the REPL binary does not link: + * + * main.cpp:466: error: no member named 'RIG' in 'strucpp::Program_MAIN' + * + * The descriptor's *display* name keeps the un-mangled name (upper-cased, as + * the REPL shows every name), since that is what the user types at the + * prompt — only the address is mangled. + */ + function mainFor(source: string): string { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return generateReplMain(result.ast!, result.projectModel!); + } + + const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + + it('addresses a variable named after its type by the mangled name', () => { + const mainCpp = mainFor(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; plain : BOOL; END_VAR + Motor(run := plain); +END_PROGRAM`); + // Address mangled, display name left as the user wrote it. + expect(mainCpp).toContain('{"MOTOR", VarTypeTag::OTHER, &prog_MAIN.MOTOR_}'); + }); + + it('matches case-insensitively, as ST names do', () => { + // `rig : Rig` is a collision — ST is case-insensitive, so codegen mangles it. + const mainCpp = mainFor(`${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; END_VAR + Motor(run := TRUE); +END_FUNCTION_BLOCK +PROGRAM Main +VAR rig : Rig; END_VAR + rig(); +END_PROGRAM`); + expect(mainCpp).toContain('&prog_MAIN.RIG_}'); + }); + + it('leaves a variable named after an elementary type alone', () => { + // `Time : TIME` is an ordinary declaration; codegen emits plain `TIME`, so + // mangling here would address a member that does not exist. + const mainCpp = mainFor(` +PROGRAM Main +VAR Time : TIME; Word : WORD; counter : INT; END_VAR + counter := counter + 1; +END_PROGRAM`); + expect(mainCpp).toContain('{"TIME", VarTypeTag::TIME, &prog_MAIN.TIME}'); + expect(mainCpp).toContain('{"WORD", VarTypeTag::WORD, &prog_MAIN.WORD}'); + expect(mainCpp).not.toContain('TIME_'); + expect(mainCpp).not.toContain('WORD_'); + }); + + it('applies to program instances under a CONFIGURATION too', () => { + const mainCpp = mainFor(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; END_VAR + Motor(run := TRUE); +END_PROGRAM +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`); + expect(mainCpp).toContain('&config_CONFIG0.INSTANCE0.MOTOR_}'); + }); +}); diff --git a/tests/integration/debug-table-cpp.test.ts b/tests/integration/debug-table-cpp.test.ts new file mode 100644 index 00000000..9428b5f4 --- /dev/null +++ b/tests/integration/debug-table-cpp.test.ts @@ -0,0 +1,237 @@ +/** + * The generated debug table has to compile. + * + * `debugTableCpp` addresses every leaf variable by name through + * `&g_config.INSTANCE.MEMBER...`, so it only builds if every one of those names + * matches what codegen actually declared. Nothing else in the pipeline checks + * that: `strucpp file.st` emits no debug table, and `--build` (the REPL binary) + * does not include one either. The ST compiles, the program's C++ compiles, and + * the failure appears only in a full firmware build, in a file the user never + * wrote. + * + * That gap let the member-mangling rule drift between the class definition and + * the table, in both directions — mangling too little named a member that does + * not exist (`RunningLights : RunningLights` is declared `RUNNINGLIGHTS_`), + * mangling too much did the same in reverse (`Time : TIME` is declared plain + * `TIME`). Each case below fails to compile if the two disagree. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { execSync } from "child_process"; +import { compile } from "../../src/index.js"; +import { hasGpp } from "./test-helpers.js"; + +const RUNTIME_INCLUDE = path.resolve(__dirname, "../../src/runtime/include"); + +const describeIfGpp = hasGpp ? describe : describe.skip; + +const CFG = ` +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + +const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + +describeIfGpp("generated debug table compiles", () => { + let tempDir: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-dbgtable-")); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + /** + * Compile ST, then syntax-check the generated debug table against the + * generated header. Returns g++'s output, empty when it compiled. + */ + function buildDebugTable(source: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + expect(result.debugTableCpp, "no debug table was generated").toBeTruthy(); + + const dir = path.join(tempDir, testName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "generated.hpp"), result.headerCode); + const tablePath = path.join(dir, "generated_debug.cpp"); + fs.writeFileSync(tablePath, result.debugTableCpp!); + + try { + execSync( + `g++ -std=c++17 -fsyntax-only -I"${RUNTIME_INCLUDE}" -I"${dir}" "${tablePath}" 2>&1`, + { encoding: "utf-8" }, + ); + return ""; + } catch (e) { + return (e as { stdout?: string }).stdout ?? String(e); + } + } + + it("compiles for a member whose name matches its type, in a PROGRAM", () => { + expect( + buildDebugTable( + `${MOTOR} +PROGRAM Main +VAR Motor : Motor; plain : BOOL; END_VAR + Motor(run := plain); +END_PROGRAM${CFG}`, + "program_var", + ), + ).toBe(""); + }); + + it("compiles for the same member one scope in, inside a FUNCTION_BLOCK", () => { + // Used to fail: "no member named 'MOTOR' in 'strucpp::RIG'". + expect( + buildDebugTable( + `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR r : Rig; END_VAR + r(); +END_PROGRAM${CFG}`, + "fb_member", + ), + ).toBe(""); + }); + + it("compiles for a STRUCT field whose name matches its type", () => { + // Used to fail: "no member named 'INNER' in 'strucpp::RIG'". + expect( + buildDebugTable( + ` +TYPE + Inner : STRUCT v : BOOL; w : INT; END_STRUCT; + Rig : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.plain := FALSE; +END_PROGRAM${CFG}`, + "struct_field", + ), + ).toBe(""); + }); + + it("compiles for a member colliding with an implemented interface method", () => { + // Used to fail: "cannot create a non-constant pointer to member function" + // — the table took the address of the method rather than the variable. + expect( + buildDebugTable( + ` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM${CFG}`, + "iface_method", + ), + ).toBe(""); + }); + + it("compiles for variables named after elementary types", () => { + // The inverse failure: mangling these would address a `TIME_` that codegen + // never declared. + expect( + buildDebugTable( + ` +PROGRAM Main +VAR Time : TIME; Word : WORD; Date : DATE; Real : REAL; END_VAR + Time := T#0s; +END_PROGRAM${CFG}`, + "elementary_names", + ), + ).toBe(""); + }); + + it("compiles for elementary-named members of an FB and a STRUCT", () => { + expect( + buildDebugTable( + ` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR Time : TIME; b : Bag; END_VAR + Time := T#0s; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; g : Bag; END_VAR + h(); +END_PROGRAM${CFG}`, + "elementary_nested", + ), + ).toBe(""); + }); + + it("compiles for a variable named after an enum type", () => { + expect( + buildDebugTable( + ` +TYPE Color : (Red, Green, Blue); END_TYPE +PROGRAM Main +VAR Color : Color; plain : BOOL; END_VAR + plain := FALSE; +END_PROGRAM${CFG}`, + "enum_name", + ), + ).toBe(""); + }); + + it("compiles for an ordinary project with arrays and nested structs", () => { + // A broad shape check, so this file also guards the table's other address + // forms against the next change to the walker. + // + // Deliberately 1-dimensional: the table indexes a multi-dimensional array as + // `[i][j]`, which `Array2D`/`Array3D` have no operator for, so any project + // with one fails here. That is a separate pre-existing defect, fixed by the + // `formatArrayElementAccess` change on the structure-initialization branch + // (PR #205); add the 2D case to this test once that lands. + expect( + buildDebugTable( + ` +TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + Frame : STRUCT origin : Point; label : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR + grid : ARRAY[0..2] OF Point; + frame : Frame; + counts : ARRAY[1..4] OF INT; + flag : BOOL; +END_VAR + flag := FALSE; +END_PROGRAM${CFG}`, + "ordinary", + ), + ).toBe(""); + }); +}); diff --git a/tests/integration/repl-runner.test.ts b/tests/integration/repl-runner.test.ts index c0cd49ab..9bd69874 100644 --- a/tests/integration/repl-runner.test.ts +++ b/tests/integration/repl-runner.test.ts @@ -380,4 +380,52 @@ END_PROGRAM`; // C++ side should also show statement code expect(output).toContain('COUNT = COUNT + 1'); }); + + /** + * A variable named after its own type is declared with a trailing underscore + * (GCC rejects a member that changes the meaning of its type name), so the + * REPL's VarDescriptor addresses have to use the same name. When they did not, + * the binary simply failed to build: + * + * main.cpp: error: no member named 'RIG' in 'strucpp::Program_MAIN' + * + * Building and running is the assertion — nothing else in the pipeline links + * the REPL harness against the generated header. + */ + it('builds and runs with members named after their own type', () => { + const source = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK + +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := NOT idle); +END_FUNCTION_BLOCK + +PROGRAM Main +VAR + Motor : Motor; + rig : Rig; + Time : TIME; + Word : WORD; + counter : INT; +END_VAR + counter := counter + 1; + Motor(run := TRUE); + rig(); + Word := WORD#7; +END_PROGRAM`; + const output = buildAndRun( + source, + ['run 3', 'get MAIN.COUNTER', 'get MAIN.WORD', 'quit'].join('\n'), + 'member_mangling', + ); + // Both the mangled composites and the elementary-named scalars are exposed + // under the names the user wrote. + expect(output).toContain('MAIN.COUNTER : INT = 3'); + expect(output).toContain('MAIN.WORD : WORD = 16#0007'); + }); }); From 8ad26a30f5623d8dcb53bb22fff46487fb756ebc Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 17 Aug 2026 17:13:57 -0400 Subject: [PATCH 11/13] fix(codegen): mangle the PROGRAM constructor initializer list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(...)) 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) --- src/backend/codegen.ts | 46 +++--- tests/backend/member-mangling.test.ts | 188 ++++++++++++++++++++++ tests/integration/debug-table-cpp.test.ts | 48 +++++- tests/integration/repl-runner.test.ts | 5 + 4 files changed, 254 insertions(+), 33 deletions(-) create mode 100644 tests/backend/member-mangling.test.ts diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index 3fdade8d..ff44be5c 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -1548,11 +1548,7 @@ export class CodeGenerator { const cppType = this.mapTypeRefToCpp(decl.type); const tag = this.elaboratedTagIfShadowed(decl.type.name, fbMemberNames); for (const name of decl.names) { - const memberName = this.mangleMemberIfNeeded( - name, - cppType, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); this.emitHeaderLineDirective(decl.sourceSpan.startLine); const memberLine = this.currentHeaderLine; this.emitHeader(` ${tag}${cppType} ${memberName};`); @@ -1649,11 +1645,7 @@ export class CodeGenerator { for (const decl of block.declarations) { const cppType = this.mapTypeRefToCpp(decl.type); for (const name of decl.names) { - const memberName = this.mangleMemberIfNeeded( - name, - cppType, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); this.emitHeaderLineDirective(decl.sourceSpan.startLine); const memberLine = this.currentHeaderLine; if (decl.address) { @@ -2177,11 +2169,7 @@ export class CodeGenerator { decl.type.name, ); for (const name of decl.names) { - const memberName = this.mangleMemberIfNeeded( - name, - cppType, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); fbInits.push(`${memberName}(${initExpr})`); } } @@ -2401,11 +2389,7 @@ export class CodeGenerator { ? { referenceKind: decl.referenceKind } : {}), }); - const memberName = this.mangleMemberIfNeeded( - decl.name, - cppType, - decl.typeName, - ); + const memberName = this.mangleMemberIfNeeded(decl.name, decl.typeName); // Map variable ST line → header member line const stLine = varSourceLines.get(decl.name); if (stLine !== undefined) { @@ -2564,7 +2548,13 @@ export class CodeGenerator { for (const decl of prog.varDeclarations) { const initVal = this.projectVarInitializer(decl); if (initVal) { - inits.push(`${decl.name}(${initVal})`); + // Name the member as `generateProgramHeaderFromModel` declared it — + // `scale : Scale` is a collision (ST names are case-insensitive) and + // is declared `SCALE_`, so an initializer list naming `SCALE` does not + // compile. The FUNCTION_BLOCK constructor already does this. + inits.push( + `${this.mangleMemberIfNeeded(decl.name, decl.typeName)}(${initVal})`, + ); } } // External globals: bind the pointer member to the canonical GlobalVar @@ -2589,7 +2579,13 @@ export class CodeGenerator { for (const decl of prog.varDeclarations) { const initVal = this.projectVarInitializer(decl); if (initVal) { - inits.push(`${decl.name}(${initVal})`); + // Name the member as `generateProgramHeaderFromModel` declared it — + // `scale : Scale` is a collision (ST names are case-insensitive) and + // is declared `SCALE_`, so an initializer list naming `SCALE` does not + // compile. The FUNCTION_BLOCK constructor already does this. + inits.push( + `${this.mangleMemberIfNeeded(decl.name, decl.typeName)}(${initVal})`, + ); } } if (inits.length > 0) { @@ -5489,11 +5485,7 @@ export class CodeGenerator { * method name (case-insensitive), append '_' to avoid C++ errors. * Populates memberMangledNames map and returns the (possibly mangled) name. */ - private mangleMemberIfNeeded( - name: string, - _cppType: string, - stTypeName: string, - ): string { + private mangleMemberIfNeeded(name: string, stTypeName: string): string { // Declaring a member of the FB currently being generated, so the interface // methods in scope are that FB's. const mangled = mangledMemberName(name, stTypeName, { diff --git a/tests/backend/member-mangling.test.ts b/tests/backend/member-mangling.test.ts new file mode 100644 index 00000000..d6fd1360 --- /dev/null +++ b/tests/backend/member-mangling.test.ts @@ -0,0 +1,188 @@ +/** + * Every emitter that names a C++ member has to agree on the mangling rule, + * because they all name the same entity. This file asserts that agreement + * directly, rather than testing each emitter in isolation — the bugs in this + * area have all been one emitter drifting from another, and only a + * cross-emitter assertion catches that. + * + * A member is renamed for two reasons (see `src/backend/member-mangling.ts`): + * its name matches its own type's name, or it matches an interface method the + * owning FB implements. Both are case-insensitive, because ST names are — + * `rig : Rig` collides just as `Rig : Rig` does. + * + * The inverse matters as much: elementary type names are not reserved, so + * `Time : TIME` is an ordinary declaration that must stay unmangled everywhere. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + +function build(source: string): { header: string; cpp: string } { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return { header: result.headerCode, cpp: result.cppCode }; +} + +/** The constructor initializer-list line for a program. */ +function initList(cpp: string): string { + return cpp.split("\n").find((l) => l.trimStart().startsWith(": ")) ?? ""; +} + +describe("declaration and constructor initializer list agree", () => { + it("mangles an initialised PROGRAM member in both places", () => { + // The declaration was mangled but the initializer list was not, so any + // colliding member *with an initialiser* failed to compile: + // error: member initializer 'AIRANGE' does not name a non-static data + // member or base class + const { header, cpp } = build(` +TYPE AiRange : STRUCT lo : REAL := 4.0; hi : REAL := 20.0; END_STRUCT; END_TYPE +PROGRAM Main +VAR AiRange : AiRange := (hi := 22.0); n : INT; END_VAR + n := n + 1; +END_PROGRAM`); + expect(header).toContain("AIRANGE AIRANGE_;"); + expect(initList(cpp)).toContain("AIRANGE_("); + // The unmangled spelling must not appear as an initializer target. + expect(initList(cpp)).not.toMatch(/(^|[\s:,])AIRANGE\(/); + }); + + it("matches case-insensitively, as ST names do", () => { + const { header, cpp } = build(` +TYPE Range1 : STRUCT lo : REAL := 1.0; END_STRUCT; END_TYPE +PROGRAM Main +VAR range1 : Range1 := (lo := 3.0); n : INT; END_VAR + n := n + 1; +END_PROGRAM`); + expect(header).toContain("RANGE1 RANGE1_;"); + expect(initList(cpp)).toContain("RANGE1_("); + }); + + it("leaves an initialised elementary-named member unmangled in both places", () => { + const { header, cpp } = build(` +PROGRAM Main +VAR Time : TIME := T#5s; Word : WORD := 16#FF; END_VAR + Word := Word; +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME;"); + expect(header).toContain("IEC_WORD WORD;"); + const inits = initList(cpp); + expect(inits).toContain("TIME("); + expect(inits).toContain("WORD("); + expect(inits).not.toContain("TIME_("); + expect(inits).not.toContain("WORD_("); + }); + + it("already agreed for a FUNCTION_BLOCK member, and still does", () => { + const { header, cpp } = build(` +TYPE AiRange : STRUCT lo : REAL := 4.0; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR AiRange : AiRange := (lo := 9.0); END_VAR + AiRange.lo := AiRange.lo; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; END_VAR + h(); +END_PROGRAM`); + expect(header).toContain("AIRANGE AIRANGE_;"); + expect(cpp).toContain("AIRANGE_("); + }); +}); + +describe("declaration and statement body agree", () => { + it("names the mangled member when the body reaches through it", () => { + const { header, cpp } = build(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; flag : BOOL; END_VAR + Motor(run := TRUE); + flag := Motor.spinning; +END_PROGRAM`); + expect(header).toContain("MOTOR MOTOR_;"); + expect(cpp).toContain("MOTOR_.SPINNING"); + }); + + it("leaves an elementary-named member alone in the body", () => { + const { header, cpp } = build(` +PROGRAM Main +VAR Word : WORD; n : INT; END_VAR + Word := WORD#7; + n := n + 1; +END_PROGRAM`); + expect(header).toContain("IEC_WORD WORD;"); + expect(cpp).toContain("WORD = "); + expect(cpp).not.toContain("WORD_"); + }); +}); + +describe("declaration and STRUCT field emission agree", () => { + it("mangles a field named after its own type", () => { + const { header } = build(` +TYPE + Inner : STRUCT v : BOOL; END_STRUCT; + Outer : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR o : Outer; END_VAR + o.plain := FALSE; +END_PROGRAM`); + expect(header).toContain("INNER INNER_"); + expect(header).toContain("IEC_BOOL PLAIN"); + }); + + it("mangles a struct field whose type is a function block", () => { + // Needs codegen's type resolution, not just the type declarations: + // a bare TypeCodeGenerator cannot tell that MOTOR is a function block. + const { header } = build(`${MOTOR} +TYPE Rig : STRUCT Motor : Motor; idle : BOOL; END_STRUCT; END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.idle := FALSE; +END_PROGRAM`); + expect(header).toContain("MOTOR MOTOR_"); + }); + + it("leaves an elementary-named struct field alone", () => { + const { header } = build(` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +PROGRAM Main +VAR b : Bag; END_VAR + b.Word := 16#FF; +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME"); + expect(header).toContain("IEC_WORD WORD"); + expect(header).not.toContain("TIME_"); + expect(header).not.toContain("WORD_"); + }); +}); + +describe("interface method collision", () => { + it("mangles the variable, leaving the method to own the name", () => { + const { header, cpp } = build(` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL := TRUE; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM`); + expect(header).toContain("IEC_BOOL START_;"); + expect(header).toContain("virtual IEC_BOOL START();"); + // The initialiser names the variable, not the method. + expect(cpp).toContain("START_("); + }); +}); diff --git a/tests/integration/debug-table-cpp.test.ts b/tests/integration/debug-table-cpp.test.ts index 9428b5f4..7fb55733 100644 --- a/tests/integration/debug-table-cpp.test.ts +++ b/tests/integration/debug-table-cpp.test.ts @@ -208,12 +208,6 @@ END_PROGRAM${CFG}`, it("compiles for an ordinary project with arrays and nested structs", () => { // A broad shape check, so this file also guards the table's other address // forms against the next change to the walker. - // - // Deliberately 1-dimensional: the table indexes a multi-dimensional array as - // `[i][j]`, which `Array2D`/`Array3D` have no operator for, so any project - // with one fails here. That is a separate pre-existing defect, fixed by the - // `formatArrayElementAccess` change on the structure-initialization branch - // (PR #205); add the 2D case to this test once that lands. expect( buildDebugTable( ` @@ -234,4 +228,46 @@ END_PROGRAM${CFG}`, ), ).toBe(""); }); + + it("compiles for multi-dimensional arrays", () => { + // `Array2D`/`Array3D` have no chained `[i][j]` operator, so the table has to + // address an element as `(i, j)` — `formatArrayElementAccess` owns that rank + // rule. Until it did, any project with a 2D array and debug enabled failed + // to build; this keeps the table and the runtime containers in step. + expect( + buildDebugTable( + ` +TYPE Point : STRUCT x : REAL; y : REAL; END_STRUCT; END_TYPE +PROGRAM Main +VAR + counts : ARRAY[0..1, 0..1] OF INT; + cube : ARRAY[0..1, 0..1, 0..1] OF BOOL; + places : ARRAY[0..1, 0..1] OF Point; + flag : BOOL; +END_VAR + flag := FALSE; +END_PROGRAM${CFG}`, + "multi_dim", + ), + ).toBe(""); + }); + + it("compiles for a multi-dimensional array of a type whose name matches its member", () => { + // Both rules on the same expression: the rank-aware subscript from + // `formatArrayElementAccess` and the member mangling underneath it. + expect( + buildDebugTable( + `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR bank : ARRAY[0..1, 0..1] OF Rig; END_VAR + bank[0, 0](); +END_PROGRAM${CFG}`, + "multi_dim_mangled", + ), + ).toBe(""); + }); }); diff --git a/tests/integration/repl-runner.test.ts b/tests/integration/repl-runner.test.ts index 9bd69874..aaa89477 100644 --- a/tests/integration/repl-runner.test.ts +++ b/tests/integration/repl-runner.test.ts @@ -405,10 +405,15 @@ VAR Motor : Motor; idle : BOOL; END_VAR Motor(run := NOT idle); END_FUNCTION_BLOCK +TYPE AiRange : STRUCT lo : REAL := 4.0; hi : REAL := 20.0; END_STRUCT; END_TYPE + PROGRAM Main VAR Motor : Motor; rig : Rig; + (* an *initialised* colliding member also exercises the constructor + initializer list, which named the un-mangled member *) + AiRange : AiRange := (hi := 22.0); Time : TIME; Word : WORD; counter : INT; From aa2ba1ffdd9c953b53214e27db161f91e2d7393b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 17 Aug 2026 17:26:40 -0400 Subject: [PATCH 12/13] fix(codegen): mangle function-block parameters at the invocation site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/backend/codegen.ts | 31 ++++++- tests/backend/member-mangling.test.ts | 100 ++++++++++++++++++++++ tests/integration/debug-table-cpp.test.ts | 54 ++++++++++-- 3 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/backend/codegen.ts b/src/backend/codegen.ts index ff44be5c..963bf288 100644 --- a/src/backend/codegen.ts +++ b/src/backend/codegen.ts @@ -5352,13 +5352,13 @@ export class CodeGenerator { if (arg.name) { // Named argument: assign directly this.emit( - `${indent}${instanceName}.${arg.name} = ${this.generateExpression(arg.value)};`, + `${indent}${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)} = ${this.generateExpression(arg.value)};`, ); } else if (inputParamNames && positionalIndex < inputParamNames.length) { // Positional argument: map to VAR_INPUT by position const paramName = inputParamNames[positionalIndex]; this.emit( - `${indent}${instanceName}.${paramName} = ${this.generateExpression(arg.value)};`, + `${indent}${instanceName}.${this.fbParamMemberName(paramName!, fbTypeName)} = ${this.generateExpression(arg.value)};`, ); positionalIndex++; } else { @@ -5398,7 +5398,7 @@ export class CodeGenerator { if (arg.name && inoutParams.has(arg.name.toUpperCase())) { this.emitCaptureToLvalue( arg.value, - `${instanceName}.${arg.name}`, + `${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)}`, indent, ); } @@ -5410,7 +5410,7 @@ export class CodeGenerator { if (arg.name && arg.isOutput) { this.emitCaptureToLvalue( arg.value, - `${instanceName}.${arg.name}`, + `${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)}`, indent, ); } @@ -5498,6 +5498,29 @@ export class CodeGenerator { return mangled; } + /** + * C++ member name for a parameter of the function block being invoked, by the + * same rule its declaration used (see member-mangling.ts). + * + * An FB whose input is named after its own type, or after an interface method + * it implements, is declared with a trailing underscore — so assigning through + * the bare name reaches a member that does not exist. Left alone when the FB + * type is unknown, which only disables the check. + */ + private fbParamMemberName( + paramName: string, + fbTypeName: string | undefined, + ): string { + if (fbTypeName === undefined) return paramName; + return this.needsFieldMangling( + paramName, + this.resolveMemberType(fbTypeName, paramName), + fbTypeName, + ) + ? `${paramName}_` + : paramName; + } + /** * Check if a field access needs mangling — true when the field name collides * with its type name (GCC -Wchanges-meaning) or with an interface method name. diff --git a/tests/backend/member-mangling.test.ts b/tests/backend/member-mangling.test.ts index d6fd1360..9b5c9048 100644 --- a/tests/backend/member-mangling.test.ts +++ b/tests/backend/member-mangling.test.ts @@ -186,3 +186,103 @@ END_PROGRAM`); expect(cpp).toContain("START_("); }); }); + +describe("declaration and function-block invocation agree", () => { + /** + * Invoking an FB assigns its inputs, copies VAR_IN_OUT back, and captures + * `=>` outputs — all through `instance.MEMBER`. A parameter named after its + * own type, or after an interface method the FB implements, is declared with + * the underscore, so the bare name reaches nothing: + * + * error: no member named 'READING' in 'strucpp::SENSOR' + */ + const READING = ` +TYPE Reading : STRUCT v : REAL; END_STRUCT; END_TYPE`; + + it("mangles a named input whose name matches its type", () => { + const { header, cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT Reading : Reading; END_VAR +VAR_OUTPUT o : REAL; END_VAR + o := Reading.v; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; END_VAR + s(Reading := inp); +END_PROGRAM`); + expect(header).toContain("READING READING_;"); + expect(cpp).toContain("S.READING_ = INP;"); + }); + + it("mangles a positional input too", () => { + const { cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT Reading : Reading; END_VAR +VAR_OUTPUT o : REAL; END_VAR + o := Reading.v; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; END_VAR + s(inp); +END_PROGRAM`); + expect(cpp).toContain("S.READING_ = INP;"); + }); + + it("mangles an input colliding with an implemented interface method", () => { + const { header, cpp } = build(` +INTERFACE IProbe + METHOD Arm : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Sensor IMPLEMENTS IProbe +VAR_INPUT Arm : BOOL; gain : REAL; END_VAR +VAR_OUTPUT o : REAL; END_VAR + METHOD Arm : BOOL + Arm := TRUE; + END_METHOD + o := gain; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; END_VAR + s(Arm := TRUE, gain := 2.0); +END_PROGRAM`); + expect(header).toContain("IEC_BOOL ARM_;"); + expect(cpp).toContain("S.ARM_ = true;"); + // A non-colliding sibling is untouched. + expect(cpp).toContain("S.GAIN = 2.0;"); + }); + + it("mangles the VAR_IN_OUT copy-back and the => output capture", () => { + const { cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT gain : REAL; END_VAR +VAR_OUTPUT Reading : Reading; END_VAR +VAR_IN_OUT acc : Reading; END_VAR + Reading.v := gain; + acc.v := gain; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; tally : Reading; got : Reading; END_VAR + s(gain := 1.0, acc := tally, Reading => got); +END_PROGRAM`); + // copy-out of the inout, and the => capture, both name the mangled member + expect(cpp).toContain("TALLY = S.ACC;"); + expect(cpp).toContain("GOT = S.READING_;"); + }); + + it("leaves an elementary-named FB input alone", () => { + const { header, cpp } = build(` +FUNCTION_BLOCK Sensor +VAR_INPUT Time : TIME; END_VAR +VAR_OUTPUT o : TIME; END_VAR + o := Time; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; END_VAR + s(Time := T#1s); +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME;"); + expect(cpp).toContain("S.TIME = "); + expect(cpp).not.toContain("S.TIME_"); + }); +}); diff --git a/tests/integration/debug-table-cpp.test.ts b/tests/integration/debug-table-cpp.test.ts index 7fb55733..9d6846db 100644 --- a/tests/integration/debug-table-cpp.test.ts +++ b/tests/integration/debug-table-cpp.test.ts @@ -71,16 +71,22 @@ describeIfGpp("generated debug table compiles", () => { fs.writeFileSync(path.join(dir, "generated.hpp"), result.headerCode); const tablePath = path.join(dir, "generated_debug.cpp"); fs.writeFileSync(tablePath, result.debugTableCpp!); + // The program's own C++ goes through the same member names, so check it in + // the same pass — the invocation paths appear only there. + const programPath = path.join(dir, "generated.cpp"); + fs.writeFileSync(programPath, result.cppCode); - try { - execSync( - `g++ -std=c++17 -fsyntax-only -I"${RUNTIME_INCLUDE}" -I"${dir}" "${tablePath}" 2>&1`, - { encoding: "utf-8" }, - ); - return ""; - } catch (e) { - return (e as { stdout?: string }).stdout ?? String(e); + for (const target of [programPath, tablePath]) { + try { + execSync( + `g++ -std=c++17 -fsyntax-only -I"${RUNTIME_INCLUDE}" -I"${dir}" "${target}" 2>&1`, + { encoding: "utf-8" }, + ); + } catch (e) { + return (e as { stdout?: string }).stdout ?? String(e); + } } + return ""; } it("compiles for a member whose name matches its type, in a PROGRAM", () => { @@ -229,6 +235,38 @@ END_PROGRAM${CFG}`, ).toBe(""); }); + it("compiles for an FB whose parameters collide, invoked with all forms", () => { + // The invocation assigns inputs, copies VAR_IN_OUT back and captures `=>` + // through `instance.MEMBER`; a colliding parameter used to reach nothing + // ("no member named 'READING' in 'strucpp::SENSOR'"). Compiling the program + // is the assertion here — the table only exercises the declarations. + expect( + buildDebugTable( + ` +TYPE Reading : STRUCT v : REAL; END_STRUCT; END_TYPE +INTERFACE IProbe + METHOD Arm : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Sensor IMPLEMENTS IProbe +VAR_INPUT Reading : Reading; Arm : BOOL; gain : REAL; END_VAR +VAR_OUTPUT out1 : REAL; END_VAR +VAR_IN_OUT acc : Reading; END_VAR + METHOD Arm : BOOL + Arm := TRUE; + END_METHOD + out1 := Reading.v * gain; + acc.v := out1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; tally : Reading; got : REAL; END_VAR + s(Reading := inp, Arm := TRUE, gain := 2.0, acc := tally, out1 => got); +END_PROGRAM${CFG}`, + "fb_invocation", + ), + ).toBe(""); + }); + it("compiles for multi-dimensional arrays", () => { // `Array2D`/`Array3D` have no chained `[i][j]` operator, so the table has to // address an element as `(i, j)` — `formatArrayElementAccess` owns that rank From 9c95b09b10cd1731cb507154493955eee8193542 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 18 Aug 2026 06:47:07 -0400 Subject: [PATCH 13/13] chore(release): bump version to 0.6.3 Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- src/version-build.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6b75daf..efd9006a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "license": "GPL-3.0-or-later", "dependencies": { "chevrotain": "^11.0.0" diff --git a/package.json b/package.json index ced4f521..f41fb239 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "description": "IEC 61131-3 Structured Text to C++ Compiler", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/version-build.ts b/src/version-build.ts index 80f1aeaa..7877e86f 100644 --- a/src/version-build.ts +++ b/src/version-build.ts @@ -2,4 +2,4 @@ // Copyright (C) 2025 Autonomy / OpenPLC Project // AUTO-GENERATED by scripts/rebuild-libs.mjs from package.json. Do not edit by hand — // any changes are overwritten on the next build. -export const STRUCPP_VERSION_BUILD = "0.6.1"; +export const STRUCPP_VERSION_BUILD = "0.6.3";