From f660bc28f010b6e16daba966f7253a234e091e1b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 11:01:40 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 +`); + }); +});