diff --git a/docs/IEC_COMPLIANCE.md b/docs/IEC_COMPLIANCE.md index ea73ac6c..273b7214 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. Only valid as a declaration's initial value, as in the standard — one written inside a statement is rejected | +| STRUCT element defaults | Supported | Scalar, array-literal and structure-initializer defaults on a STRUCT element all carry their values | + +### Initialization gaps + +| 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,9 +96,10 @@ 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 | +| Integer literals | `9223372036854775807`, `16#FF`, `1_000` | Supported — the full 64-bit LINT/ULINT range is preserved exactly; a value wider than ULINT is rejected | | NEW | `__NEW(type)`, `__NEW(type, size)` | Supported | | DELETE | `__DELETE(ptr)` | Supported | diff --git a/package-lock.json b/package-lock.json index f6b75daf..efd9006a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "license": "GPL-3.0-or-later", "dependencies": { "chevrotain": "^11.0.0" diff --git a/package.json b/package.json index ced4f521..f41fb239 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strucpp", - "version": "0.6.1", + "version": "0.6.3", "description": "IEC 61131-3 Structured Text to C++ Compiler", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/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..b9563008 100644 --- a/src/backend/codegen-utils.ts +++ b/src/backend/codegen-utils.ts @@ -4,6 +4,8 @@ * Shared utility functions for C++ code generation. */ +import { exactIntegerLiteralValue } from "../literal-utils.js"; + /** * Convert an IEC 61131-3 based numeric string to a C++ literal string. * Handles 16#FF → 0xFF, 8#77 → 077, 2#1010 → 0b1010, and plain decimals. @@ -17,6 +19,44 @@ export function iecBaseToCppLiteral(raw: string): string { return raw.replace(/_/g, ""); } +/** Largest value a C++ *decimal* literal can name without a suffix. */ +const CPP_SIGNED_LITERAL_MAX = 9223372036854775807n; + +/** + * Lower an IEC 61131-3 integer literal to a C++ integer literal. + * + * Based literals (16#FF, 8#77, 2#1010) keep their notation. Plain decimals are + * re-emitted from the *exact* value rather than from the parsed `number`, which + * matters twice over: + * + * - `number` rounds above 2^53, so a LINT/ULINT initializer such as + * `9007199254740993` would silently become ...992, and `ULINT` bounds would + * round past the type's range into a literal g++ rejects outright. + * - passing the raw digits straight through instead would make a leading zero + * an octal prefix in C++ (`0010` → 8, `008` → a compile error), so the + * digits are normalized rather than copied. + * + * A value above `CPP_SIGNED_LITERAL_MAX` gets a `ULL` suffix: a C++ decimal + * literal is only ever given a *signed* type (C++17 [lex.icon]/3), so without + * it `18446744073709551615` names no type at all. + * + * `value` is the pre-parsed fallback for a literal whose raw text is not a + * plain integer (synthesized nodes, for one). + */ +export function formatIntegerLiteral(rawValue: string, value: number): string { + const upper = rawValue.toUpperCase().replace(/_/g, ""); + if ( + upper.startsWith("16#") || + upper.startsWith("8#") || + upper.startsWith("2#") + ) { + return iecBaseToCppLiteral(rawValue); + } + const exact = exactIntegerLiteralValue(rawValue); + if (exact === undefined) return String(value); + return exact > CPP_SIGNED_LITERAL_MAX ? `${exact}ULL` : exact.toString(); +} + /** * Format an array type string from element type and dimension bounds. * @@ -52,3 +92,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..963bf288 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,28 @@ 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, + formatIntegerLiteral, + iecBaseToCppLiteral, + translateIECString, +} from "./codegen-utils.js"; +import { mangledMemberName, needsMemberMangling } from "./member-mangling.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 +371,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 +1084,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 +1165,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(); @@ -1152,6 +1184,9 @@ export class CodeGenerator { indent: this.options.indent, lineEnding: this.options.lineEnding, emitChunkMarkers: this.options.emitChunkMarkers ?? false, + // Struct fields must mangle by the same rule as everything else that + // names them, and only codegen knows the FB / program type names. + isUserDefinedType: (t) => this.isUserDefinedType(t), }); const typeCode = typeCodeGen.generateFromRegistry(typeRegistry); for (const line of typeCode.split(this.options.lineEnding)) { @@ -1169,7 +1204,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 +1259,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 +1445,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. */ @@ -1507,11 +1548,7 @@ export class CodeGenerator { const cppType = this.mapTypeRefToCpp(decl.type); const tag = this.elaboratedTagIfShadowed(decl.type.name, fbMemberNames); for (const name of decl.names) { - const memberName = this.mangleMemberIfNeeded( - name, - cppType, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); this.emitHeaderLineDirective(decl.sourceSpan.startLine); const memberLine = this.currentHeaderLine; this.emitHeader(` ${tag}${cppType} ${memberName};`); @@ -1608,11 +1645,7 @@ export class CodeGenerator { for (const decl of block.declarations) { const cppType = this.mapTypeRefToCpp(decl.type); for (const name of decl.names) { - const memberName = this.mangleMemberIfNeeded( - name, - cppType, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); this.emitHeaderLineDirective(decl.sourceSpan.startLine); const memberLine = this.currentHeaderLine; if (decl.address) { @@ -1968,12 +2001,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 +2084,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 +2134,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,14 +2162,14 @@ 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, - decl.type.name, - ); + const memberName = this.mangleMemberIfNeeded(name, decl.type.name); fbInits.push(`${memberName}(${initExpr})`); } } @@ -2217,12 +2247,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};`); } } } @@ -2360,11 +2389,7 @@ export class CodeGenerator { ? { referenceKind: decl.referenceKind } : {}), }); - const memberName = this.mangleMemberIfNeeded( - decl.name, - cppType, - decl.typeName, - ); + const memberName = this.mangleMemberIfNeeded(decl.name, decl.typeName); // Map variable ST line → header member line const stLine = varSourceLines.get(decl.name); if (stLine !== undefined) { @@ -2521,24 +2546,15 @@ 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})`); + // Name the member as `generateProgramHeaderFromModel` declared it — + // `scale : Scale` is a collision (ST names are case-insensitive) and + // is declared `SCALE_`, so an initializer list naming `SCALE` does not + // compile. The FUNCTION_BLOCK constructor already does this. + inits.push( + `${this.mangleMemberIfNeeded(decl.name, decl.typeName)}(${initVal})`, + ); } } // External globals: bind the pointer member to the canonical GlobalVar @@ -2561,24 +2577,15 @@ 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})`); + // Name the member as `generateProgramHeaderFromModel` declared it — + // `scale : Scale` is a collision (ST names are case-insensitive) and + // is declared `SCALE_`, so an initializer list naming `SCALE` does not + // compile. The FUNCTION_BLOCK constructor already does this. + inits.push( + `${this.mangleMemberIfNeeded(decl.name, decl.typeName)}(${initVal})`, + ); } } if (inits.length > 0) { @@ -2663,6 +2670,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 +2710,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 +3009,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 +3611,20 @@ 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 a + // declaration supplies — declarations route through + // `generateInitializer` instead. It is not an expression IEC allows in a + // statement either, and the analyzer rejects it there + // (`validateStructInitializerPlacement`), so this is unreachable for any + // unit that got past semantic analysis. Loud rather than silent: the + // previous `return "{}"` value-initialised, which discarded every + // element the initializer named and produced no diagnostic anywhere. + throw new Error( + `Internal error: structure initializer at ${expr.sourceSpan.startLine}:` + + `${expr.sourceSpan.startCol} reached expression codegen, where the ` + + `target type is unknown. It is only valid as a declaration's initial value.`, + ); } } @@ -3592,7 +3637,12 @@ export class CodeGenerator { const cppType = `IEC_${expr.typePrefix}`; const hashIdx = expr.rawValue.indexOf("#"); const valuePart = expr.rawValue.substring(hashIdx + 1); - const cppValue = iecBaseToCppLiteral(valuePart); + // An integer payload goes through the exact lowering too — `LINT#<64-bit>` + // must not round, and `INT#0010` must not become a C++ octal constant. + const cppValue = + expr.literalType === "INT" + ? formatIntegerLiteral(valuePart, expr.value as number) + : iecBaseToCppLiteral(valuePart); return `static_cast<${cppType}>(${cppValue})`; } @@ -3604,7 +3654,7 @@ export class CodeGenerator { ? "true" : "false"; case "INT": { - return this.formatIntegerLiteral(expr.rawValue, expr.value as number); + return formatIntegerLiteral(expr.rawValue, expr.value as number); } case "REAL": { const str = String(expr.value); @@ -3614,7 +3664,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 +3673,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,87 +3699,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, ""); - if ( - upper.startsWith("16#") || - upper.startsWith("8#") || - upper.startsWith("2#") - ) { - return iecBaseToCppLiteral(rawValue); - } - 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; @@ -5355,13 +5352,13 @@ export class CodeGenerator { if (arg.name) { // Named argument: assign directly this.emit( - `${indent}${instanceName}.${arg.name} = ${this.generateExpression(arg.value)};`, + `${indent}${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)} = ${this.generateExpression(arg.value)};`, ); } else if (inputParamNames && positionalIndex < inputParamNames.length) { // Positional argument: map to VAR_INPUT by position const paramName = inputParamNames[positionalIndex]; this.emit( - `${indent}${instanceName}.${paramName} = ${this.generateExpression(arg.value)};`, + `${indent}${instanceName}.${this.fbParamMemberName(paramName!, fbTypeName)} = ${this.generateExpression(arg.value)};`, ); positionalIndex++; } else { @@ -5401,7 +5398,7 @@ export class CodeGenerator { if (arg.name && inoutParams.has(arg.name.toUpperCase())) { this.emitCaptureToLvalue( arg.value, - `${instanceName}.${arg.name}`, + `${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)}`, indent, ); } @@ -5413,7 +5410,7 @@ export class CodeGenerator { if (arg.name && arg.isOutput) { this.emitCaptureToLvalue( arg.value, - `${instanceName}.${arg.name}`, + `${instanceName}.${this.fbParamMemberName(arg.name, fbTypeName)}`, indent, ); } @@ -5488,55 +5485,61 @@ export class CodeGenerator { * method name (case-insensitive), append '_' to avoid C++ errors. * Populates memberMangledNames map and returns the (possibly mangled) name. */ - private mangleMemberIfNeeded( - name: string, - _cppType: string, - stTypeName: string, - ): string { - // Variable name vs type name collision (GCC -Wchanges-meaning) - if (this.isUserDefinedType(stTypeName)) { - if (name.toUpperCase() === stTypeName.toUpperCase()) { - const mangled = `${name}_`; - this.memberMangledNames.set(name.toUpperCase(), mangled); - return mangled; - } - } - // Variable name vs interface method name collision - if (this.currentFBInterfaceMethods.has(name.toUpperCase())) { - const mangled = `${name}_`; + private mangleMemberIfNeeded(name: string, stTypeName: string): string { + // Declaring a member of the FB currently being generated, so the interface + // methods in scope are that FB's. + const mangled = mangledMemberName(name, stTypeName, { + isUserDefinedType: (t) => this.isUserDefinedType(t), + interfaceMethods: this.currentFBInterfaceMethods, + }); + if (mangled !== name) { this.memberMangledNames.set(name.toUpperCase(), mangled); - return mangled; } - return name; + return mangled; + } + + /** + * C++ member name for a parameter of the function block being invoked, by the + * same rule its declaration used (see member-mangling.ts). + * + * An FB whose input is named after its own type, or after an interface method + * it implements, is declared with a trailing underscore — so assigning through + * the bare name reaches a member that does not exist. Left alone when the FB + * type is unknown, which only disables the check. + */ + private fbParamMemberName( + paramName: string, + fbTypeName: string | undefined, + ): string { + if (fbTypeName === undefined) return paramName; + return this.needsFieldMangling( + paramName, + this.resolveMemberType(fbTypeName, paramName), + fbTypeName, + ) + ? `${paramName}_` + : paramName; } /** * Check if a field access needs mangling — true when the field name collides * with its type name (GCC -Wchanges-meaning) or with an interface method name. + * + * Reaching a member through a named owner rather than from inside it, so the + * interface methods come from that owner's entry. */ - private needsFieldMangling( + protected needsFieldMangling( fieldName: string, fieldTypeName: string | undefined, parentTypeName?: string, ): boolean { - // Field name vs type name collision - if ( - fieldTypeName && - this.isUserDefinedType(fieldTypeName) && - fieldName.toUpperCase() === fieldTypeName.toUpperCase() - ) { - return true; - } - // Field name vs interface method name collision - if (parentTypeName) { - const ifaceMethods = this.fbInterfaceMethodNames.get( - parentTypeName.toUpperCase(), - ); - if (ifaceMethods?.has(fieldName.toUpperCase())) { - return true; - } - } - return false; + return needsMemberMangling(fieldName, fieldTypeName, { + isUserDefinedType: (t) => this.isUserDefinedType(t), + interfaceMethods: + parentTypeName !== undefined + ? this.fbInterfaceMethodNames.get(parentTypeName.toUpperCase()) + : undefined, + }); } /** @@ -5560,95 +5563,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 +5706,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..9ff34ede 100644 --- a/src/backend/debug-table-gen.ts +++ b/src/backend/debug-table-gen.ts @@ -29,6 +29,10 @@ import type { } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { SymbolTables } from "../semantic/symbol-table.js"; +import { isElementaryType } from "../semantic/type-registry.js"; +import { evalIntConst } from "../semantic/type-utils.js"; +import { formatArrayElementAccess } from "./codegen-utils.js"; +import { mangledMemberName } from "./member-mangling.js"; // --------------------------------------------------------------------------- // Type tags — MUST match TypeTag enum in runtime/include/debug_dispatch.hpp. @@ -221,6 +225,59 @@ export function generateDebugTable( const programByName = new Map(); for (const p of ast.programs) programByName.set(p.name.toUpperCase(), p); + // --- Inputs to the shared member-mangling rule (see member-mangling.ts) ---- + // The table addresses members by the name codegen declared them under, so + // both predicates have to resolve the same way codegen's do. + + const interfaceNames = new Set( + ast.interfaces.map((i) => i.name.toUpperCase()), + ); + + /** + * Mirrors `CodeGenerator.isUserDefinedType`: a function block, interface, + * STRUCT/UDT, or program. Elementary types are excluded explicitly — codegen + * leaves `Time : TIME` unmangled, so mangling it here would name a member + * that does not exist. + */ + const isUserDefinedType = (typeName: string): boolean => { + const upper = typeName.toUpperCase(); + if (isElementaryType(upper)) return false; + return ( + symbolTables.lookupType(upper) !== undefined || + symbolTables.lookupFunctionBlock(upper) !== undefined || + interfaceNames.has(upper) || + programByName.has(upper) + ); + }; + + /** + * FB type name → upper-cased method names of every interface it implements, + * mirroring `CodeGenerator.fbInterfaceMethodNames`. Directly implemented + * interfaces only, which is what codegen consults. + */ + const fbInterfaceMethods = new Map>(); + { + const methodsByInterface = new Map>(); + for (const iface of ast.interfaces) { + methodsByInterface.set( + iface.name.toUpperCase(), + new Set(iface.methods.map((m) => m.name.toUpperCase())), + ); + } + for (const fb of ast.functionBlocks) { + if (!fb.implements || fb.implements.length === 0) continue; + const methods = new Set(); + for (const ifaceName of fb.implements) { + for (const m of methodsByInterface.get(ifaceName.toUpperCase()) ?? []) { + methods.add(m); + } + } + if (methods.size > 0) { + fbInterfaceMethods.set(fb.name.toUpperCase(), methods); + } + } + } + // Buckets of entries — grown in order, flushed at program boundary or size cap. const arrays: Entry[][] = [[]]; const leaves: DebugLeaf[] = []; @@ -376,11 +433,13 @@ export function generateDebugTable( ...fbSym.outputs, ...fbSym.inouts, ]; + // `name` is the FB type declaring these members, so it is the owner for + // both mangling collisions. if (interfaceVars.length > 0) { for (const v of interfaceVars) { visitTypeRef( `${path}.${v.name.toUpperCase()}`, - `${cppExpr}.${v.name}`, + `${cppExpr}.${memberCppName(v.name, v.declaration.type, name)}`, v.declaration.type, ); } @@ -396,7 +455,7 @@ export function generateDebugTable( for (const fieldName of fieldDecl.names) { visitTypeRef( `${path}.${fieldName.toUpperCase()}`, - `${cppExpr}.${fieldName}`, + `${cppExpr}.${memberCppName(fieldName, fieldDecl.type, name)}`, fieldDecl.type, ); } @@ -419,24 +478,38 @@ export function generateDebugTable( for (const fieldName of fieldDecl.names) { visitTypeRef( `${path}.${fieldName.toUpperCase()}`, - `${cppExpr}.${fieldName}`, + // No owner: a STRUCT implements no interfaces, so only the + // field-name-matches-its-type collision can apply. + `${cppExpr}.${memberCppName(fieldName, fieldDecl.type)}`, fieldDecl.type, ); } } }; + /** + * 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,23 +521,54 @@ export function generateDebugTable( for (let i = start; i <= end; i++) { walkArrayDims( `${path}[${i}]`, - `${cppExpr}[${i}]`, + cppExpr, dims, dimIdx + 1, elementTypeName, + [...indices, i], ); } }; + /** + * C++ member name for a declaration, by the same rule codegen used to emit it + * (see `member-mangling.ts`). + * + * The table addresses members by name, so it has to agree with the class + * definition exactly, in *both* directions. Mangling too little named a member + * that does not exist (`RunningLights : RunningLights` is declared + * `RUNNINGLIGHTS_`); mangling too much would do the same in reverse, since + * `Time : TIME` is declared plain `TIME`. Either way `generated_debug.cpp` + * fails to compile and takes the whole firmware build with it — and nothing + * catches it earlier, because `strucpp file.st` emits no debug table. + * + * `ownerTypeName` is the type declaring the member, needed for the + * interface-method collision; undefined for a PROGRAM or a STRUCT, neither of + * which can implement an interface. + */ + const memberCppName = ( + varName: string, + typeRef: TypeReference | undefined, + ownerTypeName?: string, + ): string => + mangledMemberName(varName, typeRef?.name, { + isUserDefinedType, + interfaceMethods: + ownerTypeName !== undefined + ? fbInterfaceMethods.get(ownerTypeName.toUpperCase()) + : undefined, + }); + const visitVarDecl = ( path: string, cppExpr: string, decl: VarDeclaration, + ownerTypeName?: string, ): void => { for (const varName of decl.names) { visitTypeRef( `${path}.${varName.toUpperCase()}`, - `${cppExpr}.${varName}`, + `${cppExpr}.${memberCppName(varName, decl.type, ownerTypeName)}`, decl.type, ); } @@ -635,29 +739,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/member-mangling.ts b/src/backend/member-mangling.ts new file mode 100644 index 00000000..c48837d8 --- /dev/null +++ b/src/backend/member-mangling.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * The one rule for renaming a C++ member whose ST name would not survive + * translation. + * + * Two collisions force a trailing underscore: + * + * 1. **The member's name matches its own type's name.** CODESYS allows + * `RunningLights : RunningLights` and real projects use it, but GCC rejects + * a member that changes the meaning of its type name within the class + * (`-Wchanges-meaning`), so it is emitted as `RUNNINGLIGHTS_`. + * + * 2. **The member's name matches an interface method the owning FB + * implements.** `VAR Start : BOOL` inside a `FUNCTION_BLOCK ... IMPLEMENTS + * IMotor` that declares `METHOD Start` would otherwise redeclare the method + * as a data member. + * + * Every emitter that writes or addresses a member has to agree, because they all + * name the same C++ entity: the class definition (`codegen` / `type-codegen`), + * body expressions (`codegen` / `test-codegen`), and the debugger's pointer + * table (`debug-table-gen`). The rule previously existed as five copies with + * three different conditions; the debug table drifting from the class definition + * broke the build of any project using the pattern, and only in a full firmware + * build, since `strucpp file.st` emits no debug table. + * + * Callers differ in what they can resolve, so both inputs arrive through + * {@link MemberManglingContext} rather than being computed here. + */ + +import type { CompilationUnit } from "../frontend/ast.js"; + +/** + * Upper-cased names of every user-defined type in a compilation unit — function + * blocks, interfaces, TYPE declarations, and programs, matching what + * `CodeGenerator.isUserDefinedType` recognises. + * + * For emitters that have only the AST. It cannot see library-declared types, so + * anything holding symbol tables should consult those as well; no elementary + * name can appear here, since redeclaring one is an error. + */ +export function userDefinedTypeNames(ast: CompilationUnit): Set { + const names = new Set(); + for (const fb of ast.functionBlocks) names.add(fb.name.toUpperCase()); + for (const iface of ast.interfaces) names.add(iface.name.toUpperCase()); + for (const type of ast.types) names.add(type.name.toUpperCase()); + for (const prog of ast.programs) names.add(prog.name.toUpperCase()); + return names; +} + +/** + * What the rule needs to know, supplied by each emitter from whatever it has: + * codegen from its `known*Types` sets, the debug table from the symbol tables + * and AST. + */ +export interface MemberManglingContext { + /** + * True when `typeName` is a user-defined type — function block, interface, + * STRUCT/UDT, or program. + * + * Must be false for elementary types. `Time : TIME` is an ordinary + * declaration that codegen emits unmangled, so mangling it would name a + * `TIME_` member that does not exist. + */ + isUserDefinedType(typeName: string): boolean; + + /** + * Upper-cased method names of every interface implemented by the type that + * *declares* the member — not the member's own type. Omitted where the caller + * has no owner in hand, which skips that check rather than guessing. + */ + interfaceMethods?: ReadonlySet | undefined; +} + +/** + * Whether a member needs the trailing underscore. See the module comment for the + * two collisions. + * + * `memberTypeName` is the member's declared ST type; undefined where the caller + * cannot resolve it, which skips the type-collision check. + */ +export function needsMemberMangling( + memberName: string, + memberTypeName: string | undefined, + ctx: MemberManglingContext, +): boolean { + const upperMember = memberName.toUpperCase(); + + if ( + memberTypeName !== undefined && + memberTypeName !== "" && + upperMember === memberTypeName.toUpperCase() && + ctx.isUserDefinedType(memberTypeName) + ) { + return true; + } + + return ctx.interfaceMethods?.has(upperMember) === true; +} + +/** The member's C++ name: {@link needsMemberMangling} applied. */ +export function mangledMemberName( + memberName: string, + memberTypeName: string | undefined, + ctx: MemberManglingContext, +): string { + return needsMemberMangling(memberName, memberTypeName, ctx) + ? `${memberName}_` + : memberName; +} diff --git a/src/backend/repl-main-gen.ts b/src/backend/repl-main-gen.ts index 2c289428..9d47c48b 100644 --- a/src/backend/repl-main-gen.ts +++ b/src/backend/repl-main-gen.ts @@ -12,6 +12,7 @@ import type { CompilationUnit, VarBlock } from "../frontend/ast.js"; import type { ProjectModel } from "../project-model.js"; import type { LineMapEntry } from "../types.js"; import { getProjectNamespace } from "../project-model.js"; +import { mangledMemberName, userDefinedTypeNames } from "./member-mangling.js"; /** * Escape ST source for embedding in a C++ raw string literal with delimiter STRUCPP_SRC. @@ -240,14 +241,30 @@ interface ProgramInfo { /** * Emit VarDescriptor arrays for each program. */ -function emitVarDescriptors(lines: string[], programs: ProgramInfo[]): void { +function emitVarDescriptors( + lines: string[], + programs: ProgramInfo[], + ast: CompilationUnit, +): void { + // Program variables only, and a PROGRAM implements no interfaces, so the + // name-matches-its-own-type collision is the only one that can apply. + const userTypes = userDefinedTypeNames(ast); + const ctx = { + isUserDefinedType: (typeName: string): boolean => + userTypes.has(typeName.toUpperCase()), + }; for (const prog of programs) { if (prog.vars.length > 0) { lines.push(`static VarDescriptor ${prog.varsDescName}[] = {`); for (const v of prog.vars) { const tag = getTypeTag(v.typeName); + // The address must name the member as codegen declared it — a variable + // named after its own type is emitted with a trailing underscore (see + // member-mangling.ts). The descriptor's display name stays the ST name, + // which is what the user types at the REPL prompt. + const member = mangledMemberName(v.name, v.typeName, ctx); lines.push( - ` {"${v.name}", VarTypeTag::${tag}, &${prog.instanceExpr}.${v.name}},`, + ` {"${v.name}", VarTypeTag::${tag}, &${prog.instanceExpr}.${member}},`, ); } lines.push("};"); @@ -323,7 +340,7 @@ function generateStandalone( } lines.push(""); - emitVarDescriptors(lines, programs); + emitVarDescriptors(lines, programs, ast); emitProgramDescriptorsAndMain(lines, programs); } @@ -365,6 +382,6 @@ function generateWithConfiguration( } } - emitVarDescriptors(lines, programs); + emitVarDescriptors(lines, programs, ast); emitProgramDescriptorsAndMain(lines, programs); } diff --git a/src/backend/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/test-codegen.ts b/src/backend/test-codegen.ts index 33abe160..86d5613e 100644 --- a/src/backend/test-codegen.ts +++ b/src/backend/test-codegen.ts @@ -159,21 +159,13 @@ export class TestCodeGenerator extends CodeGenerator { const field = path[i]!; if (currentType && this.ast) { const memberType = this.resolveMemberType(currentType, field); - // Check for field name vs type name collision - const typeCollision = - memberType && - this.isUserDefinedType(memberType) && - field.toUpperCase() === memberType.toUpperCase(); - // Check for field name vs interface method name collision - const ifaceCollision = - this.fbInterfaceMethodNames - .get(currentType.toUpperCase()) - ?.has(field.toUpperCase()) ?? false; - if (typeCollision || ifaceCollision) { - parts.push(`${field}_`); - } else { - parts.push(field); - } + // One rule, shared with the class definition and the debug table — + // see member-mangling.ts. + parts.push( + this.needsFieldMangling(field, memberType, currentType) + ? `${field}_` + : field, + ); currentType = memberType; } else { parts.push(field); @@ -187,6 +179,19 @@ export class TestCodeGenerator extends CodeGenerator { return this.generateExpression(expr); } + /** + * Generate C++ for a declaration's initial value, which unlike a plain + * expression may be a structure initializer — that form needs the target's + * C++ type to lower (see `generateInitializer`). + */ + emitInitializer( + expr: Expression, + cppType: string, + stTypeName: string | undefined, + ): string { + return this.generateInitializer(expr, cppType, stTypeName); + } + /** Generate C++ statement(s) and append to output buffer. */ emitStatement(stmt: Statement, indent: string): void { this.generateStatement(stmt, indent); diff --git a/src/backend/test-main-gen.ts b/src/backend/test-main-gen.ts index 661cb32a..bf64f8bf 100644 --- a/src/backend/test-main-gen.ts +++ b/src/backend/test-main-gen.ts @@ -535,8 +535,15 @@ class TestFunctionGenerator { for (const name of decl.names) { if (decl.initialValue) { + // A SETUP/TEST var is a declaration, so `p : Point := (x := 1.0)` is the + // legal form and must go through the initializer lowering — the plain + // expression emitter has no target type and cannot lower it. lines.push( - `${this.indent}${cppType} ${name} = ${this.testCodegen.emitExpression(decl.initialValue)};`, + `${this.indent}${cppType} ${name} = ${this.testCodegen.emitInitializer( + decl.initialValue, + cppType, + decl.type.name, + )};`, ); } else { lines.push(`${this.indent}${cppType} ${name};`); diff --git a/src/backend/type-codegen.ts b/src/backend/type-codegen.ts index 451a710e..a4b90a35 100644 --- a/src/backend/type-codegen.ts +++ b/src/backend/type-codegen.ts @@ -21,7 +21,12 @@ import type { UnaryExpression, } from "../frontend/ast.js"; import { TypeRegistry, isElementaryType } from "../semantic/type-registry.js"; -import { formatArrayType } from "./codegen-utils.js"; +import { + formatArrayType, + formatIntegerLiteral, + translateIECString, +} from "./codegen-utils.js"; +import { mangledMemberName } from "./member-mangling.js"; import { parseDateLiteralToDays, parseDtLiteralToNs, @@ -32,6 +37,10 @@ import { buildEnumMemberMap, type EnumMemberEntry, } from "../semantic/type-utils.js"; +import { + generateInitializerValue, + type StructInitEmitter, +} from "./struct-init-codegen.js"; /** * Options for type code generation @@ -44,6 +53,12 @@ export interface TypeCodeGenOptions { * it can slice per-symbol chunks for tree-shaking. See * `CodeGenOptions.emitChunkMarkers`. */ emitChunkMarkers: boolean; + /** Whether a type name is user-defined, for the shared member-mangling rule + * (see `member-mangling.ts`). `CodeGenerator` injects its own resolution, + * which also recognises function blocks and programs; standalone use falls + * back to "anything that is not elementary", all a bare TypeCodeGenerator + * can tell from a list of type declarations. */ + isUserDefinedType: (typeName: string) => boolean; } /** @@ -53,6 +68,8 @@ export const defaultTypeCodeGenOptions: TypeCodeGenOptions = { indent: " ", lineEnding: "\n", emitChunkMarkers: false, + isUserDefinedType: (typeName: string) => + !isElementaryType(typeName.toUpperCase()), }; /** @@ -137,6 +154,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 }; } @@ -299,16 +332,26 @@ export class TypeCodeGenerator { cppType += "*"; } for (const fieldName of field.names) { - // Mangle field name if it matches its user-defined type name - // to avoid GCC -Wchanges-meaning error. Compare against the ST - // type name, not cppType (which may include pointer '*' suffix). - const emitName = - !isElementaryType(field.type.name.toUpperCase()) && - fieldName.toUpperCase() === field.type.name.toUpperCase() - ? `${fieldName}_` - : fieldName; + // One rule, shared with the class definition and the debug table — see + // member-mangling.ts. Compare against the ST type name, not cppType + // (which may carry a pointer '*' suffix). A STRUCT implements no + // interfaces, so only the type collision can apply here. + const emitName = mangledMemberName(fieldName, field.type.name, { + isUserDefinedType: this.options.isUserDefinedType, + }); if (field.initialValue) { - const initVal = this.expressionToCpp(field.initialValue); + // 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/literal-utils.ts b/src/literal-utils.ts new file mode 100644 index 00000000..5c98e0eb --- /dev/null +++ b/src/literal-utils.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * Exact handling of IEC 61131-3 integer literals. + * + * LINT and ULINT span the full 64 bits, which a JS `number` cannot represent: + * `9007199254740993` parses as ...992, and `18446744073709551615` rounds past + * the type's own maximum. Anything that has to agree with the source digits — + * codegen's lowering, the analyzer's range check — works from the `bigint` here + * rather than from the parsed `number` on the AST node. + * + * Lives at the root rather than under `backend/` or `semantic/` because both + * layers need it and neither should depend on the other for it. + */ + +/** Widest value any IEC 61131-3 integer type (ULINT) can hold. */ +export const IEC_INTEGER_MAX = 18446744073709551615n; + +/** Narrowest value any IEC 61131-3 integer type (LINT) can hold. */ +export const IEC_INTEGER_MIN = -9223372036854775808n; + +/** + * Exact value of an IEC integer literal, or undefined when `raw` is not one. + * + * Accepts the based forms (16#FF, 8#77, 2#1010), a plain decimal, an optional + * sign, and IEC underscore separators. A typed prefix (`INT#5`) must be + * stripped by the caller — only the value part is parsed here. + */ +export function exactIntegerLiteralValue(raw: string): bigint | undefined { + const upper = raw.trim().toUpperCase().replace(/_/g, ""); + const sign = upper.startsWith("-") ? -1n : 1n; + const digits = /^[+-]/.test(upper) ? upper.slice(1) : upper; + let normalized: string; + if (/^16#[0-9A-F]+$/.test(digits)) normalized = "0x" + digits.slice(3); + else if (/^8#[0-7]+$/.test(digits)) normalized = "0o" + digits.slice(2); + else if (/^2#[01]+$/.test(digits)) normalized = "0b" + digits.slice(2); + else if (/^[0-9]+$/.test(digits)) normalized = digits; + else return undefined; + try { + return sign * BigInt(normalized); + } catch { + return undefined; + } +} diff --git a/src/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..9a51ae97 100644 --- a/src/semantic/analyzer.ts +++ b/src/semantic/analyzer.ts @@ -9,6 +9,7 @@ import type { Argument, + ArrayLiteralExpression, AssertCall, CompilationUnit, ElementaryType, @@ -16,8 +17,10 @@ import type { Expression, FunctionBlockDeclaration, FunctionCallExpression, + LiteralExpression, MethodDeclaration, MockFunctionStatement, + TypeDeclaration, TypeDefinition, TypeReference, VarBlock, @@ -39,9 +42,24 @@ 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"; +import { + exactIntegerLiteralValue, + IEC_INTEGER_MAX, + IEC_INTEGER_MIN, +} from "../literal-utils.js"; // ============================================================================= // Located Variable Address Parsing @@ -670,13 +688,401 @@ 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); + + // Validate that structure initializers only appear where IEC allows them + this.validateStructInitializerPlacement(ast); + + // Validate that integer literals fit an IEC integer type + this.validateIntegerLiteralRange(ast); + // TODO: Implement additional semantic validation - // - 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; + } + } + } + + /** + * Reject a structure initializer written anywhere but a declaration's initial + * value. + * + * `structure_initialization` (Annex B.1.4.3) belongs to `var_init_decl`; it is + * not an expression, so IEC has no position for it inside a statement. The + * lowering needs the target's C++ type, which only a declaration supplies — + * reaching codegen without one used to value-initialise silently, so + * + * arr := [(x := 1.0), (x := 2.0)]; -> ARR = {{}, {}}; + * f(P := (x := 3.0)); -> F.P = {}; + * + * compiled clean and ran with every written element discarded, the members + * left at their declared defaults. Reported here instead, against the source. + * + * The walk prunes at every initial value a declaration can carry — a variable + * or STRUCT element's (`VarDeclaration.initialValue`) and a type-level default's + * (`TypeDeclaration.defaultValue`, Annex B.1.3.3) — so the legal forms, including + * a structure initializer nested inside an array literal, are never visited. + */ + private validateStructInitializerPlacement(ast: CompilationUnit): void { + // Identity set rather than a node-kind test: only the initializer's own root + // is legal, and pruning there covers everything beneath it. + const declarationInitializers = new Set(); + walkAST(ast, (node) => { + if (node.kind === "VarDeclaration") { + const decl = node as VarDeclaration; + if (decl.initialValue) declarationInitializers.add(decl.initialValue); + } else if (node.kind === "TypeDeclaration") { + const type = node as TypeDeclaration; + if (type.defaultValue) declarationInitializers.add(type.defaultValue); + } + }); + + walkAST(ast, (node) => { + if (declarationInitializers.has(node as Expression)) return false; + if (node.kind !== "StructInitializerExpression") return; + const span = node.sourceSpan; + this.addError( + "A structure initializer '(NAME := value, ...)' is only valid as a " + + "variable's initial value in a declaration, not inside a statement. " + + "Assign the elements individually instead.", + span.startLine, + span.startCol, + span.file, + ); + // One diagnostic per initializer, not one per nesting level. + return false; + }); + } + + /** + * Reject an integer literal that no IEC 61131-3 integer type can hold. + * + * The widest are LINT (signed 64-bit) and ULINT (unsigned 64-bit), so a value + * outside `[LINT_MIN, ULINT_MAX]` is a mistake against *every* declared type + * and can be reported without knowing which one it initialises — the same + * conservative rule the array-shape checks follow. In range but wrong for the + * specific type (`INT := 70000`) is left to the type checker. + * + * Checked on the exact value rather than the parsed `number`, which rounds + * above 2^53; codegen lowers from the same exact value (see + * `formatIntegerLiteral`), so the two agree on what is representable. + */ + private validateIntegerLiteralRange(ast: CompilationUnit): void { + walkAST(ast, (node) => { + if (node.kind !== "LiteralExpression") return; + const literal = node as LiteralExpression; + if (literal.literalType !== "INT") return; + const exact = exactIntegerLiteralValue(literal.rawValue); + if (exact === undefined) return; + // A negative literal parses as unary minus over a positive one, so the + // magnitude LINT_MIN needs the unsigned bound to stay accepted here. + if (exact <= IEC_INTEGER_MAX && exact >= IEC_INTEGER_MIN) return; + const span = literal.sourceSpan; + this.addError( + `Integer literal '${literal.rawValue}' is outside the range of every ` + + `IEC 61131-3 integer type (LINT holds ${IEC_INTEGER_MIN} to ` + + `${-IEC_INTEGER_MIN - 1n}, ULINT holds 0 to ${IEC_INTEGER_MAX}).`, + span.startLine, + span.startCol, + span.file, + ); + }); + } + /** * Validate that no assignments target CONSTANT variables. */ @@ -2783,6 +3189,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/src/version-build.ts b/src/version-build.ts index 80f1aeaa..7877e86f 100644 --- a/src/version-build.ts +++ b/src/version-build.ts @@ -2,4 +2,4 @@ // Copyright (C) 2025 Autonomy / OpenPLC Project // AUTO-GENERATED by scripts/rebuild-libs.mjs from package.json. Do not edit by hand — // any changes are overwritten on the next build. -export const STRUCPP_VERSION_BUILD = "0.6.1"; +export const STRUCPP_VERSION_BUILD = "0.6.3"; diff --git a/tests/backend/codegen-integer-literal-exact.test.ts b/tests/backend/codegen-integer-literal-exact.test.ts new file mode 100644 index 00000000..90765a08 --- /dev/null +++ b/tests/backend/codegen-integer-literal-exact.test.ts @@ -0,0 +1,244 @@ +/** + * Integer literals must lower to C++ with every digit intact. + * + * LINT and ULINT span the full 64 bits, which a JS `number` cannot hold. The + * lowering used to go through `String(expr.value)` over a `parseInt` result, so + * anything past 2^53 was rounded on the way out: + * + * x : LINT := 9007199254740993; -> X(9007199254740992) wrong value + * y : LINT := 9223372036854775807; -> Y(9223372036854776000) past INT64_MAX + * z : ULINT := 18446744073709551615; -> Z(18446744073709552000) g++ rejects it + * + * The first is silent, the last two break the C++ build — LINT/ULINT bounds are + * exactly the values most likely to be written as sentinels. + * + * Re-emitting the raw digits instead is not the fix: a leading zero is an octal + * prefix in C++, so `0010` would become 8 and `008` a compile error. The value + * is re-derived exactly (bigint) and normalized, and a value above INT64_MAX + * gets a `ULL` suffix because a C++ decimal literal is only ever given a signed + * type (C++17 [lex.icon]/3). + * + * One lowering serves every position — PROGRAM/VAR_GLOBAL initializers, STRUCT + * element defaults, and statement bodies — so a literal cannot mean one thing in + * a declaration and another in an assignment. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +function compileOk(source: string): { cpp: string; header: string } { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return { cpp: result.cppCode, header: result.headerCode }; +} + +/** The constructor initializer-list line for a program. */ +function initList(cpp: string): string { + return cpp.split("\n").find((l) => l.trimStart().startsWith(": ")) ?? ""; +} + +describe("64-bit integer literals keep every digit", () => { + it("keeps a PROGRAM VAR initializer above 2^53 exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := 9007199254740993; END_VAR + x := x; + END_PROGRAM + `); + expect(initList(cpp)).toContain("X(9007199254740993)"); + }); + + it("keeps LINT_MAX and suffixes ULINT_MAX so C++ can name the type", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + y : LINT := 9223372036854775807; + z : ULINT := 18446744073709551615; + END_VAR + y := y; + END_PROGRAM + `); + const inits = initList(cpp); + expect(inits).toContain("Y(9223372036854775807)"); + // Unsuffixed, this decimal names no C++ type at all. + expect(inits).toContain("Z(18446744073709551615ULL)"); + }); + + it("keeps a file-level VAR_GLOBAL definition exact", () => { + const { header } = compileOk(` + VAR_GLOBAL + g : LINT := 9007199254740993; + u : ULINT := 18446744073709551615; + END_VAR + PROGRAM Main + VAR_EXTERNAL g : LINT; END_VAR + g := g; + END_PROGRAM + `); + expect(header).toContain("IEC_LINT G = 9007199254740993;"); + expect(header).toContain("IEC_ULINT U = 18446744073709551615ULL;"); + }); + + it("keeps a STRUCT element default exact", () => { + const { header } = compileOk(` + TYPE + Big : STRUCT + a : LINT := 9007199254740993; + b : ULINT := 18446744073709551615; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR s : Big; END_VAR + s.a := s.a; + END_PROGRAM + `); + expect(header).toContain("IEC_LINT A = 9007199254740993;"); + expect(header).toContain("IEC_ULINT B = 18446744073709551615ULL;"); + }); + + it("keeps a statement-body literal exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT; END_VAR + x := 9007199254740993; + END_PROGRAM + `); + expect(cpp).toContain("X = 9007199254740993;"); + }); + + it("agrees between the declaration and the statement path", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := 9007199254740993; END_VAR + x := 9007199254740993; + END_PROGRAM + `); + expect(initList(cpp)).toContain("X(9007199254740993)"); + expect(cpp).toContain("X = 9007199254740993;"); + }); + + it("keeps a typed-prefix 64-bit literal exact", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR x : LINT := LINT#9007199254740993; END_VAR + x := x; + END_PROGRAM + `); + expect(initList(cpp)).toContain( + "X(static_cast(9007199254740993))", + ); + }); + + it("leaves 64-bit based literals in their own notation", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR h : ULINT := 16#FFFFFFFFFFFFFFFF; END_VAR + h := h; + END_PROGRAM + `); + // A hex literal may take an unsigned type on its own, so no suffix is needed. + expect(initList(cpp)).toContain("H(0xFFFFFFFFFFFFFFFF)"); + }); +}); + +describe("decimal literals are normalized, never copied raw", () => { + it("does not turn a leading zero into a C++ octal constant", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + a : INT := 007; + b : INT := 0010; + c : INT := 008; + END_VAR + a := a; + END_PROGRAM + `); + const inits = initList(cpp); + // Raw passthrough would give 0010 (octal 8) and 008 (a g++ error). + expect(inits).toContain("A(7)"); + expect(inits).toContain("B(10)"); + expect(inits).toContain("C(8)"); + }); + + it("strips IEC digit separators", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR n : DINT := 1_000_000; END_VAR + n := n; + END_PROGRAM + `); + expect(initList(cpp)).toContain("N(1000000)"); + }); + + it("still lowers based and typed literals as before", () => { + const { cpp } = compileOk(` + PROGRAM Main + VAR + h : UDINT := 16#FF; + o : UDINT := 8#17; + b : UDINT := 2#1010; + t : INT := INT#5; + END_VAR + h := h; + END_PROGRAM + `); + const inits = initList(cpp); + expect(inits).toContain("H(0xFF)"); + expect(inits).toContain("O(017)"); + expect(inits).toContain("B(0b1010)"); + expect(inits).toContain("T(static_cast(5))"); + }); +}); + +describe("integer literals outside every IEC integer type", () => { + it("rejects a value wider than ULINT", () => { + const result = compile(` + PROGRAM Main + VAR a : ULINT := 99999999999999999999999; END_VAR + a := a; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(result.errors[0]!.message).toContain( + "outside the range of every IEC 61131-3 integer type", + ); + }); + + it("rejects one in a statement body too", () => { + const result = compile(` + PROGRAM Main + VAR a : ULINT; END_VAR + a := 99999999999999999999999; + END_PROGRAM + `); + expect(result.success).toBe(false); + expect(result.errors[0]!.message).toContain( + "outside the range of every IEC 61131-3 integer type", + ); + }); + + it("accepts both 64-bit bounds, and LINT_MIN written with a sign", () => { + // LINT_MIN parses as unary minus over 9223372036854775808, whose magnitude + // exceeds LINT_MAX — the check has to allow it or it would flag valid code. + compileOk(` + PROGRAM Main + VAR + lo : LINT := -9223372036854775808; + hi : LINT := 9223372036854775807; + u : ULINT := 18446744073709551615; + END_VAR + lo := lo; + END_PROGRAM + `); + }); + + it("accepts the widest based literal", () => { + compileOk(` + PROGRAM Main + VAR h : ULINT := 16#FFFFFFFFFFFFFFFF; END_VAR + h := h; + END_PROGRAM + `); + }); +}); diff --git a/tests/backend/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..d1bb3972 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 = ` @@ -380,3 +435,211 @@ END_CONFIGURATION }); }); }); + +describe("member whose name matches its type", () => { + /** + * CODESYS allows `RunningLights : RunningLights`, and real projects use it. Codegen + * emits that member as `RUNNINGLIGHTS_` because GCC rejects a member that changes the + * meaning of its own type name — so the debug table has to address it by the same + * name. When it did not, every entry for the instance named a member that does not + * exist and `generated_debug.cpp` failed to compile, taking the whole build with it. + */ + const src = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK + +PROGRAM Main +VAR + Motor : Motor; + plain : BOOL; +END_VAR + Motor(run := plain); +END_PROGRAM + +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + + it("addresses it by the mangled name codegen emitted", () => { + const result = compile(src); + expect(result.success).toBe(true); + + const cpp = result.debugTableCpp ?? ""; + // The declaration codegen produced, and the reference the table must match. + expect(result.headerCode ?? "").toContain("MOTOR MOTOR_;"); + expect(cpp).toContain("g_config.INSTANCE0.MOTOR_.RUN"); + // Only the C++ expression is mangled; the trailing comment keeps the ST path the + // editor shows the user. + const addresses = cpp + .split("\n") + .map((line) => line.split("//")[0]) + .join("\n"); + expect(addresses).not.toMatch(/INSTANCE0\.MOTOR\./); + }); + + it("leaves a member whose name differs from its type alone", () => { + const cpp = compile(src).debugTableCpp ?? ""; + expect(cpp).toContain("g_config.INSTANCE0.PLAIN"); + expect(cpp).not.toContain("PLAIN_"); + }); +}); + +describe("member mangling agrees with the class definition", () => { + /** + * The table addresses members by name, so it has to name exactly what codegen + * declared — in both directions. Mangling too little names a member that does + * not exist; mangling too much does the same in reverse. Either way + * `generated_debug.cpp` fails to compile and takes the firmware build with it, + * and nothing catches it earlier because `strucpp file.st` emits no table. + * + * The rule now lives in one place (`member-mangling.ts`) and covers both + * collisions — a member named after its own type, and a member named after an + * interface method the owning FB implements — at every site the table builds a + * member expression: PROGRAM variables, FB members, and STRUCT fields. + */ + const CFG = ` +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + + /** Debug-table entry addresses, with the trailing ST-path comments stripped. */ + function addresses(source: string): string { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return (result.debugTableCpp ?? "") + .split("\n") + .map((line) => line.split("//")[0]) + .join("\n"); + } + + function header(source: string): string { + return compile(source).headerCode ?? ""; + } + + const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + + it("mangles a colliding member of a FUNCTION_BLOCK, not just of a PROGRAM", () => { + const src = `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR r : Rig; END_VAR + r(); +END_PROGRAM${CFG}`; + expect(header(src)).toContain("MOTOR MOTOR_;"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.R.MOTOR_.RUN"); + expect(addr).not.toMatch(/\.R\.MOTOR\./); + // A sibling that does not collide is untouched. + expect(addr).toContain("g_config.INSTANCE0.R.IDLE"); + }); + + it("mangles a colliding STRUCT field", () => { + const src = ` +TYPE + Inner : STRUCT v : BOOL; END_STRUCT; + Rig : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.plain := FALSE; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("INNER INNER_"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.R.INNER_.V"); + expect(addr).not.toMatch(/\.R\.INNER\./); + expect(addr).toContain("g_config.INSTANCE0.R.PLAIN"); + }); + + it("mangles a member colliding with an implemented interface method", () => { + // Codegen renames the variable because the method already owns the name; + // addressing `.START` would take the address of the member function instead + // ("cannot create a non-constant pointer to member function"). + const src = ` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_BOOL START_;"); + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.D.START_"); + expect(addr).not.toMatch(/\.D\.START\b(?!_)/); + expect(addr).toContain("g_config.INSTANCE0.D.OTHER"); + }); + + it("does NOT mangle a variable named after an elementary type", () => { + // `Time : TIME` is an ordinary declaration — codegen emits it as plain + // `TIME`, because the collision rule only applies to user-defined types. A + // name-only comparison would mangle it and address a `TIME_` that does not + // exist, breaking a build that works today. + const src = ` +PROGRAM Main +VAR Time : TIME; Word : WORD; Date : DATE; Real : REAL; END_VAR + Time := T#0s; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_TIME TIME;"); + const addr = addresses(src); + for (const name of ["TIME", "WORD", "DATE", "REAL"]) { + expect(addr).toContain(`g_config.INSTANCE0.${name},`); + } + expect(addr).not.toContain("_,"); + }); + + it("does NOT mangle elementary-named members of an FB or a STRUCT", () => { + const src = ` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR Time : TIME; b : Bag; END_VAR + Time := T#0s; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; g : Bag; END_VAR + h(); +END_PROGRAM${CFG}`; + const addr = addresses(src); + expect(addr).toContain("g_config.INSTANCE0.H.TIME,"); + expect(addr).toContain("g_config.INSTANCE0.H.B.TIME,"); + expect(addr).toContain("g_config.INSTANCE0.G.WORD,"); + expect(addr).not.toContain("TIME_"); + expect(addr).not.toContain("WORD_"); + }); + + it("mangles a variable named after an enum type, matching codegen", () => { + const src = ` +TYPE Color : (Red, Green, Blue); END_TYPE +PROGRAM Main +VAR Color : Color; plain : BOOL; END_VAR + plain := FALSE; +END_PROGRAM${CFG}`; + expect(header(src)).toContain("IEC_COLOR COLOR_;"); + expect(addresses(src)).toContain("g_config.INSTANCE0.COLOR_"); + }); +}); diff --git a/tests/backend/member-mangling.test.ts b/tests/backend/member-mangling.test.ts new file mode 100644 index 00000000..9b5c9048 --- /dev/null +++ b/tests/backend/member-mangling.test.ts @@ -0,0 +1,288 @@ +/** + * Every emitter that names a C++ member has to agree on the mangling rule, + * because they all name the same entity. This file asserts that agreement + * directly, rather than testing each emitter in isolation — the bugs in this + * area have all been one emitter drifting from another, and only a + * cross-emitter assertion catches that. + * + * A member is renamed for two reasons (see `src/backend/member-mangling.ts`): + * its name matches its own type's name, or it matches an interface method the + * owning FB implements. Both are case-insensitive, because ST names are — + * `rig : Rig` collides just as `Rig : Rig` does. + * + * The inverse matters as much: elementary type names are not reserved, so + * `Time : TIME` is an ordinary declaration that must stay unmangled everywhere. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + +function build(source: string): { header: string; cpp: string } { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return { header: result.headerCode, cpp: result.cppCode }; +} + +/** The constructor initializer-list line for a program. */ +function initList(cpp: string): string { + return cpp.split("\n").find((l) => l.trimStart().startsWith(": ")) ?? ""; +} + +describe("declaration and constructor initializer list agree", () => { + it("mangles an initialised PROGRAM member in both places", () => { + // The declaration was mangled but the initializer list was not, so any + // colliding member *with an initialiser* failed to compile: + // error: member initializer 'AIRANGE' does not name a non-static data + // member or base class + const { header, cpp } = build(` +TYPE AiRange : STRUCT lo : REAL := 4.0; hi : REAL := 20.0; END_STRUCT; END_TYPE +PROGRAM Main +VAR AiRange : AiRange := (hi := 22.0); n : INT; END_VAR + n := n + 1; +END_PROGRAM`); + expect(header).toContain("AIRANGE AIRANGE_;"); + expect(initList(cpp)).toContain("AIRANGE_("); + // The unmangled spelling must not appear as an initializer target. + expect(initList(cpp)).not.toMatch(/(^|[\s:,])AIRANGE\(/); + }); + + it("matches case-insensitively, as ST names do", () => { + const { header, cpp } = build(` +TYPE Range1 : STRUCT lo : REAL := 1.0; END_STRUCT; END_TYPE +PROGRAM Main +VAR range1 : Range1 := (lo := 3.0); n : INT; END_VAR + n := n + 1; +END_PROGRAM`); + expect(header).toContain("RANGE1 RANGE1_;"); + expect(initList(cpp)).toContain("RANGE1_("); + }); + + it("leaves an initialised elementary-named member unmangled in both places", () => { + const { header, cpp } = build(` +PROGRAM Main +VAR Time : TIME := T#5s; Word : WORD := 16#FF; END_VAR + Word := Word; +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME;"); + expect(header).toContain("IEC_WORD WORD;"); + const inits = initList(cpp); + expect(inits).toContain("TIME("); + expect(inits).toContain("WORD("); + expect(inits).not.toContain("TIME_("); + expect(inits).not.toContain("WORD_("); + }); + + it("already agreed for a FUNCTION_BLOCK member, and still does", () => { + const { header, cpp } = build(` +TYPE AiRange : STRUCT lo : REAL := 4.0; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR AiRange : AiRange := (lo := 9.0); END_VAR + AiRange.lo := AiRange.lo; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; END_VAR + h(); +END_PROGRAM`); + expect(header).toContain("AIRANGE AIRANGE_;"); + expect(cpp).toContain("AIRANGE_("); + }); +}); + +describe("declaration and statement body agree", () => { + it("names the mangled member when the body reaches through it", () => { + const { header, cpp } = build(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; flag : BOOL; END_VAR + Motor(run := TRUE); + flag := Motor.spinning; +END_PROGRAM`); + expect(header).toContain("MOTOR MOTOR_;"); + expect(cpp).toContain("MOTOR_.SPINNING"); + }); + + it("leaves an elementary-named member alone in the body", () => { + const { header, cpp } = build(` +PROGRAM Main +VAR Word : WORD; n : INT; END_VAR + Word := WORD#7; + n := n + 1; +END_PROGRAM`); + expect(header).toContain("IEC_WORD WORD;"); + expect(cpp).toContain("WORD = "); + expect(cpp).not.toContain("WORD_"); + }); +}); + +describe("declaration and STRUCT field emission agree", () => { + it("mangles a field named after its own type", () => { + const { header } = build(` +TYPE + Inner : STRUCT v : BOOL; END_STRUCT; + Outer : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR o : Outer; END_VAR + o.plain := FALSE; +END_PROGRAM`); + expect(header).toContain("INNER INNER_"); + expect(header).toContain("IEC_BOOL PLAIN"); + }); + + it("mangles a struct field whose type is a function block", () => { + // Needs codegen's type resolution, not just the type declarations: + // a bare TypeCodeGenerator cannot tell that MOTOR is a function block. + const { header } = build(`${MOTOR} +TYPE Rig : STRUCT Motor : Motor; idle : BOOL; END_STRUCT; END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.idle := FALSE; +END_PROGRAM`); + expect(header).toContain("MOTOR MOTOR_"); + }); + + it("leaves an elementary-named struct field alone", () => { + const { header } = build(` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +PROGRAM Main +VAR b : Bag; END_VAR + b.Word := 16#FF; +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME"); + expect(header).toContain("IEC_WORD WORD"); + expect(header).not.toContain("TIME_"); + expect(header).not.toContain("WORD_"); + }); +}); + +describe("interface method collision", () => { + it("mangles the variable, leaving the method to own the name", () => { + const { header, cpp } = build(` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL := TRUE; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM`); + expect(header).toContain("IEC_BOOL START_;"); + expect(header).toContain("virtual IEC_BOOL START();"); + // The initialiser names the variable, not the method. + expect(cpp).toContain("START_("); + }); +}); + +describe("declaration and function-block invocation agree", () => { + /** + * Invoking an FB assigns its inputs, copies VAR_IN_OUT back, and captures + * `=>` outputs — all through `instance.MEMBER`. A parameter named after its + * own type, or after an interface method the FB implements, is declared with + * the underscore, so the bare name reaches nothing: + * + * error: no member named 'READING' in 'strucpp::SENSOR' + */ + const READING = ` +TYPE Reading : STRUCT v : REAL; END_STRUCT; END_TYPE`; + + it("mangles a named input whose name matches its type", () => { + const { header, cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT Reading : Reading; END_VAR +VAR_OUTPUT o : REAL; END_VAR + o := Reading.v; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; END_VAR + s(Reading := inp); +END_PROGRAM`); + expect(header).toContain("READING READING_;"); + expect(cpp).toContain("S.READING_ = INP;"); + }); + + it("mangles a positional input too", () => { + const { cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT Reading : Reading; END_VAR +VAR_OUTPUT o : REAL; END_VAR + o := Reading.v; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; END_VAR + s(inp); +END_PROGRAM`); + expect(cpp).toContain("S.READING_ = INP;"); + }); + + it("mangles an input colliding with an implemented interface method", () => { + const { header, cpp } = build(` +INTERFACE IProbe + METHOD Arm : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Sensor IMPLEMENTS IProbe +VAR_INPUT Arm : BOOL; gain : REAL; END_VAR +VAR_OUTPUT o : REAL; END_VAR + METHOD Arm : BOOL + Arm := TRUE; + END_METHOD + o := gain; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; END_VAR + s(Arm := TRUE, gain := 2.0); +END_PROGRAM`); + expect(header).toContain("IEC_BOOL ARM_;"); + expect(cpp).toContain("S.ARM_ = true;"); + // A non-colliding sibling is untouched. + expect(cpp).toContain("S.GAIN = 2.0;"); + }); + + it("mangles the VAR_IN_OUT copy-back and the => output capture", () => { + const { cpp } = build(`${READING} +FUNCTION_BLOCK Sensor +VAR_INPUT gain : REAL; END_VAR +VAR_OUTPUT Reading : Reading; END_VAR +VAR_IN_OUT acc : Reading; END_VAR + Reading.v := gain; + acc.v := gain; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; tally : Reading; got : Reading; END_VAR + s(gain := 1.0, acc := tally, Reading => got); +END_PROGRAM`); + // copy-out of the inout, and the => capture, both name the mangled member + expect(cpp).toContain("TALLY = S.ACC;"); + expect(cpp).toContain("GOT = S.READING_;"); + }); + + it("leaves an elementary-named FB input alone", () => { + const { header, cpp } = build(` +FUNCTION_BLOCK Sensor +VAR_INPUT Time : TIME; END_VAR +VAR_OUTPUT o : TIME; END_VAR + o := Time; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; END_VAR + s(Time := T#1s); +END_PROGRAM`); + expect(header).toContain("IEC_TIME TIME;"); + expect(cpp).toContain("S.TIME = "); + expect(cpp).not.toContain("S.TIME_"); + }); +}); diff --git a/tests/backend/repl-main-gen.test.ts b/tests/backend/repl-main-gen.test.ts index b25059c2..fac49ddb 100644 --- a/tests/backend/repl-main-gen.test.ts +++ b/tests/backend/repl-main-gen.test.ts @@ -553,3 +553,84 @@ END_PROGRAM`; }); }); }); + +describe('member mangling in VarDescriptor addresses', () => { + /** + * Each descriptor holds `&instance.MEMBER`, so it has to name the member as + * codegen declared it. A variable named after its own type is emitted with a + * trailing underscore (see member-mangling.ts) — without the same rule here + * the REPL binary does not link: + * + * main.cpp:466: error: no member named 'RIG' in 'strucpp::Program_MAIN' + * + * The descriptor's *display* name keeps the un-mangled name (upper-cased, as + * the REPL shows every name), since that is what the user types at the + * prompt — only the address is mangled. + */ + function mainFor(source: string): string { + const result = compile(source); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return generateReplMain(result.ast!, result.projectModel!); + } + + const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + + it('addresses a variable named after its type by the mangled name', () => { + const mainCpp = mainFor(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; plain : BOOL; END_VAR + Motor(run := plain); +END_PROGRAM`); + // Address mangled, display name left as the user wrote it. + expect(mainCpp).toContain('{"MOTOR", VarTypeTag::OTHER, &prog_MAIN.MOTOR_}'); + }); + + it('matches case-insensitively, as ST names do', () => { + // `rig : Rig` is a collision — ST is case-insensitive, so codegen mangles it. + const mainCpp = mainFor(`${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; END_VAR + Motor(run := TRUE); +END_FUNCTION_BLOCK +PROGRAM Main +VAR rig : Rig; END_VAR + rig(); +END_PROGRAM`); + expect(mainCpp).toContain('&prog_MAIN.RIG_}'); + }); + + it('leaves a variable named after an elementary type alone', () => { + // `Time : TIME` is an ordinary declaration; codegen emits plain `TIME`, so + // mangling here would address a member that does not exist. + const mainCpp = mainFor(` +PROGRAM Main +VAR Time : TIME; Word : WORD; counter : INT; END_VAR + counter := counter + 1; +END_PROGRAM`); + expect(mainCpp).toContain('{"TIME", VarTypeTag::TIME, &prog_MAIN.TIME}'); + expect(mainCpp).toContain('{"WORD", VarTypeTag::WORD, &prog_MAIN.WORD}'); + expect(mainCpp).not.toContain('TIME_'); + expect(mainCpp).not.toContain('WORD_'); + }); + + it('applies to program instances under a CONFIGURATION too', () => { + const mainCpp = mainFor(`${MOTOR} +PROGRAM Main +VAR Motor : Motor; END_VAR + Motor(run := TRUE); +END_PROGRAM +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`); + expect(mainCpp).toContain('&config_CONFIG0.INSTANCE0.MOTOR_}'); + }); +}); diff --git a/tests/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/backend/test-main-gen.test.ts b/tests/backend/test-main-gen.test.ts index a2c7cf19..7e21f527 100644 --- a/tests/backend/test-main-gen.test.ts +++ b/tests/backend/test-main-gen.test.ts @@ -5,8 +5,13 @@ */ import { describe, it, expect } from "vitest"; -import { generateTestMain } from "../../src/backend/test-main-gen.js"; +import { + generateTestMain, + buildPOUInfoFromAST, +} from "../../src/backend/test-main-gen.js"; import type { POUInfo } from "../../src/backend/test-main-gen.js"; +import { compile } from "../../src/index.js"; +import { parseTestFile } from "../../src/testing/test-parser.js"; import type { TestFile } from "../../src/testing/test-model.js"; /** Helper to create a basic POUInfo for a program */ @@ -871,3 +876,55 @@ describe("Test Main Generator", () => { }); }); }); + +describe("declaration initializers in a TEST var block", () => { + /** Compile ST for its types, then generate a test main against a parsed .stst. */ + function generateFor(stSource: string, testSource: string): string { + const compiled = compile(stSource); + expect(compiled.errors.map((e) => e.message)).toEqual([]); + const parsed = parseTestFile(testSource, "t.stst"); + expect(parsed.errors).toEqual([]); + const { pous } = buildPOUInfoFromAST(compiled.ast!); + return generateTestMain([parsed.testFile!], { + headerFileName: "generated.hpp", + pous, + ast: compiled.ast!, + isTestBuild: true, + }); + } + + const PROGRAM = ` + TYPE + Point : STRUCT + x : REAL := 9.0; + y : REAL := 8.0; + END_STRUCT; + END_TYPE + PROGRAM Main + VAR n : INT; END_VAR + n := n + 1; + END_PROGRAM + `; + + it("lowers a structure initializer instead of value-initialising it", () => { + // A TEST var is a declaration, so this form is legal here. It used to go + // through the plain expression emitter, which has no target type and + // emitted `POINT P = {};` — every named element silently discarded. + const code = generateFor( + PROGRAM, + `TEST 'local'\n VAR p : Point := (x := 1.5); END_VAR\n ASSERT_TRUE(TRUE);\nEND_TEST\n`, + ); + expect(code).toContain( + "POINT P = strucpp::iec_struct_init([](auto& v0) { v0.X = 1.5; });", + ); + expect(code).not.toContain("POINT P = {};"); + }); + + it("still emits an ordinary scalar initializer unchanged", () => { + const code = generateFor( + PROGRAM, + `TEST 'local'\n VAR k : INT := 5; END_VAR\n ASSERT_TRUE(TRUE);\nEND_TEST\n`, + ); + expect(code).toContain("IEC_INT K = 5;"); + }); +}); diff --git a/tests/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/debug-table-cpp.test.ts b/tests/integration/debug-table-cpp.test.ts new file mode 100644 index 00000000..9d6846db --- /dev/null +++ b/tests/integration/debug-table-cpp.test.ts @@ -0,0 +1,311 @@ +/** + * The generated debug table has to compile. + * + * `debugTableCpp` addresses every leaf variable by name through + * `&g_config.INSTANCE.MEMBER...`, so it only builds if every one of those names + * matches what codegen actually declared. Nothing else in the pipeline checks + * that: `strucpp file.st` emits no debug table, and `--build` (the REPL binary) + * does not include one either. The ST compiles, the program's C++ compiles, and + * the failure appears only in a full firmware build, in a file the user never + * wrote. + * + * That gap let the member-mangling rule drift between the class definition and + * the table, in both directions — mangling too little named a member that does + * not exist (`RunningLights : RunningLights` is declared `RUNNINGLIGHTS_`), + * mangling too much did the same in reverse (`Time : TIME` is declared plain + * `TIME`). Each case below fails to compile if the two disagree. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { execSync } from "child_process"; +import { compile } from "../../src/index.js"; +import { hasGpp } from "./test-helpers.js"; + +const RUNTIME_INCLUDE = path.resolve(__dirname, "../../src/runtime/include"); + +const describeIfGpp = hasGpp ? describe : describe.skip; + +const CFG = ` +CONFIGURATION Config0 + RESOURCE Res0 ON PLC + TASK task0(INTERVAL := T#20ms, PRIORITY := 0); + PROGRAM instance0 WITH task0 : Main; + END_RESOURCE +END_CONFIGURATION`; + +const MOTOR = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK`; + +describeIfGpp("generated debug table compiles", () => { + let tempDir: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-dbgtable-")); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + /** + * Compile ST, then syntax-check the generated debug table against the + * generated header. Returns g++'s output, empty when it compiled. + */ + function buildDebugTable(source: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + expect(result.debugTableCpp, "no debug table was generated").toBeTruthy(); + + const dir = path.join(tempDir, testName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "generated.hpp"), result.headerCode); + const tablePath = path.join(dir, "generated_debug.cpp"); + fs.writeFileSync(tablePath, result.debugTableCpp!); + // The program's own C++ goes through the same member names, so check it in + // the same pass — the invocation paths appear only there. + const programPath = path.join(dir, "generated.cpp"); + fs.writeFileSync(programPath, result.cppCode); + + for (const target of [programPath, tablePath]) { + try { + execSync( + `g++ -std=c++17 -fsyntax-only -I"${RUNTIME_INCLUDE}" -I"${dir}" "${target}" 2>&1`, + { encoding: "utf-8" }, + ); + } catch (e) { + return (e as { stdout?: string }).stdout ?? String(e); + } + } + return ""; + } + + it("compiles for a member whose name matches its type, in a PROGRAM", () => { + expect( + buildDebugTable( + `${MOTOR} +PROGRAM Main +VAR Motor : Motor; plain : BOOL; END_VAR + Motor(run := plain); +END_PROGRAM${CFG}`, + "program_var", + ), + ).toBe(""); + }); + + it("compiles for the same member one scope in, inside a FUNCTION_BLOCK", () => { + // Used to fail: "no member named 'MOTOR' in 'strucpp::RIG'". + expect( + buildDebugTable( + `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR r : Rig; END_VAR + r(); +END_PROGRAM${CFG}`, + "fb_member", + ), + ).toBe(""); + }); + + it("compiles for a STRUCT field whose name matches its type", () => { + // Used to fail: "no member named 'INNER' in 'strucpp::RIG'". + expect( + buildDebugTable( + ` +TYPE + Inner : STRUCT v : BOOL; w : INT; END_STRUCT; + Rig : STRUCT Inner : Inner; plain : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR r : Rig; END_VAR + r.plain := FALSE; +END_PROGRAM${CFG}`, + "struct_field", + ), + ).toBe(""); + }); + + it("compiles for a member colliding with an implemented interface method", () => { + // Used to fail: "cannot create a non-constant pointer to member function" + // — the table took the address of the method rather than the variable. + expect( + buildDebugTable( + ` +INTERFACE IMotor + METHOD Start : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Drive IMPLEMENTS IMotor +VAR Start : BOOL; other : INT; END_VAR + METHOD Start : BOOL + Start := TRUE; + END_METHOD + other := 1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR d : Drive; END_VAR + d(); +END_PROGRAM${CFG}`, + "iface_method", + ), + ).toBe(""); + }); + + it("compiles for variables named after elementary types", () => { + // The inverse failure: mangling these would address a `TIME_` that codegen + // never declared. + expect( + buildDebugTable( + ` +PROGRAM Main +VAR Time : TIME; Word : WORD; Date : DATE; Real : REAL; END_VAR + Time := T#0s; +END_PROGRAM${CFG}`, + "elementary_names", + ), + ).toBe(""); + }); + + it("compiles for elementary-named members of an FB and a STRUCT", () => { + expect( + buildDebugTable( + ` +TYPE Bag : STRUCT Time : TIME; Word : WORD; END_STRUCT; END_TYPE +FUNCTION_BLOCK Holder +VAR Time : TIME; b : Bag; END_VAR + Time := T#0s; +END_FUNCTION_BLOCK +PROGRAM Main +VAR h : Holder; g : Bag; END_VAR + h(); +END_PROGRAM${CFG}`, + "elementary_nested", + ), + ).toBe(""); + }); + + it("compiles for a variable named after an enum type", () => { + expect( + buildDebugTable( + ` +TYPE Color : (Red, Green, Blue); END_TYPE +PROGRAM Main +VAR Color : Color; plain : BOOL; END_VAR + plain := FALSE; +END_PROGRAM${CFG}`, + "enum_name", + ), + ).toBe(""); + }); + + it("compiles for an ordinary project with arrays and nested structs", () => { + // A broad shape check, so this file also guards the table's other address + // forms against the next change to the walker. + expect( + buildDebugTable( + ` +TYPE + Point : STRUCT x : REAL; y : REAL; END_STRUCT; + Frame : STRUCT origin : Point; label : BOOL; END_STRUCT; +END_TYPE +PROGRAM Main +VAR + grid : ARRAY[0..2] OF Point; + frame : Frame; + counts : ARRAY[1..4] OF INT; + flag : BOOL; +END_VAR + flag := FALSE; +END_PROGRAM${CFG}`, + "ordinary", + ), + ).toBe(""); + }); + + it("compiles for an FB whose parameters collide, invoked with all forms", () => { + // The invocation assigns inputs, copies VAR_IN_OUT back and captures `=>` + // through `instance.MEMBER`; a colliding parameter used to reach nothing + // ("no member named 'READING' in 'strucpp::SENSOR'"). Compiling the program + // is the assertion here — the table only exercises the declarations. + expect( + buildDebugTable( + ` +TYPE Reading : STRUCT v : REAL; END_STRUCT; END_TYPE +INTERFACE IProbe + METHOD Arm : BOOL + END_METHOD +END_INTERFACE +FUNCTION_BLOCK Sensor IMPLEMENTS IProbe +VAR_INPUT Reading : Reading; Arm : BOOL; gain : REAL; END_VAR +VAR_OUTPUT out1 : REAL; END_VAR +VAR_IN_OUT acc : Reading; END_VAR + METHOD Arm : BOOL + Arm := TRUE; + END_METHOD + out1 := Reading.v * gain; + acc.v := out1; +END_FUNCTION_BLOCK +PROGRAM Main +VAR s : Sensor; inp : Reading; tally : Reading; got : REAL; END_VAR + s(Reading := inp, Arm := TRUE, gain := 2.0, acc := tally, out1 => got); +END_PROGRAM${CFG}`, + "fb_invocation", + ), + ).toBe(""); + }); + + it("compiles for multi-dimensional arrays", () => { + // `Array2D`/`Array3D` have no chained `[i][j]` operator, so the table has to + // address an element as `(i, j)` — `formatArrayElementAccess` owns that rank + // rule. Until it did, any project with a 2D array and debug enabled failed + // to build; this keeps the table and the runtime containers in step. + expect( + buildDebugTable( + ` +TYPE Point : STRUCT x : REAL; y : REAL; END_STRUCT; END_TYPE +PROGRAM Main +VAR + counts : ARRAY[0..1, 0..1] OF INT; + cube : ARRAY[0..1, 0..1, 0..1] OF BOOL; + places : ARRAY[0..1, 0..1] OF Point; + flag : BOOL; +END_VAR + flag := FALSE; +END_PROGRAM${CFG}`, + "multi_dim", + ), + ).toBe(""); + }); + + it("compiles for a multi-dimensional array of a type whose name matches its member", () => { + // Both rules on the same expression: the rank-aware subscript from + // `formatArrayElementAccess` and the member mangling underneath it. + expect( + buildDebugTable( + `${MOTOR} +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := idle); +END_FUNCTION_BLOCK +PROGRAM Main +VAR bank : ARRAY[0..1, 0..1] OF Rig; END_VAR + bank[0, 0](); +END_PROGRAM${CFG}`, + "multi_dim_mangled", + ), + ).toBe(""); + }); +}); diff --git a/tests/integration/integer-literal-exact-cpp.test.ts b/tests/integration/integer-literal-exact-cpp.test.ts new file mode 100644 index 00000000..ea751b0b --- /dev/null +++ b/tests/integration/integer-literal-exact-cpp.test.ts @@ -0,0 +1,170 @@ +/** + * End-to-end proof that 64-bit integer literals survive the whole pipeline: the + * generated C++ must compile with g++ -std=c++17 AND print back the digits the + * ST source wrote. + * + * Compiling is not sufficient evidence here. `9007199254740993` lowered to + * `9007199254740992` compiles perfectly — the defect is only visible by running + * the binary and comparing values. The two bounds are the other half: they used + * to lower to a decimal past INT64_MAX / UINT64_MAX, which g++ warns on or + * rejects outright, so "it builds" is itself part of the assertion. + * + * -Werror is deliberate: `18446744073709551615` without the `ULL` suffix is a + * GCC extension that warns rather than fails, and a warning is exactly the + * failure mode this test exists to catch. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { compile } from "../../src/index.js"; +import { hasGpp, createPCH, compileAndRunStandalone } from "./test-helpers.js"; + +const describeIfGpp = hasGpp ? describe : describe.skip; + +describeIfGpp("64-bit integer literals — generated C++", () => { + let tempDir: string; + let pchPath: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "strucpp-intlit-")); + pchPath = createPCH(tempDir); + }); + + afterAll(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function run(source: string, mainBody: string, testName: string): string { + const result = compile(source, { headerFileName: "generated.hpp" }); + expect(result.errors.map((e) => e.message)).toEqual([]); + expect(result.success).toBe(true); + return compileAndRunStandalone({ + tempDir, + pchPath, + headerCode: result.headerCode, + cppCode: result.cppCode, + testName, + // An unsuffixed `18446744073709551615` is a GCC *extension* that warns + // rather than fails, so the warning has to be the failure here. + extraFlags: ["-Werror=implicitly-unsigned-literal", "-Werror=overflow"], + mainCode: `#include \n\nint main() {\n using namespace strucpp;\n${mainBody}\n return 0;\n}\n`, + }); + } + + it("round-trips a VAR_GLOBAL value above 2^53", () => { + const output = run( + ` + VAR_GLOBAL + big : LINT := 9007199254740993; + END_VAR + `, + ` std::cout << BIG << std::endl;`, + "intlit_global_2p53", + ); + // Rounded through a double this prints ...992. + expect(output).toBe("9007199254740993"); + }); + + it("round-trips both 64-bit bounds", () => { + const output = run( + ` + VAR_GLOBAL + hi : LINT := 9223372036854775807; + lo : LINT := -9223372036854775808; + u : ULINT := 18446744073709551615; + END_VAR + `, + ` std::cout << HI << " " << LO << " " << U << std::endl;`, + "intlit_global_bounds", + ); + expect(output).toBe( + "9223372036854775807 -9223372036854775808 18446744073709551615", + ); + }); + + it("round-trips a PROGRAM variable initializer", () => { + const output = run( + ` + PROGRAM Main + VAR + x : LINT := 9007199254740993; + u : ULINT := 18446744073709551615; + END_VAR + x := x; + END_PROGRAM + `, + ` Program_MAIN p;\n std::cout << p.X.get() << " " << p.U.get() << std::endl;`, + "intlit_program", + ); + expect(output).toBe("9007199254740993 18446744073709551615"); + }); + + it("round-trips a STRUCT element default", () => { + const output = run( + ` + TYPE + Big : STRUCT + a : LINT := 9007199254740993; + b : ULINT := 18446744073709551615; + END_STRUCT; + END_TYPE + VAR_GLOBAL + s : Big; + END_VAR + `, + ` std::cout << S.A << " " << S.B << std::endl;`, + "intlit_struct_default", + ); + expect(output).toBe("9007199254740993 18446744073709551615"); + }); + + it("agrees between a declaration initializer and the same literal assigned in a body", () => { + const output = run( + ` + PROGRAM Main + VAR + declared : LINT := 9007199254740993; + assigned : LINT; + END_VAR + assigned := 9007199254740993; + END_PROGRAM + `, + ` Program_MAIN p;\n p.run();\n std::cout << (p.DECLARED.get() == p.ASSIGNED.get() ? "same" : "differ") << " " << p.ASSIGNED.get() << std::endl;`, + "intlit_decl_vs_body", + ); + expect(output).toBe("same 9007199254740993"); + }); + + it("round-trips the widest based literal", () => { + const output = run( + ` + VAR_GLOBAL + h : ULINT := 16#FFFFFFFFFFFFFFFF; + END_VAR + `, + ` std::cout << H << std::endl;`, + "intlit_based_max", + ); + expect(output).toBe("18446744073709551615"); + }); + + it("does not read a leading-zero decimal as octal", () => { + const output = run( + ` + VAR_GLOBAL + a : INT := 007; + b : INT := 0010; + c : INT := 008; + END_VAR + `, + ` std::cout << A << " " << B << " " << C << std::endl;`, + "intlit_leading_zero", + ); + // Raw digits would make B octal 8, and C would not compile at all. + expect(output).toBe("7 10 8"); + }); +}); diff --git a/tests/integration/repl-runner.test.ts b/tests/integration/repl-runner.test.ts index c0cd49ab..aaa89477 100644 --- a/tests/integration/repl-runner.test.ts +++ b/tests/integration/repl-runner.test.ts @@ -380,4 +380,57 @@ END_PROGRAM`; // C++ side should also show statement code expect(output).toContain('COUNT = COUNT + 1'); }); + + /** + * A variable named after its own type is declared with a trailing underscore + * (GCC rejects a member that changes the meaning of its type name), so the + * REPL's VarDescriptor addresses have to use the same name. When they did not, + * the binary simply failed to build: + * + * main.cpp: error: no member named 'RIG' in 'strucpp::Program_MAIN' + * + * Building and running is the assertion — nothing else in the pipeline links + * the REPL harness against the generated header. + */ + it('builds and runs with members named after their own type', () => { + const source = ` +FUNCTION_BLOCK Motor +VAR_INPUT run : BOOL; END_VAR +VAR_OUTPUT spinning : BOOL; END_VAR + spinning := run; +END_FUNCTION_BLOCK + +FUNCTION_BLOCK Rig +VAR Motor : Motor; idle : BOOL; END_VAR + Motor(run := NOT idle); +END_FUNCTION_BLOCK + +TYPE AiRange : STRUCT lo : REAL := 4.0; hi : REAL := 20.0; END_STRUCT; END_TYPE + +PROGRAM Main +VAR + Motor : Motor; + rig : Rig; + (* an *initialised* colliding member also exercises the constructor + initializer list, which named the un-mangled member *) + AiRange : AiRange := (hi := 22.0); + Time : TIME; + Word : WORD; + counter : INT; +END_VAR + counter := counter + 1; + Motor(run := TRUE); + rig(); + Word := WORD#7; +END_PROGRAM`; + const output = buildAndRun( + source, + ['run 3', 'get MAIN.COUNTER', 'get MAIN.WORD', 'quit'].join('\n'), + 'member_mangling', + ); + // Both the mangled composites and the elementary-named scalars are exposed + // under the names the user wrote. + expect(output).toContain('MAIN.COUNTER : INT = 3'); + expect(output).toContain('MAIN.WORD : WORD = 16#0007'); + }); }); 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/struct-initializer-placement.test.ts b/tests/semantic/struct-initializer-placement.test.ts new file mode 100644 index 00000000..71e4b78b --- /dev/null +++ b/tests/semantic/struct-initializer-placement.test.ts @@ -0,0 +1,237 @@ +/** + * A structure initializer `(NAME := value, ...)` is `structure_initialization` + * (Annex B.1.4.3), part of `var_init_decl` — not an expression. IEC therefore + * has no position for it inside a statement. + * + * It used to reach codegen from three real statement positions, where the + * target's C++ type is unknown and the emitter value-initialised instead: + * + * arr := [(x := 1.0), (x := 2.0)]; -> ARR = {{}, {}}; + * f(P := (x := 3.0)); -> F.P = {}; + * s := (x := 1.0); -> S = {}; + * + * Each compiled clean, produced no diagnostic, and ran with every written + * element discarded — the members left at whatever their own declarations + * defaulted to. Now reported against the source. + * + * The accepted half is the point of the check: every position where IEC *does* + * allow the form has to keep working, or this blocks valid code. + */ + +import { describe, it, expect } from "vitest"; +import { compile } from "../../src/index.js"; + +const POINT = ` +TYPE + Point : STRUCT + x : REAL := 9.0; + y : REAL := 8.0; + END_STRUCT; +END_TYPE +`; + +function errorsFor(source: string): string[] { + return compile(source).errors.map((e) => e.message); +} + +function expectClean(source: string): void { + expect(errorsFor(source)).toEqual([]); +} + +/** The one diagnostic this check emits. */ +const PLACEMENT = "only valid as a variable's initial value in a declaration"; + +describe("structure initializer placement — rejected in statements", () => { + it("rejects a structure initializer assigned to a variable", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR p : Point; END_VAR + p := (x := 1.0); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("rejects structure initializers inside an array literal in a statement", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR arr : ARRAY[0..1] OF Point; END_VAR + arr := [(x := 1.0), (x := 2.0)]; + END_PROGRAM + `); + // One per initializer — they are two separate mistakes. + expect(errors).toHaveLength(2); + expect(errors[0]).toContain(PLACEMENT); + expect(errors[1]).toContain(PLACEMENT); + }); + + it("rejects a structure initializer passed as a named FB argument", () => { + const errors = errorsFor(` + ${POINT} + FUNCTION_BLOCK Sink + VAR_INPUT p : Point; END_VAR + p.x := p.x; + END_FUNCTION_BLOCK + PROGRAM Main + VAR s : Sink; END_VAR + s(p := (x := 3.0)); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("reports one diagnostic for a nested structure initializer, not one per level", () => { + const errors = errorsFor(` + TYPE + Inner : STRUCT v : REAL := 1.0; END_STRUCT; + Outer : STRUCT i : Inner; END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer; END_VAR + o := (i := (v := 2.0)); + END_PROGRAM + `); + expect(errors).toHaveLength(1); + }); + + it("reports the line and column of the initializer", () => { + const result = compile( + `${POINT} +PROGRAM Main + VAR p : Point; END_VAR + p := (x := 1.0); +END_PROGRAM +`, + ); + const err = result.errors[0]!; + expect(err.message).toContain(PLACEMENT); + // Points at the `(` that opens the initializer, not at the statement. + expect(err.line).toBe(11); + expect(err.column).toBe(8); + }); + + it("rejects one inside a control-flow body", () => { + const errors = errorsFor(` + ${POINT} + PROGRAM Main + VAR p : Point; c : BOOL; END_VAR + IF c THEN + p := (x := 1.0); + END_IF; + END_PROGRAM + `); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(PLACEMENT); + }); + + it("rejects one in a function block body and in a method body", () => { + const errors = errorsFor(` + ${POINT} + FUNCTION_BLOCK Holder + VAR p : Point; END_VAR + METHOD Reset + p := (x := 5.0); + END_METHOD + p := (x := 1.0); + END_FUNCTION_BLOCK + PROGRAM Main + VAR h : Holder; END_VAR + h(); + END_PROGRAM + `); + expect(errors).toHaveLength(2); + for (const e of errors) expect(e).toContain(PLACEMENT); + }); +}); + +describe("structure initializer placement — accepted in declarations", () => { + it("accepts a PROGRAM variable's initial value", () => { + expectClean(` + ${POINT} + PROGRAM Main + VAR p : Point := (x := 1.0, y := 2.0); END_VAR + p.x := p.y; + END_PROGRAM + `); + }); + + it("accepts structure initializers nested in an array literal initial value", () => { + expectClean(` + ${POINT} + PROGRAM Main + VAR arr : ARRAY[0..1] OF Point := [(x := 1.0), (x := 2.0)]; END_VAR + arr[0].x := arr[1].y; + END_PROGRAM + `); + }); + + it("accepts a STRUCT element default", () => { + expectClean(` + TYPE + Point : STRUCT x : REAL := 9.0; END_STRUCT; + Outer : STRUCT p : Point := (x := 5.0); END_STRUCT; + END_TYPE + PROGRAM Main + VAR o : Outer; END_VAR + o.p.x := o.p.x; + END_PROGRAM + `); + }); + + it("accepts a type-level default (Annex B.1.3.3)", () => { + expectClean(` + ${POINT} + TYPE + Origin : Point := (x := 0.0, y := 0.0); + END_TYPE + PROGRAM Main + VAR p : Origin; END_VAR + p.x := p.y; + END_PROGRAM + `); + }); + + it("accepts a file-level VAR_GLOBAL initial value", () => { + expectClean(` + ${POINT} + VAR_GLOBAL + origin : Point := (x := 1.0, y := 2.0); + END_VAR + PROGRAM Main + VAR_EXTERNAL origin : Point; END_VAR + origin.x := origin.y; + END_PROGRAM + `); + }); + + it("accepts a FUNCTION_BLOCK member and a METHOD local initial value", () => { + expectClean(` + ${POINT} + FUNCTION_BLOCK Holder + VAR p : Point := (x := 1.0); END_VAR + METHOD Reset + VAR q : Point := (y := 2.0); END_VAR + p.x := q.y; + END_METHOD + p.x := p.y; + END_FUNCTION_BLOCK + PROGRAM Main + VAR h : Holder; END_VAR + h(); + END_PROGRAM + `); + }); + + it("leaves an ordinary parenthesized expression alone", () => { + expectClean(` + PROGRAM Main + VAR a : INT; b : INT; END_VAR + a := (b + 1) * 2; + END_PROGRAM + `); + }); +}); 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);"); + }); +});