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..a5470695e --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/emit/__tests__/pou-graphical.test.ts @@ -0,0 +1,219 @@ +/** + * 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 { + const destinationType = typeName.match(/^TO_([A-Z][A-Z0-9]*)$/)?.[1] ?? 'ANY' + return { + id, + type: 'block', + position: { x: 50, y: 0 }, + data: { + variant: { + name: typeName, + type: 'function', + variables: [ + { name: 'IN', class: 'input', type: { definition: 'base-type', value: 'ANY' } }, + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: destinationType } }, + ], + }, + 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)') + }) + + 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', + 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/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 1f2ab278a..7f98fb527 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -130,6 +130,9 @@ function collectBlockSignatures(project: TranspileProject): Map() + for (const v of project.configuration.globalVariables) { + interfaceTypes.set(v.name, getTypeAsText(v)) + } for (const v of pou.interface?.variables ?? []) { interfaceTypes.set(v.name, getTypeAsText(v)) } 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..4f3ab50b0 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/helpers/__tests__/block-library.test.ts @@ -0,0 +1,88 @@ +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('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({ + 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', () => { + expect(resolveConversionFunctionName('TO_INT', 'REAL')).toBe('REAL_TO_INT') + }) + + it('rejects an unknown TO_ shorthand even if it matches the name pattern', () => { + expect(resolveConversionFunctionName('TO_FOO', 'REAL')).toBeNull() + }) + + it('rejects unsupported temporal conversions', () => { + expect(resolveConversionFunctionName('TO_TIME', 'DATE')).toBeNull() + }) + + 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/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index 537f5b920..cd5646b9e 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -73,3 +73,90 @@ export function blockInfosFromVariant(variant: unknown): BlockInfos | null { usage: '', } } + +/** + * Destination types of the IEC 61131-3 polymorphic conversion family + * (`TO_BOOL`, `TO_INT`, `TO_UINT`, …). Kept explicit so additions to the + * supported compiler conversion family remain visible in code review. + */ +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', +]) + +const TEMPORAL_TYPES: ReadonlySet = new Set(['DATE', 'DT', 'TIME', 'TOD']) +const UNSIGNED_INTEGER_TYPES: ReadonlySet = new Set(['UDINT', 'UINT', 'ULINT', 'USINT']) +const GENERAL_CONVERSION_TARGETS: ReadonlySet = new Set([ + 'BYTE', + 'DINT', + 'DWORD', + 'INT', + 'LINT', + 'LREAL', + 'LWORD', + 'REAL', + 'SINT', + 'STRING', + '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. 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 the supported compiler conversion family does not + * contain the source/destination pair. 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 source = sourceType.toUpperCase() + const destination = match[1] + if (!isSupportedConversionPair(source, destination)) return null + return `${source}_TO_${destination}` +} + +function isSupportedConversionPair(source: string, destination: string): boolean { + if (destination === 'BCD') return UNSIGNED_INTEGER_TYPES.has(source) + if (source === 'BCD') return UNSIGNED_INTEGER_TYPES.has(destination) + if (destination === 'DATE' && source === 'DATE_AND_TIME') return true + if (!TO_CONVERSION_TARGETS.has(source) || source === destination) return false + if (GENERAL_CONVERSION_TARGETS.has(destination)) return true + if (destination === 'BOOL') return !TEMPORAL_TYPES.has(source) + return TEMPORAL_TYPES.has(destination) && !TEMPORAL_TYPES.has(source) +} 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..fe0fbc1a5 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/walker/__tests__/ld.test.ts @@ -0,0 +1,271 @@ +/** + * 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 { TypeContext } from '../connection-types' +import type { RFBody, RFEdge, RFNode } from '../types' + +function typeContext(entries: [string, string][]): TypeContext { + const variableTypes = new Map(entries) + return { + variableType: (expression) => variableTypes.get(expression) ?? null, + resolveBlock: () => null, + } +} + +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, typeContext([['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, typeContext([['O_R', 'REAL']])) + + 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: [ + { + 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, typeContext([['_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: [ + { + 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 supported compiler conversion — + // fall back to the shorthand rather than emit that non-existent name. + const result = emitLdBody(body, typeContext([['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, typeContext([['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, typeContext([['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, + typeContext([ + ['A', 'REAL'], + ['B', 'REAL'], + ]), + ) + + expect(result.bodySt).toContain('TO_INT(') + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts index 57669b916..3e6d65a30 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 { computeConnectionTypes, pinOut, type TypeContext } from './connection-types' import { asBlockData, @@ -176,7 +177,7 @@ export function emitLdBody(body: RFBody, typeContext?: TypeContext): EmitResult connectorExprs: new Map(), yOffset: new Map(), warnings: [], - connTypes: typeContext ? computeConnectionTypes(body, typeContext) : new Map(), + connTypes: typeContext ? computeConnectionTypes(body, typeContext) : new Map(), } // Index every node + edge from every rung up front. Sinks are then @@ -855,10 +856,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([', ', []]) @@ -946,6 +960,26 @@ 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 inferred type of whatever is wired into + * its single input. Returns `null` for anything that + * isn't a `TO_` block, has zero or more than one formal input + * (every supported conversion function takes exactly one), + * an unconnected upstream, or a source type with no supported conversion. + */ +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 sourcePin = upstream.type === 'block' ? pinOut(edge.source, edge.sourceHandle ?? '') : pinOut(edge.source) + const sourceType = state.connTypes.get(sourcePin) + if (sourceType === undefined) return null + return resolveConversionFunctionName(data.typeName, sourceType) +} + /* ─────────────────────────── helpers ────────────────────────────────────── */ function pathsToChunks(paths: PathNode[]): ProgramChunk[] {