From 59c45283387bdcc8f21ee37b254a0a941ad80c16 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:16:55 -0300 Subject: [PATCH] feat(transpiler): add OPENPLC_USE_NEW_TRANSPILER toggle, ship with legacy xml2st Restores the bundled xml2st subprocess transpile path so packaged builds keep using it while devs can opt into the new in-process JSON transpiler. Default is OFF (legacy xml2st). - binary-versions.json: re-pin xml2st@v4.0.5. - scripts/download-binaries.ts: restore the xml2st download + cache logic byte-for-byte from the pre-removal state. - compiler-module.ts: restore xml2stBinaryPath field, #constructXml2stBinaryPath, #executeXml2st, and handleTranspileXMLtoST verbatim; compileForDebugger branches on the toggle (legacy: handleGenerateXMLfromJSON + handleTranspileXMLtoST); rebind the handler on createEditorCompilerPlatformPort + createDesktopLibraryBuildPort. - editor-compiler-platform-port.transpileToSt: branches on the toggle; legacy serialises via XmlGenerator, materialises to plc.xml, runs handleTranspileXMLtoST, reads program.st back. - desktop-library-build-port.transpileToSt: same toggle; legacy reproduces the prior os.tmpdir() materialise / spawn / read sequence verbatim. - backend/editor/utils/transpiler-mode.ts: isNewTranspilerEnabled() reads OPENPLC_USE_NEW_TRANSPILER from process.env. Co-Authored-By: Claude Opus 4.7 (1M context) --- binary-versions.json | 6 +- scripts/download-binaries.ts | 237 ++++++++++++++++-- .../editor/compiler/compiler-module.ts | 140 +++++++++-- .../compiler/desktop-library-build-port.ts | 105 ++++++-- .../compiler/editor-compiler-platform-port.ts | 97 ++++--- src/backend/editor/utils/transpiler-mode.ts | 19 ++ 6 files changed, 517 insertions(+), 87 deletions(-) create mode 100644 src/backend/editor/utils/transpiler-mode.ts diff --git a/binary-versions.json b/binary-versions.json index 46c0e540d..eebbe5bfa 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -1,6 +1,10 @@ { + "xml2st": { + "version": "v4.0.5", + "repository": "Autonomy-Logic/xml2st" + }, "strucpp": { "version": "v0.5.5", "repository": "Autonomy-Logic/STruCpp" } -} +} \ No newline at end of file diff --git a/scripts/download-binaries.ts b/scripts/download-binaries.ts index f9504d48d..b0318c183 100644 --- a/scripts/download-binaries.ts +++ b/scripts/download-binaries.ts @@ -1,35 +1,115 @@ /** - * Download external tool binaries (strucpp) from GitHub Releases. + * Download external tool binaries (xml2st, strucpp) from GitHub Releases. * * Usage: - * ts-node scripts/download-binaries.ts [--force] + * ts-node scripts/download-binaries.ts [--platform ] [--arch ] [--force] * - * Use --force to re-install even if cached. - * - * The legacy `xml2st` binary download path was removed when the - * editor migrated to the in-process JSON → ST transpiler - * (`backend/shared/transpilers/generate-st-from-json/`). + * Defaults to the current platform/arch. Use --force to re-download even if cached. */ import { execSync } from 'child_process' import fs from 'fs' import path from 'path' +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + interface ToolEntry { version: string repository: string } interface BinaryVersions { + xml2st: ToolEntry strucpp: ToolEntry } +interface CacheMetadata { + xml2st: string + platform: string + arch: string +} + +type Platform = 'darwin' | 'linux' | 'win32' +type Arch = 'x64' | 'arm64' + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + const ROOT_DIR = path.resolve(__dirname, '..') const VERSIONS_FILE = path.join(ROOT_DIR, 'binary-versions.json') const RESOURCES_DIR = path.join(ROOT_DIR, 'resources') -function parseArgs(): { force: boolean } { - return { force: process.argv.slice(2).includes('--force') } +function binDir(platform: Platform, arch: Arch): string { + return path.join(RESOURCES_DIR, 'bin', platform, arch) +} + +function cacheFile(platform: Platform, arch: Arch): string { + return path.join(binDir(platform, arch), '.binary-metadata.json') +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { platform: Platform; arch: Arch; force: boolean } { + const args = process.argv.slice(2) + let platform = process.platform as string + let arch = process.arch as string + let force = false + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--platform' && args[i + 1]) { + platform = args[++i] + } else if (args[i] === '--arch' && args[i + 1]) { + arch = args[++i] + } else if (args[i] === '--force') { + force = true + } + } + + if (!['darwin', 'linux', 'win32'].includes(platform)) { + console.error(`Unsupported platform: ${platform}`) + process.exit(1) + } + if (!['x64', 'arm64'].includes(arch)) { + console.error(`Unsupported architecture: ${arch}`) + process.exit(1) + } + + return { platform: platform as Platform, arch: arch as Arch, force } +} + +// --------------------------------------------------------------------------- +// Cache check +// --------------------------------------------------------------------------- + +function getCachedMetadata(platform: Platform, arch: Arch): CacheMetadata | null { + const file = cacheFile(platform, arch) + if (!fs.existsSync(file)) return null + + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')) as CacheMetadata + } catch { + return null + } +} + +function needsXml2st(versions: BinaryVersions, cached: CacheMetadata | null, platform: Platform, arch: Arch): boolean { + const dir = binDir(platform, arch) + const isWindows = platform === 'win32' + const isDarwin = platform === 'darwin' + + const xml2stPath = isDarwin + ? path.join(dir, 'xml2st', 'xml2st') + : path.join(dir, isWindows ? 'xml2st.exe' : 'xml2st') + + if (!fs.existsSync(xml2stPath)) return true + if (!cached || cached.xml2st !== versions.xml2st.version) return true + + return false } function needsStrucpp(versions: BinaryVersions): boolean { @@ -46,9 +126,24 @@ function needsStrucpp(versions: BinaryVersions): boolean { } catch { return true } + return false } +function writeCache(versions: BinaryVersions, platform: Platform, arch: Arch): void { + const data: CacheMetadata = { + xml2st: versions.xml2st.version, + platform, + arch, + } + fs.mkdirSync(path.dirname(cacheFile(platform, arch)), { recursive: true }) + fs.writeFileSync(cacheFile(platform, arch), JSON.stringify(data, null, 2) + '\n') +} + +// --------------------------------------------------------------------------- +// Download helpers +// --------------------------------------------------------------------------- + async function downloadToFile(url: string, dest: string): Promise { const response = await fetch(url, { redirect: 'follow' }) if (!response.ok) { @@ -58,12 +153,101 @@ async function downloadToFile(url: string, dest: string): Promise { fs.writeFileSync(dest, new Uint8Array(arrayBuffer)) } +function extractTarGz(archive: string, destDir: string): void { + fs.mkdirSync(destDir, { recursive: true }) + execSync(`tar xzf "${archive}" -C "${destDir}"`, { stdio: 'pipe' }) +} + +function extractZip(archive: string, destDir: string): void { + fs.mkdirSync(destDir, { recursive: true }) + execSync(`tar xf "${archive}" -C "${destDir}"`, { stdio: 'pipe' }) +} + function rmrf(p: string): void { if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true, force: true }) } } +function copyRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }) + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isSymbolicLink()) { + const linkTarget = fs.readlinkSync(srcPath) + if (fs.existsSync(destPath)) fs.rmSync(destPath, { force: true }) + fs.symlinkSync(linkTarget, destPath) + } else if (entry.isDirectory()) { + copyRecursive(srcPath, destPath) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} + +// --------------------------------------------------------------------------- +// xml2st download and extraction +// --------------------------------------------------------------------------- + +async function downloadXml2st( + tool: ToolEntry, + platform: Platform, + arch: Arch, + targetBinDir: string, +): Promise { + const isWindows = platform === 'win32' + const isDarwin = platform === 'darwin' + const ext = isWindows ? 'zip' : 'tar.gz' + const url = `https://github.com/${tool.repository}/releases/download/${tool.version}/xml2st-${platform}-${arch}.${ext}` + + console.log(` Downloading xml2st ${tool.version} for ${platform}-${arch}...`) + const tmpDir = fs.mkdtempSync(path.join(RESOURCES_DIR, '.tmp-xml2st-')) + + try { + const archivePath = path.join(tmpDir, `xml2st.${ext}`) + await downloadToFile(url, archivePath) + + const extractDir = path.join(tmpDir, 'extracted') + if (isWindows) { + extractZip(archivePath, extractDir) + } else { + extractTarGz(archivePath, extractDir) + } + + // Archive contains xml2st/ directory + const extractedToolDir = path.join(extractDir, 'xml2st') + + if (isDarwin) { + // macOS: xml2st is a directory with _internal/ — copy as-is + const destDir = path.join(targetBinDir, 'xml2st') + rmrf(destDir) + copyRecursive(extractedToolDir, destDir) + fs.chmodSync(path.join(destDir, 'xml2st'), 0o755) + } else { + // Linux/Windows: single executable + const exeName = isWindows ? 'xml2st.exe' : 'xml2st' + const srcFile = path.join(extractedToolDir, exeName) + const destFile = path.join(targetBinDir, exeName) + rmrf(destFile) + // Also remove any leftover directory from a previous macOS-style install + rmrf(path.join(targetBinDir, 'xml2st')) + fs.copyFileSync(srcFile, destFile) + if (!isWindows) { + fs.chmodSync(destFile, 0o755) + } + } + + console.log(` xml2st ${tool.version} installed.`) + } finally { + rmrf(tmpDir) + } +} + +// --------------------------------------------------------------------------- +// strucpp download and extraction +// --------------------------------------------------------------------------- + async function downloadStrucpp(tool: ToolEntry): Promise { // The npm tarball is platform-independent (pure TypeScript + C++ headers) const version = tool.version.replace(/^v/, '') @@ -100,8 +284,12 @@ async function downloadStrucpp(tool: ToolEntry): Promise { } } +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function main(): Promise { - const { force } = parseArgs() + const { platform, arch, force } = parseArgs() if (!fs.existsSync(VERSIONS_FILE)) { console.error(`binary-versions.json not found at ${VERSIONS_FILE}`) @@ -110,15 +298,34 @@ async function main(): Promise { const versions: BinaryVersions = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8')) - console.log(`[download-binaries] force=${force}`) + console.log(`[download-binaries] platform=${platform} arch=${arch} force=${force}`) - if (!force && !needsStrucpp(versions)) { - console.log(` strucpp ${versions.strucpp.version} already installed, skipping.`) - console.log(`[download-binaries] Done.`) + const targetBinDir = binDir(platform, arch) + fs.mkdirSync(targetBinDir, { recursive: true }) + + const cached = force ? null : getCachedMetadata(platform, arch) + const downloadXml2stNeeded = force || needsXml2st(versions, cached, platform, arch) + const downloadStrucppNeeded = force || needsStrucpp(versions) + + if (!downloadXml2stNeeded && !downloadStrucppNeeded) { + console.log(`[download-binaries] All tools up to date, skipping.`) return } - await downloadStrucpp(versions.strucpp) + if (downloadXml2stNeeded) { + await downloadXml2st(versions.xml2st, platform, arch, targetBinDir) + } else { + console.log(` xml2st ${versions.xml2st.version} already installed, skipping.`) + } + + // strucpp is platform-independent — only download once regardless of platform/arch + if (downloadStrucppNeeded) { + await downloadStrucpp(versions.strucpp) + } else { + console.log(` strucpp ${versions.strucpp.version} already installed, skipping.`) + } + + writeCache(versions, platform, arch) console.log(`[download-binaries] Done.`) } diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 8793d3045..3c55649b5 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -84,6 +84,7 @@ const POST_BUILD_START_POLL_INTERVAL_MS = 150 import { assertPathContained } from '@root/backend/editor/utils/path-containment' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { runCompilePipeline } from '@root/backend/shared/compile/pipeline' import { mergeStrucppRuntimeIntoSkeleton } from '@root/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton' import { readHalsFile } from '@root/backend/shared/firmware/hals-loader' @@ -164,6 +165,8 @@ class CompilerModule { arduinoCliConfigurationFilePath: string arduinoCliBaseParameters: string[] + xml2stBinaryPath: string + strucppRuntimeDir: string // Memoised arduino-cli `--show-properties=expanded` output keyed by FQBN. @@ -218,6 +221,8 @@ class CompilerModule { // INFO: We use this approach because some commands can receive additional parameters as a string array. this.arduinoCliBaseParameters = ['--config-file', this.arduinoCliConfigurationFilePath] + this.xml2stBinaryPath = this.#constructXml2stBinaryPath() + this.strucppRuntimeDir = this.#constructStrucppRuntimeDir() } @@ -344,6 +349,10 @@ class CompilerModule { return join(this.binaryDirectoryPath, 'arduino-cli') } + #constructXml2stBinaryPath(): string { + return join(this.binaryDirectoryPath, 'xml2st', CompilerModule.HOST_PLATFORM === 'darwin' ? 'xml2st' : '') + } + #constructStrucppRuntimeDir(): string { // strucpp's runtime headers (`src/runtime/include/`) live in two // places depending on whether we're running dev or a packaged app: @@ -422,6 +431,14 @@ class CompilerModule { return spawn(arduinoCliBinaryPath, args) } + #executeXml2st(args: string[]) { + let xml2stBinaryPath = this.xml2stBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') { + xml2stBinaryPath += '.exe' + } + return spawn(xml2stBinaryPath, args) + } + // ############################################################################ // =========================== Public methods ================================= // ############################################################################ @@ -841,6 +858,46 @@ class CompilerModule { }) } + async handleTranspileXMLtoST( + generatedXMLFilePath: string, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + extraXml2stArgs: readonly string[], + ) { + return new Promise>((resolve, reject) => { + // `extraXml2stArgs` comes from the shared pipeline's + // `TranspileXmlToStArgs.xml2stArgs` — the single source of truth + // for xml2st flag semantics across editor and web. Editor passes + // them through verbatim (trusted local binary); web's adapter + // filters against its known-args allowlist before sending to the + // compile-service. Strucpp targets currently pass + // `['--keep-structs']` (native STRUCT declarations vs matiec's + // legacy struct→FB rewrite); future flags appear here as the + // pipeline opts into them. + const executeCommand = this.#executeXml2st(['--generate-st', generatedXMLFilePath, ...extraXml2stArgs]) + + let stderrData = '' + + // INFO: We use the xml2st command to transpile the XML file to ST. + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`ST file generated at: ${generatedXMLFilePath.replace('plc.xml', 'program.st')}`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`xml2st process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + async handleCompileSTtoCpp( sourceTargetFolderPath: string, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error', compileError?: StrucppCompileError) => void, @@ -2545,6 +2602,7 @@ class CompilerModule { // --- Build the editor's CompilerPlatformPort implementation --- const platformPort = createEditorCompilerPlatformPort( { + handleTranspileXMLtoST: this.handleTranspileXMLtoST.bind(this), handleCompileArduinoProgram: this.handleCompileArduinoProgram.bind(this), handleUploadProgram: this.handleUploadProgram.bind(this), handleCoreInstallation: this.handleCoreInstallation.bind(this), @@ -2742,37 +2800,70 @@ class CompilerModule { return } - // JSON → ST in-process via `st-transpiler` — replaces the - // legacy XmlGenerator + xml2st-subprocess hop the program-compile - // pipeline already retired. Mirrors what - // `editor-compiler-platform-port.transpileToSt` does for the - // shared pipeline path, scoped down to the debug compile here. - try { - const ir = fromSchemaShape(projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'Failed to generate Structured Text' + if (isNewTranspilerEnabled()) { + // JSON → ST in-process via `st-transpiler`. Mirrors what + // `editor-compiler-platform-port.transpileToSt` does for the + // shared pipeline path, scoped down to the debug compile here. + try { + const ir = fromSchemaShape(projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + const message = result.errors.join('\n') || 'Failed to generate Structured Text' + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${message}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + for (const warning of result.warnings) { + _mainProcessPort.postMessage({ logLevel: 'info', message: warning }) + } + await mkdir(sourceTargetFolderPath, { recursive: true }) + const programStPath = join(sourceTargetFolderPath, 'program.st') + await writeFile(programStPath, result.programSt, 'utf-8') + _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error transpiling JSON to ST: ${getErrorMessage(error)}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + } else { + try { + const generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, + }) + } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', - message: `${message}\nStopping debug compilation process.`, + message: `Error generating XML from JSON: ${error as string}\nStopping debug compilation process.`, }) _mainProcessPort.close() return } - for (const warning of result.warnings) { - _mainProcessPort.postMessage({ logLevel: 'info', message: warning }) + + const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') + try { + await this.handleTranspileXMLtoST( + generatedXMLFilePath, + (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + ['--keep-structs'], + ) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error transpiling XML to ST: ${error as string}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return } - await mkdir(sourceTargetFolderPath, { recursive: true }) - const programStPath = join(sourceTargetFolderPath, 'program.st') - await writeFile(programStPath, result.programSt, 'utf-8') - _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error transpiling JSON to ST: ${getErrorMessage(error)}\nStopping debug compilation process.`, - }) - _mainProcessPort.close() - return } try { @@ -2925,6 +3016,7 @@ class CompilerModule { // glue the library build needs — every stage decision lives in // the shared orchestrator from here on. const libraryPort = createDesktopLibraryBuildPort({ + transpileXmlToSt: (xmlPath, log, extraArgs) => this.handleTranspileXMLtoST(xmlPath, log, extraArgs), loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), runVerificationCompile: ({ projectPath: p, verifyProjectData: v, emit }) => this.runVerificationCompile(p, v as PLCProjectData, mainProcessBridge, (message, logLevel) => diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index 442ae0e59..6e074e9d7 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -7,8 +7,10 @@ * `runLibraryBuildPipeline` cannot perform itself: * * - MD5 hashing (Node `crypto`) - * - in-process JSON → ST transpilation via - * `backend/shared/transpilers/st-transpiler/` + * - ST transpilation through either backend, selected via + * `isNewTranspilerEnabled()` — the in-process JSON-fed + * transpiler when on, the bundled `xml2st` subprocess (default) + * when off * - read / write / delete project files on the local disk * - resolve library-name → `.stlib` archive via the main-process bridge * - drive a verification compile through the editor's existing @@ -21,16 +23,19 @@ * shared with the web port impl. */ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import * as fs from 'node:fs/promises' +import * as os from 'node:os' import * as path from 'node:path' import { assertPathContained } from '@root/backend/editor/utils/path-containment' +import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { fromSchemaShape, type SchemaProjectData, transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' +import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' import type { TranspileToStArgs, TranspileToStResult } from '@root/middleware/shared/ports/compiler-platform-port' import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-build-port' @@ -40,6 +45,22 @@ import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-bui * surface stays narrow + unit-testable. */ export interface DesktopLibraryBuildPortDeps { + /** + * Spawn the bundled `xml2st` binary on the given input path. The + * binary writes `program.st` next to its input; the port reads it + * back from disk after the spawn resolves. Matches the existing + * `CompilerModule.handleTranspileXMLtoST` signature so the adapter + * can pass it through verbatim without a wrapper. + * + * Only invoked by the legacy transpile path (selected when + * `isNewTranspilerEnabled()` returns false). + */ + transpileXmlToSt( + xmlPath: string, + log: (chunk: Buffer | string, level?: 'info' | 'error') => void, + extraArgs: readonly string[], + ): Promise + /** * Resolve the names of project-enabled libraries to their parsed * `.stlib` archives. Bundled IEC standard set is included @@ -76,27 +97,73 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) args: TranspileToStArgs, log: (message: string, level: 'info' | 'warning' | 'error') => void, ): Promise { - try { - // Editor library builds receive the same schema-shape - // project data as `compileProgram` (see `transpileToSt` on - // `editor-compiler-platform-port` for the IPC-shape note). - // The double cast bridges the port's declared port-shape type - // and the actual schema-shape payload at the boundary. - const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'transpile-from-json failed' - log(message, 'error') + if (isNewTranspilerEnabled()) { + try { + // Editor library builds receive the same schema-shape + // project data as `compileProgram` (see `transpileToSt` on + // `editor-compiler-platform-port` for the IPC-shape note). + // The double cast bridges the port's declared port-shape type + // and the actual schema-shape payload at the boundary. + const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + const message = result.errors.join('\n') || 'transpile-from-json failed' + log(message, 'error') + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + for (const warning of result.warnings) { + log(warning, 'info') + } + return { ok: true, programSt: result.programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`transpile-from-json failed: ${message}`, 'error') return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } } - for (const warning of result.warnings) { - log(warning, 'info') - } - return { ok: true, programSt: result.programSt } + } + + // Legacy path: serialise the project to PLCOpen XML and spawn + // the bundled `xml2st` binary on a temp file. Lifts the + // pre-Phase-2 implementation byte-for-byte; the only delta is + // the leading `XmlGenerator` call because the shared pipeline + // no longer hands the port a pre-built XML payload. + const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') + if (!xmlResult.ok || !xmlResult.data) { + log(`XML generation failed: ${xmlResult.message}`, 'error') + return { ok: false, errors: [{ message: xmlResult.message, line: 0, column: 0, severity: 'error' }] } + } + // xml2st takes a file path on stdin, so materialise the + // in-memory XML to a unique temp file before spawning. + // Lives in `os.tmpdir()` because the user-visible `plc.xml` + // is written separately by the orchestrator via + // writeBuildFile — the intermediate here exists only for the + // subprocess. + const sessionDir = path.join(os.tmpdir(), `openplc-lib-xml2st-${randomUUID()}`) + try { + await fs.mkdir(sessionDir, { recursive: true }) + const xmlPath = path.join(sessionDir, 'plc.xml') + const programStPath = path.join(sessionDir, 'program.st') + await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') + + await deps.transpileXmlToSt( + xmlPath, + (chunk, level) => log(typeof chunk === 'string' ? chunk : chunk.toString(), level ?? 'info'), + ['--keep-structs'], + ) + + const programSt = await fs.readFile(programStPath, 'utf-8') + return { ok: true, programSt } } catch (error) { const message = error instanceof Error ? error.message : String(error) - log(`transpile-from-json failed: ${message}`, 'error') + log(`xml2st failed: ${message}`, 'error') return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } finally { + // Best-effort cleanup. Leaks aren't fatal (os.tmpdir is + // the OS's responsibility) but tidying after ourselves + // keeps the dev disk clean. + await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => { + /* swallow — the temp dir is the OS's to GC */ + }) } }, diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index b95cde697..fe2b39aeb 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -16,18 +16,21 @@ * - Translates the handler's return value back into the port's * canonical result shape * - * `transpileToSt` no longer needs disk materialisation or a - * subprocess: it projects the project IR via `fromSchemaShape` - * (editor IPC delivers schema-shape data) and runs the in-process - * JSON-fed transpiler - * (`backend/shared/transpilers/st-transpiler/`). The old - * `xml2st` binary path has been retired. + * `transpileToSt` selects between two backends at runtime via + * `isNewTranspilerEnabled()` (env: `OPENPLC_USE_NEW_TRANSPILER`): + * the in-process JSON-fed transpiler + * (`backend/shared/transpilers/st-transpiler/`) when the flag is on, + * or the bundled `xml2st` subprocess (default) — serialising the + * project IR via `XmlGenerator` and running it through + * `handleTranspileXMLtoST` on disk, the same way the editor handled + * compilation before Phase 2. * * This module is editor-only (lives under `backend/editor/`); the * web platform implements the same port interface separately under * `middleware/adapters/web/`. */ +import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' import { @@ -35,6 +38,7 @@ import { type SchemaProjectData, transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' +import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' import type { CheckRuntimeVersionArgs, CheckRuntimeVersionResult, @@ -67,6 +71,7 @@ import type { CompilerModule } from './compiler-module' * file-watching, etc.). */ export interface EditorCompilerHandlers { + handleTranspileXMLtoST: CompilerModule['handleTranspileXMLtoST'] handleCompileArduinoProgram: CompilerModule['handleCompileArduinoProgram'] handleUploadProgram: CompilerModule['handleUploadProgram'] handleCoreInstallation: CompilerModule['handleCoreInstallation'] @@ -158,34 +163,70 @@ export function createEditorCompilerPlatformPort( }, /** - * Project the editor's port-shape data straight to Structured - * Text via the in-process JSON-fed transpiler. No subprocess, - * no disk round-trip — Electron's main process drives the - * walker directly. + * Transpile the project IR to Structured Text. The toggle — + * `OPENPLC_USE_NEW_TRANSPILER` via `isNewTranspilerEnabled()` — selects + * between the in-process JSON-fed transpiler (new path, opt-in) + * and the bundled `xml2st` subprocess (legacy path, default). + * + * The legacy branch reproduces the pre-Phase-2 behaviour: + * serialise the project to IEC 61131-3 XML via the shared + * `XmlGenerator`, materialise it to `/plc.xml`, + * run `handleTranspileXMLtoST` (which spawns the bundled `xml2st` + * binary), then read `program.st` back from disk. */ async transpileToSt(args: TranspileToStArgs, log: PlatformLog): Promise { - try { - // Editor IPC delivers the schema-shape project data - // (discriminated-union POUs + singular `configuration`). - // The port's declared `projectData` type is port-shape, but - // the pipeline reaches us with the editor's schema-shape IPC - // payload (matching `compileProgram`'s actual contract). The - // double cast bridges the static mismatch without serialising - // through `unknown` at runtime. - const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'Failed to generate Structured Text' - log(message, 'error') + if (isNewTranspilerEnabled()) { + try { + // Editor IPC delivers the schema-shape project data + // (discriminated-union POUs + singular `configuration`). + // The port's declared `projectData` type is port-shape, but + // the pipeline reaches us with the editor's schema-shape IPC + // payload (matching `compileProgram`'s actual contract). The + // double cast bridges the static mismatch without serialising + // through `unknown` at runtime. + const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + const message = result.errors.join('\n') || 'Failed to generate Structured Text' + log(message, 'error') + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + for (const warning of result.warnings) { + log(warning, 'info') + } + return { ok: true, programSt: result.programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`st-transpiler failed: ${message}`, 'error') return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } } - for (const warning of result.warnings) { - log(warning, 'info') - } - return { ok: true, programSt: result.programSt } + } + + const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') + if (!xmlResult.ok || !xmlResult.data) { + log(`XML generation failed: ${xmlResult.message}`, 'error') + return { ok: false, errors: [{ message: xmlResult.message, line: 0, column: 0, severity: 'error' }] } + } + const xmlPath = join(context.sourceTargetFolderPath, 'plc.xml') + try { + await fs.mkdir(dirname(xmlPath), { recursive: true }) + await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') + + await handlers.handleTranspileXMLtoST( + xmlPath, + (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + ['--keep-structs'], + ) + + const programStPath = join(context.sourceTargetFolderPath, 'program.st') + const programSt = await fs.readFile(programStPath, 'utf-8') + return { ok: true, programSt } } catch (error) { const message = error instanceof Error ? error.message : String(error) - log(`st-transpiler failed: ${message}`, 'error') + log(`xml2st failed: ${message}`, 'error') return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } } }, diff --git a/src/backend/editor/utils/transpiler-mode.ts b/src/backend/editor/utils/transpiler-mode.ts new file mode 100644 index 000000000..172425990 --- /dev/null +++ b/src/backend/editor/utils/transpiler-mode.ts @@ -0,0 +1,19 @@ +/** + * Build-time toggle that selects between the new in-process JSON → + * Structured Text transpiler and the legacy bundled `xml2st` + * subprocess. Default is `false` (legacy xml2st) — flip with + * `OPENPLC_USE_NEW_TRANSPILER=1` to opt into the JSON-fed transpiler. + * + * Lives in `backend/editor/utils/` so every editor-side transpilation + * call site (`editor-compiler-platform-port.transpileToSt`, + * `desktop-library-build-port.transpileToSt`, + * `CompilerModule.compileForDebugger`) reads the same flag. + * + * Named without the `use` prefix on purpose: `react-hooks/rules-of-hooks` + * treats any `use*` call inside a non-component / non-hook function as + * a violation. + */ +export function isNewTranspilerEnabled(): boolean { + const v = process.env.OPENPLC_USE_NEW_TRANSPILER + return v === '1' || v === 'true' +}