From 85f89b781ba8fd168caacadf4dd4d3990c50f465 Mon Sep 17 00:00:00 2001 From: anay Date: Sun, 19 Jul 2026 13:41:06 -0700 Subject: [PATCH 1/4] fix(transpiler): resolve polymorphic TO_ conversions to concrete call names (#944) Blocks like TO_INT/TO_UINT/TO_REAL are placed on the FBD/LD canvas with the generic IEC 61131-3 conversion shorthand as their type name, but that shorthand is not itself a valid ST function -- only the fully qualified _TO_ family exists (std_block_catalog.json never has a bare TO_INT entry). The JSON->ST walker emitted the shorthand verbatim as the call name, producing e.g. `TO_INT(O_R)` for a REAL source, which matiec/strucpp reject as an undefined function -- the exact "';' missing at the end of statement" parse error reported in #944 for `TO_INT(O_R)`. PR #854 already fixed the sibling defect where the synthesized output temp's *declared type* stayed ANY for these blocks, but explicitly left the call-name resolution as future work ("computeConnectionTypes port"). This closes that specific gap: emitFunctionCall (walker/ld.ts) now resolves the block's single wired input to its declared type (from a new project/POU variable-type index built in pou-graphical.ts) and substitutes the concrete _TO_ name when the catalog has a matching entry, falling back to the original shorthand otherwise so an unresolvable case still surfaces the same "undefined function" error instead of a different wrong one. Scope: this fixes the in-process JSON->ST transpiler (backend/shared/transpilers/st-transpiler/), which is currently opt-in via OPENPLC_USE_NEW_TRANSPILER and is the path PR #854 already targeted. The default legacy pipeline (XmlGenerator -> external xml2st subprocess) has the identical defect inherited from the original Python generator (xml2st's own PLCGenerator.py also passes the block's raw type name through unresolved) and needs a separate fix -- old-editor/fbd-xml.ts has no project-wide variable-type context available at its call site today, so resolving it there needs its own plumbing change. Flagging that as follow-up scope rather than bundling a larger, untested change into this fix. Verification: - New tests reproduce the exact issue #944 scenario (REAL O_R -> TO_INT -> INT output) at both the walker level (ld.test.ts) and the full generateGraphicalPou level (pou-graphical.test.ts); each fails against the pre-fix code (bare TO_INT(O_R)) and passes with the fix (REAL_TO_INT(O_R)) -- confirmed by running both ways. - Added coverage for: unconnected input, unknown source type, no matching catalog entry, non-conversion blocks (ADD-style), sibling conversions (TO_DINT from BOOL), multi-input blocks, and global vs. local variable shadowing. - Full suite: 243 suites / 5216 tests, 0 failures, 0 regressions. - tsc --noEmit and eslint clean on all changed files. Could not run the Electron GUI or the external matiec/strucpp compilers in this environment to compile-verify the generated ST end-to-end; verification is at the transpiler-output level (the generated ST text matches the exact fully-qualified call the catalog and STruC++'s own test suite confirm is valid, e.g. REAL_TO_INT). Co-Authored-By: Claude Sonnet 5 --- .../emit/__tests__/pou-graphical.test.ts | 139 +++++++++++ .../st-transpiler/emit/pou-graphical.ts | 66 +++--- .../st-transpiler/helpers/block-library.ts | 66 ++++++ .../st-transpiler/walker/__tests__/ld.test.ts | 221 ++++++++++++++++++ .../transpilers/st-transpiler/walker/fbd.ts | 4 +- .../transpilers/st-transpiler/walker/ld.ts | 57 ++++- 6 files changed, 515 insertions(+), 38 deletions(-) create mode 100644 src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts create mode 100644 src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts diff --git a/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts new file mode 100644 index 000000000..0ddc4c0cf --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts @@ -0,0 +1,139 @@ +/** + * End-to-end regression for issue #944 ("Type Conversion TO_INT not + * working") at the level `generateGraphicalPou` actually runs at: a full + * `TranspilePou` + `TranspileProject`, exactly like the compile pipeline + * (`backend/shared/compile/pipeline.ts`) feeds it. + * + * Reproduces the reported project shape: a PROGRAM with a REAL variable + * (`O_R`) wired into a `TO_INT` conversion block on an FBD network, + * feeding an INT output variable — the exact pattern that produced + * `_TMP_TO_INT6913197_OUT := TO_INT(O_R);` in the issue's generated + * `webserver_program.st` and made matiec fail with `';' missing at the + * end of statement`. + */ + +import type { RFNode } from '../../walker/types' +import type { TranspilePou, TranspileProject } from '../../types' +import { generateGraphicalPou } from '../pou-graphical' + +function inputVariableNode(id: string, name: string): RFNode { + return { + id, + type: 'input-variable', + position: { x: 0, y: 0 }, + data: { variant: 'input-variable', variable: { name }, executionOrder: 0 }, + } +} + +function outputVariableNode(id: string, name: string): RFNode { + return { + id, + type: 'output-variable', + position: { x: 100, y: 0 }, + data: { variant: 'output-variable', variable: { name }, executionOrder: 0 }, + } +} + +function conversionBlockNode(id: string, typeName: string, numericId: string): RFNode { + return { + id, + type: 'block', + position: { x: 50, y: 0 }, + data: { + variant: { + name: typeName, + type: 'function', + variables: [ + { name: 'IN', class: 'input' }, + { name: 'OUT', class: 'output' }, + ], + }, + numericId, + executionOrder: 0, + }, + } +} + +function emptyProject(pous: TranspilePou[]): TranspileProject { + return { + dataTypes: [], + pous, + configuration: { tasks: [], instances: [], globalVariables: [] }, + } +} + +describe('generateGraphicalPou — polymorphic conversion call resolution (issue #944)', () => { + it('emits REAL_TO_INT, not the bare TO_INT shorthand, for a REAL local wired into a TO_INT block', () => { + const pou: TranspilePou = { + name: 'main', + pouType: 'program', + interface: { + variables: [ + { name: 'O_R', type: { definition: 'base-type', value: 'REAL' }, class: 'local' }, + { name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }, + ], + }, + body: { + language: 'fbd', + value: { + rung: { + nodes: [ + inputVariableNode('in1', 'O_R'), + conversionBlockNode('blk1', 'TO_INT', '6913197'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [ + { id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' }, + { id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' }, + ], + }, + }, + }, + } + + const chunks = generateGraphicalPou(pou, emptyProject([pou])) + const text = chunks.map((c) => c[0]).join('') + + expect(text).toContain('REAL_TO_INT(O_R)') + expect(text).not.toMatch(/:=\s*TO_INT\(/) + // The output temp's declared type must also resolve off ANY (the + // sibling defect PR #854 fixed) — belt-and-suspenders check that + // this change didn't regress that. + expect(text).toMatch(/_TMP_TO_INT6913197_OUT\s*:\s*INT/) + }) + + it('resolves the source type from a project global variable when the POU has no matching local', () => { + const pou: TranspilePou = { + name: 'main', + pouType: 'program', + interface: { variables: [{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }] }, + body: { + language: 'fbd', + value: { + rung: { + nodes: [ + inputVariableNode('in1', 'G_TEMP'), + conversionBlockNode('blk1', 'TO_INT', '42'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [ + { id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' }, + { id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' }, + ], + }, + }, + }, + } + const project = emptyProject([pou]) + project.configuration.globalVariables.push({ + name: 'G_TEMP', + type: { definition: 'base-type', value: 'REAL' }, + class: 'external', + }) + + const chunks = generateGraphicalPou(pou, project) + const text = chunks.map((c) => c[0]).join('') + + expect(text).toContain('REAL_TO_INT(G_TEMP)') + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 0540334e7..f825e9b66 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -10,7 +10,7 @@ */ import { PLC_BASE_TYPES } from '../helpers/base-types' -import { resolveBlockType } from '../helpers/block-library' +import { resolveBlockType, TO_CONVERSION_TARGETS } from '../helpers/block-library' import type { ProgramChunk } from '../helpers/program' import { computePouName } from '../helpers/text-helpers' import { varTypeNames } from '../helpers/type-text' @@ -26,38 +26,6 @@ interface InterfaceEntry { vars: TranspileVariable[] } -/** - * Destination types of the IEC 61131-3 polymorphic conversion family - * (`TO_BOOL`, `TO_INT`, `TO_UINT`, …). Hard-coded here rather than - * derived at runtime from the catalog so any future addition is visible - * in code review. Kept in sync with `data/std_block_catalog.json` — any - * `_TO_` entry in the catalog implies `TO_` is a valid - * polymorphic conversion target. - */ -const TO_CONVERSION_TARGETS: ReadonlySet = new Set([ - 'BCD', - 'BOOL', - 'BYTE', - 'DATE', - 'DINT', - 'DT', - 'DWORD', - 'INT', - 'LINT', - 'LREAL', - 'LWORD', - 'REAL', - 'SINT', - 'STRING', - 'TIME', - 'TOD', - 'UDINT', - 'UINT', - 'ULINT', - 'USINT', - 'WORD', -]) - /* ─────────────────────────── public entry ───────────────────────────────── */ /** @@ -78,7 +46,16 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec if (pou.body.language !== 'ld' && pou.body.language !== 'fbd') { throw new Error(`generateGraphicalPou called with non-graphical body: ${pou.body.language}`) } - const emitted = pou.body.language === 'ld' ? emitLdBody(pou.body.value) : emitFbdBody(pou.body.value) + // Declared base-type of every name the walker might see wired + // directly into a polymorphic `TO_` conversion block's input — + // globals first, then the POU's own interface/locals so a shadowing + // local wins. Only base-types are indexed: derived/array/struct + // values are never valid conversion-function sources under IEC + // 61131-3, so they're not useful here and skipping them keeps this + // cheap and unambiguous. + const variableTypes = buildVariableTypeIndex(pou, project) + const emitted = + pou.body.language === 'ld' ? emitLdBody(pou.body.value, variableTypes) : emitFbdBody(pou.body.value, variableTypes) // Compose the final POU chunk stream now that the walker has // emitted the body bytes + any synthetic vars. @@ -166,6 +143,27 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec /* ────────────────────────── helpers ─────────────────────────────────────── */ +/** + * Map every declared base-type variable name (project globals, then the + * POU's own interface/locals so a shadowing local wins) to its uppercase + * IEC type string. Feeds the walker's polymorphic `TO_` conversion + * resolution (see `walker/ld.ts`'s `resolveConversionFunctionName` call + * site) — it needs to know the source type of whatever's wired into a + * conversion block's input to build the concrete `_TO_` call + * name. + */ +function buildVariableTypeIndex(pou: TranspilePou, project: TranspileProject): Map { + const index = new Map() + const addAll = (variables: TranspileVariable[]) => { + for (const v of variables) { + if (v.type.definition === 'base-type') index.set(v.name, v.type.value.toUpperCase()) + } + } + addAll(project.configuration.globalVariables) + addAll(pou.interface?.variables ?? []) + return index +} + function computeInterface(variables: TranspileVariable[], syntheticVars: SyntheticVar[]): InterfaceEntry[] { const classToKeyword: Record = { input: varTypeNames.inputVars, diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index e98d51ae6..4b30f9da1 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -86,3 +86,69 @@ function collapseToAny(infos: BlockInfos): BlockInfos { outputs: infos.outputs.map((o) => ({ ...o, type: 'ANY' })), } } + +/** + * Destination types of the IEC 61131-3 polymorphic conversion family + * (`TO_BOOL`, `TO_INT`, `TO_UINT`, …). Hard-coded here rather than + * derived at runtime from the catalog so any future addition is visible + * in code review. Kept in sync with `data/std_block_catalog.json` — any + * `_TO_` entry in the catalog implies `TO_` is a valid + * polymorphic conversion target. + * + * Shared by both consumers that need to resolve a `TO_` shorthand: + * `emit/pou-graphical.ts` (output-temp declared type — destination side + * only) and `walker/ld.ts` (the actual ST function-call name — needs + * both source and destination). + */ +export const TO_CONVERSION_TARGETS: ReadonlySet = new Set([ + 'BCD', + 'BOOL', + 'BYTE', + 'DATE', + 'DINT', + 'DT', + 'DWORD', + 'INT', + 'LINT', + 'LREAL', + 'LWORD', + 'REAL', + 'SINT', + 'STRING', + 'TIME', + 'TOD', + 'UDINT', + 'UINT', + 'ULINT', + 'USINT', + 'WORD', +]) + +/** + * Resolve a polymorphic `TO_` block name (e.g. `TO_INT`) to the + * concrete, fully-qualified IEC 61131-3 conversion function (e.g. + * `REAL_TO_INT`) given the type of whatever is wired into its single + * input. + * + * IEC 61131-3 does not define a generic `TO_INT` — only the fully + * qualified `_TO_` family exists (see `std_block_catalog.json`, + * which enumerates ~20 source variants per destination type but never a + * bare `TO_` entry). A block instance whose type name is still the + * generic shorthand at code-generation time hasn't been resolved to a + * concrete variant — a real ST/C compiler (matiec, STruC++) rejects the + * bare name as an undefined function. + * + * Returns `null` when `blockTypeName` isn't a recognised polymorphic + * shorthand, or when `_TO_` isn't an actual catalog + * entry (e.g. the source type doesn't have a defined conversion to the + * destination) — callers should fall back to the original name so an + * unresolvable case still surfaces the same "undefined function" error + * it would have before, rather than silently emitting a different wrong + * name. + */ +export function resolveConversionFunctionName(blockTypeName: string, sourceType: string): string | null { + const match = blockTypeName.match(/^TO_([A-Z][A-Z0-9]*)$/) + if (match === null || !TO_CONVERSION_TARGETS.has(match[1])) return null + const candidate = `${sourceType.toUpperCase()}_TO_${match[1]}` + return resolveBlockType(candidate) !== null ? candidate : null +} diff --git a/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts new file mode 100644 index 000000000..8c6c20d37 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts @@ -0,0 +1,221 @@ +/** + * Regression coverage for polymorphic IEC 61131-3 conversion-block call + * emission (GitHub issue #944 — "Type Conversion TO_INT not working"). + * + * A `TO_INT` (or `TO_UINT`, `TO_REAL`, …) block is placed on the FBD/LD + * canvas with the generic shorthand as its type name — that's how the + * editor's block library exposes the IEC 61131-3 polymorphic conversion + * family. But the shorthand is not itself a valid ST function; only the + * fully qualified `_TO_` form (`REAL_TO_INT`, `BOOL_TO_INT`, …) + * exists in the standard (see `data/std_block_catalog.json`, which never + * has a bare `TO_INT` entry). Emitting the shorthand verbatim into the + * generated ST produces a call to an undefined function, which matiec / + * strucpp reject — exactly the `';' missing at the end of statement` + * parse error reported in issue #944 for `TO_INT(O_R)`. + */ + +import { emitLdBody } from '../ld' +import type { RFBody, RFEdge, RFNode } from '../types' + +function inputVariableNode(id: string, name: string): RFNode { + return { + id, + type: 'input-variable', + position: { x: 0, y: 0 }, + data: { variant: 'input-variable', variable: { name }, executionOrder: 0 }, + } +} + +function outputVariableNode(id: string, name: string): RFNode { + return { + id, + type: 'output-variable', + position: { x: 100, y: 0 }, + data: { variant: 'output-variable', variable: { name }, executionOrder: 0 }, + } +} + +function conversionBlockNode(id: string, typeName: string, numericId: string): RFNode { + return { + id, + type: 'block', + position: { x: 50, y: 0 }, + data: { + variant: { + name: typeName, + type: 'function', + variables: [ + { name: 'IN', class: 'input' }, + { name: 'OUT', class: 'output' }, + ], + }, + numericId, + executionOrder: 0, + }, + } +} + +function twoInputBlockNode(id: string, typeName: string, numericId: string): RFNode { + return { + id, + type: 'block', + position: { x: 50, y: 0 }, + data: { + variant: { + name: typeName, + type: 'function', + variables: [ + { name: 'IN1', class: 'input' }, + { name: 'IN2', class: 'input' }, + { name: 'OUT', class: 'output' }, + ], + }, + numericId, + executionOrder: 0, + }, + } +} + +function edge(id: string, source: string, target: string, sourceHandle?: string, targetHandle?: string): RFEdge { + return { id, source, target, sourceHandle, targetHandle } +} + +describe('emitLdBody — polymorphic TO_ conversion call resolution', () => { + it('resolves TO_INT to REAL_TO_INT when the wired input is a declared REAL variable (issue #944)', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'O_R'), + conversionBlockNode('blk1', 'TO_INT', '6913197'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [edge('e1', 'in1', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, new Map([['O_R', 'REAL']])) + + expect(result.bodySt).toContain('REAL_TO_INT(O_R)') + expect(result.bodySt).not.toMatch(/:=\s*TO_INT\(/) + }) + + it('leaves the shorthand name unchanged when the source type is not known (no regression / fail-open)', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'O_R'), + conversionBlockNode('blk1', 'TO_INT', '6913197'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [edge('e1', 'in1', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + // No variable-type index supplied at all. + const result = emitLdBody(body) + + expect(result.bodySt).toMatch(/:=\s*TO_INT\(O_R\)/) + }) + + it('leaves the shorthand name unchanged when the input is unconnected', () => { + const body: RFBody = { + rungs: [ + { + nodes: [conversionBlockNode('blk1', 'TO_INT', '6913197'), outputVariableNode('out1', 'OFFICE_TEMP')], + edges: [edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, new Map([['O_R', 'REAL']])) + + expect(result.bodySt).toContain('TO_INT()') + }) + + it('leaves the shorthand name unchanged when there is no known conversion from the source type to the target', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'WEIRD_SOURCE'), + conversionBlockNode('blk1', 'TO_INT', '6913197'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [edge('e1', 'in1', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + // `NOT_A_REAL_TYPE_TO_INT` isn't a catalog entry — resolution must + // fall back to the shorthand rather than emit that non-existent name. + const result = emitLdBody(body, new Map([['WEIRD_SOURCE', 'NOT_A_REAL_TYPE']])) + + expect(result.bodySt).toContain('TO_INT(WEIRD_SOURCE)') + }) + + it('does not touch non-conversion function calls (e.g. ADD) even when they have exactly one wired input', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'A'), + conversionBlockNode('blk1', 'TRUNC', '42'), + outputVariableNode('out1', 'B'), + ], + edges: [edge('e1', 'in1', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, new Map([['A', 'REAL']])) + + expect(result.bodySt).toContain('TRUNC(A)') + }) + + it('resolves sibling conversions the same way (TO_DINT from a BOOL source)', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'FLAG'), + conversionBlockNode('blk1', 'TO_DINT', '7'), + outputVariableNode('out1', 'OUT_VAR'), + ], + edges: [edge('e1', 'in1', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, new Map([['FLAG', 'BOOL']])) + + expect(result.bodySt).toContain('BOOL_TO_DINT(FLAG)') + }) + + it('leaves multi-input blocks named like a conversion shorthand alone (defensive — real conversions never have 2 inputs)', () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + inputVariableNode('in1', 'A'), + inputVariableNode('in2', 'B'), + twoInputBlockNode('blk1', 'TO_INT', '9'), + outputVariableNode('out1', 'OUT_VAR'), + ], + edges: [ + edge('e1', 'in1', 'blk1', undefined, 'IN1'), + edge('e2', 'in2', 'blk1', undefined, 'IN2'), + edge('e3', 'blk1', 'out1', 'OUT', undefined), + ], + }, + ], + } + + const result = emitLdBody(body, new Map([['A', 'REAL'], ['B', 'REAL']])) + + expect(result.bodySt).toContain('TO_INT(') + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts b/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts index 82c9b481b..27bd024c5 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts @@ -24,6 +24,6 @@ export interface RFFbdBody { rung: RFRung } -export function emitFbdBody(body: RFFbdBody): EmitResult { - return emitLdBody({ rungs: [body.rung] }) +export function emitFbdBody(body: RFFbdBody, variableTypes?: ReadonlyMap): EmitResult { + return emitLdBody({ rungs: [body.rung] }, variableTypes) } diff --git a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts index fe2c3a6b4..b512f59a9 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts @@ -25,6 +25,7 @@ import { type ProgramChunk, TRUE_NODE, } from '../core/path-tree' +import { resolveConversionFunctionName } from '../helpers/block-library' import { asBlockData, asCoilData, @@ -102,6 +103,15 @@ interface WalkerState { * in the same coordinate space the python oracle sees. */ yOffset: Map warnings: string[] + /** Declared base-type of every known variable name, keyed by name + * (see `emit/pou-graphical.ts`'s `buildVariableTypeIndex`). Used + * only to resolve a polymorphic `TO_` conversion block's + * concrete call name from the type of whatever's wired into its + * input — see `resolveConversionCallName`. Empty when the caller + * doesn't have (or doesn't pass) project/POU variable context; + * conversion blocks simply keep their as-placed name in that case, + * same as before this lookup existed. */ + variableTypes: ReadonlyMap } function rungHeight(rung: RFRung): number { @@ -152,7 +162,7 @@ function isVariableNode(node: RFNode): boolean { /* ─────────────────────────── public entry ───────────────────────────────── */ -export function emitLdBody(body: RFBody): EmitResult { +export function emitLdBody(body: RFBody, variableTypes?: ReadonlyMap): EmitResult { // POU name doesn't influence body emission today (it's only the // first element of the `Location` tuples used for source-map back- // references). Hard-code a sentinel; the orchestrator can pass a @@ -172,6 +182,7 @@ export function emitLdBody(body: RFBody): EmitResult { connectorExprs: new Map(), yOffset: new Map(), warnings: [], + variableTypes: variableTypes ?? new Map(), } // Index every node + edge from every rung up front. Sinks are then @@ -712,10 +723,23 @@ function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): vo } if (primaryName === null) return + // Polymorphic IEC 61131-3 conversion blocks (`TO_INT`, `TO_UINT`, + // `TO_REAL`, …) are placed with the generic shorthand as their type + // name, but that shorthand isn't itself a real ST function — only + // the fully qualified `_TO_` family is (matiec/strucpp both + // reject a bare `TO_INT(...)` call). Resolve it here from whatever + // is wired directly into the block's single input; leave the name + // untouched when it can't be resolved (unconnected input, upstream + // isn't a plain variable, or the source type has no defined + // conversion to this destination) so an unresolvable case still + // surfaces the same "undefined function" error it always did, + // rather than silently emitting a different wrong name. + const callName = resolveConversionCallName(state, node, data) ?? data.typeName + state.program.push([state.currentIndent, []]) state.program.push([primaryName, [...info, 'output', 0]]) state.program.push([' := ', []]) - state.program.push([data.typeName, [...info, 'type']]) + state.program.push([callName, [...info, 'type']]) state.program.push(['(', []]) for (let i = 0; i < parts.length; i++) { if (i > 0) state.program.push([', ', []]) @@ -802,6 +826,35 @@ function firstIncomingForHandle(state: WalkerState, blockId: string, handle: str return undefined } +/** + * Resolve a polymorphic `TO_` block's concrete ST call name + * (e.g. `REAL_TO_INT`) from the declared type of whatever is wired + * directly into its single input. Returns `null` for anything that + * isn't a `TO_` block, has zero or more than one formal input + * (every real conversion function in the catalog takes exactly one), + * an unconnected/non-variable upstream, or a source type with no + * known type index entry / no matching catalog entry. + * + * Deliberately narrow: only the direct "input wired straight to a + * declared variable" case is resolved. A chain through another + * block's output would need that block's own output type, which is + * the harder general connection-type-inference problem the python + * oracle's `ComputeConnectionTypes` solves and this walker hasn't + * ported (see `emit/pou-graphical.ts`'s ANY-resolution comment). + */ +function resolveConversionCallName(state: WalkerState, node: RFNode, data: BlockData): string | null { + if (data.inputs.length !== 1) return null + const edge = firstIncomingForHandle(state, node.id, data.inputs[0]) + if (edge === undefined) return null + const upstream = state.byId.get(edge.source) + if (upstream === undefined) return null + const variableName = asVariableData(upstream.data)?.variable ?? asVariableExpressionData(upstream.data)?.expression + if (variableName === undefined || variableName === '') return null + const sourceType = state.variableTypes.get(variableName) + if (sourceType === undefined) return null + return resolveConversionFunctionName(data.typeName, sourceType) +} + /* ─────────────────────────── helpers ────────────────────────────────────── */ function pathsToChunks(paths: PathNode[]): ProgramChunk[] { From 8d2868b90de9fb3b041ee468da3c2b28a7952c7f Mon Sep 17 00:00:00 2001 From: anay Date: Tue, 21 Jul 2026 02:37:36 -0700 Subject: [PATCH 2/4] test(transpiler): cover conversion resolution fallbacks --- .../emit/__tests__/pou-graphical.test.ts | 39 ++++++++++++++++++- .../helpers/__tests__/block-library.test.ts | 7 ++++ .../st-transpiler/walker/__tests__/ld.test.ts | 28 ++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts diff --git a/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts index 0ddc4c0cf..f8e056aa3 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts @@ -106,7 +106,9 @@ describe('generateGraphicalPou — polymorphic conversion call resolution (issue const pou: TranspilePou = { name: 'main', pouType: 'program', - interface: { variables: [{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }] }, + interface: { + variables: [{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }], + }, body: { language: 'fbd', value: { @@ -136,4 +138,39 @@ describe('generateGraphicalPou — polymorphic conversion call resolution (issue expect(text).toContain('REAL_TO_INT(G_TEMP)') }) + + it('does not index derived variable types as conversion sources', () => { + const pou: TranspilePou = { + name: 'main', + pouType: 'program', + interface: { + variables: [ + { name: 'DERIVED_TEMP', type: { definition: 'derived', value: 'REAL_ALIAS' }, class: 'local' }, + { name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }, + ], + }, + body: { + language: 'fbd', + value: { + rung: { + nodes: [ + inputVariableNode('in1', 'DERIVED_TEMP'), + conversionBlockNode('blk1', 'TO_INT', '43'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [ + { id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' }, + { id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' }, + ], + }, + }, + }, + } + + const chunks = generateGraphicalPou(pou, emptyProject([pou])) + const text = chunks.map((c) => c[0]).join('') + + expect(text).toContain('TO_INT(DERIVED_TEMP)') + expect(text).not.toContain('REAL_ALIAS_TO_INT(DERIVED_TEMP)') + }) }) diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts new file mode 100644 index 000000000..b2af88c1a --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts @@ -0,0 +1,7 @@ +import { resolveConversionFunctionName } from '../block-library' + +describe('resolveConversionFunctionName', () => { + it('rejects an unknown TO_ shorthand even if it matches the name pattern', () => { + expect(resolveConversionFunctionName('TO_FOO', 'REAL')).toBeNull() + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts index 8c6c20d37..997ec2777 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts @@ -136,6 +136,26 @@ describe('emitLdBody — polymorphic TO_ conversion call resolution', () = expect(result.bodySt).toContain('TO_INT()') }) + it("leaves the shorthand unchanged when the input is another block's output", () => { + const body: RFBody = { + rungs: [ + { + nodes: [ + conversionBlockNode('source', 'TRUNC', '41'), + conversionBlockNode('conversion', 'TO_INT', '42'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [edge('e1', 'source', 'conversion', 'OUT', 'IN'), edge('e2', 'conversion', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, new Map([['_TMP_TRUNC41_OUT', 'REAL']])) + + expect(result.bodySt).toContain('TO_INT(_TMP_TRUNC41_OUT)') + expect(result.bodySt).not.toContain('REAL_TO_INT(_TMP_TRUNC41_OUT)') + }) + it('leaves the shorthand name unchanged when there is no known conversion from the source type to the target', () => { const body: RFBody = { rungs: [ @@ -214,7 +234,13 @@ describe('emitLdBody — polymorphic TO_ conversion call resolution', () = ], } - const result = emitLdBody(body, new Map([['A', 'REAL'], ['B', 'REAL']])) + const result = emitLdBody( + body, + new Map([ + ['A', 'REAL'], + ['B', 'REAL'], + ]), + ) expect(result.bodySt).toContain('TO_INT(') }) From 92020336ea0aac6ba1571273833458c0e1d7f096 Mon Sep 17 00:00:00 2001 From: anay Date: Tue, 21 Jul 2026 23:42:35 -0700 Subject: [PATCH 3/4] test: cover conversion resolution edge cases --- .../emit/__tests__/pou-graphical.test.ts | 42 ++++++++++++++ .../helpers/__tests__/block-library.test.ts | 58 ++++++++++++++++++- .../st-transpiler/walker/__tests__/ld.test.ts | 15 +++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts index d385a686a..a5470695e 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts @@ -140,6 +140,48 @@ describe('generateGraphicalPou — polymorphic conversion call resolution (issue expect(text).toContain('REAL_TO_INT(G_TEMP)') }) + it('prefers a POU-local variable over a project global with the same name', () => { + const pou: TranspilePou = { + name: 'main', + pouType: 'program', + interface: { + variables: [ + { name: 'SHADOWED', type: { definition: 'base-type', value: 'BOOL' }, class: 'local' }, + { name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }, + ], + }, + body: { + language: 'fbd', + value: { + rung: { + nodes: [ + inputVariableNode('in1', 'SHADOWED'), + conversionBlockNode('blk1', 'TO_INT', '44'), + outputVariableNode('out1', 'OFFICE_TEMP'), + ], + edges: [ + { id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' }, + { id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' }, + ], + }, + }, + }, + } + const project = emptyProject([pou]) + project.configuration.globalVariables.push({ + name: 'SHADOWED', + type: { definition: 'base-type', value: 'REAL' }, + class: 'external', + }) + + const text = generateGraphicalPou(pou, project) + .map((chunk) => chunk[0]) + .join('') + + expect(text).toContain('BOOL_TO_INT(SHADOWED)') + expect(text).not.toContain('REAL_TO_INT(SHADOWED)') + }) + it('does not index derived variable types as conversion sources', () => { const pou: TranspilePou = { name: 'main', diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts index a19aa6480..969178047 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts @@ -1,4 +1,50 @@ -import { resolveConversionFunctionName } from '../block-library' +import { blockInfosFromVariant, resolveConversionFunctionName } from '../block-library' + +describe('blockInfosFromVariant', () => { + it('rejects malformed variants and variants without a string name', () => { + expect(blockInfosFromVariant(null)).toBeNull() + expect(blockInfosFromVariant([])).toBeNull() + expect(blockInfosFromVariant({})).toBeNull() + expect(blockInfosFromVariant({ name: 42 })).toBeNull() + }) + + it('normalizes valid variable records and ignores malformed or control-pin entries', () => { + expect( + blockInfosFromVariant({ + name: 'CUSTOM_BLOCK', + type: 'function-block-instance', + extensible: true, + variables: [ + null, + { class: 'input' }, + { name: 'EN', class: 'input', type: { value: 'BOOL' } }, + { name: 'ENO', class: 'output', type: { value: 'BOOL' } }, + { name: 'IN', class: 'input', type: { value: 'REAL' } }, + { name: 'OUT', class: 'output', type: { value: 42 } }, + { name: 'BOTH', class: 'inOut', type: { value: 'INT' } }, + { name: 'LEGACY_BOTH', class: 'inout' }, + { name: 'IGNORED', class: 'local', type: { value: 'BOOL' } }, + ], + }), + ).toEqual({ + name: 'CUSTOM_BLOCK', + type: 'functionBlock', + extensible: true, + inputs: [ + { name: 'IN', type: 'REAL', qualifier: 'none' }, + { name: 'BOTH', type: 'INT', qualifier: 'none' }, + { name: 'LEGACY_BOTH', type: 'ANY', qualifier: 'none' }, + ], + outputs: [ + { name: 'OUT', type: 'ANY', qualifier: 'none' }, + { name: 'BOTH', type: 'INT', qualifier: 'none' }, + { name: 'LEGACY_BOTH', type: 'ANY', qualifier: 'none' }, + ], + comment: '', + usage: '', + }) + }) +}) describe('resolveConversionFunctionName', () => { it('resolves a supported concrete conversion', () => { @@ -15,6 +61,16 @@ describe('resolveConversionFunctionName', () => { it('supports BCD conversions only for unsigned integer types', () => { expect(resolveConversionFunctionName('TO_UINT', 'BCD')).toBe('BCD_TO_UINT') + expect(resolveConversionFunctionName('TO_BCD', 'UDINT')).toBe('UDINT_TO_BCD') expect(resolveConversionFunctionName('TO_BCD', 'INT')).toBeNull() }) + + it('covers identity, boolean, and temporal conversion exits', () => { + expect(resolveConversionFunctionName('INT_TO_REAL', 'INT')).toBeNull() + expect(resolveConversionFunctionName('TO_INT', 'INT')).toBeNull() + expect(resolveConversionFunctionName('TO_BOOL', 'TIME')).toBeNull() + expect(resolveConversionFunctionName('TO_BOOL', 'INT')).toBe('INT_TO_BOOL') + expect(resolveConversionFunctionName('TO_TIME', 'INT')).toBe('INT_TO_TIME') + expect(resolveConversionFunctionName('TO_DATE', 'DATE_AND_TIME')).toBe('DATE_AND_TIME_TO_DATE') + }) }) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts index fdd60294f..fe0fbc1a5 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts @@ -145,6 +145,21 @@ describe('emitLdBody — polymorphic TO_ conversion call resolution', () = expect(result.bodySt).toContain('TO_INT()') }) + it('leaves the shorthand unchanged when an incoming edge references a missing upstream node', () => { + const body: RFBody = { + rungs: [ + { + nodes: [conversionBlockNode('blk1', 'TO_INT', '6913197'), outputVariableNode('out1', 'OFFICE_TEMP')], + edges: [edge('e1', 'missing', 'blk1', undefined, 'IN'), edge('e2', 'blk1', 'out1', 'OUT', undefined)], + }, + ], + } + + const result = emitLdBody(body, typeContext([])) + + expect(result.bodySt).toContain('TO_INT()') + }) + it("leaves the shorthand unchanged when the input is another block's output", () => { const body: RFBody = { rungs: [ From ecaf2814e117aa50d3212fde79faad642f70aae8 Mon Sep 17 00:00:00 2001 From: anay Date: Wed, 22 Jul 2026 02:18:18 -0700 Subject: [PATCH 4/4] test: cover default block variant normalization --- .../helpers/__tests__/block-library.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts index 969178047..4f3ab50b0 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts @@ -8,6 +8,18 @@ describe('blockInfosFromVariant', () => { expect(blockInfosFromVariant({ name: 42 })).toBeNull() }) + it('defaults omitted fields on a valid function variant', () => { + expect(blockInfosFromVariant({ name: 'CUSTOM_FUNCTION' })).toEqual({ + name: 'CUSTOM_FUNCTION', + type: 'function', + extensible: false, + inputs: [], + outputs: [], + comment: '', + usage: '', + }) + }) + it('normalizes valid variable records and ignores malformed or control-pin entries', () => { expect( blockInfosFromVariant({