diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index ea73ac6c..c6879047 100644 --- a/docs/IEC_COMPLIANCE.md +++ b/docs/IEC_COMPLIANCE.md @@ -27,9 +27,11 @@ 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 | +| 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) | @@ -59,14 +61,24 @@ 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 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 | + +### Initialization gaps + +| Form | Notes | +|------|-------| +| 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 @@ -84,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/ast-utils.ts b/src/ast-utils.ts index 14f64e6c..84df28d0 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; } @@ -592,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; } @@ -652,6 +657,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-utils.ts b/src/backend/codegen-utils.ts index ffc4a552..16363509 100644 --- a/src/backend/codegen-utils.ts +++ b/src/backend/codegen-utils.ts @@ -52,3 +52,101 @@ 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. + * + * 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 245e5e95..c951af7e 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, @@ -54,16 +56,26 @@ 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, 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 +369,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 +1082,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"'); @@ -1144,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(); @@ -1169,7 +1199,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};`, ); @@ -1220,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) { @@ -1425,26 +1440,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 +2004,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 +2087,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 +2137,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 +2165,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 +2254,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 +2557,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 +2582,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})`); } @@ -2663,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(); @@ -2675,22 +2709,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( @@ -2981,7 +3008,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 ( @@ -3580,6 +3610,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 "{}"; } } @@ -3614,7 +3651,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": { @@ -3623,7 +3660,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": { @@ -3649,13 +3686,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, ""); @@ -3669,67 +3699,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. */ @@ -4699,7 +4668,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 +4724,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 +4830,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 +4845,7 @@ export class CodeGenerator { ) { return this.emitOutputTempVar(param.typeName); } - return param.defaultExpr ?? this.getDefaultValue(param.typeName); + return param.defaultExpr ?? this.getTypeDefaultValue(param.typeName); }); } @@ -5214,9 +5188,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) || @@ -5335,14 +5323,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; @@ -5560,95 +5557,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 +5700,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/debug-table-gen.ts b/src/backend/debug-table-gen.ts index 3773649f..ba699d14 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -29,6 +29,8 @@ import type { } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { SymbolTables } from "../semantic/symbol-table.js"; +import { formatArrayElementAccess } from "./codegen-utils.js"; +import { evalIntConst } from "../semantic/type-utils.js"; // --------------------------------------------------------------------------- // Type tags — MUST match TypeTag enum in runtime/include/debug_dispatch.hpp. @@ -426,17 +428,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 +462,11 @@ export function generateDebugTable( for (let i = start; i <= end; i++) { walkArrayDims( `${path}[${i}]`, - `${cppExpr}[${i}]`, + cppExpr, dims, dimIdx + 1, elementTypeName, + [...indices, i], ); } }; @@ -635,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/backend/struct-init-codegen.ts b/src/backend/struct-init-codegen.ts new file mode 100644 index 00000000..061bb2b2 --- /dev/null +++ b/src/backend/struct-init-codegen.ts @@ -0,0 +1,180 @@ +// 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. + // + // 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( + element, + elementCppType, + elementStType, + emitter, + depth, + ), + ); + return `{${elements.join(", ")}}`; + } + + 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. + * + * 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..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, @@ -32,6 +32,10 @@ import { buildEnumMemberMap, type EnumMemberEntry, } from "../semantic/type-utils.js"; +import { + generateInitializerValue, + type StructInitEmitter, +} from "./struct-init-codegen.js"; /** * Options for type code generation @@ -137,6 +141,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 +328,18 @@ export class TypeCodeGenerator { ? `${fieldName}_` : fieldName; if (field.initialValue) { - const initVal = 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 t.startOffset > assign.startOffset) + : undefined; + if (!defaultToken) return undefined; + return { + kind: "VariableExpression", + sourceSpan: tokenToSourceSpan(defaultToken), + name: defaultToken.image, + subscripts: [], + fieldAccess: [], + isDereference: false, }; } @@ -1299,6 +1368,85 @@ export class ASTBuilder { }; } + /** + * Build the expression behind an `initializerExpression` CST node. + * + * 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, 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. + */ + private buildInitializerExpression( + initExprNode: CstNode | undefined, + ): Expression | undefined { + if (!initExprNode) return undefined; + const initChildren = initExprNode.children as CstChildren; + 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 (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 elements; + } + /** * Build a VarDeclaration from a CST node. */ @@ -1339,31 +1487,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; @@ -1562,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)!, @@ -2290,19 +2421,22 @@ 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; } + // 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 +2496,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. */ @@ -3120,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 374a21f4..19f4b3cc 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 @@ -622,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; } /** @@ -735,6 +755,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-error-message-provider.ts b/src/frontend/parser-error-message-provider.ts index 9a957d6d..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", @@ -93,6 +95,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 88e01115..fbeac154 100644 --- a/src/frontend/parser.ts +++ b/src/frontend/parser.ts @@ -459,31 +459,91 @@ 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)` + * + * 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 +594,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); @@ -840,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) }, @@ -977,6 +1052,35 @@ 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: 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 (:=), @@ -1026,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 */ @@ -1045,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). @@ -1470,6 +1620,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_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/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/src/semantic/analyzer.ts b/src/semantic/analyzer.ts index 930db1e4..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. */ @@ -2783,6 +3090,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/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/backend/codegen-structure-initialization.test.ts b/tests/backend/codegen-structure-initialization.test.ts new file mode 100644 index 00000000..ded540f8 --- /dev/null +++ b/tests/backend/codegen-structure-initialization.test.ts @@ -0,0 +1,512 @@ +/** + * 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; });", + ); + }); + + 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", () => { + 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/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 = ` 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/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/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/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..81069d20 --- /dev/null +++ b/tests/integration/structure-initialization-cpp.test.ts @@ -0,0 +1,747 @@ +/** + * 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"); + }); + + 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("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("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( + ` + 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( + ` + 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"); + }); + }, +); 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/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 +`); + }); +}); 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);"); + }); +});